lula2 0.5.0 → 0.5.1-nightly.1
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/{BCoAVBju.js → BdFxWaVJ.js} +1 -1
- package/dist/_app/immutable/chunks/Bvop-7hR.js +2 -0
- package/dist/_app/immutable/chunks/{RxRVmDPY.js → Bwv82F42.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/{CvviX0Gc.js → DJT-CwHK.js} +2 -2
- 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.BSaCFUch.js} +2 -2
- package/dist/_app/immutable/entry/start.BEhRFQd5.js +1 -0
- package/dist/_app/immutable/nodes/{0.BcLFbXUF.js → 0.CVjgNaoX.js} +1 -1
- package/dist/_app/immutable/nodes/{1.8i0FdqjI.js → 1.DuZUhMZB.js} +1 -1
- package/dist/_app/immutable/nodes/{2.jit_WwBQ.js → 2.B5vQqMFa.js} +1 -1
- package/dist/_app/immutable/nodes/{3.CfrrpHWE.js → 3.B4oUQOz5.js} +1 -1
- package/dist/_app/immutable/nodes/{4.BZ-_Jk1v.js → 4.BE7_moKh.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/entry/start.BUtu9VJ8.js +0 -1
package/dist/cli/commands/ui.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
3279
|
-
|
|
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
|
|
3337
|
-
const
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
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);
|