wanas-zcrm-extractor 1.2.0 → 1.4.0

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 CHANGED
@@ -276,17 +276,24 @@ metadata/
276
276
 
277
277
  ├── crm/ # Standard Zoho CRM metadata layout
278
278
  │ ├── meta/
279
- │ │ ├── custom_links/ # Global Custom Links
280
279
  │ │ ├── data_sharing/ # Data Sharing Settings
281
280
  │ │ ├── data_sharing_actions_summary/ # Data Sharing Actions Summary
282
281
  │ │ ├── data_sharing_rules/ # Data Sharing Rules
283
- │ │ ├── email_templates/ # Email Templates
282
+ │ │ ├── email_templates/ # Email Templates (grouped by module)
283
+ │ │ │ └── <Module_API_Name>/
284
+ │ │ │ ├── <Template_Name>.meta.json
285
+ │ │ │ └── <Template_Name>.html # rendered from mail_content
284
286
  │ │ ├── global_picklists/ # Global Picklists
285
- │ │ ├── inventory_templates/# Inventory Templates
287
+ │ │ ├── inventory_templates/# Inventory Templates (grouped by module)
288
+ │ │ │ └── <Module_API_Name>/
289
+ │ │ │ ├── <Template_Name>.meta.json
290
+ │ │ │ └── <Template_Name>.html # template HTML body
286
291
  │ │ ├── territories/ # Territories
287
292
  │ │ ├── variables/ # Global Variables
288
293
  │ │ ├── wizards/ # CRM Wizards
289
- │ │ ├── workflow_rules/ # Global Workflow Rules
294
+ │ │ ├── workflow_rules/ # Workflow Rules (grouped by trigger module)
295
+ │ │ │ └── <Trigger_Module_API_Name>/
296
+ │ │ │ └── <Rule_Name>.json
290
297
  │ │ ├── modules/ # Individual Module schemas
291
298
  │ │ │ └── <Module_API_Name>/
292
299
  │ │ │ ├── <Module_API_Name>.modules-meta.json
@@ -300,6 +307,8 @@ metadata/
300
307
  │ │ │ │ └── <Layout_ID>.json
301
308
  │ │ │ ├── custom_views/ # Custom View configurations
302
309
  │ │ │ │ └── <View_API_Name>.custom_views-meta.json
310
+ │ │ │ ├── custom_links/ # Module custom links/buttons
311
+ │ │ │ │ └── <Link_ID>.json
303
312
  │ │ │ ├── record_locking_configurations/ # Record Locking Rules
304
313
  │ │ │ │ └── <Config_ID>.json
305
314
  │ │ │ ├── related_lists/ # Related List configurations
package/bin/cli.js CHANGED
@@ -522,8 +522,11 @@ program
522
522
  console.log(` \x1b[1mStale Files Removed:\x1b[0m \x1b[33m${details.removedStale || 0}\x1b[0m (deleted on Zoho CRM since last pull)`);
523
523
  console.log(` \x1b[1mOutput Directory:\x1b[0m \x1b[34m${outputDir}\x1b[0m`);
524
524
 
525
+ if (details.skipped > 0) {
526
+ console.log(` \x1b[1mSkipped (optional):\x1b[0m \x1b[90m${details.skipped}\x1b[0m settings not available for some modules/org (normal).`);
527
+ }
525
528
  if (details.errors && details.errors.length > 0) {
526
- console.log(`\n \x1b[1;33m⚠️ Warnings:\x1b[0m \x1b[33m${details.errors.length} API request(s) failed or were skipped due to permissions.\x1b[0m`);
529
+ console.log(`\n \x1b[1;33m⚠️ Errors:\x1b[0m \x1b[33m${details.errors.length} unexpected API failure(s).\x1b[0m Details in the log file.`);
527
530
  }
528
531
  console.log('\x1b[1;32m====================================================================\x1b[0m\n');
529
532
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wanas-zcrm-extractor",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Local Zoho CRM V8 Metadata Extractor CLI and Web Dashboard Utility",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -40,6 +40,38 @@ function getNamespaceForCategory(category) {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * Makes an arbitrary string safe to use as a file/directory name across
45
+ * platforms (strips illegal characters, collapses whitespace, trims trailing
46
+ * dots/spaces which Windows forbids, and caps the length).
47
+ * @param {*} name
48
+ * @returns {string}
49
+ */
50
+ function sanitizeFileName(name) {
51
+ const cleaned = String(name == null ? '' : name)
52
+ .replace(/[\\/:*?"<>|]/g, '_')
53
+ .replace(/\s+/g, '_')
54
+ .replace(/_+/g, '_');
55
+ const truncated = cleaned.slice(0, 120).replace(/^[._]+|[._]+$/g, '');
56
+ return truncated || 'unnamed';
57
+ }
58
+
59
+ /**
60
+ * Returns the first non-empty string value among the candidate fields (used to
61
+ * locate a template's HTML body, whose field name varies by template type).
62
+ * @param {object} item
63
+ * @param {string[]} fields
64
+ * @returns {string|null}
65
+ */
66
+ function pickHtmlContent(item, fields) {
67
+ if (!item) return null;
68
+ for (const field of fields) {
69
+ const value = item[field];
70
+ if (typeof value === 'string' && value.trim().length > 0) return value;
71
+ }
72
+ return null;
73
+ }
74
+
43
75
  /**
44
76
  * Metadata extractor service implementation for Zoho CRM V8.
45
77
  */
@@ -105,6 +137,7 @@ class CrmMetadataService extends BaseMetadataService {
105
137
  totalCustomViews: 0,
106
138
  removedStale: 0,
107
139
  concurrency,
140
+ skipped: 0,
108
141
  errors: []
109
142
  };
110
143
 
@@ -125,14 +158,30 @@ class CrmMetadataService extends BaseMetadataService {
125
158
 
126
159
  const fieldMap = {};
127
160
 
128
- // Records a failed sub-resource fetch: noisy errors are logged loudly,
129
- // expected permission/support errors are logged quietly.
130
- const handleResourceError = (reason, endpoint, apiName, label) => {
131
- stats.errors.push({ endpoint, error: reason?.message || String(reason) });
161
+ // Records a failed sub-resource fetch. Many sub-resources simply don't apply
162
+ // to every module/org Zoho returns 400 ("module not supported", "layout
163
+ // deactivated", "feature not enabled", etc.). Those are EXPECTED: they are
164
+ // counted as `skipped` and logged quietly (file only) so they neither spam
165
+ // the console nor corrupt the progress bar. Only genuinely unexpected
166
+ // failures are recorded as `errors` and logged loudly. Pass optional=true
167
+ // for supplementary resources whose absence is always normal.
168
+ const EXPECTED_ERROR_CODES = new Set([
169
+ 'NO_PERMISSION', 'NOT_SUPPORTED', 'INVALID_MODULE', 'INVALID_URL_PATTERN',
170
+ 'FEATURE_NOT_ENABLED', 'REQUIRED_PARAM_MISSING', 'MANDATORY_NOT_FOUND', 'OAUTH_SCOPE_MISMATCH'
171
+ ]);
172
+ const EXPECTED_ERROR_RE = /not supported|not enabled|seems to be invalid|invalid module|deactivated|parameter is missing|expected parameter|no permission|not allowed|not available/i;
173
+
174
+ const handleResourceError = (reason, endpoint, apiName, label, optional = false) => {
175
+ const status = reason?.response?.status;
132
176
  const errCode = reason?.response?.data?.code;
133
- if (errCode === 'NO_PERMISSION' || errCode === 'NOT_SUPPORTED') {
134
- logInfo(`${label} not accessible/supported for module ${apiName} (Code: ${errCode})`, 'CrmMetadataService.extract');
177
+ const errMsg = reason?.response?.data?.message || reason?.message || '';
178
+ const expected = optional || EXPECTED_ERROR_CODES.has(errCode) || EXPECTED_ERROR_RE.test(errMsg);
179
+
180
+ if (expected) {
181
+ stats.skipped++;
182
+ logInfo(`${label} not available for ${apiName} (${errCode || status || 'n/a'}): ${errMsg}`, 'CrmMetadataService.extract');
135
183
  } else {
184
+ stats.errors.push({ endpoint, error: errMsg || String(reason) });
136
185
  logError(reason, `CrmMetadataService.extract - ${label}: ${apiName}`);
137
186
  }
138
187
  };
@@ -259,7 +308,9 @@ class CrmMetadataService extends BaseMetadataService {
259
308
  apiClient.get('/crm/v8/settings/duplicate_check_preference', { module: apiName }),
260
309
  apiClient.get('/crm/v8/settings/record_locking_configurations', { module: apiName }),
261
310
  apiClient.get('/crm/v8/settings/tags', { module: apiName }),
262
- apiClient.get('/crm/v8/workflow_configurations', { module: apiName })
311
+ apiClient.get('/crm/v8/workflow_configurations', { module: apiName }),
312
+ // Custom links are module-scoped; the endpoint requires the module param.
313
+ apiClient.get('/crm/v8/settings/custom_links', { module: apiName })
263
314
  ];
264
315
  // Record count is opt-in (reads record data; ~50 credits/module).
265
316
  if (withCounts) {
@@ -267,7 +318,7 @@ class CrmMetadataService extends BaseMetadataService {
267
318
  apiClient.get(`/crm/v8/${apiName}/actions/count`).catch(() => ({ count: 'unauthorized_or_unsupported' }))
268
319
  );
269
320
  }
270
- const [detailR, fieldsR, layoutsR, relatedR, viewsR, dupCheckR, lockConfigR, tagsR, workflowConfigR, countR] = await Promise.allSettled(subResourceCalls);
321
+ const [detailR, fieldsR, layoutsR, relatedR, viewsR, dupCheckR, lockConfigR, tagsR, workflowConfigR, customLinksR, countR] = await Promise.allSettled(subResourceCalls);
271
322
 
272
323
  // 3a. Module Details
273
324
  if (detailR.status === 'fulfilled') {
@@ -341,7 +392,7 @@ class CrmMetadataService extends BaseMetadataService {
341
392
  await outJson(path.join(mapDepDir, `${layoutId}.json`), depR, { spaces: 2 });
342
393
  }
343
394
  } catch (depErr) {
344
- handleResourceError(depErr, `/crm/v8/settings/layouts/${layoutId}/map_dependency?module=${apiName}`, apiName, 'Layout Map Dependency');
395
+ handleResourceError(depErr, `/crm/v8/settings/layouts/${layoutId}/map_dependency?module=${apiName}`, apiName, 'Layout Map Dependency', true);
345
396
  }
346
397
  }
347
398
  }
@@ -409,7 +460,7 @@ class CrmMetadataService extends BaseMetadataService {
409
460
  await outJson(path.join(moduleSubDir, 'duplicate_check_preference.json'), dupResp, { spaces: 2 });
410
461
  }
411
462
  } else {
412
- handleResourceError(dupCheckR.reason, `/crm/v8/settings/duplicate_check_preference?module=${apiName}`, apiName, 'Duplicate Check Preference');
463
+ handleResourceError(dupCheckR.reason, `/crm/v8/settings/duplicate_check_preference?module=${apiName}`, apiName, 'Duplicate Check Preference', true);
413
464
  }
414
465
 
415
466
  // 3e-2. Record Locking Configurations
@@ -428,7 +479,7 @@ class CrmMetadataService extends BaseMetadataService {
428
479
  }
429
480
  }
430
481
  } else {
431
- handleResourceError(lockConfigR.reason, `/crm/v8/settings/record_locking_configurations?module=${apiName}`, apiName, 'Record Locking Configurations');
482
+ handleResourceError(lockConfigR.reason, `/crm/v8/settings/record_locking_configurations?module=${apiName}`, apiName, 'Record Locking Configurations', true);
432
483
  }
433
484
 
434
485
  // 3e-3. Tags
@@ -447,7 +498,7 @@ class CrmMetadataService extends BaseMetadataService {
447
498
  }
448
499
  }
449
500
  } else {
450
- handleResourceError(tagsR.reason, `/crm/v8/settings/tags?module=${apiName}`, apiName, 'Tags');
501
+ handleResourceError(tagsR.reason, `/crm/v8/settings/tags?module=${apiName}`, apiName, 'Tags', true);
451
502
  }
452
503
 
453
504
  // 3e-4. Workflow Configurations
@@ -466,7 +517,26 @@ class CrmMetadataService extends BaseMetadataService {
466
517
  }
467
518
  }
468
519
  } else {
469
- handleResourceError(workflowConfigR.reason, `/crm/v8/workflow_configurations?module=${apiName}`, apiName, 'Workflow Configurations');
520
+ handleResourceError(workflowConfigR.reason, `/crm/v8/workflow_configurations?module=${apiName}`, apiName, 'Workflow Configurations', true);
521
+ }
522
+
523
+ // 3e-4b. Custom Links (module-scoped)
524
+ let customLinksHasItems = false;
525
+ if (customLinksR.status === 'fulfilled') {
526
+ const clResp = customLinksR.value;
527
+ if (clResp && Array.isArray(clResp.custom_links) && clResp.custom_links.length > 0) {
528
+ customLinksHasItems = true;
529
+ const clDir = path.join(moduleSubDir, 'custom_links');
530
+ await fs.ensureDir(clDir);
531
+ for (const link of clResp.custom_links) {
532
+ const linkId = link.id || link.name;
533
+ if (linkId) {
534
+ await outJson(path.join(clDir, `${linkId}.json`), link, { spaces: 2 });
535
+ }
536
+ }
537
+ }
538
+ } else {
539
+ handleResourceError(customLinksR.reason, `/crm/v8/settings/custom_links?module=${apiName}`, apiName, 'Custom Links', true);
470
540
  }
471
541
 
472
542
  // 3e-5. Record Count (actions/count) — only when --with-counts is set.
@@ -505,6 +575,7 @@ class CrmMetadataService extends BaseMetadataService {
505
575
  if (lockHasItems) sweptInModule += await sweepDir(path.join(moduleSubDir, 'record_locking_configurations'), writtenFiles);
506
576
  if (tagsHasItems) sweptInModule += await sweepDir(path.join(moduleSubDir, 'tags'), writtenFiles);
507
577
  if (wfHasItems) sweptInModule += await sweepDir(path.join(moduleSubDir, 'workflow_configurations'), writtenFiles);
578
+ if (customLinksHasItems) sweptInModule += await sweepDir(path.join(moduleSubDir, 'custom_links'), writtenFiles);
508
579
  stats.removedStale += sweptInModule;
509
580
 
510
581
  stats.modulesProcessed++;
@@ -595,19 +666,46 @@ class CrmMetadataService extends BaseMetadataService {
595
666
  // 4d. Fetch Additional Global Settings
596
667
  onProgress('FETCH_GLOBAL_SETTINGS', 'Fetching additional global settings...');
597
668
  const globalEndpoints = [
598
- { key: 'custom_links', endpoint: '/crm/v8/settings/custom_links', label: 'Custom Links', detailPath: (item) => `/crm/v8/settings/custom_links/${item.id}` },
599
- { key: 'inventory_templates', endpoint: '/crm/v8/settings/inventory_templates', label: 'Inventory Templates', detailPath: (item) => `/crm/v8/settings/inventory_templates/${item.id}` },
600
- { key: 'email_templates', endpoint: '/crm/v8/settings/email_templates', label: 'Email Templates', detailPath: (item) => `/crm/v8/settings/email_templates/${item.id}` },
669
+ { key: 'inventory_templates', endpoint: '/crm/v8/settings/inventory_templates', label: 'Inventory Templates', detailPath: (item) => `/crm/v8/settings/inventory_templates/${item.id}`, groupBy: (it) => it.module && it.module.api_name, htmlFields: ['content', 'html_content', 'mail_content', 'body'], nameFirst: true },
670
+ { key: 'email_templates', endpoint: '/crm/v8/settings/email_templates', label: 'Email Templates', detailPath: (item) => `/crm/v8/settings/email_templates/${item.id}`, groupBy: (it) => it.module && it.module.api_name, htmlFields: ['mail_content', 'content'], nameFirst: true },
601
671
  { key: 'wizards', endpoint: '/crm/v8/settings/wizards', label: 'Wizards', detailPath: (item) => `/crm/v8/settings/wizards/${item.id}${item.layout?.id ? '?layout_id=' + item.layout.id : ''}` },
602
672
  { key: 'global_picklists', endpoint: '/crm/v8/settings/global_picklists', label: 'Global Picklists', detailPath: (item) => `/crm/v8/settings/global_picklists/${item.id}` },
603
673
  { key: 'rules', metaKey: 'data_sharing_rules', endpoint: '/crm/v8/settings/data_sharing/rules', label: 'Data Sharing Rules', detailPath: (item) => `/crm/v8/settings/data_sharing/rules/${item.id}` },
604
674
  { key: 'data_sharing', endpoint: '/crm/v8/settings/data_sharing', label: 'Data Sharing Settings' },
605
675
  { key: 'summary', metaKey: 'data_sharing_actions_summary', endpoint: '/crm/v8/settings/data_sharing/rules/actions/summary', label: 'Data Sharing Actions Summary' },
606
676
  { key: 'variables', endpoint: '/crm/v8/settings/variables', label: 'Variables', detailPath: (item) => `/crm/v8/settings/variables/${item.id}${item.variable_group?.id ? '?group=' + item.variable_group.id : ''}` },
607
- { key: 'workflow_rules', endpoint: '/crm/v8/settings/automation/workflow_rules', label: 'Workflow Rules', detailPath: (item) => `/crm/v8/settings/automation/workflow_rules/${item.id}` },
677
+ { key: 'workflow_rules', endpoint: '/crm/v8/settings/automation/workflow_rules', label: 'Workflow Rules', detailPath: (item) => `/crm/v8/settings/automation/workflow_rules/${item.id}`, groupBy: (it) => (it.trigger_module && (it.trigger_module.api_name || it.trigger_module)) || (it.module && it.module.api_name), jsonExt: '.json', nameFirst: true },
608
678
  { key: 'territories', endpoint: '/crm/v8/settings/territories', label: 'Territories', detailPath: (item) => `/crm/v8/settings/territories/${item.id}` }
609
679
  ];
610
680
 
681
+ // Writes one global-settings item: places it (optionally) in a subfolder
682
+ // grouped by module, names it, and emits an .html sidecar for templates.
683
+ const writeGlobalItem = async (detailedItem, reqConfig, itemsDir) => {
684
+ if (!detailedItem) return;
685
+
686
+ let targetDir = itemsDir;
687
+ if (reqConfig.groupBy) {
688
+ const groupName = sanitizeFileName(reqConfig.groupBy(detailedItem) || '_ungrouped');
689
+ targetDir = path.join(itemsDir, groupName);
690
+ await fs.ensureDir(targetDir);
691
+ }
692
+
693
+ const rawBase = reqConfig.nameFirst
694
+ ? (detailedItem.name || detailedItem.api_name || detailedItem.id)
695
+ : (detailedItem.api_name || detailedItem.id || detailedItem.name);
696
+ if (!rawBase) return;
697
+ const base = sanitizeFileName(rawBase);
698
+
699
+ const ext = reqConfig.jsonExt || '.meta.json';
700
+ await outJson(path.join(targetDir, `${base}${ext}`), detailedItem, { spaces: 2 });
701
+
702
+ // For templates, write the HTML body as a standalone .html file.
703
+ if (reqConfig.htmlFields) {
704
+ const html = pickHtmlContent(detailedItem, reqConfig.htmlFields);
705
+ if (html) await outFile(path.join(targetDir, `${base}.html`), html, 'utf8');
706
+ }
707
+ };
708
+
611
709
  for (const reqConfig of globalEndpoints) {
612
710
  try {
613
711
  const response = await apiClient.get(reqConfig.endpoint);
@@ -616,39 +714,33 @@ class CrmMetadataService extends BaseMetadataService {
616
714
  completeMetadata[outputKey] = items;
617
715
  const itemsDir = path.join(zcrmMetaDir, outputKey);
618
716
  await fs.ensureDir(itemsDir);
619
-
717
+
620
718
  if (items.length > 0 && reqConfig.detailPath) {
621
719
  await mapWithConcurrency(items, concurrency, async (item) => {
622
- try {
623
- if (item.id) {
720
+ let detailedItem = item;
721
+ if (item.id) {
722
+ try {
624
723
  const detailResponse = await apiClient.get(reqConfig.detailPath(item));
625
- const detailedItem = (detailResponse[reqConfig.key] && detailResponse[reqConfig.key][0]) ? detailResponse[reqConfig.key][0] : item;
626
- const itemApiName = detailedItem.api_name || detailedItem.id || detailedItem.name;
627
- if (itemApiName) await outJson(path.join(itemsDir, `${itemApiName}.meta.json`), detailedItem, { spaces: 2 });
628
- } else {
629
- const itemApiName = item.api_name || item.name;
630
- if (itemApiName) await outJson(path.join(itemsDir, `${itemApiName}.meta.json`), item, { spaces: 2 });
724
+ detailedItem = (detailResponse[reqConfig.key] && detailResponse[reqConfig.key][0]) ? detailResponse[reqConfig.key][0] : item;
725
+ } catch (detailErr) {
726
+ detailedItem = item; // fall back to the list item on detail-fetch failure
631
727
  }
632
- } catch (detailErr) {
633
- const itemApiName = item.api_name || item.id || item.name;
634
- if (itemApiName) await outJson(path.join(itemsDir, `${itemApiName}.meta.json`), item, { spaces: 2 });
635
728
  }
729
+ await writeGlobalItem(detailedItem, reqConfig, itemsDir);
636
730
  });
637
731
  } else {
638
732
  for (const item of items) {
639
- const itemApiName = item.api_name || item.id || item.name;
640
- if (itemApiName) {
641
- await outJson(path.join(itemsDir, `${itemApiName}.meta.json`), item, { spaces: 2 });
642
- }
733
+ await writeGlobalItem(item, reqConfig, itemsDir);
643
734
  }
644
735
  }
645
736
 
737
+ // Recursive sweep reconciles grouped subfolders too (and prunes empties).
646
738
  if (items.length > 0) {
647
739
  const removedItems = await sweepDir(itemsDir, writtenFiles);
648
740
  stats.removedStale += removedItems;
649
741
  }
650
742
  } catch (err) {
651
- handleResourceError(err, reqConfig.endpoint, 'Global', reqConfig.label);
743
+ handleResourceError(err, reqConfig.endpoint, 'Global', reqConfig.label, true);
652
744
  onProgress(`FETCH_${reqConfig.key.toUpperCase()}_WARNING`, `Could not fetch ${reqConfig.label}.`);
653
745
  }
654
746
  }