lula2 0.0.5 → 0.0.6
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 +291 -8
- package/dist/_app/env.js +1 -0
- package/dist/_app/immutable/assets/0.DtiRW3lO.css +1 -0
- package/dist/_app/immutable/assets/DynamicControlEditor.BkVTzFZ-.css +1 -0
- package/dist/_app/immutable/chunks/7x_q-1ab.js +1 -0
- package/dist/_app/immutable/chunks/B19gt6-g.js +2 -0
- package/dist/_app/immutable/chunks/BR-0Dorr.js +1 -0
- package/dist/_app/immutable/chunks/B_3ksxz5.js +2 -0
- package/dist/_app/immutable/chunks/Bg_R1qWi.js +3 -0
- package/dist/_app/immutable/chunks/D3aNP_lg.js +1 -0
- package/dist/_app/immutable/chunks/D4Q_ObIy.js +1 -0
- package/dist/_app/immutable/chunks/DsnmJJEf.js +1 -0
- package/dist/_app/immutable/chunks/XY2j_owG.js +66 -0
- package/dist/_app/immutable/chunks/rzN25oDf.js +1 -0
- package/dist/_app/immutable/entry/app.r0uOd9qg.js +2 -0
- package/dist/_app/immutable/entry/start.DvoqR0rc.js +1 -0
- package/dist/_app/immutable/nodes/0.Ct6FAss_.js +1 -0
- package/dist/_app/immutable/nodes/1.DLoKuy8Q.js +1 -0
- package/dist/_app/immutable/nodes/2.IRkwSmiB.js +1 -0
- package/dist/_app/immutable/nodes/3.BrTg-ZHv.js +1 -0
- package/dist/_app/immutable/nodes/4.Blq-4WQS.js +9 -0
- package/dist/_app/version.json +1 -0
- package/dist/cli/commands/crawl.js +128 -0
- package/dist/cli/commands/ui.js +2769 -0
- package/dist/cli/commands/version.js +30 -0
- package/dist/cli/server/index.js +2713 -0
- package/dist/cli/server/server.js +2702 -0
- package/dist/cli/server/serverState.js +1199 -0
- package/dist/cli/server/spreadsheetRoutes.js +788 -0
- package/dist/cli/server/types.js +0 -0
- package/dist/cli/server/websocketServer.js +2625 -0
- package/dist/cli/utils/debug.js +24 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +38 -0
- package/dist/index.js +2924 -37
- package/dist/lula.png +0 -0
- package/dist/lula2 +2 -0
- package/package.json +120 -72
- package/src/app.css +192 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +13 -0
- package/src/lib/actions/fadeWhenScrollable.ts +39 -0
- package/src/lib/actions/modal.ts +230 -0
- package/src/lib/actions/tooltip.ts +82 -0
- package/src/lib/components/control-sets/ControlSetInfo.svelte +20 -0
- package/src/lib/components/control-sets/ControlSetSelector.svelte +46 -0
- package/src/lib/components/control-sets/index.ts +5 -0
- package/src/lib/components/controls/ControlDetailsPanel.svelte +235 -0
- package/src/lib/components/controls/ControlsList.svelte +608 -0
- package/src/lib/components/controls/DynamicControlEditor.svelte +298 -0
- package/src/lib/components/controls/MappingCard.svelte +105 -0
- package/src/lib/components/controls/MappingForm.svelte +188 -0
- package/src/lib/components/controls/index.ts +9 -0
- package/src/lib/components/controls/renderers/EditableFieldRenderer.svelte +103 -0
- package/src/lib/components/controls/renderers/FieldRenderer.svelte +49 -0
- package/src/lib/components/controls/renderers/index.ts +5 -0
- package/src/lib/components/controls/tabs/CustomFieldsTab.svelte +130 -0
- package/src/lib/components/controls/tabs/ImplementationTab.svelte +127 -0
- package/src/lib/components/controls/tabs/MappingsTab.svelte +182 -0
- package/src/lib/components/controls/tabs/OverviewTab.svelte +151 -0
- package/src/lib/components/controls/tabs/TimelineTab.svelte +41 -0
- package/src/lib/components/controls/tabs/index.ts +8 -0
- package/src/lib/components/controls/utils/ProcessedTextRenderer.svelte +63 -0
- package/src/lib/components/controls/utils/textProcessor.ts +164 -0
- package/src/lib/components/forms/DynamicControlForm.svelte +340 -0
- package/src/lib/components/forms/DynamicField.svelte +494 -0
- package/src/lib/components/forms/FormField.svelte +107 -0
- package/src/lib/components/forms/index.ts +6 -0
- package/src/lib/components/setup/ExistingControlSets.svelte +284 -0
- package/src/lib/components/setup/SpreadsheetImport.svelte +968 -0
- package/src/lib/components/setup/index.ts +5 -0
- package/src/lib/components/ui/Dropdown.svelte +107 -0
- package/src/lib/components/ui/EmptyState.svelte +80 -0
- package/src/lib/components/ui/FeatureToggle.svelte +50 -0
- package/src/lib/components/ui/SearchBar.svelte +73 -0
- package/src/lib/components/ui/StatusBadge.svelte +79 -0
- package/src/lib/components/ui/TabNavigation.svelte +48 -0
- package/src/lib/components/ui/Tooltip.svelte +120 -0
- package/src/lib/components/ui/index.ts +10 -0
- package/src/lib/components/version-control/DiffViewer.svelte +292 -0
- package/src/lib/components/version-control/TimelineItem.svelte +107 -0
- package/src/lib/components/version-control/YamlDiffViewer.svelte +428 -0
- package/src/lib/components/version-control/index.ts +6 -0
- package/src/lib/form-types.ts +57 -0
- package/src/lib/formatUtils.ts +17 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/types.ts +180 -0
- package/src/lib/websocket.ts +359 -0
- package/src/routes/+layout.svelte +236 -0
- package/src/routes/+page.svelte +38 -0
- package/src/routes/control/[id]/+page.svelte +112 -0
- package/src/routes/setup/+page.svelte +241 -0
- package/src/stores/compliance.ts +95 -0
- package/src/styles/highlightjs.css +20 -0
- package/src/styles/modal.css +58 -0
- package/src/styles/tables.css +111 -0
- package/src/styles/tooltip.css +65 -0
- package/dist/controls/index.d.ts +0 -18
- package/dist/controls/index.d.ts.map +0 -1
- package/dist/controls/index.js +0 -18
- package/dist/crawl.d.ts +0 -62
- package/dist/crawl.d.ts.map +0 -1
- package/dist/crawl.js +0 -172
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/src/controls/index.ts +0 -19
- package/src/crawl.ts +0 -227
- package/src/index.ts +0 -46
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
// cli/server/spreadsheetRoutes.ts
|
|
2
|
+
import { parse as parseCSVSync } from "csv-parse/sync";
|
|
3
|
+
import ExcelJS from "exceljs";
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
6
|
+
import { glob } from "glob";
|
|
7
|
+
import * as yaml4 from "js-yaml";
|
|
8
|
+
import multer from "multer";
|
|
9
|
+
import { dirname, join, relative } from "path";
|
|
10
|
+
|
|
11
|
+
// cli/utils/debug.ts
|
|
12
|
+
var debugEnabled = false;
|
|
13
|
+
function debug(...args) {
|
|
14
|
+
if (debugEnabled) {
|
|
15
|
+
console.log("[DEBUG]", ...args);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// cli/server/infrastructure/fileStore.ts
|
|
20
|
+
import * as yaml2 from "js-yaml";
|
|
21
|
+
|
|
22
|
+
// cli/server/infrastructure/controlHelpers.ts
|
|
23
|
+
import * as yaml from "js-yaml";
|
|
24
|
+
|
|
25
|
+
// cli/server/infrastructure/gitHistory.ts
|
|
26
|
+
import * as git from "isomorphic-git";
|
|
27
|
+
|
|
28
|
+
// cli/server/infrastructure/yamlDiff.ts
|
|
29
|
+
import * as yaml3 from "js-yaml";
|
|
30
|
+
|
|
31
|
+
// cli/server/serverState.ts
|
|
32
|
+
var serverState = void 0;
|
|
33
|
+
function getServerState() {
|
|
34
|
+
if (!serverState) {
|
|
35
|
+
throw new Error("Server state not initialized. Call initializeServerState() first.");
|
|
36
|
+
}
|
|
37
|
+
return serverState;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// cli/server/spreadsheetRoutes.ts
|
|
41
|
+
var router = express.Router();
|
|
42
|
+
var upload = multer({
|
|
43
|
+
storage: multer.memoryStorage(),
|
|
44
|
+
limits: { fileSize: 50 * 1024 * 1024 }
|
|
45
|
+
// 50MB limit
|
|
46
|
+
});
|
|
47
|
+
async function scanControlSets() {
|
|
48
|
+
const state = getServerState();
|
|
49
|
+
const baseDir = state.CONTROL_SET_DIR;
|
|
50
|
+
const pattern = "**/lula.yaml";
|
|
51
|
+
const files = await glob(pattern, {
|
|
52
|
+
cwd: baseDir,
|
|
53
|
+
ignore: ["node_modules/**", "dist/**", "build/**"],
|
|
54
|
+
maxDepth: 5
|
|
55
|
+
});
|
|
56
|
+
const controlSets = files.map((file) => {
|
|
57
|
+
const fullPath = join(baseDir, file);
|
|
58
|
+
const dirPath = dirname(fullPath);
|
|
59
|
+
const relativePath = relative(baseDir, dirPath) || ".";
|
|
60
|
+
try {
|
|
61
|
+
const content = readFileSync(fullPath, "utf8");
|
|
62
|
+
const data = yaml4.load(content);
|
|
63
|
+
if (data.id === "default") {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
path: relativePath,
|
|
68
|
+
name: data.name || "Unnamed Control Set",
|
|
69
|
+
description: data.description || "",
|
|
70
|
+
controlCount: data.controlCount || 0,
|
|
71
|
+
file
|
|
72
|
+
};
|
|
73
|
+
} catch (_err) {
|
|
74
|
+
return {
|
|
75
|
+
path: relativePath,
|
|
76
|
+
name: "Invalid lula.yaml",
|
|
77
|
+
description: "Could not parse file",
|
|
78
|
+
controlCount: 0,
|
|
79
|
+
file
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}).filter((cs) => cs !== null);
|
|
83
|
+
return { controlSets };
|
|
84
|
+
}
|
|
85
|
+
router.post("/import-spreadsheet", upload.single("file"), async (req, res) => {
|
|
86
|
+
try {
|
|
87
|
+
if (!req.file) {
|
|
88
|
+
return res.status(400).json({ error: "No file uploaded" });
|
|
89
|
+
}
|
|
90
|
+
const {
|
|
91
|
+
controlIdField = "Control ID",
|
|
92
|
+
startRow = "1",
|
|
93
|
+
controlSetName = "Imported Control Set",
|
|
94
|
+
controlSetDescription = "Imported from spreadsheet"
|
|
95
|
+
} = req.body;
|
|
96
|
+
debug("Import parameters received:", {
|
|
97
|
+
controlIdField,
|
|
98
|
+
startRow,
|
|
99
|
+
controlSetName,
|
|
100
|
+
controlSetDescription
|
|
101
|
+
});
|
|
102
|
+
const namingConvention = "kebab-case";
|
|
103
|
+
const skipEmpty = true;
|
|
104
|
+
const skipEmptyRows = true;
|
|
105
|
+
const fileName = req.file.originalname || "";
|
|
106
|
+
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
107
|
+
let rawData = [];
|
|
108
|
+
if (isCSV) {
|
|
109
|
+
const csvContent = req.file.buffer.toString("utf-8");
|
|
110
|
+
rawData = parseCSV(csvContent);
|
|
111
|
+
} else {
|
|
112
|
+
const workbook = new ExcelJS.Workbook();
|
|
113
|
+
const buffer = Buffer.from(req.file.buffer);
|
|
114
|
+
await workbook.xlsx.load(buffer);
|
|
115
|
+
const worksheet = workbook.worksheets[0];
|
|
116
|
+
if (!worksheet) {
|
|
117
|
+
return res.status(400).json({ error: "No worksheet found in file" });
|
|
118
|
+
}
|
|
119
|
+
worksheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
|
120
|
+
const rowData = [];
|
|
121
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
122
|
+
rowData[colNumber - 1] = cell.value;
|
|
123
|
+
});
|
|
124
|
+
rawData[rowNumber - 1] = rowData;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const startRowIndex = parseInt(startRow) - 1;
|
|
128
|
+
if (rawData.length <= startRowIndex) {
|
|
129
|
+
return res.status(400).json({ error: "Start row exceeds sheet data" });
|
|
130
|
+
}
|
|
131
|
+
const headers = rawData[startRowIndex];
|
|
132
|
+
if (!headers || headers.length === 0) {
|
|
133
|
+
return res.status(400).json({ error: "No headers found at specified row" });
|
|
134
|
+
}
|
|
135
|
+
debug("Headers found:", headers);
|
|
136
|
+
debug(
|
|
137
|
+
"After conversion, looking for control ID field:",
|
|
138
|
+
applyNamingConvention(controlIdField, namingConvention)
|
|
139
|
+
);
|
|
140
|
+
const controls = [];
|
|
141
|
+
const families = /* @__PURE__ */ new Map();
|
|
142
|
+
const fieldMetadata = /* @__PURE__ */ new Map();
|
|
143
|
+
headers.forEach((header) => {
|
|
144
|
+
if (header) {
|
|
145
|
+
const cleanName = applyNamingConvention(header, namingConvention);
|
|
146
|
+
fieldMetadata.set(cleanName, {
|
|
147
|
+
originalName: header,
|
|
148
|
+
cleanName,
|
|
149
|
+
type: "string",
|
|
150
|
+
maxLength: 0,
|
|
151
|
+
hasMultipleLines: false,
|
|
152
|
+
uniqueValues: /* @__PURE__ */ new Set(),
|
|
153
|
+
emptyCount: 0,
|
|
154
|
+
totalCount: 0,
|
|
155
|
+
examples: []
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
for (let i = startRowIndex + 1; i < rawData.length; i++) {
|
|
160
|
+
const row = rawData[i];
|
|
161
|
+
if (!row || row.length === 0) continue;
|
|
162
|
+
const control = {};
|
|
163
|
+
let hasData = false;
|
|
164
|
+
headers.forEach((header, index) => {
|
|
165
|
+
if (header && row[index] !== void 0 && row[index] !== null) {
|
|
166
|
+
const value = typeof row[index] === "string" ? row[index].trim() : row[index];
|
|
167
|
+
const fieldName = applyNamingConvention(header, namingConvention);
|
|
168
|
+
const metadata = fieldMetadata.get(fieldName);
|
|
169
|
+
metadata.totalCount++;
|
|
170
|
+
if (value === "" || value === null || value === void 0) {
|
|
171
|
+
metadata.emptyCount++;
|
|
172
|
+
if (skipEmpty) return;
|
|
173
|
+
} else {
|
|
174
|
+
const normalizedValue = typeof value === "string" ? value.trim() : value;
|
|
175
|
+
if (normalizedValue !== "") {
|
|
176
|
+
metadata.uniqueValues.add(normalizedValue);
|
|
177
|
+
}
|
|
178
|
+
const valueType = detectValueType(value);
|
|
179
|
+
if (metadata.type === "string" || metadata.totalCount === 1) {
|
|
180
|
+
metadata.type = valueType;
|
|
181
|
+
} else if (metadata.type !== valueType) {
|
|
182
|
+
metadata.type = "mixed";
|
|
183
|
+
}
|
|
184
|
+
if (typeof value === "string") {
|
|
185
|
+
const length = value.length;
|
|
186
|
+
if (length > metadata.maxLength) {
|
|
187
|
+
metadata.maxLength = length;
|
|
188
|
+
}
|
|
189
|
+
if (value.includes("\n") || length > 100) {
|
|
190
|
+
metadata.hasMultipleLines = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (metadata.examples.length < 3 && normalizedValue !== "") {
|
|
194
|
+
metadata.examples.push(normalizedValue);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
control[fieldName] = value;
|
|
198
|
+
hasData = true;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
if (hasData && (!skipEmptyRows || Object.keys(control).length > 0)) {
|
|
202
|
+
const controlIdFieldName = applyNamingConvention(controlIdField, namingConvention);
|
|
203
|
+
const controlId = control[controlIdFieldName];
|
|
204
|
+
if (!controlId) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const family = extractFamilyFromControlId(controlId);
|
|
208
|
+
control.family = family;
|
|
209
|
+
controls.push(control);
|
|
210
|
+
if (!families.has(family)) {
|
|
211
|
+
families.set(family, []);
|
|
212
|
+
}
|
|
213
|
+
families.get(family).push(control);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const state = getServerState();
|
|
217
|
+
const folderName = toKebabCase(controlSetName || "imported-controls");
|
|
218
|
+
const baseDir = join(state.CONTROL_SET_DIR || process.cwd(), folderName);
|
|
219
|
+
if (!existsSync(baseDir)) {
|
|
220
|
+
mkdirSync(baseDir, { recursive: true });
|
|
221
|
+
}
|
|
222
|
+
const uniqueFamilies = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN");
|
|
223
|
+
let frontendFieldSchema = null;
|
|
224
|
+
if (req.body.fieldSchema) {
|
|
225
|
+
try {
|
|
226
|
+
frontendFieldSchema = JSON.parse(req.body.fieldSchema);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const fields = {};
|
|
231
|
+
let displayOrder = 1;
|
|
232
|
+
const controlIdFieldNameClean = applyNamingConvention(controlIdField, namingConvention);
|
|
233
|
+
const familyOptions = Array.from(families.keys()).filter((f) => f && f !== "UNKNOWN").sort();
|
|
234
|
+
fields["family"] = {
|
|
235
|
+
type: "string",
|
|
236
|
+
ui_type: familyOptions.length <= 50 ? "select" : "short_text",
|
|
237
|
+
// Make select if reasonable number of families
|
|
238
|
+
is_array: false,
|
|
239
|
+
max_length: 10,
|
|
240
|
+
usage_count: controls.length,
|
|
241
|
+
usage_percentage: 100,
|
|
242
|
+
required: true,
|
|
243
|
+
visible: true,
|
|
244
|
+
show_in_table: true,
|
|
245
|
+
editable: false,
|
|
246
|
+
display_order: displayOrder++,
|
|
247
|
+
category: "core",
|
|
248
|
+
tab: "overview"
|
|
249
|
+
};
|
|
250
|
+
if (familyOptions.length <= 50) {
|
|
251
|
+
fields["family"].options = familyOptions;
|
|
252
|
+
}
|
|
253
|
+
fieldMetadata.forEach((metadata, fieldName) => {
|
|
254
|
+
if (fieldName === "family" || fieldName === "id" && controlIdFieldNameClean !== "id") {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const frontendConfig = frontendFieldSchema?.find((f) => f.fieldName === fieldName);
|
|
258
|
+
if (frontendFieldSchema && !frontendConfig) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const usageCount = metadata.totalCount - metadata.emptyCount;
|
|
262
|
+
const usagePercentage = metadata.totalCount > 0 ? Math.round(usageCount / metadata.totalCount * 100) : 0;
|
|
263
|
+
let uiType = "short_text";
|
|
264
|
+
const nonEmptyCount = metadata.totalCount - metadata.emptyCount;
|
|
265
|
+
const isDropdownCandidate = metadata.uniqueValues.size > 0 && metadata.uniqueValues.size <= 20 && // Max 20 unique values for dropdown
|
|
266
|
+
nonEmptyCount >= 10 && // At least 10 non-empty values to be meaningful
|
|
267
|
+
metadata.maxLength <= 100 && // Reasonably short values only
|
|
268
|
+
metadata.uniqueValues.size / nonEmptyCount <= 0.3;
|
|
269
|
+
if (metadata.hasMultipleLines || metadata.maxLength > 500) {
|
|
270
|
+
uiType = "textarea";
|
|
271
|
+
} else if (isDropdownCandidate) {
|
|
272
|
+
uiType = "select";
|
|
273
|
+
} else if (metadata.type === "boolean") {
|
|
274
|
+
uiType = "checkbox";
|
|
275
|
+
} else if (metadata.type === "number") {
|
|
276
|
+
uiType = "number";
|
|
277
|
+
} else if (metadata.type === "date") {
|
|
278
|
+
uiType = "date";
|
|
279
|
+
} else if (metadata.maxLength <= 50) {
|
|
280
|
+
uiType = "short_text";
|
|
281
|
+
} else if (metadata.maxLength <= 200) {
|
|
282
|
+
uiType = "medium_text";
|
|
283
|
+
} else {
|
|
284
|
+
uiType = "long_text";
|
|
285
|
+
}
|
|
286
|
+
let category = frontendConfig?.category || "custom";
|
|
287
|
+
if (!frontendConfig) {
|
|
288
|
+
if (fieldName.includes("status") || fieldName.includes("state")) {
|
|
289
|
+
category = "compliance";
|
|
290
|
+
} else if (fieldName.includes("title") || fieldName.includes("name") || fieldName.includes("description")) {
|
|
291
|
+
category = "core";
|
|
292
|
+
} else if (fieldName.includes("note") || fieldName.includes("comment")) {
|
|
293
|
+
category = "notes";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const isControlIdField = fieldName === controlIdFieldNameClean;
|
|
297
|
+
const fieldDef = {
|
|
298
|
+
type: metadata.type,
|
|
299
|
+
ui_type: uiType,
|
|
300
|
+
is_array: false,
|
|
301
|
+
max_length: metadata.maxLength,
|
|
302
|
+
usage_count: usageCount,
|
|
303
|
+
usage_percentage: usagePercentage,
|
|
304
|
+
required: isControlIdField ? true : frontendConfig?.required ?? usagePercentage > 95,
|
|
305
|
+
// Control ID is always required
|
|
306
|
+
visible: frontendConfig?.tab !== "hidden",
|
|
307
|
+
show_in_table: isControlIdField ? true : metadata.maxLength <= 100 && usagePercentage > 30,
|
|
308
|
+
// Always show control ID in table
|
|
309
|
+
editable: isControlIdField ? false : true,
|
|
310
|
+
// Control ID is not editable
|
|
311
|
+
display_order: isControlIdField ? 1 : frontendConfig?.displayOrder ?? displayOrder++,
|
|
312
|
+
// Control ID is always first
|
|
313
|
+
category: isControlIdField ? "core" : category,
|
|
314
|
+
// Control ID is always core
|
|
315
|
+
tab: isControlIdField ? "overview" : frontendConfig?.tab || void 0
|
|
316
|
+
// Control ID is always in overview
|
|
317
|
+
};
|
|
318
|
+
if (uiType === "select") {
|
|
319
|
+
fieldDef.options = Array.from(metadata.uniqueValues).sort();
|
|
320
|
+
}
|
|
321
|
+
if (frontendConfig?.originalName || metadata.originalName) {
|
|
322
|
+
fieldDef.original_name = frontendConfig?.originalName || metadata.originalName;
|
|
323
|
+
}
|
|
324
|
+
fields[fieldName] = fieldDef;
|
|
325
|
+
});
|
|
326
|
+
const fieldSchema = {
|
|
327
|
+
fields,
|
|
328
|
+
total_controls: controls.length,
|
|
329
|
+
analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
330
|
+
};
|
|
331
|
+
const controlSetData = {
|
|
332
|
+
name: controlSetName,
|
|
333
|
+
description: controlSetDescription,
|
|
334
|
+
version: "1.0.0",
|
|
335
|
+
control_id_field: controlIdFieldNameClean,
|
|
336
|
+
// Add this to indicate which field is the control ID
|
|
337
|
+
controlCount: controls.length,
|
|
338
|
+
families: uniqueFamilies,
|
|
339
|
+
fieldSchema
|
|
340
|
+
};
|
|
341
|
+
writeFileSync(join(baseDir, "lula.yaml"), yaml4.dump(controlSetData));
|
|
342
|
+
const controlsDir = join(baseDir, "controls");
|
|
343
|
+
families.forEach((familyControls, family) => {
|
|
344
|
+
const familyDir = join(controlsDir, family);
|
|
345
|
+
if (!existsSync(familyDir)) {
|
|
346
|
+
mkdirSync(familyDir, { recursive: true });
|
|
347
|
+
}
|
|
348
|
+
familyControls.forEach((control) => {
|
|
349
|
+
const controlId = control[controlIdFieldNameClean];
|
|
350
|
+
if (!controlId) {
|
|
351
|
+
console.error("Missing control ID for control:", control);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const controlIdStr = String(controlId).slice(0, 50);
|
|
355
|
+
const fileName2 = `${controlIdStr.replace(/[^a-zA-Z0-9-]/g, "_")}.yaml`;
|
|
356
|
+
const filePath = join(familyDir, fileName2);
|
|
357
|
+
const filteredControl = {};
|
|
358
|
+
if (control.family !== void 0) {
|
|
359
|
+
filteredControl.family = control.family;
|
|
360
|
+
}
|
|
361
|
+
Object.keys(control).forEach((fieldName) => {
|
|
362
|
+
if (fieldName === "family") return;
|
|
363
|
+
const isInFrontendSchema = frontendFieldSchema?.some((f) => f.fieldName === fieldName);
|
|
364
|
+
const isInFieldsMetadata = fields.hasOwnProperty(fieldName);
|
|
365
|
+
if (isInFrontendSchema || isInFieldsMetadata) {
|
|
366
|
+
filteredControl[fieldName] = control[fieldName];
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
writeFileSync(filePath, yaml4.dump(filteredControl));
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
res.json({
|
|
373
|
+
success: true,
|
|
374
|
+
controlCount: controls.length,
|
|
375
|
+
families: Array.from(families.keys()),
|
|
376
|
+
outputDir: folderName
|
|
377
|
+
// Return just the folder name, not full path
|
|
378
|
+
});
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.error("Error processing spreadsheet:", error);
|
|
381
|
+
res.status(500).json({ error: "Failed to process spreadsheet" });
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
function applyNamingConvention(fieldName, convention) {
|
|
385
|
+
if (!fieldName) return fieldName;
|
|
386
|
+
const cleanedName = fieldName.trim();
|
|
387
|
+
switch (convention) {
|
|
388
|
+
case "camelCase":
|
|
389
|
+
return toCamelCase(cleanedName);
|
|
390
|
+
case "snake_case":
|
|
391
|
+
return toSnakeCase(cleanedName);
|
|
392
|
+
case "kebab-case":
|
|
393
|
+
return toKebabCase(cleanedName);
|
|
394
|
+
case "lowercase":
|
|
395
|
+
return cleanedName.replace(/\W+/g, "").toLowerCase();
|
|
396
|
+
case "original":
|
|
397
|
+
return cleanedName;
|
|
398
|
+
default:
|
|
399
|
+
return toCamelCase(cleanedName);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function toCamelCase(str) {
|
|
403
|
+
return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => {
|
|
404
|
+
return index === 0 ? word.toLowerCase() : word.toUpperCase();
|
|
405
|
+
}).replace(/\s+/g, "");
|
|
406
|
+
}
|
|
407
|
+
function toSnakeCase(str) {
|
|
408
|
+
return str.replace(/\W+/g, " ").split(/ |\s/).map((word) => word.toLowerCase()).join("_");
|
|
409
|
+
}
|
|
410
|
+
function toKebabCase(str) {
|
|
411
|
+
return str.replace(/\W+/g, " ").split(/ |\s/).map((word) => word.toLowerCase()).join("-");
|
|
412
|
+
}
|
|
413
|
+
function detectValueType(value) {
|
|
414
|
+
if (typeof value === "boolean") return "boolean";
|
|
415
|
+
if (typeof value === "number") return "number";
|
|
416
|
+
if (typeof value === "string") {
|
|
417
|
+
const lowerValue = value.toLowerCase().trim();
|
|
418
|
+
if (lowerValue === "true" || lowerValue === "false" || lowerValue === "yes" || lowerValue === "no" || lowerValue === "y" || lowerValue === "n") {
|
|
419
|
+
return "boolean";
|
|
420
|
+
}
|
|
421
|
+
if (!isNaN(Number(value)) && value.trim() !== "") {
|
|
422
|
+
return "number";
|
|
423
|
+
}
|
|
424
|
+
const datePatterns = [
|
|
425
|
+
/^\d{4}-\d{2}-\d{2}$/,
|
|
426
|
+
// YYYY-MM-DD
|
|
427
|
+
/^\d{2}\/\d{2}\/\d{4}$/,
|
|
428
|
+
// MM/DD/YYYY
|
|
429
|
+
/^\d{1,2}\/\d{1,2}\/\d{2,4}$/
|
|
430
|
+
// M/D/YY or MM/DD/YYYY
|
|
431
|
+
];
|
|
432
|
+
if (datePatterns.some((pattern) => pattern.test(value))) {
|
|
433
|
+
return "date";
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return "string";
|
|
437
|
+
}
|
|
438
|
+
function parseCSV(content) {
|
|
439
|
+
try {
|
|
440
|
+
const records = parseCSVSync(content, {
|
|
441
|
+
// Don't treat first row as headers - we'll handle that ourselves
|
|
442
|
+
columns: false,
|
|
443
|
+
// Skip empty lines
|
|
444
|
+
skip_empty_lines: true,
|
|
445
|
+
// Handle different line endings
|
|
446
|
+
relax_column_count: true,
|
|
447
|
+
// Trim whitespace from fields
|
|
448
|
+
trim: true,
|
|
449
|
+
// Handle quoted fields properly
|
|
450
|
+
quote: '"',
|
|
451
|
+
// Standard escape character
|
|
452
|
+
escape: '"',
|
|
453
|
+
// Auto-detect delimiter (usually comma)
|
|
454
|
+
delimiter: ","
|
|
455
|
+
});
|
|
456
|
+
return records;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.error("CSV parsing error:", error);
|
|
459
|
+
return content.split(/\r?\n/).map((line) => line.split(","));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function extractFamilyFromControlId(controlId) {
|
|
463
|
+
if (!controlId) return "UNKNOWN";
|
|
464
|
+
controlId = controlId.trim();
|
|
465
|
+
const match = controlId.match(/^([A-Za-z]+)[-._ ]?\d/);
|
|
466
|
+
if (match) {
|
|
467
|
+
return match[1].toUpperCase();
|
|
468
|
+
}
|
|
469
|
+
const letterMatch = controlId.match(/^([A-Za-z]+)/);
|
|
470
|
+
if (letterMatch) {
|
|
471
|
+
return letterMatch[1].toUpperCase();
|
|
472
|
+
}
|
|
473
|
+
return controlId.substring(0, 2).toUpperCase();
|
|
474
|
+
}
|
|
475
|
+
router.get("/export-controls", async (req, res) => {
|
|
476
|
+
try {
|
|
477
|
+
const format = req.query.format || "csv";
|
|
478
|
+
const state = getServerState();
|
|
479
|
+
const fileStore = state.fileStore;
|
|
480
|
+
if (!fileStore) {
|
|
481
|
+
return res.status(500).json({ error: "No control set loaded" });
|
|
482
|
+
}
|
|
483
|
+
const controls = await fileStore.loadAllControls();
|
|
484
|
+
const mappings = await fileStore.loadMappings();
|
|
485
|
+
let metadata = {};
|
|
486
|
+
try {
|
|
487
|
+
const metadataPath = join(state.CONTROL_SET_DIR, "lula.yaml");
|
|
488
|
+
if (existsSync(metadataPath)) {
|
|
489
|
+
const metadataContent = readFileSync(metadataPath, "utf8");
|
|
490
|
+
metadata = yaml4.load(metadataContent);
|
|
491
|
+
}
|
|
492
|
+
} catch (err) {
|
|
493
|
+
debug("Could not load metadata:", err);
|
|
494
|
+
}
|
|
495
|
+
if (!controls || controls.length === 0) {
|
|
496
|
+
return res.status(404).json({ error: "No controls found" });
|
|
497
|
+
}
|
|
498
|
+
const controlsWithMappings = controls.map((control) => {
|
|
499
|
+
const controlIdField = metadata?.control_id_field || "id";
|
|
500
|
+
const controlId = control[controlIdField] || control.id;
|
|
501
|
+
const controlMappings = mappings.filter((m) => m.control_id === controlId);
|
|
502
|
+
return {
|
|
503
|
+
...control,
|
|
504
|
+
mappings_count: controlMappings.length,
|
|
505
|
+
mappings: controlMappings.map((m) => ({
|
|
506
|
+
uuid: m.uuid,
|
|
507
|
+
status: m.status,
|
|
508
|
+
description: m.justification || ""
|
|
509
|
+
}))
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
debug(`Exporting ${controlsWithMappings.length} controls as ${format}`);
|
|
513
|
+
switch (format.toLowerCase()) {
|
|
514
|
+
case "csv":
|
|
515
|
+
return exportAsCSV(controlsWithMappings, metadata, res);
|
|
516
|
+
case "excel":
|
|
517
|
+
case "xlsx":
|
|
518
|
+
return await exportAsExcel(controlsWithMappings, metadata, res);
|
|
519
|
+
case "json":
|
|
520
|
+
return exportAsJSON(controlsWithMappings, metadata, res);
|
|
521
|
+
default:
|
|
522
|
+
return res.status(400).json({ error: `Unsupported format: ${format}` });
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
console.error("Export error:", error);
|
|
526
|
+
res.status(500).json({ error: error.message });
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
function exportAsCSV(controls, metadata, res) {
|
|
530
|
+
const fieldSchema = metadata?.fieldSchema?.fields || {};
|
|
531
|
+
const controlIdField = metadata?.control_id_field || "id";
|
|
532
|
+
const allFields = /* @__PURE__ */ new Set();
|
|
533
|
+
controls.forEach((control) => {
|
|
534
|
+
Object.keys(control).forEach((key) => allFields.add(key));
|
|
535
|
+
});
|
|
536
|
+
const fieldMapping = [];
|
|
537
|
+
if (allFields.has(controlIdField)) {
|
|
538
|
+
const idSchema = fieldSchema[controlIdField];
|
|
539
|
+
fieldMapping.push({
|
|
540
|
+
fieldName: controlIdField,
|
|
541
|
+
displayName: idSchema?.original_name || "Control ID"
|
|
542
|
+
});
|
|
543
|
+
allFields.delete(controlIdField);
|
|
544
|
+
} else if (allFields.has("id")) {
|
|
545
|
+
fieldMapping.push({
|
|
546
|
+
fieldName: "id",
|
|
547
|
+
displayName: "Control ID"
|
|
548
|
+
});
|
|
549
|
+
allFields.delete("id");
|
|
550
|
+
}
|
|
551
|
+
if (allFields.has("family")) {
|
|
552
|
+
const familySchema = fieldSchema["family"];
|
|
553
|
+
fieldMapping.push({
|
|
554
|
+
fieldName: "family",
|
|
555
|
+
displayName: familySchema?.original_name || "Family"
|
|
556
|
+
});
|
|
557
|
+
allFields.delete("family");
|
|
558
|
+
}
|
|
559
|
+
Array.from(allFields).filter((field) => field !== "mappings" && field !== "mappings_count").sort().forEach((field) => {
|
|
560
|
+
const schema = fieldSchema[field];
|
|
561
|
+
const displayName = schema?.original_name || field.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
|
562
|
+
fieldMapping.push({ fieldName: field, displayName });
|
|
563
|
+
});
|
|
564
|
+
if (allFields.has("mappings_count")) {
|
|
565
|
+
fieldMapping.push({ fieldName: "mappings_count", displayName: "Mappings Count" });
|
|
566
|
+
}
|
|
567
|
+
if (allFields.has("mappings")) {
|
|
568
|
+
fieldMapping.push({ fieldName: "mappings", displayName: "Mappings" });
|
|
569
|
+
}
|
|
570
|
+
const csvRows = [];
|
|
571
|
+
csvRows.push(fieldMapping.map((field) => `"${field.displayName}"`).join(","));
|
|
572
|
+
controls.forEach((control) => {
|
|
573
|
+
const row = fieldMapping.map(({ fieldName }) => {
|
|
574
|
+
const value = control[fieldName];
|
|
575
|
+
if (value === void 0 || value === null) return '""';
|
|
576
|
+
if (fieldName === "mappings" && Array.isArray(value)) {
|
|
577
|
+
const mappingsStr = value.map(
|
|
578
|
+
(m) => `${m.status}: ${m.description.substring(0, 50)}${m.description.length > 50 ? "..." : ""}`
|
|
579
|
+
).join("; ");
|
|
580
|
+
return `"${mappingsStr.replace(/"/g, '""')}"`;
|
|
581
|
+
}
|
|
582
|
+
if (Array.isArray(value)) return `"${value.join("; ").replace(/"/g, '""')}"`;
|
|
583
|
+
if (typeof value === "object") return `"${JSON.stringify(value).replace(/"/g, '""')}"`;
|
|
584
|
+
return `"${String(value).replace(/"/g, '""')}"`;
|
|
585
|
+
});
|
|
586
|
+
csvRows.push(row.join(","));
|
|
587
|
+
});
|
|
588
|
+
const csvContent = csvRows.join("\n");
|
|
589
|
+
const fileName = `${metadata?.name || "controls"}_export_${Date.now()}.csv`;
|
|
590
|
+
res.setHeader("Content-Type", "text/csv");
|
|
591
|
+
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
592
|
+
res.send(csvContent);
|
|
593
|
+
}
|
|
594
|
+
async function exportAsExcel(controls, metadata, res) {
|
|
595
|
+
const fieldSchema = metadata?.fieldSchema?.fields || {};
|
|
596
|
+
const controlIdField = metadata?.control_id_field || "id";
|
|
597
|
+
const worksheetData = controls.map((control) => {
|
|
598
|
+
const exportControl = {};
|
|
599
|
+
if (control[controlIdField]) {
|
|
600
|
+
const idSchema = fieldSchema[controlIdField];
|
|
601
|
+
const idDisplayName = idSchema?.original_name || "Control ID";
|
|
602
|
+
exportControl[idDisplayName] = control[controlIdField];
|
|
603
|
+
} else if (control.id) {
|
|
604
|
+
exportControl["Control ID"] = control.id;
|
|
605
|
+
}
|
|
606
|
+
if (control.family) {
|
|
607
|
+
const familySchema = fieldSchema["family"];
|
|
608
|
+
const familyDisplayName = familySchema?.original_name || "Family";
|
|
609
|
+
exportControl[familyDisplayName] = control.family;
|
|
610
|
+
}
|
|
611
|
+
Object.keys(control).forEach((key) => {
|
|
612
|
+
if (key === controlIdField || key === "id" || key === "family") return;
|
|
613
|
+
const schema = fieldSchema[key];
|
|
614
|
+
const displayName = schema?.original_name || (key === "mappings_count" ? "Mappings Count" : key === "mappings" ? "Mappings" : key.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()));
|
|
615
|
+
const value = control[key];
|
|
616
|
+
if (key === "mappings" && Array.isArray(value)) {
|
|
617
|
+
exportControl[displayName] = value.map(
|
|
618
|
+
(m) => `${m.status}: ${m.description.substring(0, 100)}${m.description.length > 100 ? "..." : ""}`
|
|
619
|
+
).join("\n");
|
|
620
|
+
} else if (Array.isArray(value)) {
|
|
621
|
+
exportControl[displayName] = value.join("; ");
|
|
622
|
+
} else if (typeof value === "object" && value !== null) {
|
|
623
|
+
exportControl[displayName] = JSON.stringify(value);
|
|
624
|
+
} else {
|
|
625
|
+
exportControl[displayName] = value;
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
return exportControl;
|
|
629
|
+
});
|
|
630
|
+
const wb = new ExcelJS.Workbook();
|
|
631
|
+
const ws = wb.addWorksheet("Controls");
|
|
632
|
+
const headers = Object.keys(worksheetData[0] || {});
|
|
633
|
+
ws.columns = headers.map((header) => ({
|
|
634
|
+
header,
|
|
635
|
+
key: header,
|
|
636
|
+
width: Math.min(
|
|
637
|
+
Math.max(
|
|
638
|
+
header.length,
|
|
639
|
+
...worksheetData.map((row) => String(row[header] || "").length)
|
|
640
|
+
) + 2,
|
|
641
|
+
50
|
|
642
|
+
)
|
|
643
|
+
// Auto-size with max width of 50
|
|
644
|
+
}));
|
|
645
|
+
worksheetData.forEach((row) => {
|
|
646
|
+
ws.addRow(row);
|
|
647
|
+
});
|
|
648
|
+
ws.getRow(1).font = { bold: true };
|
|
649
|
+
ws.getRow(1).fill = {
|
|
650
|
+
type: "pattern",
|
|
651
|
+
pattern: "solid",
|
|
652
|
+
fgColor: { argb: "FFE0E0E0" }
|
|
653
|
+
};
|
|
654
|
+
if (metadata) {
|
|
655
|
+
const metaSheet = wb.addWorksheet("Metadata");
|
|
656
|
+
const cleanMetadata = { ...metadata };
|
|
657
|
+
delete cleanMetadata.fieldSchema;
|
|
658
|
+
metaSheet.columns = [
|
|
659
|
+
{ header: "Property", key: "property", width: 30 },
|
|
660
|
+
{ header: "Value", key: "value", width: 50 }
|
|
661
|
+
];
|
|
662
|
+
Object.entries(cleanMetadata).forEach(([key, value]) => {
|
|
663
|
+
metaSheet.addRow({ property: key, value: String(value) });
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
const buffer = await wb.xlsx.writeBuffer();
|
|
667
|
+
const fileName = `${metadata?.name || "controls"}_export_${Date.now()}.xlsx`;
|
|
668
|
+
res.setHeader(
|
|
669
|
+
"Content-Type",
|
|
670
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
671
|
+
);
|
|
672
|
+
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
673
|
+
res.send(buffer);
|
|
674
|
+
}
|
|
675
|
+
function exportAsJSON(controls, metadata, res) {
|
|
676
|
+
const exportData = {
|
|
677
|
+
metadata: metadata || {},
|
|
678
|
+
controlCount: controls.length,
|
|
679
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
680
|
+
controls
|
|
681
|
+
};
|
|
682
|
+
const fileName = `${metadata?.name || "controls"}_export_${Date.now()}.json`;
|
|
683
|
+
res.setHeader("Content-Type", "application/json");
|
|
684
|
+
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
685
|
+
res.json(exportData);
|
|
686
|
+
}
|
|
687
|
+
router.post("/parse-excel", upload.single("file"), async (req, res) => {
|
|
688
|
+
try {
|
|
689
|
+
if (!req.file) {
|
|
690
|
+
return res.status(400).json({ error: "No file uploaded" });
|
|
691
|
+
}
|
|
692
|
+
const fileName = req.file.originalname || "";
|
|
693
|
+
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
694
|
+
let sheets = [];
|
|
695
|
+
let rows = [];
|
|
696
|
+
if (isCSV) {
|
|
697
|
+
const csvContent = req.file.buffer.toString("utf-8");
|
|
698
|
+
rows = parseCSV(csvContent);
|
|
699
|
+
sheets = ["Sheet1"];
|
|
700
|
+
} else {
|
|
701
|
+
const workbook = new ExcelJS.Workbook();
|
|
702
|
+
const buffer = Buffer.from(req.file.buffer);
|
|
703
|
+
await workbook.xlsx.load(buffer);
|
|
704
|
+
sheets = workbook.worksheets.map((ws) => ws.name);
|
|
705
|
+
const worksheet = workbook.worksheets[0];
|
|
706
|
+
if (!worksheet) {
|
|
707
|
+
return res.status(400).json({ error: "No worksheet found in file" });
|
|
708
|
+
}
|
|
709
|
+
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
710
|
+
const rowData = [];
|
|
711
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
712
|
+
rowData[colNumber - 1] = cell.value;
|
|
713
|
+
});
|
|
714
|
+
rows.push(rowData);
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
const headerCandidates = rows.slice(0, 5).map((row, index) => ({
|
|
718
|
+
row: index + 1,
|
|
719
|
+
preview: row.slice(0, 4).filter((v) => v != null).join(", ") + (row.length > 4 ? ", ..." : "")
|
|
720
|
+
}));
|
|
721
|
+
res.json({
|
|
722
|
+
sheets,
|
|
723
|
+
selectedSheet: sheets[0],
|
|
724
|
+
rowPreviews: headerCandidates,
|
|
725
|
+
totalRows: rows.length,
|
|
726
|
+
sampleData: rows.slice(0, 10)
|
|
727
|
+
// First 10 rows for preview
|
|
728
|
+
});
|
|
729
|
+
} catch (error) {
|
|
730
|
+
console.error("Error parsing Excel file:", error);
|
|
731
|
+
res.status(500).json({ error: "Failed to parse Excel file" });
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
router.post("/parse-excel-sheet", upload.single("file"), async (req, res) => {
|
|
735
|
+
try {
|
|
736
|
+
const { sheetName, headerRow } = req.body;
|
|
737
|
+
if (!req.file) {
|
|
738
|
+
return res.status(400).json({ error: "No file uploaded" });
|
|
739
|
+
}
|
|
740
|
+
const fileName = req.file.originalname || "";
|
|
741
|
+
const isCSV = fileName.toLowerCase().endsWith(".csv");
|
|
742
|
+
let rows = [];
|
|
743
|
+
if (isCSV) {
|
|
744
|
+
const csvContent = req.file.buffer.toString("utf-8");
|
|
745
|
+
rows = parseCSV(csvContent);
|
|
746
|
+
} else {
|
|
747
|
+
const workbook = new ExcelJS.Workbook();
|
|
748
|
+
const buffer = Buffer.from(req.file.buffer);
|
|
749
|
+
await workbook.xlsx.load(buffer);
|
|
750
|
+
const worksheet = workbook.getWorksheet(sheetName);
|
|
751
|
+
if (!worksheet) {
|
|
752
|
+
return res.status(400).json({ error: `Sheet "${sheetName}" not found` });
|
|
753
|
+
}
|
|
754
|
+
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
755
|
+
const rowData = [];
|
|
756
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
757
|
+
rowData[colNumber - 1] = cell.value;
|
|
758
|
+
});
|
|
759
|
+
rows.push(rowData);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
const headerRowIndex = parseInt(headerRow) - 1;
|
|
763
|
+
const headers = rows[headerRowIndex] || [];
|
|
764
|
+
const fields = headers.filter((h) => h && typeof h === "string");
|
|
765
|
+
const sampleData = rows.slice(headerRowIndex + 1, headerRowIndex + 4).map((row) => {
|
|
766
|
+
const obj = {};
|
|
767
|
+
headers.forEach((header, index) => {
|
|
768
|
+
if (header) {
|
|
769
|
+
obj[header] = row[index];
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
return obj;
|
|
773
|
+
});
|
|
774
|
+
res.json({
|
|
775
|
+
fields,
|
|
776
|
+
sampleData,
|
|
777
|
+
controlCount: rows.length - headerRowIndex - 1
|
|
778
|
+
});
|
|
779
|
+
} catch (error) {
|
|
780
|
+
console.error("Error parsing Excel sheet:", error);
|
|
781
|
+
res.status(500).json({ error: "Failed to parse Excel sheet" });
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
var spreadsheetRoutes_default = router;
|
|
785
|
+
export {
|
|
786
|
+
spreadsheetRoutes_default as default,
|
|
787
|
+
scanControlSets
|
|
788
|
+
};
|