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