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 +++++++++++++++++++++++++++++ 1 file changed, 535 insertions(+) create mode 100644 src/client/components/ImportNotesModal.tsx (limited to 'src/client/components') 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" && ( + + )} +
+
+
+
+ ); +} -- cgit v1.2.3-70-g09d2