lula2 0.5.0-nightly.3 → 0.5.0-nightly.5
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/dist/_app/immutable/chunks/{qH_RjTuQ.js → 3F19wBGz.js} +1 -1
- package/dist/_app/immutable/chunks/Bvop-7hR.js +2 -0
- 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/{Bndgm7It.js → DiNHis7-.js} +1 -1
- 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/sFmgqS5x.js +3 -0
- package/dist/_app/immutable/chunks/{D6NghQtU.js → zYrdvxnm.js} +1 -1
- package/dist/_app/immutable/entry/{app.z7qxA8m8.js → app.BV9tvuYW.js} +2 -2
- package/dist/_app/immutable/entry/start.DpNVetJ9.js +1 -0
- package/dist/_app/immutable/nodes/{0.jiGrK4iR.js → 0.DVyncgQ_.js} +1 -1
- package/dist/_app/immutable/nodes/{1.DZaKLIcc.js → 1.DQcCLdS1.js} +1 -1
- package/dist/_app/immutable/nodes/{2.D5smJCAS.js → 2.CE977sdU.js} +1 -1
- package/dist/_app/immutable/nodes/{3.Bi3J5hq2.js → 3.J9UDTZFq.js} +1 -1
- package/dist/_app/immutable/nodes/{4.CSzCxQ4i.js → 4.BAPGEBrr.js} +1 -1
- package/dist/_app/version.json +1 -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 +356 -308
- package/package.json +3 -3
- package/src/lib/websocket.ts +106 -76
- package/dist/_app/immutable/chunks/B3DV5AB9.js +0 -2
- package/dist/_app/immutable/chunks/BIY1u1I9.js +0 -2
- package/dist/_app/immutable/chunks/BMsYC0uK.js +0 -3
- package/dist/_app/immutable/entry/start.DBJttd3y.js +0 -1
package/dist/cli/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
3261
|
-
|
|
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
|
|
3319
|
-
const
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
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);
|