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
|
@@ -1273,8 +1273,20 @@ var init_serverState = __esm({
|
|
|
1273
1273
|
// cli/server/spreadsheetRoutes.ts
|
|
1274
1274
|
var spreadsheetRoutes_exports = {};
|
|
1275
1275
|
__export(spreadsheetRoutes_exports, {
|
|
1276
|
+
applyNamingConvention: () => applyNamingConvention,
|
|
1277
|
+
buildFieldSchema: () => buildFieldSchema,
|
|
1278
|
+
createOutputStructure: () => createOutputStructure,
|
|
1276
1279
|
default: () => spreadsheetRoutes_default,
|
|
1277
|
-
|
|
1280
|
+
detectValueType: () => detectValueType,
|
|
1281
|
+
extractFamilyFromControlId: () => extractFamilyFromControlId,
|
|
1282
|
+
parseCSV: () => parseCSV,
|
|
1283
|
+
parseUploadedFile: () => parseUploadedFile,
|
|
1284
|
+
processImportParameters: () => processImportParameters,
|
|
1285
|
+
processSpreadsheetData: () => processSpreadsheetData,
|
|
1286
|
+
scanControlSets: () => scanControlSets,
|
|
1287
|
+
toCamelCase: () => toCamelCase,
|
|
1288
|
+
toKebabCase: () => toKebabCase,
|
|
1289
|
+
toSnakeCase: () => toSnakeCase
|
|
1278
1290
|
});
|
|
1279
1291
|
import crypto from "crypto";
|
|
1280
1292
|
import { parse as parseCSVSync } from "csv-parse/sync";
|
|
@@ -1323,6 +1335,334 @@ async function scanControlSets() {
|
|
|
1323
1335
|
}).filter((cs) => cs !== null);
|
|
1324
1336
|
return { controlSets };
|
|
1325
1337
|
}
|
|
1338
|
+
function processImportParameters(reqBody) {
|
|
1339
|
+
const {
|
|
1340
|
+
controlIdField = "Control ID",
|
|
1341
|
+
startRow = "1",
|
|
1342
|
+
controlSetName = "Imported Control Set",
|
|
1343
|
+
controlSetDescription = "Imported from spreadsheet"
|
|
1344
|
+
} = reqBody;
|
|
1345
|
+
let justificationFields = [];
|
|
1346
|
+
if (reqBody.justificationFields) {
|
|
1347
|
+
try {
|
|
1348
|
+
justificationFields = JSON.parse(reqBody.justificationFields);
|
|
1349
|
+
debug("Justification fields received:", justificationFields);
|
|
1350
|
+
} catch (e) {
|
|
1351
|
+
console.error("Failed to parse justification fields:", e);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
let frontendFieldSchema = null;
|
|
1355
|
+
if (reqBody.fieldSchema) {
|
|
1356
|
+
try {
|
|
1357
|
+
frontendFieldSchema = JSON.parse(reqBody.fieldSchema);
|
|
1358
|
+
} catch (e) {
|
|
1359
|
+
console.error("Failed to parse fieldSchema:", e);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
debug("Import parameters received:", {
|
|
1363
|
+
controlIdField,
|
|
1364
|
+
startRow,
|
|
1365
|
+
controlSetName,
|
|
1366
|
+
controlSetDescription
|
|
1367
|
+
});
|
|
1368
|
+
return {
|
|
1369
|
+
controlIdField,
|
|
1370
|
+
startRow,
|
|
1371
|
+
controlSetName,
|
|
1372
|
+
controlSetDescription,
|
|
1373
|
+
justificationFields,
|
|
1374
|
+
namingConvention: "kebab-case",
|
|
1375
|
+
skipEmpty: true,
|
|
1376
|
+
skipEmptyRows: true,
|
|
1377
|
+
frontendFieldSchema
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
async function parseUploadedFile(file) {
|
|
1381
|
+
const fileName = file.originalname || "";
|
|
1382
|
+
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
1383
|
+
let rawData = [];
|
|
1384
|
+
if (isCSV) {
|
|
1385
|
+
const csvContent = file.buffer.toString("utf-8");
|
|
1386
|
+
rawData = parseCSV(csvContent);
|
|
1387
|
+
} else {
|
|
1388
|
+
const workbook = new ExcelJS.Workbook();
|
|
1389
|
+
const buffer = Buffer.from(file.buffer);
|
|
1390
|
+
await workbook.xlsx.load(buffer);
|
|
1391
|
+
const worksheet = workbook.worksheets[0];
|
|
1392
|
+
if (!worksheet) {
|
|
1393
|
+
throw new Error("No worksheet found in file");
|
|
1394
|
+
}
|
|
1395
|
+
worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
|
1396
|
+
const rowData = [];
|
|
1397
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
1398
|
+
rowData[colNumber - 1] = cell.value;
|
|
1399
|
+
});
|
|
1400
|
+
rawData[rowNumber - 1] = rowData;
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
return rawData;
|
|
1404
|
+
}
|
|
1405
|
+
function processSpreadsheetData(rawData, headers, startRowIndex, params) {
|
|
1406
|
+
const controls = [];
|
|
1407
|
+
const families = /* @__PURE__ */ new Map();
|
|
1408
|
+
const fieldMetadata = /* @__PURE__ */ new Map();
|
|
1409
|
+
headers.forEach((header) => {
|
|
1410
|
+
if (header) {
|
|
1411
|
+
const cleanName = applyNamingConvention(header, params.namingConvention);
|
|
1412
|
+
fieldMetadata.set(cleanName, {
|
|
1413
|
+
originalName: header,
|
|
1414
|
+
cleanName,
|
|
1415
|
+
type: "string",
|
|
1416
|
+
maxLength: 0,
|
|
1417
|
+
hasMultipleLines: false,
|
|
1418
|
+
uniqueValues: /* @__PURE__ */ new Set(),
|
|
1419
|
+
emptyCount: 0,
|
|
1420
|
+
totalCount: 0,
|
|
1421
|
+
examples: []
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
for (let i = startRowIndex + 1; i < rawData.length; i++) {
|
|
1426
|
+
const row = rawData[i];
|
|
1427
|
+
if (!row || row.length === 0) continue;
|
|
1428
|
+
const control = {};
|
|
1429
|
+
let hasData = false;
|
|
1430
|
+
headers.forEach((header, index) => {
|
|
1431
|
+
if (header && row[index] !== void 0 && row[index] !== null) {
|
|
1432
|
+
const value = typeof row[index] === "string" ? row[index].trim() : row[index];
|
|
1433
|
+
const fieldName = applyNamingConvention(header, params.namingConvention);
|
|
1434
|
+
const metadata = fieldMetadata.get(fieldName);
|
|
1435
|
+
metadata.totalCount++;
|
|
1436
|
+
if (value === "" || value === null || value === void 0) {
|
|
1437
|
+
metadata.emptyCount++;
|
|
1438
|
+
if (params.skipEmpty) return;
|
|
1439
|
+
} else {
|
|
1440
|
+
const normalizedValue = typeof value === "string" ? value.trim() : value;
|
|
1441
|
+
if (normalizedValue !== "") {
|
|
1442
|
+
metadata.uniqueValues.add(normalizedValue);
|
|
1443
|
+
}
|
|
1444
|
+
const valueType = detectValueType(value);
|
|
1445
|
+
if (metadata.type === "string" || metadata.totalCount === 1) {
|
|
1446
|
+
metadata.type = valueType;
|
|
1447
|
+
} else if (metadata.type !== valueType) {
|
|
1448
|
+
metadata.type = "mixed";
|
|
1449
|
+
}
|
|
1450
|
+
if (typeof value === "string") {
|
|
1451
|
+
const length = value.length;
|
|
1452
|
+
if (length > metadata.maxLength) {
|
|
1453
|
+
metadata.maxLength = length;
|
|
1454
|
+
}
|
|
1455
|
+
if (value.includes("\n") || length > 100) {
|
|
1456
|
+
metadata.hasMultipleLines = true;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (metadata.examples.length < 3 && normalizedValue !== "") {
|
|
1460
|
+
metadata.examples.push(normalizedValue);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
control[fieldName] = value;
|
|
1464
|
+
hasData = true;
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
if (hasData && (!params.skipEmptyRows || Object.keys(control).length > 0)) {
|
|
1468
|
+
const controlIdFieldName = applyNamingConvention(
|
|
1469
|
+
params.controlIdField,
|
|
1470
|
+
params.namingConvention
|
|
1471
|
+
);
|
|
1472
|
+
const controlId = control[controlIdFieldName];
|
|
1473
|
+
if (!controlId) {
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
const family = extractFamilyFromControlId(controlId);
|
|
1477
|
+
control.family = family;
|
|
1478
|
+
controls.push(control);
|
|
1479
|
+
if (!families.has(family)) {
|
|
1480
|
+
families.set(family, []);
|
|
1481
|
+
}
|
|
1482
|
+
families.get(family).push(control);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
return { controls, families, fieldMetadata };
|
|
1486
|
+
}
|
|
1487
|
+
function buildFieldSchema(fieldMetadata, controls, params, families) {
|
|
1488
|
+
const fields = {};
|
|
1489
|
+
let displayOrder = 1;
|
|
1490
|
+
const controlIdFieldNameClean = applyNamingConvention(
|
|
1491
|
+
params.controlIdField,
|
|
1492
|
+
params.namingConvention
|
|
1493
|
+
);
|
|
1494
|
+
const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
|
|
1495
|
+
fields["family"] = {
|
|
1496
|
+
type: "string",
|
|
1497
|
+
ui_type: familyOptions.length <= 50 ? "select" : "short_text",
|
|
1498
|
+
is_array: false,
|
|
1499
|
+
max_length: 10,
|
|
1500
|
+
usage_count: controls.length,
|
|
1501
|
+
usage_percentage: 100,
|
|
1502
|
+
required: true,
|
|
1503
|
+
visible: true,
|
|
1504
|
+
show_in_table: true,
|
|
1505
|
+
editable: false,
|
|
1506
|
+
display_order: displayOrder++,
|
|
1507
|
+
category: "core",
|
|
1508
|
+
tab: "overview"
|
|
1509
|
+
};
|
|
1510
|
+
if (familyOptions.length <= 50) {
|
|
1511
|
+
fields["family"].options = familyOptions;
|
|
1512
|
+
}
|
|
1513
|
+
fieldMetadata.forEach((metadata, fieldName) => {
|
|
1514
|
+
if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const frontendConfig = params.frontendFieldSchema?.find((f) => f.fieldName === fieldName);
|
|
1518
|
+
if (params.frontendFieldSchema && !frontendConfig) {
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
const usageCount = metadata.totalCount - metadata.emptyCount;
|
|
1522
|
+
const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
|
|
1523
|
+
let uiType = "short_text";
|
|
1524
|
+
const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
|
|
1525
|
+
const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
|
|
1526
|
+
nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
|
|
1527
|
+
metadata.maxLength <= 100 && // Reasonably short values only
|
|
1528
|
+
metadata.uniqueValues.size / nonEmptyCount <= 0.3;
|
|
1529
|
+
if (metadata.hasMultipleLines || metadata.maxLength > 500) {
|
|
1530
|
+
uiType = "textarea";
|
|
1531
|
+
} else if (isDropdownCandidate) {
|
|
1532
|
+
uiType = "select";
|
|
1533
|
+
} else if (metadata.type === "boolean") {
|
|
1534
|
+
uiType = "checkbox";
|
|
1535
|
+
} else if (metadata.type === "number") {
|
|
1536
|
+
uiType = "number";
|
|
1537
|
+
} else if (metadata.type === "date") {
|
|
1538
|
+
uiType = "date";
|
|
1539
|
+
} else if (metadata.maxLength <= 50) {
|
|
1540
|
+
uiType = "short_text";
|
|
1541
|
+
} else if (metadata.maxLength <= 200) {
|
|
1542
|
+
uiType = "medium_text";
|
|
1543
|
+
} else {
|
|
1544
|
+
uiType = "long_text";
|
|
1545
|
+
}
|
|
1546
|
+
let category = frontendConfig?.category || "custom";
|
|
1547
|
+
if (!frontendConfig) {
|
|
1548
|
+
if (fieldName.includes("status") || fieldName.includes("state")) {
|
|
1549
|
+
category = "compliance";
|
|
1550
|
+
} else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
|
|
1551
|
+
category = "core";
|
|
1552
|
+
} else if (fieldName.includes("note") || fieldName.includes("comment")) {
|
|
1553
|
+
category = "notes";
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
const isControlIdField = fieldName === controlIdFieldNameClean;
|
|
1557
|
+
const fieldDef = {
|
|
1558
|
+
type: metadata.type,
|
|
1559
|
+
ui_type: uiType,
|
|
1560
|
+
is_array: false,
|
|
1561
|
+
max_length: metadata.maxLength,
|
|
1562
|
+
usage_count: usageCount,
|
|
1563
|
+
usage_percentage: usagePercentage,
|
|
1564
|
+
required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
|
|
1565
|
+
visible: frontendConfig?.tab !== "hidden",
|
|
1566
|
+
show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
|
|
1567
|
+
editable: isControlIdField ? false : true,
|
|
1568
|
+
display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
|
|
1569
|
+
category: isControlIdField ? "core" : category,
|
|
1570
|
+
tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
|
|
1571
|
+
};
|
|
1572
|
+
if (uiType === "select") {
|
|
1573
|
+
fieldDef.options = Array.from(metadata.uniqueValues).sort();
|
|
1574
|
+
}
|
|
1575
|
+
if (frontendConfig?.originalName || metadata.originalName) {
|
|
1576
|
+
fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
|
|
1577
|
+
}
|
|
1578
|
+
fields[fieldName] = fieldDef;
|
|
1579
|
+
});
|
|
1580
|
+
return {
|
|
1581
|
+
fields,
|
|
1582
|
+
total_controls: controls.length,
|
|
1583
|
+
analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
async function createOutputStructure(processedData, fieldSchema, params) {
|
|
1587
|
+
const { controls, families } = processedData;
|
|
1588
|
+
const state = getServerState();
|
|
1589
|
+
const folderName = toKebabCase(params.controlSetName || "imported-controls");
|
|
1590
|
+
const baseDir = join4(state.CONTROL_SET_DIR || process.cwd(), folderName);
|
|
1591
|
+
if (!existsSync3(baseDir)) {
|
|
1592
|
+
mkdirSync2(baseDir, { recursive: true });
|
|
1593
|
+
}
|
|
1594
|
+
const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
|
|
1595
|
+
const controlIdFieldNameClean = applyNamingConvention(
|
|
1596
|
+
params.controlIdField,
|
|
1597
|
+
params.namingConvention
|
|
1598
|
+
);
|
|
1599
|
+
const controlSetData = {
|
|
1600
|
+
name: params.controlSetName,
|
|
1601
|
+
description: params.controlSetDescription,
|
|
1602
|
+
version: "1.0.0",
|
|
1603
|
+
control_id_field: controlIdFieldNameClean,
|
|
1604
|
+
controlCount: controls.length,
|
|
1605
|
+
families: uniqueFamilies,
|
|
1606
|
+
fieldSchema
|
|
1607
|
+
};
|
|
1608
|
+
writeFileSync2(join4(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
|
|
1609
|
+
const controlsDir = join4(baseDir, "controls");
|
|
1610
|
+
const mappingsDir = join4(baseDir, "mappings");
|
|
1611
|
+
families.forEach((familyControls, family) => {
|
|
1612
|
+
const familyDir = join4(controlsDir, family);
|
|
1613
|
+
const familyMappingsDir = join4(mappingsDir, family);
|
|
1614
|
+
if (!existsSync3(familyDir)) {
|
|
1615
|
+
mkdirSync2(familyDir, { recursive: true });
|
|
1616
|
+
}
|
|
1617
|
+
if (!existsSync3(familyMappingsDir)) {
|
|
1618
|
+
mkdirSync2(familyMappingsDir, { recursive: true });
|
|
1619
|
+
}
|
|
1620
|
+
familyControls.forEach((control) => {
|
|
1621
|
+
const controlId = control[controlIdFieldNameClean];
|
|
1622
|
+
if (!controlId) {
|
|
1623
|
+
console.error("Missing control ID for control:", control);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const controlIdStr = String(controlId).slice(0, 50);
|
|
1627
|
+
const fileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
|
|
1628
|
+
const filePath = join4(familyDir, fileName);
|
|
1629
|
+
const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
|
|
1630
|
+
const mappingFilePath = join4(familyMappingsDir, mappingFileName);
|
|
1631
|
+
const filteredControl = {};
|
|
1632
|
+
const mappingData = {
|
|
1633
|
+
control_id: controlIdStr,
|
|
1634
|
+
justification: "",
|
|
1635
|
+
uuid: crypto.randomUUID()
|
|
1636
|
+
};
|
|
1637
|
+
const justificationContents = [];
|
|
1638
|
+
if (control.family !== void 0) {
|
|
1639
|
+
filteredControl.family = control.family;
|
|
1640
|
+
}
|
|
1641
|
+
Object.keys(control).forEach((fieldName) => {
|
|
1642
|
+
if (fieldName === "family") return;
|
|
1643
|
+
if (params.justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
|
|
1644
|
+
justificationContents.push(control[fieldName]);
|
|
1645
|
+
}
|
|
1646
|
+
const isInFrontendSchema = params.frontendFieldSchema?.some(
|
|
1647
|
+
(f) => f.fieldName === fieldName
|
|
1648
|
+
);
|
|
1649
|
+
const isInFieldsMetadata = fieldSchema.fields.hasOwnProperty(fieldName);
|
|
1650
|
+
if (isInFrontendSchema || isInFieldsMetadata) {
|
|
1651
|
+
filteredControl[fieldName] = control[fieldName];
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
writeFileSync2(filePath, yaml4.dump(filteredControl));
|
|
1655
|
+
if (justificationContents.length > 0) {
|
|
1656
|
+
mappingData.justification = justificationContents.join("\n\n");
|
|
1657
|
+
}
|
|
1658
|
+
if (mappingData.justification && mappingData.justification.trim() !== "") {
|
|
1659
|
+
const mappingArray = [mappingData];
|
|
1660
|
+
writeFileSync2(mappingFilePath, yaml4.dump(mappingArray));
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
});
|
|
1664
|
+
return { folderName };
|
|
1665
|
+
}
|
|
1326
1666
|
function applyNamingConvention(fieldName, convention) {
|
|
1327
1667
|
if (!fieldName) return fieldName;
|
|
1328
1668
|
const cleanedName = fieldName.trim();
|
|
@@ -1703,53 +2043,9 @@ var init_spreadsheetRoutes = __esm({
|
|
|
1703
2043
|
if (!req.file) {
|
|
1704
2044
|
return res.status(400).json({ error: "No file uploaded" });
|
|
1705
2045
|
}
|
|
1706
|
-
const
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
controlSetName = "Imported Control Set",
|
|
1710
|
-
controlSetDescription = "Imported from spreadsheet"
|
|
1711
|
-
} = req.body;
|
|
1712
|
-
let justificationFields = [];
|
|
1713
|
-
if (req.body.justificationFields) {
|
|
1714
|
-
try {
|
|
1715
|
-
justificationFields = JSON.parse(req.body.justificationFields);
|
|
1716
|
-
debug("Justification fields received:", justificationFields);
|
|
1717
|
-
} catch (e) {
|
|
1718
|
-
console.error("Failed to parse justification fields:", e);
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
debug("Import parameters received:", {
|
|
1722
|
-
controlIdField,
|
|
1723
|
-
startRow,
|
|
1724
|
-
controlSetName,
|
|
1725
|
-
controlSetDescription
|
|
1726
|
-
});
|
|
1727
|
-
const namingConvention = "kebab-case";
|
|
1728
|
-
const skipEmpty = true;
|
|
1729
|
-
const skipEmptyRows = true;
|
|
1730
|
-
const fileName = req.file.originalname || "";
|
|
1731
|
-
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
1732
|
-
let rawData = [];
|
|
1733
|
-
if (isCSV) {
|
|
1734
|
-
const csvContent = req.file.buffer.toString("utf-8");
|
|
1735
|
-
rawData = parseCSV(csvContent);
|
|
1736
|
-
} else {
|
|
1737
|
-
const workbook = new ExcelJS.Workbook();
|
|
1738
|
-
const buffer = Buffer.from(req.file.buffer);
|
|
1739
|
-
await workbook.xlsx.load(buffer);
|
|
1740
|
-
const worksheet = workbook.worksheets[0];
|
|
1741
|
-
if (!worksheet) {
|
|
1742
|
-
return res.status(400).json({ error: "No worksheet found in file" });
|
|
1743
|
-
}
|
|
1744
|
-
worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
|
1745
|
-
const rowData = [];
|
|
1746
|
-
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
1747
|
-
rowData[colNumber - 1] = cell.value;
|
|
1748
|
-
});
|
|
1749
|
-
rawData[rowNumber - 1] = rowData;
|
|
1750
|
-
});
|
|
1751
|
-
}
|
|
1752
|
-
const startRowIndex = parseInt(startRow) - 1;
|
|
2046
|
+
const params = processImportParameters(req.body);
|
|
2047
|
+
const rawData = await parseUploadedFile(req.file);
|
|
2048
|
+
const startRowIndex = parseInt(params.startRow) - 1;
|
|
1753
2049
|
if (rawData.length <= startRowIndex) {
|
|
1754
2050
|
return res.status(400).json({ error: "Start row exceeds sheet data" });
|
|
1755
2051
|
}
|
|
@@ -1760,269 +2056,21 @@ var init_spreadsheetRoutes = __esm({
|
|
|
1760
2056
|
debug("Headers found:", headers);
|
|
1761
2057
|
debug(
|
|
1762
2058
|
"After conversion, looking for control ID field:",
|
|
1763
|
-
applyNamingConvention(controlIdField, namingConvention)
|
|
2059
|
+
applyNamingConvention(params.controlIdField, params.namingConvention)
|
|
1764
2060
|
);
|
|
1765
|
-
const
|
|
1766
|
-
const
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
cleanName,
|
|
1774
|
-
type: "string",
|
|
1775
|
-
maxLength: 0,
|
|
1776
|
-
hasMultipleLines: false,
|
|
1777
|
-
uniqueValues: /* @__PURE__ */ new Set(),
|
|
1778
|
-
emptyCount: 0,
|
|
1779
|
-
totalCount: 0,
|
|
1780
|
-
examples: []
|
|
1781
|
-
});
|
|
1782
|
-
}
|
|
1783
|
-
});
|
|
1784
|
-
for (let i = startRowIndex + 1; i < rawData.length; i++) {
|
|
1785
|
-
const row = rawData[i];
|
|
1786
|
-
if (!row || row.length === 0) continue;
|
|
1787
|
-
const control = {};
|
|
1788
|
-
let hasData = false;
|
|
1789
|
-
headers.forEach((header, index) => {
|
|
1790
|
-
if (header && row[index] !== void 0 && row[index] !== null) {
|
|
1791
|
-
const value = typeof row[index] === "string" ? row[index].trim() : row[index];
|
|
1792
|
-
const fieldName = applyNamingConvention(header, namingConvention);
|
|
1793
|
-
const metadata = fieldMetadata.get(fieldName);
|
|
1794
|
-
metadata.totalCount++;
|
|
1795
|
-
if (value === "" || value === null || value === void 0) {
|
|
1796
|
-
metadata.emptyCount++;
|
|
1797
|
-
if (skipEmpty) return;
|
|
1798
|
-
} else {
|
|
1799
|
-
const normalizedValue = typeof value === "string" ? value.trim() : value;
|
|
1800
|
-
if (normalizedValue !== "") {
|
|
1801
|
-
metadata.uniqueValues.add(normalizedValue);
|
|
1802
|
-
}
|
|
1803
|
-
const valueType = detectValueType(value);
|
|
1804
|
-
if (metadata.type === "string" || metadata.totalCount === 1) {
|
|
1805
|
-
metadata.type = valueType;
|
|
1806
|
-
} else if (metadata.type !== valueType) {
|
|
1807
|
-
metadata.type = "mixed";
|
|
1808
|
-
}
|
|
1809
|
-
if (typeof value === "string") {
|
|
1810
|
-
const length = value.length;
|
|
1811
|
-
if (length > metadata.maxLength) {
|
|
1812
|
-
metadata.maxLength = length;
|
|
1813
|
-
}
|
|
1814
|
-
if (value.includes("\n") || length > 100) {
|
|
1815
|
-
metadata.hasMultipleLines = true;
|
|
1816
|
-
}
|
|
1817
|
-
}
|
|
1818
|
-
if (metadata.examples.length < 3 && normalizedValue !== "") {
|
|
1819
|
-
metadata.examples.push(normalizedValue);
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
control[fieldName] = value;
|
|
1823
|
-
hasData = true;
|
|
1824
|
-
}
|
|
1825
|
-
});
|
|
1826
|
-
if (hasData && (!skipEmptyRows || Object.keys(control).length > 0)) {
|
|
1827
|
-
const controlIdFieldName = applyNamingConvention(controlIdField, namingConvention);
|
|
1828
|
-
const controlId = control[controlIdFieldName];
|
|
1829
|
-
if (!controlId) {
|
|
1830
|
-
continue;
|
|
1831
|
-
}
|
|
1832
|
-
const family = extractFamilyFromControlId(controlId);
|
|
1833
|
-
control.family = family;
|
|
1834
|
-
controls.push(control);
|
|
1835
|
-
if (!families.has(family)) {
|
|
1836
|
-
families.set(family, []);
|
|
1837
|
-
}
|
|
1838
|
-
families.get(family).push(control);
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
const state = getServerState();
|
|
1842
|
-
const folderName = toKebabCase(controlSetName || "imported-controls");
|
|
1843
|
-
const baseDir = join4(state.CONTROL_SET_DIR || process.cwd(), folderName);
|
|
1844
|
-
if (!existsSync3(baseDir)) {
|
|
1845
|
-
mkdirSync2(baseDir, { recursive: true });
|
|
1846
|
-
}
|
|
1847
|
-
const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
|
|
1848
|
-
let frontendFieldSchema = null;
|
|
1849
|
-
if (req.body.fieldSchema) {
|
|
1850
|
-
try {
|
|
1851
|
-
frontendFieldSchema = JSON.parse(req.body.fieldSchema);
|
|
1852
|
-
} catch {
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
const fields = {};
|
|
1856
|
-
let displayOrder = 1;
|
|
1857
|
-
const controlIdFieldNameClean = applyNamingConvention(controlIdField, namingConvention);
|
|
1858
|
-
const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
|
|
1859
|
-
fields["family"] = {
|
|
1860
|
-
type: "string",
|
|
1861
|
-
ui_type: familyOptions.length <= 50 ? "select" : "short_text",
|
|
1862
|
-
// Make select if reasonable number of families
|
|
1863
|
-
is_array: false,
|
|
1864
|
-
max_length: 10,
|
|
1865
|
-
usage_count: controls.length,
|
|
1866
|
-
usage_percentage: 100,
|
|
1867
|
-
required: true,
|
|
1868
|
-
visible: true,
|
|
1869
|
-
show_in_table: true,
|
|
1870
|
-
editable: false,
|
|
1871
|
-
display_order: displayOrder++,
|
|
1872
|
-
category: "core",
|
|
1873
|
-
tab: "overview"
|
|
1874
|
-
};
|
|
1875
|
-
if (familyOptions.length <= 50) {
|
|
1876
|
-
fields["family"].options = familyOptions;
|
|
1877
|
-
}
|
|
1878
|
-
fieldMetadata.forEach((metadata, fieldName) => {
|
|
1879
|
-
if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
|
|
1880
|
-
return;
|
|
1881
|
-
}
|
|
1882
|
-
const frontendConfig = frontendFieldSchema?.find((f) => f.fieldName === fieldName);
|
|
1883
|
-
if (frontendFieldSchema && !frontendConfig) {
|
|
1884
|
-
return;
|
|
1885
|
-
}
|
|
1886
|
-
const usageCount = metadata.totalCount - metadata.emptyCount;
|
|
1887
|
-
const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
|
|
1888
|
-
let uiType = "short_text";
|
|
1889
|
-
const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
|
|
1890
|
-
const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
|
|
1891
|
-
nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
|
|
1892
|
-
metadata.maxLength <= 100 && // Reasonably short values only
|
|
1893
|
-
metadata.uniqueValues.size / nonEmptyCount <= 0.3;
|
|
1894
|
-
if (metadata.hasMultipleLines || metadata.maxLength > 500) {
|
|
1895
|
-
uiType = "textarea";
|
|
1896
|
-
} else if (isDropdownCandidate) {
|
|
1897
|
-
uiType = "select";
|
|
1898
|
-
} else if (metadata.type === "boolean") {
|
|
1899
|
-
uiType = "checkbox";
|
|
1900
|
-
} else if (metadata.type === "number") {
|
|
1901
|
-
uiType = "number";
|
|
1902
|
-
} else if (metadata.type === "date") {
|
|
1903
|
-
uiType = "date";
|
|
1904
|
-
} else if (metadata.maxLength <= 50) {
|
|
1905
|
-
uiType = "short_text";
|
|
1906
|
-
} else if (metadata.maxLength <= 200) {
|
|
1907
|
-
uiType = "medium_text";
|
|
1908
|
-
} else {
|
|
1909
|
-
uiType = "long_text";
|
|
1910
|
-
}
|
|
1911
|
-
let category = frontendConfig?.category || "custom";
|
|
1912
|
-
if (!frontendConfig) {
|
|
1913
|
-
if (fieldName.includes("status") || fieldName.includes("state")) {
|
|
1914
|
-
category = "compliance";
|
|
1915
|
-
} else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
|
|
1916
|
-
category = "core";
|
|
1917
|
-
} else if (fieldName.includes("note") || fieldName.includes("comment")) {
|
|
1918
|
-
category = "notes";
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
const isControlIdField = fieldName === controlIdFieldNameClean;
|
|
1922
|
-
const fieldDef = {
|
|
1923
|
-
type: metadata.type,
|
|
1924
|
-
ui_type: uiType,
|
|
1925
|
-
is_array: false,
|
|
1926
|
-
max_length: metadata.maxLength,
|
|
1927
|
-
usage_count: usageCount,
|
|
1928
|
-
usage_percentage: usagePercentage,
|
|
1929
|
-
required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
|
|
1930
|
-
// Control ID is always required
|
|
1931
|
-
visible: frontendConfig?.tab !== "hidden",
|
|
1932
|
-
show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
|
|
1933
|
-
// Always show control ID in table
|
|
1934
|
-
editable: isControlIdField ? false : true,
|
|
1935
|
-
// Control ID is not editable
|
|
1936
|
-
display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
|
|
1937
|
-
// Control ID is always first
|
|
1938
|
-
category: isControlIdField ? "core" : category,
|
|
1939
|
-
// Control ID is always core
|
|
1940
|
-
tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
|
|
1941
|
-
// Use frontend config or default
|
|
1942
|
-
};
|
|
1943
|
-
if (uiType === "select") {
|
|
1944
|
-
fieldDef.options = Array.from(metadata.uniqueValues).sort();
|
|
1945
|
-
}
|
|
1946
|
-
if (frontendConfig?.originalName || metadata.originalName) {
|
|
1947
|
-
fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
|
|
1948
|
-
}
|
|
1949
|
-
fields[fieldName] = fieldDef;
|
|
1950
|
-
});
|
|
1951
|
-
const fieldSchema = {
|
|
1952
|
-
fields,
|
|
1953
|
-
total_controls: controls.length,
|
|
1954
|
-
analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1955
|
-
};
|
|
1956
|
-
const controlSetData = {
|
|
1957
|
-
name: controlSetName,
|
|
1958
|
-
description: controlSetDescription,
|
|
1959
|
-
version: "1.0.0",
|
|
1960
|
-
control_id_field: controlIdFieldNameClean,
|
|
1961
|
-
// Add this to indicate which field is the control ID
|
|
1962
|
-
controlCount: controls.length,
|
|
1963
|
-
families: uniqueFamilies,
|
|
1964
|
-
fieldSchema
|
|
1965
|
-
};
|
|
1966
|
-
writeFileSync2(join4(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
|
|
1967
|
-
const controlsDir = join4(baseDir, "controls");
|
|
1968
|
-
const mappingsDir = join4(baseDir, "mappings");
|
|
1969
|
-
families.forEach((familyControls, family) => {
|
|
1970
|
-
const familyDir = join4(controlsDir, family);
|
|
1971
|
-
const familyMappingsDir = join4(mappingsDir, family);
|
|
1972
|
-
if (!existsSync3(familyDir)) {
|
|
1973
|
-
mkdirSync2(familyDir, { recursive: true });
|
|
1974
|
-
}
|
|
1975
|
-
if (!existsSync3(familyMappingsDir)) {
|
|
1976
|
-
mkdirSync2(familyMappingsDir, { recursive: true });
|
|
1977
|
-
}
|
|
1978
|
-
familyControls.forEach((control) => {
|
|
1979
|
-
const controlId = control[controlIdFieldNameClean];
|
|
1980
|
-
if (!controlId) {
|
|
1981
|
-
console.error("Missing control ID for control:", control);
|
|
1982
|
-
return;
|
|
1983
|
-
}
|
|
1984
|
-
const controlIdStr = String(controlId).slice(0, 50);
|
|
1985
|
-
const fileName2 = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
|
|
1986
|
-
const filePath = join4(familyDir, fileName2);
|
|
1987
|
-
const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
|
|
1988
|
-
const mappingFilePath = join4(familyMappingsDir, mappingFileName);
|
|
1989
|
-
const filteredControl = {};
|
|
1990
|
-
const mappingData = {
|
|
1991
|
-
control_id: controlIdStr,
|
|
1992
|
-
justification: "",
|
|
1993
|
-
uuid: crypto.randomUUID()
|
|
1994
|
-
};
|
|
1995
|
-
const justificationContents = [];
|
|
1996
|
-
if (control.family !== void 0) {
|
|
1997
|
-
filteredControl.family = control.family;
|
|
1998
|
-
}
|
|
1999
|
-
Object.keys(control).forEach((fieldName) => {
|
|
2000
|
-
if (fieldName === "family") return;
|
|
2001
|
-
if (justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
|
|
2002
|
-
justificationContents.push(control[fieldName]);
|
|
2003
|
-
}
|
|
2004
|
-
const isInFrontendSchema = frontendFieldSchema?.some((f) => f.fieldName === fieldName);
|
|
2005
|
-
const isInFieldsMetadata = fields.hasOwnProperty(fieldName);
|
|
2006
|
-
if (isInFrontendSchema || isInFieldsMetadata) {
|
|
2007
|
-
filteredControl[fieldName] = control[fieldName];
|
|
2008
|
-
}
|
|
2009
|
-
});
|
|
2010
|
-
writeFileSync2(filePath, yaml4.dump(filteredControl));
|
|
2011
|
-
if (justificationContents.length > 0) {
|
|
2012
|
-
mappingData.justification = justificationContents.join("\n\n");
|
|
2013
|
-
}
|
|
2014
|
-
if (mappingData.justification && mappingData.justification.trim() !== "") {
|
|
2015
|
-
const mappingArray = [mappingData];
|
|
2016
|
-
writeFileSync2(mappingFilePath, yaml4.dump(mappingArray));
|
|
2017
|
-
}
|
|
2018
|
-
});
|
|
2019
|
-
});
|
|
2061
|
+
const processedData = processSpreadsheetData(rawData, headers, startRowIndex, params);
|
|
2062
|
+
const fieldSchema = buildFieldSchema(
|
|
2063
|
+
processedData.fieldMetadata,
|
|
2064
|
+
processedData.controls,
|
|
2065
|
+
params,
|
|
2066
|
+
processedData.families
|
|
2067
|
+
);
|
|
2068
|
+
const result = await createOutputStructure(processedData, fieldSchema, params);
|
|
2020
2069
|
res.json({
|
|
2021
2070
|
success: true,
|
|
2022
|
-
controlCount: controls.length,
|
|
2023
|
-
families: Array.from(families.keys()),
|
|
2024
|
-
outputDir: folderName
|
|
2025
|
-
// Return just the folder name, not full path
|
|
2071
|
+
controlCount: processedData.controls.length,
|
|
2072
|
+
families: Array.from(processedData.families.keys()),
|
|
2073
|
+
outputDir: result.folderName
|
|
2026
2074
|
});
|
|
2027
2075
|
} catch (error) {
|
|
2028
2076
|
console.error("Error processing spreadsheet:", error);
|