aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/utils
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-01-01 22:06:40 +0900
committernsfisis <nsfisis@gmail.com>2026-01-01 22:06:40 +0900
commit8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b (patch)
treebe39e1436f83c716fc45df133106fba7dd4bc23a /src/client/utils
parent830b370f1b8e0f3a384b2d242ab120812e81977d (diff)
downloadkioku-8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b.tar.gz
kioku-8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b.tar.zst
kioku-8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b.zip
feat(import): add CSV bulk import for notes
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 <noreply@anthropic.com>
Diffstat (limited to 'src/client/utils')
-rw-r--r--src/client/utils/csvParser.test.ts159
-rw-r--r--src/client/utils/csvParser.ts154
2 files changed, 313 insertions, 0 deletions
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;
+}