chore: removed dropbox sync

This commit is contained in:
2026-02-14 10:12:37 +01:00
parent 6ff84c2d6a
commit d2d04b9b20
14 changed files with 61 additions and 2936 deletions

View File

@@ -1,892 +0,0 @@
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 type { FSWatcher } from 'chokidar';
type ChokidarWatchFn = (paths: string | readonly string[], options?: Record<string, unknown>) => FSWatcher;
let _chokidarWatch: ChokidarWatchFn | null = null;
async function getChokidarWatch(): Promise<ChokidarWatchFn> {
if (!_chokidarWatch) {
const chokidar = await import('chokidar');
_chokidarWatch = chokidar.watch as unknown as ChokidarWatchFn;
}
return _chokidarWatch;
}
// ============================================
// 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: ChokidarWatchFn | null;
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?: ChokidarWatchFn) {
super();
this.dropboxClient = dropboxClient || new Dropbox({});
this.watchFn = watchFn || null;
}
private async getWatchFn(): Promise<ChokidarWatchFn> {
if (!this.watchFn) {
this.watchFn = await getChokidarWatch();
}
return this.watchFn;
}
// ============================================
// 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
// ============================================
async startWatching(): Promise<void> {
if (!this.config) return;
const watchPaths = [
this.config.localPostsDir,
this.config.localMediaDir,
];
const watchFn = await this.getWatchFn();
this.watcher = 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;
}

View File

@@ -3,8 +3,6 @@ import { v4 as uuidv4 } from 'uuid';
import { eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema';
import { taskManager, Task } from './TaskManager';
import { getDropboxSyncEngine } from './DropboxSyncEngine';
export type SyncDirection = 'push' | 'pull' | 'bidirectional';
export type SyncStatus = 'idle' | 'syncing' | 'error';
@@ -20,13 +18,6 @@ export interface SyncResult {
pulled: number;
conflicts: number;
errors: string[];
dropboxResult?: {
uploaded: number;
downloaded: number;
deleted: number;
conflicts: number;
errors: string[];
};
}
export class SyncEngine extends EventEmitter {
@@ -43,11 +34,11 @@ export class SyncEngine extends EventEmitter {
}
/**
* Check if sync is configured. Uses Dropbox configuration status.
* Check if sync is configured.
* Currently returns false as cloud sync is not implemented.
*/
isConfigured(): boolean {
const dropboxEngine = getDropboxSyncEngine();
return dropboxEngine.isConfigured();
return false;
}
async configure(config: SyncConfig): Promise<void> {
@@ -59,14 +50,7 @@ export class SyncEngine extends EventEmitter {
this.syncIntervalId = null;
}
// Start auto-sync if enabled
if (config.autoSync && config.syncInterval > 0) {
this.syncIntervalId = setInterval(
() => this.fullSync('bidirectional'),
config.syncInterval * 60 * 1000
);
}
// Auto-sync is disabled as cloud sync is not implemented
this.emit('configured', config);
}
@@ -159,110 +143,17 @@ export class SyncEngine extends EventEmitter {
}
/**
* Full sync: Files via Dropbox.
* Synchronizes posts and media files to Dropbox for backup and cross-device access.
* Full sync is not currently implemented.
* Returns a result indicating sync is not configured.
*/
async fullSync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
if (this.syncStatus === 'syncing') {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Sync already in progress'],
};
}
const result: SyncResult = {
success: true,
async fullSync(_direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: [],
errors: ['Cloud sync not configured'],
};
const dropboxEngine = getDropboxSyncEngine();
if (!dropboxEngine.isConfigured()) {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Dropbox sync not configured'],
};
}
console.log('[SyncEngine] Starting Dropbox file sync...', direction);
const task: Task<SyncResult> = {
id: uuidv4(),
name: `Sync (${direction})`,
execute: async (onProgress) => {
this.syncStatus = 'syncing';
this.emit('syncStarted', direction);
try {
onProgress(10, 'Starting Dropbox sync...');
const fileResult = await dropboxEngine.syncAll();
result.dropboxResult = {
uploaded: fileResult.uploaded,
downloaded: fileResult.downloaded,
deleted: fileResult.deleted,
conflicts: fileResult.conflicts,
errors: fileResult.errors,
};
result.pushed = fileResult.uploaded;
result.pulled = fileResult.downloaded;
result.conflicts = fileResult.conflicts;
if (!fileResult.success) {
result.success = false;
result.errors.push(...fileResult.errors.map(e => `Dropbox: ${e}`));
}
onProgress(100, 'Sync complete');
console.log('[SyncEngine] Dropbox sync complete:', {
uploaded: fileResult.uploaded,
downloaded: fileResult.downloaded,
errors: fileResult.errors.length,
});
this.syncStatus = 'idle';
this.emit('syncCompleted', result);
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error('[SyncEngine] Sync failed:', errorMsg);
result.success = false;
result.errors.push(`Dropbox sync failed: ${errorMsg}`);
this.syncStatus = 'error';
this.emit('syncFailed', errorMsg);
return result;
}
},
};
try {
return await taskManager.runTask(task);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error('[SyncEngine] Task manager error:', errorMsg);
this.syncStatus = 'error';
this.emit('syncFailed', errorMsg);
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: [errorMsg],
};
}
}
}

View File

@@ -25,19 +25,6 @@ export {
getSupportedLanguages,
type SupportedLanguage,
} from './stemmer';
export {
DropboxSyncEngine,
getDropboxSyncEngine,
type DropboxSyncConfig,
type DropboxSyncStatus,
type DropboxConflict,
type DropboxRemoteChange,
type DropboxChangesResult,
type FileSyncResult,
type FileUploadResult,
type FileDownloadResult,
type ConflictResolution,
} from './DropboxSyncEngine';
export {
ChatEngine,
type ChatConversationData,

View File

@@ -5,7 +5,6 @@ import { eq } from 'drizzle-orm';
import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engine/PostEngine';
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../engine/DropboxSyncEngine';
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
import { getMetaEngine } from '../engine/MetaEngine';
import { getTagEngine } from '../engine/TagEngine';
@@ -444,87 +443,6 @@ export function registerIpcHandlers(): void {
return engine.stopAutoSync();
});
// ============ Dropbox Sync Handlers ============
safeHandle('dropbox:configure', async (_, config: Partial<DropboxSyncConfig>) => {
const engine = getDropboxSyncEngine();
// Inject local project paths so the engine knows where files live
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
const projectId = activeProject?.id || 'default';
const paths = projectEngine.getProjectPaths(projectId, activeProject?.dataPath);
const fullConfig: DropboxSyncConfig = {
accessToken: config.accessToken,
appKey: config.appKey || '',
appSecret: config.appSecret,
refreshToken: config.refreshToken,
syncEnabled: config.syncEnabled ?? true,
syncInterval: config.syncInterval ?? 60,
localPostsDir: paths.posts,
localMediaDir: paths.media,
remoteBasePath: config.remoteBasePath ?? (config as any).remotePath ?? '',
};
return engine.configure(fullConfig);
});
safeHandle('dropbox:isConfigured', async () => {
const engine = getDropboxSyncEngine();
return engine.isConfigured();
});
safeHandle('dropbox:getStatus', async () => {
const engine = getDropboxSyncEngine();
return engine.getStatus();
});
safeHandle('dropbox:syncAll', async () => {
const engine = getDropboxSyncEngine();
return engine.syncAll();
});
safeHandle('dropbox:startWatching', async () => {
const engine = getDropboxSyncEngine();
engine.startWatching();
});
safeHandle('dropbox:stopWatching', async () => {
const engine = getDropboxSyncEngine();
engine.stopWatching();
});
safeHandle('dropbox:startPolling', async () => {
const engine = getDropboxSyncEngine();
engine.startPolling();
});
safeHandle('dropbox:stopPolling', async () => {
const engine = getDropboxSyncEngine();
engine.stopPolling();
});
safeHandle('dropbox:getConflicts', async () => {
const engine = getDropboxSyncEngine();
return engine.getPendingConflicts();
});
safeHandle('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);
});
safeHandle('dropbox:getLastSyncTime', async () => {
const engine = getDropboxSyncEngine();
return engine.getLastSyncTime();
});
// ============ Task Handlers ============
safeHandle('tasks:getAll', async () => {
@@ -986,20 +904,6 @@ export function registerIpcHandlers(): void {
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
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('taskStarted', forwardEvent('task:started'));
taskManager.on('taskProgress', forwardEvent('task:progress'));

View File

@@ -88,22 +88,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
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: {
getAll: () => ipcRenderer.invoke('tasks:getAll'),

View File

@@ -70,24 +70,7 @@ const App: React.FC = () => {
}
}
// Re-configure Dropbox sync from saved credentials
const savedCreds = localStorage.getItem('bds-credentials');
if (savedCreds) {
try {
const creds = JSON.parse(savedCreds);
if (creds.dropboxAccessToken && creds.dropboxAppKey) {
await window.electronAPI?.dropbox?.configure({
accessToken: creds.dropboxAccessToken,
appKey: creds.dropboxAppKey,
remotePath: creds.dropboxRemotePath || '/blog',
});
}
} catch (e) {
console.error('Failed to restore sync configuration:', e);
}
}
// Check sync status (uses Dropbox configuration)
// Check sync status
const syncConfigured = await window.electronAPI?.sync.isConfigured();
setSyncConfigured(syncConfigured || false);

View File

@@ -4,7 +4,7 @@ import { showToast } from '../Toast';
import './SettingsView.css';
// Export category IDs for sidebar navigation
export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'sync' | 'publishing' | 'data';
export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'publishing' | 'data';
// Scroll to a settings section by category ID
export const scrollToSettingsSection = (category: SettingsCategory) => {
@@ -17,10 +17,6 @@ export const scrollToSettingsSection = (category: SettingsCategory) => {
// Settings categories
interface Credentials {
// Dropbox File Sync
dropboxAccessToken: string;
dropboxAppKey: string;
dropboxRemotePath: string;
// FTP Publishing
ftpHost: string;
ftpUser: string;
@@ -32,9 +28,6 @@ interface Credentials {
}
const defaultCredentials: Credentials = {
dropboxAccessToken: '',
dropboxAppKey: '',
dropboxRemotePath: '/blog',
ftpHost: '',
ftpUser: '',
ftpPassword: '',
@@ -101,8 +94,6 @@ export const SettingsView: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [credentials, setCredentials] = useState<Credentials>(defaultCredentials);
const [showSecrets, setShowSecrets] = useState(false);
const [dropboxConfigured, setDropboxConfigured] = useState(false);
const [dropboxLastSync, setDropboxLastSync] = useState<string | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Project settings
@@ -193,15 +184,6 @@ export const SettingsView: React.FC = () => {
} catch (error) {
console.error('Failed to load AI settings:', error);
}
// Check Dropbox status
const dbxConfigured = await window.electronAPI?.dropbox?.isConfigured();
setDropboxConfigured(dbxConfigured || false);
if (dbxConfigured) {
const lastSync = await window.electronAPI?.dropbox?.getLastSyncTime();
setDropboxLastSync(lastSync || null);
}
} catch (error) {
console.error('Failed to load settings:', error);
}
@@ -209,27 +191,6 @@ export const SettingsView: React.FC = () => {
loadSettings();
}, [activeProject?.id]); // Reload when project changes
const handleSaveDropbox = async () => {
try {
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
if (credentials.dropboxAccessToken && credentials.dropboxAppKey) {
await window.electronAPI?.dropbox?.configure({
accessToken: credentials.dropboxAccessToken,
appKey: credentials.dropboxAppKey,
remotePath: credentials.dropboxRemotePath || '/blog',
});
setDropboxConfigured(true);
showToast.success('Dropbox sync configured');
} else {
showToast.success('Credentials saved');
}
} catch (error) {
console.error('Failed to save Dropbox credentials:', error);
showToast.error('Failed to configure Dropbox sync');
}
};
const handleSavePublishing = async () => {
try {
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
@@ -240,14 +201,9 @@ export const SettingsView: React.FC = () => {
}
};
const handleClearCredentials = (type: 'dropbox' | 'ftp' | 'ssh') => {
const handleClearCredentials = (type: 'ftp' | 'ssh') => {
const newCreds = { ...credentials };
switch (type) {
case 'dropbox':
newCreds.dropboxAccessToken = '';
newCreds.dropboxAppKey = '';
newCreds.dropboxRemotePath = '/blog';
break;
case 'ftp':
newCreds.ftpHost = '';
newCreds.ftpUser = '';
@@ -264,36 +220,6 @@ export const SettingsView: React.FC = () => {
showToast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} credentials cleared`);
};
const handleDropboxSync = async () => {
try {
showToast.loading('Starting Dropbox sync...');
await window.electronAPI?.dropbox?.syncAll();
showToast.dismiss();
showToast.success('Dropbox sync completed');
const lastSync = await window.electronAPI?.dropbox?.getLastSyncTime();
setDropboxLastSync(lastSync || null);
} catch (error) {
showToast.dismiss();
showToast.error('Dropbox sync failed');
}
};
const handleTestDropboxConnection = async () => {
showToast.loading('Testing Dropbox connection...');
try {
const status = await window.electronAPI?.dropbox?.getStatus();
showToast.dismiss();
if (status) {
showToast.success('Dropbox connection active');
} else {
showToast.error('Dropbox connection failed');
}
} catch {
showToast.dismiss();
showToast.error('Dropbox connection failed');
}
};
// Save project settings
const handleSaveProject = async () => {
if (!activeProject) return;
@@ -338,7 +264,6 @@ export const SettingsView: React.FC = () => {
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
const syncKeywords = ['sync', 'dropbox', 'file', 'backup', 'token', 'remote'];
const publishingKeywords = ['publishing', 'ftp', 'ssh', 'deploy', 'server', 'host', 'upload'];
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem'];
@@ -746,97 +671,6 @@ export const SettingsView: React.FC = () => {
</SettingSection>
);
const renderSyncSettings = () => (
<>
<SettingSection
id="settings-section-sync"
title="File Sync — Dropbox"
description="Synchronize your blog files (posts and media) to Dropbox for backup and cross-device access."
hidden={!sectionHasMatches(syncKeywords)}
>
<SettingRow
id="dropbox-token"
label="Access Token"
description="Your Dropbox API access token. Generate one from the Dropbox App Console."
>
<div className="setting-input-group">
<input
id="dropbox-token"
type={showSecrets ? 'text' : 'password'}
placeholder="Your Dropbox access token"
value={credentials.dropboxAccessToken}
onChange={(e) => setCredentials({ ...credentials, dropboxAccessToken: e.target.value })}
/>
<button
className="setting-toggle-visibility"
onClick={() => setShowSecrets(!showSecrets)}
title={showSecrets ? 'Hide secrets' : 'Show secrets'}
>
{showSecrets ? '🔒' : '👁'}
</button>
</div>
</SettingRow>
<SettingRow
id="dropbox-appkey"
label="App Key"
description="The App Key from your Dropbox developer application."
>
<input
id="dropbox-appkey"
type="text"
placeholder="Your Dropbox App Key"
value={credentials.dropboxAppKey}
onChange={(e) => setCredentials({ ...credentials, dropboxAppKey: e.target.value })}
/>
</SettingRow>
<SettingRow
id="dropbox-path"
label="Remote Path"
description="The folder path in Dropbox where blog files will be synced. Default: /blog"
>
<input
id="dropbox-path"
type="text"
placeholder="/blog"
value={credentials.dropboxRemotePath}
onChange={(e) => setCredentials({ ...credentials, dropboxRemotePath: e.target.value })}
/>
</SettingRow>
<div className="setting-actions">
<button className="primary" onClick={handleSaveDropbox}>
{dropboxConfigured ? 'Update Configuration' : 'Enable Dropbox Sync'}
</button>
<button className="secondary" onClick={handleTestDropboxConnection}>
Test Connection
</button>
{dropboxConfigured && (
<button className="secondary" onClick={handleDropboxSync}>
Sync Now
</button>
)}
<button className="secondary danger" onClick={() => handleClearCredentials('dropbox')}>
Clear
</button>
</div>
{dropboxConfigured && (
<div className="setting-status success">
<span className="status-icon"></span>
<span>
Dropbox sync is configured
{dropboxLastSync && (
<span className="status-detail"> · Last sync: {new Date(dropboxLastSync).toLocaleString()}</span>
)}
</span>
</div>
)}
</SettingSection>
</>
);
const renderPublishingSettings = () => (
<>
<SettingSection
@@ -878,13 +712,22 @@ export const SettingsView: React.FC = () => {
label="Password"
description="Your FTP account password."
>
<input
id="ftp-password"
type={showSecrets ? 'text' : 'password'}
placeholder="Password"
value={credentials.ftpPassword}
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
/>
<div className="setting-input-group">
<input
id="ftp-password"
type={showSecrets ? 'text' : 'password'}
placeholder="Password"
value={credentials.ftpPassword}
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
/>
<button
className="setting-toggle-visibility"
onClick={() => setShowSecrets(!showSecrets)}
title={showSecrets ? 'Hide password' : 'Show password'}
>
{showSecrets ? '🔒' : '👁'}
</button>
</div>
</SettingRow>
<div className="setting-actions">
@@ -1095,7 +938,6 @@ export const SettingsView: React.FC = () => {
sectionHasMatches(editorKeywords) ||
sectionHasMatches(contentKeywords) ||
sectionHasMatches(aiKeywords) ||
sectionHasMatches(syncKeywords) ||
sectionHasMatches(publishingKeywords) ||
sectionHasMatches(dataKeywords);
@@ -1131,7 +973,6 @@ export const SettingsView: React.FC = () => {
{renderEditorSettings()}
{renderContentSettings()}
{renderAISettings()}
{renderSyncSettings()}
{renderPublishingSettings()}
{renderDataSettings()}
</>

View File

@@ -1031,7 +1031,7 @@ const TagsNav: React.FC = () => {
};
const SettingsNav: React.FC = () => {
const { syncConfigured, tabs, activeTabId, openTab } = useAppStore();
const { tabs, activeTabId, openTab } = useAppStore();
const [activeSection, setActiveSection] = useState<SettingsCategory | null>(null);
// Check if settings panel is currently active
@@ -1086,14 +1086,6 @@ const SettingsNav: React.FC = () => {
<span className="settings-nav-entry-icon">🤖</span>
<span>AI Assistant</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'sync' ? 'active' : ''}`}
onClick={() => handleNavClick('sync')}
>
<span className="settings-nav-entry-icon">🔄</span>
<span>Sync</span>
{syncConfigured && <span className="settings-nav-badge"></span>}
</button>
<button
className={`settings-nav-entry ${activeSection === 'publishing' ? 'active' : ''}`}
onClick={() => handleNavClick('publishing')}

View File

@@ -114,27 +114,6 @@ export interface SyncResult {
errors: string[];
}
export interface DropboxConfig {
accessToken: string;
appKey: string;
remotePath?: string;
}
export interface DropboxSyncResult {
uploaded: number;
downloaded: number;
conflicts: number;
errors?: string[];
}
export interface DropboxConflict {
id: string;
localPath: string;
remotePath: string;
localModified: string;
remoteModified: string;
}
export interface PaginatedPostsResult {
items: PostData[];
hasMore: boolean;
@@ -348,19 +327,6 @@ export interface ElectronAPI {
cancel: (taskId: string) => Promise<boolean>;
clearCompleted: () => Promise<void>;
};
dropbox: {
configure: (config: DropboxConfig) => Promise<void>;
isConfigured: () => Promise<boolean>;
getStatus: () => Promise<string>;
syncAll: () => Promise<DropboxSyncResult>;
startWatching: () => Promise<void>;
stopWatching: () => Promise<void>;
startPolling: () => Promise<void>;
stopPolling: () => Promise<void>;
getConflicts: () => Promise<DropboxConflict[]>;
resolveConflict: (conflictId: string, resolution: 'local-wins' | 'remote-wins') => Promise<void>;
getLastSyncTime: () => Promise<string | null>;
};
app: {
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
openFolder: (folderPath: string) => Promise<string>;