Luca448 commited on
Commit
f2ca8a4
·
1 Parent(s): bf38c9d

Savestate: Implement E2E encrypted flashcard sync with Supabase backend

Browse files
index.html CHANGED
@@ -74,7 +74,7 @@
74
  </head>
75
  <body class="dark-theme in-chat-view">
76
 
77
- <div id="live-version-badge" style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); font-size: 10px; color: var(--text-secondary); opacity: 0.6; z-index: 99999; pointer-events: none; font-weight: 500; font-family: monospace; letter-spacing: 1px; background: rgba(0,0,0,0.2); padding: 2px 8px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.05);">BUILD 1.8.280</div>
78
 
79
  <!-- SVG Filters for Liquid Glass Refraction -->
80
  <svg width="0" height="0" style="position: absolute; pointer-events: none;">
 
74
  </head>
75
  <body class="dark-theme in-chat-view">
76
 
77
+ <div id="live-version-badge" style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); font-size: 10px; color: var(--text-secondary); opacity: 0.6; z-index: 99999; pointer-events: none; font-weight: 500; font-family: monospace; letter-spacing: 1px; background: rgba(0,0,0,0.2); padding: 2px 8px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.05);">BUILD 1.8.281</div>
78
 
79
  <!-- SVG Filters for Liquid Glass Refraction -->
80
  <svg width="0" height="0" style="position: absolute; pointer-events: none;">
src/services/sync-service.js CHANGED
@@ -45,6 +45,8 @@ class SyncManager {
45
  if (await this.syncSettings(userId)) hasUpdates = true;
46
  if (await this.syncConversations(userId)) hasUpdates = true;
47
  if (await this.syncMessages(userId)) hasUpdates = true;
 
 
48
 
49
  console.log('[Sync] Sync completed successfully.');
50
 
@@ -245,8 +247,149 @@ class SyncManager {
245
  }
246
  }
247
  return updated;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  }
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  }
251
 
252
  export const syncManager = new SyncManager();
 
45
  if (await this.syncSettings(userId)) hasUpdates = true;
46
  if (await this.syncConversations(userId)) hasUpdates = true;
47
  if (await this.syncMessages(userId)) hasUpdates = true;
48
+ if (await this.syncFlashcardDecks(userId)) hasUpdates = true;
49
+ if (await this.syncFlashcards(userId)) hasUpdates = true;
50
 
51
  console.log('[Sync] Sync completed successfully.');
52
 
 
247
  }
248
  }
249
  return updated;
250
+ async syncFlashcardDecks(userId) {
251
+ const localDecks = db.getDecks();
252
+
253
+ const { data: remoteDecks, error } = await supabase
254
+ .from('flashcard_decks')
255
+ .select('*')
256
+ .eq('user_id', userId);
257
+
258
+ if (error) {
259
+ console.error("[Sync] Error fetching decks", error);
260
+ throw error;
261
+ }
262
+
263
+ const remoteMap = {};
264
+ if (remoteDecks) {
265
+ for (const c of remoteDecks) remoteMap[c.id] = c;
266
+ }
267
+
268
+ for (const local of localDecks) {
269
+ const remote = remoteMap[local.id];
270
+ const localTime = new Date(local.updated_at).getTime();
271
+ const remoteTime = remote ? new Date(remote.updated_at).getTime() : 0;
272
+
273
+ if (!remote || localTime > remoteTime) {
274
+ await supabase.from('flashcard_decks').upsert({
275
+ id: local.id,
276
+ user_id: userId,
277
+ title: await cryptoService.encrypt(local.title),
278
+ description: await cryptoService.encrypt(local.description || ''),
279
+ color: local.color,
280
+ source_lang: local.source_lang,
281
+ target_lang: local.target_lang,
282
+ created_at: local.created_at,
283
+ updated_at: local.updated_at
284
+ }, { onConflict: 'id, user_id' });
285
+ }
286
+ }
287
+
288
+ let updated = false;
289
+ if (remoteDecks) {
290
+ for (const remote of remoteDecks) {
291
+ const local = localDecks.find(c => c.id === remote.id);
292
+ const remoteTime = new Date(remote.updated_at).getTime();
293
+ const localTime = local ? new Date(local.updated_at).getTime() : 0;
294
+
295
+ if (!local || remoteTime > localTime) {
296
+ const title = await cryptoService.decrypt(remote.title);
297
+ const description = await cryptoService.decrypt(remote.description);
298
+
299
+ if (!local) {
300
+ db.exec(
301
+ "INSERT INTO flashcard_decks (id, title, description, color, source_lang, target_lang, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
302
+ [remote.id, title, description, remote.color, remote.source_lang, remote.target_lang, remote.created_at, remote.updated_at]
303
+ );
304
+ } else {
305
+ db.exec(
306
+ "UPDATE flashcard_decks SET title = ?, description = ?, color = ?, source_lang = ?, target_lang = ?, updated_at = ? WHERE id = ?",
307
+ [title, description, remote.color, remote.source_lang, remote.target_lang, remote.updated_at, remote.id]
308
+ );
309
+ }
310
+ updated = true;
311
+ }
312
+ }
313
+ }
314
+ return updated;
315
  }
316
 
317
+ async syncFlashcards(userId) {
318
+ const localCards = db.exec("SELECT * FROM flashcards");
319
+
320
+ const { data: remoteCardsRaw, error } = await supabase
321
+ .from('flashcards')
322
+ .select('id, updated_at')
323
+ .eq('user_id', userId);
324
+
325
+ if (error) {
326
+ console.error("[Sync] Error fetching flashcards", error);
327
+ throw error;
328
+ }
329
+
330
+ const remoteMapRaw = {};
331
+ for (const r of (remoteCardsRaw || [])) {
332
+ remoteMapRaw[r.id] = r;
333
+ }
334
+
335
+ for (const local of localCards) {
336
+ const remoteRaw = remoteMapRaw[local.id];
337
+ const localTime = new Date(local.updated_at).getTime();
338
+ const remoteTime = remoteRaw ? new Date(remoteRaw.updated_at).getTime() : 0;
339
+
340
+ if (!remoteRaw || localTime > remoteTime) {
341
+ await supabase.from('flashcards').upsert({
342
+ id: local.id,
343
+ user_id: userId,
344
+ deck_id: local.deck_id,
345
+ front: await cryptoService.encrypt(local.front),
346
+ back: await cryptoService.encrypt(local.back),
347
+ image_url: local.image_url ? await cryptoService.encrypt(local.image_url) : null,
348
+ next_review: local.next_review,
349
+ ease_factor: local.ease_factor,
350
+ interval: local.interval,
351
+ repetitions: local.repetitions,
352
+ created_at: local.created_at,
353
+ updated_at: local.updated_at
354
+ }, { onConflict: 'id, user_id' });
355
+ }
356
+ }
357
+
358
+ let updated = false;
359
+ for (const remoteRaw of (remoteCardsRaw || [])) {
360
+ const local = localCards.find(c => c.id === remoteRaw.id);
361
+ const remoteTime = new Date(remoteRaw.updated_at).getTime();
362
+ const localTime = local ? new Date(local.updated_at).getTime() : 0;
363
+
364
+ if (!local || remoteTime > localTime) {
365
+ const { data: remoteFull } = await supabase
366
+ .from('flashcards')
367
+ .select('*')
368
+ .eq('id', remoteRaw.id)
369
+ .single();
370
+
371
+ if (remoteFull) {
372
+ const front = await cryptoService.decrypt(remoteFull.front);
373
+ const back = await cryptoService.decrypt(remoteFull.back);
374
+ const image_url = remoteFull.image_url ? await cryptoService.decrypt(remoteFull.image_url) : null;
375
+
376
+ if (!local) {
377
+ db.exec(
378
+ "INSERT INTO flashcards (id, deck_id, front, back, image_url, next_review, ease_factor, interval, repetitions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
379
+ [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]
380
+ );
381
+ } else {
382
+ db.exec(
383
+ "UPDATE flashcards SET front = ?, back = ?, image_url = ?, next_review = ?, ease_factor = ?, interval = ?, repetitions = ?, updated_at = ? WHERE id = ?",
384
+ [front, back, image_url, remoteFull.next_review, remoteFull.ease_factor, remoteFull.interval, remoteFull.repetitions, remoteFull.updated_at, remoteFull.id]
385
+ );
386
+ }
387
+ updated = true;
388
+ }
389
+ }
390
+ }
391
+ return updated;
392
+ }
393
  }
394
 
395
  export const syncManager = new SyncManager();
src/views/learn/learn-view.js CHANGED
@@ -24,6 +24,17 @@ export class LearnView {
24
  this.renderOverview();
25
  }
26
  });
 
 
 
 
 
 
 
 
 
 
 
27
  }
28
 
29
  loadDecks() {
 
24
  this.renderOverview();
25
  }
26
  });
27
+
28
+ window.addEventListener('cloud_sync_updated', () => {
29
+ this.loadDecks();
30
+ const isStudyMode = this.container.querySelector('.study-mode-container') !== null;
31
+ if (isStudyMode) return; // Don't interrupt study
32
+ if (this.activeDeckId) {
33
+ this.renderDeckDetails(this.activeDeckId);
34
+ } else if (this.container.innerHTML.trim() !== '') {
35
+ this.renderOverview();
36
+ }
37
+ });
38
  }
39
 
40
  loadDecks() {