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/utils/csvParser.test.ts | 159 +++++++++++++++++++++++++++++++++++++ src/client/utils/csvParser.ts | 154 +++++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 src/client/utils/csvParser.test.ts create mode 100644 src/client/utils/csvParser.ts (limited to 'src/client/utils') 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[]; +} + +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