@cyprnet/node-red-contrib-uibuilder-formgen 0.5.1 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  All notable changes to this package will be documented in this file.
4
4
 
5
+ ## 0.5.2
6
+
7
+ - Schema Builder: added **Generate Schema (Plain JSON)** to auto-create a PortalSmith schema from a plain JSON config object and load it into the builder for editing.
8
+
5
9
  ## 0.4.0
6
10
 
7
11
  - Added **offline-first licensing framework** (Free vs Licensed).
@@ -239,6 +239,9 @@
239
239
  <b-button variant="info" @click="loadSchema" class="mr-2">
240
240
  <i class="fa fa-folder-open"></i> Load Schema
241
241
  </b-button>
242
+ <b-button variant="warning" @click="openPlainJsonGenerator" class="mr-2">
243
+ <i class="fa fa-bolt"></i> Generate Schema (Plain JSON)
244
+ </b-button>
242
245
  <b-button variant="success" @click="importJSON" class="mr-2">
243
246
  <i class="fa fa-file-import"></i> Import JSON
244
247
  </b-button>
@@ -709,6 +712,66 @@ email{{currentField.keyvalueDelimiter || '='}}john@example.com</code></pre>
709
712
  </small>
710
713
  </b-form-group>
711
714
  </b-modal>
715
+
716
+ <!-- Plain JSON -> Schema Generator Modal -->
717
+ <b-modal
718
+ id="plain-json-modal"
719
+ title="Generate Schema from Plain JSON"
720
+ size="lg"
721
+ @ok="processPlainJsonGenerate"
722
+ @cancel="cancelPlainJsonGenerate"
723
+ ok-title="Generate"
724
+ cancel-title="Cancel"
725
+ >
726
+ <b-alert variant="info" show class="mb-3">
727
+ Paste a <strong>plain JSON object</strong> (like a config file). The builder will infer field types and create sections automatically.
728
+ You can edit everything after generation.
729
+ </b-alert>
730
+
731
+ <b-form-row>
732
+ <b-col md="6">
733
+ <b-form-group label="Form ID" label-for="plain-form-id">
734
+ <b-form-input
735
+ id="plain-form-id"
736
+ v-model="plainGenFormId"
737
+ placeholder="my_form_id"
738
+ />
739
+ <small class="form-text text-muted">Alphanumeric, underscores, hyphens only. If blank, we will derive one.</small>
740
+ </b-form-group>
741
+ </b-col>
742
+ <b-col md="6">
743
+ <b-form-group label="Form Title" label-for="plain-title">
744
+ <b-form-input
745
+ id="plain-title"
746
+ v-model="plainGenTitle"
747
+ placeholder="My Form Title"
748
+ />
749
+ <small class="form-text text-muted">If blank, we will derive a title.</small>
750
+ </b-form-group>
751
+ </b-col>
752
+ </b-form-row>
753
+
754
+ <b-form-group label="Description (optional)" label-for="plain-desc">
755
+ <b-form-textarea
756
+ id="plain-desc"
757
+ v-model="plainGenDescription"
758
+ rows="2"
759
+ placeholder="Optional description"
760
+ />
761
+ </b-form-group>
762
+
763
+ <b-form-group label="Plain JSON" label-for="plain-json">
764
+ <b-form-textarea
765
+ id="plain-json"
766
+ v-model="plainJsonData"
767
+ rows="14"
768
+ placeholder='{\n "username": "incadmin",\n "password": "incadmin",\n "per_page": 1000\n}'
769
+ />
770
+ <small class="form-text text-muted">
771
+ Must be valid JSON. Top-level must be an object (not an array).
772
+ </small>
773
+ </b-form-group>
774
+ </b-modal>
712
775
 
713
776
  <!-- Save Schema Modal -->
714
777
  <b-modal
@@ -104,6 +104,12 @@
104
104
  // CSV/JSON import
105
105
  csvData: '',
106
106
  jsonData: '',
107
+
108
+ // Plain JSON -> Schema generator
109
+ plainJsonData: '',
110
+ plainGenFormId: '',
111
+ plainGenTitle: '',
112
+ plainGenDescription: '',
107
113
 
108
114
  // Save/Load
109
115
  saveName: '',
@@ -178,6 +184,308 @@
178
184
  },
179
185
 
180
186
  methods: {
187
+ // ---------- Plain JSON -> Schema generator ----------
188
+ openPlainJsonGenerator() {
189
+ // Pre-fill from current schema (if any)
190
+ this.plainGenFormId = this.schema.formId || '';
191
+ this.plainGenTitle = this.schema.title || '';
192
+ this.plainGenDescription = this.schema.description || '';
193
+ this.plainJsonData = '';
194
+ this.$bvModal.show('plain-json-modal');
195
+ },
196
+
197
+ cancelPlainJsonGenerate() {
198
+ this.plainJsonData = '';
199
+ },
200
+
201
+ processPlainJsonGenerate() {
202
+ try {
203
+ const raw = (this.plainJsonData || '').trim();
204
+ if (!raw) {
205
+ this.showAlertMessage('Please paste a plain JSON object to generate a schema', 'warning');
206
+ return;
207
+ }
208
+
209
+ const obj = JSON.parse(raw);
210
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
211
+ this.showAlertMessage('Plain JSON must be a top-level object (e.g. { "key": "value" })', 'warning');
212
+ return;
213
+ }
214
+
215
+ const generated = this.generateSchemaFromPlainJson(obj, {
216
+ formId: this.plainGenFormId,
217
+ title: this.plainGenTitle,
218
+ description: this.plainGenDescription
219
+ });
220
+
221
+ this.applySchemaObject(generated);
222
+ this.$bvModal.hide('plain-json-modal');
223
+
224
+ const sectionCount = (generated.sections || []).length;
225
+ const fieldCount = (generated.sections || []).reduce((sum, s) => sum + ((s.fields || []).length), 0);
226
+ this.showAlertMessage(`Generated schema: ${fieldCount} field(s) in ${sectionCount} section(s)`, 'success');
227
+ } catch (error) {
228
+ console.error('Plain JSON generate error:', error);
229
+ this.showAlertMessage('Generation failed: ' + (error && error.message ? error.message : String(error)), 'danger');
230
+ }
231
+ },
232
+
233
+ applySchemaObject(loadedSchema) {
234
+ // Deep copy and ensure required properties exist
235
+ const s = JSON.parse(JSON.stringify(loadedSchema || {}));
236
+ if (!s.schemaVersion) s.schemaVersion = '1.0';
237
+ if (!s.sections) s.sections = [];
238
+ if (!s.actions) s.actions = [];
239
+
240
+ this.$set(this, 'schema', {
241
+ schemaVersion: s.schemaVersion,
242
+ formId: s.formId || '',
243
+ title: s.title || '',
244
+ description: s.description || '',
245
+ sections: s.sections || [],
246
+ actions: s.actions || []
247
+ });
248
+ this.$forceUpdate();
249
+ },
250
+
251
+ generateSchemaFromPlainJson(obj, meta) {
252
+ const safeId = (s) => {
253
+ const base = String(s || '')
254
+ .trim()
255
+ .replace(/\s+/g, '_')
256
+ .replace(/[^a-zA-Z0-9._-]/g, '_');
257
+ const cleaned = base.replace(/_+/g, '_').replace(/^_+|_+$/g, '');
258
+ if (!cleaned) return 'field';
259
+ if (/^[0-9]/.test(cleaned)) return 'f_' + cleaned;
260
+ return cleaned;
261
+ };
262
+
263
+ const titleFromKey = (key) => {
264
+ const s = String(key || '')
265
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
266
+ .replace(/[_-]+/g, ' ')
267
+ .trim();
268
+ if (!s) return 'Field';
269
+ return s.charAt(0).toUpperCase() + s.slice(1);
270
+ };
271
+
272
+ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
273
+
274
+ const isString = (v) => typeof v === 'string' || v instanceof String;
275
+
276
+ const classifySection = (key, value) => {
277
+ const k = String(key || '').toLowerCase();
278
+
279
+ // Specific patterns (matches the style we used manually for azSync-like payloads)
280
+ if (k === 'username' || k === 'password' || k.includes('apikey') || k.includes('api_key') || k.includes('token') || k.includes('secret')) {
281
+ return { id: 'auth', title: 'Authentication' };
282
+ }
283
+ if (k === 'flowname' || k.startsWith('job_') || k.includes('purge') || k.includes('log')) {
284
+ return { id: 'job', title: 'Job Settings' };
285
+ }
286
+ if (k.startsWith('az_') || k === 'type' || k === 'per_page' || k.includes('subscription') || k.startsWith('device_') || k === 'dnsdomain') {
287
+ return { id: 'azure', title: 'Azure Settings' };
288
+ }
289
+ if (k.startsWith('sync') || k.startsWith('process_') || k.startsWith('delete_') || k === 'async_op' || k === 'create_rr') {
290
+ return { id: 'sync', title: 'Sync Behavior' };
291
+ }
292
+ if (k.startsWith('throttle') || k.endsWith('_delay') || k.includes('backoff')) {
293
+ return { id: 'throttle', title: 'Throttle & Timing' };
294
+ }
295
+ if (k.startsWith('udf') || k.startsWith('tags_') || k.includes('template')) {
296
+ return { id: 'udf', title: 'UDF / Templates' };
297
+ }
298
+ if (k === 'op_filters' || k.startsWith('op_filters')) {
299
+ return { id: 'filters', title: 'Operation Filters' };
300
+ }
301
+
302
+ // Fallback
303
+ return { id: 'general', title: 'General' };
304
+ };
305
+
306
+ const schema = {
307
+ schemaVersion: '1.0',
308
+ formId: safeId(meta && meta.formId ? meta.formId : (obj.flowName || obj.formId || 'generated_form')),
309
+ title: (meta && meta.title ? meta.title : (obj.flowName ? `${titleFromKey(obj.flowName)} Configuration` : 'Generated Configuration')),
310
+ description: (meta && meta.description) || '',
311
+ sections: [],
312
+ actions: []
313
+ };
314
+
315
+ const sectionsById = {};
316
+ const ensureSection = (sec) => {
317
+ if (!sectionsById[sec.id]) {
318
+ sectionsById[sec.id] = { id: sec.id, title: sec.title, description: '', fields: [] };
319
+ }
320
+ return sectionsById[sec.id];
321
+ };
322
+
323
+ const pushField = (section, field) => {
324
+ if (!field || !field.id || !field.type) return;
325
+ // Avoid duplicate ids in a section by suffixing
326
+ const existing = new Set((section.fields || []).map(f => f.id));
327
+ let fid = field.id;
328
+ let n = 2;
329
+ while (existing.has(fid)) {
330
+ fid = `${field.id}_${n++}`;
331
+ }
332
+ field.id = fid;
333
+ section.fields.push(field);
334
+ };
335
+
336
+ const inferPrimitiveField = (key, value, labelOverride) => {
337
+ const k = String(key || '');
338
+ const lower = k.toLowerCase();
339
+ const label = labelOverride || titleFromKey(k);
340
+
341
+ if (typeof value === 'boolean') {
342
+ return {
343
+ id: safeId(k),
344
+ label,
345
+ type: 'checkbox',
346
+ required: false,
347
+ defaultValue: value
348
+ };
349
+ }
350
+
351
+ if (typeof value === 'number' && Number.isFinite(value)) {
352
+ const isInt = Number.isInteger(value);
353
+ const field = {
354
+ id: safeId(k),
355
+ label,
356
+ type: 'number',
357
+ required: false,
358
+ defaultValue: value,
359
+ step: isInt ? 1 : 0.01
360
+ };
361
+ if (value >= 0) field.min = 0;
362
+ return field;
363
+ }
364
+
365
+ // Strings and everything else -> text
366
+ const field = {
367
+ id: safeId(k),
368
+ label,
369
+ type: 'text',
370
+ required: false,
371
+ defaultValue: value === null || value === undefined ? '' : String(value)
372
+ };
373
+
374
+ if (lower.includes('password')) field.inputType = 'password';
375
+ else if (lower.includes('email')) { field.inputType = 'email'; field.validate = 'email'; }
376
+ else if (lower.includes('phone') || lower.includes('tel')) { field.inputType = 'tel'; field.validate = 'phone'; }
377
+ else if (lower.includes('url')) field.inputType = 'url';
378
+
379
+ // Note: keep "true"/"false" strings as strings (we only convert actual booleans)
380
+ if (isString(value) && (String(value) === 'true' || String(value) === 'false')) {
381
+ field.help = 'This value was a string in the source JSON.';
382
+ }
383
+ return field;
384
+ };
385
+
386
+ const inferArrayField = (key, arr, labelOverride, helpPrefix) => {
387
+ const label = labelOverride || titleFromKey(key);
388
+ const isPrim = Array.isArray(arr) && arr.every(v => v === null || ['string', 'number', 'boolean'].includes(typeof v));
389
+ const field = {
390
+ id: safeId(key),
391
+ label,
392
+ type: 'textarea',
393
+ required: false,
394
+ placeholder: 'One per line',
395
+ rows: 4
396
+ };
397
+ if (helpPrefix) field.help = helpPrefix;
398
+ if (isPrim) {
399
+ field.defaultValue = arr.map(v => (v === null || v === undefined) ? '' : String(v)).filter(s => s !== '').join('\n');
400
+ } else {
401
+ field.defaultValue = JSON.stringify(arr, null, 2);
402
+ field.help = (field.help ? field.help + ' ' : '') + 'Array contains objects; generated as JSON text.';
403
+ }
404
+ return field;
405
+ };
406
+
407
+ const inferObjectFieldOrFields = (key, valueObj) => {
408
+ // Special-case op_filters style: object of arrays -> separate list fields
409
+ if (String(key).toLowerCase() === 'op_filters' && isPlainObject(valueObj)) {
410
+ const fields = [];
411
+ Object.keys(valueObj).forEach((subKey) => {
412
+ const v = valueObj[subKey];
413
+ const fullId = `${key}_${subKey}`;
414
+ if (Array.isArray(v)) {
415
+ fields.push(inferArrayField(fullId, v, `${titleFromKey(subKey)} Filter (list)`, undefined));
416
+ } else {
417
+ fields.push(inferPrimitiveField(fullId, v, `${titleFromKey(subKey)} Filter`));
418
+ }
419
+ });
420
+ return fields;
421
+ }
422
+
423
+ // Object of string values -> keyvalue pairs
424
+ const keys = Object.keys(valueObj || {});
425
+ const allStringValues = keys.length > 0 && keys.every(k => typeof valueObj[k] === 'string' || valueObj[k] instanceof String);
426
+ if (allStringValues) {
427
+ return [{
428
+ id: safeId(key),
429
+ label: titleFromKey(key),
430
+ type: 'keyvalue',
431
+ required: false,
432
+ keyvalueMode: 'pairs',
433
+ pairs: keys.map(k => ({ key: String(k), value: String(valueObj[k]) }))
434
+ }];
435
+ }
436
+
437
+ // Fallback: flatten one level into individual fields
438
+ const fields = [];
439
+ keys.forEach((subKey) => {
440
+ const v = valueObj[subKey];
441
+ const fullKey = `${key}_${subKey}`;
442
+ if (Array.isArray(v)) fields.push(inferArrayField(fullKey, v, titleFromKey(subKey), `From ${key}.${subKey}.`));
443
+ else if (isPlainObject(v)) fields.push(inferPrimitiveField(fullKey, JSON.stringify(v), titleFromKey(subKey)));
444
+ else fields.push(inferPrimitiveField(fullKey, v, titleFromKey(subKey)));
445
+ });
446
+ if (fields.length === 0) {
447
+ // Empty object -> represent as textarea JSON
448
+ return [inferPrimitiveField(key, JSON.stringify(valueObj || {}), titleFromKey(key))];
449
+ }
450
+ return fields;
451
+ };
452
+
453
+ // Process top-level keys in stable order
454
+ Object.keys(obj).sort().forEach((key) => {
455
+ const value = obj[key];
456
+ const secInfo = classifySection(key, value);
457
+ const section = ensureSection(secInfo);
458
+
459
+ if (Array.isArray(value)) {
460
+ pushField(section, inferArrayField(key, value));
461
+ return;
462
+ }
463
+
464
+ if (isPlainObject(value)) {
465
+ const fields = inferObjectFieldOrFields(key, value);
466
+ fields.forEach(f => pushField(section, f));
467
+ return;
468
+ }
469
+
470
+ pushField(section, inferPrimitiveField(key, value));
471
+ });
472
+
473
+ // Finalize sections: keep a preferred order when present
474
+ const preferredOrder = ['auth', 'job', 'azure', 'sync', 'throttle', 'udf', 'filters', 'general'];
475
+ const allSections = Object.values(sectionsById).filter(s => s.fields && s.fields.length > 0);
476
+ allSections.sort((a, b) => {
477
+ const ia = preferredOrder.indexOf(a.id);
478
+ const ib = preferredOrder.indexOf(b.id);
479
+ if (ia === -1 && ib === -1) return a.id.localeCompare(b.id);
480
+ if (ia === -1) return 1;
481
+ if (ib === -1) return -1;
482
+ return ia - ib;
483
+ });
484
+ schema.sections = allSections;
485
+
486
+ return schema;
487
+ },
488
+
181
489
  isSchemaEmpty() {
182
490
  const s = this.schema || {};
183
491
  return (
@@ -1190,12 +1498,10 @@
1190
1498
  }
1191
1499
 
1192
1500
  // Replace current schema
1193
- this.schema = imported;
1501
+ this.applySchemaObject(imported);
1194
1502
 
1195
1503
  // Ensure actions array exists
1196
- if (!this.schema.actions) {
1197
- this.schema.actions = [];
1198
- }
1504
+ // (applySchemaObject already ensures actions exists)
1199
1505
 
1200
1506
  this.$bvModal.hide('json-modal');
1201
1507
  this.showAlertMessage('Schema imported successfully', 'success');
@@ -434,7 +434,7 @@ module.exports = function(RED) {
434
434
  await fs.writeJson(targetSchema, schema, { spaces: 2 });
435
435
 
436
436
  const runtimeData = {
437
- generatorVersion: "0.5.0",
437
+ generatorVersion: "0.5.1",
438
438
  generatorNode: "uibuilder-formgen-v3",
439
439
  timestamp: new Date().toISOString(),
440
440
  instanceName: instanceName,
@@ -463,7 +463,7 @@ module.exports = function(RED) {
463
463
 
464
464
  // Write runtime metadata
465
465
  const runtimeData = {
466
- generatorVersion: "0.5.0",
466
+ generatorVersion: "0.5.1",
467
467
  timestamp: new Date().toISOString(),
468
468
  instanceName: instanceName,
469
469
  storageMode: storageMode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyprnet/node-red-contrib-uibuilder-formgen",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "PortalSmith: Generate schema-driven uibuilder form portals from JSON",
5
5
  "keywords": [
6
6
  "node-red",