chore: removed dropbox sync
This commit is contained in:
14
.github/copilot-instructions.md
vendored
14
.github/copilot-instructions.md
vendored
@@ -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
|
- Run `npm run build` after any code modifications
|
||||||
- Fix ALL type errors before considering the task complete
|
- Fix ALL build errors before considering the task complete
|
||||||
- Type errors indicate mismatches between APIs and their usage - these MUST be resolved
|
- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build)
|
||||||
- Do NOT ignore or work around type errors with `any` casts
|
- The build must complete successfully before the task is complete
|
||||||
|
|
||||||
> **Zero TypeScript errors. No exceptions.**
|
> **Successful build required. No exceptions.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -1,11 +1,10 @@
|
|||||||
# Blogging Desktop Server (bDS)
|
# 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
|
## Features
|
||||||
|
|
||||||
- **Offline-First**: All data is stored locally in SQLite, works without internet
|
- **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
|
- **VS Code-Inspired UI**: Familiar, clean interface with activity bar, sidebar, and editor
|
||||||
- **Markdown Posts**: Write blog posts in Markdown with YAML frontmatter
|
- **Markdown Posts**: Write blog posts in Markdown with YAML frontmatter
|
||||||
- **Media Management**: Import and manage images with metadata sidecar files
|
- **Media Management**: Import and manage images with metadata sidecar files
|
||||||
@@ -20,7 +19,6 @@ src/
|
|||||||
│ ├── engine/ # Business logic engines
|
│ ├── engine/ # Business logic engines
|
||||||
│ │ ├── PostEngine # Post CRUD, file operations
|
│ │ ├── PostEngine # Post CRUD, file operations
|
||||||
│ │ ├── MediaEngine # Media import/management
|
│ │ ├── MediaEngine # Media import/management
|
||||||
│ │ ├── SyncEngine # Dropbox sync logic
|
|
||||||
│ │ └── TaskManager # Async task handling
|
│ │ └── TaskManager # Async task handling
|
||||||
│ ├── ipc/ # IPC handlers for renderer communication
|
│ ├── ipc/ # IPC handlers for renderer communication
|
||||||
│ └── main.ts # App entry point
|
│ └── main.ts # App entry point
|
||||||
@@ -118,17 +116,6 @@ npx electron-builder
|
|||||||
| Ctrl+1 | View Posts |
|
| Ctrl+1 | View Posts |
|
||||||
| Ctrl+2 | View Media |
|
| Ctrl+2 | View Media |
|
||||||
| Ctrl+Shift+P | Publish Selected |
|
| 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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
|
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
|
only showing all data of the current selected project and all tools are only
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema';
|
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 SyncDirection = 'push' | 'pull' | 'bidirectional';
|
||||||
export type SyncStatus = 'idle' | 'syncing' | 'error';
|
export type SyncStatus = 'idle' | 'syncing' | 'error';
|
||||||
@@ -20,13 +18,6 @@ export interface SyncResult {
|
|||||||
pulled: number;
|
pulled: number;
|
||||||
conflicts: number;
|
conflicts: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
dropboxResult?: {
|
|
||||||
uploaded: number;
|
|
||||||
downloaded: number;
|
|
||||||
deleted: number;
|
|
||||||
conflicts: number;
|
|
||||||
errors: string[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncEngine extends EventEmitter {
|
export class SyncEngine extends EventEmitter {
|
||||||
@@ -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 {
|
isConfigured(): boolean {
|
||||||
const dropboxEngine = getDropboxSyncEngine();
|
return false;
|
||||||
return dropboxEngine.isConfigured();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async configure(config: SyncConfig): Promise<void> {
|
async configure(config: SyncConfig): Promise<void> {
|
||||||
@@ -59,14 +50,7 @@ export class SyncEngine extends EventEmitter {
|
|||||||
this.syncIntervalId = null;
|
this.syncIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start auto-sync if enabled
|
// Auto-sync is disabled as cloud sync is not implemented
|
||||||
if (config.autoSync && config.syncInterval > 0) {
|
|
||||||
this.syncIntervalId = setInterval(
|
|
||||||
() => this.fullSync('bidirectional'),
|
|
||||||
config.syncInterval * 60 * 1000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('configured', config);
|
this.emit('configured', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,110 +143,17 @@ export class SyncEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full sync: Files via Dropbox.
|
* Full sync is not currently implemented.
|
||||||
* Synchronizes posts and media files to Dropbox for backup and cross-device access.
|
* Returns a result indicating sync is not configured.
|
||||||
*/
|
*/
|
||||||
async fullSync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
|
async fullSync(_direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
|
||||||
if (this.syncStatus === 'syncing') {
|
return {
|
||||||
return {
|
success: false,
|
||||||
success: false,
|
|
||||||
pushed: 0,
|
|
||||||
pulled: 0,
|
|
||||||
conflicts: 0,
|
|
||||||
errors: ['Sync already in progress'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: SyncResult = {
|
|
||||||
success: true,
|
|
||||||
pushed: 0,
|
pushed: 0,
|
||||||
pulled: 0,
|
pulled: 0,
|
||||||
conflicts: 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],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,19 +25,6 @@ export {
|
|||||||
getSupportedLanguages,
|
getSupportedLanguages,
|
||||||
type SupportedLanguage,
|
type SupportedLanguage,
|
||||||
} from './stemmer';
|
} 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 {
|
export {
|
||||||
ChatEngine,
|
ChatEngine,
|
||||||
type ChatConversationData,
|
type ChatConversationData,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engine/PostEngine';
|
import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engine/PostEngine';
|
||||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||||
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
||||||
import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../engine/DropboxSyncEngine';
|
|
||||||
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||||
import { getMetaEngine } from '../engine/MetaEngine';
|
import { getMetaEngine } from '../engine/MetaEngine';
|
||||||
import { getTagEngine } from '../engine/TagEngine';
|
import { getTagEngine } from '../engine/TagEngine';
|
||||||
@@ -444,87 +443,6 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.stopAutoSync();
|
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 ============
|
// ============ Task Handlers ============
|
||||||
|
|
||||||
safeHandle('tasks:getAll', async () => {
|
safeHandle('tasks:getAll', async () => {
|
||||||
@@ -986,20 +904,6 @@ export function registerIpcHandlers(): void {
|
|||||||
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
|
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
|
||||||
syncEngine.on('syncFailed', forwardEvent('sync:failed'));
|
syncEngine.on('syncFailed', forwardEvent('sync:failed'));
|
||||||
|
|
||||||
const dropboxEngine = getDropboxSyncEngine();
|
|
||||||
dropboxEngine.on('configured', forwardEvent('dropbox:configured'));
|
|
||||||
dropboxEngine.on('syncStarted', forwardEvent('dropbox:syncStarted'));
|
|
||||||
dropboxEngine.on('syncCompleted', forwardEvent('dropbox:syncCompleted'));
|
|
||||||
dropboxEngine.on('syncFailed', forwardEvent('dropbox:syncFailed'));
|
|
||||||
dropboxEngine.on('fileUploaded', forwardEvent('dropbox:fileUploaded'));
|
|
||||||
dropboxEngine.on('fileDownloaded', forwardEvent('dropbox:fileDownloaded'));
|
|
||||||
dropboxEngine.on('fileDeleted', forwardEvent('dropbox:fileDeleted'));
|
|
||||||
dropboxEngine.on('conflictDetected', forwardEvent('dropbox:conflictDetected'));
|
|
||||||
dropboxEngine.on('conflictResolved', forwardEvent('dropbox:conflictResolved'));
|
|
||||||
dropboxEngine.on('watchStarted', forwardEvent('dropbox:watchStarted'));
|
|
||||||
dropboxEngine.on('watchStopped', forwardEvent('dropbox:watchStopped'));
|
|
||||||
dropboxEngine.on('authError', forwardEvent('dropbox:authError'));
|
|
||||||
|
|
||||||
taskManager.on('taskCreated', forwardEvent('task:created'));
|
taskManager.on('taskCreated', forwardEvent('task:created'));
|
||||||
taskManager.on('taskStarted', forwardEvent('task:started'));
|
taskManager.on('taskStarted', forwardEvent('task:started'));
|
||||||
taskManager.on('taskProgress', forwardEvent('task:progress'));
|
taskManager.on('taskProgress', forwardEvent('task:progress'));
|
||||||
|
|||||||
@@ -88,22 +88,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'),
|
stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Dropbox File Sync
|
|
||||||
dropbox: {
|
|
||||||
configure: (config: unknown) => ipcRenderer.invoke('dropbox:configure', config),
|
|
||||||
isConfigured: () => ipcRenderer.invoke('dropbox:isConfigured'),
|
|
||||||
getStatus: () => ipcRenderer.invoke('dropbox:getStatus'),
|
|
||||||
syncAll: () => ipcRenderer.invoke('dropbox:syncAll'),
|
|
||||||
startWatching: () => ipcRenderer.invoke('dropbox:startWatching'),
|
|
||||||
stopWatching: () => ipcRenderer.invoke('dropbox:stopWatching'),
|
|
||||||
startPolling: () => ipcRenderer.invoke('dropbox:startPolling'),
|
|
||||||
stopPolling: () => ipcRenderer.invoke('dropbox:stopPolling'),
|
|
||||||
getConflicts: () => ipcRenderer.invoke('dropbox:getConflicts'),
|
|
||||||
resolveConflict: (conflictId: string, resolution: string) =>
|
|
||||||
ipcRenderer.invoke('dropbox:resolveConflict', conflictId, resolution),
|
|
||||||
getLastSyncTime: () => ipcRenderer.invoke('dropbox:getLastSyncTime'),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
tasks: {
|
tasks: {
|
||||||
getAll: () => ipcRenderer.invoke('tasks:getAll'),
|
getAll: () => ipcRenderer.invoke('tasks:getAll'),
|
||||||
|
|||||||
@@ -70,24 +70,7 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-configure Dropbox sync from saved credentials
|
// Check sync status
|
||||||
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)
|
|
||||||
const syncConfigured = await window.electronAPI?.sync.isConfigured();
|
const syncConfigured = await window.electronAPI?.sync.isConfigured();
|
||||||
setSyncConfigured(syncConfigured || false);
|
setSyncConfigured(syncConfigured || false);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { showToast } from '../Toast';
|
|||||||
import './SettingsView.css';
|
import './SettingsView.css';
|
||||||
|
|
||||||
// Export category IDs for sidebar navigation
|
// 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
|
// Scroll to a settings section by category ID
|
||||||
export const scrollToSettingsSection = (category: SettingsCategory) => {
|
export const scrollToSettingsSection = (category: SettingsCategory) => {
|
||||||
@@ -17,10 +17,6 @@ export const scrollToSettingsSection = (category: SettingsCategory) => {
|
|||||||
// Settings categories
|
// Settings categories
|
||||||
|
|
||||||
interface Credentials {
|
interface Credentials {
|
||||||
// Dropbox File Sync
|
|
||||||
dropboxAccessToken: string;
|
|
||||||
dropboxAppKey: string;
|
|
||||||
dropboxRemotePath: string;
|
|
||||||
// FTP Publishing
|
// FTP Publishing
|
||||||
ftpHost: string;
|
ftpHost: string;
|
||||||
ftpUser: string;
|
ftpUser: string;
|
||||||
@@ -32,9 +28,6 @@ interface Credentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultCredentials: Credentials = {
|
const defaultCredentials: Credentials = {
|
||||||
dropboxAccessToken: '',
|
|
||||||
dropboxAppKey: '',
|
|
||||||
dropboxRemotePath: '/blog',
|
|
||||||
ftpHost: '',
|
ftpHost: '',
|
||||||
ftpUser: '',
|
ftpUser: '',
|
||||||
ftpPassword: '',
|
ftpPassword: '',
|
||||||
@@ -101,8 +94,6 @@ export const SettingsView: React.FC = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [credentials, setCredentials] = useState<Credentials>(defaultCredentials);
|
const [credentials, setCredentials] = useState<Credentials>(defaultCredentials);
|
||||||
const [showSecrets, setShowSecrets] = useState(false);
|
const [showSecrets, setShowSecrets] = useState(false);
|
||||||
const [dropboxConfigured, setDropboxConfigured] = useState(false);
|
|
||||||
const [dropboxLastSync, setDropboxLastSync] = useState<string | null>(null);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Project settings
|
// Project settings
|
||||||
@@ -193,15 +184,6 @@ export const SettingsView: React.FC = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load AI settings:', 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) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
}
|
}
|
||||||
@@ -209,27 +191,6 @@ export const SettingsView: React.FC = () => {
|
|||||||
loadSettings();
|
loadSettings();
|
||||||
}, [activeProject?.id]); // Reload when project changes
|
}, [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 () => {
|
const handleSavePublishing = async () => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
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 };
|
const newCreds = { ...credentials };
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'dropbox':
|
|
||||||
newCreds.dropboxAccessToken = '';
|
|
||||||
newCreds.dropboxAppKey = '';
|
|
||||||
newCreds.dropboxRemotePath = '/blog';
|
|
||||||
break;
|
|
||||||
case 'ftp':
|
case 'ftp':
|
||||||
newCreds.ftpHost = '';
|
newCreds.ftpHost = '';
|
||||||
newCreds.ftpUser = '';
|
newCreds.ftpUser = '';
|
||||||
@@ -264,36 +220,6 @@ export const SettingsView: React.FC = () => {
|
|||||||
showToast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} credentials cleared`);
|
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
|
// Save project settings
|
||||||
const handleSaveProject = async () => {
|
const handleSaveProject = async () => {
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
@@ -338,7 +264,6 @@ export const SettingsView: React.FC = () => {
|
|||||||
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
|
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
|
||||||
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
|
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
|
||||||
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
|
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 publishingKeywords = ['publishing', 'ftp', 'ssh', 'deploy', 'server', 'host', 'upload'];
|
||||||
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem'];
|
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem'];
|
||||||
|
|
||||||
@@ -746,97 +671,6 @@ export const SettingsView: React.FC = () => {
|
|||||||
</SettingSection>
|
</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 = () => (
|
const renderPublishingSettings = () => (
|
||||||
<>
|
<>
|
||||||
<SettingSection
|
<SettingSection
|
||||||
@@ -878,13 +712,22 @@ export const SettingsView: React.FC = () => {
|
|||||||
label="Password"
|
label="Password"
|
||||||
description="Your FTP account password."
|
description="Your FTP account password."
|
||||||
>
|
>
|
||||||
<input
|
<div className="setting-input-group">
|
||||||
id="ftp-password"
|
<input
|
||||||
type={showSecrets ? 'text' : 'password'}
|
id="ftp-password"
|
||||||
placeholder="Password"
|
type={showSecrets ? 'text' : 'password'}
|
||||||
value={credentials.ftpPassword}
|
placeholder="Password"
|
||||||
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
|
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>
|
</SettingRow>
|
||||||
|
|
||||||
<div className="setting-actions">
|
<div className="setting-actions">
|
||||||
@@ -1095,7 +938,6 @@ export const SettingsView: React.FC = () => {
|
|||||||
sectionHasMatches(editorKeywords) ||
|
sectionHasMatches(editorKeywords) ||
|
||||||
sectionHasMatches(contentKeywords) ||
|
sectionHasMatches(contentKeywords) ||
|
||||||
sectionHasMatches(aiKeywords) ||
|
sectionHasMatches(aiKeywords) ||
|
||||||
sectionHasMatches(syncKeywords) ||
|
|
||||||
sectionHasMatches(publishingKeywords) ||
|
sectionHasMatches(publishingKeywords) ||
|
||||||
sectionHasMatches(dataKeywords);
|
sectionHasMatches(dataKeywords);
|
||||||
|
|
||||||
@@ -1131,7 +973,6 @@ export const SettingsView: React.FC = () => {
|
|||||||
{renderEditorSettings()}
|
{renderEditorSettings()}
|
||||||
{renderContentSettings()}
|
{renderContentSettings()}
|
||||||
{renderAISettings()}
|
{renderAISettings()}
|
||||||
{renderSyncSettings()}
|
|
||||||
{renderPublishingSettings()}
|
{renderPublishingSettings()}
|
||||||
{renderDataSettings()}
|
{renderDataSettings()}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1031,7 +1031,7 @@ const TagsNav: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SettingsNav: React.FC = () => {
|
const SettingsNav: React.FC = () => {
|
||||||
const { syncConfigured, tabs, activeTabId, openTab } = useAppStore();
|
const { tabs, activeTabId, openTab } = useAppStore();
|
||||||
const [activeSection, setActiveSection] = useState<SettingsCategory | null>(null);
|
const [activeSection, setActiveSection] = useState<SettingsCategory | null>(null);
|
||||||
|
|
||||||
// Check if settings panel is currently active
|
// Check if settings panel is currently active
|
||||||
@@ -1086,14 +1086,6 @@ const SettingsNav: React.FC = () => {
|
|||||||
<span className="settings-nav-entry-icon">🤖</span>
|
<span className="settings-nav-entry-icon">🤖</span>
|
||||||
<span>AI Assistant</span>
|
<span>AI Assistant</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
className={`settings-nav-entry ${activeSection === 'publishing' ? 'active' : ''}`}
|
className={`settings-nav-entry ${activeSection === 'publishing' ? 'active' : ''}`}
|
||||||
onClick={() => handleNavClick('publishing')}
|
onClick={() => handleNavClick('publishing')}
|
||||||
|
|||||||
34
src/renderer/types/electron.d.ts
vendored
34
src/renderer/types/electron.d.ts
vendored
@@ -114,27 +114,6 @@ export interface SyncResult {
|
|||||||
errors: string[];
|
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 {
|
export interface PaginatedPostsResult {
|
||||||
items: PostData[];
|
items: PostData[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
@@ -348,19 +327,6 @@ export interface ElectronAPI {
|
|||||||
cancel: (taskId: string) => Promise<boolean>;
|
cancel: (taskId: string) => Promise<boolean>;
|
||||||
clearCompleted: () => Promise<void>;
|
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: {
|
app: {
|
||||||
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
|
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
|
||||||
openFolder: (folderPath: string) => Promise<string>;
|
openFolder: (folderPath: string) => Promise<string>;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,12 @@
|
|||||||
* SyncEngine Unit Tests
|
* SyncEngine Unit Tests
|
||||||
*
|
*
|
||||||
* Tests the REAL SyncEngine class with mocked dependencies.
|
* 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 { 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';
|
import { resetMockCounters } from '../utils/factories';
|
||||||
|
|
||||||
// Create mock data stores
|
// Create mock data stores
|
||||||
@@ -58,7 +59,7 @@ vi.mock('../../src/main/database', () => ({
|
|||||||
getDatabase: vi.fn(() => ({
|
getDatabase: vi.fn(() => ({
|
||||||
getLocal: vi.fn(() => mockLocalDb),
|
getLocal: vi.fn(() => mockLocalDb),
|
||||||
getLocalClient: vi.fn(() => null),
|
getLocalClient: vi.fn(() => null),
|
||||||
getRemote: vi.fn(() => null), // Will be overridden in tests
|
getRemote: vi.fn(() => null),
|
||||||
getDataPaths: vi.fn(() => ({
|
getDataPaths: vi.fn(() => ({
|
||||||
database: '/mock/userData/bds.db',
|
database: '/mock/userData/bds.db',
|
||||||
posts: '/mock/userData/posts',
|
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
|
// Mock uuid
|
||||||
vi.mock('uuid', () => ({
|
vi.mock('uuid', () => ({
|
||||||
v4: vi.fn(() => 'mock-sync-uuid-' + Math.random().toString(36).substr(2, 9)),
|
v4: vi.fn(() => 'mock-sync-uuid-' + Math.random().toString(36).substr(2, 9)),
|
||||||
@@ -120,10 +87,6 @@ describe('SyncEngine', () => {
|
|||||||
mockMedia.clear();
|
mockMedia.clear();
|
||||||
mockSyncLog.clear();
|
mockSyncLog.clear();
|
||||||
resetMockCounters();
|
resetMockCounters();
|
||||||
|
|
||||||
// Reset Dropbox mock state
|
|
||||||
mockDropboxConfigured = false;
|
|
||||||
mockDropboxSyncEngine.isConfigured.mockImplementation(() => mockDropboxConfigured);
|
|
||||||
|
|
||||||
syncEngine = new SyncEngine();
|
syncEngine = new SyncEngine();
|
||||||
});
|
});
|
||||||
@@ -147,27 +110,13 @@ describe('SyncEngine', () => {
|
|||||||
expect(syncEngine.getSyncStatus()).toBe('idle');
|
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);
|
expect(syncEngine.isConfigured()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Configuration', () => {
|
describe('Configuration', () => {
|
||||||
it('should configure sync settings', async () => {
|
it('should emit configured event when configure is called', 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 () => {
|
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
syncEngine.on('configured', handler);
|
syncEngine.on('configured', handler);
|
||||||
|
|
||||||
@@ -181,9 +130,7 @@ describe('SyncEngine', () => {
|
|||||||
expect(handler).toHaveBeenCalledWith(config);
|
expect(handler).toHaveBeenCalledWith(config);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not be configured when Dropbox is not configured', async () => {
|
it('should always return not configured (cloud sync not implemented)', async () => {
|
||||||
mockDropboxConfigured = false;
|
|
||||||
|
|
||||||
const config: SyncConfig = {
|
const config: SyncConfig = {
|
||||||
autoSync: false,
|
autoSync: false,
|
||||||
syncInterval: 30,
|
syncInterval: 30,
|
||||||
@@ -191,103 +138,62 @@ describe('SyncEngine', () => {
|
|||||||
|
|
||||||
await syncEngine.configure(config);
|
await syncEngine.configure(config);
|
||||||
|
|
||||||
|
// Cloud sync is not implemented, so always returns false
|
||||||
expect(syncEngine.isConfigured()).toBe(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', () => {
|
describe('Sync Status', () => {
|
||||||
it('should return idle when not syncing', () => {
|
it('should return idle when not syncing', () => {
|
||||||
expect(syncEngine.getSyncStatus()).toBe('idle');
|
expect(syncEngine.getSyncStatus()).toBe('idle');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Sync without Configuration', () => {
|
describe('Sync Operations', () => {
|
||||||
it('should return error when syncing without configuration', async () => {
|
it('should return error when syncing (cloud sync not implemented)', async () => {
|
||||||
const result = await syncEngine.sync('bidirectional');
|
const result = await syncEngine.sync('bidirectional');
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
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');
|
const result = await syncEngine.sync('push');
|
||||||
|
|
||||||
expect(result.pushed).toBe(0);
|
expect(result.pushed).toBe(0);
|
||||||
expect(result.pulled).toBe(0);
|
expect(result.pulled).toBe(0);
|
||||||
expect(result.conflicts).toBe(0);
|
expect(result.conflicts).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('Sync Directions', () => {
|
|
||||||
it('should accept push direction', async () => {
|
it('should accept push direction', async () => {
|
||||||
const result = await syncEngine.sync('push');
|
const result = await syncEngine.sync('push');
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept pull direction', async () => {
|
it('should accept pull direction', async () => {
|
||||||
const result = await syncEngine.sync('pull');
|
const result = await syncEngine.sync('pull');
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept bidirectional direction', async () => {
|
it('should accept bidirectional direction', async () => {
|
||||||
const result = await syncEngine.sync('bidirectional');
|
const result = await syncEngine.sync('bidirectional');
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should default to bidirectional when no direction specified', async () => {
|
it('should default to bidirectional when no direction specified', async () => {
|
||||||
const result = await syncEngine.sync();
|
const result = await syncEngine.sync();
|
||||||
expect(result).toBeDefined();
|
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 () => {
|
it('should return zero counts when no pending changes', async () => {
|
||||||
// With empty mock data
|
|
||||||
const count = await syncEngine.getPendingChangesCount();
|
const count = await syncEngine.getPendingChangesCount();
|
||||||
|
|
||||||
expect(count.posts).toBeGreaterThanOrEqual(0);
|
expect(count.posts).toBeGreaterThanOrEqual(0);
|
||||||
@@ -397,278 +302,4 @@ describe('SyncEngine', () => {
|
|||||||
}).not.toThrow();
|
}).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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user