aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-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
4 files changed, 886 insertions, 12 deletions
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;
+}