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 +++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/client/utils/csvParser.test.ts (limited to 'src/client/utils/csvParser.test.ts') 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", + }); + } + }); +}); -- cgit v1.2.3-70-g09d2