aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/utils/csvParser.ts
blob: c58af00ec1293c9dfec5f81c53ff332e4ccdd60a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
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;
}