lula2 0.5.0 → 0.5.1-nightly.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/README.md +8 -0
- package/dist/_app/immutable/assets/{0.Dfpe5goI.css → 0.CJjXKESY.css} +1 -1
- package/dist/_app/immutable/chunks/Bvop-7hR.js +2 -0
- package/dist/_app/immutable/chunks/{BCoAVBju.js → BxuSJT7C.js} +1 -1
- package/dist/_app/immutable/chunks/{RxRVmDPY.js → C6hLAW8R.js} +1 -1
- package/dist/_app/immutable/chunks/{BN4ish10.js → CPEw6sZY.js} +1 -1
- package/dist/_app/immutable/chunks/{BtwnwKFn.js → CXdZVXJf.js} +1 -1
- package/dist/_app/immutable/chunks/CmNS-eRo.js +3 -0
- package/dist/_app/immutable/chunks/GToHgjp8.js +2 -0
- package/dist/_app/immutable/chunks/{DBN1r830.js → R1gz3SOr.js} +1 -1
- package/dist/_app/immutable/chunks/{D6NghQtU.js → zYrdvxnm.js} +1 -1
- package/dist/_app/immutable/entry/{app.DzkPo2gz.js → app.DnDpDSzX.js} +2 -2
- package/dist/_app/immutable/entry/start.9lsj4O5d.js +1 -0
- package/dist/_app/immutable/nodes/{0.BcLFbXUF.js → 0.KEcBS74O.js} +1 -1
- package/dist/_app/immutable/nodes/{1.8i0FdqjI.js → 1.BzTwWiVT.js} +1 -1
- package/dist/_app/immutable/nodes/{2.jit_WwBQ.js → 2.OvRLlZdO.js} +1 -1
- package/dist/_app/immutable/nodes/{3.CfrrpHWE.js → 3.q5RXDnSv.js} +1 -1
- package/dist/_app/immutable/nodes/{4.BZ-_Jk1v.js → 4.DCvpKEph.js} +1 -1
- package/dist/_app/version.json +1 -1
- package/dist/cli/commands/crawl.js +53 -1
- package/dist/cli/commands/ui.js +356 -308
- package/dist/cli/server/index.js +356 -308
- package/dist/cli/server/server.js +356 -308
- package/dist/cli/server/spreadsheetRoutes.js +343 -295
- package/dist/cli/server/websocketServer.js +356 -308
- package/dist/index.html +10 -10
- package/dist/index.js +408 -309
- package/package.json +22 -23
- package/src/lib/websocket.ts +106 -76
- package/dist/_app/immutable/chunks/B3DV5AB9.js +0 -2
- package/dist/_app/immutable/chunks/BIY1u1I9.js +0 -2
- package/dist/_app/immutable/chunks/CvviX0Gc.js +0 -3
- package/dist/_app/immutable/entry/start.BUtu9VJ8.js +0 -1
|
@@ -90,58 +90,81 @@ async function scanControlSets() {
|
|
|
90
90
|
}).filter((cs) => cs !== null);
|
|
91
91
|
return { controlSets };
|
|
92
92
|
}
|
|
93
|
+
function processImportParameters(reqBody) {
|
|
94
|
+
const {
|
|
95
|
+
controlIdField = "Control ID",
|
|
96
|
+
startRow = "1",
|
|
97
|
+
controlSetName = "Imported Control Set",
|
|
98
|
+
controlSetDescription = "Imported from spreadsheet"
|
|
99
|
+
} = reqBody;
|
|
100
|
+
let justificationFields = [];
|
|
101
|
+
if (reqBody.justificationFields) {
|
|
102
|
+
try {
|
|
103
|
+
justificationFields = JSON.parse(reqBody.justificationFields);
|
|
104
|
+
debug("Justification fields received:", justificationFields);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.error("Failed to parse justification fields:", e);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
let frontendFieldSchema = null;
|
|
110
|
+
if (reqBody.fieldSchema) {
|
|
111
|
+
try {
|
|
112
|
+
frontendFieldSchema = JSON.parse(reqBody.fieldSchema);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
console.error("Failed to parse fieldSchema:", e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
debug("Import parameters received:", {
|
|
118
|
+
controlIdField,
|
|
119
|
+
startRow,
|
|
120
|
+
controlSetName,
|
|
121
|
+
controlSetDescription
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
controlIdField,
|
|
125
|
+
startRow,
|
|
126
|
+
controlSetName,
|
|
127
|
+
controlSetDescription,
|
|
128
|
+
justificationFields,
|
|
129
|
+
namingConvention: "kebab-case",
|
|
130
|
+
skipEmpty: true,
|
|
131
|
+
skipEmptyRows: true,
|
|
132
|
+
frontendFieldSchema
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async function parseUploadedFile(file) {
|
|
136
|
+
const fileName = file.originalname || "";
|
|
137
|
+
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
138
|
+
let rawData = [];
|
|
139
|
+
if (isCSV) {
|
|
140
|
+
const csvContent = file.buffer.toString("utf-8");
|
|
141
|
+
rawData = parseCSV(csvContent);
|
|
142
|
+
} else {
|
|
143
|
+
const workbook = new ExcelJS.Workbook();
|
|
144
|
+
const buffer = Buffer.from(file.buffer);
|
|
145
|
+
await workbook.xlsx.load(buffer);
|
|
146
|
+
const worksheet = workbook.worksheets[0];
|
|
147
|
+
if (!worksheet) {
|
|
148
|
+
throw new Error("No worksheet found in file");
|
|
149
|
+
}
|
|
150
|
+
worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
|
151
|
+
const rowData = [];
|
|
152
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
153
|
+
rowData[colNumber - 1] = cell.value;
|
|
154
|
+
});
|
|
155
|
+
rawData[rowNumber - 1] = rowData;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return rawData;
|
|
159
|
+
}
|
|
93
160
|
router.post("/import-spreadsheet", upload.single("file"), async (req, res) => {
|
|
94
161
|
try {
|
|
95
162
|
if (!req.file) {
|
|
96
163
|
return res.status(400).json({ error: "No file uploaded" });
|
|
97
164
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
controlSetName = "Imported Control Set",
|
|
102
|
-
controlSetDescription = "Imported from spreadsheet"
|
|
103
|
-
} = req.body;
|
|
104
|
-
let justificationFields = [];
|
|
105
|
-
if (req.body.justificationFields) {
|
|
106
|
-
try {
|
|
107
|
-
justificationFields = JSON.parse(req.body.justificationFields);
|
|
108
|
-
debug("Justification fields received:", justificationFields);
|
|
109
|
-
} catch (e) {
|
|
110
|
-
console.error("Failed to parse justification fields:", e);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
debug("Import parameters received:", {
|
|
114
|
-
controlIdField,
|
|
115
|
-
startRow,
|
|
116
|
-
controlSetName,
|
|
117
|
-
controlSetDescription
|
|
118
|
-
});
|
|
119
|
-
const namingConvention = "kebab-case";
|
|
120
|
-
const skipEmpty = true;
|
|
121
|
-
const skipEmptyRows = true;
|
|
122
|
-
const fileName = req.file.originalname || "";
|
|
123
|
-
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
124
|
-
let rawData = [];
|
|
125
|
-
if (isCSV) {
|
|
126
|
-
const csvContent = req.file.buffer.toString("utf-8");
|
|
127
|
-
rawData = parseCSV(csvContent);
|
|
128
|
-
} else {
|
|
129
|
-
const workbook = new ExcelJS.Workbook();
|
|
130
|
-
const buffer = Buffer.from(req.file.buffer);
|
|
131
|
-
await workbook.xlsx.load(buffer);
|
|
132
|
-
const worksheet = workbook.worksheets[0];
|
|
133
|
-
if (!worksheet) {
|
|
134
|
-
return res.status(400).json({ error: "No worksheet found in file" });
|
|
135
|
-
}
|
|
136
|
-
worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
|
137
|
-
const rowData = [];
|
|
138
|
-
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
139
|
-
rowData[colNumber - 1] = cell.value;
|
|
140
|
-
});
|
|
141
|
-
rawData[rowNumber - 1] = rowData;
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
const startRowIndex = parseInt(startRow) - 1;
|
|
165
|
+
const params = processImportParameters(req.body);
|
|
166
|
+
const rawData = await parseUploadedFile(req.file);
|
|
167
|
+
const startRowIndex = parseInt(params.startRow) - 1;
|
|
145
168
|
if (rawData.length <= startRowIndex) {
|
|
146
169
|
return res.status(400).json({ error: "Start row exceeds sheet data" });
|
|
147
170
|
}
|
|
@@ -152,275 +175,288 @@ router.post("/import-spreadsheet", upload.single("file"), async (req, res) => {
|
|
|
152
175
|
debug("Headers found:", headers);
|
|
153
176
|
debug(
|
|
154
177
|
"After conversion, looking for control ID field:",
|
|
155
|
-
applyNamingConvention(controlIdField, namingConvention)
|
|
178
|
+
applyNamingConvention(params.controlIdField, params.namingConvention)
|
|
156
179
|
);
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
emptyCount: 0,
|
|
171
|
-
totalCount: 0,
|
|
172
|
-
examples: []
|
|
173
|
-
});
|
|
174
|
-
}
|
|
180
|
+
const processedData = processSpreadsheetData(rawData, headers, startRowIndex, params);
|
|
181
|
+
const fieldSchema = buildFieldSchema(
|
|
182
|
+
processedData.fieldMetadata,
|
|
183
|
+
processedData.controls,
|
|
184
|
+
params,
|
|
185
|
+
processedData.families
|
|
186
|
+
);
|
|
187
|
+
const result = await createOutputStructure(processedData, fieldSchema, params);
|
|
188
|
+
res.json({
|
|
189
|
+
success: true,
|
|
190
|
+
controlCount: processedData.controls.length,
|
|
191
|
+
families: Array.from(processedData.families.keys()),
|
|
192
|
+
outputDir: result.folderName
|
|
175
193
|
});
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.error("Error processing spreadsheet:", error);
|
|
196
|
+
res.status(500).json({ error: "Failed to process spreadsheet" });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
function processSpreadsheetData(rawData, headers, startRowIndex, params) {
|
|
200
|
+
const controls = [];
|
|
201
|
+
const families = /* @__PURE__ */ new Map();
|
|
202
|
+
const fieldMetadata = /* @__PURE__ */ new Map();
|
|
203
|
+
headers.forEach((header) => {
|
|
204
|
+
if (header) {
|
|
205
|
+
const cleanName = applyNamingConvention(header, params.namingConvention);
|
|
206
|
+
fieldMetadata.set(cleanName, {
|
|
207
|
+
originalName: header,
|
|
208
|
+
cleanName,
|
|
209
|
+
type: "string",
|
|
210
|
+
maxLength: 0,
|
|
211
|
+
hasMultipleLines: false,
|
|
212
|
+
uniqueValues: /* @__PURE__ */ new Set(),
|
|
213
|
+
emptyCount: 0,
|
|
214
|
+
totalCount: 0,
|
|
215
|
+
examples: []
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
for (let i = startRowIndex + 1; i < rawData.length; i++) {
|
|
220
|
+
const row = rawData[i];
|
|
221
|
+
if (!row || row.length === 0) continue;
|
|
222
|
+
const control = {};
|
|
223
|
+
let hasData = false;
|
|
224
|
+
headers.forEach((header, index) => {
|
|
225
|
+
if (header && row[index] !== void 0 && row[index] !== null) {
|
|
226
|
+
const value = typeof row[index] === "string" ? row[index].trim() : row[index];
|
|
227
|
+
const fieldName = applyNamingConvention(header, params.namingConvention);
|
|
228
|
+
const metadata = fieldMetadata.get(fieldName);
|
|
229
|
+
metadata.totalCount++;
|
|
230
|
+
if (value === "" || value === null || value === void 0) {
|
|
231
|
+
metadata.emptyCount++;
|
|
232
|
+
if (params.skipEmpty) return;
|
|
233
|
+
} else {
|
|
234
|
+
const normalizedValue = typeof value === "string" ? value.trim() : value;
|
|
235
|
+
if (normalizedValue !== "") {
|
|
236
|
+
metadata.uniqueValues.add(normalizedValue);
|
|
237
|
+
}
|
|
238
|
+
const valueType = detectValueType(value);
|
|
239
|
+
if (metadata.type === "string" || metadata.totalCount === 1) {
|
|
240
|
+
metadata.type = valueType;
|
|
241
|
+
} else if (metadata.type !== valueType) {
|
|
242
|
+
metadata.type = "mixed";
|
|
243
|
+
}
|
|
244
|
+
if (typeof value === "string") {
|
|
245
|
+
const length = value.length;
|
|
246
|
+
if (length > metadata.maxLength) {
|
|
247
|
+
metadata.maxLength = length;
|
|
209
248
|
}
|
|
210
|
-
if (
|
|
211
|
-
metadata.
|
|
249
|
+
if (value.includes("\n") || length > 100) {
|
|
250
|
+
metadata.hasMultipleLines = true;
|
|
212
251
|
}
|
|
213
252
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
});
|
|
218
|
-
if (hasData && (!skipEmptyRows || Object.keys(control).length > 0)) {
|
|
219
|
-
const controlIdFieldName = applyNamingConvention(controlIdField, namingConvention);
|
|
220
|
-
const controlId = control[controlIdFieldName];
|
|
221
|
-
if (!controlId) {
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
const family = extractFamilyFromControlId(controlId);
|
|
225
|
-
control.family = family;
|
|
226
|
-
controls.push(control);
|
|
227
|
-
if (!families.has(family)) {
|
|
228
|
-
families.set(family, []);
|
|
253
|
+
if (metadata.examples.length < 3 && normalizedValue !== "") {
|
|
254
|
+
metadata.examples.push(normalizedValue);
|
|
255
|
+
}
|
|
229
256
|
}
|
|
230
|
-
|
|
257
|
+
control[fieldName] = value;
|
|
258
|
+
hasData = true;
|
|
231
259
|
}
|
|
260
|
+
});
|
|
261
|
+
if (hasData && (!params.skipEmptyRows || Object.keys(control).length > 0)) {
|
|
262
|
+
const controlIdFieldName = applyNamingConvention(
|
|
263
|
+
params.controlIdField,
|
|
264
|
+
params.namingConvention
|
|
265
|
+
);
|
|
266
|
+
const controlId = control[controlIdFieldName];
|
|
267
|
+
if (!controlId) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const family = extractFamilyFromControlId(controlId);
|
|
271
|
+
control.family = family;
|
|
272
|
+
controls.push(control);
|
|
273
|
+
if (!families.has(family)) {
|
|
274
|
+
families.set(family, []);
|
|
275
|
+
}
|
|
276
|
+
families.get(family).push(control);
|
|
232
277
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
278
|
+
}
|
|
279
|
+
return { controls, families, fieldMetadata };
|
|
280
|
+
}
|
|
281
|
+
function buildFieldSchema(fieldMetadata, controls, params, families) {
|
|
282
|
+
const fields = {};
|
|
283
|
+
let displayOrder = 1;
|
|
284
|
+
const controlIdFieldNameClean = applyNamingConvention(
|
|
285
|
+
params.controlIdField,
|
|
286
|
+
params.namingConvention
|
|
287
|
+
);
|
|
288
|
+
const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
|
|
289
|
+
fields["family"] = {
|
|
290
|
+
type: "string",
|
|
291
|
+
ui_type: familyOptions.length <= 50 ? "select" : "short_text",
|
|
292
|
+
is_array: false,
|
|
293
|
+
max_length: 10,
|
|
294
|
+
usage_count: controls.length,
|
|
295
|
+
usage_percentage: 100,
|
|
296
|
+
required: true,
|
|
297
|
+
visible: true,
|
|
298
|
+
show_in_table: true,
|
|
299
|
+
editable: false,
|
|
300
|
+
display_order: displayOrder++,
|
|
301
|
+
category: "core",
|
|
302
|
+
tab: "overview"
|
|
303
|
+
};
|
|
304
|
+
if (familyOptions.length <= 50) {
|
|
305
|
+
fields["family"].options = familyOptions;
|
|
306
|
+
}
|
|
307
|
+
fieldMetadata.forEach((metadata, fieldName) => {
|
|
308
|
+
if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const frontendConfig = params.frontendFieldSchema?.find((f) => f.fieldName === fieldName);
|
|
312
|
+
if (params.frontendFieldSchema && !frontendConfig) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const usageCount = metadata.totalCount - metadata.emptyCount;
|
|
316
|
+
const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
|
|
317
|
+
let uiType = "short_text";
|
|
318
|
+
const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
|
|
319
|
+
const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
|
|
320
|
+
nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
|
|
321
|
+
metadata.maxLength <= 100 && // Reasonably short values only
|
|
322
|
+
metadata.uniqueValues.size / nonEmptyCount <= 0.3;
|
|
323
|
+
if (metadata.hasMultipleLines || metadata.maxLength > 500) {
|
|
324
|
+
uiType = "textarea";
|
|
325
|
+
} else if (isDropdownCandidate) {
|
|
326
|
+
uiType = "select";
|
|
327
|
+
} else if (metadata.type === "boolean") {
|
|
328
|
+
uiType = "checkbox";
|
|
329
|
+
} else if (metadata.type === "number") {
|
|
330
|
+
uiType = "number";
|
|
331
|
+
} else if (metadata.type === "date") {
|
|
332
|
+
uiType = "date";
|
|
333
|
+
} else if (metadata.maxLength <= 50) {
|
|
334
|
+
uiType = "short_text";
|
|
335
|
+
} else if (metadata.maxLength <= 200) {
|
|
336
|
+
uiType = "medium_text";
|
|
337
|
+
} else {
|
|
338
|
+
uiType = "long_text";
|
|
238
339
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
340
|
+
let category = frontendConfig?.category || "custom";
|
|
341
|
+
if (!frontendConfig) {
|
|
342
|
+
if (fieldName.includes("status") || fieldName.includes("state")) {
|
|
343
|
+
category = "compliance";
|
|
344
|
+
} else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
|
|
345
|
+
category = "core";
|
|
346
|
+
} else if (fieldName.includes("note") || fieldName.includes("comment")) {
|
|
347
|
+
category = "notes";
|
|
245
348
|
}
|
|
246
349
|
}
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
fields["family"] = {
|
|
252
|
-
type: "string",
|
|
253
|
-
ui_type: familyOptions.length <= 50 ? "select" : "short_text",
|
|
254
|
-
// Make select if reasonable number of families
|
|
350
|
+
const isControlIdField = fieldName === controlIdFieldNameClean;
|
|
351
|
+
const fieldDef = {
|
|
352
|
+
type: metadata.type,
|
|
353
|
+
ui_type: uiType,
|
|
255
354
|
is_array: false,
|
|
256
|
-
max_length:
|
|
257
|
-
usage_count:
|
|
258
|
-
usage_percentage:
|
|
259
|
-
required: true,
|
|
260
|
-
visible:
|
|
261
|
-
show_in_table: true,
|
|
262
|
-
editable: false,
|
|
263
|
-
display_order: displayOrder++,
|
|
264
|
-
category: "core",
|
|
265
|
-
tab: "overview"
|
|
355
|
+
max_length: metadata.maxLength,
|
|
356
|
+
usage_count: usageCount,
|
|
357
|
+
usage_percentage: usagePercentage,
|
|
358
|
+
required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
|
|
359
|
+
visible: frontendConfig?.tab !== "hidden",
|
|
360
|
+
show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
|
|
361
|
+
editable: isControlIdField ? false : true,
|
|
362
|
+
display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
|
|
363
|
+
category: isControlIdField ? "core" : category,
|
|
364
|
+
tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
|
|
266
365
|
};
|
|
267
|
-
if (
|
|
268
|
-
|
|
366
|
+
if (uiType === "select") {
|
|
367
|
+
fieldDef.options = Array.from(metadata.uniqueValues).sort();
|
|
269
368
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
369
|
+
if (frontendConfig?.originalName || metadata.originalName) {
|
|
370
|
+
fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
|
|
371
|
+
}
|
|
372
|
+
fields[fieldName] = fieldDef;
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
fields,
|
|
376
|
+
total_controls: controls.length,
|
|
377
|
+
analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
async function createOutputStructure(processedData, fieldSchema, params) {
|
|
381
|
+
const { controls, families } = processedData;
|
|
382
|
+
const state = getServerState();
|
|
383
|
+
const folderName = toKebabCase(params.controlSetName || "imported-controls");
|
|
384
|
+
const baseDir = join2(state.CONTROL_SET_DIR || process.cwd(), folderName);
|
|
385
|
+
if (!existsSync(baseDir)) {
|
|
386
|
+
mkdirSync(baseDir, { recursive: true });
|
|
387
|
+
}
|
|
388
|
+
const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
|
|
389
|
+
const controlIdFieldNameClean = applyNamingConvention(
|
|
390
|
+
params.controlIdField,
|
|
391
|
+
params.namingConvention
|
|
392
|
+
);
|
|
393
|
+
const controlSetData = {
|
|
394
|
+
name: params.controlSetName,
|
|
395
|
+
description: params.controlSetDescription,
|
|
396
|
+
version: "1.0.0",
|
|
397
|
+
control_id_field: controlIdFieldNameClean,
|
|
398
|
+
controlCount: controls.length,
|
|
399
|
+
families: uniqueFamilies,
|
|
400
|
+
fieldSchema
|
|
401
|
+
};
|
|
402
|
+
writeFileSync(join2(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
|
|
403
|
+
const controlsDir = join2(baseDir, "controls");
|
|
404
|
+
const mappingsDir = join2(baseDir, "mappings");
|
|
405
|
+
families.forEach((familyControls, family) => {
|
|
406
|
+
const familyDir = join2(controlsDir, family);
|
|
407
|
+
const familyMappingsDir = join2(mappingsDir, family);
|
|
408
|
+
if (!existsSync(familyDir)) {
|
|
409
|
+
mkdirSync(familyDir, { recursive: true });
|
|
410
|
+
}
|
|
411
|
+
if (!existsSync(familyMappingsDir)) {
|
|
412
|
+
mkdirSync(familyMappingsDir, { recursive: true });
|
|
413
|
+
}
|
|
414
|
+
familyControls.forEach((control) => {
|
|
415
|
+
const controlId = control[controlIdFieldNameClean];
|
|
416
|
+
if (!controlId) {
|
|
417
|
+
console.error("Missing control ID for control:", control);
|
|
276
418
|
return;
|
|
277
419
|
}
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
} else if (isDropdownCandidate) {
|
|
289
|
-
uiType = "select";
|
|
290
|
-
} else if (metadata.type === "boolean") {
|
|
291
|
-
uiType = "checkbox";
|
|
292
|
-
} else if (metadata.type === "number") {
|
|
293
|
-
uiType = "number";
|
|
294
|
-
} else if (metadata.type === "date") {
|
|
295
|
-
uiType = "date";
|
|
296
|
-
} else if (metadata.maxLength <= 50) {
|
|
297
|
-
uiType = "short_text";
|
|
298
|
-
} else if (metadata.maxLength <= 200) {
|
|
299
|
-
uiType = "medium_text";
|
|
300
|
-
} else {
|
|
301
|
-
uiType = "long_text";
|
|
302
|
-
}
|
|
303
|
-
let category = frontendConfig?.category || "custom";
|
|
304
|
-
if (!frontendConfig) {
|
|
305
|
-
if (fieldName.includes("status") || fieldName.includes("state")) {
|
|
306
|
-
category = "compliance";
|
|
307
|
-
} else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
|
|
308
|
-
category = "core";
|
|
309
|
-
} else if (fieldName.includes("note") || fieldName.includes("comment")) {
|
|
310
|
-
category = "notes";
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
const isControlIdField = fieldName === controlIdFieldNameClean;
|
|
314
|
-
const fieldDef = {
|
|
315
|
-
type: metadata.type,
|
|
316
|
-
ui_type: uiType,
|
|
317
|
-
is_array: false,
|
|
318
|
-
max_length: metadata.maxLength,
|
|
319
|
-
usage_count: usageCount,
|
|
320
|
-
usage_percentage: usagePercentage,
|
|
321
|
-
required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
|
|
322
|
-
// Control ID is always required
|
|
323
|
-
visible: frontendConfig?.tab !== "hidden",
|
|
324
|
-
show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
|
|
325
|
-
// Always show control ID in table
|
|
326
|
-
editable: isControlIdField ? false : true,
|
|
327
|
-
// Control ID is not editable
|
|
328
|
-
display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
|
|
329
|
-
// Control ID is always first
|
|
330
|
-
category: isControlIdField ? "core" : category,
|
|
331
|
-
// Control ID is always core
|
|
332
|
-
tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
|
|
333
|
-
// Use frontend config or default
|
|
420
|
+
const controlIdStr = String(controlId).slice(0, 50);
|
|
421
|
+
const fileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
|
|
422
|
+
const filePath = join2(familyDir, fileName);
|
|
423
|
+
const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
|
|
424
|
+
const mappingFilePath = join2(familyMappingsDir, mappingFileName);
|
|
425
|
+
const filteredControl = {};
|
|
426
|
+
const mappingData = {
|
|
427
|
+
control_id: controlIdStr,
|
|
428
|
+
justification: "",
|
|
429
|
+
uuid: crypto.randomUUID()
|
|
334
430
|
};
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (frontendConfig?.originalName || metadata.originalName) {
|
|
339
|
-
fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
|
|
340
|
-
}
|
|
341
|
-
fields[fieldName] = fieldDef;
|
|
342
|
-
});
|
|
343
|
-
const fieldSchema = {
|
|
344
|
-
fields,
|
|
345
|
-
total_controls: controls.length,
|
|
346
|
-
analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
347
|
-
};
|
|
348
|
-
const controlSetData = {
|
|
349
|
-
name: controlSetName,
|
|
350
|
-
description: controlSetDescription,
|
|
351
|
-
version: "1.0.0",
|
|
352
|
-
control_id_field: controlIdFieldNameClean,
|
|
353
|
-
// Add this to indicate which field is the control ID
|
|
354
|
-
controlCount: controls.length,
|
|
355
|
-
families: uniqueFamilies,
|
|
356
|
-
fieldSchema
|
|
357
|
-
};
|
|
358
|
-
writeFileSync(join2(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
|
|
359
|
-
const controlsDir = join2(baseDir, "controls");
|
|
360
|
-
const mappingsDir = join2(baseDir, "mappings");
|
|
361
|
-
families.forEach((familyControls, family) => {
|
|
362
|
-
const familyDir = join2(controlsDir, family);
|
|
363
|
-
const familyMappingsDir = join2(mappingsDir, family);
|
|
364
|
-
if (!existsSync(familyDir)) {
|
|
365
|
-
mkdirSync(familyDir, { recursive: true });
|
|
366
|
-
}
|
|
367
|
-
if (!existsSync(familyMappingsDir)) {
|
|
368
|
-
mkdirSync(familyMappingsDir, { recursive: true });
|
|
431
|
+
const justificationContents = [];
|
|
432
|
+
if (control.family !== void 0) {
|
|
433
|
+
filteredControl.family = control.family;
|
|
369
434
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (
|
|
373
|
-
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
const controlIdStr = String(controlId).slice(0, 50);
|
|
377
|
-
const fileName2 = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
|
|
378
|
-
const filePath = join2(familyDir, fileName2);
|
|
379
|
-
const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
|
|
380
|
-
const mappingFilePath = join2(familyMappingsDir, mappingFileName);
|
|
381
|
-
const filteredControl = {};
|
|
382
|
-
const mappingData = {
|
|
383
|
-
control_id: controlIdStr,
|
|
384
|
-
justification: "",
|
|
385
|
-
uuid: crypto.randomUUID()
|
|
386
|
-
};
|
|
387
|
-
const justificationContents = [];
|
|
388
|
-
if (control.family !== void 0) {
|
|
389
|
-
filteredControl.family = control.family;
|
|
390
|
-
}
|
|
391
|
-
Object.keys(control).forEach((fieldName) => {
|
|
392
|
-
if (fieldName === "family") return;
|
|
393
|
-
if (justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
|
|
394
|
-
justificationContents.push(control[fieldName]);
|
|
395
|
-
}
|
|
396
|
-
const isInFrontendSchema = frontendFieldSchema?.some((f) => f.fieldName === fieldName);
|
|
397
|
-
const isInFieldsMetadata = fields.hasOwnProperty(fieldName);
|
|
398
|
-
if (isInFrontendSchema || isInFieldsMetadata) {
|
|
399
|
-
filteredControl[fieldName] = control[fieldName];
|
|
400
|
-
}
|
|
401
|
-
});
|
|
402
|
-
writeFileSync(filePath, yaml4.dump(filteredControl));
|
|
403
|
-
if (justificationContents.length > 0) {
|
|
404
|
-
mappingData.justification = justificationContents.join("\n\n");
|
|
435
|
+
Object.keys(control).forEach((fieldName) => {
|
|
436
|
+
if (fieldName === "family") return;
|
|
437
|
+
if (params.justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
|
|
438
|
+
justificationContents.push(control[fieldName]);
|
|
405
439
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
440
|
+
const isInFrontendSchema = params.frontendFieldSchema?.some(
|
|
441
|
+
(f) => f.fieldName === fieldName
|
|
442
|
+
);
|
|
443
|
+
const isInFieldsMetadata = fieldSchema.fields.hasOwnProperty(fieldName);
|
|
444
|
+
if (isInFrontendSchema || isInFieldsMetadata) {
|
|
445
|
+
filteredControl[fieldName] = control[fieldName];
|
|
409
446
|
}
|
|
410
447
|
});
|
|
448
|
+
writeFileSync(filePath, yaml4.dump(filteredControl));
|
|
449
|
+
if (justificationContents.length > 0) {
|
|
450
|
+
mappingData.justification = justificationContents.join("\n\n");
|
|
451
|
+
}
|
|
452
|
+
if (mappingData.justification && mappingData.justification.trim() !== "") {
|
|
453
|
+
const mappingArray = [mappingData];
|
|
454
|
+
writeFileSync(mappingFilePath, yaml4.dump(mappingArray));
|
|
455
|
+
}
|
|
411
456
|
});
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
families: Array.from(families.keys()),
|
|
416
|
-
outputDir: folderName
|
|
417
|
-
// Return just the folder name, not full path
|
|
418
|
-
});
|
|
419
|
-
} catch (error) {
|
|
420
|
-
console.error("Error processing spreadsheet:", error);
|
|
421
|
-
res.status(500).json({ error: "Failed to process spreadsheet" });
|
|
422
|
-
}
|
|
423
|
-
});
|
|
457
|
+
});
|
|
458
|
+
return { folderName };
|
|
459
|
+
}
|
|
424
460
|
function applyNamingConvention(fieldName, convention) {
|
|
425
461
|
if (!fieldName) return fieldName;
|
|
426
462
|
const cleanedName = fieldName.trim();
|
|
@@ -1039,6 +1075,18 @@ router.post("/parse-excel-sheet", upload.single("file"), async (req, res) => {
|
|
|
1039
1075
|
});
|
|
1040
1076
|
var spreadsheetRoutes_default = router;
|
|
1041
1077
|
export {
|
|
1078
|
+
applyNamingConvention,
|
|
1079
|
+
buildFieldSchema,
|
|
1080
|
+
createOutputStructure,
|
|
1042
1081
|
spreadsheetRoutes_default as default,
|
|
1043
|
-
|
|
1082
|
+
detectValueType,
|
|
1083
|
+
extractFamilyFromControlId,
|
|
1084
|
+
parseCSV,
|
|
1085
|
+
parseUploadedFile,
|
|
1086
|
+
processImportParameters,
|
|
1087
|
+
processSpreadsheetData,
|
|
1088
|
+
scanControlSets,
|
|
1089
|
+
toCamelCase,
|
|
1090
|
+
toKebabCase,
|
|
1091
|
+
toSnakeCase
|
|
1044
1092
|
};
|