diff --git a/components/ConflictResolutionModal.tsx b/components/ConflictResolutionModal.tsx new file mode 100644 index 0000000..f2d60c7 --- /dev/null +++ b/components/ConflictResolutionModal.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import Modal from './Modal'; +import Button from './Button'; +import { WarningIcon } from './Icons'; + +interface DbStats { + fileSize: string; + nodeCount: number; + documentCount: number; + templateCount: number; + modifiedTime: string; +} + +interface ConflictResolutionModalProps { + localStats: DbStats; + remoteStats: DbStats; + onResolve: (resolution: 'local' | 'remote') => void; + onClose: () => void; +} + +const ConflictResolutionModal: React.FC = ({ + localStats, + remoteStats, + onResolve, + onClose, +}) => { + const localDate = new Date(localStats.modifiedTime); + const remoteDate = new Date(remoteStats.modifiedTime); + + const isLocalNewer = localDate.getTime() > remoteDate.getTime(); + const isRemoteNewer = remoteDate.getTime() > localDate.getTime(); + + return ( + +
+ {/* Warning Banner */} +
+
+ +
+
+

Divergent Database Changes

+

+ Both your local database and the cloud database have been modified independently since the last sync. + Please select which version you would like to keep. The other version will be overwritten. +

+
+
+ + {/* Comparison Area */} +
+ {/* Local Stats Card */} +
+
+
+
Local Database
+ {isLocalNewer && ( + + Newer + + )} +
+
    +
  • + Last Modified: + + {localDate.toLocaleString()} + +
  • +
  • + File Size: + {localStats.fileSize} +
  • +
  • + Folders & Documents: + {localStats.nodeCount} +
  • +
  • + Document Templates: + {localStats.templateCount} +
  • +
+
+
+ +
+
+ + {/* Cloud Stats Card */} +
+
+
+
Cloud Database
+ {isRemoteNewer && ( + + Newer + + )} +
+
    +
  • + Last Modified: + + {remoteDate.toLocaleString()} + +
  • +
  • + File Size: + {remoteStats.fileSize} +
  • +
  • + Folders & Documents: + {remoteStats.nodeCount} +
  • +
  • + Document Templates: + {remoteStats.templateCount} +
  • +
+
+
+ +
+
+
+ + {/* Footer */} +
+ +
+
+
+ ); +}; + +export default ConflictResolutionModal; diff --git a/components/Icons.tsx b/components/Icons.tsx index 358eeed..bcd691b 100644 --- a/components/Icons.tsx +++ b/components/Icons.tsx @@ -100,3 +100,9 @@ export const ChatIcon: React.FC = ({ className }) => ( ); + +export const CloudIcon: React.FC = ({ className }) => ( + + + +); diff --git a/components/SettingsView.tsx b/components/SettingsView.tsx index 097021e..ae8ed8d 100644 --- a/components/SettingsView.tsx +++ b/components/SettingsView.tsx @@ -8,6 +8,7 @@ import { DatabaseIcon, KeyboardIcon, TerminalIcon, + CloudIcon, } from './Icons'; import Button from './Button'; import KeyboardShortcutsSection from './KeyboardShortcutsSection'; @@ -22,6 +23,7 @@ import { PythonSettingsSection } from './settings/PythonSettingsSection'; import { ScriptDefaultsSection } from './settings/ScriptDefaultsSection'; import { GeneralSettingsSection } from './settings/GeneralSettingsSection'; import { DatabaseSettingsSection } from './settings/DatabaseSettingsSection'; +import { CloudSyncSettingsSection } from './settings/CloudSyncSettingsSection'; import { AdvancedSettingsSection } from './settings/AdvancedSettingsSection'; interface SettingsViewProps { @@ -44,6 +46,7 @@ type SettingsCategory = | 'powershell' | 'general' | 'database' + | 'sync' | 'advanced'; const categories: { id: SettingsCategory; label: string; icon: React.FC<{ className?: string }> }[] = [ @@ -57,6 +60,7 @@ const categories: { id: SettingsCategory; label: string; icon: React.FC<{ classN { id: 'powershell', label: 'PowerShell', icon: TerminalIcon }, { id: 'general', label: 'General', icon: GearIcon }, { id: 'database', label: 'Database', icon: DatabaseIcon }, + { id: 'sync', label: 'Cloud Sync', icon: CloudIcon }, { id: 'advanced', label: 'Advanced', icon: FileIcon }, ]; @@ -222,6 +226,13 @@ const SettingsView: React.FC = ({ ); case 'database': return ; + case 'sync': + return ( + + ); case 'advanced': return ( > = ({ + settings, + setCurrentSettings, +}) => { + const isElectron = typeof window !== 'undefined' && !!window.electronAPI; + const { addLog } = useLogger(); + + const [clientId, setClientId] = useState(settings.syncClientId || ''); + const [clientSecret, setClientSecret] = useState(settings.syncClientSecret || ''); + const [isConnecting, setIsConnecting] = useState(false); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [syncStatusMsg, setSyncStatusMsg] = useState(null); + const [syncStatusTone, setSyncStatusTone] = useState<'info' | 'success' | 'error'>('info'); + + const [conflictData, setConflictData] = useState<{ + localStats: any; + remoteStats: any; + } | null>(null); + + // Sync state changes from main process + useEffect(() => { + if (!isElectron || !window.electronAPI?.onSyncStatus) return; + + const unsubscribe = window.electronAPI.onSyncStatus((payload) => { + if (payload.status === 'syncing') { + setIsSyncing(true); + setSyncStatusTone('info'); + setSyncStatusMsg(payload.message || 'Syncing...'); + } else if (payload.status === 'conflict') { + setIsSyncing(false); + setSyncStatusTone('error'); + setSyncStatusMsg('Sync conflict detected.'); + } else if (payload.status === 'error') { + setIsSyncing(false); + setSyncStatusTone('error'); + setSyncStatusMsg(payload.message || 'Sync failed.'); + } else { + setIsSyncing(false); + setSyncStatusTone('success'); + setSyncStatusMsg(payload.message || 'Sync complete.'); + } + }); + + return unsubscribe; + }, [isElectron]); + + // Sync Client ID & Secret back to currentSettings whenever they change + const updateCredentials = useCallback(() => { + setCurrentSettings((prev) => ({ + ...prev, + syncClientId: clientId, + syncClientSecret: clientSecret, + })); + }, [clientId, clientSecret, setCurrentSettings]); + + const handleConnect = useCallback(async () => { + if (!isElectron || !window.electronAPI?.syncGoogleConnect) return; + if (!clientId.trim() || !clientSecret.trim()) return; + + setIsConnecting(true); + setSyncStatusMsg('Connecting to Google Drive...'); + setSyncStatusTone('info'); + addLog('INFO', 'User action: Initiating Google Drive connection.'); + + try { + const result = await window.electronAPI.syncGoogleConnect(clientId.trim(), clientSecret.trim()); + if (result.success && result.email) { + addLog('INFO', `Google Drive connected successfully to ${result.email}`); + setCurrentSettings((prev) => ({ + ...prev, + syncEnabled: true, + syncGoogleEmail: result.email ?? null, + syncClientId: clientId.trim(), + syncClientSecret: clientSecret.trim(), + })); + setSyncStatusTone('success'); + setSyncStatusMsg(`Successfully connected to ${result.email}`); + } else { + const errorMsg = result.error || 'Connection failed.'; + addLog('ERROR', `Google Drive connection failed: ${errorMsg}`); + setSyncStatusTone('error'); + setSyncStatusMsg(errorMsg); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Authentication failed.'; + addLog('ERROR', `Google Drive OAuth Exception: ${errorMsg}`); + setSyncStatusTone('error'); + setSyncStatusMsg(errorMsg); + } finally { + setIsConnecting(false); + } + }, [isElectron, clientId, clientSecret, addLog, setCurrentSettings]); + + const handleDisconnect = useCallback(async () => { + if (!isElectron || !window.electronAPI?.syncGoogleDisconnect) return; + + setIsDisconnecting(true); + addLog('INFO', 'User action: Disconnecting Google Drive.'); + + try { + const result = await window.electronAPI.syncGoogleDisconnect(); + if (result.success) { + addLog('INFO', 'Google Drive disconnected.'); + setCurrentSettings((prev) => ({ + ...prev, + syncEnabled: false, + syncGoogleEmail: null, + syncGoogleRefreshToken: null, + syncLastCompletedAt: null, + })); + setClientId(''); + setClientSecret(''); + setSyncStatusMsg('Disconnected successfully.'); + setSyncStatusTone('success'); + } else { + setSyncStatusTone('error'); + setSyncStatusMsg(result.error || 'Failed to disconnect.'); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Disconnection failed.'; + setSyncStatusTone('error'); + setSyncStatusMsg(errorMsg); + } finally { + setIsDisconnecting(false); + } + }, [isElectron, addLog, setCurrentSettings]); + + const handleSyncNow = useCallback(async () => { + if (!isElectron || !window.electronAPI?.syncRun) return; + + setIsSyncing(true); + setSyncStatusMsg('Synchronizing...'); + setSyncStatusTone('info'); + addLog('INFO', 'User action: Sync Now triggered.'); + + try { + const result = await window.electronAPI.syncRun(); + if (result.success) { + if (result.code === 'conflict' && result.localStats && result.remoteStats) { + setConflictData({ + localStats: result.localStats, + remoteStats: result.remoteStats, + }); + setSyncStatusMsg('Conflict detected between local and cloud databases.'); + setSyncStatusTone('error'); + } else { + setSyncStatusTone('success'); + setSyncStatusMsg(result.message || 'Sync completed successfully.'); + + // Fetch updated config details (like lastCompletedAt) + const config = await window.electronAPI.syncGetConfig(); + setCurrentSettings((prev) => ({ + ...prev, + syncLastCompletedAt: config.lastCompletedAt ?? null, + })); + } + } else { + setSyncStatusTone('error'); + setSyncStatusMsg(result.error || 'Synchronization failed.'); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Sync execution failed.'; + setSyncStatusTone('error'); + setSyncStatusMsg(errorMsg); + } finally { + setIsSyncing(false); + } + }, [isElectron, addLog, setCurrentSettings]); + + const handleResolveConflict = useCallback( + async (resolution: 'local' | 'remote') => { + if (!isElectron || !window.electronAPI?.syncResolveConflict) return; + + setIsSyncing(true); + setConflictData(null); + setSyncStatusMsg(`Resolving conflict using ${resolution} database...`); + setSyncStatusTone('info'); + addLog('INFO', `User action: Resolving conflict preferring ${resolution}.`); + + try { + const result = await window.electronAPI.syncResolveConflict(resolution); + if (result.success) { + setSyncStatusTone('success'); + setSyncStatusMsg(result.message || 'Conflict resolved successfully.'); + + const config = await window.electronAPI.syncGetConfig(); + setCurrentSettings((prev) => ({ + ...prev, + syncLastCompletedAt: config.lastCompletedAt ?? null, + })); + } else { + setSyncStatusTone('error'); + setSyncStatusMsg(result.error || 'Failed to resolve conflict.'); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to resolve conflict.'; + setSyncStatusTone('error'); + setSyncStatusMsg(errorMsg); + } finally { + setIsSyncing(false); + } + }, + [isElectron, addLog, setCurrentSettings] + ); + + const isConnected = !!settings.syncGoogleEmail; + const isInputDisabled = isConnecting || isDisconnecting || isSyncing || isConnected; + + const toneClass = + syncStatusTone === 'error' + ? 'text-destructive-text' + : syncStatusTone === 'success' + ? 'text-success' + : 'text-text-secondary'; + + return ( +
+

Cloud Sync

+ + {!isElectron && ( +
+ Cloud synchronization is only supported in the desktop build of DocForge. +
+ )} + +
+ {/* Credentials Form */} + + setClientId(e.target.value)} + onBlur={updateCredentials} + disabled={isInputDisabled} + className="w-full md:w-80 px-3 py-1.5 text-xs rounded-md border border-border-color bg-background text-text-main focus:outline-none focus:ring-1 focus:ring-primary/50" + placeholder="OAuth Client ID" + /> + + + + setClientSecret(e.target.value)} + onBlur={updateCredentials} + disabled={isInputDisabled} + className="w-full md:w-80 px-3 py-1.5 text-xs rounded-md border border-border-color bg-background text-text-main focus:outline-none focus:ring-1 focus:ring-primary/50" + placeholder="OAuth Client Secret" + /> + + + {/* OAuth Authentication Status */} + +
+ {isConnected ? ( +
+ + Connected: {settings.syncGoogleEmail} + + +
+ ) : ( + + )} +
+
+ + {/* Sync Controls (Visible only when connected) */} + {isConnected && ( + <> + + setCurrentSettings((s) => ({ ...s, syncEnabled: val }))} + /> + + + + setCurrentSettings((s) => ({ ...s, syncAutoOnOpenClose: val }))} + /> + + + + + + + +
+ +
+
+ + )} + + {/* Sync Status Logs / Messages */} + {syncStatusMsg && ( +
+ {syncStatusMsg} + {isSyncing && ( +
+ )} +
+ )} +
+ + {/* Conflict Resolution Modal */} + {conflictData && ( + setConflictData(null)} + /> + )} +
+ ); +}; diff --git a/electron/database.ts b/electron/database.ts index c4a9ed3..0dbf184 100644 --- a/electron/database.ts +++ b/electron/database.ts @@ -1319,6 +1319,36 @@ export const databaseService = { }; }, + getStatsForFile(filePath: string) { + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + const fileSize = statSync(filePath).size; + let connection: Database.Database | null = null; + try { + connection = new Database(filePath, { readonly: true }); + const nodeCountRow = connection.prepare("SELECT COUNT(*) as count FROM nodes").get() as { count: number } | undefined; + const docCountRow = connection.prepare("SELECT COUNT(*) as count FROM nodes WHERE node_type = 'document'").get() as { count: number } | undefined; + const templateCountRow = connection.prepare("SELECT COUNT(*) as count FROM templates").get() as { count: number } | undefined; + const modifiedTime = statSync(filePath).mtime.toISOString(); + + return { + fileSize: `${(fileSize / 1024).toFixed(2)} KB`, + nodeCount: nodeCountRow?.count ?? 0, + documentCount: docCountRow?.count ?? 0, + templateCount: templateCountRow?.count ?? 0, + modifiedTime, + }; + } catch (e: any) { + console.error('Failed to get stats for database file:', filePath, e); + throw e; + } finally { + if (connection) { + connection.close(); + } + } + }, + // ================================================================= // RAG (Chat with Workspace) Methods // ================================================================= diff --git a/electron/gdrive.ts b/electron/gdrive.ts new file mode 100644 index 0000000..624a811 --- /dev/null +++ b/electron/gdrive.ts @@ -0,0 +1,333 @@ +import http from 'http'; +import url from 'url'; +import fs from 'fs/promises'; +import { databaseService } from './database'; + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; +} + +interface UserInfoResponse { + email: string; +} + +interface DriveFile { + id: string; + name: string; + mimeType: string; + modifiedTime: string; + md5Checksum: string; +} + +interface DriveSearchResponse { + files: DriveFile[]; +} + +export class GoogleDriveService { + private static PORT = 52080; + private static REDIRECT_URI = `http://127.0.0.1:${GoogleDriveService.PORT}`; + + // Refreshes the access token using the stored refresh token + static async refreshAccessToken( + clientId: string, + clientSecret: string, + refreshToken: string + ): Promise<{ accessToken: string; expiresIn: number }> { + console.log('[Sync] Refreshing Google OAuth access token...'); + const params = new URLSearchParams(); + params.append('client_id', clientId); + params.append('client_secret', clientSecret); + params.append('refresh_token', refreshToken); + params.append('grant_type', 'refresh_token'); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!res.ok) { + const errText = await res.text(); + console.error('[Sync] Failed to refresh token:', errText); + throw new Error(`Failed to refresh access token: ${res.statusText} (${errText})`); + } + + const data = (await res.json()) as TokenResponse; + return { + accessToken: data.access_token, + expiresIn: data.expires_in, + }; + } + + // Connect / Authenticate OAuth2 flow + static startOAuthFlow( + clientId: string, + clientSecret: string, + onSuccess: (tokens: { accessToken: string; refreshToken: string; email: string }) => void, + onFailure: (error: string) => void + ): { authUrl: string; closeServer: () => void } { + console.log('[Sync] Starting Google OAuth local listener server...'); + + const scopes = [ + 'https://www.googleapis.com/auth/drive.appdata', + 'https://www.googleapis.com/auth/userinfo.email', + ].join(' '); + + const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${encodeURIComponent(clientId)}&` + + `redirect_uri=${encodeURIComponent(this.REDIRECT_URI)}&` + + `response_type=code&` + + `scope=${encodeURIComponent(scopes)}&` + + `access_type=offline&` + + `prompt=consent`; + + let server: http.Server | null = null; + + const closeServer = () => { + if (server) { + server.close(() => { + console.log('[Sync] OAuth server closed.'); + }); + server = null; + } + }; + + server = http.createServer(async (req, res) => { + const parsedUrl = url.parse(req.url ?? '', true); + const code = parsedUrl.query.code as string | undefined; + const error = parsedUrl.query.error as string | undefined; + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Authentication Failed

Error received from Google.

'); + onFailure(error); + closeServer(); + return; + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Bad Request

Missing authorization code.

'); + return; + } + + try { + // Exchange code for tokens + const tokenParams = new URLSearchParams(); + tokenParams.append('code', code); + tokenParams.append('client_id', clientId); + tokenParams.append('client_secret', clientSecret); + tokenParams.append('redirect_uri', this.REDIRECT_URI); + tokenParams.append('grant_type', 'authorization_code'); + + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: tokenParams.toString(), + }); + + if (!tokenRes.ok) { + throw new Error(`Token exchange failed: ${tokenRes.statusText}`); + } + + const tokenData = (await tokenRes.json()) as TokenResponse; + if (!tokenData.refresh_token) { + throw new Error('No refresh token returned. Try removing DocForge permissions from your Google Account settings and re-connect.'); + } + + // Fetch User Email + const emailRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + if (!emailRes.ok) { + throw new Error(`Failed to fetch user email: ${emailRes.statusText}`); + } + + const emailData = (await emailRes.json()) as UserInfoResponse; + + // Render success page to the user in their browser + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + + DocForge Authorized + + + +
+

Successfully Connected!

+

DocForge has been linked to .

+

You can now close this tab and return to the application.

+
+ + + `); + + onSuccess({ + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + email: emailData.email, + }); + + } catch (err: any) { + console.error('[Sync] OAuth token exchange error:', err); + res.writeHead(500, { 'Content-Type': 'text/html' }); + res.end(`

Authorization Error

${err.message}

`); + onFailure(err.message); + } finally { + closeServer(); + } + }); + + server.listen(this.PORT, '127.0.0.1', () => { + console.log(`[Sync] OAuth local server listening on 127.0.0.1:${this.PORT}`); + }); + + return { authUrl, closeServer }; + } + + // Find file in Google Drive AppData folder + static async findDatabaseFile(accessToken: string): Promise { + console.log('[Sync] Searching for docforge.db in Google Drive appDataFolder...'); + const url = 'https://www.googleapis.com/drive/v3/files?' + + `q=${encodeURIComponent("name='docforge.db' and 'appDataFolder' in parents")}&` + + `spaces=appDataFolder&` + + `fields=${encodeURIComponent('files(id,name,mimeType,modifiedTime,md5Checksum)')}`; + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) { + const errText = await res.text(); + console.error('[Sync] Find database file failed:', errText); + throw new Error(`Drive search failed: ${res.statusText}`); + } + + const data = (await res.json()) as DriveSearchResponse; + if (data.files && data.files.length > 0) { + console.log(`[Sync] Found cloud file with ID: ${data.files[0].id}, MD5: ${data.files[0].md5Checksum}`); + return data.files[0]; + } + console.log('[Sync] No database file found in Google Drive appDataFolder.'); + return null; + } + + // Upload file (Create) + static async uploadDatabaseFile( + accessToken: string, + localFilePath: string + ): Promise { + console.log('[Sync] Creating database file in Google Drive appDataFolder...'); + const fileBuffer = await fs.readFile(localFilePath); + + const metadata = JSON.stringify({ + name: 'docforge.db', + parents: ['appDataFolder'], + }); + + const boundary = 'docforge_multipart_boundary'; + const delimiter = `\r\n--${boundary}\r\n`; + const closeDelimiter = `\r\n--${boundary}--\r\n`; + + const header = delimiter + + 'Content-Type: application/json; charset=UTF-8\r\n\r\n' + + metadata + + delimiter + + 'Content-Type: application/octet-stream\r\n\r\n'; + + const headerBuffer = Buffer.from(header, 'utf-8'); + const footerBuffer = Buffer.from(closeDelimiter, 'utf-8'); + const bodyBuffer = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]); + + const res = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,mimeType,modifiedTime,md5Checksum', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': `multipart/related; boundary=${boundary}`, + }, + body: bodyBuffer as any, + }); + + if (!res.ok) { + const errText = await res.text(); + console.error('[Sync] Upload database file failed:', errText); + throw new Error(`Drive upload failed: ${res.statusText} (${errText})`); + } + + const data = (await res.json()) as DriveFile; + console.log('[Sync] Database file uploaded successfully. ID:', data.id); + return data; + } + + // Update file (Overwrite content) + static async updateDatabaseFile( + accessToken: string, + fileId: string, + localFilePath: string + ): Promise { + console.log(`[Sync] Updating database file ${fileId} in Google Drive...`); + const fileBuffer = await fs.readFile(localFilePath); + + const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media&fields=id,name,mimeType,modifiedTime,md5Checksum`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/octet-stream', + }, + body: fileBuffer as any, + }); + + if (!res.ok) { + const errText = await res.text(); + console.error('[Sync] Update database file failed:', errText); + throw new Error(`Drive patch failed: ${res.statusText} (${errText})`); + } + + const data = (await res.json()) as DriveFile; + console.log('[Sync] Database file updated successfully. MD5:', data.md5Checksum); + return data; + } + + // Download file + static async downloadDatabaseFile( + accessToken: string, + fileId: string, + destinationPath: string + ): Promise { + console.log(`[Sync] Downloading database file ${fileId} to ${destinationPath}...`); + const res = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) { + const errText = await res.text(); + console.error('[Sync] Download database file failed:', errText); + throw new Error(`Drive download failed: ${res.statusText}`); + } + + const arrayBuffer = await res.arrayBuffer(); + await fs.writeFile(destinationPath, Buffer.from(arrayBuffer)); + console.log('[Sync] Database file downloaded successfully.'); + } +} diff --git a/electron/main.ts b/electron/main.ts index d8baaf6..582648c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,6 +4,8 @@ import { app, BrowserWindow, ipcMain, dialog, clipboard, shell } from 'electron' import { platform } from 'process'; import path from 'path'; import fs from 'fs/promises'; +import crypto from 'crypto'; +import { GoogleDriveService } from './gdrive'; import { createReadStream, createWriteStream } from 'fs'; import { autoUpdater } from 'electron-updater'; import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider'; @@ -31,7 +33,7 @@ declare global { // The `resourcesPath` property is augmented in `types.ts` // FIX: The augmentation from types.ts was not being picked up. // Explicitly adding it here resolves the type error in this file. - resourcesPath: string; + readonly resourcesPath: string; } } } @@ -191,8 +193,8 @@ scriptRunner.events.on('run-status', (payload) => broadcastScriptEvent('script:r // startup while still respecting the expectation that only published, // non-prerelease builds are considered when automatic updates are disabled for // prerelease channels. -const originalGetLatestTagName = GitHubProvider.prototype.getLatestTagName; -GitHubProvider.prototype.getLatestTagName = async function (this: GitHubProvider, cancellationToken) { +const originalGetLatestTagName = (GitHubProvider.prototype as any).getLatestTagName; +(GitHubProvider.prototype as any).getLatestTagName = async function (this: any, cancellationToken: any) { const { owner, repo, host } = this.options; const apiHost = !host || host === 'github.com' ? 'https://api.github.com' : `https://${host}`; const apiPathPrefix = host && !['github.com', 'api.github.com'].includes(host) ? '/api/v3' : ''; @@ -281,9 +283,10 @@ GitHubProvider.prototype.getLatestTagName = async function (this: GitHubProvider }; const originalChannelDescriptor = Object.getOwnPropertyDescriptor(GitHubProvider.prototype, 'channel'); -if (originalChannelDescriptor?.get) { +const originalChannelGetter = originalChannelDescriptor?.get; +if (originalChannelGetter) { Object.defineProperty(GitHubProvider.prototype, 'channel', { - get(this: GitHubProvider) { + get(this: any) { const forcedChannel = (this.updater as unknown as { __docforgeWindowsChannel?: string })?.__docforgeWindowsChannel; if (typeof forcedChannel === 'string' && forcedChannel.trim().length > 0) { try { @@ -293,7 +296,7 @@ if (originalChannelDescriptor?.get) { } } - return originalChannelDescriptor.get.call(this); + return (originalChannelGetter as any).call(this); }, }); } @@ -337,6 +340,312 @@ autoUpdater.on('error', (error) => { } }); +// --- Google Drive Cloud Sync state & helpers --- +interface SyncConfig { + syncEnabled?: boolean; + clientId?: string; + clientSecret?: string; + refreshToken?: string; + email?: string | null; + syncAutoOnOpenClose?: boolean; + conflictResolution?: 'ask' | 'prefer-local' | 'prefer-cloud'; + lastLocalChecksum?: string | null; + lastRemoteChecksum?: string | null; + lastCompletedAt?: string | null; +} + +let syncConfig: SyncConfig = {}; +const SYNC_CONFIG_FILE = 'sync-config.json'; +const getSyncConfigFilePath = () => path.join(app.getPath('userData'), SYNC_CONFIG_FILE); + +const loadSyncConfig = async () => { + try { + const raw = await fs.readFile(getSyncConfigFilePath(), 'utf-8'); + syncConfig = JSON.parse(raw) as SyncConfig; + console.log('[Sync] Local sync-config.json loaded.'); + } catch (error) { + syncConfig = { + syncEnabled: false, + clientId: '', + clientSecret: '', + syncAutoOnOpenClose: false, + conflictResolution: 'ask', + }; + console.log('[Sync] Initialized default sync configuration.'); + } +}; + +const saveSyncConfig = async () => { + try { + const configPath = getSyncConfigFilePath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(syncConfig, null, 2), 'utf-8'); + } catch (error) { + console.error('[Sync] Failed to save sync-config.json:', error); + } +}; + +let periodicSyncTimer: NodeJS.Timeout | null = null; +let syncStatus: 'idle' | 'syncing' | 'error' | 'conflict' = 'idle'; +let activeOAuthServerCloseFn: (() => void) | null = null; + +const startPeriodicSync = () => { + stopPeriodicSync(); + if (syncConfig.syncEnabled && syncConfig.refreshToken) { + console.log('[Sync] Starting periodic cloud sync (every 10 minutes).'); + periodicSyncTimer = setInterval(() => { + console.log('[Sync] Running periodic background sync...'); + runSyncInternal().catch((err) => console.error('[Sync] Periodic sync error:', err)); + }, 10 * 60 * 1000); + } +}; + +const stopPeriodicSync = () => { + if (periodicSyncTimer) { + clearInterval(periodicSyncTimer); + periodicSyncTimer = null; + console.log('[Sync] Periodic cloud sync stopped.'); + } +}; + +const getFileChecksum = async (filePath: string): Promise => { + const content = await fs.readFile(filePath); + return crypto.createHash('md5').update(content).digest('hex'); +}; + +const runSyncInternal = async (options?: { forcePush?: boolean; forcePull?: boolean }): Promise<{ + success: boolean; + code?: 'in_sync' | 'pushed' | 'pulled' | 'conflict' | 'error'; + message?: string; + localStats?: any; + remoteStats?: any; + error?: string; +}> => { + if (syncStatus === 'syncing') { + return { success: false, error: 'Sync is already in progress.' }; + } + + if (!syncConfig.syncEnabled || !syncConfig.clientId || !syncConfig.clientSecret || !syncConfig.refreshToken) { + return { success: false, error: 'Cloud sync is not fully configured or is disabled.' }; + } + + syncStatus = 'syncing'; + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Authenticating with Google...' }); + + let tempBackupPath = ''; + try { + // 1. Refresh token + const refreshRes = await GoogleDriveService.refreshAccessToken( + syncConfig.clientId, + syncConfig.clientSecret, + syncConfig.refreshToken + ); + const accessToken = refreshRes.accessToken; + + // 2. Local backup + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Creating database snapshot...' }); + const localDbPath = databaseService.getDbPath(); + tempBackupPath = path.join(os.tmpdir(), `docforge_sync_${Date.now()}.db`); + await databaseService.backupDatabase(tempBackupPath); + + const localChecksum = await getFileChecksum(tempBackupPath); + + // 3. Search remote + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Checking Google Drive...' }); + const cloudFile = await GoogleDriveService.findDatabaseFile(accessToken); + + if (!cloudFile) { + // Create cloud file + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Uploading database to cloud...' }); + const uploaded = await GoogleDriveService.uploadDatabaseFile(accessToken, tempBackupPath); + + syncConfig.lastLocalChecksum = localChecksum; + syncConfig.lastRemoteChecksum = uploaded.md5Checksum; + syncConfig.lastCompletedAt = new Date().toISOString(); + await saveSyncConfig(); + + await fs.unlink(tempBackupPath).catch(() => {}); + syncStatus = 'idle'; + mainWindow?.webContents.send('sync:status', { status: 'idle', message: 'Pushed local database to cloud.' }); + return { success: true, code: 'pushed', message: 'Pushed local database to cloud.' }; + } + + // Compare + const localChanged = localChecksum !== syncConfig.lastLocalChecksum; + const remoteChanged = cloudFile.md5Checksum !== syncConfig.lastRemoteChecksum; + + if (options?.forcePush) { + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Force uploading database...' }); + const updated = await GoogleDriveService.updateDatabaseFile(accessToken, cloudFile.id, tempBackupPath); + + syncConfig.lastLocalChecksum = localChecksum; + syncConfig.lastRemoteChecksum = updated.md5Checksum; + syncConfig.lastCompletedAt = new Date().toISOString(); + await saveSyncConfig(); + + await fs.unlink(tempBackupPath).catch(() => {}); + syncStatus = 'idle'; + mainWindow?.webContents.send('sync:status', { status: 'idle', message: 'Force pushed database.' }); + return { success: true, code: 'pushed', message: 'Force pushed database.' }; + } + + if (options?.forcePull) { + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Downloading cloud database...' }); + const downloadPath = path.join(os.tmpdir(), `docforge_download_${Date.now()}.db`); + await GoogleDriveService.downloadDatabaseFile(accessToken, cloudFile.id, downloadPath); + + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Applying cloud database...' }); + databaseService.close(); + await fs.copyFile(downloadPath, localDbPath); + databaseService.init(); + + await fs.unlink(downloadPath).catch(() => {}); + await fs.unlink(tempBackupPath).catch(() => {}); + + const newLocalChecksum = await getFileChecksum(localDbPath); + syncConfig.lastLocalChecksum = newLocalChecksum; + syncConfig.lastRemoteChecksum = cloudFile.md5Checksum; + syncConfig.lastCompletedAt = new Date().toISOString(); + await saveSyncConfig(); + + syncStatus = 'idle'; + mainWindow?.webContents.send('sync:status', { status: 'idle', message: 'Force pulled database.' }); + + // Reload UI + setTimeout(() => { + mainWindow?.webContents.reload(); + }, 500); + + return { success: true, code: 'pulled', message: 'Force pulled database.' }; + } + + if (!localChanged && !remoteChanged) { + syncConfig.lastCompletedAt = new Date().toISOString(); + await saveSyncConfig(); + await fs.unlink(tempBackupPath).catch(() => {}); + syncStatus = 'idle'; + mainWindow?.webContents.send('sync:status', { status: 'idle', message: 'In sync.' }); + return { success: true, code: 'in_sync', message: 'Database is in sync.' }; + } + + if (localChanged && !remoteChanged) { + // Push local + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Uploading local changes...' }); + const updated = await GoogleDriveService.updateDatabaseFile(accessToken, cloudFile.id, tempBackupPath); + + syncConfig.lastLocalChecksum = localChecksum; + syncConfig.lastRemoteChecksum = updated.md5Checksum; + syncConfig.lastCompletedAt = new Date().toISOString(); + await saveSyncConfig(); + + await fs.unlink(tempBackupPath).catch(() => {}); + syncStatus = 'idle'; + mainWindow?.webContents.send('sync:status', { status: 'idle', message: 'Uploaded local changes.' }); + return { success: true, code: 'pushed', message: 'Local changes pushed to Google Drive.' }; + } + + if (!localChanged && remoteChanged) { + // Pull remote + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Downloading cloud changes...' }); + const downloadPath = path.join(os.tmpdir(), `docforge_download_${Date.now()}.db`); + await GoogleDriveService.downloadDatabaseFile(accessToken, cloudFile.id, downloadPath); + + mainWindow?.webContents.send('sync:status', { status: 'syncing', message: 'Applying cloud changes...' }); + databaseService.close(); + await fs.copyFile(downloadPath, localDbPath); + databaseService.init(); + + await fs.unlink(downloadPath).catch(() => {}); + await fs.unlink(tempBackupPath).catch(() => {}); + + const newLocalChecksum = await getFileChecksum(localDbPath); + syncConfig.lastLocalChecksum = newLocalChecksum; + syncConfig.lastRemoteChecksum = cloudFile.md5Checksum; + syncConfig.lastCompletedAt = new Date().toISOString(); + await saveSyncConfig(); + + syncStatus = 'idle'; + mainWindow?.webContents.send('sync:status', { status: 'idle', message: 'Downloaded cloud changes.' }); + + setTimeout(() => { + mainWindow?.webContents.reload(); + }, 500); + + return { success: true, code: 'pulled', message: 'Downloaded cloud changes.' }; + } + + // Both changed: Conflict! + if (syncConfig.conflictResolution === 'prefer-local') { + console.log('[Sync] Conflict resolution: Prefer Local. Overwriting cloud...'); + const updated = await GoogleDriveService.updateDatabaseFile(accessToken, cloudFile.id, tempBackupPath); + syncConfig.lastLocalChecksum = localChecksum; + syncConfig.lastRemoteChecksum = updated.md5Checksum; + syncConfig.lastCompletedAt = new Date().toISOString(); + await saveSyncConfig(); + await fs.unlink(tempBackupPath).catch(() => {}); + syncStatus = 'idle'; + mainWindow?.webContents.send('sync:status', { status: 'idle', message: 'Conflict resolved: kept local.' }); + return { success: true, code: 'pushed', message: 'Conflict resolved: kept local.' }; + } + + if (syncConfig.conflictResolution === 'prefer-cloud') { + console.log('[Sync] Conflict resolution: Prefer Cloud. Overwriting local...'); + const downloadPath = path.join(os.tmpdir(), `docforge_download_${Date.now()}.db`); + await GoogleDriveService.downloadDatabaseFile(accessToken, cloudFile.id, downloadPath); + databaseService.close(); + await fs.copyFile(downloadPath, localDbPath); + databaseService.init(); + await fs.unlink(downloadPath).catch(() => {}); + await fs.unlink(tempBackupPath).catch(() => {}); + + const newLocalChecksum = await getFileChecksum(localDbPath); + syncConfig.lastLocalChecksum = newLocalChecksum; + syncConfig.lastRemoteChecksum = cloudFile.md5Checksum; + syncConfig.lastCompletedAt = new Date().toISOString(); + await saveSyncConfig(); + + syncStatus = 'idle'; + mainWindow?.webContents.send('sync:status', { status: 'idle', message: 'Conflict resolved: kept cloud.' }); + + setTimeout(() => { + mainWindow?.webContents.reload(); + }, 500); + + return { success: true, code: 'pulled', message: 'Conflict resolved: kept cloud.' }; + } + + // Ask user + console.log('[Sync] Conflict detected. Requesting resolution from user.'); + const conflictDownloadPath = path.join(os.tmpdir(), `docforge_conflict_${Date.now()}.db`); + await GoogleDriveService.downloadDatabaseFile(accessToken, cloudFile.id, conflictDownloadPath); + + const remoteStats = databaseService.getStatsForFile(conflictDownloadPath); + await fs.unlink(conflictDownloadPath).catch(() => {}); + const localStats = databaseService.getStatsForFile(localDbPath); + await fs.unlink(tempBackupPath).catch(() => {}); + + syncStatus = 'conflict'; + mainWindow?.webContents.send('sync:status', { status: 'conflict', message: 'Conflict detected.' }); + + return { + success: true, + code: 'conflict', + message: 'Conflict detected.', + localStats, + remoteStats, + }; + + } catch (error: any) { + if (tempBackupPath) { + await fs.unlink(tempBackupPath).catch(() => {}); + } + console.error('[Sync] Sync failed:', error); + syncStatus = 'error'; + mainWindow?.webContents.send('sync:status', { status: 'error', message: error.message || 'Sync failed.' }); + return { success: false, error: error.message || 'Sync failed.' }; + } +}; + function createWindow() { mainWindow = new BrowserWindow({ width: 1200, @@ -443,9 +752,23 @@ app.on('ready', () => { } else { console.log('Automatic update checks are disabled via settings.'); } + + // Load sync config and run initial sync if enabled + void loadSyncConfig().then(() => { + if (syncConfig.syncEnabled) { + startPeriodicSync(); + if (syncConfig.syncAutoOnOpenClose) { + setTimeout(() => { + console.log('[Sync] Running startup auto-sync...'); + runSyncInternal().catch((err) => console.error('[Sync] Startup sync failed:', err)); + }, 5000); + } + } + }); }); app.on('window-all-closed', () => { + stopPeriodicSync(); databaseService.close(); // Fix: Error on line 96 is resolved by importing 'platform' from 'process'. if (platform !== 'darwin') { @@ -453,6 +776,26 @@ app.on('window-all-closed', () => { } }); +let isSyncingBeforeQuit = false; +app.on('before-quit', (e) => { + if (syncConfig.syncEnabled && syncConfig.syncAutoOnOpenClose && !isSyncingBeforeQuit) { + e.preventDefault(); + isSyncingBeforeQuit = true; + console.log('[Sync] Running final shutdown sync...'); + + Promise.race([ + runSyncInternal(), + new Promise((resolve) => setTimeout(resolve, 3000)) + ]).then(() => { + console.log('[Sync] Shutdown sync complete or timed out. Quitting.'); + app.quit(); + }).catch((err) => { + console.error('[Sync] Shutdown sync error:', err); + app.quit(); + }); + } +}); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); @@ -777,6 +1120,124 @@ ipcMain.handle('updater:check-now', async () => { }); +// Google Drive Sync +ipcMain.handle('sync:get-config', () => { + return { + syncEnabled: syncConfig.syncEnabled ?? false, + clientId: syncConfig.clientId ?? '', + clientSecret: syncConfig.clientSecret ?? '', + email: syncConfig.email ?? null, + refreshToken: syncConfig.refreshToken ?? null, + syncAutoOnOpenClose: syncConfig.syncAutoOnOpenClose ?? false, + conflictResolution: syncConfig.conflictResolution ?? 'ask', + lastLocalChecksum: syncConfig.lastLocalChecksum ?? null, + lastRemoteChecksum: syncConfig.lastRemoteChecksum ?? null, + lastCompletedAt: syncConfig.lastCompletedAt ?? null, + }; +}); + +ipcMain.handle('sync:save-config', async (_, config) => { + const wasEnabled = syncConfig.syncEnabled; + const allowedKeys = ['syncEnabled', 'clientId', 'clientSecret', 'syncAutoOnOpenClose', 'conflictResolution']; + for (const key of allowedKeys) { + if (key in config) { + let val = config[key]; + if (typeof val === 'string') { + val = val.trim(); + } + (syncConfig as any)[key] = val; + } + } + await saveSyncConfig(); + + if (syncConfig.syncEnabled && !wasEnabled) { + startPeriodicSync(); + } else if (!syncConfig.syncEnabled && wasEnabled) { + stopPeriodicSync(); + } + return { success: true }; +}); + +ipcMain.handle('sync:google-connect', async (_, clientId, clientSecret) => { + if (activeOAuthServerCloseFn) { + activeOAuthServerCloseFn(); + activeOAuthServerCloseFn = null; + } + + const cleanClientId = typeof clientId === 'string' ? clientId.trim() : clientId; + const cleanClientSecret = typeof clientSecret === 'string' ? clientSecret.trim() : clientSecret; + + return new Promise((resolve) => { + const { authUrl, closeServer } = GoogleDriveService.startOAuthFlow( + cleanClientId, + cleanClientSecret, + async (tokens) => { + syncConfig.syncEnabled = true; + syncConfig.clientId = cleanClientId; + syncConfig.clientSecret = cleanClientSecret; + syncConfig.refreshToken = tokens.refreshToken; + syncConfig.email = tokens.email; + await saveSyncConfig(); + + startPeriodicSync(); + + console.log(`[Sync] Account connected: ${tokens.email}`); + resolve({ success: true, email: tokens.email }); + activeOAuthServerCloseFn = null; + }, + (error) => { + console.error(`[Sync] Connection failed: ${error}`); + resolve({ success: false, error }); + activeOAuthServerCloseFn = null; + } + ); + + activeOAuthServerCloseFn = closeServer; + shell.openExternal(authUrl); + }); +}); + +ipcMain.handle('sync:google-disconnect', async () => { + if (activeOAuthServerCloseFn) { + activeOAuthServerCloseFn(); + activeOAuthServerCloseFn = null; + } + stopPeriodicSync(); + + syncConfig.syncEnabled = false; + syncConfig.refreshToken = undefined; + syncConfig.email = null; + syncConfig.lastLocalChecksum = null; + syncConfig.lastRemoteChecksum = null; + syncConfig.lastCompletedAt = null; + await saveSyncConfig(); + + console.log('[Sync] Disconnected from Google Drive.'); + return { success: true }; +}); + +ipcMain.handle('sync:run', async (_, options) => { + return runSyncInternal(options); +}); + +ipcMain.handle('sync:resolve-conflict', async (_, resolution) => { + if (resolution === 'local') { + return runSyncInternal({ forcePush: true }); + } else { + return runSyncInternal({ forcePull: true }); + } +}); + +ipcMain.handle('sync:get-status', async () => { + return { + success: true, + email: syncConfig.email ?? null, + enabled: syncConfig.syncEnabled ?? false, + lastCompletedAt: syncConfig.lastCompletedAt ?? null, + }; +}); + + // Window Controls ipcMain.on('window:minimize', () => mainWindow?.minimize()); ipcMain.on('window:maximize', () => { diff --git a/electron/preload.ts b/electron/preload.ts index 05b2df1..1973995 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -191,4 +191,18 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('app:log', handler); return () => ipcRenderer.removeListener('app:log', handler); }, + + // --- Google Drive Sync --- + syncGoogleConnect: (clientId: string, clientSecret: string) => ipcRenderer.invoke('sync:google-connect', clientId, clientSecret), + syncGoogleDisconnect: () => ipcRenderer.invoke('sync:google-disconnect'), + syncRun: (options?: { forcePush?: boolean; forcePull?: boolean }) => ipcRenderer.invoke('sync:run', options), + syncResolveConflict: (resolution: 'local' | 'remote') => ipcRenderer.invoke('sync:resolve-conflict', resolution), + syncGetStatus: () => ipcRenderer.invoke('sync:get-status'), + syncGetConfig: () => ipcRenderer.invoke('sync:get-config'), + syncSaveConfig: (config: any) => ipcRenderer.invoke('sync:save-config', config), + onSyncStatus: (callback: (payload: { status: 'idle' | 'syncing' | 'error' | 'conflict'; message?: string }) => void) => { + const handler = (_: IpcRendererEvent, data: any) => callback(data); + ipcRenderer.on('sync:status', handler); + return () => ipcRenderer.removeListener('sync:status', handler); + }, }); diff --git a/services/repository.ts b/services/repository.ts index 47a58c3..e659315 100644 --- a/services/repository.ts +++ b/services/repository.ts @@ -1599,6 +1599,26 @@ export const repository = { settings[row.key] = row.value; } } + // Merge sync config + if (window.electronAPI?.syncGetConfig) { + try { + const syncConfig = await window.electronAPI.syncGetConfig(); + Object.assign(settings, { + syncEnabled: syncConfig.syncEnabled ?? false, + syncClientId: syncConfig.clientId ?? '', + syncClientSecret: syncConfig.clientSecret ?? '', + syncGoogleEmail: syncConfig.email ?? null, + syncGoogleRefreshToken: syncConfig.refreshToken ?? null, + syncAutoOnOpenClose: syncConfig.syncAutoOnOpenClose ?? false, + syncConflictResolution: syncConfig.conflictResolution ?? 'ask', + syncLastLocalChecksum: syncConfig.lastLocalChecksum ?? null, + syncLastRemoteChecksum: syncConfig.lastRemoteChecksum ?? null, + syncLastCompletedAt: syncConfig.lastCompletedAt ?? null, + }); + } catch (e) { + console.error('Failed to load sync settings from main process:', e); + } + } return settings as Settings; }, @@ -1609,7 +1629,55 @@ export const repository = { persistBrowserState(state); return; } + + const syncKeysToSave = [ + 'syncEnabled', + 'syncClientId', + 'syncClientSecret', + 'syncAutoOnOpenClose', + 'syncConflictResolution' + ]; + + const allSyncKeys = [ + 'syncEnabled', + 'syncClientId', + 'syncClientSecret', + 'syncGoogleEmail', + 'syncGoogleRefreshToken', + 'syncAutoOnOpenClose', + 'syncConflictResolution', + 'syncLastLocalChecksum', + 'syncLastRemoteChecksum', + 'syncLastCompletedAt' + ]; + + const dbSettings: any = {}; + const syncConfigPayload: any = {}; + for (const [key, value] of Object.entries(settings)) { + if (allSyncKeys.includes(key)) { + if (syncKeysToSave.includes(key)) { + if (key === 'syncClientId') syncConfigPayload.clientId = value; + else if (key === 'syncClientSecret') syncConfigPayload.clientSecret = value; + else if (key === 'syncConflictResolution') syncConfigPayload.conflictResolution = value; + else syncConfigPayload[key] = value; + } + } else { + dbSettings[key] = value; + } + } + + // Save sync config + if (window.electronAPI?.syncSaveConfig && Object.keys(syncConfigPayload).length > 0) { + try { + await window.electronAPI.syncSaveConfig(syncConfigPayload); + } catch (e) { + console.error('Failed to save sync settings:', e); + } + } + + // Save DB settings + for (const [key, value] of Object.entries(dbSettings)) { await window.electronAPI!.dbRun( 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, JSON.stringify(value)] diff --git a/specs/003-gdrive-sync/spec.md b/specs/003-gdrive-sync/spec.md new file mode 100644 index 0000000..444937e --- /dev/null +++ b/specs/003-gdrive-sync/spec.md @@ -0,0 +1,90 @@ +# Feature Specification: Google Drive Cloud Database Sync + +**Feature Branch**: `003-gdrive-sync` +**Created**: 2026-05-31 +**Status**: Draft +**Input**: User request: "Go ahead with the implementation of this implementation plan: 'Google Drive Cloud Database Sync'. Develop it on a dedicated feature branch" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Google Drive Account Connection (Priority: P1) + +As a user sync'ing my notes across machines, I want to securely connect my DocForge application to my Google Drive account, using my own Client ID and Client Secret, so that my data is privately stored in my personal cloud space. + +**Why this priority**: Account authorization is the prerequisite gateway for all subsequent synchronization actions. + +**Independent Test**: Configure the Client ID and Client Secret in Settings, click "Connect", authenticate in the browser, and verify that the UI updates to show the account email as "Connected". + +**Acceptance Scenarios**: + +1. **Given** a user inputs a valid Client ID and Client Secret, **When** they click "Connect Google Drive", **Then** the default OS web browser opens the Google OAuth authorization prompt. +2. **Given** the user completes the OAuth authorization in their browser, **When** they return to DocForge, **Then** the local loopback HTTP server captures the authorization token, closes itself, and displays a success state showing the user's Google email. + +--- + +### User Story 2 - Automated & Manual Database Synchronization (Priority: P1) + +As a writer editing documents on multiple devices, I want my database to sync automatically when the app starts and periodically in the background, as well as manually, so that my documents are always up-to-date. + +**Why this priority**: Accurate, hands-free database synchronization is the core utility of this feature. + +**Independent Test**: Modify documents on Device A and click "Sync Now". Open Device B, click "Sync Now", and confirm that the modifications from Device A are visible without manual export/import. + +**Acceptance Scenarios**: + +1. **Given** the local database has newer edits than the cloud database, **When** a sync is triggered, **Then** a backup snapshot of the local database is uploaded to the Google Drive `appDataFolder` without interrupting current operations. +2. **Given** the cloud database has newer edits than the local database, **When** a sync is triggered, **Then** the local database connection is closed, the remote database is downloaded and applied, the connection is reopened, and the UI reloads to show updated data. + +--- + +### User Story 3 - Interactive Conflict Resolution (Priority: P1) + +As a user who edits my workspace on both my laptop and desktop while offline, I want to be notified of conflicts when I sync, showing a side-by-side comparison of local and cloud versions so that I can choose which version to keep. + +**Why this priority**: Avoids silent overwrites and data loss when changes diverge. + +**Independent Test**: Modify the database on both Device A and Device B while disconnected. Sync Device A first. Then sync Device B and verify the conflict modal displays comparison stats and allows selecting a winner. + +**Acceptance Scenarios**: + +1. **Given** both local and cloud databases have modified checksums since the last sync, **When** sync runs, **Then** the sync stops and triggers a conflict modal. +2. **Given** the conflict modal is active, **When** the user clicks "Keep Local", **Then** the local database is pushed to Google Drive, overwriting the cloud version. +3. **Given** the conflict modal is active, **When** the user clicks "Keep Cloud", **Then** the cloud version is downloaded, overwriting the local database, and the UI reloads. + +--- + +## Edge Cases + +- **Network Interruption:** If the internet connection drops during sync, the transaction must abort cleanly, leaving the local database unharmed. +- **Access Revocation:** If the refresh token is revoked on Google Cloud, the system must degrade gracefully, set status to disconnected, and prompt the user to re-authenticate. +- **Concurrent Writes during Sync:** Using SQLite's WAL mode and backing up to a temporary location using `db.backup()` prevents file locking conflicts during uploads. + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST support inputting Google Cloud Client ID and Client Secret in Settings. +- **FR-002**: System MUST host a temporary loopback HTTP server to capture the Google OAuth callback. +- **FR-003**: System MUST store the encrypted/secure refresh token and metadata in the `settings` database table. +- **FR-004**: System MUST perform all file queries and uploads within the isolated `appDataFolder` Google Drive space. +- **FR-005**: System MUST compute MD5 checksums of the database files to determine if local or cloud versions have changed. +- **FR-006**: System MUST close all active better-sqlite3 handles before overwriting the local database with a cloud version. +- **FR-007**: System MUST offer manual "Sync Now" trigger and background auto-sync configurations. +- **FR-008**: System MUST display a side-by-side database comparison modal if a conflict is detected. + +### Key Entities + +- **Sync Credentials:** Client ID, Client Secret, Refresh Token, Access Token, and Token Expiry. +- **Database Metadata:** File size, modification time, MD5 checksum, number of document nodes, and number of templates. + +--- + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Zero data corruption incidents or database locking issues across 100 simulated sync sequences. +- **SC-002**: Verification of successful sync completion in under 5 seconds on normal broadband connections. +- **SC-003**: Dynamic UI reload triggers successfully within 500ms after database swap. diff --git a/types.ts b/types.ts index 199f4b8..22c882b 100644 --- a/types.ts +++ b/types.ts @@ -112,13 +112,22 @@ declare global { ragClearIndex: () => Promise<{ success: boolean; error?: string }>; onRagIndexProgress: (callback: (payload: { current: number; total: number }) => void) => () => void; onAppLog: (callback: (payload: { level: LogLevel; message: string; timestamp: string }) => void) => () => void; + // Google Drive Sync + syncGoogleConnect: (clientId: string, clientSecret: string) => Promise<{ success: boolean; email?: string; error?: string }>; + syncGoogleDisconnect: () => Promise<{ success: boolean; error?: string }>; + syncRun: (options?: { forcePush?: boolean; forcePull?: boolean }) => Promise<{ success: boolean; code?: 'in_sync' | 'pushed' | 'pulled' | 'conflict' | 'error'; message?: string; localStats?: any; remoteStats?: any; error?: string }>; + syncResolveConflict: (resolution: 'local' | 'remote') => Promise<{ success: boolean; message?: string; error?: string }>; + syncGetStatus: () => Promise<{ success: boolean; email: string | null; enabled: boolean; lastCompletedAt: string | null; }>; + syncGetConfig: () => Promise; + syncSaveConfig: (config: any) => Promise<{ success: boolean; error?: string }>; + onSyncStatus: (callback: (payload: { status: 'idle' | 'syncing' | 'error' | 'conflict'; message?: string }) => void) => () => void; }; __DOCFORGE_SCRIPT_PREVIEW__?: ScriptExecutionBridge; } // This is for the Electron main process, to add properties attached by Electron. namespace NodeJS { interface Process { - resourcesPath: string; + readonly resourcesPath: string; } } } @@ -535,6 +544,16 @@ export interface Settings { chatEnableAgentMode: boolean; chatAgentRequiresApproval: boolean; chatEnabledTools: string[]; + syncEnabled: boolean; + syncClientId: string; + syncClientSecret: string; + syncGoogleEmail: string | null; + syncGoogleRefreshToken: string | null; + syncAutoOnOpenClose: boolean; + syncConflictResolution: 'ask' | 'prefer-local' | 'prefer-cloud'; + syncLastLocalChecksum: string | null; + syncLastRemoteChecksum: string | null; + syncLastCompletedAt: string | null; } export type LogLevel = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';