diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2c7140c..101bfa3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -59,16 +59,16 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo --- -## ⚠️ MANDATORY: TypeScript Checks After Code Changes +## ⚠️ MANDATORY: Build Verification After Code Changes -**You MUST run TypeScript type checking after making code changes.** +**You MUST run the full build after making code changes.** -- Run `npx tsc --noEmit` after any code modifications -- Fix ALL type errors before considering the task complete -- Type errors indicate mismatches between APIs and their usage - these MUST be resolved -- Do NOT ignore or work around type errors with `any` casts +- Run `npm run build` after any code modifications +- Fix ALL build errors before considering the task complete +- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build) +- The build must complete successfully before the task is complete -> **Zero TypeScript errors. No exceptions.** +> **Successful build required. No exceptions.** --- diff --git a/README.md b/README.md index 0edc173..11b1bc7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Blogging Desktop Server (bDS) -A desktop blogging application with offline-first capabilities and cloud sync via Dropbox. +A desktop blogging application with offline-first capabilities. ## Features - **Offline-First**: All data is stored locally in SQLite, works without internet -- **Cloud Sync**: Synchronize files with Dropbox for multi-device access - **VS Code-Inspired UI**: Familiar, clean interface with activity bar, sidebar, and editor - **Markdown Posts**: Write blog posts in Markdown with YAML frontmatter - **Media Management**: Import and manage images with metadata sidecar files @@ -20,7 +19,6 @@ src/ │ ├── engine/ # Business logic engines │ │ ├── PostEngine # Post CRUD, file operations │ │ ├── MediaEngine # Media import/management -│ │ ├── SyncEngine # Dropbox sync logic │ │ └── TaskManager # Async task handling │ ├── ipc/ # IPC handlers for renderer communication │ └── main.ts # App entry point @@ -118,17 +116,6 @@ npx electron-builder | Ctrl+1 | View Posts | | Ctrl+2 | View Media | | Ctrl+Shift+P | Publish Selected | -| Ctrl+Shift+S | Sync Now | - -## Cloud Sync Setup - -1. Create a Dropbox App at https://www.dropbox.com/developers/apps -2. Generate an access token for your app -3. Go to Settings in the app -4. Enter your Dropbox credentials (access token, app key, remote path) -5. Click "Configure Dropbox" - -Files are synced to Dropbox for backup and multi-device access. ## License diff --git a/VISION.md b/VISION.md index 79ecd56..bd4f324 100644 --- a/VISION.md +++ b/VISION.md @@ -38,10 +38,6 @@ reflections in the filesystem, so available tags, available categories, all those things must be automatically reflected to the filesystem in a per-project way. Use a meta/ folder under the project folder for those files. -There should be good cloud-storage based syncing that can be triggered when -online again and should use asynchronous syncing with auto-resolving of issues. -The solution should be end-user friendly and not too technical in setup. - The application must be able to support multiple projects (ie web sites), so there must be a way to create new projects and select current project. The UI is only showing all data of the current selected project and all tools are only diff --git a/src/main/engine/DropboxSyncEngine.ts b/src/main/engine/DropboxSyncEngine.ts deleted file mode 100644 index 58126fb..0000000 --- a/src/main/engine/DropboxSyncEngine.ts +++ /dev/null @@ -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) => FSWatcher; - -let _chokidarWatch: ChokidarWatchFn | null = null; -async function getChokidarWatch(): Promise { - 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 = new Map(); - private cursor: string | null = null; - private lastSyncTime: Date | null = null; - private isSyncing = false; - - // Debounce tracking for local file changes - private pendingUploads: Map = new Map(); - private uploadDebounceMs = 1000; - - // Track files we wrote ourselves (to ignore watcher events) - private recentDownloads: Set = new Set(); - - constructor(dropboxClient?: Dropbox, watchFn?: ChokidarWatchFn) { - super(); - this.dropboxClient = dropboxClient || new Dropbox({}); - this.watchFn = watchFn || null; - } - - private async getWatchFn(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; - 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> { - const files = new Map(); - 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> { - const files = new Map(); - - 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 - ): Promise { - 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 { - 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 { - 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 { - 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 { - 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; -} diff --git a/src/main/engine/SyncEngine.ts b/src/main/engine/SyncEngine.ts index bffa6be..d0eaeb5 100644 --- a/src/main/engine/SyncEngine.ts +++ b/src/main/engine/SyncEngine.ts @@ -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 { @@ -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 { - 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 { + 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 = { - 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], - }; - } } } diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index b9c4a36..3750341 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -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, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 619689b..da51140 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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) => { - 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')); diff --git a/src/main/preload.ts b/src/main/preload.ts index 4718c73..7d0a404 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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'), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3f37a9e..049dac9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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); diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 6af2137..cab4a13 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -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(defaultCredentials); const [showSecrets, setShowSecrets] = useState(false); - const [dropboxConfigured, setDropboxConfigured] = useState(false); - const [dropboxLastSync, setDropboxLastSync] = useState(null); const contentRef = useRef(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 = () => { ); - const renderSyncSettings = () => ( - <> - - - ); - const renderPublishingSettings = () => ( <> { label="Password" description="Your FTP account password." > - setCredentials({ ...credentials, ftpPassword: e.target.value })} - /> +
+ setCredentials({ ...credentials, ftpPassword: e.target.value })} + /> + +
@@ -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()} diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 66becfa..536b170 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -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(null); // Check if settings panel is currently active @@ -1086,14 +1086,6 @@ const SettingsNav: React.FC = () => { 🤖 AI Assistant -