@zergai/zergbox-client 0.1.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.
@@ -0,0 +1,1311 @@
1
+ // ../desktop/src/sync-planner.mjs
2
+ var DEFAULT_IGNORE_PATTERNS = [
3
+ ".zergbox-desktop",
4
+ ".zergbox-desktop/**",
5
+ "*.zergbox-download",
6
+ ".DS_Store",
7
+ "Thumbs.db",
8
+ "desktop.ini"
9
+ ];
10
+ var ACTION_PRIORITY = {
11
+ create_remote_folder: 10,
12
+ move_remote_folder: 15,
13
+ upload_file: 20,
14
+ move_remote_file: 25,
15
+ create_local_folder: 30,
16
+ move_local_folder: 35,
17
+ download_file: 40,
18
+ move_local_file: 45,
19
+ conflict: 50,
20
+ delete_remote: 60,
21
+ delete_remote_folder: 61,
22
+ delete_local: 70,
23
+ delete_local_folder: 71
24
+ };
25
+ var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([
26
+ "CON",
27
+ "PRN",
28
+ "AUX",
29
+ "NUL",
30
+ "COM1",
31
+ "COM2",
32
+ "COM3",
33
+ "COM4",
34
+ "COM5",
35
+ "COM6",
36
+ "COM7",
37
+ "COM8",
38
+ "COM9",
39
+ "LPT1",
40
+ "LPT2",
41
+ "LPT3",
42
+ "LPT4",
43
+ "LPT5",
44
+ "LPT6",
45
+ "LPT7",
46
+ "LPT8",
47
+ "LPT9"
48
+ ]);
49
+ function normalizeSyncPath(input) {
50
+ return String(input || "").replaceAll("\\", "/").split("/").filter((part) => part && part !== ".").join("/");
51
+ }
52
+ function isPortableSyncName(value) {
53
+ if (typeof value !== "string" || value.length < 1 || value.length > 255) {
54
+ return false;
55
+ }
56
+ if (value !== value.trim()) {
57
+ return false;
58
+ }
59
+ if (value === "." || value === "..") {
60
+ return false;
61
+ }
62
+ if (/[\u0000-\u001F\u007F]/.test(value)) {
63
+ return false;
64
+ }
65
+ if (/[<>:"\\|?*]/.test(value)) {
66
+ return false;
67
+ }
68
+ if (/[. ]$/.test(value)) {
69
+ return false;
70
+ }
71
+ return !WINDOWS_RESERVED_NAMES.has(value.split(".")[0].toUpperCase());
72
+ }
73
+ function hasSafePortableSegments(normalizedPath) {
74
+ return normalizedPath.split("/").every(isPortableSyncName);
75
+ }
76
+ function isSafeSyncPath(input) {
77
+ const rawPath = String(input || "");
78
+ const traversalPath = rawPath.replaceAll("\\", "/");
79
+ const normalizedPath = normalizeSyncPath(rawPath);
80
+ return Boolean(normalizedPath) && !rawPath.includes("\\") && !traversalPath.startsWith("/") && !traversalPath.split("/").some((part) => part === "..") && hasSafePortableSegments(normalizedPath);
81
+ }
82
+ function isSyncPathIgnored(path2, ignorePatterns = DEFAULT_IGNORE_PATTERNS) {
83
+ return ignorePatterns.some((pattern) => {
84
+ if (pattern.endsWith("/**")) {
85
+ const prefix = pattern.slice(0, -3);
86
+ return path2 === prefix || path2.startsWith(`${prefix}/`);
87
+ }
88
+ if (pattern.startsWith("*")) {
89
+ return path2.endsWith(pattern.slice(1));
90
+ }
91
+ return path2 === pattern || path2.endsWith(`/${pattern}`);
92
+ });
93
+ }
94
+ function caseFoldSyncPath(syncPath) {
95
+ return normalizeSyncPath(syncPath).toLowerCase();
96
+ }
97
+ function collectCaseCollisionData(entries, ignorePatterns, includeRemoteIds = false) {
98
+ const groups = /* @__PURE__ */ new Map();
99
+ for (const entry of entries || []) {
100
+ const rawPath = String(entry.path || "");
101
+ const normalizedPath = normalizeSyncPath(rawPath);
102
+ if (!normalizedPath || !isSafeSyncPath(rawPath) || isSyncPathIgnored(normalizedPath, ignorePatterns)) {
103
+ continue;
104
+ }
105
+ const caseKey = caseFoldSyncPath(normalizedPath);
106
+ const group = groups.get(caseKey) || [];
107
+ group.push({ entry, path: normalizedPath });
108
+ groups.set(caseKey, group);
109
+ }
110
+ const keys = /* @__PURE__ */ new Set();
111
+ const actions = [];
112
+ for (const [caseKey, group] of groups) {
113
+ if (group.length < 2) {
114
+ continue;
115
+ }
116
+ keys.add(caseKey);
117
+ for (const item of group) {
118
+ const action = {
119
+ type: "conflict",
120
+ path: item.path,
121
+ reason: "case_collision"
122
+ };
123
+ if (includeRemoteIds && item.entry.id) {
124
+ action.remoteId = item.entry.id;
125
+ }
126
+ actions.push(action);
127
+ }
128
+ }
129
+ return { keys, actions };
130
+ }
131
+ function groupEntriesByCaseKey(entries, ignorePatterns) {
132
+ const groups = /* @__PURE__ */ new Map();
133
+ for (const entry of entries || []) {
134
+ const rawPath = String(entry.path || "");
135
+ const normalizedPath = normalizeSyncPath(rawPath);
136
+ if (!normalizedPath || !isSafeSyncPath(rawPath) || isSyncPathIgnored(normalizedPath, ignorePatterns)) {
137
+ continue;
138
+ }
139
+ const caseKey = caseFoldSyncPath(normalizedPath);
140
+ const group = groups.get(caseKey) || [];
141
+ group.push({ entry, path: normalizedPath });
142
+ groups.set(caseKey, group);
143
+ }
144
+ return groups;
145
+ }
146
+ function collectCrossCaseCollisionData(localEntries, remoteEntries, ignorePatterns, excludedCaseKeys = /* @__PURE__ */ new Set()) {
147
+ const localGroups = groupEntriesByCaseKey(localEntries, ignorePatterns);
148
+ const remoteGroups = groupEntriesByCaseKey(remoteEntries, ignorePatterns);
149
+ const keys = /* @__PURE__ */ new Set();
150
+ const actions = [];
151
+ for (const [caseKey, localGroup] of localGroups) {
152
+ if (excludedCaseKeys.has(caseKey) || !remoteGroups.has(caseKey)) {
153
+ continue;
154
+ }
155
+ const remoteGroup = remoteGroups.get(caseKey);
156
+ const hasCaseMismatch = localGroup.some((localItem) => {
157
+ return remoteGroup.some((remoteItem) => localItem.path !== remoteItem.path);
158
+ });
159
+ if (!hasCaseMismatch) {
160
+ continue;
161
+ }
162
+ keys.add(caseKey);
163
+ for (const item of localGroup) {
164
+ actions.push({
165
+ type: "conflict",
166
+ path: item.path,
167
+ reason: "case_collision"
168
+ });
169
+ }
170
+ for (const item of remoteGroup) {
171
+ const action = {
172
+ type: "conflict",
173
+ path: item.path,
174
+ reason: "case_collision"
175
+ };
176
+ if (item.entry.id) {
177
+ action.remoteId = item.entry.id;
178
+ }
179
+ actions.push(action);
180
+ }
181
+ }
182
+ return { keys, actions };
183
+ }
184
+ function expandDescendantCaseKeys(excludedCaseKeys, entries, ignorePatterns) {
185
+ const parentCaseKeys = Array.from(excludedCaseKeys);
186
+ if (parentCaseKeys.length === 0) {
187
+ return new Set(excludedCaseKeys);
188
+ }
189
+ const expanded = new Set(excludedCaseKeys);
190
+ for (const entry of entries || []) {
191
+ const rawPath = String(entry.path || entry || "");
192
+ const normalizedPath = normalizeSyncPath(rawPath);
193
+ if (!normalizedPath || !isSafeSyncPath(rawPath) || isSyncPathIgnored(normalizedPath, ignorePatterns)) {
194
+ continue;
195
+ }
196
+ const caseKey = caseFoldSyncPath(normalizedPath);
197
+ if (parentCaseKeys.some((parentCaseKey) => caseKey.startsWith(`${parentCaseKey}/`))) {
198
+ expanded.add(caseKey);
199
+ }
200
+ }
201
+ return expanded;
202
+ }
203
+ function indexEntries(entries, ignorePatterns, excludedCaseKeys = /* @__PURE__ */ new Set()) {
204
+ const map = /* @__PURE__ */ new Map();
205
+ for (const entry of entries || []) {
206
+ const rawPath = String(entry.path || "");
207
+ const normalizedPath = normalizeSyncPath(rawPath);
208
+ if (!normalizedPath || !isSafeSyncPath(rawPath) || isSyncPathIgnored(normalizedPath, ignorePatterns) || excludedCaseKeys.has(caseFoldSyncPath(normalizedPath))) {
209
+ continue;
210
+ }
211
+ map.set(normalizedPath, {
212
+ ...entry,
213
+ path: normalizedPath,
214
+ type: entry.type === "directory" ? "directory" : "file"
215
+ });
216
+ }
217
+ return map;
218
+ }
219
+ function collectUnsupportedLocalActions(entries, ignorePatterns) {
220
+ const unsupported = [];
221
+ const unsupportedPaths = [];
222
+ for (const entry of entries || []) {
223
+ const rawPath = String(entry.path || "");
224
+ const normalizedPath = normalizeSyncPath(rawPath);
225
+ if (!normalizedPath || isSyncPathIgnored(normalizedPath, ignorePatterns)) {
226
+ continue;
227
+ }
228
+ if (isSafeSyncPath(rawPath)) {
229
+ continue;
230
+ }
231
+ if (unsupportedPaths.some((parentPath) => normalizedPath.startsWith(`${parentPath}/`))) {
232
+ continue;
233
+ }
234
+ unsupported.push({
235
+ type: "conflict",
236
+ path: rawPath,
237
+ reason: "unsupported_local_name"
238
+ });
239
+ if (entry.type === "directory") {
240
+ unsupportedPaths.push(normalizedPath);
241
+ }
242
+ }
243
+ return unsupported;
244
+ }
245
+ function collectUnsupportedRemoteActions(entries, ignorePatterns) {
246
+ const unsupported = [];
247
+ const unsupportedPaths = [];
248
+ for (const entry of entries || []) {
249
+ const rawPath = String(entry.path || "");
250
+ const normalizedPath = normalizeSyncPath(rawPath);
251
+ if (!normalizedPath || isSyncPathIgnored(normalizedPath, ignorePatterns)) {
252
+ continue;
253
+ }
254
+ if (isSafeSyncPath(rawPath)) {
255
+ continue;
256
+ }
257
+ if (unsupportedPaths.some((parentPath) => normalizedPath.startsWith(`${parentPath}/`))) {
258
+ continue;
259
+ }
260
+ unsupported.push(addRemoteId({
261
+ type: "conflict",
262
+ path: rawPath,
263
+ reason: "unsupported_remote_name"
264
+ }, entry, null));
265
+ if (entry.type === "directory") {
266
+ unsupportedPaths.push(normalizedPath);
267
+ }
268
+ }
269
+ return unsupported;
270
+ }
271
+ function getRemoteId(remoteEntry, stateEntry) {
272
+ return remoteEntry?.id || stateEntry?.remoteId;
273
+ }
274
+ function addRemoteId(action, remoteEntry, stateEntry) {
275
+ const remoteId = getRemoteId(remoteEntry, stateEntry);
276
+ return remoteId ? { ...action, remoteId } : action;
277
+ }
278
+ function hasLocalChanged(localEntry, stateEntry) {
279
+ if (!stateEntry) {
280
+ return false;
281
+ }
282
+ return localEntry?.contentHash !== stateEntry.localHash;
283
+ }
284
+ function hasRemoteChanged(remoteEntry, stateEntry) {
285
+ if (!stateEntry) {
286
+ return false;
287
+ }
288
+ return remoteEntry?.contentHash !== stateEntry.remoteHash;
289
+ }
290
+ function planBothPresent(path2, localEntry, remoteEntry, stateEntry) {
291
+ if (localEntry.type !== remoteEntry.type) {
292
+ return addRemoteId({ type: "conflict", path: path2, reason: "type_mismatch" }, remoteEntry, stateEntry);
293
+ }
294
+ if (localEntry.type === "directory") {
295
+ return null;
296
+ }
297
+ if (!stateEntry) {
298
+ if (localEntry.contentHash === remoteEntry.contentHash) {
299
+ return null;
300
+ }
301
+ return addRemoteId({ type: "conflict", path: path2, reason: "untracked_divergence" }, remoteEntry, stateEntry);
302
+ }
303
+ const localChanged = hasLocalChanged(localEntry, stateEntry);
304
+ const remoteChanged = hasRemoteChanged(remoteEntry, stateEntry);
305
+ if (localChanged && remoteChanged) {
306
+ if (localEntry.contentHash === remoteEntry.contentHash) {
307
+ return null;
308
+ }
309
+ return addRemoteId({ type: "conflict", path: path2, reason: "both_changed" }, remoteEntry, stateEntry);
310
+ }
311
+ if (localChanged) {
312
+ return addRemoteId({ type: "upload_file", path: path2, reason: "local_changed" }, remoteEntry, stateEntry);
313
+ }
314
+ if (remoteChanged) {
315
+ return addRemoteId({ type: "download_file", path: path2, reason: "remote_changed" }, remoteEntry, stateEntry);
316
+ }
317
+ return null;
318
+ }
319
+ function planLocalOnly(path2, localEntry, stateEntry) {
320
+ if (!stateEntry) {
321
+ return {
322
+ type: localEntry.type === "directory" ? "create_remote_folder" : "upload_file",
323
+ path: path2,
324
+ reason: "local_created"
325
+ };
326
+ }
327
+ if (localEntry.type === "directory") {
328
+ return addRemoteId({ type: "delete_local_folder", path: path2, reason: "remote_deleted" }, null, stateEntry);
329
+ }
330
+ const localChanged = hasLocalChanged(localEntry, stateEntry);
331
+ if (localChanged) {
332
+ return addRemoteId({ type: "conflict", path: path2, reason: "remote_deleted_local_changed" }, null, stateEntry);
333
+ }
334
+ return addRemoteId({ type: "delete_local", path: path2, reason: "remote_deleted" }, null, stateEntry);
335
+ }
336
+ function planRemoteOnly(path2, remoteEntry, stateEntry) {
337
+ if (!stateEntry) {
338
+ return addRemoteId(
339
+ {
340
+ type: remoteEntry.type === "directory" ? "create_local_folder" : "download_file",
341
+ path: path2,
342
+ reason: "remote_created"
343
+ },
344
+ remoteEntry,
345
+ stateEntry
346
+ );
347
+ }
348
+ if (remoteEntry.type === "directory") {
349
+ return addRemoteId({ type: "delete_remote_folder", path: path2, reason: "local_deleted" }, remoteEntry, stateEntry);
350
+ }
351
+ const remoteChanged = hasRemoteChanged(remoteEntry, stateEntry);
352
+ if (remoteChanged) {
353
+ return addRemoteId({ type: "conflict", path: path2, reason: "local_deleted_remote_changed" }, remoteEntry, stateEntry);
354
+ }
355
+ return addRemoteId({ type: "delete_remote", path: path2, reason: "local_deleted" }, remoteEntry, stateEntry);
356
+ }
357
+ function isFileStateEntry(stateEntry) {
358
+ return stateEntry?.type === "file" || stateEntry?.localHash && stateEntry?.remoteHash;
359
+ }
360
+ function isDirectoryStateEntry(stateEntry) {
361
+ return stateEntry?.type === "directory";
362
+ }
363
+ function stableRemoteContentFingerprint(contentHash) {
364
+ const parts = String(contentHash || "").split(":");
365
+ if (parts.length < 3) {
366
+ return null;
367
+ }
368
+ return parts.slice(0, 2).join(":");
369
+ }
370
+ function hasSameRemoteFileContent(remoteEntry, stateEntry) {
371
+ if (!remoteEntry?.contentHash || !stateEntry?.remoteHash) {
372
+ return false;
373
+ }
374
+ if (remoteEntry.contentHash === stateEntry.remoteHash) {
375
+ return true;
376
+ }
377
+ const currentStable = stableRemoteContentFingerprint(remoteEntry.contentHash);
378
+ const previousStable = stableRemoteContentFingerprint(stateEntry.remoteHash);
379
+ return Boolean(currentStable && previousStable && currentStable === previousStable);
380
+ }
381
+ function collectLocalCreatedFileCandidates(localByPath, remoteByPath, stateEntries) {
382
+ const candidatesByHash = /* @__PURE__ */ new Map();
383
+ for (const [path2, entry] of localByPath) {
384
+ if (entry.type !== "file" || !entry.contentHash || remoteByPath.has(path2) || stateEntries.has(path2)) {
385
+ continue;
386
+ }
387
+ const candidates = candidatesByHash.get(entry.contentHash) || [];
388
+ candidates.push({ path: path2, entry });
389
+ candidatesByHash.set(entry.contentHash, candidates);
390
+ }
391
+ return candidatesByHash;
392
+ }
393
+ function collectRemoteCreatedFileCandidates(remoteByPath, localByPath, stateEntries) {
394
+ const candidatesByRemoteId = /* @__PURE__ */ new Map();
395
+ for (const [path2, entry] of remoteByPath) {
396
+ if (entry.type !== "file" || !entry.id || localByPath.has(path2) || stateEntries.has(path2)) {
397
+ continue;
398
+ }
399
+ const candidates = candidatesByRemoteId.get(entry.id) || [];
400
+ candidates.push({ path: path2, entry });
401
+ candidatesByRemoteId.set(entry.id, candidates);
402
+ }
403
+ return candidatesByRemoteId;
404
+ }
405
+ function collectLocalCreatedFolderCandidates(localByPath, remoteByPath, stateEntries) {
406
+ const candidates = [];
407
+ for (const [path2, entry] of localByPath) {
408
+ if (entry.type === "directory" && !remoteByPath.has(path2) && !stateEntries.has(path2)) {
409
+ candidates.push({ path: path2, entry });
410
+ }
411
+ }
412
+ return candidates;
413
+ }
414
+ function collectRemoteCreatedFolderCandidates(remoteByPath, localByPath, stateEntries) {
415
+ const candidatesByRemoteId = /* @__PURE__ */ new Map();
416
+ for (const [path2, entry] of remoteByPath) {
417
+ if (entry.type !== "directory" || !entry.id || localByPath.has(path2) || stateEntries.has(path2)) {
418
+ continue;
419
+ }
420
+ const candidates = candidatesByRemoteId.get(entry.id) || [];
421
+ candidates.push({ path: path2, entry });
422
+ candidatesByRemoteId.set(entry.id, candidates);
423
+ }
424
+ return candidatesByRemoteId;
425
+ }
426
+ function pathIsWithin(syncPath, parentPath) {
427
+ return syncPath === parentPath || syncPath.startsWith(`${parentPath}/`);
428
+ }
429
+ function replacePathPrefix(syncPath, fromPath, toPath) {
430
+ if (syncPath === fromPath) {
431
+ return toPath;
432
+ }
433
+ return `${toPath}/${syncPath.slice(fromPath.length + 1)}`;
434
+ }
435
+ function localSubtreeMatchesState(fromPath, toPath, stateEntries, localByPath) {
436
+ let matched = false;
437
+ for (const [statePath, stateEntry] of stateEntries) {
438
+ if (!pathIsWithin(statePath, fromPath)) {
439
+ continue;
440
+ }
441
+ matched = true;
442
+ const localEntry = localByPath.get(replacePathPrefix(statePath, fromPath, toPath));
443
+ if (!localEntry || localEntry.type !== (stateEntry.type === "directory" ? "directory" : "file")) {
444
+ return false;
445
+ }
446
+ if (localEntry.type === "file" && localEntry.contentHash !== stateEntry.localHash) {
447
+ return false;
448
+ }
449
+ }
450
+ return matched;
451
+ }
452
+ function remoteSubtreeMatchesState(fromPath, toPath, stateEntries, remoteByPath) {
453
+ let matched = false;
454
+ for (const [statePath, stateEntry] of stateEntries) {
455
+ if (!pathIsWithin(statePath, fromPath)) {
456
+ continue;
457
+ }
458
+ matched = true;
459
+ const remoteEntry = remoteByPath.get(replacePathPrefix(statePath, fromPath, toPath));
460
+ if (!remoteEntry || remoteEntry.type !== (stateEntry.type === "directory" ? "directory" : "file")) {
461
+ return false;
462
+ }
463
+ if (stateEntry.remoteId && remoteEntry.id !== stateEntry.remoteId) {
464
+ return false;
465
+ }
466
+ if (remoteEntry.type === "file" && !hasSameRemoteFileContent(remoteEntry, stateEntry)) {
467
+ return false;
468
+ }
469
+ }
470
+ return matched;
471
+ }
472
+ function collectFolderMoveActions(localByPath, remoteByPath, stateEntries) {
473
+ const localCreatedFolders = collectLocalCreatedFolderCandidates(localByPath, remoteByPath, stateEntries);
474
+ const remoteCreatedFoldersById = collectRemoteCreatedFolderCandidates(remoteByPath, localByPath, stateEntries);
475
+ const localMoveCandidates = [];
476
+ const remoteMoveCandidates = [];
477
+ for (const [fromPath, stateEntry] of stateEntries) {
478
+ if (!isDirectoryStateEntry(stateEntry) || !stateEntry.remoteId) {
479
+ continue;
480
+ }
481
+ const localEntry = localByPath.get(fromPath);
482
+ const remoteEntry = remoteByPath.get(fromPath);
483
+ if (!localEntry && remoteEntry?.type === "directory" && remoteEntry.id === stateEntry.remoteId && remoteSubtreeMatchesState(fromPath, fromPath, stateEntries, remoteByPath)) {
484
+ const candidates = localCreatedFolders.filter((candidate) => {
485
+ return localSubtreeMatchesState(fromPath, candidate.path, stateEntries, localByPath);
486
+ });
487
+ if (candidates.length === 1) {
488
+ localMoveCandidates.push({
489
+ fromPath,
490
+ path: candidates[0].path,
491
+ remoteEntry
492
+ });
493
+ }
494
+ }
495
+ if (localEntry?.type === "directory" && !remoteEntry && localSubtreeMatchesState(fromPath, fromPath, stateEntries, localByPath)) {
496
+ const candidates = (remoteCreatedFoldersById.get(stateEntry.remoteId) || []).filter((candidate) => {
497
+ return remoteSubtreeMatchesState(fromPath, candidate.path, stateEntries, remoteByPath);
498
+ });
499
+ if (candidates.length === 1) {
500
+ remoteMoveCandidates.push({
501
+ fromPath,
502
+ path: candidates[0].path,
503
+ remoteEntry: candidates[0].entry
504
+ });
505
+ }
506
+ }
507
+ }
508
+ return [
509
+ ...compactFolderMoveCandidates(resolveUniqueMoveCandidates(localMoveCandidates)).map((candidate) => ({
510
+ type: "move_remote_folder",
511
+ fromPath: candidate.fromPath,
512
+ path: candidate.path,
513
+ reason: "local_moved",
514
+ remoteId: candidate.remoteEntry.id
515
+ })),
516
+ ...compactFolderMoveCandidates(resolveUniqueMoveCandidates(remoteMoveCandidates)).map((candidate) => ({
517
+ type: "move_local_folder",
518
+ fromPath: candidate.fromPath,
519
+ path: candidate.path,
520
+ reason: "remote_moved",
521
+ remoteId: candidate.remoteEntry.id
522
+ }))
523
+ ];
524
+ }
525
+ function compactFolderMoveCandidates(candidates) {
526
+ return candidates.filter((candidate) => {
527
+ return !candidates.some((parentCandidate) => {
528
+ return parentCandidate !== candidate && pathIsWithin(candidate.fromPath, parentCandidate.fromPath) && pathIsWithin(candidate.path, parentCandidate.path);
529
+ });
530
+ });
531
+ }
532
+ function collectMovedStatePaths(moveActions, stateEntries) {
533
+ const movedPaths = /* @__PURE__ */ new Set();
534
+ for (const action of moveActions) {
535
+ movedPaths.add(action.fromPath);
536
+ movedPaths.add(action.path);
537
+ for (const statePath of stateEntries.keys()) {
538
+ if (!pathIsWithin(statePath, action.fromPath)) {
539
+ continue;
540
+ }
541
+ movedPaths.add(statePath);
542
+ movedPaths.add(replacePathPrefix(statePath, action.fromPath, action.path));
543
+ }
544
+ }
545
+ return movedPaths;
546
+ }
547
+ function collectFileMoveActions(localByPath, remoteByPath, stateEntries, excludedPaths = /* @__PURE__ */ new Set()) {
548
+ const localCreatedByHash = collectLocalCreatedFileCandidates(localByPath, remoteByPath, stateEntries);
549
+ const remoteCreatedById = collectRemoteCreatedFileCandidates(remoteByPath, localByPath, stateEntries);
550
+ const localMoveCandidates = [];
551
+ const remoteMoveCandidates = [];
552
+ for (const [fromPath, stateEntry] of stateEntries) {
553
+ if (!isFileStateEntry(stateEntry) || excludedPaths.has(fromPath)) {
554
+ continue;
555
+ }
556
+ const localEntry = localByPath.get(fromPath);
557
+ const remoteEntry = remoteByPath.get(fromPath);
558
+ if (!localEntry && remoteEntry?.type === "file" && hasSameRemoteFileContent(remoteEntry, stateEntry)) {
559
+ const candidates = localCreatedByHash.get(stateEntry.localHash) || [];
560
+ const availableCandidates = candidates.filter((candidate) => !excludedPaths.has(candidate.path));
561
+ if (availableCandidates.length === 1) {
562
+ localMoveCandidates.push({
563
+ fromPath,
564
+ path: availableCandidates[0].path,
565
+ remoteEntry
566
+ });
567
+ }
568
+ }
569
+ if (localEntry?.type === "file" && localEntry.contentHash === stateEntry.localHash && !remoteEntry && stateEntry.remoteId) {
570
+ const candidates = remoteCreatedById.get(stateEntry.remoteId) || [];
571
+ const availableCandidates = candidates.filter((candidate) => !excludedPaths.has(candidate.path));
572
+ if (availableCandidates.length === 1 && hasSameRemoteFileContent(availableCandidates[0].entry, stateEntry)) {
573
+ remoteMoveCandidates.push({
574
+ fromPath,
575
+ path: availableCandidates[0].path,
576
+ remoteEntry: availableCandidates[0].entry
577
+ });
578
+ }
579
+ }
580
+ }
581
+ return [
582
+ ...resolveUniqueMoveCandidates(localMoveCandidates).map((candidate) => ({
583
+ type: "move_remote_file",
584
+ fromPath: candidate.fromPath,
585
+ path: candidate.path,
586
+ reason: "local_moved",
587
+ remoteId: candidate.remoteEntry.id
588
+ })),
589
+ ...resolveUniqueMoveCandidates(remoteMoveCandidates).map((candidate) => ({
590
+ type: "move_local_file",
591
+ fromPath: candidate.fromPath,
592
+ path: candidate.path,
593
+ reason: "remote_moved",
594
+ remoteId: candidate.remoteEntry.id
595
+ }))
596
+ ];
597
+ }
598
+ function resolveUniqueMoveCandidates(candidates) {
599
+ const fromPathCounts = countBy(candidates, (candidate) => candidate.fromPath);
600
+ const pathCounts = countBy(candidates, (candidate) => candidate.path);
601
+ return candidates.filter((candidate) => {
602
+ return fromPathCounts.get(candidate.fromPath) === 1 && pathCounts.get(candidate.path) === 1;
603
+ });
604
+ }
605
+ function countBy(items, getKey) {
606
+ const counts = /* @__PURE__ */ new Map();
607
+ for (const item of items) {
608
+ const key = getKey(item);
609
+ counts.set(key, (counts.get(key) || 0) + 1);
610
+ }
611
+ return counts;
612
+ }
613
+ function isDescendantPath(path2, parentPath) {
614
+ return path2.startsWith(`${parentPath}/`);
615
+ }
616
+ function isDeleteActionCoveredByFolderDelete(action, folderDeleteAction) {
617
+ if (!isDescendantPath(action.path, folderDeleteAction.path)) {
618
+ return false;
619
+ }
620
+ if (folderDeleteAction.type === "delete_remote_folder") {
621
+ return action.type === "delete_remote" || action.type === "delete_remote_folder";
622
+ }
623
+ if (folderDeleteAction.type === "delete_local_folder") {
624
+ return action.type === "delete_local" || action.type === "delete_local_folder";
625
+ }
626
+ return false;
627
+ }
628
+ function compactRecursiveDeleteActions(actions) {
629
+ const folderDeletes = actions.filter((action) => {
630
+ return action.type === "delete_remote_folder" || action.type === "delete_local_folder";
631
+ });
632
+ const blockedFolderDeletes = new Set(
633
+ folderDeletes.filter((folderDelete) => {
634
+ return actions.some((action) => {
635
+ return action.type === "conflict" && isDescendantPath(action.path, folderDelete.path);
636
+ });
637
+ }).map((action) => `${action.type}:${action.path}`)
638
+ );
639
+ const activeFolderDeletes = folderDeletes.filter((action) => {
640
+ return !blockedFolderDeletes.has(`${action.type}:${action.path}`);
641
+ });
642
+ return actions.filter((action) => {
643
+ if (blockedFolderDeletes.has(`${action.type}:${action.path}`)) {
644
+ return false;
645
+ }
646
+ return !activeFolderDeletes.some((folderDelete) => {
647
+ return folderDelete !== action && isDeleteActionCoveredByFolderDelete(action, folderDelete);
648
+ });
649
+ });
650
+ }
651
+ function convertCreatedDescendantsUnderDeletedFolders(actions) {
652
+ const folderDeletes = actions.filter((action) => {
653
+ return action.type === "delete_remote_folder" || action.type === "delete_local_folder";
654
+ });
655
+ return actions.map((action) => {
656
+ if (action.reason !== "local_created" && action.reason !== "remote_created") {
657
+ return action;
658
+ }
659
+ const deletedParent = folderDeletes.find((folderDelete) => {
660
+ return isDescendantPath(action.path, folderDelete.path);
661
+ });
662
+ if (!deletedParent) {
663
+ return action;
664
+ }
665
+ if (deletedParent.type === "delete_local_folder" && action.reason === "local_created") {
666
+ return {
667
+ type: "conflict",
668
+ path: action.path,
669
+ reason: "remote_deleted_local_created"
670
+ };
671
+ }
672
+ if (deletedParent.type === "delete_remote_folder" && action.reason === "remote_created") {
673
+ return addRemoteId({
674
+ type: "conflict",
675
+ path: action.path,
676
+ reason: "local_deleted_remote_created"
677
+ }, null, action);
678
+ }
679
+ return action;
680
+ });
681
+ }
682
+ function planSync({ localEntries = [], remoteEntries = [], state = {}, ignorePatterns = DEFAULT_IGNORE_PATTERNS } = {}) {
683
+ const localCaseCollisions = collectCaseCollisionData(localEntries, ignorePatterns);
684
+ const remoteCaseCollisions = collectCaseCollisionData(remoteEntries, ignorePatterns, true);
685
+ const oneSidedCollisionKeys = /* @__PURE__ */ new Set([
686
+ ...localCaseCollisions.keys,
687
+ ...remoteCaseCollisions.keys
688
+ ]);
689
+ const crossCaseCollisions = collectCrossCaseCollisionData(
690
+ localEntries,
691
+ remoteEntries,
692
+ ignorePatterns,
693
+ oneSidedCollisionKeys
694
+ );
695
+ const excludedCaseKeys = /* @__PURE__ */ new Set([
696
+ ...oneSidedCollisionKeys,
697
+ ...crossCaseCollisions.keys
698
+ ]);
699
+ const statePaths = Object.keys(state.entries || {});
700
+ const expandedExcludedCaseKeys = expandDescendantCaseKeys(
701
+ expandDescendantCaseKeys(
702
+ expandDescendantCaseKeys(excludedCaseKeys, localEntries, ignorePatterns),
703
+ remoteEntries,
704
+ ignorePatterns
705
+ ),
706
+ statePaths,
707
+ ignorePatterns
708
+ );
709
+ const localByPath = indexEntries(localEntries, ignorePatterns, expandedExcludedCaseKeys);
710
+ const remoteByPath = indexEntries(remoteEntries, ignorePatterns, expandedExcludedCaseKeys);
711
+ const stateEntries = new Map(
712
+ Object.entries(state.entries || {}).flatMap(([path2, entry]) => {
713
+ const normalizedPath = normalizeSyncPath(path2);
714
+ if (!normalizedPath || !isSafeSyncPath(path2) || isSyncPathIgnored(normalizedPath, ignorePatterns) || expandedExcludedCaseKeys.has(caseFoldSyncPath(normalizedPath))) {
715
+ return [];
716
+ }
717
+ return [[normalizedPath, entry]];
718
+ })
719
+ );
720
+ const paths = /* @__PURE__ */ new Set([
721
+ ...localByPath.keys(),
722
+ ...remoteByPath.keys(),
723
+ ...stateEntries.keys()
724
+ ]);
725
+ const folderMoveActions = collectFolderMoveActions(localByPath, remoteByPath, stateEntries);
726
+ const folderMovedPaths = collectMovedStatePaths(folderMoveActions, stateEntries);
727
+ const fileMoveActions = collectFileMoveActions(localByPath, remoteByPath, stateEntries, folderMovedPaths);
728
+ const moveActions = [
729
+ ...folderMoveActions,
730
+ ...fileMoveActions
731
+ ];
732
+ const movedPaths = collectMovedStatePaths(moveActions, stateEntries);
733
+ const actions = [
734
+ ...collectUnsupportedLocalActions(localEntries, ignorePatterns),
735
+ ...collectUnsupportedRemoteActions(remoteEntries, ignorePatterns),
736
+ ...localCaseCollisions.actions,
737
+ ...remoteCaseCollisions.actions,
738
+ ...crossCaseCollisions.actions,
739
+ ...moveActions
740
+ ];
741
+ for (const path2 of paths) {
742
+ if (isSyncPathIgnored(path2, ignorePatterns) || movedPaths.has(path2)) {
743
+ continue;
744
+ }
745
+ const localEntry = localByPath.get(path2);
746
+ const remoteEntry = remoteByPath.get(path2);
747
+ const stateEntry = stateEntries.get(path2);
748
+ let action = null;
749
+ if (localEntry && remoteEntry) {
750
+ action = planBothPresent(path2, localEntry, remoteEntry, stateEntry);
751
+ } else if (localEntry) {
752
+ action = planLocalOnly(path2, localEntry, stateEntry);
753
+ } else if (remoteEntry) {
754
+ action = planRemoteOnly(path2, remoteEntry, stateEntry);
755
+ }
756
+ if (action) {
757
+ actions.push(action);
758
+ }
759
+ }
760
+ return compactRecursiveDeleteActions(convertCreatedDescendantsUnderDeletedFolders(actions)).sort((a, b) => {
761
+ const priority = ACTION_PRIORITY[a.type] - ACTION_PRIORITY[b.type];
762
+ return priority === 0 ? a.path.localeCompare(b.path) : priority;
763
+ });
764
+ }
765
+
766
+ // ../desktop/src/api-client.mjs
767
+ import fs from "node:fs/promises";
768
+ import path from "node:path";
769
+ var REMOTE_TREE_PAGE_SIZE = 500;
770
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
771
+ var DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
772
+ var DEFAULT_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
773
+ var ZergBoxApiError = class extends Error {
774
+ constructor(message, { status, body } = {}) {
775
+ super(message);
776
+ this.name = "ZergBoxApiError";
777
+ this.status = status;
778
+ this.body = body;
779
+ }
780
+ };
781
+ var ZergBoxSyncConflictError = class extends ZergBoxApiError {
782
+ constructor(message, {
783
+ status,
784
+ body,
785
+ resourceType,
786
+ resourceId,
787
+ expectedRevision,
788
+ resourcePath,
789
+ expectedAbsent
790
+ } = {}) {
791
+ super(message, { status, body });
792
+ this.name = "ZergBoxSyncConflictError";
793
+ this.code = "SYNC_CONFLICT";
794
+ this.recoverable = true;
795
+ this.resourceType = resourceType;
796
+ this.resourceId = resourceId;
797
+ this.expectedRevision = expectedRevision;
798
+ this.resourcePath = resourcePath;
799
+ this.expectedAbsent = expectedAbsent;
800
+ }
801
+ };
802
+ function normalizeBaseUrl(baseUrl) {
803
+ let parsed;
804
+ try {
805
+ parsed = new URL(baseUrl);
806
+ } catch {
807
+ throw new Error("baseUrl must be an exact HTTP(S) origin");
808
+ }
809
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
810
+ throw new Error("baseUrl must be an exact HTTP(S) origin");
811
+ }
812
+ return parsed.origin;
813
+ }
814
+ function resolveApiUrl(baseUrl, apiPath) {
815
+ if (typeof apiPath !== "string" || !/^\/api(?:\/|\?|$)/.test(apiPath)) {
816
+ throw new Error("apiPath must be an absolute same-origin /api path");
817
+ }
818
+ const resolvedUrl = new URL(apiPath, `${baseUrl}/`);
819
+ if (resolvedUrl.origin !== baseUrl || resolvedUrl.username || resolvedUrl.password || resolvedUrl.hash || !/^\/api(?:\/|$)/.test(resolvedUrl.pathname)) {
820
+ throw new Error("apiPath must be an absolute same-origin /api path");
821
+ }
822
+ return resolvedUrl;
823
+ }
824
+ function remoteFileFingerprint(file) {
825
+ if (typeof file.content_sha256 === "string" && /^[0-9a-f]{64}$/.test(file.content_sha256)) {
826
+ return file.content_sha256;
827
+ }
828
+ return [
829
+ file.current_version ?? "",
830
+ file.size_bytes ?? "",
831
+ file.updated_at ?? ""
832
+ ].join(":");
833
+ }
834
+ function expectedRevisionHeader(expectedRevision) {
835
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
836
+ throw new Error("expectedRevision must be a positive integer");
837
+ }
838
+ return `"${expectedRevision}"`;
839
+ }
840
+ function normalizeBytes(bytes) {
841
+ if (Buffer.isBuffer(bytes)) {
842
+ return bytes;
843
+ }
844
+ if (bytes instanceof Uint8Array) {
845
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
846
+ }
847
+ throw new Error("bytes must be a Buffer or Uint8Array");
848
+ }
849
+ function joinRemotePath(parentPath, name) {
850
+ return normalizeSyncPath(parentPath ? `${parentPath}/${name}` : name);
851
+ }
852
+ function clampPageSize(pageSize) {
853
+ const parsed = Number(pageSize || REMOTE_TREE_PAGE_SIZE);
854
+ if (!Number.isFinite(parsed)) {
855
+ return REMOTE_TREE_PAGE_SIZE;
856
+ }
857
+ return Math.min(Math.max(Math.trunc(parsed), 1), REMOTE_TREE_PAGE_SIZE);
858
+ }
859
+ function appendQuery(apiPath, params) {
860
+ const searchParams = new URLSearchParams();
861
+ for (const [key, value] of Object.entries(params)) {
862
+ if (value !== void 0 && value !== null && value !== "") {
863
+ searchParams.set(key, String(value));
864
+ }
865
+ }
866
+ const query = searchParams.toString();
867
+ return query ? `${apiPath}?${query}` : apiPath;
868
+ }
869
+ function normalizeRequestTimeoutMs(value) {
870
+ const timeoutMs = Number(value ?? DEFAULT_REQUEST_TIMEOUT_MS);
871
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
872
+ return DEFAULT_REQUEST_TIMEOUT_MS;
873
+ }
874
+ return Math.trunc(timeoutMs);
875
+ }
876
+ function normalizeByteLimit(value, fallback, name) {
877
+ const limit = Number(value ?? fallback);
878
+ if (!Number.isSafeInteger(limit) || limit < 1) {
879
+ throw new Error(`${name} must be a positive safe integer`);
880
+ }
881
+ return limit;
882
+ }
883
+ async function readBoundedResponse(response, maxBytes, label) {
884
+ const declaredLength = response.headers.get("content-length");
885
+ if (/^(0|[1-9][0-9]*)$/.test(declaredLength || "")) {
886
+ const parsedLength = Number(declaredLength);
887
+ if (!Number.isSafeInteger(parsedLength) || parsedLength > maxBytes) {
888
+ await response.body?.cancel(`${label} exceeded ${maxBytes} bytes`);
889
+ throw new ZergBoxApiError(`${label} exceeded ${maxBytes} bytes`, {
890
+ status: response.status
891
+ });
892
+ }
893
+ }
894
+ if (!response.body) {
895
+ return Buffer.alloc(0);
896
+ }
897
+ const reader = response.body.getReader();
898
+ const chunks = [];
899
+ let totalBytes = 0;
900
+ try {
901
+ while (true) {
902
+ const { done, value } = await reader.read();
903
+ if (done) {
904
+ break;
905
+ }
906
+ const chunk = Buffer.from(value);
907
+ totalBytes += chunk.length;
908
+ if (totalBytes > maxBytes) {
909
+ await reader.cancel(`${label} exceeded ${maxBytes} bytes`);
910
+ throw new ZergBoxApiError(`${label} exceeded ${maxBytes} bytes`, {
911
+ status: response.status
912
+ });
913
+ }
914
+ chunks.push(chunk);
915
+ }
916
+ } finally {
917
+ reader.releaseLock();
918
+ }
919
+ return Buffer.concat(chunks, totalBytes);
920
+ }
921
+ function decodeResponseText(bytes) {
922
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
923
+ }
924
+ var ZergBoxDesktopClient = class {
925
+ constructor({
926
+ baseUrl,
927
+ token,
928
+ requestTimeoutMs,
929
+ maxResponseBytes,
930
+ maxDownloadBytes
931
+ }) {
932
+ if (!baseUrl) {
933
+ throw new Error("baseUrl is required");
934
+ }
935
+ if (!token) {
936
+ throw new Error("token is required");
937
+ }
938
+ this.baseUrl = normalizeBaseUrl(baseUrl);
939
+ this.token = token;
940
+ this.requestTimeoutMs = normalizeRequestTimeoutMs(requestTimeoutMs);
941
+ this.maxResponseBytes = normalizeByteLimit(
942
+ maxResponseBytes,
943
+ DEFAULT_MAX_RESPONSE_BYTES,
944
+ "maxResponseBytes"
945
+ );
946
+ this.maxDownloadBytes = normalizeByteLimit(
947
+ maxDownloadBytes,
948
+ DEFAULT_MAX_DOWNLOAD_BYTES,
949
+ "maxDownloadBytes"
950
+ );
951
+ }
952
+ async request(apiPath, options = {}) {
953
+ const requestUrl = resolveApiUrl(this.baseUrl, apiPath);
954
+ const headers = new Headers(options.headers || {});
955
+ headers.set("Authorization", `Bearer ${this.token}`);
956
+ let body = options.body;
957
+ if (body && !(body instanceof FormData) && !Buffer.isBuffer(body)) {
958
+ headers.set("Content-Type", "application/json");
959
+ body = JSON.stringify(body);
960
+ }
961
+ const timeoutMs = normalizeRequestTimeoutMs(options.timeoutMs ?? this.requestTimeoutMs);
962
+ const abortController = new AbortController();
963
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
964
+ try {
965
+ const response = await fetch(requestUrl, {
966
+ method: options.method || "GET",
967
+ headers,
968
+ body,
969
+ redirect: "error",
970
+ signal: abortController.signal
971
+ });
972
+ if (!response.ok) {
973
+ const errorBytes = await readBoundedResponse(
974
+ response,
975
+ this.maxResponseBytes,
976
+ "ZergBox API error response"
977
+ );
978
+ const text2 = decodeResponseText(errorBytes);
979
+ if ([409, 412].includes(response.status) && options.syncConflict) {
980
+ throw new ZergBoxSyncConflictError(
981
+ `ZergBox sync conflict: ${response.status} ${response.statusText}`,
982
+ {
983
+ status: response.status,
984
+ body: text2,
985
+ ...options.syncConflict
986
+ }
987
+ );
988
+ }
989
+ throw new ZergBoxApiError(`ZergBox API request failed: ${response.status} ${response.statusText}`, {
990
+ status: response.status,
991
+ body: text2
992
+ });
993
+ }
994
+ if (response.status === 204) {
995
+ return null;
996
+ }
997
+ if (options.binary) {
998
+ return await readBoundedResponse(
999
+ response,
1000
+ normalizeByteLimit(
1001
+ options.maxResponseBytes,
1002
+ this.maxDownloadBytes,
1003
+ "maxDownloadBytes"
1004
+ ),
1005
+ "ZergBox download"
1006
+ );
1007
+ }
1008
+ const responseBytes = await readBoundedResponse(
1009
+ response,
1010
+ this.maxResponseBytes,
1011
+ "ZergBox API response"
1012
+ );
1013
+ const text = decodeResponseText(responseBytes);
1014
+ const contentType = response.headers.get("content-type") || "";
1015
+ return contentType.includes("application/json") ? JSON.parse(text) : text;
1016
+ } catch (error) {
1017
+ if (abortController.signal.aborted) {
1018
+ throw new ZergBoxApiError(`ZergBox API request timed out after ${timeoutMs}ms`);
1019
+ }
1020
+ throw error;
1021
+ } finally {
1022
+ clearTimeout(timeoutId);
1023
+ }
1024
+ }
1025
+ async listOrganizations() {
1026
+ const data = await this.request("/api/orgs");
1027
+ return data.organizations || [];
1028
+ }
1029
+ async getRootFolder(orgId, options = {}) {
1030
+ return this.request(appendQuery("/api/folders/root", {
1031
+ orgId,
1032
+ limit: options.limit,
1033
+ offset: options.offset
1034
+ }));
1035
+ }
1036
+ async getFolder(folderId, options = {}) {
1037
+ return this.request(appendQuery(`/api/folders/${encodeURIComponent(folderId)}`, {
1038
+ limit: options.limit,
1039
+ offset: options.offset
1040
+ }));
1041
+ }
1042
+ async getChanges({ rootFolderId, cursor = 0, limit = 100 }) {
1043
+ return this.request(appendQuery(
1044
+ `/api/folders/${encodeURIComponent(rootFolderId)}/changes`,
1045
+ { cursor, limit }
1046
+ ));
1047
+ }
1048
+ async listChanges(rootFolderId, options = {}) {
1049
+ return this.getChanges({ rootFolderId, ...options });
1050
+ }
1051
+ async uploadBytes({
1052
+ orgId,
1053
+ folderId,
1054
+ filename,
1055
+ bytes,
1056
+ mediaType = "application/octet-stream",
1057
+ expectedAbsent = true
1058
+ }) {
1059
+ if (expectedAbsent !== true) {
1060
+ throw new Error("uploadBytes only supports create-if-absent; use updateFileBytes with expectedRevision");
1061
+ }
1062
+ const formData = new FormData();
1063
+ formData.append("orgId", orgId);
1064
+ formData.append("folderId", folderId);
1065
+ formData.append("replaceExisting", expectedAbsent ? "false" : "true");
1066
+ formData.append("file", new Blob([normalizeBytes(bytes)], { type: mediaType }), filename);
1067
+ return this.request("/api/files/upload", {
1068
+ method: "POST",
1069
+ headers: { "If-None-Match": "*" },
1070
+ syncConflict: {
1071
+ resourceType: "file",
1072
+ resourceId: null,
1073
+ resourcePath: `${folderId}/${filename}`,
1074
+ expectedAbsent: true
1075
+ },
1076
+ body: formData
1077
+ });
1078
+ }
1079
+ async updateFileBytes({
1080
+ fileId,
1081
+ filename = "content.bin",
1082
+ bytes,
1083
+ mediaType = "application/octet-stream",
1084
+ expectedRevision
1085
+ }) {
1086
+ const formData = new FormData();
1087
+ formData.append("file", new Blob([normalizeBytes(bytes)], { type: mediaType }), filename);
1088
+ return this.request(`/api/files/${encodeURIComponent(fileId)}/content`, {
1089
+ method: "PUT",
1090
+ headers: { "If-Match": expectedRevisionHeader(expectedRevision) },
1091
+ syncConflict: { resourceType: "file", resourceId: fileId, expectedRevision },
1092
+ body: formData
1093
+ });
1094
+ }
1095
+ async createFolder({
1096
+ orgId,
1097
+ parentFolderId,
1098
+ name,
1099
+ expectedAbsent,
1100
+ createIfAbsent = expectedAbsent ?? true
1101
+ }) {
1102
+ if (!createIfAbsent) {
1103
+ throw new Error("createFolder only supports create-if-absent");
1104
+ }
1105
+ return this.request("/api/folders", {
1106
+ method: "POST",
1107
+ headers: { "If-None-Match": "*" },
1108
+ syncConflict: {
1109
+ resourceType: "folder",
1110
+ resourceId: null,
1111
+ resourcePath: `${parentFolderId}/${name}`,
1112
+ expectedAbsent: true
1113
+ },
1114
+ body: { orgId, parentFolderId, name }
1115
+ });
1116
+ }
1117
+ async updateFolder({ folderId, name, parentFolderId, expectedRevision }) {
1118
+ return this.request(`/api/folders/${encodeURIComponent(folderId)}`, {
1119
+ method: "PATCH",
1120
+ headers: { "If-Match": expectedRevisionHeader(expectedRevision) },
1121
+ syncConflict: { resourceType: "folder", resourceId: folderId, expectedRevision },
1122
+ body: {
1123
+ name,
1124
+ parentFolderId
1125
+ }
1126
+ });
1127
+ }
1128
+ async uploadFile({ orgId, folderId, filePath, filename, replaceExisting = true }) {
1129
+ const data = await fs.readFile(filePath);
1130
+ const resolvedFilename = filename || path.basename(filePath);
1131
+ if (!replaceExisting) {
1132
+ return this.uploadBytes({
1133
+ orgId,
1134
+ folderId,
1135
+ filename: resolvedFilename,
1136
+ bytes: data,
1137
+ expectedAbsent: true
1138
+ });
1139
+ }
1140
+ const formData = new FormData();
1141
+ formData.append("orgId", orgId);
1142
+ formData.append("folderId", folderId);
1143
+ formData.append("replaceExisting", "true");
1144
+ formData.append("file", new Blob([data]), resolvedFilename);
1145
+ return this.request("/api/files/upload", { method: "POST", body: formData });
1146
+ }
1147
+ async updateFileContent({ fileId, filePath, expectedRevision }) {
1148
+ const data = await fs.readFile(filePath);
1149
+ return this.updateFileBytes({
1150
+ fileId,
1151
+ filename: path.basename(filePath),
1152
+ bytes: data,
1153
+ expectedRevision
1154
+ });
1155
+ }
1156
+ async updateFile({ fileId, name, folderId, expectedRevision }) {
1157
+ return this.request(`/api/files/${encodeURIComponent(fileId)}`, {
1158
+ method: "PATCH",
1159
+ headers: { "If-Match": expectedRevisionHeader(expectedRevision) },
1160
+ syncConflict: { resourceType: "file", resourceId: fileId, expectedRevision },
1161
+ body: {
1162
+ name,
1163
+ folderId
1164
+ }
1165
+ });
1166
+ }
1167
+ async downloadFile(fileId, { maxBytes } = {}) {
1168
+ return this.request(`/api/files/${encodeURIComponent(fileId)}/download`, {
1169
+ binary: true,
1170
+ maxResponseBytes: maxBytes
1171
+ });
1172
+ }
1173
+ async deleteRemoteFile(fileId, { expectedRevision } = {}) {
1174
+ return this.request(`/api/files/${encodeURIComponent(fileId)}`, {
1175
+ method: "DELETE",
1176
+ headers: { "If-Match": expectedRevisionHeader(expectedRevision) },
1177
+ syncConflict: { resourceType: "file", resourceId: fileId, expectedRevision }
1178
+ });
1179
+ }
1180
+ async deleteRemoteFolder(folderId, { expectedRevision } = {}) {
1181
+ return this.request(`/api/folders/${encodeURIComponent(folderId)}`, {
1182
+ method: "DELETE",
1183
+ headers: { "If-Match": expectedRevisionHeader(expectedRevision) },
1184
+ syncConflict: { resourceType: "folder", resourceId: folderId, expectedRevision }
1185
+ });
1186
+ }
1187
+ };
1188
+ async function getAllFolderPages(loadPage, pageSize) {
1189
+ const pages = [];
1190
+ let offset = 0;
1191
+ while (true) {
1192
+ const page = await loadPage({ limit: pageSize, offset });
1193
+ pages.push(page);
1194
+ const folderCount = page.folders?.length || 0;
1195
+ const fileCount = page.files?.length || 0;
1196
+ if (folderCount < pageSize && fileCount < pageSize) {
1197
+ break;
1198
+ }
1199
+ offset += pageSize;
1200
+ }
1201
+ return {
1202
+ folder: pages[0].folder,
1203
+ folders: pages.flatMap((page) => page.folders || []),
1204
+ files: pages.flatMap((page) => page.files || [])
1205
+ };
1206
+ }
1207
+ async function walkRemoteFolder(client, folderData, parentPath, entries, pageSize) {
1208
+ for (const folder of folderData.folders || []) {
1209
+ const folderPath = joinRemotePath(parentPath, folder.name);
1210
+ entries.push({
1211
+ path: folderPath,
1212
+ type: "directory",
1213
+ id: folder.id,
1214
+ revision: Number(folder.revision || 1),
1215
+ updatedAt: folder.updated_at
1216
+ });
1217
+ const childData = await getAllFolderPages((options) => client.getFolder(folder.id, options), pageSize);
1218
+ await walkRemoteFolder(client, childData, folderPath, entries, pageSize);
1219
+ }
1220
+ for (const file of folderData.files || []) {
1221
+ entries.push({
1222
+ path: joinRemotePath(parentPath, file.name),
1223
+ type: "file",
1224
+ id: file.id,
1225
+ contentHash: remoteFileFingerprint(file),
1226
+ revision: Number(file.revision || 1),
1227
+ sizeBytes: Number(file.size_bytes || 0),
1228
+ updatedAt: file.updated_at
1229
+ });
1230
+ }
1231
+ }
1232
+ async function fetchRemoteTree(client, {
1233
+ orgId,
1234
+ rootFolderId,
1235
+ pageSize = REMOTE_TREE_PAGE_SIZE
1236
+ }) {
1237
+ const normalizedPageSize = clampPageSize(pageSize);
1238
+ const loadRootPage = rootFolderId ? (options) => client.getFolder(rootFolderId, options) : (options) => client.getRootFolder(orgId, options);
1239
+ const rootData = await getAllFolderPages(loadRootPage, normalizedPageSize);
1240
+ if (!rootData.folder?.id) {
1241
+ throw new ZergBoxApiError("ZergBox remote root response did not include a folder");
1242
+ }
1243
+ if (rootFolderId && rootData.folder.id !== rootFolderId) {
1244
+ throw new ZergBoxApiError(
1245
+ `ZergBox remote root mismatch: requested ${rootFolderId}, received ${rootData.folder.id}`
1246
+ );
1247
+ }
1248
+ const entries = [];
1249
+ await walkRemoteFolder(client, rootData, "", entries, normalizedPageSize);
1250
+ return {
1251
+ rootFolder: rootData.folder,
1252
+ entries: entries.sort((a, b) => a.path.localeCompare(b.path))
1253
+ };
1254
+ }
1255
+
1256
+ // src/contracts.mjs
1257
+ var SYNC_NODE_TYPES = Object.freeze(["directory", "file"]);
1258
+ var SYNC_CHANGE_TYPES = Object.freeze([
1259
+ "create_remote_folder",
1260
+ "move_remote_folder",
1261
+ "upload_file",
1262
+ "move_remote_file",
1263
+ "create_local_folder",
1264
+ "move_local_folder",
1265
+ "download_file",
1266
+ "move_local_file",
1267
+ "conflict",
1268
+ "delete_remote",
1269
+ "delete_remote_folder",
1270
+ "delete_local",
1271
+ "delete_local_folder"
1272
+ ]);
1273
+ var MERGE_CANDIDATE_STATES = Object.freeze([
1274
+ "pending",
1275
+ "resolved",
1276
+ "dismissed"
1277
+ ]);
1278
+ function isSyncNode(value) {
1279
+ return Boolean(
1280
+ value && typeof value === "object" && typeof value.id === "string" && typeof value.path === "string" && SYNC_NODE_TYPES.includes(value.type)
1281
+ );
1282
+ }
1283
+ function isSyncChange(value) {
1284
+ return Boolean(
1285
+ value && typeof value === "object" && typeof value.path === "string" && SYNC_CHANGE_TYPES.includes(value.type)
1286
+ );
1287
+ }
1288
+ function isMergeCandidate(value) {
1289
+ return Boolean(
1290
+ value && typeof value === "object" && typeof value.id === "string" && typeof value.path === "string" && MERGE_CANDIDATE_STATES.includes(value.state) && isSyncNode(value.local) && isSyncNode(value.remote)
1291
+ );
1292
+ }
1293
+
1294
+ export {
1295
+ DEFAULT_IGNORE_PATTERNS,
1296
+ normalizeSyncPath,
1297
+ isSafeSyncPath,
1298
+ isSyncPathIgnored,
1299
+ planSync,
1300
+ ZergBoxApiError,
1301
+ ZergBoxSyncConflictError,
1302
+ remoteFileFingerprint,
1303
+ ZergBoxDesktopClient,
1304
+ fetchRemoteTree,
1305
+ SYNC_NODE_TYPES,
1306
+ SYNC_CHANGE_TYPES,
1307
+ MERGE_CANDIDATE_STATES,
1308
+ isSyncNode,
1309
+ isSyncChange,
1310
+ isMergeCandidate
1311
+ };