aethel 0.3.3 → 0.3.4
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/CHANGELOG.md +4 -0
- package/package.json +1 -1
- package/src/core/diff.js +52 -0
- package/src/core/drive-api.js +35 -0
- package/src/core/snapshot.js +62 -0
- package/src/core/staging.js +5 -0
- package/src/core/sync.js +39 -11
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/core/diff.js
CHANGED
|
@@ -87,6 +87,11 @@ function buildDiffResult(changes) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function remoteChanged(snapshotEntry, remoteEntry) {
|
|
90
|
+
// Folders don't change — only their existence matters
|
|
91
|
+
if (remoteEntry.isFolder || remoteEntry.mimeType === "application/vnd.google-apps.folder") {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
90
95
|
if (isWorkspaceType(remoteEntry.mimeType || "")) {
|
|
91
96
|
return snapshotEntry.modifiedTime !== remoteEntry.modifiedTime;
|
|
92
97
|
}
|
|
@@ -95,6 +100,8 @@ function remoteChanged(snapshotEntry, remoteEntry) {
|
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
function localChanged(snapshotEntry, localEntry) {
|
|
103
|
+
// Folders don't change — only their existence matters
|
|
104
|
+
if (localEntry.isFolder) return false;
|
|
98
105
|
return snapshotEntry.md5 !== localEntry.md5;
|
|
99
106
|
}
|
|
100
107
|
|
|
@@ -153,6 +160,21 @@ function promoteConflicts(changes) {
|
|
|
153
160
|
* @param {object} localFiles
|
|
154
161
|
* @param {{ root?: string, respectIgnore?: boolean }} options
|
|
155
162
|
*/
|
|
163
|
+
/**
|
|
164
|
+
* Collect all implicit folder paths from a set of file paths.
|
|
165
|
+
* e.g. "a/b/c.txt" → {"a", "a/b"}
|
|
166
|
+
*/
|
|
167
|
+
function collectFolderPaths(filePaths) {
|
|
168
|
+
const folders = new Set();
|
|
169
|
+
for (const p of filePaths) {
|
|
170
|
+
const parts = p.split("/");
|
|
171
|
+
for (let i = 1; i < parts.length; i++) {
|
|
172
|
+
folders.add(parts.slice(0, i).join("/"));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return folders;
|
|
176
|
+
}
|
|
177
|
+
|
|
156
178
|
export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIgnore = true } = {}) {
|
|
157
179
|
const ignoreRules = root && respectIgnore ? loadIgnoreRules(root) : null;
|
|
158
180
|
|
|
@@ -164,6 +186,19 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
164
186
|
const snapshotFiles = snapshot?.files || {};
|
|
165
187
|
const snapshotLocalFiles = snapshot?.localFiles || {};
|
|
166
188
|
|
|
189
|
+
// Build sets of all folder paths that implicitly exist on each side
|
|
190
|
+
// (from parent directories of files), so we can skip redundant folder additions.
|
|
191
|
+
const remoteFolderPaths = collectFolderPaths(remoteFiles.map((f) => f.path));
|
|
192
|
+
const localFolderPaths = collectFolderPaths(Object.keys(localFiles));
|
|
193
|
+
|
|
194
|
+
// Also include explicit folder entries
|
|
195
|
+
for (const f of remoteFiles) {
|
|
196
|
+
if (f.isFolder) remoteFolderPaths.add(f.path);
|
|
197
|
+
}
|
|
198
|
+
for (const [p, meta] of Object.entries(localFiles)) {
|
|
199
|
+
if (meta.isFolder) localFolderPaths.add(p);
|
|
200
|
+
}
|
|
201
|
+
|
|
167
202
|
// Build remote lookup and detect additions/modifications in one pass
|
|
168
203
|
const remoteById = new Map();
|
|
169
204
|
for (const remoteFile of remoteFiles) {
|
|
@@ -171,6 +206,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
171
206
|
const snapshotEntry = snapshotFiles[remoteFile.id];
|
|
172
207
|
|
|
173
208
|
if (!snapshotEntry) {
|
|
209
|
+
// Skip remote folder if it already exists locally (as parent or explicit dir)
|
|
210
|
+
if (remoteFile.isFolder && localFolderPaths.has(remoteFile.path)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
174
213
|
changes.push(
|
|
175
214
|
createChange({
|
|
176
215
|
changeType: ChangeType.REMOTE_ADDED,
|
|
@@ -199,6 +238,11 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
199
238
|
for (const fileId of Object.keys(snapshotFiles)) {
|
|
200
239
|
if (!remoteById.has(fileId)) {
|
|
201
240
|
const snapshotEntry = snapshotFiles[fileId];
|
|
241
|
+
// Skip folder deletion if the folder still implicitly exists on Drive
|
|
242
|
+
// (e.g. it became non-empty, or was recreated with a different ID)
|
|
243
|
+
if (snapshotEntry.isFolder && remoteFolderPaths.has(snapshotEntry.path || snapshotEntry.localPath || "")) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
202
246
|
changes.push(
|
|
203
247
|
createChange({
|
|
204
248
|
changeType: ChangeType.REMOTE_DELETED,
|
|
@@ -214,6 +258,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
214
258
|
const snapshotEntry = snapshotLocalFiles[relativePath];
|
|
215
259
|
|
|
216
260
|
if (!snapshotEntry) {
|
|
261
|
+
// Skip local folder if it already exists on Drive (as parent or explicit dir)
|
|
262
|
+
if (localMeta.isFolder && remoteFolderPaths.has(relativePath)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
217
265
|
changes.push(
|
|
218
266
|
createChange({
|
|
219
267
|
changeType: ChangeType.LOCAL_ADDED,
|
|
@@ -238,6 +286,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
238
286
|
|
|
239
287
|
for (const [relativePath, snapshotEntry] of Object.entries(snapshotLocalFiles)) {
|
|
240
288
|
if (!(relativePath in localFiles)) {
|
|
289
|
+
// Skip folder deletion if the folder still implicitly exists locally
|
|
290
|
+
if (snapshotEntry.isFolder && localFolderPaths.has(relativePath)) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
241
293
|
changes.push(
|
|
242
294
|
createChange({
|
|
243
295
|
changeType: ChangeType.LOCAL_DELETED,
|
package/src/core/drive-api.js
CHANGED
|
@@ -302,6 +302,10 @@ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
|
|
|
302
302
|
const resolve = createFolderResolver(folders, rootFolderId);
|
|
303
303
|
const isOrphaned = createOrphanChecker(folders);
|
|
304
304
|
const files = [];
|
|
305
|
+
|
|
306
|
+
// Track which folders have children (files or subfolders)
|
|
307
|
+
const foldersWithChildren = new Set();
|
|
308
|
+
|
|
305
309
|
for (const file of rawFiles) {
|
|
306
310
|
const parentId = file.parents?.[0] || "";
|
|
307
311
|
|
|
@@ -312,6 +316,9 @@ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
|
|
|
312
316
|
|
|
313
317
|
if (rootFolderId && parentPath === null) continue;
|
|
314
318
|
|
|
319
|
+
// Mark parent as having children
|
|
320
|
+
if (parentId) foldersWithChildren.add(parentId);
|
|
321
|
+
|
|
315
322
|
files.push({
|
|
316
323
|
id: file.id,
|
|
317
324
|
name: file.name,
|
|
@@ -322,6 +329,34 @@ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
|
|
|
322
329
|
md5Checksum: file.md5Checksum || null,
|
|
323
330
|
});
|
|
324
331
|
}
|
|
332
|
+
|
|
333
|
+
// Mark folders that have subfolders as children
|
|
334
|
+
for (const folder of folders.values()) {
|
|
335
|
+
const parentId = folder.parents?.[0] || "";
|
|
336
|
+
if (parentId) foldersWithChildren.add(parentId);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Include empty folders (no file children AND no subfolder children)
|
|
340
|
+
for (const folder of folders.values()) {
|
|
341
|
+
if (foldersWithChildren.has(folder.id)) continue;
|
|
342
|
+
if (isOrphaned(folder.id)) continue;
|
|
343
|
+
|
|
344
|
+
const folderPath = resolve(folder.id);
|
|
345
|
+
if (rootFolderId && folderPath === null) continue;
|
|
346
|
+
if (!folderPath) continue; // skip root-level marker
|
|
347
|
+
|
|
348
|
+
files.push({
|
|
349
|
+
id: folder.id,
|
|
350
|
+
name: folder.name,
|
|
351
|
+
path: folderPath,
|
|
352
|
+
mimeType: FOLDER_MIME,
|
|
353
|
+
size: null,
|
|
354
|
+
modifiedTime: folder.modifiedTime || null,
|
|
355
|
+
md5Checksum: null,
|
|
356
|
+
isFolder: true,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
325
360
|
return files;
|
|
326
361
|
}
|
|
327
362
|
|
package/src/core/snapshot.js
CHANGED
|
@@ -52,6 +52,9 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
52
52
|
|
|
53
53
|
// Phase 1: collect all file stats (fast — no hashing yet)
|
|
54
54
|
const filesToHash = [];
|
|
55
|
+
// Track directories and their child counts to detect empty folders
|
|
56
|
+
const dirChildCount = new Map();
|
|
57
|
+
const dirStats = new Map();
|
|
55
58
|
|
|
56
59
|
async function walk(currentPath) {
|
|
57
60
|
let entries;
|
|
@@ -61,8 +64,20 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
61
64
|
return;
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
const relativeDirPath = currentPath === resolvedRoot
|
|
68
|
+
? null
|
|
69
|
+
: path.relative(resolvedRoot, currentPath).split(path.sep).join("/");
|
|
70
|
+
|
|
71
|
+
// Register this directory (skip root itself)
|
|
72
|
+
if (relativeDirPath !== null) {
|
|
73
|
+
if (!dirChildCount.has(relativeDirPath)) {
|
|
74
|
+
dirChildCount.set(relativeDirPath, 0);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
const subdirs = [];
|
|
65
79
|
const statPromises = [];
|
|
80
|
+
let trackedChildren = 0;
|
|
66
81
|
|
|
67
82
|
for (const entry of entries) {
|
|
68
83
|
const fullPath = path.join(currentPath, entry.name);
|
|
@@ -76,6 +91,7 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
if (entry.isDirectory()) {
|
|
94
|
+
trackedChildren++;
|
|
79
95
|
subdirs.push(fullPath);
|
|
80
96
|
continue;
|
|
81
97
|
}
|
|
@@ -84,6 +100,7 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
84
100
|
continue;
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
trackedChildren++;
|
|
87
104
|
statPromises.push(
|
|
88
105
|
fs.promises.stat(fullPath).then((stat) => {
|
|
89
106
|
filesToHash.push({ fullPath, relativePath, stat });
|
|
@@ -91,6 +108,16 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
91
108
|
);
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
if (relativeDirPath !== null) {
|
|
112
|
+
dirChildCount.set(relativeDirPath, trackedChildren);
|
|
113
|
+
try {
|
|
114
|
+
const stat = await fs.promises.stat(currentPath);
|
|
115
|
+
dirStats.set(relativeDirPath, stat);
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
94
121
|
await Promise.all([
|
|
95
122
|
...statPromises,
|
|
96
123
|
...subdirs.map((dir) => walk(dir)),
|
|
@@ -121,6 +148,40 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
121
148
|
}
|
|
122
149
|
}
|
|
123
150
|
|
|
151
|
+
// Phase 3: detect empty folders (directories with zero tracked children)
|
|
152
|
+
// Walk bottom-up: a dir is "empty" if it has no tracked children AND
|
|
153
|
+
// all its subdirectories are also empty.
|
|
154
|
+
const emptyDirs = new Set();
|
|
155
|
+
// Sort by depth (deepest first) for bottom-up processing
|
|
156
|
+
const sortedDirs = [...dirChildCount.keys()].sort(
|
|
157
|
+
(a, b) => b.split("/").length - a.split("/").length
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
for (const dirPath of sortedDirs) {
|
|
161
|
+
const childCount = dirChildCount.get(dirPath);
|
|
162
|
+
if (childCount === 0) {
|
|
163
|
+
emptyDirs.add(dirPath);
|
|
164
|
+
// Propagate: decrement parent's tracked child count since this child is empty
|
|
165
|
+
const parentDir = dirPath.includes("/")
|
|
166
|
+
? dirPath.slice(0, dirPath.lastIndexOf("/"))
|
|
167
|
+
: null;
|
|
168
|
+
if (parentDir && dirChildCount.has(parentDir)) {
|
|
169
|
+
dirChildCount.set(parentDir, dirChildCount.get(parentDir) - 1);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const dirPath of emptyDirs) {
|
|
175
|
+
const stat = dirStats.get(dirPath);
|
|
176
|
+
result[dirPath] = {
|
|
177
|
+
localPath: dirPath,
|
|
178
|
+
isFolder: true,
|
|
179
|
+
size: 0,
|
|
180
|
+
md5: null,
|
|
181
|
+
modifiedTime: stat ? new Date(stat.mtimeMs).toISOString() : new Date().toISOString(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
124
185
|
// Persist updated cache
|
|
125
186
|
saveHashCache(resolvedRoot, nextCache);
|
|
126
187
|
return result;
|
|
@@ -158,6 +219,7 @@ export function buildSnapshot(remoteFiles, localFiles, message = "") {
|
|
|
158
219
|
mimeType: file.mimeType || "",
|
|
159
220
|
modifiedTime: file.modifiedTime ?? null,
|
|
160
221
|
localPath: file.path,
|
|
222
|
+
...(file.isFolder ? { isFolder: true } : {}),
|
|
161
223
|
};
|
|
162
224
|
}
|
|
163
225
|
|
package/src/core/staging.js
CHANGED
|
@@ -19,6 +19,11 @@ function changeToEntry(change) {
|
|
|
19
19
|
entry.remotePath = change.remoteMeta.path;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Propagate folder flag so sync knows to create folder instead of uploading file
|
|
23
|
+
if (change.localMeta?.isFolder || change.remoteMeta?.isFolder) {
|
|
24
|
+
entry.isFolder = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
return entry;
|
|
23
28
|
}
|
|
24
29
|
|
package/src/core/sync.js
CHANGED
|
@@ -20,6 +20,7 @@ export class CommitResult {
|
|
|
20
20
|
this.uploaded = 0;
|
|
21
21
|
this.deletedLocal = 0;
|
|
22
22
|
this.deletedRemote = 0;
|
|
23
|
+
this.foldersCreated = 0;
|
|
23
24
|
this.errors = [];
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -28,7 +29,8 @@ export class CommitResult {
|
|
|
28
29
|
this.downloaded +
|
|
29
30
|
this.uploaded +
|
|
30
31
|
this.deletedLocal +
|
|
31
|
-
this.deletedRemote
|
|
32
|
+
this.deletedRemote +
|
|
33
|
+
this.foldersCreated
|
|
32
34
|
);
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -41,6 +43,9 @@ export class CommitResult {
|
|
|
41
43
|
if (this.uploaded) {
|
|
42
44
|
parts.push(`${this.uploaded} uploaded`);
|
|
43
45
|
}
|
|
46
|
+
if (this.foldersCreated) {
|
|
47
|
+
parts.push(`${this.foldersCreated} folders created`);
|
|
48
|
+
}
|
|
44
49
|
if (this.deletedLocal) {
|
|
45
50
|
parts.push(`${this.deletedLocal} deleted locally`);
|
|
46
51
|
}
|
|
@@ -56,9 +61,16 @@ export class CommitResult {
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
async function downloadStagedFile(drive, entry, root) {
|
|
59
|
-
const fileId = entry.fileId;
|
|
60
64
|
const localRelativePath = entry.localPath || entry.path;
|
|
61
65
|
const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
|
|
66
|
+
|
|
67
|
+
// Empty folder: just create the directory locally
|
|
68
|
+
if (entry.isFolder) {
|
|
69
|
+
fs.mkdirSync(localAbsolutePath, { recursive: true });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fileId = entry.fileId;
|
|
62
74
|
const response = await drive.files.get({
|
|
63
75
|
fileId,
|
|
64
76
|
fields: "id,name,mimeType",
|
|
@@ -69,9 +81,16 @@ async function downloadStagedFile(drive, entry, root) {
|
|
|
69
81
|
|
|
70
82
|
async function uploadStagedFile(drive, entry, root, driveFolderId) {
|
|
71
83
|
const localRelativePath = entry.localPath || entry.path;
|
|
72
|
-
const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
|
|
73
84
|
const remotePath = entry.remotePath || entry.path;
|
|
74
85
|
|
|
86
|
+
// Empty folder: just ensure it exists on Drive
|
|
87
|
+
if (entry.isFolder) {
|
|
88
|
+
await ensureFolder(drive, remotePath, driveFolderId);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
|
|
93
|
+
|
|
75
94
|
if (!fs.existsSync(localAbsolutePath)) {
|
|
76
95
|
throw new Error(`Local file not found: ${localAbsolutePath}`);
|
|
77
96
|
}
|
|
@@ -97,18 +116,25 @@ async function deleteLocalFile(entry, root) {
|
|
|
97
116
|
return;
|
|
98
117
|
}
|
|
99
118
|
|
|
100
|
-
|
|
119
|
+
// Empty folder: remove the directory itself
|
|
120
|
+
if (entry.isFolder) {
|
|
121
|
+
await fs.promises.rmdir(localAbsolutePath).catch(() => {});
|
|
122
|
+
} else {
|
|
123
|
+
await fs.promises.unlink(localAbsolutePath);
|
|
124
|
+
}
|
|
101
125
|
|
|
102
|
-
|
|
126
|
+
// Clean up empty parent directories up to workspace root
|
|
127
|
+
let currentPath = entry.isFolder ? localAbsolutePath : path.dirname(localAbsolutePath);
|
|
103
128
|
const resolvedRoot = path.resolve(root);
|
|
104
129
|
|
|
105
130
|
while (currentPath !== resolvedRoot) {
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
try {
|
|
132
|
+
const contents = await fs.promises.readdir(currentPath);
|
|
133
|
+
if (contents.length > 0) break;
|
|
134
|
+
await fs.promises.rmdir(currentPath);
|
|
135
|
+
} catch {
|
|
108
136
|
break;
|
|
109
137
|
}
|
|
110
|
-
|
|
111
|
-
await fs.promises.rmdir(currentPath);
|
|
112
138
|
currentPath = path.dirname(currentPath);
|
|
113
139
|
}
|
|
114
140
|
}
|
|
@@ -195,10 +221,12 @@ export async function executeStaged(drive, root, progress) {
|
|
|
195
221
|
const action = entry.action;
|
|
196
222
|
if (action === "download") {
|
|
197
223
|
await downloadStagedFile(drive, entry, root);
|
|
198
|
-
result.
|
|
224
|
+
if (entry.isFolder) result.foldersCreated++;
|
|
225
|
+
else result.downloaded++;
|
|
199
226
|
} else if (action === "upload") {
|
|
200
227
|
await uploadStagedFile(drive, entry, root, driveFolderId);
|
|
201
|
-
result.
|
|
228
|
+
if (entry.isFolder) result.foldersCreated++;
|
|
229
|
+
else result.uploaded++;
|
|
202
230
|
} else if (action === "delete_remote") {
|
|
203
231
|
await deleteRemoteFile(drive, entry);
|
|
204
232
|
result.deletedRemote++;
|