Setting the file. One moment. Checkout Field CSV Parser · Rp Source Wordpress · wix/skills · Skills Docslib/checkout-field-csv-parser.js
lib/checkout-field-csv-parser.js
JavaScript·115 lines·5 KB
,
'heading'
]);
8const CHOICE_TYPES = new Set(['select', 'radio', 'multiselect']);
9const REQUIRED_COLUMNS = ['meta_key', 'label', 'type', 'section', 'required', 'options', 'enabled', 'sort_order'];
10const YES_NO_VALUES = new Set(['yes', 'no']);
11
12// RFC4180 field splitter: a field starting with `"` is quoted, a doubled `""` inside a quoted
13// field is one literal quote, and a comma only splits outside quotes. Returns null (never a
14// partially-split row) when a quote is left unterminated, so a malformed row is caught rather
15// than silently mis-split.
16function splitCsvLine(line) {
17 const cells = [];
18 let current = '';
19 let inQuotes = false;
20 for (let i = 0; i < line.length; i += 1) {
21 const char = line[i];
22 if (inQuotes) {
23 if (char === '"') {
24 if (line[i + 1] === '"') { current += '"'; i += 1; } else { inQuotes = false; }
25 } else {
26 current += char;
27 }
28 } else if (char === '"' && current.length === 0) {
29 inQuotes = true;
30 } else if (char === ',') {
31 cells.push(current);
32 current = '';
33 } else {
34 current += char;
35 }
36 }
37 if (inQuotes) return null;
38 cells.push(current);
39 return cells.map((cell) => cell.trim());
40}
41
42// Pure over its input string — no I/O — so it can be exercised directly by a self-test or a
43// direct unit test without touching disk.
44function parseCsvText(csvText) {
45 if (typeof csvText !== 'string' || csvText.trim().length === 0) {
46 return { reconciled: false, reason: 'empty-input' };
47 }
48
49 const lines = csvText.split(/\r?\n/).filter((line) => line.trim().length > 0);
50 if (lines.length < 1) return { reconciled: false, reason: 'empty-input' };
51
52 const headerCells = splitCsvLine(lines[0]);
53 if (!headerCells) return { reconciled: false, reason: 'malformed-row', rowNumber: 1 };
54 const header = headerCells.map((cell) => cell.toLowerCase());
55 for (const column of REQUIRED_COLUMNS) {
56 if (!header.includes(column)) return { reconciled: false, reason: 'missing-column', column };
57 }
58
59 const seenKeys = new Set();
60 const fields = [];
61 let headingCount = 0;
62
63 for (let i = 1; i < lines.length; i += 1) {
64 const cells = splitCsvLine(lines[i]);
65 if (!cells) return { reconciled: false, reason: 'malformed-row', rowNumber: i + 1 };
66 const row = {};
67 header.forEach((column, index) => { row[column] = cells[index] !== undefined ? cells[index] : ''; });
68
69 if (row.type === 'heading') {
70 headingCount += 1;
71 continue;
72 }
73 if (!VALID_TYPES.has(row.type)) return { reconciled: false, reason: 'unrecognized-type', row };
74 if (!row.meta_key) return { reconciled: false, reason: 'missing-meta-key', row };
75 if (seenKeys.has(row.meta_key)) return { reconciled: false, reason: 'duplicate-meta-key', row };
76 if (CHOICE_TYPES.has(row.type) && !row.options) return { reconciled: false, reason: 'missing-options-for-choice-type', row };
77 const requiredValue = row.required.toLowerCase();
78 if (!YES_NO_VALUES.has(requiredValue)) return { reconciled: false, reason: 'invalid-required-value', row };
79 const enabledValue = row.enabled.toLowerCase();
80 if (!YES_NO_VALUES.has(enabledValue)) return { reconciled: false, reason: 'invalid-enabled-value', row };
81 const sortOrder = Number(row.sort_order);
82 if (!Number.isFinite(sortOrder)) return { reconciled: false, reason: 'invalid-sort-order', row };
83
84 seenKeys.add(row.meta_key);
85 fields.push({
86 metaKey: row.meta_key,
87 label: row.label,
88 fieldType: row.type,
89 section: row.section,
90 required: requiredValue === 'yes',
91 options: row.options ? row.options.split('|').map((option) => option.trim()).filter(Boolean) : [],
92 enabled: enabledValue === 'yes',
93 sortOrder,
94 });
95 }
96
97 // A CSV is read whole in one pass, never paginated, so by the time every row above has
98 // validated cleanly there is nothing left to reconcile against — sourceCount and
99 // expectedTotal are trivially the same value. Kept as two fields (not folded into one)
100 // because resolveBlockedDataRequest's resultReconciles() requires both, matching the
101 // paginated-fetch shape every other fulfillment kind reports.
102 const dataRowCount = lines.length - 1;
103 return { reconciled: true, fields, headingCount, sourceCount: dataRowCount, expectedTotal: dataRowCount };
104}
105
106// The handler entry point blocked-data-requests.js's attemptFulfillment() actually calls:
107// handler.parse({ inputPath, ...handlerContext }). It has already confirmed inputPath exists
108// before calling this. `readFile` is injectable so a self-test can exercise this exact entry
109// point without touching disk.
110async function parse({ inputPath, readFile = (p) => fsp.readFile(p, 'utf8') } = {}) {
111 const csvText = await readFile(inputPath);
112 return parseCsvText(csvText);
113}
114
115module.exports = { HANDLER_VERSION, parse, parseCsvText, VALID_TYPES, CHOICE_TYPES };