import { supabase } from './supabase-client.js'; import { authService } from './auth-service.js'; import { cryptoService } from './crypto-service.js'; import { db } from '../db/database.js'; class SyncManager { constructor() { this.isSyncing = false; this.lastSyncTime = 0; this.lastUploadedSettings = {}; // Cache to prevent redundant uploads // Listen for auth state changes authService.onAuthStateChange(async (event, session) => { if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') { // If we log in and the crypto is unlocked, we can trigger sync if (authService.isCryptoUnlocked()) { this.sync(); } } }); } async initialize() { // Called once on app startup to trigger initial sync if already logged in if (authService.user && authService.isCryptoUnlocked()) { this.sync(); // Auto-Sync every 15 seconds setInterval(() => { if (authService.user && authService.isCryptoUnlocked()) { this.sync(); } }, 15000); } } async sync() { if (!authService.user || !authService.isCryptoUnlocked() || this.isSyncing) return; this.isSyncing = true; const userId = authService.user.id; console.log('[Sync] Starting Sync...'); try { let hasUpdates = false; if (await this.syncSettings(userId)) hasUpdates = true; if (await this.syncConversations(userId)) hasUpdates = true; if (await this.syncMessages(userId)) hasUpdates = true; if (await this.syncFlashcardDecks(userId)) hasUpdates = true; if (await this.syncFlashcards(userId)) hasUpdates = true; console.log('[Sync] Sync completed successfully.'); if (hasUpdates) { console.log('[Sync] New remote changes found, dispatching event.'); window.dispatchEvent(new CustomEvent('cloud_sync_updated')); } this.lastSyncTime = Date.now(); } catch (e) { console.error('[Sync] Error during sync:', e); } finally { this.isSyncing = false; } } async syncSettings(userId) { // Local to Remote const localSettings = db.exec("SELECT key, value FROM settings"); // Remote to Local const { data: remoteSettings, error } = await supabase .from('settings') .select('*') .eq('user_id', userId); if (error) throw error; const remoteSettingsMap = {}; if (remoteSettings) { for (const row of remoteSettings) { remoteSettingsMap[row.key] = await cryptoService.decrypt(row.value); } } // Check if local settings is effectively empty (only defaults, no api keys) const hasLocalApiKey = localSettings.some(r => r.key.includes('api_key') && r.value && r.value.trim().length > 0); const hasRemoteApiKey = Object.keys(remoteSettingsMap).some(k => k.includes('api_key') && remoteSettingsMap[k] && remoteSettingsMap[k].trim().length > 0); // If local has no API keys, but remote has some, we assume this is a NEW login on a new device. // In this case, we DO NOT push our local defaults to remote. We just pull from remote. if (!hasLocalApiKey && hasRemoteApiKey) { for (const key of Object.keys(remoteSettingsMap)) { if (remoteSettingsMap[key]) { db.setSetting(key, remoteSettingsMap[key]); } } return; // Skip pushing to remote } // Otherwise, push local to remote for (const row of localSettings) { if (this.lastUploadedSettings[row.key] === row.value) continue; // Skip redundant uploads const encryptedValue = await cryptoService.encrypt(row.value); await supabase.from('settings').upsert({ user_id: userId, key: row.key, value: encryptedValue, updated_at: new Date().toISOString() }, { onConflict: 'user_id, key' }); this.lastUploadedSettings[row.key] = row.value; // Cache it } let updated = false; // Pull any setting from remote that isn't locally set (e.g. from another device) if (remoteSettings) { for (const row of remoteSettings) { const localVal = db.getSetting(row.key); if (!localVal) { const decrypted = await cryptoService.decrypt(row.value); if (decrypted) { db.setSetting(row.key, decrypted); updated = true; } } } } return updated; } async syncConversations(userId) { const localConvs = db.getConversations(); const { data: remoteConvs, error } = await supabase .from('conversations') .select('*') .eq('user_id', userId); if (error) throw error; // Create map for fast lookup const remoteMap = {}; if (remoteConvs) { for (const c of remoteConvs) remoteMap[c.id] = c; } for (const local of localConvs) { const remote = remoteMap[local.id]; const localTime = new Date(local.updated_at).getTime(); const remoteTime = remote ? new Date(remote.updated_at).getTime() : 0; // Push to remote if local is newer or doesn't exist on remote if (!remote || localTime > remoteTime) { await supabase.from('conversations').upsert({ id: local.id, user_id: userId, title: await cryptoService.encrypt(local.title), emoji: await cryptoService.encrypt(local.emoji), preview: await cryptoService.encrypt(local.preview), model_used: local.model_used, created_at: local.created_at, updated_at: local.updated_at }, { onConflict: 'id, user_id' }); } } let updated = false; // Pull from remote if remote is newer or doesn't exist locally if (remoteConvs) { for (const remote of remoteConvs) { const local = localConvs.find(c => c.id === remote.id); const remoteTime = new Date(remote.updated_at).getTime(); const localTime = local ? new Date(local.updated_at).getTime() : 0; if (!local || remoteTime > localTime) { const title = await cryptoService.decrypt(remote.title); const emoji = await cryptoService.decrypt(remote.emoji); const preview = await cryptoService.decrypt(remote.preview); if (!local) { db.exec( "INSERT INTO conversations (id, title, emoji, preview, model_used, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [remote.id, title, emoji, preview, remote.model_used, remote.created_at, remote.updated_at] ); } else { db.exec( "UPDATE conversations SET title = ?, emoji = ?, preview = ?, model_used = ?, updated_at = ? WHERE id = ?", [title, emoji, preview, remote.model_used, remote.updated_at, remote.id] ); } updated = true; } } } return updated; } async syncMessages(userId) { const localMsgs = db.exec("SELECT * FROM messages"); // Because messages can be large, we might only want to fetch IDs to see what's missing const { data: remoteMsgs, error } = await supabase .from('messages') .select('id, created_at') .eq('user_id', userId); if (error) throw error; const remoteIds = new Set((remoteMsgs || []).map(m => m.id)); const localIds = new Set(localMsgs.map(m => m.id)); // Push local to remote for (const local of localMsgs) { if (!remoteIds.has(local.id)) { await supabase.from('messages').upsert({ id: local.id, conversation_id: local.conversation_id, user_id: userId, role: local.role, content: await cryptoService.encrypt(local.content), model_used: local.model_used, style_used: local.style_used, created_at: local.created_at }, { onConflict: 'id, user_id' }); } } let updated = false; // Pull remote to local for (const remote of (remoteMsgs || [])) { if (!localIds.has(remote.id)) { const { data: remoteFull } = await supabase .from('messages') .select('*') .eq('id', remote.id) .single(); if (remoteFull) { const content = await cryptoService.decrypt(remoteFull.content); db.exec( "INSERT INTO messages (id, conversation_id, role, content, model_used, style_used, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [remoteFull.id, remoteFull.conversation_id, remoteFull.role, content, remoteFull.model_used, remoteFull.style_used, remoteFull.created_at] ); updated = true; } } } return updated; } async syncFlashcardDecks(userId) { const localDecks = db.getDecks(); const { data: remoteDecks, error } = await supabase .from('flashcard_decks') .select('*') .eq('user_id', userId); if (error) { console.error("[Sync] Error fetching decks", error); throw error; } const remoteMap = {}; if (remoteDecks) { for (const c of remoteDecks) remoteMap[c.id] = c; } for (const local of localDecks) { const remote = remoteMap[local.id]; const localTime = new Date(local.updated_at).getTime(); const remoteTime = remote ? new Date(remote.updated_at).getTime() : 0; if (!remote || localTime > remoteTime) { await supabase.from('flashcard_decks').upsert({ id: local.id, user_id: userId, title: await cryptoService.encrypt(local.title), description: await cryptoService.encrypt(local.description || ''), color: local.color, source_lang: local.source_lang, target_lang: local.target_lang, created_at: local.created_at, updated_at: local.updated_at }, { onConflict: 'id, user_id' }); } } let updated = false; if (remoteDecks) { for (const remote of remoteDecks) { const local = localDecks.find(c => c.id === remote.id); const remoteTime = new Date(remote.updated_at).getTime(); const localTime = local ? new Date(local.updated_at).getTime() : 0; if (!local || remoteTime > localTime) { const title = await cryptoService.decrypt(remote.title); const description = await cryptoService.decrypt(remote.description); if (!local) { db.exec( "INSERT INTO flashcard_decks (id, title, description, color, source_lang, target_lang, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [remote.id, title, description, remote.color, remote.source_lang, remote.target_lang, remote.created_at, remote.updated_at] ); } else { db.exec( "UPDATE flashcard_decks SET title = ?, description = ?, color = ?, source_lang = ?, target_lang = ?, updated_at = ? WHERE id = ?", [title, description, remote.color, remote.source_lang, remote.target_lang, remote.updated_at, remote.id] ); } updated = true; } } } return updated; } async syncFlashcards(userId) { const localCards = db.exec("SELECT * FROM flashcards"); const { data: remoteCardsRaw, error } = await supabase .from('flashcards') .select('id, updated_at') .eq('user_id', userId); if (error) { console.error("[Sync] Error fetching flashcards", error); throw error; } const remoteMapRaw = {}; for (const r of (remoteCardsRaw || [])) { remoteMapRaw[r.id] = r; } for (const local of localCards) { const remoteRaw = remoteMapRaw[local.id]; const localTime = new Date(local.updated_at).getTime(); const remoteTime = remoteRaw ? new Date(remoteRaw.updated_at).getTime() : 0; if (!remoteRaw || localTime > remoteTime) { await supabase.from('flashcards').upsert({ id: local.id, user_id: userId, deck_id: local.deck_id, front: await cryptoService.encrypt(local.front), back: await cryptoService.encrypt(local.back), image_url: local.image_url ? await cryptoService.encrypt(local.image_url) : null, next_review: local.next_review, ease_factor: local.ease_factor, interval: local.interval, repetitions: local.repetitions, created_at: local.created_at, updated_at: local.updated_at }, { onConflict: 'id, user_id' }); } } let updated = false; for (const remoteRaw of (remoteCardsRaw || [])) { const local = localCards.find(c => c.id === remoteRaw.id); const remoteTime = new Date(remoteRaw.updated_at).getTime(); const localTime = local ? new Date(local.updated_at).getTime() : 0; if (!local || remoteTime > localTime) { const { data: remoteFull } = await supabase .from('flashcards') .select('*') .eq('id', remoteRaw.id) .single(); if (remoteFull) { const front = await cryptoService.decrypt(remoteFull.front); const back = await cryptoService.decrypt(remoteFull.back); const image_url = remoteFull.image_url ? await cryptoService.decrypt(remoteFull.image_url) : null; if (!local) { db.exec( "INSERT INTO flashcards (id, deck_id, front, back, image_url, next_review, ease_factor, interval, repetitions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [remoteFull.id, remoteFull.deck_id, front, back, image_url, remoteFull.next_review, remoteFull.ease_factor, remoteFull.interval, remoteFull.repetitions, remoteFull.created_at, remoteFull.updated_at] ); } else { db.exec( "UPDATE flashcards SET front = ?, back = ?, image_url = ?, next_review = ?, ease_factor = ?, interval = ?, repetitions = ?, updated_at = ? WHERE id = ?", [front, back, image_url, remoteFull.next_review, remoteFull.ease_factor, remoteFull.interval, remoteFull.repetitions, remoteFull.updated_at, remoteFull.id] ); } updated = true; } } } return updated; } } export const syncManager = new SyncManager();