@mahe_pkm/buzl-capi 0.1.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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Safe HTML & Tracking Injector
3
+ * Injects GTM, Meta Pixel, Form Sync runtime, and configuration
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const {
8
+ HEAD_START_MARKER,
9
+ HEAD_END_MARKER,
10
+ BODY_START_MARKER,
11
+ BODY_END_MARKER,
12
+ generateHeadSnippet,
13
+ generateBodySnippet
14
+ } = require('../templates/gtm-meta-snippets');
15
+ const { createBackup } = require('./rollback');
16
+
17
+ const RUNTIME_START_MARKER = '<!-- BUZL_TRACKING_RUNTIME_START -->';
18
+ const RUNTIME_END_MARKER = '<!-- BUZL_TRACKING_RUNTIME_END -->';
19
+
20
+ /**
21
+ * Generate runtime script tag & inline configuration
22
+ */
23
+ function generateRuntimeSnippet(config, scriptRelPath) {
24
+ const zohoObj = config.zoho || {};
25
+ const zohoXn = (config.zohoXnqsjsdp || zohoObj.xnQsjsdp || '').trim();
26
+ const zohoEndpoint = zohoXn ? (config.zohoEndpoint || zohoObj.endpoint || 'https://crm.zoho.in/crm/WebToLeadForm') : '';
27
+ const zohoXm = zohoXn ? (config.zohoXmiwtld || zohoObj.xmIwtLD || '') : '';
28
+ const zohoFields = zohoXn ? (config.zohoFields || zohoObj.fields || { lastName: 'Last Name', phone: 'Mobile', location: 'City' }) : {};
29
+
30
+ const clientConfig = {
31
+ enableGTM: !!(config.gtmId && config.gtmId.trim()),
32
+ gtmEvent: config.gtmEvent || 'lead_form_submitted',
33
+ enableMeta: !!(config.metaPixelId && config.metaPixelId.trim()),
34
+ trackMetaLeadEvent: !!config.trackMetaLeadEvent,
35
+ googleSheetUrl: config.googleSheetUrl || '',
36
+ siteLocation: config.siteLocation || '',
37
+ zoho: {
38
+ endpoint: zohoEndpoint,
39
+ xnQsjsdp: zohoXn,
40
+ xmIwtLD: zohoXm,
41
+ fields: zohoFields
42
+ },
43
+ buzlCapi: {
44
+ endpoint: (config.buzlCapi && config.buzlCapi.endpoint) || config.buzlCapiEndpoint || '',
45
+ authUser: (config.buzlCapi && config.buzlCapi.authUser) || config.buzlCapiUser || '',
46
+ authPass: (config.buzlCapi && config.buzlCapi.authPass) || config.buzlCapiPass || ''
47
+ },
48
+ whatsapp: {
49
+ number: config.whatsappNumber || (config.whatsapp && config.whatsapp.number) || '',
50
+ template: config.whatsappTemplate || (config.whatsapp && config.whatsapp.template) || 'Hi, I submitted an inquiry from {name} in {location}.'
51
+ },
52
+ safetyTimeoutMs: config.safetyTimeoutMs || 800
53
+ };
54
+
55
+ return [
56
+ RUNTIME_START_MARKER,
57
+ ' <script id="buzl-tracking-config">',
58
+ ` window.__BUZL_CONFIG__ = ${JSON.stringify(clientConfig, null, 2)};`,
59
+ ' </script>',
60
+ ` <script src="${scriptRelPath}" defer></script>`,
61
+ RUNTIME_END_MARKER
62
+ ].join('\n');
63
+ }
64
+
65
+ /**
66
+ * Injects tracking into a single HTML string
67
+ */
68
+ function injectHtml(htmlContent, config, scriptRelPath = 'assets/js/buzl-tracking.js') {
69
+ let content = htmlContent;
70
+
71
+ const hasHeadTrackers = !!((config.gtmId && config.gtmId.trim()) || (config.metaPixelId && config.metaPixelId.trim()));
72
+ const headSnippet = hasHeadTrackers ? generateHeadSnippet(config) : '';
73
+ const bodySnippet = hasHeadTrackers ? generateBodySnippet(config) : '';
74
+ const runtimeSnippet = generateRuntimeSnippet(config, scriptRelPath);
75
+
76
+ // 1. Head snippet replacement or injection
77
+ const headMarkerRegex = new RegExp(`[\\t ]*${HEAD_START_MARKER}[\\s\\S]*?${HEAD_END_MARKER}\\r?\\n?`, 'g');
78
+ if (headMarkerRegex.test(content)) {
79
+ content = content.replace(headMarkerRegex, hasHeadTrackers ? `${headSnippet}\n` : '');
80
+ } else if (hasHeadTrackers) {
81
+ if (/<\/head>/i.test(content)) {
82
+ content = content.replace(/<\/head>/i, `${headSnippet}\n</head>`);
83
+ } else if (/<head[\s>]/i.test(content)) {
84
+ content = content.replace(/(<head[\s>][^>]*>)/i, `$1\n${headSnippet}`);
85
+ }
86
+ }
87
+
88
+ // 2. Body noscript snippet replacement or injection
89
+ const bodyMarkerRegex = new RegExp(`[\\t ]*${BODY_START_MARKER}[\\s\\S]*?${BODY_END_MARKER}\\r?\\n?`, 'g');
90
+ if (bodyMarkerRegex.test(content)) {
91
+ content = content.replace(bodyMarkerRegex, hasHeadTrackers ? `${bodySnippet}\n` : '');
92
+ } else if (hasHeadTrackers) {
93
+ if (/<body[\s>]/i.test(content)) {
94
+ content = content.replace(/(<body[\s>][^>]*>)/i, `$1\n${bodySnippet}`);
95
+ }
96
+ }
97
+
98
+ // 3. Runtime script replacement or injection
99
+ const runtimeMarkerRegex = new RegExp(`${RUNTIME_START_MARKER}[\\s\\S]*?${RUNTIME_END_MARKER}`, 'g');
100
+ if (runtimeMarkerRegex.test(content)) {
101
+ content = content.replace(runtimeMarkerRegex, runtimeSnippet);
102
+ } else if (/<\/body>/i.test(content)) {
103
+ content = content.replace(/<\/body>/i, `${runtimeSnippet}\n</body>`);
104
+ } else {
105
+ content += `\n${runtimeSnippet}`;
106
+ }
107
+
108
+ // 4. Form tag attribute injection (adds data-buzl-track="true" to forms if missing)
109
+ content = content.replace(/<form\b(?![^>]*\bdata-buzl-)([^>]*)>/gi, (match, p1) => {
110
+ return `<form data-buzl-track="true"${p1}>`;
111
+ });
112
+
113
+ return content;
114
+ }
115
+
116
+ /**
117
+ * Apply injection across the whole project
118
+ */
119
+ function applyInjection(rootDir, htmlFiles, config) {
120
+ // If whatsapp number is not explicitly configured, fallback to auto-detected number from buttons
121
+ if (!config.whatsappNumber && !(config.whatsapp && config.whatsapp.number)) {
122
+ try {
123
+ const { scanProject } = require('./scanner');
124
+ const scan = scanProject(rootDir);
125
+ if (scan && scan.detectedWhatsapp) {
126
+ config = Object.assign({}, config, {
127
+ whatsappNumber: scan.detectedWhatsapp,
128
+ whatsapp: Object.assign({}, config.whatsapp || {}, { number: scan.detectedWhatsapp, autoDetected: true })
129
+ });
130
+ }
131
+ } catch (e) {}
132
+ }
133
+
134
+ // 1. Ensure backup first
135
+ const backup = createBackup(htmlFiles, rootDir);
136
+
137
+ // 2. Deploy runtime SDK script into assets/js/
138
+ const targetJsDir = path.join(rootDir, 'assets', 'js');
139
+ fs.mkdirSync(targetJsDir, { recursive: true });
140
+
141
+ const sdkSourcePath = path.join(__dirname, '..', 'templates', 'buzl-tracking.js');
142
+ const sdkDestPath = path.join(targetJsDir, 'buzl-tracking.js');
143
+ fs.copyFileSync(sdkSourcePath, sdkDestPath);
144
+
145
+ // 3. Deploy GoogleAppsScript.gs to root or reference folder
146
+ const gsSource = path.join(__dirname, '..', 'templates', 'GoogleAppsScript.gs');
147
+ const gsDest = path.join(rootDir, 'Buzl_GoogleAppsScript_Template.gs');
148
+ if (!fs.existsSync(gsDest)) {
149
+ fs.copyFileSync(gsSource, gsDest);
150
+ }
151
+
152
+ // 4. Process each HTML file
153
+ const modifiedFiles = [];
154
+ for (const filePath of htmlFiles) {
155
+ const originalHtml = fs.readFileSync(filePath, 'utf8');
156
+ // Calculate relative path to assets/js/buzl-tracking.js
157
+ let scriptRelPath = 'assets/js/buzl-tracking.js';
158
+ if (config && config.pathMode === 'root-absolute') {
159
+ scriptRelPath = '/assets/js/buzl-tracking.js';
160
+ } else {
161
+ const relToTarget = path.relative(path.dirname(filePath), targetJsDir).replace(/\\/g, '/');
162
+ scriptRelPath = relToTarget ? `${relToTarget}/buzl-tracking.js` : 'assets/js/buzl-tracking.js';
163
+ }
164
+
165
+ const modifiedHtml = injectHtml(originalHtml, config, scriptRelPath);
166
+ fs.writeFileSync(filePath, modifiedHtml, 'utf8');
167
+ modifiedFiles.push({
168
+ path: filePath,
169
+ relative: path.relative(rootDir, filePath).replace(/\\/g, '/')
170
+ });
171
+ }
172
+
173
+ return {
174
+ success: true,
175
+ backupDir: backup.backupDir,
176
+ modifiedFiles,
177
+ runtimeScript: path.relative(rootDir, sdkDestPath),
178
+ googleAppsScriptFile: path.relative(rootDir, gsDest)
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Cleanly remove all Buzl tracking tags and restore pristine HTML files
184
+ */
185
+ function removeTracking(rootDir, htmlFiles) {
186
+ if (!htmlFiles || !Array.isArray(htmlFiles)) {
187
+ const { findHtmlFiles } = require('./scanner');
188
+ htmlFiles = findHtmlFiles(rootDir);
189
+ }
190
+
191
+ // 1. Create a named safety backup before removal
192
+ const backup = createBackup(htmlFiles, rootDir, 'Pre-Uninstall Clean Backup');
193
+
194
+ const headMarkerRegex = new RegExp(`[\\t ]*${HEAD_START_MARKER}[\\s\\S]*?${HEAD_END_MARKER}\\r?\\n?`, 'g');
195
+ const bodyMarkerRegex = new RegExp(`[\\t ]*${BODY_START_MARKER}[\\s\\S]*?${BODY_END_MARKER}\\r?\\n?`, 'g');
196
+ const runtimeMarkerRegex = new RegExp(`[\\t ]*${RUNTIME_START_MARKER}[\\s\\S]*?${RUNTIME_END_MARKER}\\r?\\n?`, 'g');
197
+
198
+ const cleanedFiles = [];
199
+
200
+ for (const filePath of htmlFiles) {
201
+ let content = fs.readFileSync(filePath, 'utf8');
202
+
203
+ // Strip markers
204
+ content = content.replace(headMarkerRegex, '');
205
+ content = content.replace(bodyMarkerRegex, '');
206
+ content = content.replace(runtimeMarkerRegex, '');
207
+
208
+ // Strip data-buzl-track="true" attribute from forms
209
+ content = content.replace(/\s*data-buzl-track=["']true["']/gi, '');
210
+
211
+ // Clean up any double blank lines before </head> or </body>
212
+ content = content.replace(/(\r?\n\s*){2,}<\/head>/i, '\n</head>');
213
+ content = content.replace(/(\r?\n\s*){2,}<\/body>/i, '\n</body>');
214
+
215
+ fs.writeFileSync(filePath, content, 'utf8');
216
+ cleanedFiles.push({
217
+ path: filePath,
218
+ relative: path.relative(rootDir, filePath)
219
+ });
220
+ }
221
+
222
+ return {
223
+ success: true,
224
+ backupDir: backup.backupDir,
225
+ cleanedFiles,
226
+ message: `Successfully removed all tracking from ${cleanedFiles.length} file(s). Clean safety backup saved in ${path.basename(backup.backupDir)}.`
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Selectively remove a single tracking service or configuration without breaking other services
232
+ */
233
+ function removeService(rootDir, serviceName) {
234
+ const normService = (serviceName || '').toLowerCase().trim();
235
+ const { scanProject } = require('./scanner');
236
+ const scan = scanProject(rootDir);
237
+ const htmlFiles = scan.files.map(f => f.filePath);
238
+
239
+ if (htmlFiles.length === 0) {
240
+ return { success: false, message: 'No HTML files found in project.' };
241
+ }
242
+
243
+ // If full uninstall requested
244
+ if (normService === 'all' || normService === 'everything') {
245
+ return removeTracking(rootDir, htmlFiles);
246
+ }
247
+
248
+ // 1. Create safety snapshot backup before selective removal
249
+ const backup = createBackup(htmlFiles, rootDir, `Pre-Remove ${normService.toUpperCase()} Backup`);
250
+
251
+ // 2. Derive new configuration by blanking the selected service
252
+ const current = scan.existingConfig || {};
253
+ const newConfig = {
254
+ gtmId: normService === 'gtm' ? '' : (current.gtmId || (normService !== 'gtm' && scan.liveState.gtm.id) || ''),
255
+ enableDeferred: current.enableDeferred !== false,
256
+ metaPixelId: normService === 'meta' ? '' : (current.metaPixelId || (normService !== 'meta' && scan.liveState.meta.id) || ''),
257
+ trackMetaLeadEvent: normService === 'meta' ? false : !!current.trackMetaLeadEvent,
258
+ googleSheetUrl: normService === 'sheets' ? '' : (current.googleSheetUrl || ''),
259
+ siteLocation: current.siteLocation || scan.detectedLocation || '',
260
+ dynamicFields: current.dynamicFields || [],
261
+ buzlCapi: {
262
+ endpoint: normService === 'capi' ? '' : ((current.buzlCapi && current.buzlCapi.endpoint) || ''),
263
+ authUser: normService === 'capi' ? '' : ((current.buzlCapi && current.buzlCapi.authUser) || ''),
264
+ authPass: normService === 'capi' ? '' : ((current.buzlCapi && current.buzlCapi.authPass) || '')
265
+ },
266
+ zoho: {
267
+ endpoint: normService === 'zoho' ? '' : ((current.zoho && current.zoho.endpoint) || ''),
268
+ xnQsjsdp: normService === 'zoho' ? '' : ((current.zoho && current.zoho.xnQsjsdp) || ''),
269
+ xmIwtLD: normService === 'zoho' ? '' : ((current.zoho && current.zoho.xmIwtLD) || ''),
270
+ fields: normService === 'zoho' ? {} : ((current.zoho && current.zoho.fields) || {})
271
+ },
272
+ whatsappNumber: normService === 'whatsapp' ? '' : (current.whatsappNumber || (current.whatsapp && current.whatsapp.number) || '')
273
+ };
274
+
275
+ // 3. Inject updated HTML files
276
+ const targetJsDir = path.join(rootDir, 'assets', 'js');
277
+ const modifiedFiles = [];
278
+
279
+ for (const filePath of htmlFiles) {
280
+ const originalHtml = fs.readFileSync(filePath, 'utf8');
281
+ let scriptRelPath = 'assets/js/buzl-tracking.js';
282
+ if (current && current.pathMode === 'root-absolute') {
283
+ scriptRelPath = '/assets/js/buzl-tracking.js';
284
+ } else {
285
+ const relToTarget = path.relative(path.dirname(filePath), targetJsDir).replace(/\\/g, '/');
286
+ scriptRelPath = relToTarget ? `${relToTarget}/buzl-tracking.js` : 'assets/js/buzl-tracking.js';
287
+ }
288
+
289
+ const modifiedHtml = injectHtml(originalHtml, newConfig, scriptRelPath);
290
+ fs.writeFileSync(filePath, modifiedHtml, 'utf8');
291
+ modifiedFiles.push({
292
+ path: filePath,
293
+ relative: path.relative(rootDir, filePath).replace(/\\/g, '/')
294
+ });
295
+ }
296
+
297
+ const serviceLabels = {
298
+ gtm: 'Google Tag Manager (GTM)',
299
+ meta: 'Meta Pixel & Client CAPI',
300
+ capi: 'Buzl CAPI Server-Side Endpoint',
301
+ sheets: 'Google Sheets Direct Sync',
302
+ zoho: 'Zoho CRM Web-to-Lead',
303
+ whatsapp: 'WhatsApp Handoff'
304
+ };
305
+
306
+ return {
307
+ success: true,
308
+ service: normService,
309
+ serviceLabel: serviceLabels[normService] || normService,
310
+ backupDir: backup.backupDir,
311
+ modifiedFiles,
312
+ message: `Successfully removed ${serviceLabels[normService] || normService} from site. Clean safety backup saved in ${path.basename(backup.backupDir)}.`
313
+ };
314
+ }
315
+
316
+ module.exports = {
317
+ injectHtml,
318
+ applyInjection,
319
+ removeTracking,
320
+ removeService
321
+ };
@@ -0,0 +1,281 @@
1
+ /**
2
+ * ============================================================================
3
+ * BUZL BACKUP & SNAPSHOT MANAGER
4
+ * Provides 100% safe file snapshots with SHA content hashing inside `.buzl/snapshots/`
5
+ *
6
+ * Copyright (c) 2026 Buzl Digital Solutions
7
+ * Licensed under the MIT License
8
+ * ============================================================================
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const crypto = require('crypto');
14
+
15
+ /**
16
+ * Compute a deterministic SHA-1 content hash across all candidate files
17
+ */
18
+ function computeFilesHash(files, rootDir) {
19
+ const hashSum = crypto.createHash('sha1');
20
+ for (const filePath of files) {
21
+ if (fs.existsSync(filePath)) {
22
+ hashSum.update(path.relative(rootDir, filePath));
23
+ try {
24
+ hashSum.update(fs.readFileSync(filePath));
25
+ } catch (e) {}
26
+ }
27
+ }
28
+ return hashSum.digest('hex').substring(0, 8);
29
+ }
30
+
31
+ /**
32
+ * Create a point-in-time snapshot backup
33
+ * Stored in `<rootDir>/.buzl/snapshots/<timestamp>_<hash>/`
34
+ */
35
+ function createBackup(arg1, arg2, arg3 = '') {
36
+ let files;
37
+ let rootDir;
38
+ let backupName = '';
39
+
40
+ if (Array.isArray(arg1)) {
41
+ files = arg1;
42
+ rootDir = arg2;
43
+ backupName = arg3 || '';
44
+ } else if (typeof arg1 === 'string') {
45
+ rootDir = arg1;
46
+ backupName = typeof arg2 === 'string' ? arg2 : '';
47
+ const { findHtmlFiles } = require('./scanner');
48
+ files = findHtmlFiles(rootDir);
49
+ } else {
50
+ files = [];
51
+ rootDir = process.cwd();
52
+ }
53
+
54
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
55
+ const hash = computeFilesHash(files, rootDir) || '00000000';
56
+ const dirName = `${timestamp}_${hash}`;
57
+
58
+ // Dedicated unified .buzl/snapshots directory
59
+ const snapshotsDir = path.join(rootDir, '.buzl', 'snapshots');
60
+ const backupDir = path.join(snapshotsDir, dirName);
61
+ fs.mkdirSync(backupDir, { recursive: true });
62
+
63
+ const manifest = {
64
+ id: dirName,
65
+ hash: hash,
66
+ name: (backupName || '').trim() || `Snapshot ${new Date().toLocaleTimeString()} [${hash}]`,
67
+ timestamp: new Date().toISOString(),
68
+ files: []
69
+ };
70
+
71
+ for (const filePath of files) {
72
+ const relPath = path.relative(rootDir, filePath);
73
+ const destPath = path.join(backupDir, relPath);
74
+ const destDir = path.dirname(destPath);
75
+ fs.mkdirSync(destDir, { recursive: true });
76
+ fs.copyFileSync(filePath, destPath);
77
+ manifest.files.push(relPath);
78
+ }
79
+
80
+ fs.writeFileSync(
81
+ path.join(backupDir, 'backup-manifest.json'),
82
+ JSON.stringify(manifest, null, 2),
83
+ 'utf8'
84
+ );
85
+
86
+ return {
87
+ success: true,
88
+ backupDir,
89
+ dirName,
90
+ hash,
91
+ timestamp: manifest.timestamp,
92
+ name: manifest.name,
93
+ fileCount: manifest.files.length
94
+ };
95
+ }
96
+
97
+ /**
98
+ * List all available snapshot backups
99
+ * Checks `.buzl/snapshots/` first, plus legacy `.buzl-backup-*` folders
100
+ */
101
+ function listBackups(rootDir) {
102
+ const allBackups = [];
103
+
104
+ // 1. Primary: Check .buzl/snapshots/
105
+ const snapshotsDir = path.join(rootDir, '.buzl', 'snapshots');
106
+ if (fs.existsSync(snapshotsDir)) {
107
+ try {
108
+ const entries = fs.readdirSync(snapshotsDir);
109
+ for (const e of entries) {
110
+ const dirPath = path.join(snapshotsDir, e);
111
+ if (fs.statSync(dirPath).isDirectory()) {
112
+ let manifest = null;
113
+ try {
114
+ const mPath = path.join(dirPath, 'backup-manifest.json');
115
+ if (fs.existsSync(mPath)) {
116
+ manifest = JSON.parse(fs.readFileSync(mPath, 'utf8'));
117
+ }
118
+ } catch (err) {}
119
+
120
+ allBackups.push({
121
+ id: (manifest && manifest.id) || e,
122
+ dirName: e,
123
+ hash: (manifest && manifest.hash) || (e.split('_')[1] || ''),
124
+ fullPath: dirPath,
125
+ name: (manifest && manifest.name) || e,
126
+ timestamp: (manifest && manifest.timestamp) || '',
127
+ filesCount: (manifest && manifest.files && manifest.files.length) || 0,
128
+ isLegacy: false,
129
+ manifest
130
+ });
131
+ }
132
+ }
133
+ } catch (e) {}
134
+ }
135
+
136
+ // 2. Fallback: Check legacy root-level `.buzl-backup-*` directories
137
+ try {
138
+ const rootEntries = fs.readdirSync(rootDir);
139
+ for (const e of rootEntries) {
140
+ if (e.startsWith('.buzl-backup-')) {
141
+ const dirPath = path.join(rootDir, e);
142
+ if (fs.statSync(dirPath).isDirectory()) {
143
+ let manifest = null;
144
+ try {
145
+ const mPath = path.join(dirPath, 'backup-manifest.json');
146
+ if (fs.existsSync(mPath)) {
147
+ manifest = JSON.parse(fs.readFileSync(mPath, 'utf8'));
148
+ }
149
+ } catch (err) {}
150
+
151
+ allBackups.push({
152
+ id: e,
153
+ dirName: e,
154
+ hash: (manifest && manifest.hash) || '',
155
+ fullPath: dirPath,
156
+ name: (manifest && manifest.name) || e.replace('.buzl-backup-', ''),
157
+ timestamp: (manifest && manifest.timestamp) || '',
158
+ filesCount: (manifest && manifest.files && manifest.files.length) || 0,
159
+ isLegacy: true,
160
+ manifest
161
+ });
162
+ }
163
+ }
164
+ }
165
+ } catch (e) {}
166
+
167
+ // Sort newest first
168
+ allBackups.sort((a, b) => b.dirName.localeCompare(a.dirName));
169
+ return allBackups;
170
+ }
171
+
172
+ /**
173
+ * Restore a specific snapshot or 'latest'
174
+ */
175
+ function restoreBackup(rootDir, identifier) {
176
+ const backups = listBackups(rootDir);
177
+ if (backups.length === 0) {
178
+ return { success: false, message: 'No backups found to restore.' };
179
+ }
180
+
181
+ let targetBackup = backups[0];
182
+ if (identifier && identifier !== 'latest') {
183
+ targetBackup = backups.find(b =>
184
+ b.dirName === identifier ||
185
+ b.name === identifier ||
186
+ b.hash === identifier ||
187
+ b.id === identifier
188
+ );
189
+ if (!targetBackup) {
190
+ return { success: false, message: `Backup "${identifier}" not found.` };
191
+ }
192
+ }
193
+
194
+ const manifest = targetBackup.manifest;
195
+ if (!manifest || !manifest.files) {
196
+ return { success: false, message: 'Backup manifest corrupted or missing.' };
197
+ }
198
+
199
+ let restoredCount = 0;
200
+ for (const relFile of manifest.files) {
201
+ const src = path.join(targetBackup.fullPath, relFile);
202
+ const dest = path.join(rootDir, relFile);
203
+ if (fs.existsSync(src)) {
204
+ const destDir = path.dirname(dest);
205
+ fs.mkdirSync(destDir, { recursive: true });
206
+ fs.copyFileSync(src, dest);
207
+ restoredCount++;
208
+ }
209
+ }
210
+
211
+ return {
212
+ success: true,
213
+ backupUsed: targetBackup.dirName,
214
+ backupName: targetBackup.name,
215
+ hash: targetBackup.hash,
216
+ restoredCount,
217
+ message: `Successfully restored ${restoredCount} file(s) from "${targetBackup.name}" [${targetBackup.hash || targetBackup.dirName}].`
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Restore the latest snapshot
223
+ */
224
+ function restoreLatestBackup(rootDir) {
225
+ return restoreBackup(rootDir, 'latest');
226
+ }
227
+
228
+ /**
229
+ * Delete a specific backup
230
+ */
231
+ function deleteBackup(rootDir, identifier) {
232
+ const backups = listBackups(rootDir);
233
+ const target = backups.find(b =>
234
+ b.dirName === identifier ||
235
+ b.name === identifier ||
236
+ b.id === identifier ||
237
+ b.hash === identifier
238
+ );
239
+
240
+ if (!target) {
241
+ return { success: false, message: `Backup "${identifier}" not found.` };
242
+ }
243
+
244
+ try {
245
+ fs.rmSync(target.fullPath, { recursive: true, force: true });
246
+ return { success: true, message: `Backup "${target.name}" deleted successfully.` };
247
+ } catch (e) {
248
+ return { success: false, message: e.message };
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Create a manual named backup
254
+ */
255
+ function manualBackup(rootDir, backupName = '') {
256
+ const { findHtmlFiles } = require('./scanner');
257
+ const files = findHtmlFiles(rootDir);
258
+ if (files.length === 0) {
259
+ return { success: false, message: 'No HTML files found to backup.' };
260
+ }
261
+ const result = createBackup(files, rootDir, backupName);
262
+ return {
263
+ success: true,
264
+ backupDir: result.backupDir,
265
+ dirName: result.dirName,
266
+ hash: result.hash,
267
+ name: result.name,
268
+ fileCount: result.fileCount,
269
+ message: `Snapshot "${result.name}" created with ${result.fileCount} file(s) [SHA: ${result.hash}].`
270
+ };
271
+ }
272
+
273
+ module.exports = {
274
+ createBackup,
275
+ computeFilesHash,
276
+ listBackups,
277
+ restoreBackup,
278
+ restoreLatestBackup,
279
+ deleteBackup,
280
+ manualBackup
281
+ };