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.
Files changed (33) hide show
  1. package/README.md +8 -0
  2. package/dist/_app/immutable/assets/{0.Dfpe5goI.css → 0.CJjXKESY.css} +1 -1
  3. package/dist/_app/immutable/chunks/Bvop-7hR.js +2 -0
  4. package/dist/_app/immutable/chunks/{BCoAVBju.js → BxuSJT7C.js} +1 -1
  5. package/dist/_app/immutable/chunks/{RxRVmDPY.js → C6hLAW8R.js} +1 -1
  6. package/dist/_app/immutable/chunks/{BN4ish10.js → CPEw6sZY.js} +1 -1
  7. package/dist/_app/immutable/chunks/{BtwnwKFn.js → CXdZVXJf.js} +1 -1
  8. package/dist/_app/immutable/chunks/CmNS-eRo.js +3 -0
  9. package/dist/_app/immutable/chunks/GToHgjp8.js +2 -0
  10. package/dist/_app/immutable/chunks/{DBN1r830.js → R1gz3SOr.js} +1 -1
  11. package/dist/_app/immutable/chunks/{D6NghQtU.js → zYrdvxnm.js} +1 -1
  12. package/dist/_app/immutable/entry/{app.DzkPo2gz.js → app.DnDpDSzX.js} +2 -2
  13. package/dist/_app/immutable/entry/start.9lsj4O5d.js +1 -0
  14. package/dist/_app/immutable/nodes/{0.BcLFbXUF.js → 0.KEcBS74O.js} +1 -1
  15. package/dist/_app/immutable/nodes/{1.8i0FdqjI.js → 1.BzTwWiVT.js} +1 -1
  16. package/dist/_app/immutable/nodes/{2.jit_WwBQ.js → 2.OvRLlZdO.js} +1 -1
  17. package/dist/_app/immutable/nodes/{3.CfrrpHWE.js → 3.q5RXDnSv.js} +1 -1
  18. package/dist/_app/immutable/nodes/{4.BZ-_Jk1v.js → 4.DCvpKEph.js} +1 -1
  19. package/dist/_app/version.json +1 -1
  20. package/dist/cli/commands/crawl.js +53 -1
  21. package/dist/cli/commands/ui.js +356 -308
  22. package/dist/cli/server/index.js +356 -308
  23. package/dist/cli/server/server.js +356 -308
  24. package/dist/cli/server/spreadsheetRoutes.js +343 -295
  25. package/dist/cli/server/websocketServer.js +356 -308
  26. package/dist/index.html +10 -10
  27. package/dist/index.js +408 -309
  28. package/package.json +22 -23
  29. package/src/lib/websocket.ts +106 -76
  30. package/dist/_app/immutable/chunks/B3DV5AB9.js +0 -2
  31. package/dist/_app/immutable/chunks/BIY1u1I9.js +0 -2
  32. package/dist/_app/immutable/chunks/CvviX0Gc.js +0 -3
  33. package/dist/_app/immutable/entry/start.BUtu9VJ8.js +0 -1
@@ -2826,8 +2826,20 @@ var init_serverState = __esm({
2826
2826
  // cli/server/spreadsheetRoutes.ts
2827
2827
  var spreadsheetRoutes_exports = {};
2828
2828
  __export(spreadsheetRoutes_exports, {
2829
+ applyNamingConvention: () => applyNamingConvention,
2830
+ buildFieldSchema: () => buildFieldSchema,
2831
+ createOutputStructure: () => createOutputStructure,
2829
2832
  default: () => spreadsheetRoutes_default,
2830
- scanControlSets: () => scanControlSets
2833
+ detectValueType: () => detectValueType,
2834
+ extractFamilyFromControlId: () => extractFamilyFromControlId,
2835
+ parseCSV: () => parseCSV,
2836
+ parseUploadedFile: () => parseUploadedFile,
2837
+ processImportParameters: () => processImportParameters,
2838
+ processSpreadsheetData: () => processSpreadsheetData,
2839
+ scanControlSets: () => scanControlSets,
2840
+ toCamelCase: () => toCamelCase,
2841
+ toKebabCase: () => toKebabCase,
2842
+ toSnakeCase: () => toSnakeCase
2831
2843
  });
2832
2844
  import crypto from "crypto";
2833
2845
  import { parse as parseCSVSync } from "csv-parse/sync";
@@ -2876,6 +2888,334 @@ async function scanControlSets() {
2876
2888
  }).filter((cs) => cs !== null);
2877
2889
  return { controlSets };
2878
2890
  }
2891
+ function processImportParameters(reqBody) {
2892
+ const {
2893
+ controlIdField = "Control ID",
2894
+ startRow = "1",
2895
+ controlSetName = "Imported Control Set",
2896
+ controlSetDescription = "Imported from spreadsheet"
2897
+ } = reqBody;
2898
+ let justificationFields = [];
2899
+ if (reqBody.justificationFields) {
2900
+ try {
2901
+ justificationFields = JSON.parse(reqBody.justificationFields);
2902
+ debug("Justification fields received:", justificationFields);
2903
+ } catch (e) {
2904
+ console.error("Failed to parse justification fields:", e);
2905
+ }
2906
+ }
2907
+ let frontendFieldSchema = null;
2908
+ if (reqBody.fieldSchema) {
2909
+ try {
2910
+ frontendFieldSchema = JSON.parse(reqBody.fieldSchema);
2911
+ } catch (e) {
2912
+ console.error("Failed to parse fieldSchema:", e);
2913
+ }
2914
+ }
2915
+ debug("Import parameters received:", {
2916
+ controlIdField,
2917
+ startRow,
2918
+ controlSetName,
2919
+ controlSetDescription
2920
+ });
2921
+ return {
2922
+ controlIdField,
2923
+ startRow,
2924
+ controlSetName,
2925
+ controlSetDescription,
2926
+ justificationFields,
2927
+ namingConvention: "kebab-case",
2928
+ skipEmpty: true,
2929
+ skipEmptyRows: true,
2930
+ frontendFieldSchema
2931
+ };
2932
+ }
2933
+ async function parseUploadedFile(file) {
2934
+ const fileName = file.originalname || "";
2935
+ const isCSV = fileName.toLowerCase().endsWith(".csv");
2936
+ let rawData = [];
2937
+ if (isCSV) {
2938
+ const csvContent = file.buffer.toString("utf-8");
2939
+ rawData = parseCSV(csvContent);
2940
+ } else {
2941
+ const workbook = new ExcelJS.Workbook();
2942
+ const buffer = Buffer.from(file.buffer);
2943
+ await workbook.xlsx.load(buffer);
2944
+ const worksheet = workbook.worksheets[0];
2945
+ if (!worksheet) {
2946
+ throw new Error("No worksheet found in file");
2947
+ }
2948
+ worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
2949
+ const rowData = [];
2950
+ row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
2951
+ rowData[colNumber - 1] = cell.value;
2952
+ });
2953
+ rawData[rowNumber - 1] = rowData;
2954
+ });
2955
+ }
2956
+ return rawData;
2957
+ }
2958
+ function processSpreadsheetData(rawData, headers, startRowIndex, params) {
2959
+ const controls = [];
2960
+ const families = /* @__PURE__ */ new Map();
2961
+ const fieldMetadata = /* @__PURE__ */ new Map();
2962
+ headers.forEach((header) => {
2963
+ if (header) {
2964
+ const cleanName = applyNamingConvention(header, params.namingConvention);
2965
+ fieldMetadata.set(cleanName, {
2966
+ originalName: header,
2967
+ cleanName,
2968
+ type: "string",
2969
+ maxLength: 0,
2970
+ hasMultipleLines: false,
2971
+ uniqueValues: /* @__PURE__ */ new Set(),
2972
+ emptyCount: 0,
2973
+ totalCount: 0,
2974
+ examples: []
2975
+ });
2976
+ }
2977
+ });
2978
+ for (let i = startRowIndex + 1; i < rawData.length; i++) {
2979
+ const row = rawData[i];
2980
+ if (!row || row.length === 0) continue;
2981
+ const control = {};
2982
+ let hasData = false;
2983
+ headers.forEach((header, index) => {
2984
+ if (header && row[index] !== void 0 && row[index] !== null) {
2985
+ const value = typeof row[index] === "string" ? row[index].trim() : row[index];
2986
+ const fieldName = applyNamingConvention(header, params.namingConvention);
2987
+ const metadata = fieldMetadata.get(fieldName);
2988
+ metadata.totalCount++;
2989
+ if (value === "" || value === null || value === void 0) {
2990
+ metadata.emptyCount++;
2991
+ if (params.skipEmpty) return;
2992
+ } else {
2993
+ const normalizedValue = typeof value === "string" ? value.trim() : value;
2994
+ if (normalizedValue !== "") {
2995
+ metadata.uniqueValues.add(normalizedValue);
2996
+ }
2997
+ const valueType = detectValueType(value);
2998
+ if (metadata.type === "string" || metadata.totalCount === 1) {
2999
+ metadata.type = valueType;
3000
+ } else if (metadata.type !== valueType) {
3001
+ metadata.type = "mixed";
3002
+ }
3003
+ if (typeof value === "string") {
3004
+ const length = value.length;
3005
+ if (length > metadata.maxLength) {
3006
+ metadata.maxLength = length;
3007
+ }
3008
+ if (value.includes("\n") || length > 100) {
3009
+ metadata.hasMultipleLines = true;
3010
+ }
3011
+ }
3012
+ if (metadata.examples.length < 3 && normalizedValue !== "") {
3013
+ metadata.examples.push(normalizedValue);
3014
+ }
3015
+ }
3016
+ control[fieldName] = value;
3017
+ hasData = true;
3018
+ }
3019
+ });
3020
+ if (hasData && (!params.skipEmptyRows || Object.keys(control).length > 0)) {
3021
+ const controlIdFieldName = applyNamingConvention(
3022
+ params.controlIdField,
3023
+ params.namingConvention
3024
+ );
3025
+ const controlId = control[controlIdFieldName];
3026
+ if (!controlId) {
3027
+ continue;
3028
+ }
3029
+ const family = extractFamilyFromControlId(controlId);
3030
+ control.family = family;
3031
+ controls.push(control);
3032
+ if (!families.has(family)) {
3033
+ families.set(family, []);
3034
+ }
3035
+ families.get(family).push(control);
3036
+ }
3037
+ }
3038
+ return { controls, families, fieldMetadata };
3039
+ }
3040
+ function buildFieldSchema(fieldMetadata, controls, params, families) {
3041
+ const fields = {};
3042
+ let displayOrder = 1;
3043
+ const controlIdFieldNameClean = applyNamingConvention(
3044
+ params.controlIdField,
3045
+ params.namingConvention
3046
+ );
3047
+ const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
3048
+ fields["family"] = {
3049
+ type: "string",
3050
+ ui_type: familyOptions.length <= 50 ? "select" : "short_text",
3051
+ is_array: false,
3052
+ max_length: 10,
3053
+ usage_count: controls.length,
3054
+ usage_percentage: 100,
3055
+ required: true,
3056
+ visible: true,
3057
+ show_in_table: true,
3058
+ editable: false,
3059
+ display_order: displayOrder++,
3060
+ category: "core",
3061
+ tab: "overview"
3062
+ };
3063
+ if (familyOptions.length <= 50) {
3064
+ fields["family"].options = familyOptions;
3065
+ }
3066
+ fieldMetadata.forEach((metadata, fieldName) => {
3067
+ if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
3068
+ return;
3069
+ }
3070
+ const frontendConfig = params.frontendFieldSchema?.find((f) => f.fieldName === fieldName);
3071
+ if (params.frontendFieldSchema && !frontendConfig) {
3072
+ return;
3073
+ }
3074
+ const usageCount = metadata.totalCount - metadata.emptyCount;
3075
+ const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
3076
+ let uiType = "short_text";
3077
+ const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
3078
+ const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
3079
+ nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
3080
+ metadata.maxLength <= 100 && // Reasonably short values only
3081
+ metadata.uniqueValues.size / nonEmptyCount <= 0.3;
3082
+ if (metadata.hasMultipleLines || metadata.maxLength > 500) {
3083
+ uiType = "textarea";
3084
+ } else if (isDropdownCandidate) {
3085
+ uiType = "select";
3086
+ } else if (metadata.type === "boolean") {
3087
+ uiType = "checkbox";
3088
+ } else if (metadata.type === "number") {
3089
+ uiType = "number";
3090
+ } else if (metadata.type === "date") {
3091
+ uiType = "date";
3092
+ } else if (metadata.maxLength <= 50) {
3093
+ uiType = "short_text";
3094
+ } else if (metadata.maxLength <= 200) {
3095
+ uiType = "medium_text";
3096
+ } else {
3097
+ uiType = "long_text";
3098
+ }
3099
+ let category = frontendConfig?.category || "custom";
3100
+ if (!frontendConfig) {
3101
+ if (fieldName.includes("status") || fieldName.includes("state")) {
3102
+ category = "compliance";
3103
+ } else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
3104
+ category = "core";
3105
+ } else if (fieldName.includes("note") || fieldName.includes("comment")) {
3106
+ category = "notes";
3107
+ }
3108
+ }
3109
+ const isControlIdField = fieldName === controlIdFieldNameClean;
3110
+ const fieldDef = {
3111
+ type: metadata.type,
3112
+ ui_type: uiType,
3113
+ is_array: false,
3114
+ max_length: metadata.maxLength,
3115
+ usage_count: usageCount,
3116
+ usage_percentage: usagePercentage,
3117
+ required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
3118
+ visible: frontendConfig?.tab !== "hidden",
3119
+ show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
3120
+ editable: isControlIdField ? false : true,
3121
+ display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
3122
+ category: isControlIdField ? "core" : category,
3123
+ tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
3124
+ };
3125
+ if (uiType === "select") {
3126
+ fieldDef.options = Array.from(metadata.uniqueValues).sort();
3127
+ }
3128
+ if (frontendConfig?.originalName || metadata.originalName) {
3129
+ fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
3130
+ }
3131
+ fields[fieldName] = fieldDef;
3132
+ });
3133
+ return {
3134
+ fields,
3135
+ total_controls: controls.length,
3136
+ analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
3137
+ };
3138
+ }
3139
+ async function createOutputStructure(processedData, fieldSchema, params) {
3140
+ const { controls, families } = processedData;
3141
+ const state = getServerState();
3142
+ const folderName = toKebabCase(params.controlSetName || "imported-controls");
3143
+ const baseDir = join4(state.CONTROL_SET_DIR || process.cwd(), folderName);
3144
+ if (!existsSync3(baseDir)) {
3145
+ mkdirSync2(baseDir, { recursive: true });
3146
+ }
3147
+ const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
3148
+ const controlIdFieldNameClean = applyNamingConvention(
3149
+ params.controlIdField,
3150
+ params.namingConvention
3151
+ );
3152
+ const controlSetData = {
3153
+ name: params.controlSetName,
3154
+ description: params.controlSetDescription,
3155
+ version: "1.0.0",
3156
+ control_id_field: controlIdFieldNameClean,
3157
+ controlCount: controls.length,
3158
+ families: uniqueFamilies,
3159
+ fieldSchema
3160
+ };
3161
+ writeFileSync2(join4(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
3162
+ const controlsDir = join4(baseDir, "controls");
3163
+ const mappingsDir = join4(baseDir, "mappings");
3164
+ families.forEach((familyControls, family) => {
3165
+ const familyDir = join4(controlsDir, family);
3166
+ const familyMappingsDir = join4(mappingsDir, family);
3167
+ if (!existsSync3(familyDir)) {
3168
+ mkdirSync2(familyDir, { recursive: true });
3169
+ }
3170
+ if (!existsSync3(familyMappingsDir)) {
3171
+ mkdirSync2(familyMappingsDir, { recursive: true });
3172
+ }
3173
+ familyControls.forEach((control) => {
3174
+ const controlId = control[controlIdFieldNameClean];
3175
+ if (!controlId) {
3176
+ console.error("Missing control ID for control:", control);
3177
+ return;
3178
+ }
3179
+ const controlIdStr = String(controlId).slice(0, 50);
3180
+ const fileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
3181
+ const filePath = join4(familyDir, fileName);
3182
+ const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
3183
+ const mappingFilePath = join4(familyMappingsDir, mappingFileName);
3184
+ const filteredControl = {};
3185
+ const mappingData = {
3186
+ control_id: controlIdStr,
3187
+ justification: "",
3188
+ uuid: crypto.randomUUID()
3189
+ };
3190
+ const justificationContents = [];
3191
+ if (control.family !== void 0) {
3192
+ filteredControl.family = control.family;
3193
+ }
3194
+ Object.keys(control).forEach((fieldName) => {
3195
+ if (fieldName === "family") return;
3196
+ if (params.justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
3197
+ justificationContents.push(control[fieldName]);
3198
+ }
3199
+ const isInFrontendSchema = params.frontendFieldSchema?.some(
3200
+ (f) => f.fieldName === fieldName
3201
+ );
3202
+ const isInFieldsMetadata = fieldSchema.fields.hasOwnProperty(fieldName);
3203
+ if (isInFrontendSchema || isInFieldsMetadata) {
3204
+ filteredControl[fieldName] = control[fieldName];
3205
+ }
3206
+ });
3207
+ writeFileSync2(filePath, yaml4.dump(filteredControl));
3208
+ if (justificationContents.length > 0) {
3209
+ mappingData.justification = justificationContents.join("\n\n");
3210
+ }
3211
+ if (mappingData.justification && mappingData.justification.trim() !== "") {
3212
+ const mappingArray = [mappingData];
3213
+ writeFileSync2(mappingFilePath, yaml4.dump(mappingArray));
3214
+ }
3215
+ });
3216
+ });
3217
+ return { folderName };
3218
+ }
2879
3219
  function applyNamingConvention(fieldName, convention) {
2880
3220
  if (!fieldName) return fieldName;
2881
3221
  const cleanedName = fieldName.trim();
@@ -3256,53 +3596,9 @@ var init_spreadsheetRoutes = __esm({
3256
3596
  if (!req.file) {
3257
3597
  return res.status(400).json({ error: "No file uploaded" });
3258
3598
  }
3259
- const {
3260
- controlIdField = "Control ID",
3261
- startRow = "1",
3262
- controlSetName = "Imported Control Set",
3263
- controlSetDescription = "Imported from spreadsheet"
3264
- } = req.body;
3265
- let justificationFields = [];
3266
- if (req.body.justificationFields) {
3267
- try {
3268
- justificationFields = JSON.parse(req.body.justificationFields);
3269
- debug("Justification fields received:", justificationFields);
3270
- } catch (e) {
3271
- console.error("Failed to parse justification fields:", e);
3272
- }
3273
- }
3274
- debug("Import parameters received:", {
3275
- controlIdField,
3276
- startRow,
3277
- controlSetName,
3278
- controlSetDescription
3279
- });
3280
- const namingConvention = "kebab-case";
3281
- const skipEmpty = true;
3282
- const skipEmptyRows = true;
3283
- const fileName = req.file.originalname || "";
3284
- const isCSV = fileName.toLowerCase().endsWith(".csv");
3285
- let rawData = [];
3286
- if (isCSV) {
3287
- const csvContent = req.file.buffer.toString("utf-8");
3288
- rawData = parseCSV(csvContent);
3289
- } else {
3290
- const workbook = new ExcelJS.Workbook();
3291
- const buffer = Buffer.from(req.file.buffer);
3292
- await workbook.xlsx.load(buffer);
3293
- const worksheet = workbook.worksheets[0];
3294
- if (!worksheet) {
3295
- return res.status(400).json({ error: "No worksheet found in file" });
3296
- }
3297
- worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
3298
- const rowData = [];
3299
- row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
3300
- rowData[colNumber - 1] = cell.value;
3301
- });
3302
- rawData[rowNumber - 1] = rowData;
3303
- });
3304
- }
3305
- const startRowIndex = parseInt(startRow) - 1;
3599
+ const params = processImportParameters(req.body);
3600
+ const rawData = await parseUploadedFile(req.file);
3601
+ const startRowIndex = parseInt(params.startRow) - 1;
3306
3602
  if (rawData.length <= startRowIndex) {
3307
3603
  return res.status(400).json({ error: "Start row exceeds sheet data" });
3308
3604
  }
@@ -3313,269 +3609,21 @@ var init_spreadsheetRoutes = __esm({
3313
3609
  debug("Headers found:", headers);
3314
3610
  debug(
3315
3611
  "After conversion, looking for control ID field:",
3316
- applyNamingConvention(controlIdField, namingConvention)
3612
+ applyNamingConvention(params.controlIdField, params.namingConvention)
3317
3613
  );
3318
- const controls = [];
3319
- const families = /* @__PURE__ */ new Map();
3320
- const fieldMetadata = /* @__PURE__ */ new Map();
3321
- headers.forEach((header) => {
3322
- if (header) {
3323
- const cleanName = applyNamingConvention(header, namingConvention);
3324
- fieldMetadata.set(cleanName, {
3325
- originalName: header,
3326
- cleanName,
3327
- type: "string",
3328
- maxLength: 0,
3329
- hasMultipleLines: false,
3330
- uniqueValues: /* @__PURE__ */ new Set(),
3331
- emptyCount: 0,
3332
- totalCount: 0,
3333
- examples: []
3334
- });
3335
- }
3336
- });
3337
- for (let i = startRowIndex + 1; i < rawData.length; i++) {
3338
- const row = rawData[i];
3339
- if (!row || row.length === 0) continue;
3340
- const control = {};
3341
- let hasData = false;
3342
- headers.forEach((header, index) => {
3343
- if (header && row[index] !== void 0 && row[index] !== null) {
3344
- const value = typeof row[index] === "string" ? row[index].trim() : row[index];
3345
- const fieldName = applyNamingConvention(header, namingConvention);
3346
- const metadata = fieldMetadata.get(fieldName);
3347
- metadata.totalCount++;
3348
- if (value === "" || value === null || value === void 0) {
3349
- metadata.emptyCount++;
3350
- if (skipEmpty) return;
3351
- } else {
3352
- const normalizedValue = typeof value === "string" ? value.trim() : value;
3353
- if (normalizedValue !== "") {
3354
- metadata.uniqueValues.add(normalizedValue);
3355
- }
3356
- const valueType = detectValueType(value);
3357
- if (metadata.type === "string" || metadata.totalCount === 1) {
3358
- metadata.type = valueType;
3359
- } else if (metadata.type !== valueType) {
3360
- metadata.type = "mixed";
3361
- }
3362
- if (typeof value === "string") {
3363
- const length = value.length;
3364
- if (length > metadata.maxLength) {
3365
- metadata.maxLength = length;
3366
- }
3367
- if (value.includes("\n") || length > 100) {
3368
- metadata.hasMultipleLines = true;
3369
- }
3370
- }
3371
- if (metadata.examples.length < 3 && normalizedValue !== "") {
3372
- metadata.examples.push(normalizedValue);
3373
- }
3374
- }
3375
- control[fieldName] = value;
3376
- hasData = true;
3377
- }
3378
- });
3379
- if (hasData && (!skipEmptyRows || Object.keys(control).length > 0)) {
3380
- const controlIdFieldName = applyNamingConvention(controlIdField, namingConvention);
3381
- const controlId = control[controlIdFieldName];
3382
- if (!controlId) {
3383
- continue;
3384
- }
3385
- const family = extractFamilyFromControlId(controlId);
3386
- control.family = family;
3387
- controls.push(control);
3388
- if (!families.has(family)) {
3389
- families.set(family, []);
3390
- }
3391
- families.get(family).push(control);
3392
- }
3393
- }
3394
- const state = getServerState();
3395
- const folderName = toKebabCase(controlSetName || "imported-controls");
3396
- const baseDir = join4(state.CONTROL_SET_DIR || process.cwd(), folderName);
3397
- if (!existsSync3(baseDir)) {
3398
- mkdirSync2(baseDir, { recursive: true });
3399
- }
3400
- const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
3401
- let frontendFieldSchema = null;
3402
- if (req.body.fieldSchema) {
3403
- try {
3404
- frontendFieldSchema = JSON.parse(req.body.fieldSchema);
3405
- } catch {
3406
- }
3407
- }
3408
- const fields = {};
3409
- let displayOrder = 1;
3410
- const controlIdFieldNameClean = applyNamingConvention(controlIdField, namingConvention);
3411
- const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
3412
- fields["family"] = {
3413
- type: "string",
3414
- ui_type: familyOptions.length <= 50 ? "select" : "short_text",
3415
- // Make select if reasonable number of families
3416
- is_array: false,
3417
- max_length: 10,
3418
- usage_count: controls.length,
3419
- usage_percentage: 100,
3420
- required: true,
3421
- visible: true,
3422
- show_in_table: true,
3423
- editable: false,
3424
- display_order: displayOrder++,
3425
- category: "core",
3426
- tab: "overview"
3427
- };
3428
- if (familyOptions.length <= 50) {
3429
- fields["family"].options = familyOptions;
3430
- }
3431
- fieldMetadata.forEach((metadata, fieldName) => {
3432
- if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
3433
- return;
3434
- }
3435
- const frontendConfig = frontendFieldSchema?.find((f) => f.fieldName === fieldName);
3436
- if (frontendFieldSchema && !frontendConfig) {
3437
- return;
3438
- }
3439
- const usageCount = metadata.totalCount - metadata.emptyCount;
3440
- const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
3441
- let uiType = "short_text";
3442
- const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
3443
- const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
3444
- nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
3445
- metadata.maxLength <= 100 && // Reasonably short values only
3446
- metadata.uniqueValues.size / nonEmptyCount <= 0.3;
3447
- if (metadata.hasMultipleLines || metadata.maxLength > 500) {
3448
- uiType = "textarea";
3449
- } else if (isDropdownCandidate) {
3450
- uiType = "select";
3451
- } else if (metadata.type === "boolean") {
3452
- uiType = "checkbox";
3453
- } else if (metadata.type === "number") {
3454
- uiType = "number";
3455
- } else if (metadata.type === "date") {
3456
- uiType = "date";
3457
- } else if (metadata.maxLength <= 50) {
3458
- uiType = "short_text";
3459
- } else if (metadata.maxLength <= 200) {
3460
- uiType = "medium_text";
3461
- } else {
3462
- uiType = "long_text";
3463
- }
3464
- let category = frontendConfig?.category || "custom";
3465
- if (!frontendConfig) {
3466
- if (fieldName.includes("status") || fieldName.includes("state")) {
3467
- category = "compliance";
3468
- } else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
3469
- category = "core";
3470
- } else if (fieldName.includes("note") || fieldName.includes("comment")) {
3471
- category = "notes";
3472
- }
3473
- }
3474
- const isControlIdField = fieldName === controlIdFieldNameClean;
3475
- const fieldDef = {
3476
- type: metadata.type,
3477
- ui_type: uiType,
3478
- is_array: false,
3479
- max_length: metadata.maxLength,
3480
- usage_count: usageCount,
3481
- usage_percentage: usagePercentage,
3482
- required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
3483
- // Control ID is always required
3484
- visible: frontendConfig?.tab !== "hidden",
3485
- show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
3486
- // Always show control ID in table
3487
- editable: isControlIdField ? false : true,
3488
- // Control ID is not editable
3489
- display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
3490
- // Control ID is always first
3491
- category: isControlIdField ? "core" : category,
3492
- // Control ID is always core
3493
- tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
3494
- // Use frontend config or default
3495
- };
3496
- if (uiType === "select") {
3497
- fieldDef.options = Array.from(metadata.uniqueValues).sort();
3498
- }
3499
- if (frontendConfig?.originalName || metadata.originalName) {
3500
- fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
3501
- }
3502
- fields[fieldName] = fieldDef;
3503
- });
3504
- const fieldSchema = {
3505
- fields,
3506
- total_controls: controls.length,
3507
- analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
3508
- };
3509
- const controlSetData = {
3510
- name: controlSetName,
3511
- description: controlSetDescription,
3512
- version: "1.0.0",
3513
- control_id_field: controlIdFieldNameClean,
3514
- // Add this to indicate which field is the control ID
3515
- controlCount: controls.length,
3516
- families: uniqueFamilies,
3517
- fieldSchema
3518
- };
3519
- writeFileSync2(join4(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
3520
- const controlsDir = join4(baseDir, "controls");
3521
- const mappingsDir = join4(baseDir, "mappings");
3522
- families.forEach((familyControls, family) => {
3523
- const familyDir = join4(controlsDir, family);
3524
- const familyMappingsDir = join4(mappingsDir, family);
3525
- if (!existsSync3(familyDir)) {
3526
- mkdirSync2(familyDir, { recursive: true });
3527
- }
3528
- if (!existsSync3(familyMappingsDir)) {
3529
- mkdirSync2(familyMappingsDir, { recursive: true });
3530
- }
3531
- familyControls.forEach((control) => {
3532
- const controlId = control[controlIdFieldNameClean];
3533
- if (!controlId) {
3534
- console.error("Missing control ID for control:", control);
3535
- return;
3536
- }
3537
- const controlIdStr = String(controlId).slice(0, 50);
3538
- const fileName2 = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
3539
- const filePath = join4(familyDir, fileName2);
3540
- const mappingFileName = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}-mappings.yaml`;
3541
- const mappingFilePath = join4(familyMappingsDir, mappingFileName);
3542
- const filteredControl = {};
3543
- const mappingData = {
3544
- control_id: controlIdStr,
3545
- justification: "",
3546
- uuid: crypto.randomUUID()
3547
- };
3548
- const justificationContents = [];
3549
- if (control.family !== void 0) {
3550
- filteredControl.family = control.family;
3551
- }
3552
- Object.keys(control).forEach((fieldName) => {
3553
- if (fieldName === "family") return;
3554
- if (justificationFields.includes(fieldName) && control[fieldName] !== void 0 && control[fieldName] !== null) {
3555
- justificationContents.push(control[fieldName]);
3556
- }
3557
- const isInFrontendSchema = frontendFieldSchema?.some((f) => f.fieldName === fieldName);
3558
- const isInFieldsMetadata = fields.hasOwnProperty(fieldName);
3559
- if (isInFrontendSchema || isInFieldsMetadata) {
3560
- filteredControl[fieldName] = control[fieldName];
3561
- }
3562
- });
3563
- writeFileSync2(filePath, yaml4.dump(filteredControl));
3564
- if (justificationContents.length > 0) {
3565
- mappingData.justification = justificationContents.join("\n\n");
3566
- }
3567
- if (mappingData.justification && mappingData.justification.trim() !== "") {
3568
- const mappingArray = [mappingData];
3569
- writeFileSync2(mappingFilePath, yaml4.dump(mappingArray));
3570
- }
3571
- });
3572
- });
3614
+ const processedData = processSpreadsheetData(rawData, headers, startRowIndex, params);
3615
+ const fieldSchema = buildFieldSchema(
3616
+ processedData.fieldMetadata,
3617
+ processedData.controls,
3618
+ params,
3619
+ processedData.families
3620
+ );
3621
+ const result = await createOutputStructure(processedData, fieldSchema, params);
3573
3622
  res.json({
3574
3623
  success: true,
3575
- controlCount: controls.length,
3576
- families: Array.from(families.keys()),
3577
- outputDir: folderName
3578
- // Return just the folder name, not full path
3624
+ controlCount: processedData.controls.length,
3625
+ families: Array.from(processedData.families.keys()),
3626
+ outputDir: result.folderName
3579
3627
  });
3580
3628
  } catch (error) {
3581
3629
  console.error("Error processing spreadsheet:", error);