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

@@ -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.**
---

View File

@@ -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

View File

@@ -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

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>;

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,12 @@
* SyncEngine Unit Tests
*
* Tests the REAL SyncEngine class with mocked dependencies.
* Following TDD best practices: mock external dependencies, test real implementation.
* Note: Cloud sync is currently not implemented, so SyncEngine
* always returns "not configured" status.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SyncEngine, SyncConfig, SyncResult, SyncDirection } from '../../src/main/engine/SyncEngine';
import { SyncEngine, SyncConfig } from '../../src/main/engine/SyncEngine';
import { resetMockCounters } from '../utils/factories';
// Create mock data stores
@@ -58,7 +59,7 @@ vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => null), // Will be overridden in tests
getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
@@ -71,40 +72,6 @@ vi.mock('../../src/main/database', () => ({
})),
}));
// Mock PostEngine and MediaEngine
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
on: vi.fn(),
emit: vi.fn(),
})),
}));
vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({
on: vi.fn(),
emit: vi.fn(),
})),
}));
// Mock DropboxSyncEngine
let mockDropboxConfigured = false;
const mockDropboxSyncEngine = {
isConfigured: vi.fn(() => mockDropboxConfigured),
configure: vi.fn(async () => {}),
syncAll: vi.fn(async () => ({
success: true,
uploaded: 0,
downloaded: 0,
deleted: 0,
conflicts: 0,
errors: [],
})),
};
vi.mock('../../src/main/engine/DropboxSyncEngine', () => ({
getDropboxSyncEngine: vi.fn(() => mockDropboxSyncEngine),
}));
// Mock uuid
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-sync-uuid-' + Math.random().toString(36).substr(2, 9)),
@@ -120,10 +87,6 @@ describe('SyncEngine', () => {
mockMedia.clear();
mockSyncLog.clear();
resetMockCounters();
// Reset Dropbox mock state
mockDropboxConfigured = false;
mockDropboxSyncEngine.isConfigured.mockImplementation(() => mockDropboxConfigured);
syncEngine = new SyncEngine();
});
@@ -147,27 +110,13 @@ describe('SyncEngine', () => {
expect(syncEngine.getSyncStatus()).toBe('idle');
});
it('should not be configured initially', () => {
it('should not be configured (cloud sync is not implemented)', () => {
expect(syncEngine.isConfigured()).toBe(false);
});
});
describe('Configuration', () => {
it('should configure sync settings', async () => {
// Mark Dropbox as configured
mockDropboxConfigured = true;
const config: SyncConfig = {
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
expect(syncEngine.isConfigured()).toBe(true);
});
it('should emit configured event', async () => {
it('should emit configured event when configure is called', async () => {
const handler = vi.fn();
syncEngine.on('configured', handler);
@@ -181,9 +130,7 @@ describe('SyncEngine', () => {
expect(handler).toHaveBeenCalledWith(config);
});
it('should not be configured when Dropbox is not configured', async () => {
mockDropboxConfigured = false;
it('should always return not configured (cloud sync not implemented)', async () => {
const config: SyncConfig = {
autoSync: false,
syncInterval: 30,
@@ -191,103 +138,62 @@ describe('SyncEngine', () => {
await syncEngine.configure(config);
// Cloud sync is not implemented, so always returns false
expect(syncEngine.isConfigured()).toBe(false);
});
});
describe('Auto Sync', () => {
it('should start auto sync when enabled', async () => {
mockDropboxConfigured = true;
const config: SyncConfig = {
autoSync: true,
syncInterval: 1, // 1 minute
};
await syncEngine.configure(config);
// Auto sync should be scheduled
expect(syncEngine.isConfigured()).toBe(true);
});
it('should stop auto sync when called', async () => {
const handler = vi.fn();
syncEngine.on('autoSyncStopped', handler);
mockDropboxConfigured = true;
const config: SyncConfig = {
autoSync: true,
syncInterval: 1,
};
await syncEngine.configure(config);
syncEngine.stopAutoSync();
expect(handler).toHaveBeenCalled();
});
it('should stop previous auto sync when reconfiguring', async () => {
mockDropboxConfigured = true;
const config1: SyncConfig = {
autoSync: true,
syncInterval: 1,
};
const config2: SyncConfig = {
autoSync: true,
syncInterval: 5,
};
await syncEngine.configure(config1);
await syncEngine.configure(config2);
expect(syncEngine.isConfigured()).toBe(true);
});
});
describe('Sync Status', () => {
it('should return idle when not syncing', () => {
expect(syncEngine.getSyncStatus()).toBe('idle');
});
});
describe('Sync without Configuration', () => {
it('should return error when syncing without configuration', async () => {
describe('Sync Operations', () => {
it('should return error when syncing (cloud sync not implemented)', async () => {
const result = await syncEngine.sync('bidirectional');
expect(result.success).toBe(false);
expect(result.errors).toContain('Dropbox sync not configured');
expect(result.errors).toContain('Cloud sync not configured');
});
it('should return zero counts when not configured', async () => {
it('should return zero counts when sync is not available', async () => {
const result = await syncEngine.sync('push');
expect(result.pushed).toBe(0);
expect(result.pulled).toBe(0);
expect(result.conflicts).toBe(0);
});
});
describe('Sync Directions', () => {
it('should accept push direction', async () => {
const result = await syncEngine.sync('push');
expect(result).toBeDefined();
expect(result.success).toBe(false);
});
it('should accept pull direction', async () => {
const result = await syncEngine.sync('pull');
expect(result).toBeDefined();
expect(result.success).toBe(false);
});
it('should accept bidirectional direction', async () => {
const result = await syncEngine.sync('bidirectional');
expect(result).toBeDefined();
expect(result.success).toBe(false);
});
it('should default to bidirectional when no direction specified', async () => {
const result = await syncEngine.sync();
expect(result).toBeDefined();
expect(result.success).toBe(false);
});
it('should use fullSync as alias for sync', async () => {
const result = await syncEngine.fullSync('push');
expect(result).toBeDefined();
expect(result.success).toBe(false);
expect(result.errors).toContain('Cloud sync not configured');
});
});
@@ -351,7 +257,6 @@ describe('SyncEngine', () => {
});
it('should return zero counts when no pending changes', async () => {
// With empty mock data
const count = await syncEngine.getPendingChangesCount();
expect(count.posts).toBeGreaterThanOrEqual(0);
@@ -397,278 +302,4 @@ describe('SyncEngine', () => {
}).not.toThrow();
});
});
describe('Sync Configuration Validation', () => {
it('should not be configured when Dropbox is not set up', async () => {
mockDropboxConfigured = false;
await syncEngine.configure({
autoSync: false,
syncInterval: 30,
});
expect(syncEngine.isConfigured()).toBe(false);
});
it('should be configured when Dropbox is set up', async () => {
mockDropboxConfigured = true;
await syncEngine.configure({
autoSync: false,
syncInterval: 30,
});
expect(syncEngine.isConfigured()).toBe(true);
});
});
describe('Sync Interval Configuration', () => {
it('should accept sync interval in minutes', async () => {
mockDropboxConfigured = true;
const config: SyncConfig = {
autoSync: true,
syncInterval: 15, // 15 minutes
};
await syncEngine.configure(config);
expect(syncEngine.isConfigured()).toBe(true);
});
it('should not set auto sync with zero interval', async () => {
mockDropboxConfigured = true;
const config: SyncConfig = {
autoSync: true,
syncInterval: 0,
};
await syncEngine.configure(config);
// Should not crash, but won't set up interval
expect(syncEngine.isConfigured()).toBe(true);
});
});
describe('SyncStatus Reset on Failure', () => {
it('should reset syncStatus to idle when task manager throws', async () => {
const { getDatabase } = await import('../../src/main/database');
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => {
throw new Error('Database exploded');
}),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => mockRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
mockDropboxConfigured = true;
// Mock Dropbox sync to fail
mockDropboxSyncEngine.syncAll.mockRejectedValue(new Error('Database exploded'));
const config: SyncConfig = {
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
// First sync should fail
await syncEngine.sync('push');
// Status should be reset to allow future syncs
expect(syncEngine.getSyncStatus()).not.toBe('syncing');
// Reset mock for second call
mockDropboxSyncEngine.syncAll.mockRejectedValue(new Error('Database exploded'));
// A subsequent sync should not return "Sync already in progress"
const result = await syncEngine.sync('push');
expect(result.errors).not.toContain('Sync already in progress');
});
it('should reset syncStatus to error after failure', async () => {
const { getDatabase } = await import('../../src/main/database');
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => {
throw new Error('Database error');
}),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => mockRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
mockDropboxConfigured = true;
// Mock Dropbox sync to fail
mockDropboxSyncEngine.syncAll.mockRejectedValue(new Error('Database error'));
const config: SyncConfig = {
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
await syncEngine.sync('push');
// After failure, status should be 'error' or 'idle', not 'syncing'
const status = syncEngine.getSyncStatus();
expect(status === 'error' || status === 'idle').toBe(true);
});
});
describe('Console Logging', () => {
it('should log sync start with direction', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockDropboxConfigured = true;
const config: SyncConfig = {
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
await syncEngine.sync('push');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[SyncEngine]'),
expect.stringContaining('push')
);
consoleSpy.mockRestore();
});
it('should log sync completion with results', async () => {
// Use real timers for this test as TaskManager may use timers
vi.useRealTimers();
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { getDatabase } = await import('../../src/main/database');
// Reset the Dropbox sync mock to return success (might have been modified by previous test)
mockDropboxSyncEngine.syncAll.mockResolvedValue({
success: true,
uploaded: 0,
downloaded: 0,
deleted: 0,
conflicts: 0,
errors: [],
});
// Mock a complete sync with no pending items (so it completes quickly)
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
all: vi.fn().mockResolvedValue([]), // No pending items
})),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => mockRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
mockDropboxConfigured = true;
const config: SyncConfig = {
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
const result = await syncEngine.sync('push');
// Debug: check what was logged
const calls = consoleSpy.mock.calls;
// Check that sync completed successfully
expect(result.success).toBe(true);
// Check that at least one log call contains "[SyncEngine]" and "complete"
const hasCompleteLog = calls.some((call: any[]) =>
call.some((arg: any) => typeof arg === 'string' && arg.includes('[SyncEngine]') && arg.includes('complete'))
);
expect(hasCompleteLog).toBe(true);
consoleSpy.mockRestore();
});
it('should log errors when sync fails', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { getDatabase } = await import('../../src/main/database');
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => {
throw new Error('Test error for logging');
}),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => mockRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
mockDropboxConfigured = true;
// Mock Dropbox sync to fail
mockDropboxSyncEngine.syncAll.mockRejectedValue(new Error('Test error for logging'));
const config: SyncConfig = {
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
await syncEngine.sync('push');
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('[SyncEngine]'),
expect.anything()
);
consoleErrorSpy.mockRestore();
});
});
});