File size: 14,387 Bytes
3ed9766
 
 
 
 
 
 
 
 
6bae38d
3ed9766
 
 
 
 
 
 
 
 
 
 
 
dfd686a
 
 
 
47d819f
 
 
 
 
 
 
dfd686a
 
 
3ed9766
 
 
 
 
 
 
47d819f
 
 
 
f2ca8a4
 
3ed9766
 
47d819f
 
 
 
 
3ed9766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfd686a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ed9766
6bae38d
 
3ed9766
 
 
 
 
 
 
6bae38d
 
3ed9766
 
47d819f
3ed9766
 
 
 
 
 
47d819f
 
 
 
3ed9766
 
 
47d819f
3ed9766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47d819f
3ed9766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47d819f
3ed9766
 
 
47d819f
3ed9766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47d819f
3ed9766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47d819f
3ed9766
 
 
47d819f
a563c57
 
f2ca8a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ed9766
 
f2ca8a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ed9766
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
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();