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/json-to-csv.js ADDED
@@ -0,0 +1,430 @@
1
+ /**
2
+ * JSON to CSV Converter - Node.js Module
3
+ *
4
+ * A lightweight, efficient module for converting JSON data to CSV format
5
+ * with proper escaping and formatting for Excel compatibility.
6
+ *
7
+ * @module json-to-csv
8
+ */
9
+
10
+ const {
11
+ ValidationError,
12
+ SecurityError,
13
+ FileSystemError,
14
+ LimitError,
15
+ ConfigurationError,
16
+ safeExecute
17
+ } = require('./errors');
18
+
19
+ /**
20
+ * Validates input data and options
21
+ * @private
22
+ */
23
+ function validateInput(data, options) {
24
+ // Validate data
25
+ if (!Array.isArray(data)) {
26
+ throw new ValidationError('Input data must be an array');
27
+ }
28
+
29
+ // Validate options
30
+ if (options && typeof options !== 'object') {
31
+ throw new ConfigurationError('Options must be an object');
32
+ }
33
+
34
+ // Validate delimiter
35
+ if (options?.delimiter && typeof options.delimiter !== 'string') {
36
+ throw new ConfigurationError('Delimiter must be a string');
37
+ }
38
+
39
+ if (options?.delimiter && options.delimiter.length !== 1) {
40
+ throw new ConfigurationError('Delimiter must be a single character');
41
+ }
42
+
43
+ // Validate renameMap
44
+ if (options?.renameMap && typeof options.renameMap !== 'object') {
45
+ throw new ConfigurationError('renameMap must be an object');
46
+ }
47
+
48
+ // Validate maxRecords
49
+ if (options && options.maxRecords !== undefined) {
50
+ if (typeof options.maxRecords !== 'number' || options.maxRecords <= 0) {
51
+ throw new ConfigurationError('maxRecords must be a positive number');
52
+ }
53
+ }
54
+
55
+ // Validate preventCsvInjection
56
+ if (options?.preventCsvInjection !== undefined && typeof options.preventCsvInjection !== 'boolean') {
57
+ throw new ConfigurationError('preventCsvInjection must be a boolean');
58
+ }
59
+
60
+ // Validate rfc4180Compliant
61
+ if (options?.rfc4180Compliant !== undefined && typeof options.rfc4180Compliant !== 'boolean') {
62
+ throw new ConfigurationError('rfc4180Compliant must be a boolean');
63
+ }
64
+
65
+ return true;
66
+ }
67
+
68
+ /**
69
+ * Converts JSON data to CSV format
70
+ *
71
+ * @param {Array<Object>} data - Array of objects to convert to CSV
72
+ * @param {Object} [options] - Configuration options
73
+ * @param {string} [options.delimiter=';'] - CSV delimiter character
74
+ * @param {boolean} [options.includeHeaders=true] - Whether to include headers row
75
+ * @param {Object} [options.renameMap={}] - Map for renaming column headers (oldKey: newKey)
76
+ * @param {Object} [options.template={}] - Template object to ensure consistent column order
77
+ * @param {number} [options.maxRecords=1000000] - Maximum number of records to process
78
+ * @param {boolean} [options.preventCsvInjection=true] - Prevent CSV injection attacks by escaping formulas
79
+ * @param {boolean} [options.rfc4180Compliant=true] - Ensure RFC 4180 compliance (proper quoting, line endings)
80
+ * @returns {string} CSV formatted string
81
+ *
82
+ * @example
83
+ * const jsonToCsv = require('./json-to-csv');
84
+ *
85
+ * const data = [
86
+ * { id: 1, name: 'John', email: 'john@example.com' },
87
+ * { id: 2, name: 'Jane', email: 'jane@example.com' }
88
+ * ];
89
+ *
90
+ * const csv = jsonToCsv(data, {
91
+ * delimiter: ',',
92
+ * renameMap: { id: 'ID', name: 'Full Name' },
93
+ * preventCsvInjection: true,
94
+ * rfc4180Compliant: true
95
+ * });
96
+ */
97
+ function jsonToCsv(data, options = {}) {
98
+ return safeExecute(() => {
99
+ // Validate input
100
+ validateInput(data, options);
101
+
102
+ const opts = options && typeof options === 'object' ? options : {};
103
+
104
+ const {
105
+ delimiter = ';',
106
+ includeHeaders = true,
107
+ renameMap = {},
108
+ template = {},
109
+ maxRecords = 1000000,
110
+ preventCsvInjection = true,
111
+ rfc4180Compliant = true
112
+ } = opts;
113
+
114
+ // Handle empty data
115
+ if (data.length === 0) {
116
+ return '';
117
+ }
118
+
119
+ // Limit data size to prevent OOM
120
+ if (data.length > maxRecords) {
121
+ throw new LimitError(
122
+ `Data size exceeds maximum limit of ${maxRecords} records`,
123
+ maxRecords,
124
+ data.length
125
+ );
126
+ }
127
+
128
+ // Get all unique keys from all objects
129
+ const allKeys = new Set();
130
+ data.forEach((item) => {
131
+ if (!item || typeof item !== 'object') {
132
+ return;
133
+ }
134
+ Object.keys(item).forEach(key => allKeys.add(key));
135
+ });
136
+
137
+ // Convert Set to Array
138
+ const originalKeys = Array.from(allKeys);
139
+
140
+ // Apply rename map to create header names
141
+ const headers = originalKeys.map(key => renameMap[key] || key);
142
+
143
+ // Create a reverse mapping from new header to original key
144
+ const reverseRenameMap = {};
145
+ originalKeys.forEach((key, index) => {
146
+ reverseRenameMap[headers[index]] = key;
147
+ });
148
+
149
+ // Apply template ordering if provided
150
+ let finalHeaders = headers;
151
+ if (Object.keys(template).length > 0) {
152
+ // Create template headers with renaming applied
153
+ const templateHeaders = Object.keys(template).map(key => renameMap[key] || key);
154
+ const extraHeaders = headers.filter(h => !templateHeaders.includes(h));
155
+ finalHeaders = [...templateHeaders, ...extraHeaders];
156
+ }
157
+
158
+ /**
159
+ * Escapes a value for CSV format with CSV injection protection
160
+ *
161
+ * @private
162
+ * @param {*} value - The value to escape
163
+ * @returns {string} Escaped CSV value
164
+ */
165
+ const escapeValue = (value) => {
166
+ if (value === null || value === undefined || value === '') {
167
+ return '';
168
+ }
169
+
170
+ const stringValue = String(value);
171
+
172
+ // CSV Injection protection - escape formulas if enabled
173
+ let escapedValue = stringValue;
174
+ if (preventCsvInjection && /^[=+\-@]/.test(stringValue)) {
175
+ // Prepend single quote to prevent formula execution in Excel
176
+ escapedValue = "'" + stringValue;
177
+ }
178
+
179
+ // RFC 4180 compliance: fields containing line breaks, double quotes, or commas must be quoted
180
+ const needsQuoting = rfc4180Compliant
181
+ ? (escapedValue.includes(delimiter) ||
182
+ escapedValue.includes('"') ||
183
+ escapedValue.includes('\n') ||
184
+ escapedValue.includes('\r'))
185
+ : (escapedValue.includes(delimiter) ||
186
+ escapedValue.includes('"') ||
187
+ escapedValue.includes('\n') ||
188
+ escapedValue.includes('\r'));
189
+
190
+ if (needsQuoting) {
191
+ // RFC 4180: If double-quotes are used to enclose fields, then a double-quote
192
+ // appearing inside a field must be escaped by preceding it with another double quote.
193
+ return `"${escapedValue.replace(/"/g, '""')}"`;
194
+ }
195
+
196
+ return escapedValue;
197
+ };
198
+
199
+ // Build CSV rows
200
+ const rows = [];
201
+
202
+ // Add headers row if requested
203
+ if (includeHeaders && finalHeaders.length > 0) {
204
+ rows.push(finalHeaders.join(delimiter));
205
+ }
206
+
207
+ // Add data rows
208
+ for (const item of data) {
209
+ if (!item || typeof item !== 'object') {
210
+ continue;
211
+ }
212
+
213
+ const row = finalHeaders.map(header => {
214
+ // Get the original key for this header
215
+ const originalKey = reverseRenameMap[header] || header;
216
+ const value = item[originalKey];
217
+ return escapeValue(value);
218
+ }).join(delimiter);
219
+
220
+ rows.push(row);
221
+ }
222
+
223
+ // RFC 4180: Each record is located on a separate line, delimited by a line break (CRLF)
224
+ const lineEnding = rfc4180Compliant ? '\r\n' : '\n';
225
+ return rows.join(lineEnding);
226
+ }, 'PARSE_FAILED', { function: 'jsonToCsv' });
227
+ }
228
+
229
+ /**
230
+ * Deeply unwraps nested objects and arrays to extract primitive values
231
+ *
232
+ * @param {*} value - Value to unwrap
233
+ * @param {number} [depth=0] - Current recursion depth
234
+ * @param {number} [maxDepth=5] - Maximum recursion depth
235
+ * @param {Set} [visited=new Set()] - Set of visited objects to detect circular references
236
+ * @returns {string} Unwrapped string value
237
+ *
238
+ * @private
239
+ */
240
+ function deepUnwrap(value, depth = 0, maxDepth = 5, visited = new Set()) {
241
+ // Check depth before processing
242
+ if (depth >= maxDepth) {
243
+ return '[Too Deep]';
244
+ }
245
+ if (value === null || value === undefined) {
246
+ return '';
247
+ }
248
+
249
+ // Handle circular references - return early for circular refs
250
+ if (typeof value === 'object') {
251
+ if (visited.has(value)) {
252
+ return '[Circular Reference]';
253
+ }
254
+ visited.add(value);
255
+ }
256
+
257
+ // Handle arrays - join all elements
258
+ if (Array.isArray(value)) {
259
+ if (value.length === 0) {
260
+ return '';
261
+ }
262
+ const unwrappedItems = value.map(item =>
263
+ deepUnwrap(item, depth + 1, maxDepth, visited)
264
+ ).filter(item => item !== '');
265
+ return unwrappedItems.join(', ');
266
+ }
267
+
268
+ // Handle objects
269
+ if (typeof value === 'object') {
270
+ const keys = Object.keys(value);
271
+ if (keys.length === 0) {
272
+ return '';
273
+ }
274
+
275
+ // For maxDepth = 0 or 1, return [Too Deep] for objects
276
+ if (depth + 1 >= maxDepth) {
277
+ return '[Too Deep]';
278
+ }
279
+
280
+ // Stringify complex objects
281
+ try {
282
+ return JSON.stringify(value);
283
+ } catch (error) {
284
+ // Check if it's a circular reference
285
+ if (error.message.includes('circular') || error.message.includes('Converting circular')) {
286
+ return '[Circular Reference]';
287
+ }
288
+ return '[Unstringifiable Object]';
289
+ }
290
+ }
291
+
292
+ // Convert to string for primitive values
293
+ return String(value);
294
+ }
295
+
296
+ /**
297
+ * Preprocesses JSON data by deeply unwrapping nested structures
298
+ *
299
+ * @param {Array<Object>} data - Array of objects to preprocess
300
+ * @returns {Array<Object>} Preprocessed data with unwrapped values
301
+ *
302
+ * @example
303
+ * const processed = preprocessData(complexJsonData);
304
+ * const csv = jsonToCsv(processed);
305
+ */
306
+ function preprocessData(data) {
307
+ if (!Array.isArray(data)) {
308
+ return [];
309
+ }
310
+
311
+ return data.map(item => {
312
+ if (!item || typeof item !== 'object') {
313
+ return {};
314
+ }
315
+
316
+ const processed = {};
317
+
318
+ for (const key in item) {
319
+ if (Object.prototype.hasOwnProperty.call(item, key)) {
320
+ const value = item[key];
321
+ if (value && typeof value === 'object') {
322
+ processed[key] = deepUnwrap(value);
323
+ } else {
324
+ processed[key] = value;
325
+ }
326
+ }
327
+ }
328
+
329
+ return processed;
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Validates file path to prevent path traversal attacks
335
+ * @private
336
+ */
337
+ function validateFilePath(filePath) {
338
+ const path = require('path');
339
+
340
+ // Basic validation
341
+ if (typeof filePath !== 'string' || filePath.trim() === '') {
342
+ throw new ValidationError('File path must be a non-empty string');
343
+ }
344
+
345
+ // Ensure file has .csv extension
346
+ if (!filePath.toLowerCase().endsWith('.csv')) {
347
+ throw new ValidationError('File must have .csv extension');
348
+ }
349
+
350
+ // Get absolute path and check for traversal
351
+ const absolutePath = path.resolve(filePath);
352
+ const normalizedPath = path.normalize(filePath);
353
+
354
+ // Prevent directory traversal attacks
355
+ // Check if normalized path contains parent directory references
356
+ if (normalizedPath.includes('..') ||
357
+ /\\\.\.\\|\/\.\.\//.test(filePath) ||
358
+ filePath.startsWith('..') ||
359
+ filePath.includes('/..')) {
360
+ throw new SecurityError('Directory traversal detected in file path');
361
+ }
362
+
363
+ return absolutePath;
364
+ }
365
+
366
+ /**
367
+ * Converts JSON to CSV and saves it to a file
368
+ *
369
+ * @param {Array<Object>} data - Array of objects to convert
370
+ * @param {string} filePath - Path to save the CSV file
371
+ * @param {Object} [options] - Configuration options (same as jsonToCsv)
372
+ * @returns {Promise<void>}
373
+ *
374
+ * @example
375
+ * const { saveAsCsv } = require('./json-to-csv');
376
+ *
377
+ * await saveAsCsv(data, './output.csv', {
378
+ * delimiter: ',',
379
+ * renameMap: { id: 'ID' }
380
+ * });
381
+ */
382
+ async function saveAsCsv(data, filePath, options = {}) {
383
+ return safeExecute(async () => {
384
+ const fs = require('fs').promises;
385
+
386
+ // Validate file path
387
+ const safePath = validateFilePath(filePath);
388
+
389
+ // Convert data to CSV
390
+ const csvContent = jsonToCsv(data, options);
391
+
392
+ // Ensure directory exists
393
+ const dir = require('path').dirname(safePath);
394
+
395
+ try {
396
+ await fs.mkdir(dir, { recursive: true });
397
+
398
+ // Write file
399
+ await fs.writeFile(safePath, csvContent, 'utf8');
400
+
401
+ return safePath;
402
+ } catch (error) {
403
+ if (error.code === 'ENOENT') {
404
+ throw new FileSystemError(`Directory does not exist: ${dir}`, error);
405
+ }
406
+ if (error.code === 'EACCES') {
407
+ throw new FileSystemError(`Permission denied: ${safePath}`, error);
408
+ }
409
+ if (error.code === 'ENOSPC') {
410
+ throw new FileSystemError(`No space left on device: ${safePath}`, error);
411
+ }
412
+
413
+ throw new FileSystemError(`Failed to write CSV file: ${error.message}`, error);
414
+ }
415
+ }, 'FILE_SYSTEM_ERROR', { function: 'saveAsCsv' });
416
+ }
417
+
418
+ // Export the main functions
419
+ module.exports = {
420
+ jsonToCsv,
421
+ preprocessData,
422
+ saveAsCsv,
423
+ deepUnwrap,
424
+ validateFilePath
425
+ };
426
+
427
+ // For ES6 module compatibility
428
+ if (typeof module !== 'undefined' && module.exports) {
429
+ module.exports.default = jsonToCsv;
430
+ }
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "jtcsv",
3
+ "version": "1.1.0",
4
+ "description": "Complete JSON↔CSV converter for Node.js with streaming, security, TUI, and TypeScript support - Zero dependencies",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "bin": {
8
+ "jtcsv": "./bin/jtcsv.js"
9
+ },
10
+ "scripts": {
11
+ "test": "jest",
12
+ "test:coverage": "jest --coverage",
13
+ "test:watch": "jest --watch",
14
+ "lint": "eslint index.js json-to-csv.js csv-to-json.js errors.js stream-json-to-csv.js stream-csv-to-json.js json-save.js",
15
+ "lint:all": "eslint .",
16
+ "security-check": "npm audit",
17
+ "prepublishOnly": "npm test && eslint index.js json-to-csv.js csv-to-json.js errors.js stream-json-to-csv.js stream-csv-to-json.js json-save.js",
18
+ "tui": "node cli-tui.js",
19
+ "cli": "node bin/jtcsv.js"
20
+ },
21
+ "keywords": [
22
+ "json",
23
+ "csv",
24
+ "converter",
25
+ "json-to-csv",
26
+ "csv-to-json",
27
+ "json2csv",
28
+ "csv2json",
29
+ "export",
30
+ "import",
31
+ "excel",
32
+ "data",
33
+ "transform",
34
+ "nodejs",
35
+ "javascript",
36
+ "simple",
37
+ "lightweight",
38
+ "zero-dependencies",
39
+ "security",
40
+ "streaming",
41
+ "utf8",
42
+ "cyrillic",
43
+ "typescript",
44
+ "bidirectional",
45
+ "cli",
46
+ "tui",
47
+ "terminal",
48
+ "interface",
49
+ "gui",
50
+ "tool",
51
+ "utility"
52
+ ],
53
+ "author": "Ruslan Fomenko",
54
+ "license": "MIT",
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "https://github.com/Linol-Hamelton/jtcsv.git"
58
+ },
59
+ "bugs": {
60
+ "url": "https://github.com/Linol-Hamelton/jtcsv/issues"
61
+ },
62
+ "homepage": "https://github.com/Linol-Hamelton/jtcsv#readme",
63
+ "engines": {
64
+ "node": ">=12.0.0"
65
+ },
66
+ "files": [
67
+ "index.js",
68
+ "json-to-csv.js",
69
+ "csv-to-json.js",
70
+ "errors.js",
71
+ "stream-json-to-csv.js",
72
+ "stream-csv-to-json.js",
73
+ "json-save.js",
74
+ "index.d.ts",
75
+ "bin/",
76
+ "cli-tui.js"
77
+ ],
78
+ "devDependencies": {
79
+ "jest": "^29.0.0",
80
+ "eslint": "^8.0.0"
81
+ },
82
+ "optionalDependencies": {
83
+ "blessed": "^0.1.81",
84
+ "blessed-contrib": "^4.11.0"
85
+ }
86
+ }
87
+