Spaces:
Paused
Paused
Savestate: Implement E2E encrypted flashcard sync with Supabase backend
Browse files- index.html +1 -1
- src/services/sync-service.js +143 -0
- src/views/learn/learn-view.js +11 -0
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.
|
| 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() {
|