jtcsv 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/csv-to-json.js ADDED
@@ -0,0 +1,492 @@
1
+ /**
2
+ * CSV to JSON Converter - Node.js Module
3
+ *
4
+ * A lightweight, efficient module for converting CSV data to JSON format
5
+ * with proper parsing and error handling.
6
+ *
7
+ * @module csv-to-json
8
+ */
9
+
10
+ const {
11
+ ValidationError,
12
+ SecurityError,
13
+ FileSystemError,
14
+ ParsingError,
15
+ LimitError,
16
+ ConfigurationError,
17
+ safeExecute
18
+ } = require('./errors');
19
+
20
+ /**
21
+ * Validates CSV input and options
22
+ * @private
23
+ */
24
+ function validateCsvInput(csv, options) {
25
+ // Validate CSV input
26
+ if (typeof csv !== 'string') {
27
+ throw new ValidationError('Input must be a CSV string');
28
+ }
29
+
30
+ // Validate options
31
+ if (options && typeof options !== 'object') {
32
+ throw new ConfigurationError('Options must be an object');
33
+ }
34
+
35
+ // Validate delimiter
36
+ if (options?.delimiter && typeof options.delimiter !== 'string') {
37
+ throw new ConfigurationError('Delimiter must be a string');
38
+ }
39
+
40
+ if (options?.delimiter && options.delimiter.length !== 1) {
41
+ throw new ConfigurationError('Delimiter must be a single character');
42
+ }
43
+
44
+ // Validate maxRows
45
+ if (options?.maxRows && (typeof options.maxRows !== 'number' || options.maxRows <= 0)) {
46
+ throw new ConfigurationError('maxRows must be a positive number');
47
+ }
48
+
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * Parses a single CSV line with proper escaping
54
+ * @private
55
+ */
56
+ function parseCsvLine(line, lineNumber, delimiter) {
57
+ const fields = [];
58
+ let currentField = '';
59
+ let insideQuotes = false;
60
+ let escapeNext = false;
61
+
62
+ for (let i = 0; i < line.length; i++) {
63
+ const char = line[i];
64
+
65
+ if (escapeNext) {
66
+ currentField += char;
67
+ escapeNext = false;
68
+ continue;
69
+ }
70
+
71
+ if (char === '\\') {
72
+ escapeNext = true;
73
+ continue;
74
+ }
75
+
76
+ if (char === '"') {
77
+ if (insideQuotes) {
78
+ if (i + 1 < line.length && line[i + 1] === '"') {
79
+ // Could be escaped quote ("") or double quote at end ("")
80
+ if (i + 2 === line.length) {
81
+ // This is the pattern "" at the end of the line
82
+ // First quote is part of field, second is closing quote
83
+ currentField += '"';
84
+ i++; // Skip the closing quote
85
+ insideQuotes = false;
86
+ } else {
87
+ // Escaped quote inside quotes ("" -> ")
88
+ currentField += '"';
89
+ i++; // Skip next quote
90
+ }
91
+ } else {
92
+ // Check if this is really the end of the quoted field
93
+ // Look ahead to see if next char is delimiter or end of line
94
+ let isEndOfField = false;
95
+ let j = i + 1;
96
+ // Skip whitespace
97
+ while (j < line.length && (line[j] === ' ' || line[j] === '\t')) {
98
+ j++;
99
+ }
100
+ if (j === line.length || line[j] === delimiter) {
101
+ isEndOfField = true;
102
+ }
103
+
104
+ if (isEndOfField) {
105
+ // This is the closing quote
106
+ insideQuotes = false;
107
+ } else {
108
+ // This quote is part of the field content
109
+ currentField += '"';
110
+ }
111
+ }
112
+ } else {
113
+ // Start of quoted field
114
+ insideQuotes = true;
115
+ }
116
+ continue;
117
+ }
118
+
119
+ if (!insideQuotes && char === delimiter) {
120
+ // End of field
121
+ fields.push(currentField);
122
+ currentField = '';
123
+ continue;
124
+ }
125
+
126
+ currentField += char;
127
+ }
128
+
129
+ // Add last field
130
+ fields.push(currentField);
131
+
132
+ // Check for unclosed quotes
133
+ if (insideQuotes) {
134
+ throw new ParsingError('Unclosed quotes in CSV', lineNumber);
135
+ }
136
+
137
+ // Validate field count consistency
138
+ if (fields.length === 0) {
139
+ throw new ParsingError('No fields found', lineNumber);
140
+ }
141
+
142
+ return fields;
143
+ }
144
+
145
+ /**
146
+ * Parses a value based on options
147
+ * @private
148
+ */
149
+ function parseCsvValue(value, options) {
150
+ const { trim = true, parseNumbers = false, parseBooleans = false } = options;
151
+
152
+ let result = value;
153
+
154
+ if (trim) {
155
+ result = result.trim();
156
+ }
157
+
158
+ // Remove Excel formula protection
159
+ if (result.startsWith("'")) {
160
+ result = result.substring(1);
161
+ }
162
+
163
+ // Parse numbers
164
+ if (parseNumbers && /^-?\d+(\.\d+)?$/.test(result)) {
165
+ const num = parseFloat(result);
166
+ if (!isNaN(num)) {
167
+ return num;
168
+ }
169
+ }
170
+
171
+ // Parse booleans
172
+ if (parseBooleans) {
173
+ const lowerValue = result.toLowerCase();
174
+ if (lowerValue === 'true') {
175
+ return true;
176
+ }
177
+ if (lowerValue === 'false') {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ // Parse empty strings as null
183
+ if (result === '') {
184
+ return null;
185
+ }
186
+
187
+ return result;
188
+ }
189
+
190
+ /**
191
+ * Converts CSV string to JSON array
192
+ *
193
+ * @param {string} csv - CSV string to convert
194
+ * @param {Object} [options] - Configuration options
195
+ * @param {string} [options.delimiter=';'] - CSV delimiter character
196
+ * @param {boolean} [options.hasHeaders=true] - Whether CSV has headers row
197
+ * @param {Object} [options.renameMap={}] - Map for renaming column headers (newKey: oldKey)
198
+ * @param {boolean} [options.trim=true] - Trim whitespace from values
199
+ * @param {boolean} [options.parseNumbers=false] - Parse numeric values
200
+ * @param {boolean} [options.parseBooleans=false] - Parse boolean values
201
+ * @param {number} [options.maxRows=1000000] - Maximum number of rows to process
202
+ * @returns {Array<Object>} JSON array
203
+ *
204
+ * @example
205
+ * const { csvToJson } = require('./csv-to-json');
206
+ *
207
+ * const csv = `id;name;email\n1;John;john@example.com\n2;Jane;jane@example.com`;
208
+ * const json = csvToJson(csv, {
209
+ * delimiter: ';',
210
+ * parseNumbers: true
211
+ * });
212
+ */
213
+ function csvToJson(csv, options = {}) {
214
+ return safeExecute(() => {
215
+ // Validate input
216
+ validateCsvInput(csv, options);
217
+
218
+ const opts = options && typeof options === 'object' ? options : {};
219
+
220
+ const {
221
+ delimiter = ';',
222
+ hasHeaders = true,
223
+ renameMap = {},
224
+ trim = true,
225
+ parseNumbers = false,
226
+ parseBooleans = false,
227
+ maxRows = 1000000
228
+ } = opts;
229
+
230
+ // Handle empty CSV
231
+ if (csv.trim() === '') {
232
+ return [];
233
+ }
234
+
235
+ // Parse CSV with proper handling of quotes and newlines
236
+ const lines = [];
237
+ let currentLine = '';
238
+ let insideQuotes = false;
239
+
240
+ for (let i = 0; i < csv.length; i++) {
241
+ const char = csv[i];
242
+
243
+ if (char === '"') {
244
+ if (insideQuotes && i + 1 < csv.length && csv[i + 1] === '"') {
245
+ // Escaped quote inside quotes ("" -> ")
246
+ currentLine += '"';
247
+ i++; // Skip next quote
248
+ } else {
249
+ // Toggle quote mode
250
+ insideQuotes = !insideQuotes;
251
+ }
252
+ currentLine += char;
253
+ continue;
254
+ }
255
+
256
+ if (char === '\n' && !insideQuotes) {
257
+ // End of line (outside quotes)
258
+ lines.push(currentLine);
259
+ currentLine = '';
260
+ continue;
261
+ }
262
+
263
+ if (char === '\r') {
264
+ // Ignore carriage return, will be handled by \n
265
+ continue;
266
+ }
267
+
268
+ currentLine += char;
269
+ }
270
+
271
+ // Add the last line
272
+ if (currentLine !== '' || insideQuotes) {
273
+ lines.push(currentLine);
274
+ }
275
+
276
+ // Check for unclosed quotes
277
+ if (insideQuotes) {
278
+ throw new ParsingError('Unclosed quotes in CSV', lines.length);
279
+ }
280
+
281
+ if (lines.length === 0) {
282
+ return [];
283
+ }
284
+
285
+ // Limit rows to prevent OOM
286
+ if (lines.length > maxRows) {
287
+ throw new LimitError(
288
+ `CSV size exceeds maximum limit of ${maxRows} rows`,
289
+ maxRows,
290
+ lines.length
291
+ );
292
+ }
293
+
294
+ let headers = [];
295
+ let startIndex = 0;
296
+
297
+ // Parse headers if present
298
+ if (hasHeaders && lines.length > 0) {
299
+ try {
300
+ headers = parseCsvLine(lines[0], 1, delimiter).map(header => {
301
+ const trimmed = trim ? header.trim() : header;
302
+ // Apply rename map
303
+ return renameMap[trimmed] || trimmed;
304
+ });
305
+ startIndex = 1;
306
+ } catch (error) {
307
+ if (error instanceof ParsingError) {
308
+ throw new ParsingError(`Failed to parse headers: ${error.message}`, 1);
309
+ }
310
+ throw error;
311
+ }
312
+ } else {
313
+ // Generate numeric headers from first line
314
+ try {
315
+ const firstLineFields = parseCsvLine(lines[0], 1, delimiter);
316
+ headers = firstLineFields.map((_, index) => `column${index + 1}`);
317
+ } catch (error) {
318
+ if (error instanceof ParsingError) {
319
+ throw new ParsingError(`Failed to parse first line: ${error.message}`, 1);
320
+ }
321
+ throw error;
322
+ }
323
+ }
324
+
325
+ // Parse data rows
326
+ const result = [];
327
+
328
+ for (let i = startIndex; i < lines.length; i++) {
329
+ const line = lines[i];
330
+
331
+ // Skip empty lines
332
+ if (line.trim() === '') {
333
+ continue;
334
+ }
335
+
336
+ try {
337
+ const fields = parseCsvLine(line, i + 1, delimiter);
338
+
339
+ // Handle mismatched field count
340
+ const row = {};
341
+ const fieldCount = Math.min(fields.length, headers.length);
342
+
343
+ for (let j = 0; j < fieldCount; j++) {
344
+ row[headers[j]] = parseCsvValue(fields[j], { trim, parseNumbers, parseBooleans });
345
+ }
346
+
347
+ // Warn about extra fields
348
+ if (fields.length > headers.length && process.env.NODE_ENV === 'development') {
349
+ console.warn(`[jtcsv] Line ${i + 1}: ${fields.length - headers.length} extra fields ignored`);
350
+ }
351
+
352
+ result.push(row);
353
+ } catch (error) {
354
+ if (error instanceof ParsingError) {
355
+ throw new ParsingError(`Line ${i + 1}: ${error.message}`, i + 1);
356
+ }
357
+ throw error;
358
+ }
359
+ }
360
+
361
+ return result;
362
+ }, 'PARSE_FAILED', { function: 'csvToJson' });
363
+ }
364
+
365
+ /**
366
+ * Validates file path for CSV reading
367
+ * @private
368
+ */
369
+ function validateCsvFilePath(filePath) {
370
+ const path = require('path');
371
+
372
+ // Basic validation
373
+ if (typeof filePath !== 'string' || filePath.trim() === '') {
374
+ throw new ValidationError('File path must be a non-empty string');
375
+ }
376
+
377
+ // Ensure file has .csv extension
378
+ if (!filePath.toLowerCase().endsWith('.csv')) {
379
+ throw new ValidationError('File must have .csv extension');
380
+ }
381
+
382
+ // Prevent directory traversal attacks
383
+ const normalizedPath = path.normalize(filePath);
384
+ if (normalizedPath.includes('..') ||
385
+ /\\\\.\\.\\|\/\\.\\.\//.test(filePath) ||
386
+ filePath.startsWith('..') ||
387
+ filePath.includes('/..')) {
388
+ throw new SecurityError('Directory traversal detected in file path');
389
+ }
390
+
391
+ return path.resolve(filePath);
392
+ }
393
+
394
+ /**
395
+ * Reads CSV file and converts it to JSON array
396
+ *
397
+ * @param {string} filePath - Path to CSV file
398
+ * @param {Object} [options] - Configuration options (same as csvToJson)
399
+ * @returns {Promise<Array<Object>>} Promise that resolves to JSON array
400
+ *
401
+ * @example
402
+ * const { readCsvAsJson } = require('./csv-to-json');
403
+ *
404
+ * const json = await readCsvAsJson('./data.csv', {
405
+ * delimiter: ',',
406
+ * parseNumbers: true
407
+ * });
408
+ */
409
+ async function readCsvAsJson(filePath, options = {}) {
410
+ const fs = require('fs').promises;
411
+
412
+ // Validate file path
413
+ const safePath = validateCsvFilePath(filePath);
414
+
415
+ try {
416
+ // Read file
417
+ const csvContent = await fs.readFile(safePath, 'utf8');
418
+
419
+ // Parse CSV
420
+ return csvToJson(csvContent, options);
421
+ } catch (error) {
422
+ // Re-throw parsing errors as-is
423
+ if (error instanceof ParsingError || error instanceof ValidationError || error instanceof LimitError) {
424
+ throw error;
425
+ }
426
+
427
+ // Wrap file system errors
428
+ if (error.code === 'ENOENT') {
429
+ throw new FileSystemError(`File not found: ${safePath}`, error);
430
+ }
431
+ if (error.code === 'EACCES') {
432
+ throw new FileSystemError(`Permission denied: ${safePath}`, error);
433
+ }
434
+ if (error.code === 'EISDIR') {
435
+ throw new FileSystemError(`Path is a directory: ${safePath}`, error);
436
+ }
437
+
438
+ throw new FileSystemError(`Failed to read CSV file: ${error.message}`, error);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Synchronously reads CSV file and converts it to JSON array
444
+ *
445
+ * @param {string} filePath - Path to CSV file
446
+ * @param {Object} [options] - Configuration options (same as csvToJson)
447
+ * @returns {Array<Object>} JSON array
448
+ */
449
+ function readCsvAsJsonSync(filePath, options = {}) {
450
+ const fs = require('fs');
451
+
452
+ // Validate file path
453
+ const safePath = validateCsvFilePath(filePath);
454
+
455
+ try {
456
+ // Read file
457
+ const csvContent = fs.readFileSync(safePath, 'utf8');
458
+
459
+ // Parse CSV
460
+ return csvToJson(csvContent, options);
461
+ } catch (error) {
462
+ // Re-throw parsing errors as-is
463
+ if (error instanceof ParsingError || error instanceof ValidationError || error instanceof LimitError) {
464
+ throw error;
465
+ }
466
+
467
+ // Wrap file system errors
468
+ if (error.code === 'ENOENT') {
469
+ throw new FileSystemError(`File not found: ${safePath}`, error);
470
+ }
471
+ if (error.code === 'EACCES') {
472
+ throw new FileSystemError(`Permission denied: ${safePath}`, error);
473
+ }
474
+ if (error.code === 'EISDIR') {
475
+ throw new FileSystemError(`Path is a directory: ${safePath}`, error);
476
+ }
477
+
478
+ throw new FileSystemError(`Failed to read CSV file: ${error.message}`, error);
479
+ }
480
+ }
481
+
482
+ // Export the functions
483
+ module.exports = {
484
+ csvToJson,
485
+ readCsvAsJson,
486
+ readCsvAsJsonSync
487
+ };
488
+
489
+ // For ES6 module compatibility
490
+ if (typeof module !== 'undefined' && module.exports) {
491
+ module.exports.default = csvToJson;
492
+ }
package/errors.js ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Custom error classes for jtcsv
3
+ */
4
+
5
+ /**
6
+ * Base error class for jtcsv
7
+ */
8
+ class JtcsvError extends Error {
9
+ constructor(message, code = 'JTCSV_ERROR') {
10
+ super(message);
11
+ this.name = 'JtcsvError';
12
+ this.code = code;
13
+
14
+ // Maintains proper stack trace for where our error was thrown
15
+ if (Error.captureStackTrace) {
16
+ Error.captureStackTrace(this, JtcsvError);
17
+ }
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Error for invalid input data
23
+ */
24
+ class ValidationError extends JtcsvError {
25
+ constructor(message) {
26
+ super(message, 'VALIDATION_ERROR');
27
+ this.name = 'ValidationError';
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Error for security violations
33
+ */
34
+ class SecurityError extends JtcsvError {
35
+ constructor(message) {
36
+ super(message, 'SECURITY_ERROR');
37
+ this.name = 'SecurityError';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Error for file system operations
43
+ */
44
+ class FileSystemError extends JtcsvError {
45
+ constructor(message, originalError = null) {
46
+ super(message, 'FILE_SYSTEM_ERROR');
47
+ this.name = 'FileSystemError';
48
+ this.originalError = originalError;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Error for parsing/formatting issues
54
+ */
55
+ class ParsingError extends JtcsvError {
56
+ constructor(message, lineNumber = null, column = null) {
57
+ super(message, 'PARSING_ERROR');
58
+ this.name = 'ParsingError';
59
+ this.lineNumber = lineNumber;
60
+ this.column = column;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Error for size/limit violations
66
+ */
67
+ class LimitError extends JtcsvError {
68
+ constructor(message, limit, actual) {
69
+ super(message, 'LIMIT_ERROR');
70
+ this.name = 'LimitError';
71
+ this.limit = limit;
72
+ this.actual = actual;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Error for configuration issues
78
+ */
79
+ class ConfigurationError extends JtcsvError {
80
+ constructor(message) {
81
+ super(message, 'CONFIGURATION_ERROR');
82
+ this.name = 'ConfigurationError';
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Utility function to create standardized error messages
88
+ */
89
+ function createErrorMessage(type, details) {
90
+ const messages = {
91
+ INVALID_INPUT: `Invalid input: ${details}`,
92
+ SECURITY_VIOLATION: `Security violation: ${details}`,
93
+ FILE_NOT_FOUND: `File not found: ${details}`,
94
+ PARSE_FAILED: `Parse failed: ${details}`,
95
+ SIZE_LIMIT: `Size limit exceeded: ${details}`,
96
+ INVALID_CONFIG: `Invalid configuration: ${details}`,
97
+ UNKNOWN_ERROR: `Unknown error: ${details}`
98
+ };
99
+
100
+ return messages[type] || messages.UNKNOWN_ERROR;
101
+ }
102
+
103
+ /**
104
+ * Error handler utility
105
+ */
106
+ function handleError(error, context = {}) {
107
+ // Log error in development
108
+ if (process.env.NODE_ENV === 'development') {
109
+ console.error(`[jtcsv] Error in ${context.function || 'unknown'}:`, {
110
+ message: error.message,
111
+ code: error.code,
112
+ stack: error.stack,
113
+ context
114
+ });
115
+ }
116
+
117
+ // Re-throw the error
118
+ throw error;
119
+ }
120
+
121
+ /**
122
+ * Safe execution wrapper for async functions
123
+ */
124
+ async function safeExecuteAsync(fn, errorType, context = {}) {
125
+ try {
126
+ return await fn();
127
+ } catch (error) {
128
+ if (error instanceof JtcsvError) {
129
+ throw error;
130
+ }
131
+
132
+ // Wrap unknown errors
133
+ const message = createErrorMessage(errorType, error.message);
134
+ const wrappedError = new JtcsvError(message, errorType);
135
+ wrappedError.originalError = error;
136
+
137
+ handleError(wrappedError, context);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Safe execution wrapper for sync functions
143
+ */
144
+ function safeExecuteSync(fn, errorType, context = {}) {
145
+ try {
146
+ return fn();
147
+ } catch (error) {
148
+ if (error instanceof JtcsvError) {
149
+ throw error;
150
+ }
151
+
152
+ // Wrap unknown errors
153
+ const message = createErrorMessage(errorType, error.message);
154
+ const wrappedError = new JtcsvError(message, errorType);
155
+ wrappedError.originalError = error;
156
+
157
+ handleError(wrappedError, context);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Safe execution wrapper (auto-detects async/sync)
163
+ */
164
+ function safeExecute(fn, errorType, context = {}) {
165
+ const result = fn();
166
+
167
+ // Check if function returns a promise
168
+ if (result && typeof result.then === 'function') {
169
+ return safeExecuteAsync(async () => result, errorType, context);
170
+ }
171
+
172
+ return safeExecuteSync(() => result, errorType, context);
173
+ }
174
+
175
+ module.exports = {
176
+ JtcsvError,
177
+ ValidationError,
178
+ SecurityError,
179
+ FileSystemError,
180
+ ParsingError,
181
+ LimitError,
182
+ ConfigurationError,
183
+ createErrorMessage,
184
+ handleError,
185
+ safeExecute,
186
+ safeExecuteAsync,
187
+ safeExecuteSync
188
+ };