wanas-zcrm-extractor 1.1.0 → 1.2.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 +79 -34
- package/banner.txt +8 -7
- package/bin/cli.js +49 -5
- package/package.json +1 -1
- package/src/services/auditLogService.js +139 -0
- package/src/services/metadata/crmMetadataService.js +186 -7
- package/src/utils/apiClient.js +23 -10
- package/src/utils/delugeFormatter.js +45 -0
package/README.md
CHANGED
|
@@ -10,13 +10,16 @@ A robust, production-quality CLI utility and local web dashboard designed to aut
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
> ### ⚠️ Important:
|
|
13
|
+
> ### ⚠️ Important: read-only — it never modifies your CRM
|
|
14
14
|
>
|
|
15
|
-
> `zcrm`
|
|
16
|
-
>
|
|
17
|
-
>
|
|
18
|
-
>
|
|
19
|
-
>
|
|
15
|
+
> `zcrm` is strictly **read-only**: it **never creates, updates, or deletes** anything in Zoho
|
|
16
|
+
> CRM. By **default** it pulls **schema/metadata only** — module and field definitions, layouts,
|
|
17
|
+
> picklists, related lists, tags, profiles, roles, webhooks, org-wide settings, and custom
|
|
18
|
+
> function source code — with **no access to your CRM records**.
|
|
19
|
+
>
|
|
20
|
+
> Reading actual record data is always **opt-in and explicit**: per-module record *counts* only
|
|
21
|
+
> with `zcrm pull --with-counts` (~50 API credits/module), and audit-log data only via the
|
|
22
|
+
> `zcrm audit` command. Even then, the tool only ever *reads* — it cannot change anything in your CRM.
|
|
20
23
|
|
|
21
24
|
---
|
|
22
25
|
|
|
@@ -123,6 +126,7 @@ zcrm pull [options]
|
|
|
123
126
|
**Options:**
|
|
124
127
|
* `-o, --output <dir>` - Destination folder path (defaults to `./metadata` in the current working directory).
|
|
125
128
|
* `-c, --concurrency <n>` - Maximum number of simultaneous Zoho API calls, `1`–`25` (defaults to `5`).
|
|
129
|
+
* `--with-counts` - Also fetch each module's **record count**. This reads record data (not just schema) and costs **~50 API credits per module**, so it is **off by default**. Omit it to keep the pull strictly metadata-only.
|
|
126
130
|
|
|
127
131
|
**What happens during a pull:**
|
|
128
132
|
1. Verifies authentication and refreshes active OAuth access tokens automatically.
|
|
@@ -229,6 +233,20 @@ zcrm skill --ide claude --metadata-dir ./crm-meta
|
|
|
229
233
|
|
|
230
234
|
---
|
|
231
235
|
|
|
236
|
+
### `zcrm audit`
|
|
237
|
+
Triggers an asynchronous export of the Zoho CRM audit log. By default, it exports all audit logs for the last 3 years. It automatically polls the API until the job is complete and downloads the resulting CSV or ZIP file to `storage/audit_logs/`.
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# Export all audit logs
|
|
241
|
+
zcrm audit
|
|
242
|
+
|
|
243
|
+
# Export with specific criteria using a JSON file
|
|
244
|
+
zcrm audit --filter input.json
|
|
245
|
+
```
|
|
246
|
+
*(See Zoho CRM documentation for the format of the filter JSON).*
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
232
250
|
### 7. `zcrm logout`
|
|
233
251
|
Clears stored configuration credentials, OAuth tokens, and active sessions from the workspace.
|
|
234
252
|
|
|
@@ -257,31 +275,52 @@ metadata/
|
|
|
257
275
|
│ └── functions.json # Extracted functions catalog
|
|
258
276
|
│
|
|
259
277
|
├── crm/ # Standard Zoho CRM metadata layout
|
|
260
|
-
│
|
|
261
|
-
│
|
|
262
|
-
│
|
|
263
|
-
│
|
|
264
|
-
│
|
|
265
|
-
│
|
|
266
|
-
│
|
|
267
|
-
│
|
|
268
|
-
│
|
|
269
|
-
│
|
|
270
|
-
│
|
|
271
|
-
│
|
|
272
|
-
│
|
|
273
|
-
│
|
|
274
|
-
│
|
|
275
|
-
│ ├──
|
|
276
|
-
│
|
|
277
|
-
│
|
|
278
|
-
│
|
|
279
|
-
│
|
|
280
|
-
│
|
|
281
|
-
│
|
|
282
|
-
│
|
|
283
|
-
│
|
|
284
|
-
│
|
|
278
|
+
│ ├── meta/
|
|
279
|
+
│ │ ├── custom_links/ # Global Custom Links
|
|
280
|
+
│ │ ├── data_sharing/ # Data Sharing Settings
|
|
281
|
+
│ │ ├── data_sharing_actions_summary/ # Data Sharing Actions Summary
|
|
282
|
+
│ │ ├── data_sharing_rules/ # Data Sharing Rules
|
|
283
|
+
│ │ ├── email_templates/ # Email Templates
|
|
284
|
+
│ │ ├── global_picklists/ # Global Picklists
|
|
285
|
+
│ │ ├── inventory_templates/# Inventory Templates
|
|
286
|
+
│ │ ├── territories/ # Territories
|
|
287
|
+
│ │ ├── variables/ # Global Variables
|
|
288
|
+
│ │ ├── wizards/ # CRM Wizards
|
|
289
|
+
│ │ ├── workflow_rules/ # Global Workflow Rules
|
|
290
|
+
│ │ ├── modules/ # Individual Module schemas
|
|
291
|
+
│ │ │ └── <Module_API_Name>/
|
|
292
|
+
│ │ │ ├── <Module_API_Name>.modules-meta.json
|
|
293
|
+
│ │ │ ├── summary.json # Custom module summary (counts, fields, picklists, formulas, records_count)
|
|
294
|
+
│ │ │ ├── duplicate_check_preference.json # Duplicate Check Preferences
|
|
295
|
+
│ │ │ ├── fields/ # Field configurations
|
|
296
|
+
│ │ │ │ └── <Field_API_Name>.fields-meta.json
|
|
297
|
+
│ │ │ ├── layouts/ # Page Layout configurations
|
|
298
|
+
│ │ │ │ ├── <Layout_API_Name>.layouts-meta.json
|
|
299
|
+
│ │ │ │ └── map_dependencies/
|
|
300
|
+
│ │ │ │ └── <Layout_ID>.json
|
|
301
|
+
│ │ │ ├── custom_views/ # Custom View configurations
|
|
302
|
+
│ │ │ │ └── <View_API_Name>.custom_views-meta.json
|
|
303
|
+
│ │ │ ├── record_locking_configurations/ # Record Locking Rules
|
|
304
|
+
│ │ │ │ └── <Config_ID>.json
|
|
305
|
+
│ │ │ ├── related_lists/ # Related List configurations
|
|
306
|
+
│ │ │ │ └── <List_API_Name>.related_lists-meta.json
|
|
307
|
+
│ │ │ ├── tags/ # Module Tags
|
|
308
|
+
│ │ │ │ └── <Tag_ID>.json
|
|
309
|
+
│ │ │ └── workflow_configurations/ # Module Workflow Configurations
|
|
310
|
+
│ │ │ └── <Config_ID>.json
|
|
311
|
+
│ │ ├── profiles/ # Profile Permissions mapping
|
|
312
|
+
│ │ │ └── <Profile_Name>.profiles-meta.json
|
|
313
|
+
│ │ └── roles/ # Role Hierarchy definitions
|
|
314
|
+
│ │ └── <Role_Name>.roles-meta.json
|
|
315
|
+
│ └── functions/ # Custom Deluge function files (.ds)
|
|
316
|
+
│ ├── functions.json # Complete list of extracted functions
|
|
317
|
+
│ ├── standalone/ # Deluge scripts sorted by namespace
|
|
318
|
+
│ │ └── standalone.<Function_API_Name>.ds
|
|
319
|
+
│ ├── automation/
|
|
320
|
+
│ ├── button/
|
|
321
|
+
│ ├── related_list/
|
|
322
|
+
│ ├── schedule/
|
|
323
|
+
│ └── salessignals/
|
|
285
324
|
│
|
|
286
325
|
├── zcrm-project.json # ZCRM CLI project settings config
|
|
287
326
|
├── meta.json # ZCRM resource configuration
|
|
@@ -292,14 +331,20 @@ metadata/
|
|
|
292
331
|
|
|
293
332
|
## 🏢 About Wanas Apps
|
|
294
333
|
|
|
295
|
-
**[Wanas Apps](https://www.wanasapps.com)** is an elite **Zoho Premium Partner** and consulting firm
|
|
334
|
+
**[Wanas Apps](https://www.wanasapps.com)** is an elite **Zoho Premium Partner** and advanced technical consulting firm serving the Middle East and North Africa (MENA) region. We bridge the gap between standard business processes and advanced technical architecture, specializing in end-to-end digital transformation.
|
|
335
|
+
|
|
336
|
+
### 🌟 Our Core Expertise
|
|
337
|
+
- **Zoho Ecosystem Implementation:** Tailored deployments of Zoho CRM, Zoho Books, Zoho Creator, and the wider Zoho suite.
|
|
338
|
+
- **Custom Application Development:** Full-stack, scalable, and serverless solutions built on **Zoho Catalyst**, Node.js, and modern frontends like Angular.
|
|
339
|
+
- **Advanced Integrations & Automations:** We start where off-the-shelf software ends—building robust API gateways, custom webhooks, and complex CRM workflow automations.
|
|
340
|
+
- **Compliance & Localization:** Certified business compliance tools, including seamless native integrations for the Egyptian E-Invoice and E-Receipt systems (ETA).
|
|
296
341
|
|
|
297
|
-
We
|
|
342
|
+
*We don't just implement software; we build custom features that fit seamlessly around your unique business blueprint.*
|
|
298
343
|
|
|
299
344
|
---
|
|
300
345
|
|
|
301
346
|
## ✉️ Support & Feedback
|
|
302
347
|
|
|
303
|
-
If you encounter any issues, have feature requests, or need custom Zoho integrations, please reach out to our team at **support@wanasapps.com**.
|
|
348
|
+
If you encounter any issues, have feature requests, or need custom Zoho integrations, please reach out to our team at **support@wanasapps.com** or visit **[wanasapps.com](https://www.wanasapps.com)**.
|
|
304
349
|
|
|
305
350
|
This project is maintained by Wanas Apps and licensed under the **ISC License**.
|
package/banner.txt
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
__ __ _
|
|
2
|
+
\ \ / /_ _ _ __ __ _ ___ / \ _ __ _ __ ___
|
|
3
|
+
\ \ /\ / / _` | '_ \ / _` / __| / _ \ | '_ \| '_ \/ __|
|
|
4
|
+
\ V V / (_| | | | | (_| \__ \ / ___ \| |_) | |_) \__ \
|
|
5
|
+
\_/\_/ \__,_|_| |_|\__,_|___/ /_/ \_\ .__/| .__/|___/
|
|
6
|
+
|_| |_|
|
|
7
|
+
For Support Contact us on support@wanasapps.com
|
|
8
|
+
www.wanasapps.com
|
package/bin/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ const { exec, spawn } = require('child_process');
|
|
|
12
12
|
const configStore = require('../src/utils/configStore');
|
|
13
13
|
const authService = require('../src/services/authService');
|
|
14
14
|
const crmMetadataService = require('../src/services/metadata/crmMetadataService');
|
|
15
|
+
const auditLogService = require('../src/services/auditLogService');
|
|
15
16
|
const skillGenerator = require('../src/utils/skillGenerator');
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -38,10 +39,13 @@ function openBrowser(targetUrl) {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
// A short, prominent reminder
|
|
42
|
+
// A short, prominent reminder of the core guarantee: the tool is strictly
|
|
43
|
+
// read-only and never modifies Zoho CRM. By default it pulls schema/metadata
|
|
44
|
+
// only; reading record data is opt-in (pull --with-counts) or explicit (audit).
|
|
42
45
|
const READ_ONLY_NOTICE =
|
|
43
|
-
'\x1b[1;33mℹ Note:\x1b[0m This tool
|
|
44
|
-
'
|
|
46
|
+
'\x1b[1;33mℹ Note:\x1b[0m This tool is \x1b[1mread-only\x1b[0m — it \x1b[1mnever creates, updates, or deletes\x1b[0m anything\n' +
|
|
47
|
+
' in Zoho CRM. By default it extracts \x1b[1mschema/metadata only\x1b[0m (record counts and audit\n' +
|
|
48
|
+
' logs are read only when you explicitly ask via --with-counts or the audit command).';
|
|
45
49
|
|
|
46
50
|
// Default client credentials for generating Zoho login links
|
|
47
51
|
const DEFAULT_CLIENT_ID = '1000.M1XE1M7CA4VFDDIRANB8E5AKIBQ72U';
|
|
@@ -412,6 +416,7 @@ program
|
|
|
412
416
|
.description('Pull Zoho CRM metadata (read-only) and sync it into a local JSON directory structure')
|
|
413
417
|
.option('-o, --output <dir>', 'Output folder path', './metadata')
|
|
414
418
|
.option('-c, --concurrency <n>', 'Max concurrent Zoho API calls (1-25; default 5 — safe for all editions)', '5')
|
|
419
|
+
.option('--with-counts', 'Also fetch per-module record counts (reads record data; ~50 API credits per module)')
|
|
415
420
|
.action(async (options) => {
|
|
416
421
|
console.log(getBanner());
|
|
417
422
|
// Reload tokens
|
|
@@ -429,7 +434,11 @@ program
|
|
|
429
434
|
|
|
430
435
|
console.log(`\x1b[36mInitializing CRM Metadata Pulling...\x1b[0m`);
|
|
431
436
|
console.log(`Destination Folder: ${outputDir}`);
|
|
432
|
-
console.log(`Concurrency: ${concurrency} simultaneous API call(s)
|
|
437
|
+
console.log(`Concurrency: ${concurrency} simultaneous API call(s)`);
|
|
438
|
+
if (options.withCounts) {
|
|
439
|
+
console.log('\x1b[33mRecord counts: ON\x1b[0m (reads record data; ~50 API credits per module)');
|
|
440
|
+
}
|
|
441
|
+
console.log('');
|
|
433
442
|
|
|
434
443
|
let inModulesPhase = false;
|
|
435
444
|
let inFunctionsPhase = false;
|
|
@@ -524,7 +533,7 @@ program
|
|
|
524
533
|
}
|
|
525
534
|
break;
|
|
526
535
|
}
|
|
527
|
-
}, outputDir, { concurrency });
|
|
536
|
+
}, outputDir, { concurrency, withCounts: !!options.withCounts });
|
|
528
537
|
|
|
529
538
|
process.exit(0);
|
|
530
539
|
} catch (error) {
|
|
@@ -533,6 +542,40 @@ program
|
|
|
533
542
|
}
|
|
534
543
|
});
|
|
535
544
|
|
|
545
|
+
// REGISTER COMMAND: audit
|
|
546
|
+
program
|
|
547
|
+
.command('audit')
|
|
548
|
+
.description('Export the Zoho CRM audit log (asynchronous job)')
|
|
549
|
+
.option('-f, --filter <path>', 'Path to a JSON file containing the audit_log_export criteria')
|
|
550
|
+
.action(async (options) => {
|
|
551
|
+
console.log(getBanner());
|
|
552
|
+
// This command DOES read/export audit-log data, so it gets its own honest
|
|
553
|
+
// notice rather than the generic "metadata only / no data access" one.
|
|
554
|
+
console.log(
|
|
555
|
+
'\x1b[1;33mℹ Note:\x1b[0m This exports your Zoho CRM \x1b[1maudit-log data\x1b[0m (read-only).\n' +
|
|
556
|
+
' It never creates, updates, or deletes anything in Zoho CRM.\n'
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
// Ensure user is authenticated
|
|
560
|
+
authService.loadTokens();
|
|
561
|
+
if (!authService.isAuthenticated()) {
|
|
562
|
+
console.error('\n❌ You are not logged in. Please run `zcrm login` first.');
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
await authService.getAccessToken(); // Refresh if necessary
|
|
568
|
+
await auditLogService.exportAuditLog(options.filter);
|
|
569
|
+
console.log('\n\x1b[1;32m====================================================================\x1b[0m');
|
|
570
|
+
console.log('🎉 \x1b[1;32mAudit Log Export Completed Successfully!\x1b[0m');
|
|
571
|
+
console.log('\x1b[1;32m====================================================================\x1b[0m\n');
|
|
572
|
+
process.exit(0);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
console.error(`\n❌ Audit log export failed: ${error.message}`);
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
536
579
|
// REGISTER COMMAND: llm
|
|
537
580
|
program
|
|
538
581
|
.command('llm')
|
|
@@ -653,6 +696,7 @@ program
|
|
|
653
696
|
.option('-p, --port <port>', 'Port to run the dashboard on', '3000')
|
|
654
697
|
.action((options) => {
|
|
655
698
|
process.env.PORT = options.port;
|
|
699
|
+
process.env.ZCRM_VERBOSE = 'true';
|
|
656
700
|
// Keep ZCRM_CLI_MODE='true' (set at startup) so the dashboard shares the
|
|
657
701
|
// same global credential/token store as `zcrm login` and `zcrm pull`.
|
|
658
702
|
// Flipping it to 'false' here would desync the already-initialized
|
package/package.json
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
const apiClient = require('../utils/apiClient');
|
|
5
|
+
const authService = require('./authService');
|
|
6
|
+
const { logInfo, logError } = require('../utils/logger');
|
|
7
|
+
|
|
8
|
+
async function delay(ms) {
|
|
9
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function exportAuditLog(filterFile) {
|
|
13
|
+
try {
|
|
14
|
+
let requestBody = { audit_log_export: [{}] }; // default: all logs up to 3 years
|
|
15
|
+
|
|
16
|
+
if (filterFile) {
|
|
17
|
+
const filterPath = path.resolve(process.cwd(), filterFile);
|
|
18
|
+
if (await fs.pathExists(filterPath)) {
|
|
19
|
+
requestBody = await fs.readJson(filterPath);
|
|
20
|
+
logInfo(`Loaded audit log filter criteria from ${filterFile}`);
|
|
21
|
+
} else {
|
|
22
|
+
throw new Error(`Filter file not found: ${filterFile}`);
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
logInfo('No filter specified. Requesting full audit log export (up to 3 years).');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
logInfo('Starting Audit Log Export job...');
|
|
29
|
+
const triggerRes = await apiClient.post('/crm/v8/settings/audit_log_export', requestBody);
|
|
30
|
+
|
|
31
|
+
if (!triggerRes.audit_log_export || triggerRes.audit_log_export.length === 0) {
|
|
32
|
+
throw new Error('Unexpected response format when starting export job.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const jobDetails = triggerRes.audit_log_export[0];
|
|
36
|
+
if (jobDetails.status !== 'success' || !jobDetails.details || !jobDetails.details.id) {
|
|
37
|
+
throw new Error(`Failed to schedule job: ${JSON.stringify(jobDetails)}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const jobId = jobDetails.details.id;
|
|
41
|
+
logInfo(`Export job scheduled successfully. Job ID: ${jobId}`);
|
|
42
|
+
logInfo('Polling for job completion... This may take several minutes depending on the data size.');
|
|
43
|
+
|
|
44
|
+
let downloadUrl = null;
|
|
45
|
+
let attempts = 0;
|
|
46
|
+
const POLL_INTERVAL_MS = 15000;
|
|
47
|
+
const MAX_ATTEMPTS = 120; // 120 × 15s = 30 minutes, then give up rather than hang forever
|
|
48
|
+
|
|
49
|
+
// Poll until completion (bounded so a stuck job can never hang the CLI).
|
|
50
|
+
while (attempts < MAX_ATTEMPTS) {
|
|
51
|
+
attempts++;
|
|
52
|
+
// Wait between polls
|
|
53
|
+
await delay(POLL_INTERVAL_MS);
|
|
54
|
+
|
|
55
|
+
logInfo(`Checking status... (Attempt ${attempts}/${MAX_ATTEMPTS})`);
|
|
56
|
+
const statusRes = await apiClient.get(`/crm/v8/settings/audit_log_export/${jobId}`);
|
|
57
|
+
|
|
58
|
+
if (!statusRes.audit_log_export || statusRes.audit_log_export.length === 0) {
|
|
59
|
+
throw new Error('Invalid status response from server.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const statusDetails = statusRes.audit_log_export[0];
|
|
63
|
+
const jobStatus = (statusDetails.status || '').toLowerCase();
|
|
64
|
+
|
|
65
|
+
if (jobStatus === 'finished') {
|
|
66
|
+
if (!statusDetails.download_links || statusDetails.download_links.length === 0) {
|
|
67
|
+
throw new Error('Job finished but no download links were provided.');
|
|
68
|
+
}
|
|
69
|
+
downloadUrl = statusDetails.download_links[0];
|
|
70
|
+
logInfo(`Job finished! Download URL obtained.`);
|
|
71
|
+
break;
|
|
72
|
+
} else if (jobStatus === 'failed') {
|
|
73
|
+
throw new Error('Export job failed on the server side.');
|
|
74
|
+
} else {
|
|
75
|
+
// Status is 'progress' or 'scheduled'
|
|
76
|
+
logInfo(`Job status: ${jobStatus}. Waiting...`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!downloadUrl) {
|
|
81
|
+
throw new Error(`Audit log export did not finish within ${Math.round(MAX_ATTEMPTS * POLL_INTERVAL_MS / 60000)} minutes (job ${jobId}). Try a narrower filter or re-run later.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Download the file
|
|
85
|
+
const outputDir = path.join(process.cwd(), 'storage', 'audit_logs');
|
|
86
|
+
await fs.ensureDir(outputDir);
|
|
87
|
+
|
|
88
|
+
// Determine the extension from the URL *path* (signed URLs carry a query
|
|
89
|
+
// string, so endsWith('.zip') on the whole URL would always miss). Default
|
|
90
|
+
// to .zip, the usual audit-export format.
|
|
91
|
+
let ext = '.zip';
|
|
92
|
+
try {
|
|
93
|
+
const urlPath = new URL(downloadUrl).pathname.toLowerCase();
|
|
94
|
+
const parsedExt = path.extname(urlPath);
|
|
95
|
+
if (parsedExt === '.csv' || parsedExt === '.zip') ext = parsedExt;
|
|
96
|
+
} catch (urlErr) {
|
|
97
|
+
// Keep the .zip default if the URL can't be parsed.
|
|
98
|
+
}
|
|
99
|
+
const outputPath = path.join(outputDir, `AuditLog_${jobId}${ext}`);
|
|
100
|
+
|
|
101
|
+
logInfo(`Downloading result to ${outputPath}...`);
|
|
102
|
+
|
|
103
|
+
const accessToken = await authService.getAccessToken();
|
|
104
|
+
const writer = fs.createWriteStream(outputPath);
|
|
105
|
+
|
|
106
|
+
const downloadResponse = await axios({
|
|
107
|
+
method: 'GET',
|
|
108
|
+
url: downloadUrl,
|
|
109
|
+
responseType: 'stream',
|
|
110
|
+
headers: {
|
|
111
|
+
'Authorization': `Zoho-oauthtoken ${accessToken}`
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
downloadResponse.data.on('error', reject); // surface read-stream errors
|
|
118
|
+
writer.on('error', reject);
|
|
119
|
+
writer.on('finish', resolve);
|
|
120
|
+
downloadResponse.data.pipe(writer);
|
|
121
|
+
});
|
|
122
|
+
} catch (streamErr) {
|
|
123
|
+
// Don't leave a half-written file behind on failure.
|
|
124
|
+
writer.destroy();
|
|
125
|
+
await fs.remove(outputPath).catch(() => {});
|
|
126
|
+
throw streamErr;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
logInfo(`Audit log successfully downloaded and saved to: ${outputPath}`);
|
|
130
|
+
|
|
131
|
+
} catch (error) {
|
|
132
|
+
logError(error, 'auditLogService.exportAuditLog');
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
exportAuditLog
|
|
139
|
+
};
|
|
@@ -5,6 +5,7 @@ const apiClient = require('../../utils/apiClient');
|
|
|
5
5
|
const { mapWithConcurrency } = require('../../utils/concurrency');
|
|
6
6
|
const { sweepDir, sweepDeletedDirs, normalizePath } = require('../../utils/fsSync');
|
|
7
7
|
const { logError, logInfo } = require('../../utils/logger');
|
|
8
|
+
const { formatDeluge } = require('../../utils/delugeFormatter');
|
|
8
9
|
|
|
9
10
|
const STORAGE_DIR = path.join(process.cwd(), 'storage/metadata');
|
|
10
11
|
|
|
@@ -73,6 +74,11 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
73
74
|
const concurrency = Math.max(1, Math.min(25, parseInt(options.concurrency, 10) || DEFAULT_CONCURRENCY));
|
|
74
75
|
apiClient.setConcurrency(concurrency);
|
|
75
76
|
|
|
77
|
+
// Per-module record counts read RECORD DATA (not schema) and cost ~50 API
|
|
78
|
+
// credits per module, so they are strictly opt-in. Default pulls stay
|
|
79
|
+
// metadata-only with no record-data access.
|
|
80
|
+
const withCounts = options.withCounts === true;
|
|
81
|
+
|
|
76
82
|
// Track every file written this run so stale files can be swept afterward.
|
|
77
83
|
const writtenFiles = new Set();
|
|
78
84
|
const recordWrite = (p) => writtenFiles.add(normalizePath(p));
|
|
@@ -242,15 +248,26 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
242
248
|
|
|
243
249
|
fieldMap[apiName] = {};
|
|
244
250
|
|
|
245
|
-
// The
|
|
251
|
+
// The module sub-resources are independent — fetch concurrently.
|
|
246
252
|
// (Overall in-flight calls are still capped by the apiClient gate.)
|
|
247
|
-
const
|
|
253
|
+
const subResourceCalls = [
|
|
248
254
|
apiClient.get(`/crm/v8/settings/modules/${apiName}`),
|
|
249
255
|
apiClient.get('/crm/v8/settings/fields', { module: apiName }),
|
|
250
256
|
apiClient.get('/crm/v8/settings/layouts', { module: apiName }),
|
|
251
257
|
apiClient.get('/crm/v8/settings/related_lists', { module: apiName }),
|
|
252
|
-
apiClient.get('/crm/v8/settings/custom_views', { module: apiName })
|
|
253
|
-
|
|
258
|
+
apiClient.get('/crm/v8/settings/custom_views', { module: apiName }),
|
|
259
|
+
apiClient.get('/crm/v8/settings/duplicate_check_preference', { module: apiName }),
|
|
260
|
+
apiClient.get('/crm/v8/settings/record_locking_configurations', { module: apiName }),
|
|
261
|
+
apiClient.get('/crm/v8/settings/tags', { module: apiName }),
|
|
262
|
+
apiClient.get('/crm/v8/workflow_configurations', { module: apiName })
|
|
263
|
+
];
|
|
264
|
+
// Record count is opt-in (reads record data; ~50 credits/module).
|
|
265
|
+
if (withCounts) {
|
|
266
|
+
subResourceCalls.push(
|
|
267
|
+
apiClient.get(`/crm/v8/${apiName}/actions/count`).catch(() => ({ count: 'unauthorized_or_unsupported' }))
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const [detailR, fieldsR, layoutsR, relatedR, viewsR, dupCheckR, lockConfigR, tagsR, workflowConfigR, countR] = await Promise.allSettled(subResourceCalls);
|
|
254
271
|
|
|
255
272
|
// 3a. Module Details
|
|
256
273
|
if (detailR.status === 'fulfilled') {
|
|
@@ -308,12 +325,25 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
308
325
|
|
|
309
326
|
if (hasLayouts && layouts.length > 0) {
|
|
310
327
|
const layoutsDir = path.join(moduleSubDir, 'layouts');
|
|
328
|
+
const mapDepDir = path.join(layoutsDir, 'map_dependencies');
|
|
311
329
|
await fs.ensureDir(layoutsDir);
|
|
312
330
|
for (const layout of layouts) {
|
|
313
|
-
const layoutApiName = layout.api_name;
|
|
331
|
+
const layoutApiName = layout.api_name || layout.name;
|
|
332
|
+
const layoutId = layout.id;
|
|
314
333
|
if (layoutApiName) {
|
|
315
334
|
await outJson(path.join(layoutsDir, `${layoutApiName}.layouts-meta.json`), layout, { spaces: 2 });
|
|
316
335
|
}
|
|
336
|
+
if (layoutId) {
|
|
337
|
+
try {
|
|
338
|
+
const depR = await apiClient.get(`/crm/v8/settings/layouts/${layoutId}/map_dependency`, { module: apiName });
|
|
339
|
+
if (depR && Array.isArray(depR.map_dependency) && depR.map_dependency.length > 0) {
|
|
340
|
+
await fs.ensureDir(mapDepDir);
|
|
341
|
+
await outJson(path.join(mapDepDir, `${layoutId}.json`), depR, { spaces: 2 });
|
|
342
|
+
}
|
|
343
|
+
} catch (depErr) {
|
|
344
|
+
handleResourceError(depErr, `/crm/v8/settings/layouts/${layoutId}/map_dependency?module=${apiName}`, apiName, 'Layout Map Dependency');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
317
347
|
}
|
|
318
348
|
}
|
|
319
349
|
|
|
@@ -372,6 +402,82 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
372
402
|
handleResourceError(viewsR.reason, `/crm/v8/settings/custom_views?module=${apiName}`, apiName, 'Custom Views');
|
|
373
403
|
}
|
|
374
404
|
|
|
405
|
+
// 3e-1. Duplicate Check Preference
|
|
406
|
+
if (dupCheckR.status === 'fulfilled') {
|
|
407
|
+
const dupResp = dupCheckR.value;
|
|
408
|
+
if (dupResp && dupResp.duplicate_check_preference) {
|
|
409
|
+
await outJson(path.join(moduleSubDir, 'duplicate_check_preference.json'), dupResp, { spaces: 2 });
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
handleResourceError(dupCheckR.reason, `/crm/v8/settings/duplicate_check_preference?module=${apiName}`, apiName, 'Duplicate Check Preference');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 3e-2. Record Locking Configurations
|
|
416
|
+
let lockHasItems = false;
|
|
417
|
+
if (lockConfigR.status === 'fulfilled') {
|
|
418
|
+
const lockResp = lockConfigR.value;
|
|
419
|
+
if (lockResp && Array.isArray(lockResp.record_locking_configurations) && lockResp.record_locking_configurations.length > 0) {
|
|
420
|
+
lockHasItems = true;
|
|
421
|
+
const lockDir = path.join(moduleSubDir, 'record_locking_configurations');
|
|
422
|
+
await fs.ensureDir(lockDir);
|
|
423
|
+
for (const config of lockResp.record_locking_configurations) {
|
|
424
|
+
const configId = config.id || config.name;
|
|
425
|
+
if (configId) {
|
|
426
|
+
await outJson(path.join(lockDir, `${configId}.json`), config, { spaces: 2 });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
handleResourceError(lockConfigR.reason, `/crm/v8/settings/record_locking_configurations?module=${apiName}`, apiName, 'Record Locking Configurations');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 3e-3. Tags
|
|
435
|
+
let tagsHasItems = false;
|
|
436
|
+
if (tagsR.status === 'fulfilled') {
|
|
437
|
+
const tagsResp = tagsR.value;
|
|
438
|
+
if (tagsResp && Array.isArray(tagsResp.tags) && tagsResp.tags.length > 0) {
|
|
439
|
+
tagsHasItems = true;
|
|
440
|
+
const tagsDir = path.join(moduleSubDir, 'tags');
|
|
441
|
+
await fs.ensureDir(tagsDir);
|
|
442
|
+
for (const tag of tagsResp.tags) {
|
|
443
|
+
const tagId = tag.id || tag.name;
|
|
444
|
+
if (tagId) {
|
|
445
|
+
await outJson(path.join(tagsDir, `${tagId}.json`), tag, { spaces: 2 });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
handleResourceError(tagsR.reason, `/crm/v8/settings/tags?module=${apiName}`, apiName, 'Tags');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 3e-4. Workflow Configurations
|
|
454
|
+
let wfHasItems = false;
|
|
455
|
+
if (workflowConfigR.status === 'fulfilled') {
|
|
456
|
+
const wfResp = workflowConfigR.value;
|
|
457
|
+
if (wfResp && Array.isArray(wfResp.workflow_configurations) && wfResp.workflow_configurations.length > 0) {
|
|
458
|
+
wfHasItems = true;
|
|
459
|
+
const wfDir = path.join(moduleSubDir, 'workflow_configurations');
|
|
460
|
+
await fs.ensureDir(wfDir);
|
|
461
|
+
for (const config of wfResp.workflow_configurations) {
|
|
462
|
+
const configId = config.id || config.name;
|
|
463
|
+
if (configId) {
|
|
464
|
+
await outJson(path.join(wfDir, `${configId}.json`), config, { spaces: 2 });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
handleResourceError(workflowConfigR.reason, `/crm/v8/workflow_configurations?module=${apiName}`, apiName, 'Workflow Configurations');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 3e-5. Record Count (actions/count) — only when --with-counts is set.
|
|
473
|
+
if (withCounts) {
|
|
474
|
+
if (countR && countR.status === 'fulfilled' && countR.value && countR.value.count !== undefined) {
|
|
475
|
+
summary.records_count = countR.value.count;
|
|
476
|
+
} else {
|
|
477
|
+
summary.records_count = 'unknown';
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
375
481
|
// 3f. Save summary.json
|
|
376
482
|
await outJson(path.join(moduleSubDir, 'summary.json'), summary, { spaces: 2 });
|
|
377
483
|
|
|
@@ -386,9 +492,19 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
386
492
|
// shared field would lose updates).
|
|
387
493
|
let sweptInModule = 0;
|
|
388
494
|
if (summary.fields_count > 0) sweptInModule += await sweepDir(path.join(moduleSubDir, 'fields'), writtenFiles);
|
|
389
|
-
if (summary.layouts_count > 0)
|
|
495
|
+
if (summary.layouts_count > 0) {
|
|
496
|
+
sweptInModule += await sweepDir(path.join(moduleSubDir, 'layouts'), writtenFiles);
|
|
497
|
+
if (await fs.pathExists(path.join(moduleSubDir, 'layouts', 'map_dependencies'))) {
|
|
498
|
+
sweptInModule += await sweepDir(path.join(moduleSubDir, 'layouts', 'map_dependencies'), writtenFiles);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
390
501
|
if (summary.related_lists_count > 0) sweptInModule += await sweepDir(path.join(moduleSubDir, 'related_lists'), writtenFiles);
|
|
391
502
|
if (viewsCount > 0) sweptInModule += await sweepDir(path.join(moduleSubDir, 'custom_views'), writtenFiles);
|
|
503
|
+
// Reconcile the new per-module resources only when this run fetched them
|
|
504
|
+
// successfully and non-empty — a transient failure must not delete them.
|
|
505
|
+
if (lockHasItems) sweptInModule += await sweepDir(path.join(moduleSubDir, 'record_locking_configurations'), writtenFiles);
|
|
506
|
+
if (tagsHasItems) sweptInModule += await sweepDir(path.join(moduleSubDir, 'tags'), writtenFiles);
|
|
507
|
+
if (wfHasItems) sweptInModule += await sweepDir(path.join(moduleSubDir, 'workflow_configurations'), writtenFiles);
|
|
392
508
|
stats.removedStale += sweptInModule;
|
|
393
509
|
|
|
394
510
|
stats.modulesProcessed++;
|
|
@@ -476,6 +592,67 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
476
592
|
onProgress('FETCH_ROLES_WARNING', 'Could not fetch roles.');
|
|
477
593
|
}
|
|
478
594
|
|
|
595
|
+
// 4d. Fetch Additional Global Settings
|
|
596
|
+
onProgress('FETCH_GLOBAL_SETTINGS', 'Fetching additional global settings...');
|
|
597
|
+
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}` },
|
|
601
|
+
{ 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
|
+
{ key: 'global_picklists', endpoint: '/crm/v8/settings/global_picklists', label: 'Global Picklists', detailPath: (item) => `/crm/v8/settings/global_picklists/${item.id}` },
|
|
603
|
+
{ 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
|
+
{ key: 'data_sharing', endpoint: '/crm/v8/settings/data_sharing', label: 'Data Sharing Settings' },
|
|
605
|
+
{ key: 'summary', metaKey: 'data_sharing_actions_summary', endpoint: '/crm/v8/settings/data_sharing/rules/actions/summary', label: 'Data Sharing Actions Summary' },
|
|
606
|
+
{ 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}` },
|
|
608
|
+
{ key: 'territories', endpoint: '/crm/v8/settings/territories', label: 'Territories', detailPath: (item) => `/crm/v8/settings/territories/${item.id}` }
|
|
609
|
+
];
|
|
610
|
+
|
|
611
|
+
for (const reqConfig of globalEndpoints) {
|
|
612
|
+
try {
|
|
613
|
+
const response = await apiClient.get(reqConfig.endpoint);
|
|
614
|
+
const items = response[reqConfig.key] || [];
|
|
615
|
+
const outputKey = reqConfig.metaKey || reqConfig.key;
|
|
616
|
+
completeMetadata[outputKey] = items;
|
|
617
|
+
const itemsDir = path.join(zcrmMetaDir, outputKey);
|
|
618
|
+
await fs.ensureDir(itemsDir);
|
|
619
|
+
|
|
620
|
+
if (items.length > 0 && reqConfig.detailPath) {
|
|
621
|
+
await mapWithConcurrency(items, concurrency, async (item) => {
|
|
622
|
+
try {
|
|
623
|
+
if (item.id) {
|
|
624
|
+
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 });
|
|
631
|
+
}
|
|
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
|
+
}
|
|
636
|
+
});
|
|
637
|
+
} else {
|
|
638
|
+
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
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (items.length > 0) {
|
|
647
|
+
const removedItems = await sweepDir(itemsDir, writtenFiles);
|
|
648
|
+
stats.removedStale += removedItems;
|
|
649
|
+
}
|
|
650
|
+
} catch (err) {
|
|
651
|
+
handleResourceError(err, reqConfig.endpoint, 'Global', reqConfig.label);
|
|
652
|
+
onProgress(`FETCH_${reqConfig.key.toUpperCase()}_WARNING`, `Could not fetch ${reqConfig.label}.`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
479
656
|
// 5. Retrieve Functions and their Deluge scripts (bounded concurrency)
|
|
480
657
|
onProgress('FETCH_FUNCTIONS', 'Fetching CRM functions...');
|
|
481
658
|
try {
|
|
@@ -485,6 +662,7 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
485
662
|
|
|
486
663
|
const functionsList = functionsResponse.functions || [];
|
|
487
664
|
await fs.ensureDir(functionsDir);
|
|
665
|
+
await outJson(path.join(functionsDir, 'functions.json'), functionsResponse, { spaces: 2 });
|
|
488
666
|
|
|
489
667
|
onProgress('FUNCTIONS_START', `Found ${functionsList.length} functions. Extracting deluge scripts (concurrency: ${concurrency})...`, {
|
|
490
668
|
total: functionsList.length,
|
|
@@ -507,7 +685,8 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
507
685
|
try {
|
|
508
686
|
const scriptContent = await apiClient.get(`/crm/v8/settings/functions/${fnId}/code`);
|
|
509
687
|
if (scriptContent && typeof scriptContent === 'string') {
|
|
510
|
-
|
|
688
|
+
const formattedScript = formatDeluge(scriptContent);
|
|
689
|
+
await outFile(filePath, formattedScript, 'utf8');
|
|
511
690
|
} else {
|
|
512
691
|
throw new Error('Empty script code returned');
|
|
513
692
|
}
|
package/src/utils/apiClient.js
CHANGED
|
@@ -65,16 +65,19 @@ function setConcurrency(n) {
|
|
|
65
65
|
* @param {object} params - Optional query parameters
|
|
66
66
|
* @param {number} retryCount - Tracking retries internally
|
|
67
67
|
*/
|
|
68
|
-
async function request(endpoint, params = {}, retryCount = 0) {
|
|
68
|
+
async function request(method, endpoint, params = {}, data = null, retryCount = 0) {
|
|
69
69
|
try {
|
|
70
70
|
const accessToken = await authService.getAccessToken();
|
|
71
71
|
const baseUrl = authService.getApiBaseUrl();
|
|
72
72
|
const url = `${baseUrl}${endpoint}`;
|
|
73
73
|
|
|
74
|
-
logInfo(
|
|
74
|
+
logInfo(`${method.toUpperCase()} request to ${url} (Retry: ${retryCount})`, 'apiClient.request');
|
|
75
75
|
|
|
76
|
-
const response = await apiClient
|
|
76
|
+
const response = await apiClient({
|
|
77
|
+
method,
|
|
78
|
+
url,
|
|
77
79
|
params,
|
|
80
|
+
data,
|
|
78
81
|
headers: {
|
|
79
82
|
'Authorization': `Zoho-oauthtoken ${accessToken}`
|
|
80
83
|
}
|
|
@@ -94,10 +97,10 @@ async function request(endpoint, params = {}, retryCount = 0) {
|
|
|
94
97
|
|
|
95
98
|
// Handle 401 Unauthorized (Expired token or session)
|
|
96
99
|
if (status === 401 && retryCount < 2) {
|
|
97
|
-
logInfo('Received 401 Unauthorized. Forcing token refresh and retrying...', 'apiClient.
|
|
100
|
+
logInfo('Received 401 Unauthorized. Forcing token refresh and retrying...', 'apiClient.request');
|
|
98
101
|
try {
|
|
99
102
|
await authService.refreshAccessToken();
|
|
100
|
-
return await request(endpoint, params, retryCount + 1);
|
|
103
|
+
return await request(method, endpoint, params, data, retryCount + 1);
|
|
101
104
|
} catch (refreshErr) {
|
|
102
105
|
logError(refreshErr, 'apiClient.get - Token refresh retry failed');
|
|
103
106
|
throw error;
|
|
@@ -107,17 +110,17 @@ async function request(endpoint, params = {}, retryCount = 0) {
|
|
|
107
110
|
// Handle 429 Too Many Requests (Rate limit / concurrency limit hit)
|
|
108
111
|
if ((status === 429 || errorData?.code === 'TOO_MANY_REQUESTS') && retryCount < 5) {
|
|
109
112
|
const waitTime = Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s, 8s, 16s backoff
|
|
110
|
-
logInfo(`Rate/concurrency limit hit (429). Retrying in ${waitTime}ms...`, 'apiClient.
|
|
113
|
+
logInfo(`Rate/concurrency limit hit (429). Retrying in ${waitTime}ms...`, 'apiClient.request');
|
|
111
114
|
await delay(waitTime);
|
|
112
|
-
return await request(endpoint, params, retryCount + 1);
|
|
115
|
+
return await request(method, endpoint, params, data, retryCount + 1);
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
// Handle temporary network or server failures (5xx)
|
|
116
119
|
if (status >= 500 && status < 600 && retryCount < 2) {
|
|
117
120
|
const waitTime = 1000;
|
|
118
|
-
logInfo(`Server error ${status}. Retrying in ${waitTime}ms...`, 'apiClient.
|
|
121
|
+
logInfo(`Server error ${status}. Retrying in ${waitTime}ms...`, 'apiClient.request');
|
|
119
122
|
await delay(waitTime);
|
|
120
|
-
return await request(endpoint, params, retryCount + 1);
|
|
123
|
+
return await request(method, endpoint, params, data, retryCount + 1);
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
throw error;
|
|
@@ -137,7 +140,16 @@ async function request(endpoint, params = {}, retryCount = 0) {
|
|
|
137
140
|
async function get(endpoint, params = {}) {
|
|
138
141
|
await acquireSlot();
|
|
139
142
|
try {
|
|
140
|
-
return await request(endpoint, params, 0);
|
|
143
|
+
return await request('get', endpoint, params, null, 0);
|
|
144
|
+
} finally {
|
|
145
|
+
releaseSlot();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function post(endpoint, data = {}, params = {}) {
|
|
150
|
+
await acquireSlot();
|
|
151
|
+
try {
|
|
152
|
+
return await request('post', endpoint, params, data, 0);
|
|
141
153
|
} finally {
|
|
142
154
|
releaseSlot();
|
|
143
155
|
}
|
|
@@ -145,5 +157,6 @@ async function get(endpoint, params = {}) {
|
|
|
145
157
|
|
|
146
158
|
module.exports = {
|
|
147
159
|
get,
|
|
160
|
+
post,
|
|
148
161
|
setConcurrency
|
|
149
162
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic formatter for Zoho Deluge scripts.
|
|
3
|
+
* Adjusts indentation based on block scopes '{' and '}'.
|
|
4
|
+
* Note: This is a simple line-based formatter and does not fully parse the AST.
|
|
5
|
+
*/
|
|
6
|
+
function formatDeluge(code) {
|
|
7
|
+
if (!code || typeof code !== 'string') return code;
|
|
8
|
+
|
|
9
|
+
let formatted = '';
|
|
10
|
+
let indentLevel = 0;
|
|
11
|
+
const indentString = ' '; // 4 spaces
|
|
12
|
+
|
|
13
|
+
// Replace standard block formatting to ensure newlines before/after braces if it's on one line
|
|
14
|
+
// (We skip deep parsing, assuming the code from Zoho generally has basic newlines)
|
|
15
|
+
const lines = code.split('\n');
|
|
16
|
+
|
|
17
|
+
for (let line of lines) {
|
|
18
|
+
line = line.trim();
|
|
19
|
+
|
|
20
|
+
// Preserve empty lines
|
|
21
|
+
if (!line) {
|
|
22
|
+
formatted += '\n';
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Determine if this line closes a block
|
|
27
|
+
if (line.startsWith('}') || line.startsWith(']')) {
|
|
28
|
+
indentLevel = Math.max(0, indentLevel - 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Apply indentation
|
|
32
|
+
formatted += indentString.repeat(indentLevel) + line + '\n';
|
|
33
|
+
|
|
34
|
+
// Determine if this line opens a new block
|
|
35
|
+
if (line.endsWith('{') || line.endsWith('[')) {
|
|
36
|
+
indentLevel++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return formatted.trim() + '\n';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
formatDeluge
|
|
45
|
+
};
|