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.
- package/.env.example +2 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/docs/ARCHITECTURE.md +237 -0
- package/package.json +60 -0
- package/src/cli.js +1063 -0
- package/src/core/auth.js +288 -0
- package/src/core/config.js +117 -0
- package/src/core/diff.js +254 -0
- package/src/core/drive-api.js +1442 -0
- package/src/core/ignore.js +146 -0
- package/src/core/local-fs.js +109 -0
- package/src/core/remote-cache.js +65 -0
- package/src/core/snapshot.js +159 -0
- package/src/core/staging.js +125 -0
- package/src/core/sync.js +227 -0
- package/src/tui/app.js +1025 -0
- package/src/tui/index.js +10 -0
|
@@ -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
|
+
}
|