aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-01-01 22:06:40 +0900
committernsfisis <nsfisis@gmail.com>2026-01-01 22:06:40 +0900
commit8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b (patch)
treebe39e1436f83c716fc45df133106fba7dd4bc23a
parent830b370f1b8e0f3a384b2d242ab120812e81977d (diff)
downloadkioku-8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b.tar.gz
kioku-8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b.tar.zst
kioku-8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b.zip
feat(import): add CSV bulk import for notes
Add client-side CSV parsing and bulk import API endpoint for importing notes from CSV files. Supports quoted fields, newlines in values, and escaped quotes. - New POST /api/decks/{deckId}/notes/import endpoint for bulk creation - CSV parser with RFC 4180 compliance - Multi-phase import modal (upload → validate → preview → import) - Client-side validation with per-row error reporting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--README.md1
-rw-r--r--docs/manual/features.md1
-rw-r--r--src/client/components/ImportNotesModal.tsx535
-rw-r--r--src/client/pages/DeckDetailPage.tsx50
-rw-r--r--src/client/utils/csvParser.test.ts159
-rw-r--r--src/client/utils/csvParser.ts154
-rw-r--r--src/server/repositories/note.test.ts1
-rw-r--r--src/server/repositories/note.ts125
-rw-r--r--src/server/repositories/types.ts19
-rw-r--r--src/server/routes/notes.test.ts1
-rw-r--r--src/server/routes/notes.ts32
-rw-r--r--src/server/schemas/index.ts11
12 files changed, 1076 insertions, 13 deletions
diff --git a/README.md b/README.md
index 3623e0a..86e6cf6 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ A spaced repetition learning application (Anki clone).
- Spaced Repetition: FSRS algorithm for optimal learning scheduling
- Offline Support: Full PWA with IndexedDB local storage
- Cloud Sync: Automatic sync when online with conflict resolution
+- CSV Import: Bulk import notes from CSV files
## Development Setup
diff --git a/docs/manual/features.md b/docs/manual/features.md
index cc5db35..5821cd4 100644
--- a/docs/manual/features.md
+++ b/docs/manual/features.md
@@ -22,6 +22,7 @@ A list of features available in Kioku.
- Edit note content and all generated cards update automatically
- Browse cards grouped by note in deck view
- Independent scheduling: each card maintains its own FSRS state
+- CSV import: Bulk import notes from CSV files
## Study Session
diff --git a/src/client/components/ImportNotesModal.tsx b/src/client/components/ImportNotesModal.tsx
new file mode 100644
index 0000000..b49b276
--- /dev/null
+++ b/src/client/components/ImportNotesModal.tsx
@@ -0,0 +1,535 @@
+import {
+ faCheck,
+ faExclamationTriangle,
+ faFileImport,
+ faSpinner,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { type ChangeEvent, useCallback, useEffect, useState } from "react";
+import { ApiClientError, apiClient } from "../api";
+import { parseCSV } from "../utils/csvParser";
+
+interface NoteField {
+ id: string;
+ name: string;
+ order: number;
+}
+
+interface NoteType {
+ id: string;
+ name: string;
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+ fields: NoteField[];
+}
+
+interface ImportNotesModalProps {
+ isOpen: boolean;
+ deckId: string;
+ onClose: () => void;
+ onImportComplete: () => void;
+}
+
+type ImportPhase =
+ | "upload"
+ | "validating"
+ | "preview"
+ | "importing"
+ | "complete";
+
+interface ValidationError {
+ rowNumber: number;
+ message: string;
+}
+
+interface ValidatedRow {
+ rowNumber: number;
+ noteTypeId: string;
+ noteTypeName: string;
+ fields: Record<string, string>;
+ preview: Record<string, string>;
+}
+
+interface ImportResult {
+ created: number;
+ failed: { index: number; error: string }[];
+}
+
+export function ImportNotesModal({
+ isOpen,
+ deckId,
+ onClose,
+ onImportComplete,
+}: ImportNotesModalProps) {
+ const [phase, setPhase] = useState<ImportPhase>("upload");
+ const [error, setError] = useState<string | null>(null);
+ const [noteTypes, setNoteTypes] = useState<NoteType[]>([]);
+ const [validatedRows, setValidatedRows] = useState<ValidatedRow[]>([]);
+ const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
+ [],
+ );
+ const [importResult, setImportResult] = useState<ImportResult | null>(null);
+
+ const fetchNoteTypes = useCallback(async () => {
+ try {
+ const authHeader = apiClient.getAuthHeader();
+ if (!authHeader) {
+ throw new ApiClientError("Not authenticated", 401);
+ }
+
+ const res = await fetch("/api/note-types", {
+ headers: authHeader,
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.json().catch(() => ({}));
+ throw new ApiClientError(
+ (errorBody as { error?: string }).error ||
+ `Request failed with status ${res.status}`,
+ res.status,
+ );
+ }
+
+ const data = await res.json();
+
+ // Fetch details for each note type to get fields
+ const noteTypesWithFields: NoteType[] = [];
+ for (const nt of data.noteTypes) {
+ const detailRes = await fetch(`/api/note-types/${nt.id}`, {
+ headers: authHeader,
+ });
+ if (detailRes.ok) {
+ const detailData = await detailRes.json();
+ noteTypesWithFields.push(detailData.noteType);
+ }
+ }
+
+ setNoteTypes(noteTypesWithFields);
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to load note types. Please try again.");
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ if (isOpen && noteTypes.length === 0) {
+ fetchNoteTypes();
+ }
+ }, [isOpen, noteTypes.length, fetchNoteTypes]);
+
+ const resetState = () => {
+ setPhase("upload");
+ setError(null);
+ setValidatedRows([]);
+ setValidationErrors([]);
+ setImportResult(null);
+ };
+
+ const handleClose = () => {
+ resetState();
+ onClose();
+ };
+
+ const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setPhase("validating");
+ setError(null);
+ setValidationErrors([]);
+ setValidatedRows([]);
+
+ try {
+ const content = await file.text();
+ const parseResult = parseCSV(content);
+
+ if (!parseResult.success) {
+ setError(parseResult.error.message);
+ setPhase("upload");
+ return;
+ }
+
+ const { headers, rows } = parseResult.data;
+
+ // Validate headers: must have deck, note_type, and at least one field
+ if (headers.length < 3) {
+ setError(
+ "CSV must have at least 3 columns: deck, note_type, and field(s)",
+ );
+ setPhase("upload");
+ return;
+ }
+
+ if (headers[0] !== "deck" || headers[1] !== "note_type") {
+ setError("First two columns must be 'deck' and 'note_type'");
+ setPhase("upload");
+ return;
+ }
+
+ const fieldNames = headers.slice(2);
+ const validated: ValidatedRow[] = [];
+ const errors: ValidationError[] = [];
+
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ if (!row) continue;
+
+ const rowNumber = i + 2; // +2 because 1-indexed and header is row 1
+
+ const noteTypeName = row.note_type ?? "";
+ const noteType = noteTypes.find(
+ (nt) => nt.name.toLowerCase() === noteTypeName.toLowerCase(),
+ );
+
+ if (!noteType) {
+ errors.push({
+ rowNumber,
+ message: `Note type "${noteTypeName}" not found`,
+ });
+ continue;
+ }
+
+ // Map field names to field type IDs
+ const fields: Record<string, string> = {};
+ const preview: Record<string, string> = {};
+ let fieldError = false;
+
+ for (const fieldName of fieldNames) {
+ const fieldType = noteType.fields.find(
+ (f) => f.name.toLowerCase() === fieldName.toLowerCase(),
+ );
+
+ if (!fieldType) {
+ errors.push({
+ rowNumber,
+ message: `Field "${fieldName}" not found in note type "${noteTypeName}"`,
+ });
+ fieldError = true;
+ break;
+ }
+
+ const value = row[fieldName] ?? "";
+ fields[fieldType.id] = value;
+ preview[fieldName] = value;
+ }
+
+ if (fieldError) continue;
+
+ validated.push({
+ rowNumber,
+ noteTypeId: noteType.id,
+ noteTypeName: noteType.name,
+ fields,
+ preview,
+ });
+ }
+
+ setValidatedRows(validated);
+ setValidationErrors(errors);
+ setPhase("preview");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to parse CSV");
+ setPhase("upload");
+ }
+
+ // Reset file input
+ e.target.value = "";
+ };
+
+ const handleImport = async () => {
+ if (validatedRows.length === 0) return;
+
+ setPhase("importing");
+ setError(null);
+
+ try {
+ const authHeader = apiClient.getAuthHeader();
+ if (!authHeader) {
+ throw new ApiClientError("Not authenticated", 401);
+ }
+
+ const res = await fetch(`/api/decks/${deckId}/notes/import`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...authHeader,
+ },
+ body: JSON.stringify({
+ notes: validatedRows.map((row) => ({
+ noteTypeId: row.noteTypeId,
+ fields: row.fields,
+ })),
+ }),
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.json().catch(() => ({}));
+ throw new ApiClientError(
+ (errorBody as { error?: string }).error ||
+ `Request failed with status ${res.status}`,
+ res.status,
+ );
+ }
+
+ const result = await res.json();
+ setImportResult(result);
+ setPhase("complete");
+ onImportComplete();
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to import notes. Please try again.");
+ }
+ setPhase("preview");
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <div
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="import-notes-title"
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
+ onClick={(e) => {
+ if (e.target === e.currentTarget && phase !== "importing") {
+ handleClose();
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape" && phase !== "importing") {
+ handleClose();
+ }
+ }}
+ >
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg animate-scale-in max-h-[90vh] flex flex-col">
+ <div className="p-6 flex-shrink-0">
+ <h2
+ id="import-notes-title"
+ className="font-display text-xl font-medium text-ink mb-4"
+ >
+ Import Notes from CSV
+ </h2>
+
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20 mb-4"
+ >
+ {error}
+ </div>
+ )}
+
+ {/* Phase: Upload */}
+ {phase === "upload" && (
+ <div className="space-y-4">
+ <div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
+ <FontAwesomeIcon
+ icon={faFileImport}
+ className="w-10 h-10 text-muted mb-4"
+ />
+ <p className="text-slate mb-4">Select a CSV file to import</p>
+ <label className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg cursor-pointer transition-all duration-200">
+ <span>Choose File</span>
+ <input
+ type="file"
+ accept=".csv,text/csv"
+ onChange={handleFileChange}
+ className="hidden"
+ />
+ </label>
+ </div>
+ <div className="bg-ivory rounded-lg px-4 py-3 text-sm text-muted">
+ <p className="font-medium text-slate mb-1">Expected format:</p>
+ <code className="text-xs">
+ deck,note_type,Front,Back
+ <br />
+ MyDeck,Basic,hello,world
+ </code>
+ </div>
+ </div>
+ )}
+
+ {/* Phase: Validating */}
+ {phase === "validating" && (
+ <div className="flex items-center justify-center py-8">
+ <FontAwesomeIcon
+ icon={faSpinner}
+ className="w-8 h-8 text-primary animate-spin"
+ />
+ <span className="ml-3 text-muted">Validating CSV...</span>
+ </div>
+ )}
+ </div>
+
+ {/* Phase: Preview - scrollable content */}
+ {phase === "preview" && (
+ <div className="flex-1 overflow-y-auto px-6">
+ {validationErrors.length > 0 && (
+ <div className="bg-warning/5 border border-warning/20 rounded-lg p-4 mb-4">
+ <div className="flex items-center gap-2 text-warning font-medium mb-2">
+ <FontAwesomeIcon icon={faExclamationTriangle} />
+ {validationErrors.length} error(s) found
+ </div>
+ <ul className="text-sm text-slate space-y-1 max-h-32 overflow-y-auto">
+ {validationErrors.map((err) => (
+ <li key={err.rowNumber}>
+ Row {err.rowNumber}: {err.message}
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+
+ {validatedRows.length > 0 && (
+ <div className="space-y-4">
+ <p className="text-slate">
+ <span className="font-medium">{validatedRows.length}</span>{" "}
+ note(s) ready to import
+ </p>
+
+ {/* Preview table */}
+ <div className="border border-border rounded-lg overflow-hidden">
+ <div className="max-h-48 overflow-y-auto">
+ <table className="w-full text-sm">
+ <thead className="bg-ivory sticky top-0">
+ <tr>
+ <th className="px-3 py-2 text-left font-medium text-slate">
+ #
+ </th>
+ <th className="px-3 py-2 text-left font-medium text-slate">
+ Type
+ </th>
+ <th className="px-3 py-2 text-left font-medium text-slate">
+ Preview
+ </th>
+ </tr>
+ </thead>
+ <tbody className="divide-y divide-border">
+ {validatedRows.slice(0, 10).map((row) => (
+ <tr key={row.rowNumber}>
+ <td className="px-3 py-2 text-muted">
+ {row.rowNumber}
+ </td>
+ <td className="px-3 py-2 text-slate">
+ {row.noteTypeName}
+ </td>
+ <td className="px-3 py-2 text-slate truncate max-w-[200px]">
+ {Object.values(row.preview).join(" | ")}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ {validatedRows.length > 10 && (
+ <div className="bg-ivory px-3 py-2 text-xs text-muted text-center">
+ ...and {validatedRows.length - 10} more
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* Phase: Importing */}
+ {phase === "importing" && (
+ <div className="px-6 flex items-center justify-center py-8">
+ <FontAwesomeIcon
+ icon={faSpinner}
+ className="w-8 h-8 text-primary animate-spin"
+ />
+ <span className="ml-3 text-muted">Importing notes...</span>
+ </div>
+ )}
+
+ {/* Phase: Complete */}
+ {phase === "complete" && importResult && (
+ <div className="px-6">
+ <div className="text-center py-4">
+ <div className="w-14 h-14 mx-auto mb-4 bg-success/10 rounded-full flex items-center justify-center">
+ <FontAwesomeIcon
+ icon={faCheck}
+ className="w-7 h-7 text-success"
+ />
+ </div>
+ <p className="text-lg font-medium text-slate mb-2">
+ Import complete!
+ </p>
+ <p className="text-muted">
+ {importResult.created} note(s) imported successfully
+ </p>
+ {importResult.failed.length > 0 && (
+ <div className="mt-4 bg-error/5 border border-error/20 rounded-lg p-4 text-left">
+ <p className="text-error font-medium mb-2">
+ {importResult.failed.length} failed:
+ </p>
+ <ul className="text-sm text-slate space-y-1 max-h-24 overflow-y-auto">
+ {importResult.failed.map((f) => (
+ <li key={f.index}>
+ Row {validatedRows[f.index]?.rowNumber ?? f.index + 1}:{" "}
+ {f.error}
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Footer buttons */}
+ <div className="p-6 flex-shrink-0 border-t border-border mt-auto">
+ <div className="flex gap-3 justify-end">
+ {phase === "upload" && (
+ <button
+ type="button"
+ onClick={handleClose}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors"
+ >
+ Cancel
+ </button>
+ )}
+
+ {phase === "preview" && (
+ <>
+ <button
+ type="button"
+ onClick={resetState}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors"
+ >
+ Back
+ </button>
+ <button
+ type="button"
+ onClick={handleImport}
+ disabled={validatedRows.length === 0}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Import {validatedRows.length} Note(s)
+ </button>
+ </>
+ )}
+
+ {phase === "complete" && (
+ <button
+ type="button"
+ onClick={handleClose}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200"
+ >
+ Done
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index d018d1f..3741111 100644
--- a/src/client/pages/DeckDetailPage.tsx
+++ b/src/client/pages/DeckDetailPage.tsx
@@ -2,6 +2,7 @@ import {
faChevronLeft,
faCirclePlay,
faFile,
+ faFileImport,
faLayerGroup,
faPen,
faPlus,
@@ -17,6 +18,7 @@ import { DeleteCardModal } from "../components/DeleteCardModal";
import { DeleteNoteModal } from "../components/DeleteNoteModal";
import { EditCardModal } from "../components/EditCardModal";
import { EditNoteModal } from "../components/EditNoteModal";
+import { ImportNotesModal } from "../components/ImportNotesModal";
interface Card {
id: string;
@@ -183,6 +185,7 @@ export function DeckDetailPage() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [editingCard, setEditingCard] = useState<Card | null>(null);
const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
const [deletingCard, setDeletingCard] = useState<Card | null>(null);
@@ -397,18 +400,32 @@ export function DeckDetailPage() {
Cards{" "}
<span className="text-muted font-normal">({cards.length})</span>
</h2>
- <button
- type="button"
- onClick={() => setIsCreateModalOpen(true)}
- className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
- >
- <FontAwesomeIcon
- icon={faPlus}
- className="w-5 h-5"
- aria-hidden="true"
- />
- Add Note
- </button>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() => setIsImportModalOpen(true)}
+ className="inline-flex items-center gap-2 border border-border hover:bg-ivory text-slate font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
+ >
+ <FontAwesomeIcon
+ icon={faFileImport}
+ className="w-5 h-5"
+ aria-hidden="true"
+ />
+ Import CSV
+ </button>
+ <button
+ type="button"
+ onClick={() => setIsCreateModalOpen(true)}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
+ >
+ <FontAwesomeIcon
+ icon={faPlus}
+ className="w-5 h-5"
+ aria-hidden="true"
+ />
+ Add Note
+ </button>
+ </div>
</div>
{/* Empty State */}
@@ -472,6 +489,15 @@ export function DeckDetailPage() {
)}
{deckId && (
+ <ImportNotesModal
+ isOpen={isImportModalOpen}
+ deckId={deckId}
+ onClose={() => setIsImportModalOpen(false)}
+ onImportComplete={fetchCards}
+ />
+ )}
+
+ {deckId && (
<EditCardModal
isOpen={editingCard !== null}
deckId={deckId}
diff --git a/src/client/utils/csvParser.test.ts b/src/client/utils/csvParser.test.ts
new file mode 100644
index 0000000..616e3e8
--- /dev/null
+++ b/src/client/utils/csvParser.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, it } from "vitest";
+import { parseCSV } from "./csvParser";
+
+describe("parseCSV", () => {
+ it("parses simple CSV with headers", () => {
+ const csv = `deck,note_type,Front,Back
+English,Basic,hello,world
+English,Basic,goodbye,farewell`;
+
+ const result = parseCSV(csv);
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.headers).toEqual([
+ "deck",
+ "note_type",
+ "Front",
+ "Back",
+ ]);
+ expect(result.data.rows).toHaveLength(2);
+ expect(result.data.rows[0]).toEqual({
+ deck: "English",
+ note_type: "Basic",
+ Front: "hello",
+ Back: "world",
+ });
+ expect(result.data.rows[1]).toEqual({
+ deck: "English",
+ note_type: "Basic",
+ Front: "goodbye",
+ Back: "farewell",
+ });
+ }
+ });
+
+ it("handles quoted fields with commas", () => {
+ const csv = `deck,note_type,Front,Back
+English,Basic,"hello, world","foo, bar"`;
+
+ const result = parseCSV(csv);
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.rows[0]).toEqual({
+ deck: "English",
+ note_type: "Basic",
+ Front: "hello, world",
+ Back: "foo, bar",
+ });
+ }
+ });
+
+ it("handles quoted fields with newlines", () => {
+ const csv = `deck,note_type,Front,Back
+English,Basic,"line1
+line2","back"`;
+
+ const result = parseCSV(csv);
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.rows).toHaveLength(1);
+ expect(result.data.rows[0]).toEqual({
+ deck: "English",
+ note_type: "Basic",
+ Front: "line1\nline2",
+ Back: "back",
+ });
+ }
+ });
+
+ it("handles escaped quotes", () => {
+ const csv = `deck,note_type,Front,Back
+English,Basic,"say ""hello""",world`;
+
+ const result = parseCSV(csv);
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.rows[0]).toEqual({
+ deck: "English",
+ note_type: "Basic",
+ Front: 'say "hello"',
+ Back: "world",
+ });
+ }
+ });
+
+ it("skips empty lines", () => {
+ const csv = `deck,note_type,Front,Back
+English,Basic,hello,world
+
+English,Basic,goodbye,farewell
+`;
+
+ const result = parseCSV(csv);
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.rows).toHaveLength(2);
+ }
+ });
+
+ it("handles CRLF line endings", () => {
+ const csv = "deck,note_type,Front,Back\r\nEnglish,Basic,hello,world\r\n";
+
+ const result = parseCSV(csv);
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.rows).toHaveLength(1);
+ expect(result.data.rows[0]).toEqual({
+ deck: "English",
+ note_type: "Basic",
+ Front: "hello",
+ Back: "world",
+ });
+ }
+ });
+
+ it("returns error for empty file", () => {
+ const result = parseCSV("");
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.message).toBe("CSV file is empty");
+ }
+ });
+
+ it("returns error for inconsistent column count", () => {
+ const csv = `deck,note_type,Front,Back
+English,Basic,hello`;
+
+ const result = parseCSV(csv);
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.message).toBe("Row 2: Expected 4 columns, got 3");
+ expect(result.error.line).toBe(2);
+ }
+ });
+
+ it("trims whitespace from values", () => {
+ const csv = `deck,note_type,Front,Back
+English , Basic , hello , world `;
+
+ const result = parseCSV(csv);
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.rows[0]).toEqual({
+ deck: "English",
+ note_type: "Basic",
+ Front: "hello",
+ Back: "world",
+ });
+ }
+ });
+});
diff --git a/src/client/utils/csvParser.ts b/src/client/utils/csvParser.ts
new file mode 100644
index 0000000..c58af00
--- /dev/null
+++ b/src/client/utils/csvParser.ts
@@ -0,0 +1,154 @@
+/**
+ * CSV Parser utility for importing notes
+ * Handles RFC 4180 compliant CSV parsing with:
+ * - Quoted fields containing commas or newlines
+ * - Escaped quotes ("")
+ * - Different line endings (CRLF, LF)
+ */
+
+export interface CSVParseResult {
+ headers: string[];
+ rows: Record<string, string>[];
+}
+
+export interface CSVParseError {
+ message: string;
+ line?: number;
+}
+
+export type CSVParseOutcome =
+ | { success: true; data: CSVParseResult }
+ | { success: false; error: CSVParseError };
+
+/**
+ * Parse a CSV string into headers and rows
+ */
+export function parseCSV(content: string): CSVParseOutcome {
+ const lines = splitCSVLines(content);
+
+ if (lines.length === 0) {
+ return { success: false, error: { message: "CSV file is empty" } };
+ }
+
+ const headerLine = lines[0];
+ if (!headerLine) {
+ return { success: false, error: { message: "CSV file is empty" } };
+ }
+
+ const headers = parseCSVLine(headerLine);
+ if (headers.length === 0) {
+ return { success: false, error: { message: "CSV header is empty" } };
+ }
+
+ const rows: Record<string, string>[] = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i];
+ if (!line) continue;
+
+ // Skip empty lines
+ if (line.trim() === "") {
+ continue;
+ }
+
+ const values = parseCSVLine(line);
+
+ if (values.length !== headers.length) {
+ return {
+ success: false,
+ error: {
+ message: `Row ${i + 1}: Expected ${headers.length} columns, got ${values.length}`,
+ line: i + 1,
+ },
+ };
+ }
+
+ const row: Record<string, string> = {};
+ for (let j = 0; j < headers.length; j++) {
+ const header = headers[j];
+ const value = values[j];
+ if (header !== undefined && value !== undefined) {
+ row[header] = value;
+ }
+ }
+ rows.push(row);
+ }
+
+ return { success: true, data: { headers, rows } };
+}
+
+/**
+ * Split CSV content into logical lines, handling quoted fields with newlines
+ */
+function splitCSVLines(content: string): string[] {
+ const lines: string[] = [];
+ let currentLine = "";
+ let inQuotes = false;
+
+ // Normalize line endings to \n
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+
+ for (let i = 0; i < normalized.length; i++) {
+ const char = normalized[i];
+
+ if (char === '"') {
+ // Check for escaped quote ("")
+ if (inQuotes && normalized[i + 1] === '"') {
+ currentLine += '""';
+ i++; // Skip next quote
+ } else {
+ inQuotes = !inQuotes;
+ currentLine += char;
+ }
+ } else if (char === "\n" && !inQuotes) {
+ lines.push(currentLine);
+ currentLine = "";
+ } else {
+ currentLine += char;
+ }
+ }
+
+ // Don't forget the last line
+ if (currentLine.length > 0) {
+ lines.push(currentLine);
+ }
+
+ return lines;
+}
+
+/**
+ * Parse a single CSV line into an array of values
+ */
+function parseCSVLine(line: string): string[] {
+ const values: string[] = [];
+ let currentValue = "";
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+
+ if (char === '"') {
+ if (!inQuotes) {
+ // Start of quoted field
+ inQuotes = true;
+ } else if (line[i + 1] === '"') {
+ // Escaped quote
+ currentValue += '"';
+ i++; // Skip next quote
+ } else {
+ // End of quoted field
+ inQuotes = false;
+ }
+ } else if (char === "," && !inQuotes) {
+ values.push(currentValue.trim());
+ currentValue = "";
+ } else {
+ currentValue += char;
+ }
+ }
+
+ // Don't forget the last value
+ values.push(currentValue.trim());
+
+ return values;
+}
diff --git a/src/server/repositories/note.test.ts b/src/server/repositories/note.test.ts
index 0fe1c9f..2c4b900 100644
--- a/src/server/repositories/note.test.ts
+++ b/src/server/repositories/note.test.ts
@@ -109,6 +109,7 @@ function createMockNoteRepo(): NoteRepository {
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
+ createMany: vi.fn(),
};
}
diff --git a/src/server/repositories/note.ts b/src/server/repositories/note.ts
index eb1aa28..6f607cf 100644
--- a/src/server/repositories/note.ts
+++ b/src/server/repositories/note.ts
@@ -9,6 +9,8 @@ import {
noteTypes,
} from "../db/schema.js";
import type {
+ BulkCreateNoteInput,
+ BulkCreateNoteResult,
Card,
CreateNoteResult,
Note,
@@ -265,6 +267,129 @@ export const noteRepository: NoteRepository = {
return result.length > 0;
},
+
+ async createMany(
+ deckId: string,
+ notesInput: BulkCreateNoteInput[],
+ ): Promise<BulkCreateNoteResult> {
+ const failed: { index: number; error: string }[] = [];
+ let created = 0;
+
+ // Pre-fetch all note types and their field types for validation
+ const noteTypeCache = new Map<
+ string,
+ {
+ noteType: {
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+ };
+ fieldTypes: { id: string; name: string }[];
+ }
+ >();
+
+ for (let i = 0; i < notesInput.length; i++) {
+ const input = notesInput[i];
+ if (!input) continue;
+
+ try {
+ // Get note type from cache or fetch
+ let cached = noteTypeCache.get(input.noteTypeId);
+ if (!cached) {
+ const noteType = await db
+ .select()
+ .from(noteTypes)
+ .where(
+ and(
+ eq(noteTypes.id, input.noteTypeId),
+ isNull(noteTypes.deletedAt),
+ ),
+ );
+
+ if (!noteType[0]) {
+ failed.push({ index: i, error: "Note type not found" });
+ continue;
+ }
+
+ const fieldTypes = await db
+ .select()
+ .from(noteFieldTypes)
+ .where(
+ and(
+ eq(noteFieldTypes.noteTypeId, input.noteTypeId),
+ isNull(noteFieldTypes.deletedAt),
+ ),
+ )
+ .orderBy(noteFieldTypes.order);
+
+ cached = { noteType: noteType[0], fieldTypes };
+ noteTypeCache.set(input.noteTypeId, cached);
+ }
+
+ // Create note
+ const [note] = await db
+ .insert(notes)
+ .values({
+ deckId,
+ noteTypeId: input.noteTypeId,
+ })
+ .returning();
+
+ if (!note) {
+ failed.push({ index: i, error: "Failed to create note" });
+ continue;
+ }
+
+ // Create field values
+ const fieldValuesResult: NoteFieldValue[] = [];
+ for (const fieldType of cached.fieldTypes) {
+ const value = input.fields[fieldType.id] ?? "";
+ const [fieldValue] = await db
+ .insert(noteFieldValues)
+ .values({
+ noteId: note.id,
+ noteFieldTypeId: fieldType.id,
+ value,
+ })
+ .returning();
+ if (fieldValue) {
+ fieldValuesResult.push(fieldValue);
+ }
+ }
+
+ // Create normal card
+ await createCardForNote(
+ deckId,
+ note.id,
+ cached.noteType,
+ fieldValuesResult,
+ cached.fieldTypes,
+ false,
+ );
+
+ // Create reversed card if reversible
+ if (cached.noteType.isReversible) {
+ await createCardForNote(
+ deckId,
+ note.id,
+ cached.noteType,
+ fieldValuesResult,
+ cached.fieldTypes,
+ true,
+ );
+ }
+
+ created++;
+ } catch (error) {
+ failed.push({
+ index: i,
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ }
+
+ return { created, failed };
+ },
};
async function createCardForNote(
diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts
index a986cad..4768d49 100644
--- a/src/server/repositories/types.ts
+++ b/src/server/repositories/types.ts
@@ -310,6 +310,21 @@ export interface CreateNoteResult {
cards: Card[];
}
+export interface BulkCreateNoteInput {
+ noteTypeId: string;
+ fields: Record<string, string>;
+}
+
+export interface BulkCreateNoteFailure {
+ index: number;
+ error: string;
+}
+
+export interface BulkCreateNoteResult {
+ created: number;
+ failed: BulkCreateNoteFailure[];
+}
+
export interface NoteRepository {
findByDeckId(deckId: string): Promise<Note[]>;
findById(id: string, deckId: string): Promise<Note | undefined>;
@@ -330,4 +345,8 @@ export interface NoteRepository {
fields: Record<string, string>,
): Promise<NoteWithFieldValues | undefined>;
softDelete(id: string, deckId: string): Promise<boolean>;
+ createMany(
+ deckId: string,
+ notes: BulkCreateNoteInput[],
+ ): Promise<BulkCreateNoteResult>;
}
diff --git a/src/server/routes/notes.test.ts b/src/server/routes/notes.test.ts
index 2ca08f9..e354fa6 100644
--- a/src/server/routes/notes.test.ts
+++ b/src/server/routes/notes.test.ts
@@ -23,6 +23,7 @@ function createMockNoteRepo(): NoteRepository {
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
+ createMany: vi.fn(),
};
}
diff --git a/src/server/routes/notes.ts b/src/server/routes/notes.ts
index 16ffb09..ea7b0d0 100644
--- a/src/server/routes/notes.ts
+++ b/src/server/routes/notes.ts
@@ -8,7 +8,11 @@ import {
type NoteRepository,
noteRepository,
} from "../repositories/index.js";
-import { createNoteSchema, updateNoteSchema } from "../schemas/index.js";
+import {
+ bulkCreateNotesSchema,
+ createNoteSchema,
+ updateNoteSchema,
+} from "../schemas/index.js";
export interface NoteDependencies {
noteRepo: NoteRepository;
@@ -86,6 +90,32 @@ export function createNotesRouter(deps: NoteDependencies) {
}
},
)
+ // Bulk import notes
+ .post(
+ "/import",
+ zValidator("param", deckIdParamSchema),
+ zValidator("json", bulkCreateNotesSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { deckId } = c.req.valid("param");
+ const data = c.req.valid("json");
+
+ const deck = await deckRepo.findById(deckId, user.id);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ const result = await noteRepo.createMany(deckId, data.notes);
+
+ return c.json(
+ {
+ created: result.created,
+ failed: result.failed,
+ },
+ 201,
+ );
+ },
+ )
// Get note with field values
.get("/:noteId", zValidator("param", noteIdParamSchema), async (c) => {
const user = getAuthUser(c);
diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts
index d2a8fb0..fc1bd77 100644
--- a/src/server/schemas/index.ts
+++ b/src/server/schemas/index.ts
@@ -186,6 +186,16 @@ export const updateNoteSchema = z.object({
fields: z.record(z.uuid(), z.string()),
});
+// Bulk note import input schema
+export const bulkCreateNotesSchema = z.object({
+ notes: z.array(
+ z.object({
+ noteTypeId: z.uuid(),
+ fields: z.record(z.uuid(), z.string()),
+ }),
+ ),
+});
+
// NoteFieldValue schema
export const noteFieldValueSchema = z.object({
id: z.uuid(),
@@ -244,4 +254,5 @@ export type UpdateNoteFieldTypeSchema = z.infer<
export type NoteSchema = z.infer<typeof noteSchema>;
export type CreateNoteSchema = z.infer<typeof createNoteSchema>;
export type UpdateNoteSchema = z.infer<typeof updateNoteSchema>;
+export type BulkCreateNotesSchema = z.infer<typeof bulkCreateNotesSchema>;
export type NoteFieldValueSchema = z.infer<typeof noteFieldValueSchema>;