pal-explorer-cli 0.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.
Files changed (156) hide show
  1. package/LICENSE.md +18 -0
  2. package/README.md +314 -0
  3. package/bin/pal.js +230 -0
  4. package/extensions/@palexplorer/analytics/README.md +45 -0
  5. package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
  6. package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
  7. package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
  8. package/extensions/@palexplorer/analytics/extension.json +27 -0
  9. package/extensions/@palexplorer/analytics/index.js +186 -0
  10. package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
  11. package/extensions/@palexplorer/audit/extension.json +17 -0
  12. package/extensions/@palexplorer/audit/index.js +2 -0
  13. package/extensions/@palexplorer/auth-email/extension.json +17 -0
  14. package/extensions/@palexplorer/auth-email/index.js +102 -0
  15. package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
  16. package/extensions/@palexplorer/auth-oauth/index.js +199 -0
  17. package/extensions/@palexplorer/chat/extension.json +17 -0
  18. package/extensions/@palexplorer/chat/index.js +2 -0
  19. package/extensions/@palexplorer/discovery/extension.json +16 -0
  20. package/extensions/@palexplorer/discovery/index.js +111 -0
  21. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  22. package/extensions/@palexplorer/email-notifications/index.js +242 -0
  23. package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
  24. package/extensions/@palexplorer/explorer-integration/index.js +122 -0
  25. package/extensions/@palexplorer/groups/extension.json +17 -0
  26. package/extensions/@palexplorer/groups/index.js +2 -0
  27. package/extensions/@palexplorer/networks/extension.json +17 -0
  28. package/extensions/@palexplorer/networks/index.js +2 -0
  29. package/extensions/@palexplorer/share-links/extension.json +17 -0
  30. package/extensions/@palexplorer/share-links/index.js +2 -0
  31. package/extensions/@palexplorer/sync/extension.json +17 -0
  32. package/extensions/@palexplorer/sync/index.js +2 -0
  33. package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
  34. package/extensions/@palexplorer/user-mgmt/index.js +2 -0
  35. package/extensions/@palexplorer/vfs/extension.json +17 -0
  36. package/extensions/@palexplorer/vfs/index.js +167 -0
  37. package/lib/capabilities.js +263 -0
  38. package/lib/commands/analytics.js +175 -0
  39. package/lib/commands/api-keys.js +131 -0
  40. package/lib/commands/audit.js +235 -0
  41. package/lib/commands/auth.js +137 -0
  42. package/lib/commands/backup.js +76 -0
  43. package/lib/commands/billing.js +148 -0
  44. package/lib/commands/chat.js +217 -0
  45. package/lib/commands/cloud-backup.js +231 -0
  46. package/lib/commands/comment.js +99 -0
  47. package/lib/commands/completion.js +203 -0
  48. package/lib/commands/compliance.js +218 -0
  49. package/lib/commands/config.js +136 -0
  50. package/lib/commands/connect.js +44 -0
  51. package/lib/commands/dept.js +294 -0
  52. package/lib/commands/device.js +146 -0
  53. package/lib/commands/download.js +226 -0
  54. package/lib/commands/explorer.js +178 -0
  55. package/lib/commands/extension.js +970 -0
  56. package/lib/commands/favorite.js +90 -0
  57. package/lib/commands/federation.js +270 -0
  58. package/lib/commands/file.js +533 -0
  59. package/lib/commands/group.js +271 -0
  60. package/lib/commands/gui-share.js +29 -0
  61. package/lib/commands/init.js +61 -0
  62. package/lib/commands/invite.js +59 -0
  63. package/lib/commands/list.js +59 -0
  64. package/lib/commands/log.js +116 -0
  65. package/lib/commands/nearby.js +108 -0
  66. package/lib/commands/network.js +251 -0
  67. package/lib/commands/notify.js +198 -0
  68. package/lib/commands/org.js +273 -0
  69. package/lib/commands/pal.js +180 -0
  70. package/lib/commands/permissions.js +216 -0
  71. package/lib/commands/pin.js +97 -0
  72. package/lib/commands/protocol.js +357 -0
  73. package/lib/commands/rbac.js +147 -0
  74. package/lib/commands/recover.js +36 -0
  75. package/lib/commands/register.js +171 -0
  76. package/lib/commands/relay.js +131 -0
  77. package/lib/commands/remote.js +368 -0
  78. package/lib/commands/revoke.js +50 -0
  79. package/lib/commands/scanner.js +280 -0
  80. package/lib/commands/schedule.js +344 -0
  81. package/lib/commands/scim.js +203 -0
  82. package/lib/commands/search.js +181 -0
  83. package/lib/commands/serve.js +438 -0
  84. package/lib/commands/server.js +350 -0
  85. package/lib/commands/share-link.js +199 -0
  86. package/lib/commands/share.js +323 -0
  87. package/lib/commands/sso.js +200 -0
  88. package/lib/commands/status.js +136 -0
  89. package/lib/commands/stream.js +562 -0
  90. package/lib/commands/su.js +187 -0
  91. package/lib/commands/sync.js +827 -0
  92. package/lib/commands/transfers.js +152 -0
  93. package/lib/commands/uninstall.js +188 -0
  94. package/lib/commands/update.js +204 -0
  95. package/lib/commands/user.js +276 -0
  96. package/lib/commands/vfs.js +84 -0
  97. package/lib/commands/web.js +52 -0
  98. package/lib/commands/webhook.js +180 -0
  99. package/lib/commands/whoami.js +59 -0
  100. package/lib/commands/workspace.js +121 -0
  101. package/lib/core/accessLog.js +54 -0
  102. package/lib/core/analytics.js +99 -0
  103. package/lib/core/backup.js +84 -0
  104. package/lib/core/billing.js +336 -0
  105. package/lib/core/bitfieldStore.js +53 -0
  106. package/lib/core/connectionManager.js +182 -0
  107. package/lib/core/dhtDiscovery.js +148 -0
  108. package/lib/core/discoveryClient.js +408 -0
  109. package/lib/core/extensionAnalyzer.js +357 -0
  110. package/lib/core/extensionSandbox.js +250 -0
  111. package/lib/core/extensionWorkerHost.js +166 -0
  112. package/lib/core/extensions.js +1082 -0
  113. package/lib/core/fileDiff.js +69 -0
  114. package/lib/core/groups.js +119 -0
  115. package/lib/core/identity.js +340 -0
  116. package/lib/core/mdnsService.js +126 -0
  117. package/lib/core/networks.js +81 -0
  118. package/lib/core/permissions.js +109 -0
  119. package/lib/core/pro.js +27 -0
  120. package/lib/core/resolver.js +74 -0
  121. package/lib/core/serverList.js +224 -0
  122. package/lib/core/sharePolicy.js +69 -0
  123. package/lib/core/shares.js +325 -0
  124. package/lib/core/signalingServer.js +441 -0
  125. package/lib/core/streamTransport.js +106 -0
  126. package/lib/core/su.js +55 -0
  127. package/lib/core/syncEngine.js +264 -0
  128. package/lib/core/syncState.js +159 -0
  129. package/lib/core/transfers.js +259 -0
  130. package/lib/core/users.js +225 -0
  131. package/lib/core/vfs.js +216 -0
  132. package/lib/core/webServer.js +702 -0
  133. package/lib/core/webrtcStream.js +396 -0
  134. package/lib/crypto/chatEncryption.js +57 -0
  135. package/lib/crypto/shareEncryption.js +195 -0
  136. package/lib/crypto/sharePassword.js +35 -0
  137. package/lib/crypto/streamEncryption.js +189 -0
  138. package/lib/package.json +1 -0
  139. package/lib/protocol/envelope.js +271 -0
  140. package/lib/protocol/handler.js +191 -0
  141. package/lib/protocol/index.js +27 -0
  142. package/lib/protocol/messages.js +247 -0
  143. package/lib/protocol/negotiation.js +127 -0
  144. package/lib/protocol/policy.js +142 -0
  145. package/lib/protocol/router.js +86 -0
  146. package/lib/protocol/sync.js +122 -0
  147. package/lib/utils/cli.js +15 -0
  148. package/lib/utils/config.js +123 -0
  149. package/lib/utils/configIntegrity.js +87 -0
  150. package/lib/utils/downloadDir.js +9 -0
  151. package/lib/utils/explorer.js +83 -0
  152. package/lib/utils/format.js +12 -0
  153. package/lib/utils/help.js +357 -0
  154. package/lib/utils/logger.js +103 -0
  155. package/lib/utils/torrent.js +203 -0
  156. package/package.json +71 -0
@@ -0,0 +1,264 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import logger from '../utils/logger.js';
5
+
6
+ // mtime+size hash cache: skip re-hashing files that haven't changed
7
+ const _hashCache = new Map();
8
+
9
+ function getCachedHash(filePath, mtimeMs, size) {
10
+ const cached = _hashCache.get(filePath);
11
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
12
+ return cached.hash;
13
+ }
14
+ return null;
15
+ }
16
+
17
+ function setCachedHash(filePath, mtimeMs, size, hash) {
18
+ _hashCache.set(filePath, { mtimeMs, size, hash });
19
+ }
20
+
21
+ const IGNORE_PATTERNS = [
22
+ /node_modules/,
23
+ /\.git\//,
24
+ /\.DS_Store$/,
25
+ /Thumbs\.db$/,
26
+ /\.sync-conflict-/,
27
+ ];
28
+
29
+ function shouldIgnore(relativePath) {
30
+ return IGNORE_PATTERNS.some(p => p.test(relativePath));
31
+ }
32
+
33
+ function hashFileStream(filePath) {
34
+ return new Promise((resolve, reject) => {
35
+ const h = crypto.createHash('sha256');
36
+ const stream = fs.createReadStream(filePath);
37
+ stream.on('data', (chunk) => h.update(chunk));
38
+ stream.on('end', () => resolve(h.digest('hex')));
39
+ stream.on('error', reject);
40
+ });
41
+ }
42
+
43
+ export async function buildManifest(dirPath, cachedManifest) {
44
+ const cachedMap = new Map();
45
+ if (cachedManifest) {
46
+ const entries = Array.isArray(cachedManifest) ? cachedManifest : Object.values(cachedManifest);
47
+ for (const entry of entries) {
48
+ cachedMap.set(entry.relativePath, entry);
49
+ }
50
+ }
51
+ const absDir = path.resolve(dirPath);
52
+ if (!fs.existsSync(absDir)) {
53
+ throw new Error(`Directory not found: ${absDir}`);
54
+ }
55
+ if (!fs.statSync(absDir).isDirectory()) {
56
+ throw new Error(`Not a directory: ${absDir}`);
57
+ }
58
+
59
+ const entries = [];
60
+
61
+ async function walk(dir, relBase) {
62
+ let dirents;
63
+ try {
64
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
65
+ } catch (err) {
66
+ logger.warn(`Cannot read directory: ${dir} (${err.message})`);
67
+ return;
68
+ }
69
+
70
+ for (const dirent of dirents) {
71
+ const fullPath = path.join(dir, dirent.name);
72
+ const relativePath = relBase ? `${relBase}/${dirent.name}` : dirent.name;
73
+
74
+ if (shouldIgnore(relativePath)) continue;
75
+
76
+ if (dirent.isDirectory()) {
77
+ await walk(fullPath, relativePath);
78
+ } else if (dirent.isFile()) {
79
+ try {
80
+ const stat = fs.statSync(fullPath);
81
+ let hash = getCachedHash(fullPath, stat.mtimeMs, stat.size);
82
+ if (!hash) {
83
+ const cached = cachedMap.get(relativePath);
84
+ if (cached && cached.size === stat.size && Math.abs((cached.mtime || 0) - stat.mtimeMs) <= 1) {
85
+ hash = cached.hash;
86
+ }
87
+ }
88
+ if (!hash) {
89
+ hash = await hashFileStream(fullPath);
90
+ setCachedHash(fullPath, stat.mtimeMs, stat.size, hash);
91
+ }
92
+ entries.push({
93
+ relativePath,
94
+ size: stat.size,
95
+ hash,
96
+ mtime: stat.mtimeMs,
97
+ });
98
+ } catch (err) {
99
+ logger.warn(`Cannot read file: ${fullPath} (${err.message})`);
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ await walk(absDir, '');
106
+ return entries;
107
+ }
108
+
109
+ export function manifestToMap(manifest) {
110
+ const map = {};
111
+ for (const entry of manifest) {
112
+ map[entry.relativePath] = entry;
113
+ }
114
+ return map;
115
+ }
116
+
117
+ export function diffManifests(local, remote) {
118
+ const localMap = Array.isArray(local) ? manifestToMap(local) : local;
119
+ const remoteMap = Array.isArray(remote) ? manifestToMap(remote) : remote;
120
+
121
+ const added = [];
122
+ const modified = [];
123
+ const deleted = [];
124
+ const unchanged = [];
125
+ const conflicts = [];
126
+
127
+ const allPaths = new Set([...Object.keys(localMap), ...Object.keys(remoteMap)]);
128
+
129
+ for (const filePath of allPaths) {
130
+ const localEntry = localMap[filePath];
131
+ const remoteEntry = remoteMap[filePath];
132
+
133
+ if (!localEntry && remoteEntry) {
134
+ added.push({ relativePath: filePath, remote: remoteEntry });
135
+ } else if (localEntry && !remoteEntry) {
136
+ deleted.push({ relativePath: filePath, local: localEntry });
137
+ } else if (localEntry.hash === remoteEntry.hash) {
138
+ unchanged.push({ relativePath: filePath });
139
+ } else {
140
+ modified.push({
141
+ relativePath: filePath,
142
+ local: localEntry,
143
+ remote: remoteEntry,
144
+ });
145
+ }
146
+ }
147
+
148
+ return { added, modified, deleted, unchanged, conflicts };
149
+ }
150
+
151
+ export function detectConflicts(localManifest, remoteManifest, baseManifest) {
152
+ const localMap = Array.isArray(localManifest) ? manifestToMap(localManifest) : localManifest;
153
+ const remoteMap = Array.isArray(remoteManifest) ? manifestToMap(remoteManifest) : remoteManifest;
154
+ const baseMap = Array.isArray(baseManifest) ? manifestToMap(baseManifest) : baseManifest;
155
+
156
+ const conflicts = [];
157
+ const safeModified = [];
158
+
159
+ const allPaths = new Set([...Object.keys(localMap), ...Object.keys(remoteMap)]);
160
+
161
+ for (const filePath of allPaths) {
162
+ const local = localMap[filePath];
163
+ const remote = remoteMap[filePath];
164
+ const base = baseMap[filePath];
165
+
166
+ if (!local || !remote) continue;
167
+ if (local.hash === remote.hash) continue;
168
+
169
+ // No base = first sync — treat as safe modification (remote wins), not conflict
170
+ if (!base) {
171
+ safeModified.push({ relativePath: filePath, local, remote });
172
+ continue;
173
+ }
174
+
175
+ const localChanged = base.hash !== local.hash;
176
+ const remoteChanged = base.hash !== remote.hash;
177
+
178
+ if (localChanged && remoteChanged) {
179
+ conflicts.push({ relativePath: filePath, local, remote, base });
180
+ } else {
181
+ safeModified.push({ relativePath: filePath, local, remote });
182
+ }
183
+ }
184
+
185
+ return { conflicts, safeModified };
186
+ }
187
+
188
+ export function applyDiff(diff, sourcePath, destPath, options = {}) {
189
+ const { dryRun = false } = options;
190
+ const results = { copied: [], deleted: [], conflicted: [], errors: [] };
191
+
192
+ for (const entry of diff.added) {
193
+ const src = path.join(sourcePath, entry.relativePath);
194
+ const dst = path.join(destPath, entry.relativePath);
195
+ if (dryRun) {
196
+ results.copied.push(entry.relativePath);
197
+ continue;
198
+ }
199
+ try {
200
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
201
+ fs.copyFileSync(src, dst);
202
+ results.copied.push(entry.relativePath);
203
+ } catch (err) {
204
+ results.errors.push({ file: entry.relativePath, error: err.message });
205
+ }
206
+ }
207
+
208
+ for (const entry of diff.modified) {
209
+ const src = path.join(sourcePath, entry.relativePath);
210
+ const dst = path.join(destPath, entry.relativePath);
211
+ if (dryRun) {
212
+ results.copied.push(entry.relativePath);
213
+ continue;
214
+ }
215
+ try {
216
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
217
+ fs.copyFileSync(src, dst);
218
+ results.copied.push(entry.relativePath);
219
+ } catch (err) {
220
+ results.errors.push({ file: entry.relativePath, error: err.message });
221
+ }
222
+ }
223
+
224
+ for (const entry of (diff.conflicts || [])) {
225
+ const src = path.join(sourcePath, entry.relativePath);
226
+ const dst = path.join(destPath, entry.relativePath);
227
+ if (dryRun) {
228
+ results.conflicted.push(entry.relativePath);
229
+ continue;
230
+ }
231
+ try {
232
+ const ext = path.extname(entry.relativePath);
233
+ const base = entry.relativePath.slice(0, -ext.length || undefined);
234
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
235
+ const conflictName = `${base}.sync-conflict-${timestamp}${ext}`;
236
+ const conflictDst = path.join(destPath, conflictName);
237
+ fs.mkdirSync(path.dirname(conflictDst), { recursive: true });
238
+ fs.copyFileSync(src, conflictDst);
239
+ results.conflicted.push({ original: entry.relativePath, conflictFile: conflictName });
240
+ } catch (err) {
241
+ results.errors.push({ file: entry.relativePath, error: err.message });
242
+ }
243
+ }
244
+
245
+ for (const entry of diff.deleted) {
246
+ const dst = path.join(destPath, entry.relativePath);
247
+ if (dryRun) {
248
+ results.deleted.push(entry.relativePath);
249
+ continue;
250
+ }
251
+ try {
252
+ if (fs.existsSync(dst)) {
253
+ fs.unlinkSync(dst);
254
+ results.deleted.push(entry.relativePath);
255
+ }
256
+ } catch (err) {
257
+ results.errors.push({ file: entry.relativePath, error: err.message });
258
+ }
259
+ }
260
+
261
+ return results;
262
+ }
263
+
264
+ export { formatSize } from '../utils/format.js';
@@ -0,0 +1,159 @@
1
+ import config from '../utils/config.js';
2
+ import crypto from 'crypto';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ // --- Sync Pairs ---
7
+
8
+ export function getSyncPairs() {
9
+ return config.get('syncPairs') || [];
10
+ }
11
+
12
+ export function getSyncPair(id) {
13
+ return getSyncPairs().find(p => p.id === id);
14
+ }
15
+
16
+ export function findSyncPair(localPath, palId) {
17
+ const absPath = path.resolve(localPath);
18
+ return getSyncPairs().find(p => p.localPath === absPath && p.palId === palId);
19
+ }
20
+
21
+ export function addSyncPair(localPath, palId, remotePath) {
22
+ const pairs = getSyncPairs();
23
+ const absPath = path.resolve(localPath);
24
+
25
+ const existing = pairs.find(p => p.localPath === absPath && p.palId === palId);
26
+ if (existing) return existing;
27
+
28
+ const id = crypto.randomUUID();
29
+ const pair = {
30
+ id,
31
+ localPath: absPath,
32
+ palId,
33
+ remotePath,
34
+ lastSync: null,
35
+ status: 'idle',
36
+ createdAt: new Date().toISOString()
37
+ };
38
+ pairs.push(pair);
39
+ config.set('syncPairs', pairs);
40
+ return pair;
41
+ }
42
+
43
+ export function updateSyncPair(id, updates) {
44
+ const pairs = getSyncPairs();
45
+ const pair = pairs.find(p => p.id === id);
46
+ if (!pair) return null;
47
+ Object.assign(pair, updates);
48
+ config.set('syncPairs', pairs);
49
+ return pair;
50
+ }
51
+
52
+ export function removeSyncPair(id) {
53
+ const pairs = getSyncPairs().filter(p => p.id !== id);
54
+ config.set('syncPairs', pairs);
55
+ clearSyncManifest(id);
56
+ clearSyncHistory(id);
57
+ }
58
+
59
+ // --- Last-Synced Manifests ---
60
+
61
+ function manifestKey(pairId) {
62
+ return `syncManifests.${pairId}`;
63
+ }
64
+
65
+ export function getSyncManifest(pairId) {
66
+ return config.get(manifestKey(pairId)) || null;
67
+ }
68
+
69
+ export function saveSyncManifest(pairId, manifest) {
70
+ config.set(manifestKey(pairId), manifest);
71
+ }
72
+
73
+ export function clearSyncManifest(pairId) {
74
+ config.delete(manifestKey(pairId));
75
+ }
76
+
77
+ export function getLastSyncedManifestMap(pairId) {
78
+ const manifest = getSyncManifest(pairId);
79
+ if (!manifest) return {};
80
+ const map = {};
81
+ for (const entry of manifest) {
82
+ map[entry.relativePath] = entry;
83
+ }
84
+ return map;
85
+ }
86
+
87
+ // --- Sync History ---
88
+
89
+ function historyKey(pairId) {
90
+ return `syncHistory.${pairId}`;
91
+ }
92
+
93
+ const MAX_HISTORY = 50;
94
+
95
+ export function getSyncHistory(pairId) {
96
+ return config.get(historyKey(pairId)) || [];
97
+ }
98
+
99
+ export function addSyncHistoryEntry(pairId, entry) {
100
+ const history = getSyncHistory(pairId);
101
+ history.unshift({
102
+ id: crypto.randomUUID(),
103
+ timestamp: new Date().toISOString(),
104
+ ...entry,
105
+ });
106
+ if (history.length > MAX_HISTORY) history.length = MAX_HISTORY;
107
+ config.set(historyKey(pairId), history);
108
+ return history[0];
109
+ }
110
+
111
+ export function clearSyncHistory(pairId) {
112
+ config.delete(historyKey(pairId));
113
+ }
114
+
115
+ // --- Legacy compat: buildFileManifest / diffManifests (used by old code) ---
116
+
117
+ export function buildFileManifest(dirPath) {
118
+ const manifest = {};
119
+ function walk(dir, relBase) {
120
+ let entries;
121
+ try {
122
+ entries = fs.readdirSync(dir, { withFileTypes: true });
123
+ } catch {
124
+ return;
125
+ }
126
+ for (const entry of entries) {
127
+ const full = path.join(dir, entry.name);
128
+ const rel = relBase ? path.join(relBase, entry.name).split(path.sep).join('/') : entry.name;
129
+ if (entry.isDirectory()) {
130
+ walk(full, rel);
131
+ } else if (entry.isFile()) {
132
+ try {
133
+ const stat = fs.statSync(full);
134
+ const hash = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex');
135
+ manifest[rel] = { size: stat.size, modified: stat.mtimeMs, hash };
136
+ } catch {
137
+ // skip unreadable files
138
+ }
139
+ }
140
+ }
141
+ }
142
+ walk(dirPath, '');
143
+ return manifest;
144
+ }
145
+
146
+ export function diffManifests(local, remote) {
147
+ const added = [];
148
+ const modified = [];
149
+ const deleted = [];
150
+
151
+ for (const [file, info] of Object.entries(remote)) {
152
+ if (!local[file]) added.push(file);
153
+ else if (local[file].hash !== info.hash) modified.push(file);
154
+ }
155
+ for (const file of Object.keys(local)) {
156
+ if (!remote[file]) deleted.push(file);
157
+ }
158
+ return { added, modified, deleted };
159
+ }
@@ -0,0 +1,259 @@
1
+ import Conf from 'conf';
2
+ import crypto from 'crypto';
3
+ import path from 'path';
4
+ import { hooks } from './extensions.js';
5
+
6
+
7
+ const transferStore = new Conf({
8
+ projectName: 'palexplorer-cli',
9
+ configName: 'transfers',
10
+ defaults: {
11
+ active: [],
12
+ history: [],
13
+ batches: []
14
+ }
15
+ });
16
+
17
+ export const getTransfers = () => transferStore.get('active');
18
+
19
+ export const getTransferHistory = () => transferStore.get('history');
20
+
21
+ export const trackTransfer = (magnet, name, savePath, encryptedShareKey = null) => {
22
+ const active = transferStore.get('active');
23
+ if (active.find(t => t.magnet === magnet)) return;
24
+
25
+ const entry = {
26
+ magnet,
27
+ name,
28
+ savePath,
29
+ addedAt: new Date().toISOString(),
30
+ status: 'downloading',
31
+ progress: 0,
32
+ paused: false
33
+ };
34
+ if (encryptedShareKey) entry.encryptedShareKey = encryptedShareKey;
35
+
36
+ active.push(entry);
37
+ transferStore.set('active', active);
38
+ };
39
+
40
+ export const setTransferPaused = (magnet, isPaused) => {
41
+ const active = transferStore.get('active');
42
+ const index = active.findIndex(t => t.magnet === magnet);
43
+ if (index !== -1) {
44
+ active[index].status = isPaused ? 'paused' : 'downloading';
45
+ active[index].paused = isPaused;
46
+ transferStore.set('active', active);
47
+ }
48
+ };
49
+
50
+ export const updateTransferProgress = (magnet, progress) => {
51
+ const active = transferStore.get('active');
52
+ const index = active.findIndex(t => t.magnet === magnet);
53
+ if (index !== -1) {
54
+ active[index].progress = progress;
55
+ transferStore.set('active', active);
56
+ }
57
+ };
58
+
59
+ export const completeTransfer = (magnet) => {
60
+ const active = transferStore.get('active');
61
+ const history = transferStore.get('history');
62
+
63
+ const index = active.findIndex(t => t.magnet === magnet);
64
+ if (index !== -1) {
65
+ const [transfer] = active.splice(index, 1);
66
+ transfer.status = 'completed';
67
+ transfer.completedAt = new Date().toISOString();
68
+ history.push(transfer);
69
+
70
+ transferStore.set('active', active);
71
+ transferStore.set('history', history);
72
+
73
+ hooks.emit('after:download:complete', {
74
+ filePath: transfer.savePath ? path.join(transfer.savePath, transfer.name || '') : null,
75
+ name: transfer.name,
76
+ magnet: transfer.magnet,
77
+ size: transfer.bytesTransferred || 0,
78
+ duration: transfer.startedAt && transfer.completedAt
79
+ ? new Date(transfer.completedAt).getTime() - new Date(transfer.startedAt).getTime()
80
+ : 0,
81
+ speed: transfer.speed || 0,
82
+ }).catch(() => {});
83
+
84
+ }
85
+ };
86
+
87
+ export const removeTransfer = (magnet) => {
88
+ const active = transferStore.get('active');
89
+ const index = active.findIndex(t => t.magnet === magnet);
90
+ if (index !== -1) {
91
+ active.splice(index, 1);
92
+ transferStore.set('active', active);
93
+ }
94
+ };
95
+
96
+ export const cleanStaleTransfers = (maxAgeHours = 24) => {
97
+ const active = transferStore.get('active');
98
+ const cutoff = Date.now() - (maxAgeHours * 60 * 60 * 1000);
99
+ const stale = active.filter(t => t.progress === 0 && t.status === 'downloading' && new Date(t.addedAt).getTime() < cutoff);
100
+ if (stale.length === 0) return 0;
101
+ const remaining = active.filter(t => !stale.includes(t));
102
+ const cleaned = stale.map(t => ({ ...t, status: 'failed', completedAt: new Date().toISOString(), error: 'Stale — no progress' }));
103
+ const history = transferStore.get('history');
104
+ history.push(...cleaned);
105
+ transferStore.set('active', remaining);
106
+ transferStore.set('history', history);
107
+ return stale.length;
108
+ };
109
+
110
+ // ── Batch operations ──
111
+
112
+ const MAX_CONCURRENT_DOWNLOADS = 3;
113
+
114
+ export const createBatch = (transfers) => {
115
+ const id = crypto.randomUUID();
116
+ const batch = {
117
+ id,
118
+ transfers: transfers.map(t => ({ magnet: t.magnet, name: t.name, savePath: t.savePath })),
119
+ status: 'active',
120
+ createdAt: new Date().toISOString()
121
+ };
122
+ const batches = transferStore.get('batches');
123
+ batches.push(batch);
124
+ transferStore.set('batches', batches);
125
+
126
+ for (const t of batch.transfers) {
127
+ trackTransfer(t.magnet, t.name, t.savePath);
128
+ }
129
+
130
+ return id;
131
+ };
132
+
133
+ export const getBatches = () => transferStore.get('batches');
134
+
135
+ export const pauseBatch = (id) => {
136
+ const batches = transferStore.get('batches');
137
+ const batch = batches.find(b => b.id === id);
138
+ if (!batch) return false;
139
+
140
+ batch.status = 'paused';
141
+ transferStore.set('batches', batches);
142
+
143
+ for (const t of batch.transfers) {
144
+ setTransferPaused(t.magnet, true);
145
+ }
146
+ return true;
147
+ };
148
+
149
+ export const resumeBatch = (id) => {
150
+ const batches = transferStore.get('batches');
151
+ const batch = batches.find(b => b.id === id);
152
+ if (!batch) return false;
153
+
154
+ batch.status = 'active';
155
+ transferStore.set('batches', batches);
156
+
157
+ for (const t of batch.transfers) {
158
+ setTransferPaused(t.magnet, false);
159
+ }
160
+ return true;
161
+ };
162
+
163
+ export const cancelBatch = (id) => {
164
+ const batches = transferStore.get('batches');
165
+ const index = batches.findIndex(b => b.id === id);
166
+ if (index === -1) return false;
167
+
168
+ const [batch] = batches.splice(index, 1);
169
+ transferStore.set('batches', batches);
170
+
171
+ for (const t of batch.transfers) {
172
+ removeTransfer(t.magnet);
173
+ }
174
+ return true;
175
+ };
176
+
177
+ export const getBatchProgress = (id) => {
178
+ const batches = transferStore.get('batches');
179
+ const batch = batches.find(b => b.id === id);
180
+ if (!batch) return null;
181
+
182
+ const active = transferStore.get('active');
183
+ const history = transferStore.get('history');
184
+ let totalProgress = 0;
185
+ const count = batch.transfers.length;
186
+
187
+ for (const t of batch.transfers) {
188
+ const activeT = active.find(a => a.magnet === t.magnet);
189
+ if (activeT) {
190
+ totalProgress += activeT.progress;
191
+ } else if (history.find(h => h.magnet === t.magnet)) {
192
+ totalProgress += 1;
193
+ }
194
+ }
195
+
196
+ return {
197
+ id,
198
+ status: batch.status,
199
+ total: count,
200
+ progress: count > 0 ? totalProgress / count : 0
201
+ };
202
+ };
203
+
204
+ export const getMaxConcurrentDownloads = () => MAX_CONCURRENT_DOWNLOADS;
205
+
206
+ // ── Transfer analytics ──
207
+
208
+ export const updateTransferStats = (magnet, stats) => {
209
+ const active = transferStore.get('active');
210
+ const index = active.findIndex(t => t.magnet === magnet);
211
+ if (index !== -1) {
212
+ if (stats.bytesTransferred !== undefined) active[index].bytesTransferred = stats.bytesTransferred;
213
+ if (stats.speed !== undefined) active[index].speed = stats.speed;
214
+ if (!active[index].startedAt) active[index].startedAt = new Date().toISOString();
215
+ transferStore.set('active', active);
216
+ }
217
+ };
218
+
219
+ export const pruneOldHistory = (maxAgeDays = 90) => {
220
+ const history = transferStore.get('history');
221
+ const cutoff = Date.now() - maxAgeDays * 86400000;
222
+ const pruned = history.filter(t => {
223
+ const ts = t.completedAt ? new Date(t.completedAt).getTime() : 0;
224
+ return ts > cutoff;
225
+ });
226
+ if (pruned.length < history.length) {
227
+ transferStore.set('history', pruned);
228
+ }
229
+ return history.length - pruned.length;
230
+ };
231
+
232
+ export const getTransferStats = (days = 90) => {
233
+ const history = transferStore.get('history');
234
+ const cutoff = Date.now() - days * 86400000;
235
+ const recent = history.filter(t => {
236
+ const ts = t.completedAt ? new Date(t.completedAt).getTime() : 0;
237
+ return ts > cutoff;
238
+ });
239
+
240
+ let totalBytes = 0;
241
+ let totalDuration = 0;
242
+ const perDay = {};
243
+
244
+ for (const t of recent) {
245
+ totalBytes += t.bytesTransferred || 0;
246
+ if (t.startedAt && t.completedAt) {
247
+ totalDuration += new Date(t.completedAt).getTime() - new Date(t.startedAt).getTime();
248
+ }
249
+ const day = t.completedAt ? t.completedAt.slice(0, 10) : 'unknown';
250
+ perDay[day] = (perDay[day] || 0) + 1;
251
+ }
252
+
253
+ return {
254
+ totalTransfers: recent.length,
255
+ totalBytes,
256
+ avgSpeed: totalDuration > 0 ? totalBytes / (totalDuration / 1000) : 0,
257
+ perDay,
258
+ };
259
+ };