From 8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 1 Jan 2026 22:06:40 +0900 Subject: feat(import): add CSV bulk import for notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/client/components/ImportNotesModal.tsx | 535 +++++++++++++++++++++++++++++ src/client/pages/DeckDetailPage.tsx | 50 ++- src/client/utils/csvParser.test.ts | 159 +++++++++ src/client/utils/csvParser.ts | 154 +++++++++ 4 files changed, 886 insertions(+), 12 deletions(-) create mode 100644 src/client/components/ImportNotesModal.tsx create mode 100644 src/client/utils/csvParser.test.ts create mode 100644 src/client/utils/csvParser.ts (limited to 'src/client') 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; + preview: Record; +} + +interface ImportResult { + created: number; + failed: { index: number; error: string }[]; +} + +export function ImportNotesModal({ + isOpen, + deckId, + onClose, + onImportComplete, +}: ImportNotesModalProps) { + const [phase, setPhase] = useState("upload"); + const [error, setError] = useState(null); + const [noteTypes, setNoteTypes] = useState([]); + const [validatedRows, setValidatedRows] = useState([]); + const [validationErrors, setValidationErrors] = useState( + [], + ); + const [importResult, setImportResult] = useState(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) => { + 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 = {}; + const preview: Record = {}; + 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 ( +
{ + if (e.target === e.currentTarget && phase !== "importing") { + handleClose(); + } + }} + onKeyDown={(e) => { + if (e.key === "Escape" && phase !== "importing") { + handleClose(); + } + }} + > +
+
+

+ Import Notes from CSV +

+ + {error && ( +
+ {error} +
+ )} + + {/* Phase: Upload */} + {phase === "upload" && ( +
+
+ +

Select a CSV file to import

+ +
+
+

Expected format:

+ + deck,note_type,Front,Back +
+ MyDeck,Basic,hello,world +
+
+
+ )} + + {/* Phase: Validating */} + {phase === "validating" && ( +
+ + Validating CSV... +
+ )} +
+ + {/* Phase: Preview - scrollable content */} + {phase === "preview" && ( +
+ {validationErrors.length > 0 && ( +
+
+ + {validationErrors.length} error(s) found +
+
    + {validationErrors.map((err) => ( +
  • + Row {err.rowNumber}: {err.message} +
  • + ))} +
+
+ )} + + {validatedRows.length > 0 && ( +
+

+ {validatedRows.length}{" "} + note(s) ready to import +

+ + {/* Preview table */} +
+
+ + + + + + + + + + {validatedRows.slice(0, 10).map((row) => ( + + + + + + ))} + +
+ # + + Type + + Preview +
+ {row.rowNumber} + + {row.noteTypeName} + + {Object.values(row.preview).join(" | ")} +
+
+ {validatedRows.length > 10 && ( +
+ ...and {validatedRows.length - 10} more +
+ )} +
+
+ )} +
+ )} + + {/* Phase: Importing */} + {phase === "importing" && ( +
+ + Importing notes... +
+ )} + + {/* Phase: Complete */} + {phase === "complete" && importResult && ( +
+
+
+ +
+

+ Import complete! +

+

+ {importResult.created} note(s) imported successfully +

+ {importResult.failed.length > 0 && ( +
+

+ {importResult.failed.length} failed: +

+
    + {importResult.failed.map((f) => ( +
  • + Row {validatedRows[f.index]?.rowNumber ?? f.index + 1}:{" "} + {f.error} +
  • + ))} +
+
+ )} +
+
+ )} + + {/* Footer buttons */} +
+
+ {phase === "upload" && ( + + )} + + {phase === "preview" && ( + <> + + + + )} + + {phase === "complete" && ( + + )} +
+
+
+
+ ); +} 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(null); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [editingCard, setEditingCard] = useState(null); const [editingNoteId, setEditingNoteId] = useState(null); const [deletingCard, setDeletingCard] = useState(null); @@ -397,18 +400,32 @@ export function DeckDetailPage() { Cards{" "} ({cards.length}) - +
+ + +
{/* Empty State */} @@ -471,6 +488,15 @@ export function DeckDetailPage() { /> )} + {deckId && ( + setIsImportModalOpen(false)} + onImportComplete={fetchCards} + /> + )} + {deckId && ( { + 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[]; +} + +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[] = []; + + 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 = {}; + 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; +} -- cgit v1.2.3-70-g09d2