@cyprnet/node-red-contrib-uibuilder-formgen 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +22 -0
  3. package/README.md +58 -0
  4. package/docs/user-guide.html +565 -0
  5. package/examples/formgen-builder/src/index.html +921 -0
  6. package/examples/formgen-builder/src/index.js +1338 -0
  7. package/examples/portalsmith-formgen-example.json +531 -0
  8. package/examples/schema-builder-integration.json +109 -0
  9. package/examples/schemas/Banking/banking_fraud_report.json +102 -0
  10. package/examples/schemas/Banking/banking_kyc_update.json +59 -0
  11. package/examples/schemas/Banking/banking_loan_application.json +113 -0
  12. package/examples/schemas/Banking/banking_new_account.json +98 -0
  13. package/examples/schemas/Banking/banking_wire_transfer_request.json +94 -0
  14. package/examples/schemas/HR/hr_employee_change_form.json +65 -0
  15. package/examples/schemas/HR/hr_exit_interview.json +105 -0
  16. package/examples/schemas/HR/hr_job_application.json +166 -0
  17. package/examples/schemas/HR/hr_onboarding_request.json +140 -0
  18. package/examples/schemas/HR/hr_time_off_request.json +95 -0
  19. package/examples/schemas/HR/hr_training_request.json +70 -0
  20. package/examples/schemas/Healthcare/health_appointment_request.json +103 -0
  21. package/examples/schemas/Healthcare/health_incident_report.json +82 -0
  22. package/examples/schemas/Healthcare/health_lab_order_request.json +72 -0
  23. package/examples/schemas/Healthcare/health_medication_refill.json +72 -0
  24. package/examples/schemas/Healthcare/health_patient_intake.json +113 -0
  25. package/examples/schemas/IT/it_access_request.json +145 -0
  26. package/examples/schemas/IT/it_dhcp_reservation.json +175 -0
  27. package/examples/schemas/IT/it_dns_domain_external.json +192 -0
  28. package/examples/schemas/IT/it_dns_domain_internal.json +171 -0
  29. package/examples/schemas/IT/it_network_change_request.json +126 -0
  30. package/examples/schemas/IT/it_network_request-form.json +299 -0
  31. package/examples/schemas/IT/it_new_hardware_request.json +155 -0
  32. package/examples/schemas/IT/it_password_reset.json +133 -0
  33. package/examples/schemas/IT/it_software_license_request.json +93 -0
  34. package/examples/schemas/IT/it_static_ip_request.json +199 -0
  35. package/examples/schemas/IT/it_subnet_request_form.json +216 -0
  36. package/examples/schemas/Maintenance/maint_checklist.json +176 -0
  37. package/examples/schemas/Maintenance/maint_facility_issue_report.json +127 -0
  38. package/examples/schemas/Maintenance/maint_incident_intake.json +174 -0
  39. package/examples/schemas/Maintenance/maint_inventory_restock.json +79 -0
  40. package/examples/schemas/Maintenance/maint_safety_audit.json +92 -0
  41. package/examples/schemas/Maintenance/maint_vehicle_inspection.json +112 -0
  42. package/examples/schemas/Maintenance/maint_work_order.json +134 -0
  43. package/index.js +12 -0
  44. package/lib/licensing.js +254 -0
  45. package/nodes/portalsmith-license.html +40 -0
  46. package/nodes/portalsmith-license.js +23 -0
  47. package/nodes/uibuilder-formgen.html +261 -0
  48. package/nodes/uibuilder-formgen.js +598 -0
  49. package/package.json +47 -0
  50. package/scripts/normalize_schema_titles.py +77 -0
  51. package/templates/index.html.mustache +541 -0
  52. package/templates/index.js.mustache +1135 -0
@@ -0,0 +1,598 @@
1
+ /**
2
+ * PortalSmith FormGen - uibuilder-formgen node
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * Copyright (c) 2026 CyprNet Solutions, LLC
6
+ */
7
+
8
+ module.exports = function(RED) {
9
+ const path = require("path");
10
+ const fs = require("fs-extra");
11
+ const Mustache = require("mustache");
12
+ const crypto = require("crypto");
13
+ const http = require("http");
14
+ const https = require("https");
15
+ const licensing = require("../lib/licensing");
16
+
17
+ // Register admin endpoints once (used by the editor UI to show license status)
18
+ let _adminRegistered = false;
19
+ function registerAdminEndpoints() {
20
+ if (_adminRegistered) return;
21
+ _adminRegistered = true;
22
+ if (!RED.httpAdmin) return;
23
+
24
+ const guard =
25
+ RED.auth && typeof RED.auth.needsPermission === "function"
26
+ ? RED.auth.needsPermission("uibuilder-formgen.read")
27
+ : function (_req, _res, next) { next(); };
28
+
29
+ RED.httpAdmin.post("/portalsmith/license/validate", guard, function (req, res) {
30
+ try {
31
+ const body = req.body || {};
32
+ const licenseKey = String(body.licenseKey || "").trim();
33
+ const licenseConfigId = String(body.licenseConfigId || "").trim();
34
+
35
+ let resolvedKey = licenseKey;
36
+ if (!resolvedKey && licenseConfigId) {
37
+ const cfg = RED.nodes.getNode(licenseConfigId);
38
+ resolvedKey = String(cfg && cfg.credentials ? cfg.credentials.licenseKey : "").trim();
39
+ }
40
+
41
+ const result = licensing.resolveLicenseString(resolvedKey);
42
+ const verifier = licensing.getVerifierPublicKey();
43
+ res.json({
44
+ ok: true,
45
+ licensed: result.licensed,
46
+ tier: result.tier,
47
+ reason: result.reason,
48
+ displayName: result.display && result.display.displayName ? result.display.displayName : "",
49
+ licensedTo: result.display && result.display.licensedTo ? result.display.licensedTo : "",
50
+ expiresAt: result.display && result.display.expiresAt ? result.display.expiresAt : "",
51
+ features: result.features || {},
52
+ policy: result.policy || {},
53
+ verifier: {
54
+ source: verifier.source,
55
+ fingerprint: licensing.publicKeyFingerprint(verifier.pem),
56
+ }
57
+ });
58
+ } catch (e) {
59
+ res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) });
60
+ }
61
+ });
62
+ }
63
+ registerAdminEndpoints();
64
+
65
+ function isNonEmptyString(v) {
66
+ return typeof v === "string" && v.trim().length > 0;
67
+ }
68
+
69
+ function safeBasename(filename) {
70
+ // Basic hardening: strip path separators
71
+ return path.basename(String(filename || "").replace(/[/\\]+/g, "_"));
72
+ }
73
+
74
+ async function maybePrepareLogo({ logoPathRaw, logoAltRaw, srcDir }) {
75
+ const logoPath = String(logoPathRaw ?? "").trim();
76
+ const logoAlt = String(logoAltRaw ?? "Logo").trim() || "Logo";
77
+
78
+ if (!isNonEmptyString(logoPath)) {
79
+ return { logoUrl: "", logoAlt: logoAlt };
80
+ }
81
+
82
+ // If absolute path, copy it into src/images and reference it as a relative URL
83
+ if (path.isAbsolute(logoPath)) {
84
+ const exists = await fs.pathExists(logoPath);
85
+ if (!exists) {
86
+ throw new Error(`logoPath does not exist: ${logoPath}`);
87
+ }
88
+
89
+ const imagesDir = path.join(srcDir, "images");
90
+ await fs.ensureDir(imagesDir);
91
+
92
+ const srcBase = safeBasename(logoPath);
93
+ // Avoid collisions if user generates multiple times with different files of same name
94
+ const hash = crypto.createHash("sha1").update(logoPath).digest("hex").slice(0, 8);
95
+ const outName = `${hash}-${srcBase}`;
96
+ const outPath = path.join(imagesDir, outName);
97
+
98
+ await fs.copy(logoPath, outPath, { overwrite: true, errorOnExist: false });
99
+ return { logoUrl: `./images/${outName}`, logoAlt };
100
+ }
101
+
102
+ // Non-absolute: assume it's a filename under src/images/
103
+ const trimmed = logoPath.replace(/^\.?\/*/, ""); // remove leading ./ or /
104
+ const fileOnly = safeBasename(trimmed);
105
+ return { logoUrl: `./images/${fileOnly}`, logoAlt };
106
+ }
107
+
108
+ async function httpRequestJson({ url, method, headers, body, insecureTls, timeoutMs }) {
109
+ const urlObj = new URL(url);
110
+ const isHttps = urlObj.protocol === "https:";
111
+ const lib = isHttps ? https : http;
112
+
113
+ const reqHeaders = Object.assign({}, headers || {});
114
+ let bodyStr = "";
115
+ if (body !== undefined) {
116
+ bodyStr = typeof body === "string" ? body : JSON.stringify(body);
117
+ if (!reqHeaders["Content-Type"] && !reqHeaders["content-type"]) {
118
+ reqHeaders["Content-Type"] = "application/json";
119
+ }
120
+ reqHeaders["Content-Length"] = Buffer.byteLength(bodyStr);
121
+ }
122
+
123
+ const agent = isHttps
124
+ ? new https.Agent({ rejectUnauthorized: insecureTls ? false : true })
125
+ : undefined;
126
+
127
+ const options = {
128
+ method: method || "POST",
129
+ protocol: urlObj.protocol,
130
+ hostname: urlObj.hostname,
131
+ port: urlObj.port || (isHttps ? 443 : 80),
132
+ path: urlObj.pathname + urlObj.search,
133
+ headers: reqHeaders,
134
+ agent,
135
+ timeout: Number(timeoutMs || 15000),
136
+ };
137
+
138
+ return await new Promise((resolve, reject) => {
139
+ const req = lib.request(options, (res) => {
140
+ const chunks = [];
141
+ res.on("data", (d) => chunks.push(d));
142
+ res.on("end", () => {
143
+ const text = Buffer.concat(chunks).toString("utf8");
144
+ const ct = String(res.headers["content-type"] || "").toLowerCase();
145
+ let json = null;
146
+ if (ct.includes("application/json")) {
147
+ try { json = JSON.parse(text); } catch (e) { /* ignore */ }
148
+ }
149
+ resolve({
150
+ status: res.statusCode || 0,
151
+ headers: res.headers,
152
+ text,
153
+ json,
154
+ });
155
+ });
156
+ });
157
+
158
+ req.on("timeout", () => {
159
+ req.destroy(new Error("Request timeout"));
160
+ });
161
+ req.on("error", reject);
162
+
163
+ if (bodyStr) req.write(bodyStr);
164
+ req.end();
165
+ });
166
+ }
167
+
168
+ function resolveUibuilderPaths({ RED, config, msg, instanceName }) {
169
+ const userDir = RED.settings.userDir || path.join(process.env.HOME || "", ".node-red");
170
+
171
+ // Optional overrides (msg wins over config)
172
+ const projectName = String(msg.options?.projectName ?? config.projectName ?? "").trim();
173
+ const uibRootDir = String(msg.options?.uibRootDir ?? msg.options?.uibRoot ?? config.uibRootDir ?? "").trim();
174
+ const instanceRootDir = String(msg.options?.instanceRootDir ?? config.instanceRootDir ?? "").trim();
175
+
176
+ // Precedence:
177
+ // 1) instanceRootDir (full path to instance root)
178
+ // 2) uibRootDir (full path to uibuilder root) + instanceName
179
+ // 3) projectName (derive: userDir/projects/<projectName>/uibuilder) + instanceName
180
+ // 4) default: userDir/uibuilder + instanceName
181
+
182
+ if (isNonEmptyString(instanceRootDir)) {
183
+ if (!path.isAbsolute(instanceRootDir)) {
184
+ throw new Error(`instanceRootDir must be an absolute path. Got: ${instanceRootDir}`);
185
+ }
186
+ const instRoot = instanceRootDir;
187
+ return {
188
+ userDir,
189
+ uibRoot: path.dirname(instRoot),
190
+ instRoot,
191
+ srcDir: path.join(instRoot, "src"),
192
+ };
193
+ }
194
+
195
+ let uibRoot;
196
+ if (isNonEmptyString(uibRootDir)) {
197
+ if (!path.isAbsolute(uibRootDir)) {
198
+ throw new Error(`uibRootDir must be an absolute path. Got: ${uibRootDir}`);
199
+ }
200
+ uibRoot = uibRootDir;
201
+ } else if (isNonEmptyString(projectName)) {
202
+ // Common Node-RED projects layout
203
+ uibRoot = path.join(userDir, "projects", projectName, "uibuilder");
204
+ } else {
205
+ uibRoot = path.join(userDir, "uibuilder");
206
+ }
207
+
208
+ const instRoot = path.join(uibRoot, instanceName);
209
+ return { userDir, uibRoot, instRoot, srcDir: path.join(instRoot, "src") };
210
+ }
211
+
212
+ function UibuilderFormGenNode(config) {
213
+ RED.nodes.createNode(this, config);
214
+ const node = this;
215
+ const licenseConfigNode = config.licenseConfig ? RED.nodes.getNode(config.licenseConfig) : null;
216
+
217
+ node.on("input", async function(msg, send, done) {
218
+ try {
219
+ // Handle runtime submit proxy (messages coming back from uibuilder)
220
+ const payload = msg && (msg.payload ?? msg);
221
+ if (payload && typeof payload === "object" && payload.type === "submit") {
222
+ const formId = payload.formId || "";
223
+ const submitPayload = payload.payload ?? payload.data ?? payload;
224
+
225
+ // Determine API config (msg.options overrides node config)
226
+ const apiUrl = String(msg.options?.apiUrl ?? config.apiUrl ?? "").trim();
227
+ const apiMethod = String(msg.options?.apiMethod ?? config.apiMethod ?? "POST").trim().toUpperCase();
228
+ const apiHeadersJson = String(msg.options?.apiHeadersJson ?? config.apiHeadersJson ?? "").trim();
229
+ const apiInsecureTls = (msg.options?.apiInsecureTls ?? config.apiInsecureTls) === true;
230
+ const apiTimeoutMs = Number(msg.options?.apiTimeoutMs ?? config.apiTimeoutMs ?? 15000);
231
+
232
+ let apiHeaders = {};
233
+ if (isNonEmptyString(apiHeadersJson)) {
234
+ try { apiHeaders = JSON.parse(apiHeadersJson); } catch (e) { throw new Error("apiHeadersJson must be valid JSON"); }
235
+ }
236
+
237
+ // If no apiUrl configured, just pass-through submit (so flows can handle it)
238
+ if (!isNonEmptyString(apiUrl)) {
239
+ msg.payload = {
240
+ type: "submit:ok",
241
+ formId,
242
+ result: { passthrough: true, payload: submitPayload },
243
+ status: 0,
244
+ };
245
+ send(msg);
246
+ done();
247
+ return;
248
+ }
249
+
250
+ const apiBody = {
251
+ formId,
252
+ payload: submitPayload,
253
+ meta: payload.meta || {},
254
+ };
255
+
256
+ const resp = await httpRequestJson({
257
+ url: apiUrl,
258
+ method: apiMethod,
259
+ headers: apiHeaders,
260
+ body: apiBody,
261
+ insecureTls: apiInsecureTls,
262
+ timeoutMs: apiTimeoutMs,
263
+ });
264
+
265
+ msg.payload = {
266
+ type: resp.status >= 200 && resp.status < 300 ? "submit:ok" : "submit:error",
267
+ formId,
268
+ status: resp.status,
269
+ result: resp.json !== null ? resp.json : resp.text,
270
+ responseHeaders: resp.headers,
271
+ };
272
+
273
+ send(msg);
274
+ done();
275
+ return;
276
+ }
277
+
278
+ // Get schema from msg.schema (preferred) or msg.payload (fallback)
279
+ let schema = msg.schema;
280
+ if (!schema || typeof schema !== "object") {
281
+ schema = msg.payload;
282
+ }
283
+
284
+ // If schema is a string, try to parse it as JSON
285
+ if (typeof schema === "string") {
286
+ try {
287
+ schema = JSON.parse(schema);
288
+ } catch (e) {
289
+ throw new Error("msg.schema or msg.payload must be a JSON object or valid JSON string");
290
+ }
291
+ }
292
+
293
+ if (!schema || typeof schema !== "object") {
294
+ throw new Error("Missing required msg.schema or msg.payload object");
295
+ }
296
+
297
+ // Validate schema structure
298
+ validateSchema(schema);
299
+
300
+ // Get instanceName from msg.uibuilder (preferred) or config
301
+ const instanceName = String(
302
+ msg.uibuilder ??
303
+ msg.options?.instance ??
304
+ config.instanceName ??
305
+ "formgen"
306
+ ).trim();
307
+
308
+ // Validate instanceName pattern
309
+ if (!/^[a-zA-Z0-9._-]+$/.test(instanceName)) {
310
+ throw new Error("Invalid instanceName; use letters/numbers/._- only");
311
+ }
312
+
313
+ // Get options
314
+ const overwrite = (msg.options?.overwrite ?? config.overwrite) !== false;
315
+ const storageMode = msg.options?.storageMode ?? config.storageMode ?? "file";
316
+ const exportFormats = msg.options?.exportFormats ??
317
+ (config.exportFormats ? JSON.parse(config.exportFormats) : ["json", "csv", "html"]);
318
+ const uiTitle = msg.options?.uiTitle ?? schema.title ?? "PortalSmith Form";
319
+ const themeMode = String(msg.options?.themeMode ?? config.themeMode ?? "auto").trim().toLowerCase();
320
+
321
+ // Validate themeMode
322
+ if (!["auto", "light", "dark"].includes(themeMode)) {
323
+ throw new Error("themeMode must be 'auto', 'light', or 'dark'");
324
+ }
325
+
326
+ // Validate storageMode
327
+ if (!["file", "localstorage"].includes(storageMode)) {
328
+ throw new Error("storageMode must be 'file' or 'localstorage'");
329
+ }
330
+
331
+ // Validate exportFormats
332
+ const validFormats = ["json", "csv", "html"];
333
+ const invalidFormats = exportFormats.filter(f => !validFormats.includes(f));
334
+ if (invalidFormats.length > 0) {
335
+ throw new Error(`Invalid exportFormats: ${invalidFormats.join(", ")}`);
336
+ }
337
+
338
+ // Resolve output folders (supports Node-RED Projects and explicit path overrides)
339
+ const { userDir, uibRoot, instRoot, srcDir } = resolveUibuilderPaths({
340
+ RED,
341
+ config,
342
+ msg,
343
+ instanceName,
344
+ });
345
+
346
+ // Create folders
347
+ await fs.ensureDir(srcDir);
348
+
349
+ // Licensing (offline-first): missing/invalid => FREE (watermarked)
350
+ const license = licensing.resolveLicense({ node, RED, licenseConfigNode });
351
+ const licensePublic = {
352
+ licensed: Boolean(license.licensed),
353
+ tier: license.tier || "FREE",
354
+ watermark: Boolean(license.policy && license.policy.watermark),
355
+ brandingLocked: Boolean(license.policy && license.policy.brandingLocked),
356
+ displayName: String(license.display && license.display.displayName ? license.display.displayName : ""),
357
+ reason: String(license.reason || "missing"),
358
+ brandingAttempted: false,
359
+ };
360
+
361
+ // Logo (optional): absolute path will be copied into src/images automatically
362
+ const logoRequested = isNonEmptyString(msg.options?.logoPath ?? config.logoPath ?? "");
363
+ let logoPathRaw = msg.options?.logoPath ?? config.logoPath ?? "";
364
+ const logoAltRaw = msg.options?.logoAlt ?? config.logoAlt ?? "Logo";
365
+ if (isNonEmptyString(logoPathRaw) && licensePublic.brandingLocked) {
366
+ node.warn("PortalSmith FormGen: custom logo is disabled in Free mode (watermarked). Using default branding.");
367
+ licensePublic.brandingAttempted = Boolean(logoRequested);
368
+ logoPathRaw = "";
369
+ }
370
+ const { logoUrl, logoAlt } = await maybePrepareLogo({ logoPathRaw, logoAltRaw, srcDir });
371
+
372
+ // Submit configuration (optional)
373
+ const submitMode = String(msg.options?.submitMode ?? config.submitMode ?? "uibuilder").trim().toLowerCase();
374
+ const submitUrl = String(msg.options?.submitUrl ?? config.submitUrl ?? "").trim();
375
+ const submitHeadersJson = String(msg.options?.submitHeadersJson ?? config.submitHeadersJson ?? "").trim();
376
+
377
+ if (!["uibuilder", "http"].includes(submitMode)) {
378
+ throw new Error("submitMode must be 'uibuilder' or 'http'");
379
+ }
380
+ if (submitMode === "http" && !isNonEmptyString(submitUrl)) {
381
+ throw new Error("submitUrl is required when submitMode is 'http'");
382
+ }
383
+ if (isNonEmptyString(submitHeadersJson)) {
384
+ try { JSON.parse(submitHeadersJson); } catch (e) { throw new Error("submitHeadersJson must be valid JSON"); }
385
+ }
386
+
387
+ // Define target files (write to src/ folder)
388
+ const targetHtml = path.join(srcDir, "index.html");
389
+ const targetJs = path.join(srcDir, "index.js");
390
+ // Schema goes in src/ so frontend can load it via fetch('./form.schema.json')
391
+ const targetSchema = path.join(srcDir, "form.schema.json");
392
+ // Runtime metadata can stay in root for reference
393
+ const targetRuntime = path.join(instRoot, "portalsmith.runtime.json");
394
+
395
+ // Check overwrite flag
396
+ if (!overwrite) {
397
+ const existingFiles = [targetHtml, targetJs, targetSchema, targetRuntime];
398
+ for (const f of existingFiles) {
399
+ if (await fs.pathExists(f)) {
400
+ throw new Error(`File exists and overwrite=false: ${f}`);
401
+ }
402
+ }
403
+ }
404
+
405
+ // Calculate schema summary
406
+ const schemaSummary = calculateSchemaSummary(schema);
407
+
408
+ // Load mustache templates
409
+ const templatesDir = path.join(__dirname, "..", "templates");
410
+ const htmlTemplate = await fs.readFile(
411
+ path.join(templatesDir, "index.html.mustache"),
412
+ "utf8"
413
+ );
414
+ const jsTemplate = await fs.readFile(
415
+ path.join(templatesDir, "index.js.mustache"),
416
+ "utf8"
417
+ );
418
+
419
+ // Prepare view model for HTML template
420
+ const baseUrl = config.uibuilderUrl || "/uibuilder";
421
+ const htmlViewModel = {
422
+ title: uiTitle,
423
+ description: schema.description || "",
424
+ baseUrl: baseUrl,
425
+ themeMode: themeMode,
426
+ logoUrl: logoUrl,
427
+ logoAlt: logoAlt,
428
+ licensed: licensePublic.licensed
429
+ };
430
+
431
+ // Prepare view model for JS template
432
+ const jsViewModel = {
433
+ timestamp: new Date().toISOString(),
434
+ instanceName: instanceName,
435
+ formId: schema.formId || schemaSummary.formId,
436
+ storageMode: storageMode,
437
+ exportFormatsJson: JSON.stringify(exportFormats), // Pass as JSON string
438
+ baseUrl: baseUrl,
439
+ themeMode: themeMode,
440
+ submitMode: submitMode,
441
+ submitUrl: submitUrl,
442
+ submitHeadersJson: submitHeadersJson,
443
+ licenseJson: JSON.stringify(licensePublic)
444
+ };
445
+
446
+ // Render templates
447
+ // Use custom delimiters for Mustache to avoid conflicts with Vue bindings
448
+ // Mustache will use [[ ]] instead of {{ }}
449
+ const oldTags = Mustache.tags;
450
+ Mustache.tags = ['[[', ']]'];
451
+ let renderedHtml = Mustache.render(htmlTemplate, htmlViewModel);
452
+ let renderedJs = Mustache.render(jsTemplate, jsViewModel);
453
+ Mustache.tags = oldTags; // Restore default
454
+
455
+ // Post-process: replace JSON placeholder with actual JSON (unescaped)
456
+ renderedJs = renderedJs.replace('__EXPORT_FORMATS_JSON__', JSON.stringify(exportFormats));
457
+ renderedJs = renderedJs.replace('__LICENSE_JSON__', jsViewModel.licenseJson);
458
+
459
+ // Write files
460
+ await fs.writeFile(targetHtml, renderedHtml, "utf8");
461
+ await fs.writeFile(targetJs, renderedJs, "utf8");
462
+ await fs.writeJson(targetSchema, schema, { spaces: 2 });
463
+
464
+ // Write runtime metadata
465
+ const runtimeData = {
466
+ generatorVersion: "0.4.9",
467
+ timestamp: new Date().toISOString(),
468
+ instanceName: instanceName,
469
+ storageMode: storageMode,
470
+ exportFormats: exportFormats,
471
+ themeMode: themeMode,
472
+ logoUrl: logoUrl || "",
473
+ logoAlt: logoAlt || "",
474
+ submitMode: submitMode,
475
+ submitUrl: submitUrl,
476
+ license: licensePublic,
477
+ schemaSummary: schemaSummary
478
+ };
479
+ await fs.writeJson(targetRuntime, runtimeData, { spaces: 2 });
480
+
481
+ // Set output payload
482
+ msg.payload = {
483
+ status: "generated",
484
+ instance: instanceName,
485
+ uibRoot: uibRoot,
486
+ path: instRoot,
487
+ srcPath: srcDir,
488
+ userDir: userDir,
489
+ url: `${baseUrl}/${instanceName}/`,
490
+ files: ["src/index.html", "src/index.js", "src/form.schema.json", "portalsmith.runtime.json"],
491
+ schemaSummary: schemaSummary
492
+ };
493
+
494
+ send(msg);
495
+ done();
496
+ } catch (err) {
497
+ node.error(err, msg);
498
+ done(err);
499
+ }
500
+ });
501
+ }
502
+
503
+ function validateSchema(schema) {
504
+ if (!schema.schemaVersion) {
505
+ throw new Error("Schema missing required 'schemaVersion' field");
506
+ }
507
+
508
+ if (!schema.sections || !Array.isArray(schema.sections)) {
509
+ throw new Error("Schema missing required 'sections' array");
510
+ }
511
+
512
+ if (schema.sections.length === 0) {
513
+ throw new Error("Schema must have at least one section");
514
+ }
515
+
516
+ // Validate each section
517
+ schema.sections.forEach((section, idx) => {
518
+ if (!section.fields || !Array.isArray(section.fields)) {
519
+ throw new Error(`Section ${idx} missing required 'fields' array`);
520
+ }
521
+
522
+ if (section.fields.length === 0) {
523
+ throw new Error(`Section ${idx} must have at least one field`);
524
+ }
525
+
526
+ // Validate each field
527
+ section.fields.forEach((field, fieldIdx) => {
528
+ if (!field.id && !field.key) {
529
+ throw new Error(`Section ${idx}, field ${fieldIdx} missing required 'id' or 'key'`);
530
+ }
531
+
532
+ // Normalize: use 'id' if 'key' is present
533
+ if (field.key && !field.id) {
534
+ field.id = field.key;
535
+ }
536
+
537
+ if (!field.type) {
538
+ throw new Error(`Section ${idx}, field ${field.id} missing required 'type'`);
539
+ }
540
+
541
+ const validTypes = ["text", "textarea", "number", "select", "checkbox", "radio", "date", "keyvalue"];
542
+ if (!validTypes.includes(field.type)) {
543
+ throw new Error(`Section ${idx}, field ${field.id} has invalid type: ${field.type}`);
544
+ }
545
+
546
+ // Validate select/radio options
547
+ if ((field.type === "select" || field.type === "radio") && !field.options) {
548
+ throw new Error(`Section ${idx}, field ${field.id} (${field.type}) missing required 'options'`);
549
+ }
550
+
551
+ // Validate keyvalue fields
552
+ if (field.type === "keyvalue") {
553
+ const mode = field.keyvalueMode || "pairs";
554
+ if (mode === "pairs") {
555
+ if (!field.pairs || !Array.isArray(field.pairs) || field.pairs.length === 0) {
556
+ throw new Error(`Section ${idx}, field ${field.id} (keyvalue pairs mode) requires at least one key-value pair`);
557
+ }
558
+ } else if (mode === "delimiter") {
559
+ if (!field.keyvalueDelimiter || String(field.keyvalueDelimiter).trim() === "") {
560
+ throw new Error(`Section ${idx}, field ${field.id} (keyvalue delimiter mode) requires a delimiter`);
561
+ }
562
+ } else {
563
+ throw new Error(`Section ${idx}, field ${field.id} (keyvalue) has invalid mode: ${mode}. Must be 'pairs' or 'delimiter'`);
564
+ }
565
+ }
566
+ });
567
+ });
568
+ }
569
+
570
+ function calculateSchemaSummary(schema) {
571
+ const formId = schema.formId || "unknown";
572
+ const title = schema.title || "Untitled Form";
573
+ const sectionCount = schema.sections ? schema.sections.length : 0;
574
+
575
+ let fieldCount = 0;
576
+ if (schema.sections) {
577
+ schema.sections.forEach(section => {
578
+ if (section.fields) {
579
+ fieldCount += section.fields.length;
580
+ }
581
+ });
582
+ }
583
+
584
+ return {
585
+ formId: formId,
586
+ title: title,
587
+ sectionCount: sectionCount,
588
+ fieldCount: fieldCount
589
+ };
590
+ }
591
+
592
+ RED.nodes.registerType("uibuilder-formgen", UibuilderFormGenNode, {
593
+ credentials: {
594
+ licenseKey: { type: "password" },
595
+ },
596
+ });
597
+ };
598
+
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@cyprnet/node-red-contrib-uibuilder-formgen",
3
+ "version": "0.4.11",
4
+ "description": "PortalSmith: Generate schema-driven uibuilder form portals from JSON",
5
+ "keywords": [
6
+ "node-red",
7
+ "node-red-contrib",
8
+ "uibuilder",
9
+ "form",
10
+ "form-generator",
11
+ "schema",
12
+ "json-schema",
13
+ "portal",
14
+ "workflow-automation",
15
+ "bootstrap",
16
+ "vue"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "CyprNet Solutions, LLC (CyprNet Solutions)",
20
+ "homepage": "https://www.cyprnetsolutions.com",
21
+ "main": "index.js",
22
+ "files": [
23
+ "nodes",
24
+ "templates",
25
+ "examples",
26
+ "docs",
27
+ "scripts",
28
+ "lib",
29
+ "README.md",
30
+ "LICENSE",
31
+ "CHANGELOG.md"
32
+ ],
33
+ "scripts": {
34
+ "lint": "echo \"(optional) add eslint later\"",
35
+ "test": "echo \"no tests yet\""
36
+ },
37
+ "dependencies": {
38
+ "fs-extra": "^11.2.0",
39
+ "mustache": "^4.2.0"
40
+ },
41
+ "node-red": {
42
+ "nodes": {
43
+ "uibuilder-formgen": "nodes/uibuilder-formgen.js",
44
+ "portalsmith-license": "nodes/portalsmith-license.js"
45
+ }
46
+ }
47
+ }