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 res = await apiClient.rpc.api["note-types"].$get(); const data = await apiClient.handleResponse<{ noteTypes: { id: string; name: string }[]; }>(res); // Fetch details for each note type to get fields const noteTypesWithFields: NoteType[] = []; for (const nt of data.noteTypes) { const detailRes = await apiClient.rpc.api["note-types"][":id"].$get({ param: { id: nt.id }, }); if (detailRes.ok) { const detailData = await apiClient.handleResponse<{ noteType: NoteType; }>(detailRes); 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 note_type and at least one field if (headers.length < 2) { setError("CSV must have at least 2 columns: note_type and field(s)"); setPhase("upload"); return; } if (headers[0] !== "note_type") { setError("First column must be 'note_type'"); setPhase("upload"); return; } const fieldNames = headers.slice(1); 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 res = await apiClient.rpc.api.decks[":deckId"].notes.import.$post({ param: { deckId }, json: { notes: validatedRows.map((row) => ({ noteTypeId: row.noteTypeId, fields: row.fields, })), }, }); const result = await apiClient.handleResponse(res); 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:

{noteTypes.length === 0 ? (

Loading note types...

) : (
{noteTypes.map((nt) => { const sortedFields = [...nt.fields].sort( (a, b) => a.order - b.order, ); const fieldNames = sortedFields.map((f) => f.name); return ( note_type,{fieldNames.join(",")}
{nt.name},{fieldNames.map(() => "...").join(",")}
); })}
)}
)} {/* 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" && ( )}
); }