Bump to v0.2.1; fix cross-folder rename data loss + surface delete errors

1. Cross-folder rename: handlePush now calls drive.moveFile when op='create'
   and a cached driveId exists (index.rename already ran). Previously,
   updateFile only updated content — the Drive file stayed in the old parent
   folder and was deleted when that folder was trashed.
   New DriveApi.moveFile uses addParents/removeParents to atomically relocate
   the file on Drive.

2. handleDelete no longer swallows Drive API errors silently. Failures are
   now surfaced in result.errors and the index entry is preserved so the
   next sync can retry, instead of orphaning the Drive file forever.

3. getChanges fields now include 'size' and confirm 'trashed' is requested
   (aligns compiled JS with existing TS source).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ml2310 2026-05-01 18:09:51 +08:00
parent 4104a479da
commit 0e2ffe2e79
8 changed files with 205 additions and 15 deletions

23
main.js
View file

@ -158,6 +158,10 @@ var DriveApi = class {
const { id } = resp.json;
return id;
}
async moveFile(driveId, oldParentId, newParentId) {
const params = new URLSearchParams({ addParents: newParentId, removeParents: oldParentId, fields: "id" });
await this.request("PATCH", `${BASE}/files/${driveId}?${params}`);
}
async renameFile(driveId, newName) {
await this.request(
"PATCH",
@ -191,7 +195,7 @@ var DriveApi = class {
pageToken: token,
pageSize: "1000",
includeRemoved: "true",
fields: "nextPageToken,newStartPageToken,changes(fileId,removed,file(id,name,mimeType,modifiedTime,trashed))"
fields: "nextPageToken,newStartPageToken,changes(fileId,removed,file(id,name,mimeType,modifiedTime,size,trashed))"
});
const resp = await this.request("GET", `${BASE}/changes?${params}`);
const data = resp.json;
@ -756,6 +760,14 @@ var Syncer = class {
const mimeType = fromPath(path);
const cached2 = this.index.get(path);
if (cached2 && !cached2.isFolder) {
if (op === "create") {
const targetParentId = await this.index.resolveParentFolder(path);
const meta = await this.drive.getFileMeta(cached2.driveId);
const currentParentId = meta.parents?.[0];
if (currentParentId && currentParentId !== targetParentId) {
await this.drive.moveFile(cached2.driveId, currentParentId, targetParentId);
}
}
await this.drive.updateFile(cached2.driveId, bytes, mimeType, mtime, this.settings.keepRevisions);
this.index.set(path, { ...cached2, driveMtime: mtime, syncedAt: Date.now() });
} else {
@ -777,11 +789,14 @@ var Syncer = class {
if (cached2) {
try {
await this.drive.deleteFile(cached2.driveId);
} catch (e) {
this.index.delete(path);
result.deleted.push(path);
} catch (err) {
result.errors.push({ path, error: `Drive delete failed: ${err instanceof Error ? err.message : String(err)}` });
}
this.index.delete(path);
} else {
result.deleted.push(path);
}
result.deleted.push(path);
}
async handleConflict(path, driveMtime, result) {
const entry = this.index.get(path);

View file

@ -1,7 +1,7 @@
{
"id": "neogdsync",
"name": "NeoGDSync",
"version": "0.2.0",
"version": "0.2.1",
"minAppVersion": "1.6.0",
"description": "Lightweight Google Drive sync with path-based index, conflict detection, and smart/push/pull modes.",
"author": "LM",

View file

@ -1,6 +1,6 @@
{
"name": "neogdsync",
"version": "0.2.0",
"version": "0.2.1",
"description": "",
"main": "index.js",
"scripts": {

140
src/driveApi.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -115,6 +115,11 @@ export class DriveApi {
return id;
}
async moveFile(driveId: string, oldParentId: string, newParentId: string): Promise<void> {
const params = new URLSearchParams({ addParents: newParentId, removeParents: oldParentId, fields: 'id' });
await this.request('PATCH', `${BASE}/files/${driveId}?${params}`);
}
async renameFile(driveId: string, newName: string): Promise<void> {
await this.request(
'PATCH',
@ -156,7 +161,7 @@ export class DriveApi {
pageToken: token,
pageSize: '1000',
includeRemoved: 'true',
fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file(id,name,mimeType,modifiedTime,trashed))',
fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file(id,name,mimeType,modifiedTime,size,trashed))',
});
const resp = await this.request('GET', `${BASE}/changes?${params}`);
const data = resp.json as {

View file

@ -182,6 +182,14 @@ export class Syncer {
const mimeType = mime.fromPath(path);
const cached = this.index.get(path);
if (cached && !cached.isFolder) {
if (op === 'create') {
const targetParentId = await this.index.resolveParentFolder(path);
const meta = await this.drive.getFileMeta(cached.driveId);
const currentParentId = meta.parents?.[0];
if (currentParentId && currentParentId !== targetParentId) {
await this.drive.moveFile(cached.driveId, currentParentId, targetParentId);
}
}
await this.drive.updateFile(cached.driveId, bytes, mimeType, mtime, this.settings.keepRevisions);
this.index.set(path, { ...cached, driveMtime: mtime, syncedAt: Date.now() });
}
@ -197,11 +205,16 @@ export class Syncer {
if (cached) {
try {
await this.drive.deleteFile(cached.driveId);
this.index.delete(path);
result.deleted.push(path);
}
catch (err) {
result.errors.push({ path, error: `Drive delete failed: ${err instanceof Error ? err.message : String(err)}` });
}
catch ( /* already gone */_a) { /* already gone */ }
this.index.delete(path);
}
result.deleted.push(path);
else {
result.deleted.push(path);
}
}
async handleConflict(path, driveMtime, result) {
const entry = this.index.get(path);

View file

@ -80,7 +80,7 @@ export class Syncer {
for (const c of changes) {
const localPath = driveIdToPath.get(c.fileId);
if (localPath) {
driveChanged.set(localPath, { removed: c.removed, mtime: c.file?.modifiedTime });
driveChanged.set(localPath, { removed: c.removed || c.file?.trashed === true, mtime: c.file?.modifiedTime });
}
}
@ -267,6 +267,16 @@ export class Syncer {
const mimeType = mime.fromPath(path);
const cached = this.index.get(path);
if (cached && !cached.isFolder) {
// Cross-folder rename: 'create' op + existing driveId means index.rename already ran.
// Drive.updateFile only updates content — we must also move the file to the new parent.
if (op === 'create') {
const targetParentId = await this.index.resolveParentFolder(path);
const meta = await this.drive.getFileMeta(cached.driveId);
const currentParentId = meta.parents?.[0];
if (currentParentId && currentParentId !== targetParentId) {
await this.drive.moveFile(cached.driveId, currentParentId, targetParentId);
}
}
await this.drive.updateFile(cached.driveId, bytes, mimeType, mtime, this.settings.keepRevisions);
this.index.set(path, { ...cached, driveMtime: mtime, syncedAt: Date.now() });
} else {
@ -282,10 +292,16 @@ export class Syncer {
private async handleDelete(path: string, result: SyncResult): Promise<void> {
const cached = this.index.get(path);
if (cached) {
try { await this.drive.deleteFile(cached.driveId); } catch { /* already gone */ }
this.index.delete(path);
try {
await this.drive.deleteFile(cached.driveId);
this.index.delete(path);
result.deleted.push(path);
} catch (err: unknown) {
result.errors.push({ path, error: `Drive delete failed: ${err instanceof Error ? err.message : String(err)}` });
}
} else {
result.deleted.push(path);
}
result.deleted.push(path);
}
private async handleConflict(path: string, driveMtime: string, result: SyncResult): Promise<void> {

View file

@ -8,5 +8,6 @@
"0.1.18": "1.6.0",
"0.1.19": "1.6.0",
"0.1.20": "1.6.0",
"0.2.0": "1.6.0"
"0.2.0": "1.6.0",
"0.2.1": "1.6.0"
}