lula2 0.5.0-nightly.3 → 0.5.0-nightly.4

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.
@@ -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
- scanControlSets: () => scanControlSets
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
- controlIdField = "Control ID",
1708
- startRow = "1",
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 controls = [];
1766
- const families = /* @__PURE__ */ new Map();
1767
- const fieldMetadata = /* @__PURE__ */ new Map();
1768
- headers.forEach((header) => {
1769
- if (header) {
1770
- const cleanName = applyNamingConvention(header, namingConvention);
1771
- fieldMetadata.set(cleanName, {
1772
- originalName: header,
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);