feat: dropbox sync for filesystem content
This commit is contained in:
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"npx vitest": true
|
||||||
|
}
|
||||||
|
}
|
||||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -20,8 +20,10 @@
|
|||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
|
"chokidar": "^5.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.29.0",
|
"drizzle-orm": "^0.29.0",
|
||||||
|
"dropbox": "^10.34.0",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/chokidar": "^1.7.5",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
@@ -4401,6 +4404,17 @@
|
|||||||
"@types/responselike": "^1.0.0"
|
"@types/responselike": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chokidar": {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chokidar/-/chokidar-1.7.5.tgz",
|
||||||
|
"integrity": "sha512-PDkSRY7KltW3M60hSBlerxI8SFPXsO3AL/aRVsO4Kh9IHRW74Ih75gUuTd/aE4LSSFqypb10UIX3QzOJwBQMGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/events": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -4418,6 +4432,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/events": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/fs-extra": {
|
"node_modules/@types/fs-extra": {
|
||||||
"version": "9.0.13",
|
"version": "9.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
|
||||||
@@ -4488,6 +4509,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"form-data": "^4.0.4"
|
"form-data": "^4.0.4"
|
||||||
@@ -5667,6 +5689,21 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chokidar": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chownr": {
|
"node_modules/chownr": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||||
@@ -6783,6 +6820,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dropbox": {
|
||||||
|
"version": "10.34.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz",
|
||||||
|
"integrity": "sha512-5jb5/XzU0fSnq36/hEpwT5/QIep7MgqKuxghEG44xCu7HruOAjPdOb3x0geXv5O/hd0nHpQpWO+r5MjYTpMvJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node-fetch": "^2.5.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dropbox/node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -8657,7 +8729,6 @@
|
|||||||
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
|
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.31",
|
"@acemir/cssom": "^0.9.31",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||||
@@ -10400,6 +10471,19 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readdirp": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/chokidar": "^1.7.5",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
@@ -62,8 +63,10 @@
|
|||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
|
"chokidar": "^5.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.29.0",
|
"drizzle-orm": "^0.29.0",
|
||||||
|
"dropbox": "^10.34.0",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
|
|||||||
873
src/main/engine/DropboxSyncEngine.ts
Normal file
873
src/main/engine/DropboxSyncEngine.ts
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { Dropbox } from 'dropbox';
|
||||||
|
import { watch as chokidarWatch, type FSWatcher } from 'chokidar';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Types & Interfaces
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface DropboxSyncConfig {
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
appKey: string;
|
||||||
|
appSecret?: string;
|
||||||
|
syncEnabled: boolean;
|
||||||
|
syncInterval: number; // seconds between remote polls
|
||||||
|
localPostsDir: string;
|
||||||
|
localMediaDir: string;
|
||||||
|
remoteBasePath?: string; // e.g., '/bds' or '' for app folder root
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DropboxSyncStatus = 'idle' | 'syncing' | 'watching' | 'error' | 'unauthorized';
|
||||||
|
|
||||||
|
export type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual';
|
||||||
|
|
||||||
|
export interface DropboxConflict {
|
||||||
|
id: string;
|
||||||
|
localPath: string;
|
||||||
|
remotePath: string;
|
||||||
|
localModified: Date;
|
||||||
|
remoteModified: Date;
|
||||||
|
localHash: string;
|
||||||
|
remoteHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropboxRemoteChange {
|
||||||
|
tag: 'file' | 'folder' | 'deleted';
|
||||||
|
name: string;
|
||||||
|
pathLower: string;
|
||||||
|
contentHash?: string;
|
||||||
|
serverModified?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropboxChangesResult {
|
||||||
|
entries: DropboxRemoteChange[];
|
||||||
|
cursor: string;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadResult {
|
||||||
|
remotePath: string;
|
||||||
|
contentHash: string;
|
||||||
|
serverModified: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDownloadResult {
|
||||||
|
localPath: string;
|
||||||
|
remotePath: string;
|
||||||
|
contentHash: string;
|
||||||
|
serverModified: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSyncResult {
|
||||||
|
success: boolean;
|
||||||
|
uploaded: number;
|
||||||
|
downloaded: number;
|
||||||
|
deleted: number;
|
||||||
|
conflicts: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DropboxSyncEngine
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class DropboxSyncEngine extends EventEmitter {
|
||||||
|
private status: DropboxSyncStatus = 'idle';
|
||||||
|
private config: DropboxSyncConfig | null = null;
|
||||||
|
private dropboxClient: Dropbox;
|
||||||
|
private watchFn: typeof chokidarWatch;
|
||||||
|
private watcher: FSWatcher | null = null;
|
||||||
|
private pollIntervalId: NodeJS.Timeout | null = null;
|
||||||
|
private pendingConflicts: Map<string, DropboxConflict> = new Map();
|
||||||
|
private cursor: string | null = null;
|
||||||
|
private lastSyncTime: Date | null = null;
|
||||||
|
private isSyncing = false;
|
||||||
|
|
||||||
|
// Debounce tracking for local file changes
|
||||||
|
private pendingUploads: Map<string, NodeJS.Timeout> = new Map();
|
||||||
|
private uploadDebounceMs = 1000;
|
||||||
|
|
||||||
|
// Track files we wrote ourselves (to ignore watcher events)
|
||||||
|
private recentDownloads: Set<string> = new Set();
|
||||||
|
|
||||||
|
constructor(dropboxClient?: Dropbox, watchFn?: typeof chokidarWatch) {
|
||||||
|
super();
|
||||||
|
this.dropboxClient = dropboxClient || new Dropbox({});
|
||||||
|
this.watchFn = watchFn || chokidarWatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Status & Configuration
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
getStatus(): DropboxSyncStatus {
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return this.config !== null && !!this.config.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteBasePath(): string {
|
||||||
|
return this.config?.remoteBasePath ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async configure(config: DropboxSyncConfig): Promise<void> {
|
||||||
|
this.config = {
|
||||||
|
...config,
|
||||||
|
remoteBasePath: config.remoteBasePath ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the Dropbox client with new auth if using default client
|
||||||
|
if (config.accessToken) {
|
||||||
|
// Re-initialize client would happen here for real SDK usage
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('configured', this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Path Mapping
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a local filesystem path to a remote Dropbox path.
|
||||||
|
* Returns null if the path doesn't fall under posts or media directories.
|
||||||
|
*/
|
||||||
|
localToRemotePath(localPath: string): string | null {
|
||||||
|
if (!this.config) return null;
|
||||||
|
|
||||||
|
const normalizedLocal = localPath.replace(/\\/g, '/');
|
||||||
|
const postsDir = this.config.localPostsDir.replace(/\\/g, '/');
|
||||||
|
const mediaDir = this.config.localMediaDir.replace(/\\/g, '/');
|
||||||
|
const basePath = this.config.remoteBasePath ?? '';
|
||||||
|
|
||||||
|
if (normalizedLocal.startsWith(postsDir)) {
|
||||||
|
const relativePath = normalizedLocal.slice(postsDir.length);
|
||||||
|
return `${basePath}/posts${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedLocal.startsWith(mediaDir)) {
|
||||||
|
const relativePath = normalizedLocal.slice(mediaDir.length);
|
||||||
|
return `${basePath}/media${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a remote Dropbox path to a local filesystem path.
|
||||||
|
* Returns null if the path doesn't match posts or media remote paths.
|
||||||
|
*/
|
||||||
|
remoteToLocalPath(remotePath: string): string | null {
|
||||||
|
if (!this.config) return null;
|
||||||
|
|
||||||
|
const basePath = this.config.remoteBasePath ?? '';
|
||||||
|
const postsPrefix = `${basePath}/posts`;
|
||||||
|
const mediaPrefix = `${basePath}/media`;
|
||||||
|
|
||||||
|
if (remotePath.startsWith(postsPrefix)) {
|
||||||
|
const relativePath = remotePath.slice(postsPrefix.length);
|
||||||
|
return `${this.config.localPostsDir}${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remotePath.startsWith(mediaPrefix)) {
|
||||||
|
const relativePath = remotePath.slice(mediaPrefix.length);
|
||||||
|
return `${this.config.localMediaDir}${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// File Upload
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async uploadFile(localPath: string): Promise<FileUploadResult> {
|
||||||
|
const remotePath = this.localToRemotePath(localPath);
|
||||||
|
if (!remotePath) {
|
||||||
|
throw new Error('Cannot map local path to remote path');
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: Buffer;
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(localPath) as Buffer;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.dropboxClient.filesUpload({
|
||||||
|
path: remotePath,
|
||||||
|
contents: content,
|
||||||
|
mode: { '.tag': 'overwrite' },
|
||||||
|
autorename: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: FileUploadResult = {
|
||||||
|
remotePath: response.result.path_lower || remotePath,
|
||||||
|
contentHash: (response.result as any).content_hash || '',
|
||||||
|
serverModified: (response.result as any).server_modified || new Date().toISOString(),
|
||||||
|
size: (response.result as any).size || content.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit('fileUploaded', {
|
||||||
|
localPath,
|
||||||
|
remotePath: result.remotePath,
|
||||||
|
contentHash: result.contentHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.status === 401) {
|
||||||
|
this.emit('authError', error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// File Download
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async downloadFile(remotePath: string): Promise<FileDownloadResult> {
|
||||||
|
const localPath = this.remoteToLocalPath(remotePath);
|
||||||
|
if (!localPath) {
|
||||||
|
throw new Error('Cannot map remote path to local path');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.dropboxClient.filesDownload({ path: remotePath });
|
||||||
|
const result = response.result as any;
|
||||||
|
const content: Buffer = result.fileBinary;
|
||||||
|
|
||||||
|
// Ensure the target directory exists
|
||||||
|
const dir = path.dirname(localPath);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
// Mark as our own write so the watcher ignores it
|
||||||
|
this.recentDownloads.add(localPath);
|
||||||
|
setTimeout(() => this.recentDownloads.delete(localPath), 2000);
|
||||||
|
|
||||||
|
await fs.writeFile(localPath, content);
|
||||||
|
|
||||||
|
const downloadResult: FileDownloadResult = {
|
||||||
|
localPath,
|
||||||
|
remotePath,
|
||||||
|
contentHash: result.content_hash || '',
|
||||||
|
serverModified: result.server_modified || new Date().toISOString(),
|
||||||
|
size: result.size || content.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit('fileDownloaded', downloadResult);
|
||||||
|
|
||||||
|
return downloadResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// File Deletion (Remote)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async deleteRemoteFile(remotePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dropboxClient.filesDeleteV2({ path: remotePath });
|
||||||
|
this.emit('fileDeleted', { remotePath });
|
||||||
|
} catch (error: any) {
|
||||||
|
// If the file is already gone, that's fine
|
||||||
|
const isNotFound =
|
||||||
|
error?.error?.['.tag'] === 'path_lookup' ||
|
||||||
|
error?.error?.path_lookup?.['.tag'] === 'not_found';
|
||||||
|
if (!isNotFound) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Delta Sync (Cursor-based)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async getLatestCursor(): Promise<string> {
|
||||||
|
const basePath = this.config?.remoteBasePath ?? '';
|
||||||
|
const response = await this.dropboxClient.filesListFolderGetLatestCursor({
|
||||||
|
path: basePath,
|
||||||
|
recursive: true,
|
||||||
|
include_deleted: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return response.result.cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRemoteChanges(cursor: string): Promise<DropboxChangesResult> {
|
||||||
|
const response = await this.dropboxClient.filesListFolderContinue({ cursor });
|
||||||
|
const result = response.result;
|
||||||
|
|
||||||
|
const entries: DropboxRemoteChange[] = result.entries.map((entry: any) => ({
|
||||||
|
tag: entry['.tag'] as 'file' | 'folder' | 'deleted',
|
||||||
|
name: entry.name,
|
||||||
|
pathLower: entry.path_lower,
|
||||||
|
contentHash: entry.content_hash,
|
||||||
|
serverModified: entry.server_modified,
|
||||||
|
size: entry.size,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
cursor: result.cursor,
|
||||||
|
hasMore: result.has_more,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all remote changes, following pagination automatically.
|
||||||
|
*/
|
||||||
|
async getAllRemoteChanges(cursor: string): Promise<DropboxChangesResult> {
|
||||||
|
const allEntries: DropboxRemoteChange[] = [];
|
||||||
|
let currentCursor = cursor;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const changes = await this.getRemoteChanges(currentCursor);
|
||||||
|
allEntries.push(...changes.entries);
|
||||||
|
currentCursor = changes.cursor;
|
||||||
|
hasMore = changes.hasMore;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: allEntries,
|
||||||
|
cursor: currentCursor,
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Full Sync Operation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async syncAll(): Promise<FileSyncResult> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
uploaded: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
deleted: 0,
|
||||||
|
conflicts: 0,
|
||||||
|
errors: ['Dropbox sync not configured'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSyncing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
uploaded: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
deleted: 0,
|
||||||
|
conflicts: 0,
|
||||||
|
errors: ['Sync already in progress'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSyncing = true;
|
||||||
|
this.status = 'syncing';
|
||||||
|
this.emit('syncStarted');
|
||||||
|
|
||||||
|
const result: FileSyncResult = {
|
||||||
|
success: true,
|
||||||
|
uploaded: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
deleted: 0,
|
||||||
|
conflicts: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: Get remote state
|
||||||
|
let remoteFiles: Map<string, DropboxRemoteChange>;
|
||||||
|
try {
|
||||||
|
remoteFiles = await this.listRemoteFiles();
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
result.success = false;
|
||||||
|
result.errors.push(`Failed to list remote files: ${msg}`);
|
||||||
|
this.status = 'error';
|
||||||
|
this.emit('syncFailed', msg);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Get local state
|
||||||
|
const localFiles = await this.listLocalFiles();
|
||||||
|
|
||||||
|
// Phase 3: Compare and sync
|
||||||
|
// Files only on remote → download
|
||||||
|
for (const [remotePath, remoteEntry] of remoteFiles) {
|
||||||
|
const localPath = this.remoteToLocalPath(remotePath);
|
||||||
|
if (!localPath) continue;
|
||||||
|
|
||||||
|
if (!localFiles.has(localPath)) {
|
||||||
|
try {
|
||||||
|
await this.downloadFile(remotePath);
|
||||||
|
result.downloaded++;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
result.errors.push(`Download failed ${remotePath}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files only on local → upload
|
||||||
|
for (const [localPath] of localFiles) {
|
||||||
|
const remotePath = this.localToRemotePath(localPath);
|
||||||
|
if (!remotePath) continue;
|
||||||
|
|
||||||
|
if (!remoteFiles.has(remotePath)) {
|
||||||
|
try {
|
||||||
|
await this.uploadFile(localPath);
|
||||||
|
result.uploaded++;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
result.errors.push(`Upload failed ${localPath}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files on both → compare hashes, handle conflicts
|
||||||
|
for (const [remotePath, remoteEntry] of remoteFiles) {
|
||||||
|
const localPath = this.remoteToLocalPath(remotePath);
|
||||||
|
if (!localPath || !localFiles.has(localPath)) continue;
|
||||||
|
|
||||||
|
const localInfo = localFiles.get(localPath)!;
|
||||||
|
const localContent = await fs.readFile(localPath) as Buffer;
|
||||||
|
const localHash = this.calculateContentHash(localContent);
|
||||||
|
|
||||||
|
if (remoteEntry.contentHash && localHash !== remoteEntry.contentHash) {
|
||||||
|
// Both modified → conflict
|
||||||
|
const conflict = this.createConflict(localPath, remotePath, {
|
||||||
|
localModified: localInfo.mtime,
|
||||||
|
remoteModified: remoteEntry.serverModified
|
||||||
|
? new Date(remoteEntry.serverModified)
|
||||||
|
: new Date(),
|
||||||
|
localHash,
|
||||||
|
remoteHash: remoteEntry.contentHash,
|
||||||
|
});
|
||||||
|
this.addPendingConflict(conflict);
|
||||||
|
result.conflicts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor
|
||||||
|
try {
|
||||||
|
const newCursor = await this.getLatestCursor();
|
||||||
|
this.cursor = newCursor;
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: cursor update failure doesn't invalidate the sync
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSyncTime = new Date();
|
||||||
|
this.status = 'idle';
|
||||||
|
this.emit('syncCompleted', result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
result.success = false;
|
||||||
|
result.errors.push(msg);
|
||||||
|
this.status = 'error';
|
||||||
|
this.emit('syncFailed', msg);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this.isSyncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// List Remote Files
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async listRemoteFiles(): Promise<Map<string, DropboxRemoteChange>> {
|
||||||
|
const files = new Map<string, DropboxRemoteChange>();
|
||||||
|
const basePath = this.config?.remoteBasePath ?? '';
|
||||||
|
|
||||||
|
const response = await this.dropboxClient.filesListFolder({
|
||||||
|
path: basePath,
|
||||||
|
recursive: true,
|
||||||
|
include_deleted: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
for (const entry of response.result.entries as any[]) {
|
||||||
|
if (entry['.tag'] === 'file') {
|
||||||
|
files.set(entry.path_lower, {
|
||||||
|
tag: 'file',
|
||||||
|
name: entry.name,
|
||||||
|
pathLower: entry.path_lower,
|
||||||
|
contentHash: entry.content_hash,
|
||||||
|
serverModified: entry.server_modified,
|
||||||
|
size: entry.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = response.result.cursor;
|
||||||
|
let hasMore = response.result.has_more;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const continueResponse = await this.dropboxClient.filesListFolderContinue({ cursor });
|
||||||
|
for (const entry of continueResponse.result.entries as any[]) {
|
||||||
|
if (entry['.tag'] === 'file') {
|
||||||
|
files.set(entry.path_lower, {
|
||||||
|
tag: 'file',
|
||||||
|
name: entry.name,
|
||||||
|
pathLower: entry.path_lower,
|
||||||
|
contentHash: entry.content_hash,
|
||||||
|
serverModified: entry.server_modified,
|
||||||
|
size: entry.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor = continueResponse.result.cursor;
|
||||||
|
hasMore = continueResponse.result.has_more;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// List Local Files
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async listLocalFiles(): Promise<Map<string, { mtime: Date; size: number }>> {
|
||||||
|
const files = new Map<string, { mtime: Date; size: number }>();
|
||||||
|
|
||||||
|
if (!this.config) return files;
|
||||||
|
|
||||||
|
const dirs = [this.config.localPostsDir, this.config.localMediaDir];
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try {
|
||||||
|
await this.walkDirectory(dir, files);
|
||||||
|
} catch {
|
||||||
|
// Directory may not exist yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async walkDirectory(
|
||||||
|
dir: string,
|
||||||
|
files: Map<string, { mtime: Date; size: number }>
|
||||||
|
): Promise<void> {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dir) as unknown as string[];
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(fullPath);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
files.set(fullPath.replace(/\\/g, '/'), {
|
||||||
|
mtime: stat.mtime,
|
||||||
|
size: stat.size,
|
||||||
|
});
|
||||||
|
} else if (stat.isDirectory()) {
|
||||||
|
await this.walkDirectory(fullPath, files);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip files we can't stat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Conflict Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
createConflict(
|
||||||
|
localPath: string,
|
||||||
|
remotePath: string,
|
||||||
|
details: {
|
||||||
|
localModified: Date;
|
||||||
|
remoteModified: Date;
|
||||||
|
localHash: string;
|
||||||
|
remoteHash: string;
|
||||||
|
}
|
||||||
|
): DropboxConflict {
|
||||||
|
const conflict: DropboxConflict = {
|
||||||
|
id: uuidv4(),
|
||||||
|
localPath,
|
||||||
|
remotePath,
|
||||||
|
localModified: details.localModified,
|
||||||
|
remoteModified: details.remoteModified,
|
||||||
|
localHash: details.localHash,
|
||||||
|
remoteHash: details.remoteHash,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit('conflictDetected', conflict);
|
||||||
|
return conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveConflict(
|
||||||
|
conflict: DropboxConflict,
|
||||||
|
resolution: ConflictResolution
|
||||||
|
): Promise<void> {
|
||||||
|
if (resolution === 'local-wins') {
|
||||||
|
// Upload local version to overwrite remote
|
||||||
|
await this.uploadFile(conflict.localPath);
|
||||||
|
} else if (resolution === 'remote-wins') {
|
||||||
|
// Download remote version to overwrite local
|
||||||
|
await this.downloadFile(conflict.remotePath);
|
||||||
|
}
|
||||||
|
// 'manual' resolution is handled by the UI
|
||||||
|
|
||||||
|
this.removePendingConflict(conflict.id);
|
||||||
|
this.emit('conflictResolved', conflict, resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPendingConflict(conflict: DropboxConflict): void {
|
||||||
|
this.pendingConflicts.set(conflict.id, conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
removePendingConflict(id: string): void {
|
||||||
|
this.pendingConflicts.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingConflicts(): DropboxConflict[] {
|
||||||
|
return Array.from(this.pendingConflicts.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPendingConflicts(): void {
|
||||||
|
this.pendingConflicts.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Content Hash (Dropbox-compatible)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a content hash for a buffer.
|
||||||
|
* Uses SHA-256 of 4 MB blocks, then SHA-256 of the concatenated block hashes,
|
||||||
|
* matching Dropbox's content_hash algorithm.
|
||||||
|
*/
|
||||||
|
calculateContentHash(content: Buffer): string {
|
||||||
|
const BLOCK_SIZE = 4 * 1024 * 1024; // 4 MB
|
||||||
|
const blockHashes: Buffer[] = [];
|
||||||
|
|
||||||
|
for (let offset = 0; offset < content.length; offset += BLOCK_SIZE) {
|
||||||
|
const block = content.subarray(offset, Math.min(offset + BLOCK_SIZE, content.length));
|
||||||
|
const hash = crypto.createHash('sha256').update(block).digest();
|
||||||
|
blockHashes.push(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty file
|
||||||
|
if (blockHashes.length === 0) {
|
||||||
|
blockHashes.push(crypto.createHash('sha256').update(Buffer.alloc(0)).digest());
|
||||||
|
}
|
||||||
|
|
||||||
|
const concatenated = Buffer.concat(blockHashes);
|
||||||
|
return crypto.createHash('sha256').update(concatenated).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Local File Watching
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
startWatching(): void {
|
||||||
|
if (!this.config) return;
|
||||||
|
|
||||||
|
const watchPaths = [
|
||||||
|
this.config.localPostsDir,
|
||||||
|
this.config.localMediaDir,
|
||||||
|
];
|
||||||
|
|
||||||
|
this.watcher = this.watchFn(watchPaths, {
|
||||||
|
ignoreInitial: true,
|
||||||
|
persistent: true,
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: 500,
|
||||||
|
pollInterval: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.watcher
|
||||||
|
.on('add', (filePath: string) => this.handleLocalFileChange(filePath, 'add'))
|
||||||
|
.on('change', (filePath: string) => this.handleLocalFileChange(filePath, 'change'))
|
||||||
|
.on('unlink', (filePath: string) => this.handleLocalFileUnlink(filePath));
|
||||||
|
|
||||||
|
this.status = 'watching';
|
||||||
|
this.emit('watchStarted');
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWatching(): void {
|
||||||
|
if (this.watcher) {
|
||||||
|
this.watcher.close();
|
||||||
|
this.watcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any pending upload debounces
|
||||||
|
for (const timeout of this.pendingUploads.values()) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
this.pendingUploads.clear();
|
||||||
|
|
||||||
|
if (this.status === 'watching') {
|
||||||
|
this.status = 'idle';
|
||||||
|
}
|
||||||
|
this.emit('watchStopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLocalFileChange(filePath: string, event: 'add' | 'change'): void {
|
||||||
|
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// Skip files we just downloaded from Dropbox
|
||||||
|
if (this.recentDownloads.has(normalizedPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce uploads to avoid rapid successive uploads
|
||||||
|
if (this.pendingUploads.has(normalizedPath)) {
|
||||||
|
clearTimeout(this.pendingUploads.get(normalizedPath)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
this.pendingUploads.delete(normalizedPath);
|
||||||
|
try {
|
||||||
|
await this.uploadFile(normalizedPath);
|
||||||
|
this.emit('localFileChanged', { path: normalizedPath, event });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
this.emit('uploadError', { path: normalizedPath, error: msg });
|
||||||
|
}
|
||||||
|
}, this.uploadDebounceMs);
|
||||||
|
|
||||||
|
this.pendingUploads.set(normalizedPath, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleLocalFileUnlink(filePath: string): Promise<void> {
|
||||||
|
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||||
|
const remotePath = this.localToRemotePath(normalizedPath);
|
||||||
|
|
||||||
|
if (remotePath) {
|
||||||
|
try {
|
||||||
|
await this.deleteRemoteFile(remotePath);
|
||||||
|
this.emit('localFileDeleted', { localPath: normalizedPath, remotePath });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
this.emit('deleteError', { path: normalizedPath, error: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Remote Polling
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
startPolling(): void {
|
||||||
|
if (this.pollIntervalId) return;
|
||||||
|
|
||||||
|
const intervalMs = (this.config?.syncInterval ?? 60) * 1000;
|
||||||
|
|
||||||
|
this.pollIntervalId = setInterval(async () => {
|
||||||
|
await this.pollRemoteChanges();
|
||||||
|
}, intervalMs);
|
||||||
|
|
||||||
|
this.emit('pollingStarted');
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling(): void {
|
||||||
|
if (this.pollIntervalId) {
|
||||||
|
clearInterval(this.pollIntervalId);
|
||||||
|
this.pollIntervalId = null;
|
||||||
|
}
|
||||||
|
this.emit('pollingStopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
isPolling(): boolean {
|
||||||
|
return this.pollIntervalId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollRemoteChanges(): Promise<void> {
|
||||||
|
if (!this.cursor) {
|
||||||
|
try {
|
||||||
|
this.cursor = await this.getLatestCursor();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changes = await this.getAllRemoteChanges(this.cursor);
|
||||||
|
this.cursor = changes.cursor;
|
||||||
|
|
||||||
|
for (const entry of changes.entries) {
|
||||||
|
if (entry.tag === 'file') {
|
||||||
|
try {
|
||||||
|
await this.downloadFile(entry.pathLower);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
this.emit('downloadError', { path: entry.pathLower, error: msg });
|
||||||
|
}
|
||||||
|
} else if (entry.tag === 'deleted') {
|
||||||
|
const localPath = this.remoteToLocalPath(entry.pathLower);
|
||||||
|
if (localPath) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(localPath);
|
||||||
|
this.emit('remoteFileDeleted', { remotePath: entry.pathLower, localPath });
|
||||||
|
} catch {
|
||||||
|
// File may already be deleted locally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.entries.length > 0) {
|
||||||
|
this.emit('remoteChangesApplied', { count: changes.entries.length });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
this.emit('pollError', { error: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Sync State
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
getCursor(): string | null {
|
||||||
|
return this.cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursor(cursor: string): void {
|
||||||
|
this.cursor = cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastSyncTime(): Date | null {
|
||||||
|
return this.lastSyncTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastSyncTime(time: Date): void {
|
||||||
|
this.lastSyncTime = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Singleton
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let dropboxSyncEngine: DropboxSyncEngine | null = null;
|
||||||
|
|
||||||
|
export function getDropboxSyncEngine(): DropboxSyncEngine {
|
||||||
|
if (!dropboxSyncEngine) {
|
||||||
|
dropboxSyncEngine = new DropboxSyncEngine();
|
||||||
|
}
|
||||||
|
return dropboxSyncEngine;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema';
|
|||||||
import { taskManager, Task } from './TaskManager';
|
import { taskManager, Task } from './TaskManager';
|
||||||
import { getPostEngine } from './PostEngine';
|
import { getPostEngine } from './PostEngine';
|
||||||
import { getMediaEngine } from './MediaEngine';
|
import { getMediaEngine } from './MediaEngine';
|
||||||
|
import { getDropboxSyncEngine } from './DropboxSyncEngine';
|
||||||
|
|
||||||
export type SyncDirection = 'push' | 'pull' | 'bidirectional';
|
export type SyncDirection = 'push' | 'pull' | 'bidirectional';
|
||||||
export type SyncStatus = 'idle' | 'syncing' | 'error';
|
export type SyncStatus = 'idle' | 'syncing' | 'error';
|
||||||
@@ -23,6 +24,13 @@ export interface SyncResult {
|
|||||||
pulled: number;
|
pulled: number;
|
||||||
conflicts: number;
|
conflicts: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
dropboxResult?: {
|
||||||
|
uploaded: number;
|
||||||
|
downloaded: number;
|
||||||
|
deleted: number;
|
||||||
|
conflicts: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncEngine extends EventEmitter {
|
export class SyncEngine extends EventEmitter {
|
||||||
@@ -311,6 +319,41 @@ export class SyncEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.emit('autoSyncStopped');
|
this.emit('autoSyncStopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full sync: metadata via Turso + files via Dropbox.
|
||||||
|
* Coordinates both sync engines for a complete bidirectional sync.
|
||||||
|
*/
|
||||||
|
async fullSync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
|
||||||
|
// Run metadata sync (Turso)
|
||||||
|
const metadataResult = await this.sync(direction);
|
||||||
|
|
||||||
|
// Run file sync (Dropbox) if configured
|
||||||
|
const dropboxEngine = getDropboxSyncEngine();
|
||||||
|
if (dropboxEngine.isConfigured()) {
|
||||||
|
try {
|
||||||
|
const fileResult = await dropboxEngine.syncAll();
|
||||||
|
metadataResult.dropboxResult = {
|
||||||
|
uploaded: fileResult.uploaded,
|
||||||
|
downloaded: fileResult.downloaded,
|
||||||
|
deleted: fileResult.deleted,
|
||||||
|
conflicts: fileResult.conflicts,
|
||||||
|
errors: fileResult.errors,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fileResult.success) {
|
||||||
|
metadataResult.errors.push(
|
||||||
|
...fileResult.errors.map(e => `Dropbox: ${e}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown Dropbox error';
|
||||||
|
metadataResult.errors.push(`Dropbox sync failed: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadataResult;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -3,3 +3,16 @@ export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchR
|
|||||||
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
|
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
|
||||||
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
|
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
|
||||||
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
|
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
|
||||||
|
export {
|
||||||
|
DropboxSyncEngine,
|
||||||
|
getDropboxSyncEngine,
|
||||||
|
type DropboxSyncConfig,
|
||||||
|
type DropboxSyncStatus,
|
||||||
|
type DropboxConflict,
|
||||||
|
type DropboxRemoteChange,
|
||||||
|
type DropboxChangesResult,
|
||||||
|
type FileSyncResult,
|
||||||
|
type FileUploadResult,
|
||||||
|
type FileDownloadResult,
|
||||||
|
type ConflictResolution,
|
||||||
|
} from './DropboxSyncEngine';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine';
|
import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine';
|
||||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||||
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
||||||
|
import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../engine/DropboxSyncEngine';
|
||||||
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
@@ -289,6 +290,68 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.stopAutoSync();
|
return engine.stopAutoSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ Dropbox Sync Handlers ============
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:configure', async (_, config: DropboxSyncConfig) => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
return engine.configure(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:isConfigured', async () => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
return engine.isConfigured();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:getStatus', async () => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
return engine.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:syncAll', async () => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
return engine.syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:startWatching', async () => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
engine.startWatching();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:stopWatching', async () => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
engine.stopWatching();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:startPolling', async () => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
engine.startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:stopPolling', async () => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
engine.stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:getConflicts', async () => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
return engine.getPendingConflicts();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:resolveConflict', async (_, conflictId: string, resolution: ConflictResolution) => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
const conflicts = engine.getPendingConflicts();
|
||||||
|
const conflict = conflicts.find(c => c.id === conflictId);
|
||||||
|
if (!conflict) {
|
||||||
|
throw new Error(`Conflict ${conflictId} not found`);
|
||||||
|
}
|
||||||
|
return engine.resolveConflict(conflict, resolution);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dropbox:getLastSyncTime', async () => {
|
||||||
|
const engine = getDropboxSyncEngine();
|
||||||
|
return engine.getLastSyncTime();
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Task Handlers ============
|
// ============ Task Handlers ============
|
||||||
|
|
||||||
ipcMain.handle('tasks:getAll', async () => {
|
ipcMain.handle('tasks:getAll', async () => {
|
||||||
@@ -355,6 +418,20 @@ export function registerIpcHandlers(): void {
|
|||||||
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
|
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
|
||||||
syncEngine.on('syncFailed', forwardEvent('sync:failed'));
|
syncEngine.on('syncFailed', forwardEvent('sync:failed'));
|
||||||
|
|
||||||
|
const dropboxEngine = getDropboxSyncEngine();
|
||||||
|
dropboxEngine.on('configured', forwardEvent('dropbox:configured'));
|
||||||
|
dropboxEngine.on('syncStarted', forwardEvent('dropbox:syncStarted'));
|
||||||
|
dropboxEngine.on('syncCompleted', forwardEvent('dropbox:syncCompleted'));
|
||||||
|
dropboxEngine.on('syncFailed', forwardEvent('dropbox:syncFailed'));
|
||||||
|
dropboxEngine.on('fileUploaded', forwardEvent('dropbox:fileUploaded'));
|
||||||
|
dropboxEngine.on('fileDownloaded', forwardEvent('dropbox:fileDownloaded'));
|
||||||
|
dropboxEngine.on('fileDeleted', forwardEvent('dropbox:fileDeleted'));
|
||||||
|
dropboxEngine.on('conflictDetected', forwardEvent('dropbox:conflictDetected'));
|
||||||
|
dropboxEngine.on('conflictResolved', forwardEvent('dropbox:conflictResolved'));
|
||||||
|
dropboxEngine.on('watchStarted', forwardEvent('dropbox:watchStarted'));
|
||||||
|
dropboxEngine.on('watchStopped', forwardEvent('dropbox:watchStopped'));
|
||||||
|
dropboxEngine.on('authError', forwardEvent('dropbox:authError'));
|
||||||
|
|
||||||
taskManager.on('taskCreated', forwardEvent('task:created'));
|
taskManager.on('taskCreated', forwardEvent('task:created'));
|
||||||
taskManager.on('taskStarted', forwardEvent('task:started'));
|
taskManager.on('taskStarted', forwardEvent('task:started'));
|
||||||
taskManager.on('taskProgress', forwardEvent('task:progress'));
|
taskManager.on('taskProgress', forwardEvent('task:progress'));
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'),
|
stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Dropbox File Sync
|
||||||
|
dropbox: {
|
||||||
|
configure: (config: unknown) => ipcRenderer.invoke('dropbox:configure', config),
|
||||||
|
isConfigured: () => ipcRenderer.invoke('dropbox:isConfigured'),
|
||||||
|
getStatus: () => ipcRenderer.invoke('dropbox:getStatus'),
|
||||||
|
syncAll: () => ipcRenderer.invoke('dropbox:syncAll'),
|
||||||
|
startWatching: () => ipcRenderer.invoke('dropbox:startWatching'),
|
||||||
|
stopWatching: () => ipcRenderer.invoke('dropbox:stopWatching'),
|
||||||
|
startPolling: () => ipcRenderer.invoke('dropbox:startPolling'),
|
||||||
|
stopPolling: () => ipcRenderer.invoke('dropbox:stopPolling'),
|
||||||
|
getConflicts: () => ipcRenderer.invoke('dropbox:getConflicts'),
|
||||||
|
resolveConflict: (conflictId: string, resolution: string) =>
|
||||||
|
ipcRenderer.invoke('dropbox:resolveConflict', conflictId, resolution),
|
||||||
|
getLastSyncTime: () => ipcRenderer.invoke('dropbox:getLastSyncTime'),
|
||||||
|
},
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
tasks: {
|
tasks: {
|
||||||
getAll: () => ipcRenderer.invoke('tasks:getAll'),
|
getAll: () => ipcRenderer.invoke('tasks:getAll'),
|
||||||
@@ -138,6 +154,19 @@ export interface ElectronAPI {
|
|||||||
getLog: (limit?: number) => Promise<unknown[]>;
|
getLog: (limit?: number) => Promise<unknown[]>;
|
||||||
stopAutoSync: () => Promise<void>;
|
stopAutoSync: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
dropbox: {
|
||||||
|
configure: (config: unknown) => Promise<void>;
|
||||||
|
isConfigured: () => Promise<boolean>;
|
||||||
|
getStatus: () => Promise<string>;
|
||||||
|
syncAll: () => Promise<unknown>;
|
||||||
|
startWatching: () => Promise<void>;
|
||||||
|
stopWatching: () => Promise<void>;
|
||||||
|
startPolling: () => Promise<void>;
|
||||||
|
stopPolling: () => Promise<void>;
|
||||||
|
getConflicts: () => Promise<unknown[]>;
|
||||||
|
resolveConflict: (conflictId: string, resolution: string) => Promise<void>;
|
||||||
|
getLastSyncTime: () => Promise<string | null>;
|
||||||
|
};
|
||||||
tasks: {
|
tasks: {
|
||||||
getAll: () => Promise<unknown[]>;
|
getAll: () => Promise<unknown[]>;
|
||||||
getRunning: () => Promise<unknown[]>;
|
getRunning: () => Promise<unknown[]>;
|
||||||
|
|||||||
1154
tests/engine/DropboxSyncEngine.test.ts
Normal file
1154
tests/engine/DropboxSyncEngine.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -273,6 +273,95 @@ export function createMockFileSystem() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Dropbox Mock Factory
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import type { DropboxSyncConfig, DropboxConflict } from '../../src/main/engine/DropboxSyncEngine';
|
||||||
|
|
||||||
|
let dropboxConflictIdCounter = 1;
|
||||||
|
|
||||||
|
export function createMockDropboxConfig(overrides?: Partial<DropboxSyncConfig>): DropboxSyncConfig {
|
||||||
|
return {
|
||||||
|
accessToken: 'mock-dropbox-access-token',
|
||||||
|
refreshToken: 'mock-dropbox-refresh-token',
|
||||||
|
appKey: 'mock-app-key',
|
||||||
|
appSecret: 'mock-app-secret',
|
||||||
|
syncEnabled: true,
|
||||||
|
syncInterval: 60,
|
||||||
|
localPostsDir: '/mock/userData/projects/default/posts',
|
||||||
|
localMediaDir: '/mock/userData/projects/default/media',
|
||||||
|
remoteBasePath: '/bds',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockDropboxClient() {
|
||||||
|
return {
|
||||||
|
filesUpload: vi.fn().mockResolvedValue({
|
||||||
|
result: {
|
||||||
|
name: 'test.md',
|
||||||
|
path_lower: '/bds/posts/2026/01/test.md',
|
||||||
|
content_hash: 'mockhash123',
|
||||||
|
server_modified: '2026-01-15T10:00:00Z',
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
filesDownload: vi.fn().mockResolvedValue({
|
||||||
|
result: {
|
||||||
|
name: 'test.md',
|
||||||
|
path_lower: '/bds/posts/2026/01/test.md',
|
||||||
|
fileBinary: Buffer.from('downloaded content'),
|
||||||
|
content_hash: 'mockhash123',
|
||||||
|
server_modified: '2026-01-15T10:00:00Z',
|
||||||
|
size: 18,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
filesDeleteV2: vi.fn().mockResolvedValue({
|
||||||
|
result: { metadata: { '.tag': 'file', name: 'test.md' } },
|
||||||
|
}),
|
||||||
|
filesListFolder: vi.fn().mockResolvedValue({
|
||||||
|
result: { entries: [], cursor: 'mock-cursor', has_more: false },
|
||||||
|
}),
|
||||||
|
filesListFolderContinue: vi.fn().mockResolvedValue({
|
||||||
|
result: { entries: [], cursor: 'mock-cursor-next', has_more: false },
|
||||||
|
}),
|
||||||
|
filesListFolderGetLatestCursor: vi.fn().mockResolvedValue({
|
||||||
|
result: { cursor: 'mock-latest-cursor' },
|
||||||
|
}),
|
||||||
|
filesListFolderLongpoll: vi.fn().mockResolvedValue({
|
||||||
|
result: { changes: false, backoff: 0 },
|
||||||
|
}),
|
||||||
|
filesGetMetadata: vi.fn().mockResolvedValue({
|
||||||
|
result: {
|
||||||
|
'.tag': 'file',
|
||||||
|
name: 'test.md',
|
||||||
|
path_lower: '/bds/posts/2026/01/test.md',
|
||||||
|
content_hash: 'mockhash123',
|
||||||
|
server_modified: '2026-01-15T10:00:00Z',
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setRefreshToken: vi.fn(),
|
||||||
|
getRefreshToken: vi.fn().mockReturnValue('mock-refresh-token'),
|
||||||
|
getAccessToken: vi.fn().mockReturnValue('mock-access-token'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockDropboxConflict(overrides?: Partial<DropboxConflict>): DropboxConflict {
|
||||||
|
const id = `conflict-${dropboxConflictIdCounter++}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
localPath: '/mock/userData/projects/default/posts/2026/01/test.md',
|
||||||
|
remotePath: '/bds/posts/2026/01/test.md',
|
||||||
|
localModified: new Date('2026-01-20T15:00:00Z'),
|
||||||
|
remoteModified: new Date('2026-01-20T14:00:00Z'),
|
||||||
|
localHash: 'localhash123',
|
||||||
|
remoteHash: 'remotehash456',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Reset Utilities
|
// Reset Utilities
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -281,6 +370,7 @@ export function resetMockCounters(): void {
|
|||||||
postIdCounter = 1;
|
postIdCounter = 1;
|
||||||
mediaIdCounter = 1;
|
mediaIdCounter = 1;
|
||||||
taskIdCounter = 1;
|
taskIdCounter = 1;
|
||||||
|
dropboxConflictIdCounter = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user