aethel 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,1442 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { findRoot } from "./config.js";
5
+ import { loadIgnoreRules } from "./ignore.js";
6
+
7
+ const PAGE_SIZE = 1000;
8
+ const CLEANER_BATCH_SIZE = 20;
9
+ const FOLDER_MIME = "application/vnd.google-apps.folder";
10
+ const DEFAULT_ITEM_FIELDS =
11
+ "nextPageToken, files(id, name, mimeType, size, modifiedTime, createdTime, md5Checksum, parents)";
12
+ const CHILD_QUERY_FIELDS =
13
+ "nextPageToken, files(id,name,mimeType,parents,createdTime,modifiedTime,md5Checksum,size,capabilities(canAddChildren,canEdit,canTrash,canDelete,canRename))";
14
+
15
+ function readPositiveIntEnv(name, fallback) {
16
+ const rawValue = Number.parseInt(process.env[name] || "", 10);
17
+ return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : fallback;
18
+ }
19
+
20
+ const DRIVE_API_CONCURRENCY = readPositiveIntEnv(
21
+ "AETHEL_DRIVE_CONCURRENCY",
22
+ 40
23
+ );
24
+ const UPLOAD_BATCH_SIZE = DRIVE_API_CONCURRENCY;
25
+ const DEDUPE_BATCH_SIZE = DRIVE_API_CONCURRENCY;
26
+
27
+ // Retry with exponential backoff for transient Drive API errors (429, 5xx).
28
+ const RETRY_MAX_ATTEMPTS = 5;
29
+ const RETRY_BASE_DELAY_MS = 500;
30
+
31
+ function isRetryableError(err) {
32
+ const status = err?.response?.status ?? err?.code;
33
+ return status === 429 || status === 500 || status === 502 || status === 503;
34
+ }
35
+
36
+ function getRetryDelay(err, attempt) {
37
+ // Respect Retry-After header from Google when present (value in seconds).
38
+ const retryAfter = err?.response?.headers?.["retry-after"];
39
+ if (retryAfter) {
40
+ const seconds = Number.parseFloat(retryAfter);
41
+ if (Number.isFinite(seconds) && seconds > 0) {
42
+ return seconds * 1000;
43
+ }
44
+ }
45
+ return RETRY_BASE_DELAY_MS * Math.pow(2, attempt) * (0.5 + Math.random());
46
+ }
47
+
48
+ async function withRetry(fn) {
49
+ for (let attempt = 0; ; attempt++) {
50
+ try {
51
+ return await fn();
52
+ } catch (err) {
53
+ if (!isRetryableError(err) || attempt >= RETRY_MAX_ATTEMPTS - 1) {
54
+ throw err;
55
+ }
56
+ await new Promise((r) => setTimeout(r, getRetryDelay(err, attempt)));
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Return a thin wrapper around a googleapis drive client whose
63
+ * `files.*` methods automatically retry on 429 / 5xx.
64
+ * The original object is not mutated.
65
+ */
66
+ export function withDriveRetry(drive) {
67
+ const filesProxy = new Proxy(drive.files, {
68
+ get(target, prop, receiver) {
69
+ const value = Reflect.get(target, prop, receiver);
70
+ if (typeof value !== "function") return value;
71
+ return (...args) => withRetry(() => value.apply(target, args));
72
+ },
73
+ });
74
+ return new Proxy(drive, {
75
+ get(target, prop, receiver) {
76
+ if (prop === "files") return filesProxy;
77
+ return Reflect.get(target, prop, receiver);
78
+ },
79
+ });
80
+ }
81
+
82
+ export const EXPORT_MAP = {
83
+ "application/vnd.google-apps.document": {
84
+ mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
85
+ ext: ".docx",
86
+ },
87
+ "application/vnd.google-apps.spreadsheet": {
88
+ mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
89
+ ext: ".xlsx",
90
+ },
91
+ "application/vnd.google-apps.presentation": {
92
+ mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
93
+ ext: ".pptx",
94
+ },
95
+ "application/vnd.google-apps.drawing": {
96
+ mime: "application/pdf",
97
+ ext: ".pdf",
98
+ },
99
+ };
100
+
101
+ const MIME_ICONS = {
102
+ [FOLDER_MIME]: "[DIR]",
103
+ "application/vnd.google-apps.document": "[DOC]",
104
+ "application/vnd.google-apps.spreadsheet": "[SHT]",
105
+ "application/vnd.google-apps.presentation": "[SLD]",
106
+ "application/pdf": "[PDF]",
107
+ "image/": "[IMG]",
108
+ "video/": "[VID]",
109
+ "audio/": "[AUD]",
110
+ };
111
+
112
+ export function isWorkspaceType(mime) {
113
+ return mime?.startsWith("application/vnd.google-apps.") ?? false;
114
+ }
115
+
116
+ function escapeDriveQueryValue(value) {
117
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
118
+ }
119
+
120
+ export function iconForMime(mime) {
121
+ for (const [prefix, icon] of Object.entries(MIME_ICONS)) {
122
+ if ((mime || "").startsWith(prefix)) {
123
+ return icon;
124
+ }
125
+ }
126
+
127
+ return "[FIL]";
128
+ }
129
+
130
+ export function humanSize(rawSize) {
131
+ if (!rawSize) {
132
+ return " -- ";
133
+ }
134
+
135
+ let size = Number(rawSize);
136
+ for (const unit of ["B", "KB", "MB", "GB", "TB"]) {
137
+ if (size < 1024) {
138
+ return `${size.toPrecision(4).padStart(5, " ")} ${unit}`;
139
+ }
140
+ size /= 1024;
141
+ }
142
+
143
+ return `${size.toFixed(1).padStart(5, " ")} PB`;
144
+ }
145
+
146
+ export function sourceBadgeForItem(item) {
147
+ if (item?.isSharedDriveItem) {
148
+ return "[DRV]";
149
+ }
150
+
151
+ if (item?.ownedByMe) {
152
+ return "[MY ]";
153
+ }
154
+
155
+ if (item?.shared) {
156
+ return "[SHR]";
157
+ }
158
+
159
+ return "[EXT]";
160
+ }
161
+
162
+ function createdTimeRank(item) {
163
+ const value = item?.createdTime ? Date.parse(item.createdTime) : Number.NaN;
164
+ return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
165
+ }
166
+
167
+ function canonicalItemComparator(left, right) {
168
+ const createdDiff = createdTimeRank(left) - createdTimeRank(right);
169
+ if (createdDiff !== 0) {
170
+ return createdDiff;
171
+ }
172
+ return String(left.id || "").localeCompare(String(right.id || ""));
173
+ }
174
+
175
+ function pickCanonicalItem(items) {
176
+ if (!items.length) {
177
+ return null;
178
+ }
179
+ return [...items].sort(canonicalItemComparator)[0];
180
+ }
181
+
182
+ /**
183
+ * Build a folder-path resolver from a pre-collected folder map.
184
+ */
185
+ function createFolderResolver(folders, rootFolderId) {
186
+ const cache = new Map();
187
+
188
+ return function resolve(folderId) {
189
+ if (cache.has(folderId)) return cache.get(folderId);
190
+
191
+ if (!folderId) {
192
+ const v = rootFolderId ? null : "";
193
+ cache.set(folderId, v);
194
+ return v;
195
+ }
196
+ if (folderId === rootFolderId) {
197
+ cache.set(folderId, "");
198
+ return "";
199
+ }
200
+
201
+ const folder = folders.get(folderId);
202
+ if (!folder) {
203
+ const v = rootFolderId ? null : "";
204
+ cache.set(folderId, v);
205
+ return v;
206
+ }
207
+
208
+ const parentPath = resolve(folder.parents?.[0] || "");
209
+ if (rootFolderId && parentPath === null) {
210
+ cache.set(folderId, null);
211
+ return null;
212
+ }
213
+
214
+ const result = parentPath
215
+ ? path.posix.join(parentPath, folder.name)
216
+ : folder.name;
217
+ cache.set(folderId, result);
218
+ return result;
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Single-pass fetch: get ALL non-trashed items (folders + files) in one
224
+ * pagination loop, then split in memory. Cuts API round-trips in half.
225
+ */
226
+ async function fetchAllItems(drive, { fields, includeSharedDrives = false } = {}) {
227
+ const allFields = fields || DEFAULT_ITEM_FIELDS;
228
+ const folders = new Map();
229
+ const files = [];
230
+ let pageToken = null;
231
+
232
+ const listOpts = {
233
+ q: "trashed = false",
234
+ fields: allFields,
235
+ pageSize: PAGE_SIZE,
236
+ };
237
+ if (includeSharedDrives) {
238
+ listOpts.includeItemsFromAllDrives = true;
239
+ listOpts.supportsAllDrives = true;
240
+ listOpts.corpora = "allDrives";
241
+ }
242
+
243
+ do {
244
+ listOpts.pageToken = pageToken;
245
+ const response = await drive.files.list(listOpts);
246
+
247
+ for (const item of response.data.files || []) {
248
+ if (item.mimeType === FOLDER_MIME) {
249
+ folders.set(item.id, item);
250
+ } else {
251
+ files.push(item);
252
+ }
253
+ }
254
+
255
+ pageToken = response.data.nextPageToken;
256
+ } while (pageToken);
257
+
258
+ return { folders, files };
259
+ }
260
+
261
+ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
262
+ const resolve = createFolderResolver(folders, rootFolderId);
263
+ const files = [];
264
+ for (const file of rawFiles) {
265
+ const parentId = file.parents?.[0] || "";
266
+ const parentPath = parentId === rootFolderId ? "" : resolve(parentId);
267
+
268
+ if (rootFolderId && parentPath === null) continue;
269
+
270
+ files.push({
271
+ id: file.id,
272
+ name: file.name,
273
+ path: parentPath ? path.posix.join(parentPath, file.name) : file.name,
274
+ mimeType: file.mimeType || "",
275
+ size: file.size || null,
276
+ modifiedTime: file.modifiedTime || null,
277
+ md5Checksum: file.md5Checksum || null,
278
+ });
279
+ }
280
+ return files;
281
+ }
282
+
283
+ function buildDuplicateFolderGroups(folders, rootFolderId = null, ignoreRules = null) {
284
+ const resolve = createFolderResolver(folders, rootFolderId);
285
+ const groups = new Map();
286
+
287
+ for (const folder of folders.values()) {
288
+ const folderPath = resolve(folder.id);
289
+ if (rootFolderId && folderPath === null) {
290
+ continue;
291
+ }
292
+ if (ignoreRules && folderPath && ignoreRules.ignores(folderPath)) {
293
+ continue;
294
+ }
295
+
296
+ const rawParentId = folder.parents?.[0] || "root";
297
+ const parentPath =
298
+ rawParentId === rootFolderId ? "" : resolve(folder.parents?.[0] || "");
299
+
300
+ if (rootFolderId && parentPath === null) {
301
+ continue;
302
+ }
303
+
304
+ const key = `${rawParentId}::${folder.name}`;
305
+ const folderEntry = {
306
+ ...folder,
307
+ path: folderPath || folder.name,
308
+ };
309
+
310
+ if (!groups.has(key)) {
311
+ groups.set(key, {
312
+ key,
313
+ parentId: rawParentId,
314
+ name: folder.name,
315
+ path: parentPath ? path.posix.join(parentPath, folder.name) : folder.name,
316
+ folders: [],
317
+ });
318
+ }
319
+
320
+ groups.get(key).folders.push(folderEntry);
321
+ }
322
+
323
+ return [...groups.values()]
324
+ .filter((group) => group.folders.length > 1)
325
+ .map((group) => {
326
+ const foldersInGroup = [...group.folders].sort(canonicalItemComparator);
327
+ return {
328
+ ...group,
329
+ folders: foldersInGroup,
330
+ canonical: foldersInGroup[0],
331
+ losers: foldersInGroup.slice(1),
332
+ };
333
+ })
334
+ .sort((left, right) => {
335
+ const depthDiff = left.path.split("/").length - right.path.split("/").length;
336
+ if (depthDiff !== 0) {
337
+ return depthDiff;
338
+ }
339
+ return left.path.localeCompare(right.path);
340
+ });
341
+ }
342
+
343
+ function folderPathDepth(folderPath) {
344
+ return String(folderPath || "")
345
+ .split("/")
346
+ .filter(Boolean).length;
347
+ }
348
+
349
+ export class DuplicateFoldersError extends Error {
350
+ constructor(duplicateFolders) {
351
+ const preview = duplicateFolders
352
+ .slice(0, 5)
353
+ .map(
354
+ (group) =>
355
+ `- ${group.path} (${group.folders.length}) canonical=${group.canonical.id}`
356
+ )
357
+ .join("\n");
358
+ const suffix =
359
+ duplicateFolders.length > 5
360
+ ? `\n... and ${duplicateFolders.length - 5} more`
361
+ : "";
362
+ super(
363
+ `Duplicate folders detected in the Drive sync root. Run 'aethel dedupe-folders' before syncing.\n${preview}${suffix}`
364
+ );
365
+ this.name = "DuplicateFoldersError";
366
+ this.duplicateFolders = duplicateFolders;
367
+ }
368
+ }
369
+
370
+ export async function getRemoteState(drive, rootFolderId = null, ignoreRules = null) {
371
+ const { folders, files } = await fetchAllItems(drive);
372
+ return {
373
+ files: buildRemoteFiles(folders, files, rootFolderId),
374
+ duplicateFolders: buildDuplicateFolderGroups(folders, rootFolderId, ignoreRules),
375
+ };
376
+ }
377
+
378
+ export async function listRemoteFiles(drive, rootFolderId = null) {
379
+ const remoteState = await getRemoteState(drive, rootFolderId);
380
+ return remoteState.files;
381
+ }
382
+
383
+ export async function findDuplicateFolders(drive, rootFolderId = null, ignoreRules = null) {
384
+ const remoteState = await getRemoteState(drive, rootFolderId, ignoreRules);
385
+ return remoteState.duplicateFolders;
386
+ }
387
+
388
+ export function assertNoDuplicateFolders(duplicateFolders) {
389
+ if (duplicateFolders.length > 0) {
390
+ throw new DuplicateFoldersError(duplicateFolders);
391
+ }
392
+ }
393
+
394
+ export async function downloadFile(drive, fileMeta, localPath) {
395
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
396
+
397
+ const mime = fileMeta.mimeType || "";
398
+ const exportInfo = EXPORT_MAP[mime];
399
+
400
+ if (exportInfo) {
401
+ let targetPath = localPath;
402
+ if (!targetPath.endsWith(exportInfo.ext)) {
403
+ const parsed = path.parse(targetPath);
404
+ targetPath = path.join(parsed.dir, parsed.name + exportInfo.ext);
405
+ }
406
+
407
+ const response = await drive.files.export(
408
+ { fileId: fileMeta.id, mimeType: exportInfo.mime, supportsAllDrives: true },
409
+ { responseType: "stream" }
410
+ );
411
+ await pipeline(response.data, fs.createWriteStream(targetPath));
412
+ return;
413
+ }
414
+
415
+ const response = await drive.files.get(
416
+ { fileId: fileMeta.id, alt: "media", supportsAllDrives: true },
417
+ { responseType: "stream" }
418
+ );
419
+ await pipeline(response.data, fs.createWriteStream(localPath));
420
+ }
421
+
422
+ export async function uploadFile(
423
+ drive,
424
+ localPath,
425
+ remotePath,
426
+ { parentId = null, existingId = null } = {}
427
+ ) {
428
+ const name = path.basename(remotePath);
429
+ const media = { body: fs.createReadStream(localPath) };
430
+
431
+ if (existingId) {
432
+ const response = await drive.files.update({
433
+ fileId: existingId,
434
+ requestBody: { name },
435
+ media,
436
+ supportsAllDrives: true,
437
+ fields: "id,name,parents,md5Checksum,modifiedTime,size,mimeType",
438
+ });
439
+ return response.data;
440
+ }
441
+
442
+ const requestBody = { name };
443
+ if (parentId) {
444
+ requestBody.parents = [parentId];
445
+ }
446
+
447
+ const response = await drive.files.create({
448
+ requestBody,
449
+ media,
450
+ supportsAllDrives: true,
451
+ fields: "id,name,parents,md5Checksum,modifiedTime,size,mimeType",
452
+ });
453
+ return response.data;
454
+ }
455
+
456
+ const _folderIdCache = new Map();
457
+ const _folderPromiseCache = new Map();
458
+
459
+ export function resetFolderLookupCache() {
460
+ _folderIdCache.clear();
461
+ _folderPromiseCache.clear();
462
+ }
463
+
464
+ async function listMatchingChildren(drive, parentId, name, mimeType = null) {
465
+ const escapedName = escapeDriveQueryValue(name);
466
+ const queryParts = [
467
+ `name = '${escapedName}'`,
468
+ `'${parentId}' in parents`,
469
+ "trashed = false",
470
+ ];
471
+
472
+ if (mimeType) {
473
+ queryParts.push(`mimeType = '${mimeType}'`);
474
+ }
475
+
476
+ let pageToken = null;
477
+ const items = [];
478
+
479
+ do {
480
+ const response = await drive.files.list({
481
+ q: queryParts.join(" and "),
482
+ fields: CHILD_QUERY_FIELDS,
483
+ pageSize: PAGE_SIZE,
484
+ pageToken,
485
+ includeItemsFromAllDrives: true,
486
+ supportsAllDrives: true,
487
+ });
488
+ items.push(...(response.data.files || []));
489
+ pageToken = response.data.nextPageToken;
490
+ } while (pageToken);
491
+
492
+ return items.sort(canonicalItemComparator);
493
+ }
494
+
495
+ function createChildNameIndex(items) {
496
+ const index = new Map();
497
+
498
+ for (const item of items) {
499
+ if (!index.has(item.name)) {
500
+ index.set(item.name, []);
501
+ }
502
+ index.get(item.name).push(item);
503
+ }
504
+
505
+ for (const groupedItems of index.values()) {
506
+ groupedItems.sort(canonicalItemComparator);
507
+ }
508
+
509
+ return index;
510
+ }
511
+
512
+ async function getChildNameIndex(drive, parentId, childIndexCache = null) {
513
+ if (!childIndexCache) {
514
+ return null;
515
+ }
516
+
517
+ let pending = childIndexCache.get(parentId);
518
+ if (!pending) {
519
+ pending = listDirectChildren(drive, parentId).then((items) =>
520
+ createChildNameIndex(items)
521
+ );
522
+ childIndexCache.set(parentId, pending);
523
+ }
524
+
525
+ return pending;
526
+ }
527
+
528
+ async function getIndexedChild(
529
+ drive,
530
+ parentId,
531
+ name,
532
+ mimeType = null,
533
+ childIndexCache = null
534
+ ) {
535
+ if (!childIndexCache) {
536
+ return undefined;
537
+ }
538
+
539
+ const index = await getChildNameIndex(drive, parentId, childIndexCache);
540
+ const candidates = (index.get(name) || []).filter(
541
+ (item) => !mimeType || item.mimeType === mimeType
542
+ );
543
+ return pickCanonicalItem(candidates) || null;
544
+ }
545
+
546
+ async function resolveCanonicalFolder(
547
+ drive,
548
+ parentId,
549
+ name,
550
+ createIfMissing = true,
551
+ { childIndexCache = null } = {}
552
+ ) {
553
+ const cacheKey = `${parentId}/${name}`;
554
+ const cachedId = _folderIdCache.get(cacheKey);
555
+ if (cachedId) {
556
+ return {
557
+ id: cachedId,
558
+ name,
559
+ mimeType: FOLDER_MIME,
560
+ parents: [parentId],
561
+ };
562
+ }
563
+
564
+ const pending = _folderPromiseCache.get(cacheKey);
565
+ if (pending) {
566
+ return pending;
567
+ }
568
+
569
+ const promise = (async () => {
570
+ const indexedExisting = await getIndexedChild(
571
+ drive,
572
+ parentId,
573
+ name,
574
+ FOLDER_MIME,
575
+ childIndexCache
576
+ );
577
+ const existing = indexedExisting !== undefined
578
+ ? indexedExisting
579
+ ? [indexedExisting]
580
+ : []
581
+ : await listMatchingChildren(drive, parentId, name, FOLDER_MIME);
582
+ const canonical = pickCanonicalItem(existing);
583
+
584
+ if (canonical) {
585
+ _folderIdCache.set(cacheKey, canonical.id);
586
+ return canonical;
587
+ }
588
+
589
+ if (!createIfMissing) {
590
+ return null;
591
+ }
592
+
593
+ const created = await createFolder(drive, name, parentId);
594
+ _folderIdCache.set(cacheKey, created.id);
595
+ return created;
596
+ })().finally(() => {
597
+ _folderPromiseCache.delete(cacheKey);
598
+ });
599
+
600
+ _folderPromiseCache.set(cacheKey, promise);
601
+ return promise;
602
+ }
603
+
604
+ export async function ensureFolder(drive, folderPath, rootId = null) {
605
+ const parts = folderPath.split("/").filter(Boolean);
606
+ let parent = rootId || "root";
607
+
608
+ for (const part of parts) {
609
+ const folder = await resolveCanonicalFolder(drive, parent, part, true);
610
+ parent = folder.id;
611
+ }
612
+
613
+ return parent;
614
+ }
615
+
616
+ export async function trashFile(drive, fileId) {
617
+ await drive.files.update({
618
+ fileId,
619
+ requestBody: { trashed: true },
620
+ supportsAllDrives: true,
621
+ });
622
+ }
623
+
624
+ export async function deleteFile(drive, fileId) {
625
+ await drive.files.delete({ fileId, supportsAllDrives: true });
626
+ }
627
+
628
+ export async function getAccountInfo(drive) {
629
+ const response = await drive.about.get({
630
+ fields: "user(emailAddress, displayName), storageQuota(usage, limit)",
631
+ });
632
+ const user = response.data.user || {};
633
+ const quota = response.data.storageQuota || {};
634
+
635
+ return {
636
+ email: user.emailAddress || "unknown",
637
+ name: user.displayName || "unknown",
638
+ usage: humanSize(quota.usage),
639
+ limit: humanSize(quota.limit),
640
+ };
641
+ }
642
+
643
+ export async function listAccessibleFiles(drive, includeSharedDrives = false) {
644
+ const richFields =
645
+ "nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, ownedByMe, shared, driveId, owners(displayName,emailAddress), capabilities(canAddChildren,canEdit,canTrash,canDelete,canRename))";
646
+
647
+ const { folders, files: rawItems } = await fetchAllItems(drive, {
648
+ fields: richFields,
649
+ includeSharedDrives,
650
+ });
651
+
652
+ // Build resolver from the folders already collected
653
+ const pathCache = new Map();
654
+ function resolveFolderPath(folderId) {
655
+ if (pathCache.has(folderId)) return pathCache.get(folderId);
656
+ if (!folderId) { pathCache.set(folderId, ""); return ""; }
657
+ const folder = folders.get(folderId);
658
+ if (!folder) { pathCache.set(folderId, ""); return ""; }
659
+ const parentPath = resolveFolderPath(folder.parents?.[0] || "");
660
+ const result = parentPath ? path.posix.join(parentPath, folder.name) : folder.name;
661
+ pathCache.set(folderId, result);
662
+ return result;
663
+ }
664
+
665
+ // Combine folders + files into result list (TUI needs folders too)
666
+ const allItems = [...folders.values(), ...rawItems];
667
+ const result = [];
668
+
669
+ for (const file of allItems) {
670
+ const parentId = file.parents?.[0] || "";
671
+ const parentPath = resolveFolderPath(parentId);
672
+ const itemPath = parentPath
673
+ ? path.posix.join(parentPath, file.name)
674
+ : file.name;
675
+
676
+ result.push({
677
+ ...file,
678
+ parentId: parentId || null,
679
+ path: itemPath,
680
+ isFolder: file.mimeType === FOLDER_MIME,
681
+ isRootLevel: !parentId || !folders.has(parentId),
682
+ ownedByMe: Boolean(file.ownedByMe),
683
+ shared: Boolean(file.shared),
684
+ isSharedDriveItem: Boolean(file.driveId),
685
+ ownerName: file.owners?.[0]?.displayName || null,
686
+ ownerEmail: file.owners?.[0]?.emailAddress || null,
687
+ capabilities: file.capabilities || {},
688
+ });
689
+ }
690
+
691
+ return result;
692
+ }
693
+
694
+ export async function batchOperateFiles(
695
+ drive,
696
+ files,
697
+ { permanent = false, includeSharedDrives = false, onProgress } = {}
698
+ ) {
699
+ let success = 0;
700
+ let errors = 0;
701
+ const total = files.length;
702
+
703
+ for (let start = 0; start < total; start += CLEANER_BATCH_SIZE) {
704
+ const chunk = files.slice(start, start + CLEANER_BATCH_SIZE);
705
+ const results = await Promise.allSettled(
706
+ chunk.map(async (file) => {
707
+ if (permanent) {
708
+ await drive.files.delete({
709
+ fileId: file.id,
710
+ supportsAllDrives: includeSharedDrives,
711
+ });
712
+ return { verb: "Deleted", name: file.name };
713
+ }
714
+
715
+ await drive.files.update({
716
+ fileId: file.id,
717
+ requestBody: { trashed: true },
718
+ supportsAllDrives: includeSharedDrives,
719
+ });
720
+ return { verb: "Trashed", name: file.name };
721
+ })
722
+ );
723
+
724
+ for (const [index, result] of results.entries()) {
725
+ const file = chunk[index];
726
+
727
+ if (result.status === "fulfilled") {
728
+ success += 1;
729
+ onProgress?.(success + errors, total, result.value.verb, file.name);
730
+ continue;
731
+ }
732
+
733
+ errors += 1;
734
+ onProgress?.(success + errors, total, "FAILED", file.name);
735
+ }
736
+ }
737
+
738
+ return { success, errors };
739
+ }
740
+
741
+ export async function createFolder(drive, name, parentId = "root") {
742
+ const response = await drive.files.create({
743
+ requestBody: {
744
+ name,
745
+ mimeType: FOLDER_MIME,
746
+ parents: parentId ? [parentId] : undefined,
747
+ },
748
+ supportsAllDrives: true,
749
+ fields: "id,name,mimeType,parents,createdTime,modifiedTime",
750
+ });
751
+
752
+ return response.data;
753
+ }
754
+
755
+ export async function findChildrenByName(drive, parentId, name, mimeType = null) {
756
+ return listMatchingChildren(drive, parentId, name, mimeType);
757
+ }
758
+
759
+ export async function findChildByName(drive, parentId, name, mimeType = null) {
760
+ const matches = await listMatchingChildren(drive, parentId, name, mimeType);
761
+ return matches[0] || null;
762
+ }
763
+
764
+ async function listDirectChildren(drive, parentId) {
765
+ let pageToken = null;
766
+ const items = [];
767
+
768
+ do {
769
+ const response = await drive.files.list({
770
+ q: `'${parentId}' in parents and trashed = false`,
771
+ fields: CHILD_QUERY_FIELDS,
772
+ pageSize: PAGE_SIZE,
773
+ pageToken,
774
+ includeItemsFromAllDrives: true,
775
+ supportsAllDrives: true,
776
+ });
777
+ items.push(...(response.data.files || []));
778
+ pageToken = response.data.nextPageToken;
779
+ } while (pageToken);
780
+
781
+ return items;
782
+ }
783
+
784
+ async function moveItemToParent(drive, item, fromParentId, toParentId) {
785
+ const response = await drive.files.update({
786
+ fileId: item.id,
787
+ addParents: toParentId,
788
+ removeParents: fromParentId,
789
+ requestBody: {},
790
+ supportsAllDrives: true,
791
+ fields: "id,name,mimeType,parents,createdTime,modifiedTime,md5Checksum,size",
792
+ });
793
+ return response.data;
794
+ }
795
+
796
+ function filesHaveSameContent(left, right) {
797
+ return Boolean(
798
+ left.mimeType === right.mimeType &&
799
+ left.md5Checksum &&
800
+ right.md5Checksum &&
801
+ left.md5Checksum === right.md5Checksum
802
+ );
803
+ }
804
+
805
+ function createUploadContext(localPath) {
806
+ const workspaceRoot = findRoot(localPath);
807
+ return {
808
+ workspaceRoot,
809
+ ignoreRules: workspaceRoot ? loadIgnoreRules(workspaceRoot) : null,
810
+ parentMetaCache: new Map(),
811
+ childIndexCache: new Map(),
812
+ };
813
+ }
814
+
815
+ function shouldIgnoreUploadPath(targetPath, isDirectory, context) {
816
+ if (!context?.workspaceRoot || !context.ignoreRules) {
817
+ return false;
818
+ }
819
+
820
+ let relativePath = path
821
+ .relative(context.workspaceRoot, targetPath)
822
+ .split(path.sep)
823
+ .join("/");
824
+
825
+ if (!relativePath || relativePath.startsWith("..")) {
826
+ return false;
827
+ }
828
+
829
+ if (isDirectory && !relativePath.endsWith("/")) {
830
+ relativePath += "/";
831
+ }
832
+
833
+ return context.ignoreRules.ignores(relativePath);
834
+ }
835
+
836
+ async function mapWithConcurrency(items, limit, worker) {
837
+ const results = new Array(items.length);
838
+ let nextIndex = 0;
839
+ const workerCount = Math.min(limit, items.length);
840
+
841
+ await Promise.all(
842
+ Array.from({ length: workerCount }, async () => {
843
+ while (nextIndex < items.length) {
844
+ const currentIndex = nextIndex;
845
+ nextIndex += 1;
846
+ results[currentIndex] = await worker(items[currentIndex], currentIndex);
847
+ }
848
+ })
849
+ );
850
+
851
+ return results;
852
+ }
853
+
854
+ function createChildState(items) {
855
+ return {
856
+ items: [...items],
857
+ byName: createChildNameIndex(items),
858
+ };
859
+ }
860
+
861
+ async function getChildState(drive, parentId, childStateCache) {
862
+ let statePromise = childStateCache.get(parentId);
863
+ if (!statePromise) {
864
+ statePromise = listDirectChildren(drive, parentId).then((items) =>
865
+ createChildState(items)
866
+ );
867
+ childStateCache.set(parentId, statePromise);
868
+ }
869
+
870
+ return statePromise;
871
+ }
872
+
873
+ function addToChildState(state, item) {
874
+ state.items.push(item);
875
+ const groupedItems = state.byName.get(item.name) || [];
876
+ groupedItems.push(item);
877
+ groupedItems.sort(canonicalItemComparator);
878
+ state.byName.set(item.name, groupedItems);
879
+ }
880
+
881
+ function removeFromChildState(state, itemId) {
882
+ const index = state.items.findIndex((item) => item.id === itemId);
883
+ if (index === -1) {
884
+ return null;
885
+ }
886
+
887
+ const [removed] = state.items.splice(index, 1);
888
+ const groupedItems = (state.byName.get(removed.name) || []).filter(
889
+ (item) => item.id !== itemId
890
+ );
891
+
892
+ if (groupedItems.length > 0) {
893
+ state.byName.set(removed.name, groupedItems);
894
+ } else {
895
+ state.byName.delete(removed.name);
896
+ }
897
+
898
+ return removed;
899
+ }
900
+
901
+ function findMatchingChild(state, name, mimeType = null) {
902
+ const candidates = (state.byName.get(name) || []).filter(
903
+ (item) => !mimeType || item.mimeType === mimeType
904
+ );
905
+ return pickCanonicalItem(candidates);
906
+ }
907
+
908
+ function createDedupeContext() {
909
+ return {
910
+ childStateCache: new Map(),
911
+ trashedFolderIds: new Set(),
912
+ };
913
+ }
914
+
915
+ function groupItemsByName(items) {
916
+ const groups = new Map();
917
+
918
+ for (const item of items) {
919
+ if (!groups.has(item.name)) {
920
+ groups.set(item.name, []);
921
+ }
922
+ groups.get(item.name).push(item);
923
+ }
924
+
925
+ return [...groups.values()];
926
+ }
927
+
928
+ async function mergeFolderIntoCanonical(
929
+ drive,
930
+ sourceFolder,
931
+ targetFolder,
932
+ stats,
933
+ onProgress = null,
934
+ context = createDedupeContext()
935
+ ) {
936
+ if (
937
+ context.trashedFolderIds.has(sourceFolder.id) ||
938
+ context.trashedFolderIds.has(targetFolder.id)
939
+ ) {
940
+ return;
941
+ }
942
+
943
+ const sourceState = await getChildState(
944
+ drive,
945
+ sourceFolder.id,
946
+ context.childStateCache
947
+ );
948
+ const targetState = await getChildState(
949
+ drive,
950
+ targetFolder.id,
951
+ context.childStateCache
952
+ );
953
+ const childGroups = groupItemsByName([...sourceState.items]);
954
+
955
+ await mapWithConcurrency(
956
+ childGroups,
957
+ DEDUPE_BATCH_SIZE,
958
+ async (group) => {
959
+ for (const child of group) {
960
+ if (!sourceState.items.some((item) => item.id === child.id)) {
961
+ continue;
962
+ }
963
+
964
+ if (child.mimeType === FOLDER_MIME) {
965
+ const existingFolder = findMatchingChild(
966
+ targetState,
967
+ child.name,
968
+ FOLDER_MIME
969
+ );
970
+
971
+ if (existingFolder) {
972
+ await mergeFolderIntoCanonical(
973
+ drive,
974
+ child,
975
+ existingFolder,
976
+ stats,
977
+ onProgress,
978
+ context
979
+ );
980
+ continue;
981
+ }
982
+
983
+ const movedChild = await moveItemToParent(
984
+ drive,
985
+ child,
986
+ sourceFolder.id,
987
+ targetFolder.id
988
+ );
989
+ removeFromChildState(sourceState, child.id);
990
+ addToChildState(targetState, movedChild);
991
+ stats.movedItems += 1;
992
+ onProgress?.({
993
+ type: "move",
994
+ itemType: "folder",
995
+ path: child.name,
996
+ sourceId: sourceFolder.id,
997
+ targetId: targetFolder.id,
998
+ });
999
+ continue;
1000
+ }
1001
+
1002
+ const existing = findMatchingChild(targetState, child.name);
1003
+
1004
+ if (!existing) {
1005
+ const movedChild = await moveItemToParent(
1006
+ drive,
1007
+ child,
1008
+ sourceFolder.id,
1009
+ targetFolder.id
1010
+ );
1011
+ removeFromChildState(sourceState, child.id);
1012
+ addToChildState(targetState, movedChild);
1013
+ stats.movedItems += 1;
1014
+ onProgress?.({
1015
+ type: "move",
1016
+ itemType: "file",
1017
+ path: child.name,
1018
+ sourceId: sourceFolder.id,
1019
+ targetId: targetFolder.id,
1020
+ });
1021
+ continue;
1022
+ }
1023
+
1024
+ if (
1025
+ existing.mimeType === FOLDER_MIME ||
1026
+ !filesHaveSameContent(existing, child)
1027
+ ) {
1028
+ stats.skippedConflicts += 1;
1029
+ onProgress?.({
1030
+ type: "skip_conflict",
1031
+ path: child.name,
1032
+ sourceId: sourceFolder.id,
1033
+ targetId: targetFolder.id,
1034
+ });
1035
+ continue;
1036
+ }
1037
+
1038
+ await trashFile(drive, child.id);
1039
+ removeFromChildState(sourceState, child.id);
1040
+ stats.trashedDuplicateFiles += 1;
1041
+ onProgress?.({
1042
+ type: "trash_duplicate_file",
1043
+ path: child.name,
1044
+ fileId: child.id,
1045
+ });
1046
+ }
1047
+ }
1048
+ );
1049
+
1050
+ if (sourceState.items.length === 0) {
1051
+ await trashFile(drive, sourceFolder.id);
1052
+ context.trashedFolderIds.add(sourceFolder.id);
1053
+ const parentId = sourceFolder.parents?.[0];
1054
+ if (parentId) {
1055
+ const parentState = await getChildState(
1056
+ drive,
1057
+ parentId,
1058
+ context.childStateCache
1059
+ );
1060
+ removeFromChildState(parentState, sourceFolder.id);
1061
+ }
1062
+ stats.trashedFolders += 1;
1063
+ onProgress?.({
1064
+ type: "trash_folder",
1065
+ path: sourceFolder.name,
1066
+ folderId: sourceFolder.id,
1067
+ });
1068
+ }
1069
+ }
1070
+
1071
+ export async function dedupeDuplicateFolders(
1072
+ drive,
1073
+ rootFolderId = null,
1074
+ { execute = false, onProgress = null, ignoreRules = null } = {}
1075
+ ) {
1076
+ const stats = {
1077
+ duplicatePaths: 0,
1078
+ movedItems: 0,
1079
+ skippedConflicts: 0,
1080
+ trashedDuplicateFiles: 0,
1081
+ trashedFolders: 0,
1082
+ };
1083
+
1084
+ const { folders } = await fetchAllItems(drive);
1085
+ const initialGroups = buildDuplicateFolderGroups(folders, rootFolderId, ignoreRules);
1086
+ stats.duplicatePaths = initialGroups.length;
1087
+
1088
+ if (!execute || initialGroups.length === 0) {
1089
+ return {
1090
+ ...stats,
1091
+ duplicateFolders: initialGroups,
1092
+ remainingDuplicateFolders: initialGroups,
1093
+ };
1094
+ }
1095
+ const executionGroups = [...initialGroups].sort((left, right) => {
1096
+ const depthDiff = folderPathDepth(right.path) - folderPathDepth(left.path);
1097
+ if (depthDiff !== 0) {
1098
+ return depthDiff;
1099
+ }
1100
+ return left.path.localeCompare(right.path);
1101
+ });
1102
+ const context = createDedupeContext();
1103
+
1104
+ // Partition groups by depth level for inter-group parallelism.
1105
+ // Groups at the same depth target different parent folders, so they
1106
+ // can be processed concurrently. Deepest-first ordering guarantees
1107
+ // sub-duplicates are resolved before parent-level merges begin.
1108
+ const byDepth = new Map();
1109
+ for (const group of executionGroups) {
1110
+ const depth = folderPathDepth(group.path);
1111
+ if (!byDepth.has(depth)) byDepth.set(depth, []);
1112
+ byDepth.get(depth).push(group);
1113
+ }
1114
+ const depths = [...byDepth.keys()].sort((a, b) => b - a);
1115
+
1116
+ for (const depth of depths) {
1117
+ const levelGroups = byDepth.get(depth);
1118
+ const tasks = levelGroups.map((group) => async () => {
1119
+ if (context.trashedFolderIds.has(group.canonical.id)) {
1120
+ return;
1121
+ }
1122
+ for (const loser of group.losers) {
1123
+ if (context.trashedFolderIds.has(loser.id)) {
1124
+ continue;
1125
+ }
1126
+ await mergeFolderIntoCanonical(
1127
+ drive,
1128
+ loser,
1129
+ group.canonical,
1130
+ stats,
1131
+ onProgress,
1132
+ context
1133
+ );
1134
+ }
1135
+ });
1136
+ await mapWithConcurrency(tasks, DEDUPE_BATCH_SIZE, (task) => task());
1137
+ }
1138
+
1139
+ const remainingDuplicateFolders = await findDuplicateFolders(drive, rootFolderId, ignoreRules);
1140
+
1141
+ return {
1142
+ ...stats,
1143
+ duplicateFolders: initialGroups,
1144
+ remainingDuplicateFolders,
1145
+ };
1146
+ }
1147
+
1148
+ export async function getRemoteItemMeta(drive, fileId) {
1149
+ if (!fileId || fileId === "root") {
1150
+ return {
1151
+ id: "root",
1152
+ name: "My Drive",
1153
+ capabilities: {
1154
+ canAddChildren: true,
1155
+ canEdit: true,
1156
+ },
1157
+ };
1158
+ }
1159
+
1160
+ const response = await drive.files.get({
1161
+ fileId,
1162
+ supportsAllDrives: true,
1163
+ fields:
1164
+ "id,name,mimeType,capabilities(canAddChildren,canEdit,canTrash,canDelete,canRename)",
1165
+ });
1166
+
1167
+ return response.data;
1168
+ }
1169
+
1170
+ async function assertParentWritable(drive, parentId, parentMetaCache = null) {
1171
+ let parentMetaPromise = parentMetaCache?.get(parentId);
1172
+ if (!parentMetaPromise) {
1173
+ parentMetaPromise = getRemoteItemMeta(drive, parentId);
1174
+ parentMetaCache?.set(parentId, parentMetaPromise);
1175
+ }
1176
+
1177
+ const parentMeta = await parentMetaPromise;
1178
+ if (parentMeta.capabilities?.canAddChildren === false) {
1179
+ throw new Error(
1180
+ `No permission to upload into "${parentMeta.name}". This folder does not allow adding children.`
1181
+ );
1182
+ }
1183
+ return parentMeta;
1184
+ }
1185
+
1186
+ async function uploadLocalFileToParent(
1187
+ drive,
1188
+ localPath,
1189
+ parentId,
1190
+ onProgress = null,
1191
+ context = null
1192
+ ) {
1193
+ const fileName = path.basename(localPath);
1194
+ await assertParentWritable(drive, parentId, context?.parentMetaCache);
1195
+ const indexedExisting = await getIndexedChild(
1196
+ drive,
1197
+ parentId,
1198
+ fileName,
1199
+ null,
1200
+ context?.childIndexCache
1201
+ );
1202
+ const existing =
1203
+ indexedExisting !== undefined
1204
+ ? indexedExisting
1205
+ : await findChildByName(drive, parentId, fileName);
1206
+
1207
+ if (
1208
+ existing &&
1209
+ existing.mimeType !== "application/vnd.google-apps.folder" &&
1210
+ existing.capabilities?.canEdit === false
1211
+ ) {
1212
+ throw new Error(
1213
+ `No permission to overwrite existing file "${fileName}" in the target folder.`
1214
+ );
1215
+ }
1216
+
1217
+ onProgress?.("upload", localPath, fileName);
1218
+ await uploadFile(drive, localPath, fileName, {
1219
+ parentId,
1220
+ existingId:
1221
+ existing && existing.mimeType !== "application/vnd.google-apps.folder"
1222
+ ? existing.id
1223
+ : null,
1224
+ });
1225
+
1226
+ return { uploadedFiles: 1, uploadedDirectories: 0 };
1227
+ }
1228
+
1229
+ async function uploadLocalDirectoryToParent(
1230
+ drive,
1231
+ localPath,
1232
+ parentId,
1233
+ onProgress = null,
1234
+ context = createUploadContext(localPath)
1235
+ ) {
1236
+ const directoryName = path.basename(localPath);
1237
+ await assertParentWritable(drive, parentId, context.parentMetaCache);
1238
+ const existingFolder = await resolveCanonicalFolder(
1239
+ drive,
1240
+ parentId,
1241
+ directoryName,
1242
+ false,
1243
+ { childIndexCache: context.childIndexCache }
1244
+ );
1245
+ let targetFolder = existingFolder;
1246
+
1247
+ if (!targetFolder) {
1248
+ onProgress?.("mkdir", localPath, directoryName);
1249
+ targetFolder = await resolveCanonicalFolder(
1250
+ drive,
1251
+ parentId,
1252
+ directoryName,
1253
+ true,
1254
+ { childIndexCache: context.childIndexCache }
1255
+ );
1256
+ }
1257
+
1258
+ const entries = (await fs.promises.readdir(localPath, { withFileTypes: true })).filter(
1259
+ (entry) => {
1260
+ if (entry.name.startsWith(".")) {
1261
+ return false;
1262
+ }
1263
+ return !shouldIgnoreUploadPath(
1264
+ path.join(localPath, entry.name),
1265
+ entry.isDirectory(),
1266
+ context
1267
+ );
1268
+ }
1269
+ );
1270
+ let uploadedFiles = 0;
1271
+ let uploadedDirectories = 1;
1272
+
1273
+ const directories = entries.filter((entry) => entry.isDirectory());
1274
+ const files = entries.filter((entry) => entry.isFile());
1275
+ const directoryResults = await mapWithConcurrency(
1276
+ directories,
1277
+ UPLOAD_BATCH_SIZE,
1278
+ async (entry) =>
1279
+ uploadLocalDirectoryToParent(
1280
+ drive,
1281
+ path.join(localPath, entry.name),
1282
+ targetFolder.id,
1283
+ onProgress,
1284
+ context
1285
+ )
1286
+ );
1287
+
1288
+ for (const nestedResult of directoryResults) {
1289
+ uploadedFiles += nestedResult.uploadedFiles;
1290
+ uploadedDirectories += nestedResult.uploadedDirectories;
1291
+ }
1292
+
1293
+ const fileResults = await mapWithConcurrency(
1294
+ files,
1295
+ UPLOAD_BATCH_SIZE,
1296
+ async (entry) =>
1297
+ uploadLocalFileToParent(
1298
+ drive,
1299
+ path.join(localPath, entry.name),
1300
+ targetFolder.id,
1301
+ onProgress,
1302
+ context
1303
+ )
1304
+ );
1305
+
1306
+ for (const fileResult of fileResults) {
1307
+ uploadedFiles += fileResult.uploadedFiles;
1308
+ }
1309
+
1310
+ return { uploadedFiles, uploadedDirectories };
1311
+ }
1312
+
1313
+ async function syncLocalDirectoryContentsToParent(
1314
+ drive,
1315
+ localPath,
1316
+ parentId,
1317
+ onProgress = null,
1318
+ context = createUploadContext(localPath)
1319
+ ) {
1320
+ const resolvedPath = path.resolve(localPath);
1321
+ await assertParentWritable(drive, parentId, context.parentMetaCache);
1322
+ const entries = (await fs.promises.readdir(resolvedPath, { withFileTypes: true })).filter(
1323
+ (entry) => {
1324
+ if (entry.name.startsWith(".")) {
1325
+ return false;
1326
+ }
1327
+ return !shouldIgnoreUploadPath(
1328
+ path.join(resolvedPath, entry.name),
1329
+ entry.isDirectory(),
1330
+ context
1331
+ );
1332
+ }
1333
+ );
1334
+ let uploadedFiles = 0;
1335
+ let uploadedDirectories = 0;
1336
+
1337
+ const directories = entries.filter((entry) => entry.isDirectory());
1338
+ const files = entries.filter((entry) => entry.isFile());
1339
+ const directoryResults = await mapWithConcurrency(
1340
+ directories,
1341
+ UPLOAD_BATCH_SIZE,
1342
+ async (entry) => {
1343
+ const childPath = path.join(resolvedPath, entry.name);
1344
+ let targetFolder = await resolveCanonicalFolder(
1345
+ drive,
1346
+ parentId,
1347
+ entry.name,
1348
+ false,
1349
+ { childIndexCache: context.childIndexCache }
1350
+ );
1351
+
1352
+ if (!targetFolder) {
1353
+ onProgress?.("mkdir", childPath, entry.name);
1354
+ targetFolder = await resolveCanonicalFolder(
1355
+ drive,
1356
+ parentId,
1357
+ entry.name,
1358
+ true,
1359
+ { childIndexCache: context.childIndexCache }
1360
+ );
1361
+ }
1362
+
1363
+ const nestedResult = await syncLocalDirectoryContentsToParent(
1364
+ drive,
1365
+ childPath,
1366
+ targetFolder.id,
1367
+ onProgress,
1368
+ context
1369
+ );
1370
+
1371
+ return {
1372
+ uploadedFiles: nestedResult.uploadedFiles,
1373
+ uploadedDirectories: nestedResult.uploadedDirectories + 1,
1374
+ };
1375
+ }
1376
+ );
1377
+
1378
+ for (const nestedResult of directoryResults) {
1379
+ uploadedFiles += nestedResult.uploadedFiles;
1380
+ uploadedDirectories += nestedResult.uploadedDirectories;
1381
+ }
1382
+
1383
+ const fileResults = await mapWithConcurrency(
1384
+ files,
1385
+ UPLOAD_BATCH_SIZE,
1386
+ async (entry) =>
1387
+ uploadLocalFileToParent(
1388
+ drive,
1389
+ path.join(resolvedPath, entry.name),
1390
+ parentId,
1391
+ onProgress,
1392
+ context
1393
+ )
1394
+ );
1395
+
1396
+ for (const fileResult of fileResults) {
1397
+ uploadedFiles += fileResult.uploadedFiles;
1398
+ }
1399
+
1400
+ return { uploadedFiles, uploadedDirectories };
1401
+ }
1402
+
1403
+ export async function uploadLocalEntry(
1404
+ drive,
1405
+ localPath,
1406
+ parentId = "root",
1407
+ onProgress = null
1408
+ ) {
1409
+ const resolvedPath = path.resolve(localPath);
1410
+ const stat = await fs.promises.stat(resolvedPath);
1411
+
1412
+ if (stat.isDirectory()) {
1413
+ return uploadLocalDirectoryToParent(drive, resolvedPath, parentId, onProgress);
1414
+ }
1415
+
1416
+ if (stat.isFile()) {
1417
+ return uploadLocalFileToParent(drive, resolvedPath, parentId, onProgress);
1418
+ }
1419
+
1420
+ throw new Error(`Unsupported local path type: ${resolvedPath}`);
1421
+ }
1422
+
1423
+ export async function syncLocalDirectoryToParent(
1424
+ drive,
1425
+ localPath,
1426
+ parentId = "root",
1427
+ onProgress = null
1428
+ ) {
1429
+ const resolvedPath = path.resolve(localPath);
1430
+ const stat = await fs.promises.stat(resolvedPath);
1431
+
1432
+ if (!stat.isDirectory()) {
1433
+ throw new Error(`Local path is not a directory: ${resolvedPath}`);
1434
+ }
1435
+
1436
+ return syncLocalDirectoryContentsToParent(
1437
+ drive,
1438
+ resolvedPath,
1439
+ parentId,
1440
+ onProgress
1441
+ );
1442
+ }