aethel 0.2.6 → 0.3.1
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 +8 -0
- package/README.md +58 -8
- package/package.json +1 -1
- package/src/cli.js +92 -181
- package/src/core/auth.js +17 -5
- package/src/core/diff.js +7 -9
- package/src/core/drive-api.js +56 -13
- package/src/core/repository.js +309 -0
- package/src/core/snapshot.js +14 -3
- package/src/tui/app.js +530 -70
- package/src/tui/command-catalog.js +140 -0
- package/src/tui/commands.js +74 -0
- package/src/tui/index.js +12 -2
package/src/core/auth.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import fsSyncFallback from "node:fs";
|
|
3
3
|
import http from "node:http";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { URL } from "node:url";
|
|
7
7
|
import { google } from "googleapis";
|
|
8
8
|
import open from "open";
|
|
@@ -10,8 +10,7 @@ import open from "open";
|
|
|
10
10
|
const SCOPES = ["https://www.googleapis.com/auth/drive"];
|
|
11
11
|
const DEFAULT_CREDENTIALS_PATH = "credentials.json";
|
|
12
12
|
const DEFAULT_TOKEN_PATH = "token.json";
|
|
13
|
-
const
|
|
14
|
-
const PROJECT_ROOT = path.resolve(MODULE_DIR, "..", "..");
|
|
13
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "aethel");
|
|
15
14
|
const AUTH_TIMEOUT_MS = 120_000;
|
|
16
15
|
|
|
17
16
|
// ── Path resolution ─────────────────────────────────────────────────
|
|
@@ -22,7 +21,12 @@ function resolvePath(candidatePath, fallbackFileName) {
|
|
|
22
21
|
? candidatePath
|
|
23
22
|
: path.resolve(process.cwd(), candidatePath);
|
|
24
23
|
}
|
|
25
|
-
|
|
24
|
+
// Also check cwd before falling back to ~/.config/aethel/
|
|
25
|
+
const cwdPath = path.resolve(process.cwd(), fallbackFileName);
|
|
26
|
+
if (fsSyncFallback.existsSync(cwdPath)) {
|
|
27
|
+
return cwdPath;
|
|
28
|
+
}
|
|
29
|
+
return path.join(CONFIG_DIR, fallbackFileName);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export function resolveCredentialsPath(customPath) {
|
|
@@ -47,7 +51,15 @@ async function loadClientConfig(credentialsPath) {
|
|
|
47
51
|
raw = await fs.readFile(credentialsPath, "utf8");
|
|
48
52
|
} catch {
|
|
49
53
|
throw new Error(
|
|
50
|
-
`OAuth credentials file
|
|
54
|
+
`OAuth credentials file not found: ${credentialsPath}\n\n` +
|
|
55
|
+
"Setup steps:\n" +
|
|
56
|
+
" 1. Go to https://console.cloud.google.com/\n" +
|
|
57
|
+
" 2. Create a project and enable the Google Drive API\n" +
|
|
58
|
+
" 3. Create an OAuth 2.0 Client ID (Desktop application)\n" +
|
|
59
|
+
" 4. Download the credentials JSON and save it as:\n" +
|
|
60
|
+
` ${path.join(CONFIG_DIR, DEFAULT_CREDENTIALS_PATH)}\n\n` +
|
|
61
|
+
"Or pass a custom path:\n" +
|
|
62
|
+
" aethel auth --credentials /path/to/credentials.json"
|
|
51
63
|
);
|
|
52
64
|
}
|
|
53
65
|
|
package/src/core/diff.js
CHANGED
|
@@ -163,16 +163,12 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
163
163
|
const changes = [];
|
|
164
164
|
const snapshotFiles = snapshot?.files || {};
|
|
165
165
|
const snapshotLocalFiles = snapshot?.localFiles || {};
|
|
166
|
-
const snapshotById = new Map();
|
|
167
|
-
|
|
168
|
-
for (const [fileId, meta] of Object.entries(snapshotFiles)) {
|
|
169
|
-
snapshotById.set(fileId, meta);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const remoteById = new Map(remoteFiles.map((file) => [file.id, file]));
|
|
173
166
|
|
|
167
|
+
// Build remote lookup and detect additions/modifications in one pass
|
|
168
|
+
const remoteById = new Map();
|
|
174
169
|
for (const remoteFile of remoteFiles) {
|
|
175
|
-
|
|
170
|
+
remoteById.set(remoteFile.id, remoteFile);
|
|
171
|
+
const snapshotEntry = snapshotFiles[remoteFile.id];
|
|
176
172
|
|
|
177
173
|
if (!snapshotEntry) {
|
|
178
174
|
changes.push(
|
|
@@ -199,8 +195,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
199
195
|
}
|
|
200
196
|
}
|
|
201
197
|
|
|
202
|
-
|
|
198
|
+
// Detect remote deletions — snapshot entries missing from remote
|
|
199
|
+
for (const fileId of Object.keys(snapshotFiles)) {
|
|
203
200
|
if (!remoteById.has(fileId)) {
|
|
201
|
+
const snapshotEntry = snapshotFiles[fileId];
|
|
204
202
|
changes.push(
|
|
205
203
|
createChange({
|
|
206
204
|
changeType: ChangeType.REMOTE_DELETED,
|
package/src/core/drive-api.js
CHANGED
|
@@ -255,11 +255,59 @@ async function fetchAllItems(drive, { fields, includeSharedDrives = false } = {}
|
|
|
255
255
|
return { folders, files };
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Build a cached orphan checker. A folder is "orphaned" if its parent
|
|
260
|
+
* chain leads to a trashed/inaccessible folder rather than "root".
|
|
261
|
+
*
|
|
262
|
+
* When a folder is trashed, Google Drive does NOT mark its children as
|
|
263
|
+
* trashed. They remain `trashed = false` but their parent is gone from
|
|
264
|
+
* our fetched folder map, making them "orphaned".
|
|
265
|
+
*/
|
|
266
|
+
function createOrphanChecker(folders) {
|
|
267
|
+
const cache = new Map();
|
|
268
|
+
|
|
269
|
+
return function isOrphaned(folderId) {
|
|
270
|
+
if (cache.has(folderId)) return cache.get(folderId);
|
|
271
|
+
|
|
272
|
+
// Walk the chain, collecting unresolved IDs
|
|
273
|
+
const pending = [];
|
|
274
|
+
let current = folderId;
|
|
275
|
+
|
|
276
|
+
while (current) {
|
|
277
|
+
if (current === "root") {
|
|
278
|
+
for (const id of pending) cache.set(id, false);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (cache.has(current)) {
|
|
282
|
+
const result = cache.get(current);
|
|
283
|
+
for (const id of pending) cache.set(id, result);
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
pending.push(current);
|
|
287
|
+
const folder = folders.get(current);
|
|
288
|
+
if (!folder) {
|
|
289
|
+
for (const id of pending) cache.set(id, true);
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
current = folder.parents?.[0] || "";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// No parent — root-level
|
|
296
|
+
for (const id of pending) cache.set(id, false);
|
|
297
|
+
return false;
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
258
301
|
function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
|
|
259
302
|
const resolve = createFolderResolver(folders, rootFolderId);
|
|
303
|
+
const isOrphaned = createOrphanChecker(folders);
|
|
260
304
|
const files = [];
|
|
261
305
|
for (const file of rawFiles) {
|
|
262
306
|
const parentId = file.parents?.[0] || "";
|
|
307
|
+
|
|
308
|
+
// Skip files whose parent folder was trashed (orphaned children)
|
|
309
|
+
if (parentId && isOrphaned(parentId)) continue;
|
|
310
|
+
|
|
263
311
|
const parentPath = parentId === rootFolderId ? "" : resolve(parentId);
|
|
264
312
|
|
|
265
313
|
if (rootFolderId && parentPath === null) continue;
|
|
@@ -646,26 +694,21 @@ export async function listAccessibleFiles(drive, includeSharedDrives = false) {
|
|
|
646
694
|
includeSharedDrives,
|
|
647
695
|
});
|
|
648
696
|
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
function resolveFolderPath(folderId) {
|
|
652
|
-
if (pathCache.has(folderId)) return pathCache.get(folderId);
|
|
653
|
-
if (!folderId) { pathCache.set(folderId, ""); return ""; }
|
|
654
|
-
const folder = folders.get(folderId);
|
|
655
|
-
if (!folder) { pathCache.set(folderId, ""); return ""; }
|
|
656
|
-
const parentPath = resolveFolderPath(folder.parents?.[0] || "");
|
|
657
|
-
const result = parentPath ? path.posix.join(parentPath, folder.name) : folder.name;
|
|
658
|
-
pathCache.set(folderId, result);
|
|
659
|
-
return result;
|
|
660
|
-
}
|
|
697
|
+
const resolveFolderPath = createFolderResolver(folders, null);
|
|
698
|
+
const isOrphaned = createOrphanChecker(folders);
|
|
661
699
|
|
|
662
700
|
// Combine folders + files into result list (TUI needs folders too)
|
|
701
|
+
// Filter out orphaned items (children of trashed folders)
|
|
663
702
|
const allItems = [...folders.values(), ...rawItems];
|
|
664
703
|
const result = [];
|
|
665
704
|
|
|
666
705
|
for (const file of allItems) {
|
|
667
706
|
const parentId = file.parents?.[0] || "";
|
|
668
|
-
|
|
707
|
+
|
|
708
|
+
// Skip orphaned items (children of trashed folders)
|
|
709
|
+
if (parentId && isOrphaned(parentId)) continue;
|
|
710
|
+
|
|
711
|
+
const parentPath = resolveFolderPath(parentId) || "";
|
|
669
712
|
const itemPath = parentPath
|
|
670
713
|
? path.posix.join(parentPath, file.name)
|
|
671
714
|
: file.name;
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository — unified data-access layer for Aethel workspaces.
|
|
3
|
+
*
|
|
4
|
+
* Wraps all core modules behind a single interface so that both
|
|
5
|
+
* the CLI and the TUI share the same entry point for state loading,
|
|
6
|
+
* staging, syncing, file browsing, and history.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { authenticate } from "./auth.js";
|
|
12
|
+
import {
|
|
13
|
+
AETHEL_DIR,
|
|
14
|
+
HISTORY_DIR,
|
|
15
|
+
LATEST_SNAPSHOT,
|
|
16
|
+
SNAPSHOTS_DIR,
|
|
17
|
+
readConfig,
|
|
18
|
+
readLatestSnapshot,
|
|
19
|
+
writeConfig,
|
|
20
|
+
writeSnapshot,
|
|
21
|
+
} from "./config.js";
|
|
22
|
+
import { computeDiff } from "./diff.js";
|
|
23
|
+
import {
|
|
24
|
+
assertNoDuplicateFolders,
|
|
25
|
+
batchOperateFiles,
|
|
26
|
+
getAccountInfo,
|
|
27
|
+
getRemoteState,
|
|
28
|
+
listAccessibleFiles,
|
|
29
|
+
syncLocalDirectoryToParent,
|
|
30
|
+
uploadLocalEntry,
|
|
31
|
+
withDriveRetry,
|
|
32
|
+
} from "./drive-api.js";
|
|
33
|
+
import {
|
|
34
|
+
invalidateRemoteCache,
|
|
35
|
+
readRemoteCache,
|
|
36
|
+
writeRemoteCache,
|
|
37
|
+
} from "./remote-cache.js";
|
|
38
|
+
import { buildSnapshot, scanLocal } from "./snapshot.js";
|
|
39
|
+
import {
|
|
40
|
+
stageChange,
|
|
41
|
+
stageChanges,
|
|
42
|
+
stageConflictResolution,
|
|
43
|
+
stagedEntries,
|
|
44
|
+
unstageAll,
|
|
45
|
+
unstagePath,
|
|
46
|
+
} from "./staging.js";
|
|
47
|
+
import { executeStaged } from "./sync.js";
|
|
48
|
+
import {
|
|
49
|
+
deleteLocalEntry,
|
|
50
|
+
listLocalEntries,
|
|
51
|
+
renameLocalEntry,
|
|
52
|
+
} from "./local-fs.js";
|
|
53
|
+
|
|
54
|
+
export class Repository {
|
|
55
|
+
/**
|
|
56
|
+
* @param {string|null} root Workspace root (null for workspace-less commands like auth/clean)
|
|
57
|
+
* @param {object} [options]
|
|
58
|
+
* @param {object} [options.drive] Pre-authenticated drive instance (skips auth)
|
|
59
|
+
* @param {string} [options.credentials] Path to OAuth credentials JSON
|
|
60
|
+
* @param {string} [options.token] Path to OAuth token JSON
|
|
61
|
+
*/
|
|
62
|
+
constructor(root, options = {}) {
|
|
63
|
+
this._root = root;
|
|
64
|
+
this._options = options;
|
|
65
|
+
this._drive = options.drive || null;
|
|
66
|
+
this._config = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get root() {
|
|
70
|
+
return this._root;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get isConnected() {
|
|
74
|
+
return this._drive !== null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get drive() {
|
|
78
|
+
if (!this._drive) {
|
|
79
|
+
throw new Error("Repository is not connected. Call connect() first.");
|
|
80
|
+
}
|
|
81
|
+
return this._drive;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Authenticate and prepare the drive connection. Idempotent. */
|
|
85
|
+
async connect() {
|
|
86
|
+
if (this._drive) return;
|
|
87
|
+
const raw = await authenticate(this._options.credentials, this._options.token);
|
|
88
|
+
this._drive = withDriveRetry(raw);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Config ──────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
getConfig() {
|
|
94
|
+
if (!this._config) {
|
|
95
|
+
this._config = readConfig(this._root);
|
|
96
|
+
}
|
|
97
|
+
return this._config;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setConfig(data) {
|
|
101
|
+
writeConfig(this._root, data);
|
|
102
|
+
this._config = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── State loading (sync workflow) ───────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Load full workspace state in parallel, replacing the old
|
|
109
|
+
* loadWorkspaceState() helper from cli.js.
|
|
110
|
+
*/
|
|
111
|
+
async loadState({ useCache = true } = {}) {
|
|
112
|
+
const config = this.getConfig();
|
|
113
|
+
|
|
114
|
+
const [local, snapshot] = await Promise.all([
|
|
115
|
+
scanLocal(this._root),
|
|
116
|
+
Promise.resolve(readLatestSnapshot(this._root)),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
const remoteState = await this._loadRemoteState({ useCache });
|
|
120
|
+
const remote = remoteState.files;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
config,
|
|
124
|
+
remote,
|
|
125
|
+
local,
|
|
126
|
+
snapshot,
|
|
127
|
+
diff: computeDiff(snapshot, remote, local, { root: this._root }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async getRemoteState({ useCache = true } = {}) {
|
|
132
|
+
return this._loadRemoteState({ useCache });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async scanLocal() {
|
|
136
|
+
return scanLocal(this._root);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getSnapshot() {
|
|
140
|
+
return readLatestSnapshot(this._root);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
computeDiff(snapshot, remote, local) {
|
|
144
|
+
return computeDiff(snapshot, remote, local, { root: this._root });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Staging ─────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
getStagedEntries() {
|
|
150
|
+
return stagedEntries(this._root);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
stageChange(change) {
|
|
154
|
+
return stageChange(this._root, change);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
stageChanges(changes) {
|
|
158
|
+
return stageChanges(this._root, changes);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
unstagePath(targetPath) {
|
|
162
|
+
return unstagePath(this._root, targetPath);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
unstageAll() {
|
|
166
|
+
return unstageAll(this._root);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
stageConflictResolution(change, strategy) {
|
|
170
|
+
return stageConflictResolution(this._root, change, strategy);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Sync execution ──────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async executeStaged(progress) {
|
|
176
|
+
return executeStaged(this.drive, this._root, progress);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Invalidate cache, re-fetch remote + re-scan local, write snapshot.
|
|
181
|
+
*/
|
|
182
|
+
async saveSnapshot(message = "sync") {
|
|
183
|
+
const config = this.getConfig();
|
|
184
|
+
const rootFolderId = config.drive_folder_id || null;
|
|
185
|
+
|
|
186
|
+
invalidateRemoteCache(this._root);
|
|
187
|
+
const [remoteState, local] = await Promise.all([
|
|
188
|
+
getRemoteState(this.drive, rootFolderId),
|
|
189
|
+
scanLocal(this._root),
|
|
190
|
+
]);
|
|
191
|
+
assertNoDuplicateFolders(remoteState.duplicateFolders);
|
|
192
|
+
writeRemoteCache(this._root, remoteState, rootFolderId);
|
|
193
|
+
writeSnapshot(this._root, buildSnapshot(remoteState.files, local, message));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Cache management ────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
invalidateRemoteCache() {
|
|
199
|
+
invalidateRemoteCache(this._root);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── File browser (TUI) ─────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async listRemoteFiles({ includeSharedDrives = false } = {}) {
|
|
205
|
+
return listAccessibleFiles(this.drive, includeSharedDrives);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async listLocalEntries(targetPath) {
|
|
209
|
+
return listLocalEntries(targetPath);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async deleteLocalEntry(targetPath) {
|
|
213
|
+
return deleteLocalEntry(targetPath);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async renameLocalEntry(targetPath, newName) {
|
|
217
|
+
return renameLocalEntry(targetPath, newName);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async uploadLocalEntry(localPath, parentId, onProgress) {
|
|
221
|
+
return uploadLocalEntry(this.drive, localPath, parentId, onProgress);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async syncLocalDirectory(localPath, parentId, onProgress) {
|
|
225
|
+
return syncLocalDirectoryToParent(this.drive, localPath, parentId, onProgress);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async batchOperateFiles(files, options) {
|
|
229
|
+
return batchOperateFiles(this.drive, files, options);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async getAccountInfo() {
|
|
233
|
+
return getAccountInfo(this.drive);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── History ─────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
getHistory(limit = 10) {
|
|
239
|
+
const snapshotsPath = path.join(this._root, AETHEL_DIR, SNAPSHOTS_DIR);
|
|
240
|
+
const entries = [];
|
|
241
|
+
|
|
242
|
+
const latestPath = path.join(snapshotsPath, LATEST_SNAPSHOT);
|
|
243
|
+
if (fs.existsSync(latestPath)) {
|
|
244
|
+
entries.push(JSON.parse(fs.readFileSync(latestPath, "utf8")));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const historyPath = path.join(snapshotsPath, HISTORY_DIR);
|
|
248
|
+
if (fs.existsSync(historyPath)) {
|
|
249
|
+
const historyFiles = fs
|
|
250
|
+
.readdirSync(historyPath)
|
|
251
|
+
.filter((f) => f.endsWith(".json"))
|
|
252
|
+
.sort()
|
|
253
|
+
.reverse();
|
|
254
|
+
|
|
255
|
+
for (const fileName of historyFiles) {
|
|
256
|
+
entries.push(
|
|
257
|
+
JSON.parse(fs.readFileSync(path.join(historyPath, fileName), "utf8"))
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return entries.slice(0, limit);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
getSnapshotByRef(ref) {
|
|
266
|
+
if (!ref || ref === "HEAD" || ref === "latest") {
|
|
267
|
+
return readLatestSnapshot(this._root);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const historyPath = path.join(
|
|
271
|
+
this._root,
|
|
272
|
+
AETHEL_DIR,
|
|
273
|
+
SNAPSHOTS_DIR,
|
|
274
|
+
HISTORY_DIR
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (!fs.existsSync(historyPath)) return null;
|
|
278
|
+
|
|
279
|
+
const files = fs
|
|
280
|
+
.readdirSync(historyPath)
|
|
281
|
+
.filter((f) => f.endsWith(".json"))
|
|
282
|
+
.sort()
|
|
283
|
+
.reverse();
|
|
284
|
+
|
|
285
|
+
const match = files.find((f) => f.startsWith(ref));
|
|
286
|
+
if (!match) return null;
|
|
287
|
+
|
|
288
|
+
return JSON.parse(fs.readFileSync(path.join(historyPath, match), "utf-8"));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Private helpers ─────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
async _loadRemoteState({ useCache = true } = {}) {
|
|
294
|
+
const config = this.getConfig();
|
|
295
|
+
const rootFolderId = config.drive_folder_id || null;
|
|
296
|
+
|
|
297
|
+
let remoteState = useCache
|
|
298
|
+
? readRemoteCache(this._root, rootFolderId)
|
|
299
|
+
: null;
|
|
300
|
+
|
|
301
|
+
if (!remoteState) {
|
|
302
|
+
remoteState = await getRemoteState(this.drive, rootFolderId);
|
|
303
|
+
writeRemoteCache(this._root, remoteState, rootFolderId);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
assertNoDuplicateFolders(remoteState.duplicateFolders);
|
|
307
|
+
return remoteState;
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/core/snapshot.js
CHANGED
|
@@ -61,6 +61,9 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
const subdirs = [];
|
|
65
|
+
const statPromises = [];
|
|
66
|
+
|
|
64
67
|
for (const entry of entries) {
|
|
65
68
|
const fullPath = path.join(currentPath, entry.name);
|
|
66
69
|
const relativePath = path
|
|
@@ -73,7 +76,7 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
if (entry.isDirectory()) {
|
|
76
|
-
|
|
79
|
+
subdirs.push(fullPath);
|
|
77
80
|
continue;
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -81,9 +84,17 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
81
84
|
continue;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
statPromises.push(
|
|
88
|
+
fs.promises.stat(fullPath).then((stat) => {
|
|
89
|
+
filesToHash.push({ fullPath, relativePath, stat });
|
|
90
|
+
})
|
|
91
|
+
);
|
|
86
92
|
}
|
|
93
|
+
|
|
94
|
+
await Promise.all([
|
|
95
|
+
...statPromises,
|
|
96
|
+
...subdirs.map((dir) => walk(dir)),
|
|
97
|
+
]);
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
await walk(resolvedRoot);
|