diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..1744be8 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,3 @@ +# Google OAuth 2.0 Client ID for Google Drive sync +# Get one at https://console.cloud.google.com/apis/credentials +PUBLIC_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com diff --git a/apps/web/src/app.html b/apps/web/src/app.html index 76bae4a..b92457c 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -4,6 +4,7 @@ %sveltekit.head% +
%sveltekit.body%
diff --git a/apps/web/src/lib/components/LibraryScreen.svelte b/apps/web/src/lib/components/LibraryScreen.svelte index 541ea4d..dcf67d9 100644 --- a/apps/web/src/lib/components/LibraryScreen.svelte +++ b/apps/web/src/lib/components/LibraryScreen.svelte @@ -5,7 +5,7 @@ import { t, getLocale } from '$lib/i18n.svelte'; import { formatBytes } from '$lib/format'; import { loadBackupBytes } from '$lib/storage'; - import { Plus, Merge, Download, Eye, Smartphone, Loader } from 'lucide-svelte'; + import { Plus, Merge, Download, Eye, Smartphone, Loader, Cloud, CloudOff } from 'lucide-svelte'; let loadingTruth = $state(false); let addingFile = $state(false); @@ -110,9 +110,20 @@ {/each} -

+

{t('library.sourceOfTruth.desc')}

+ {#if appState.syncStatus === 'connected' && appState.syncMeta?.lastSyncedAt} +
+ + {t('sync.synced')} +
+ {:else if appState.syncStatus === 'connected'} +
+ + {t('sync.notSynced')} +
+ {/if}
+ +
+ +
+
diff --git a/apps/web/src/lib/components/SyncSection.svelte b/apps/web/src/lib/components/SyncSection.svelte new file mode 100644 index 0000000..d237484 --- /dev/null +++ b/apps/web/src/lib/components/SyncSection.svelte @@ -0,0 +1,196 @@ + + +

+ + {t('sync.title')} +

+ +{#if appState.syncStatus === 'disconnected'} +

+ {t('sync.description')} +

+ + +{:else if appState.syncStatus === 'connecting'} +
+ + {t('sync.connect')}… +
+ +{:else if appState.syncStatus === 'syncing'} +
+
+ + {t('sync.uploading')} +
+
+ +{:else if appState.syncStatus === 'connected'} +
+
+ + + {t('sync.connected')} + +
+
+ {#if appState.syncMeta?.lastSyncedAt} + {t('sync.lastSync', { date: formatDate(appState.syncMeta.lastSyncedAt) })} + {:else} + {t('sync.neverSynced')} + {/if} +
+
+ + +
+
+ +{:else if appState.syncStatus === 'error'} +
+
+ + + {t('sync.error')} + +
+ {#if appState.syncError} +

{appState.syncError}

+ {/if} + +
+{/if} + + +{#if showConflict} +
+
+ +

{t('sync.conflict.title')}

+

+ {t('sync.conflict.desc')} +

+
+ + + +
+
+
+{/if} diff --git a/apps/web/src/lib/gdrive.ts b/apps/web/src/lib/gdrive.ts new file mode 100644 index 0000000..ff7a38b --- /dev/null +++ b/apps/web/src/lib/gdrive.ts @@ -0,0 +1,343 @@ +/** + * Google Drive sync module — client-side only, no backend. + * Uses Google Identity Services (GIS) for OAuth 2.0 implicit flow + * and Drive API v3 REST with fetch for file operations. + */ + +import { PUBLIC_GOOGLE_CLIENT_ID } from '$env/static/public'; +import { saveConfig, loadConfig, deleteConfig, type BackupMeta } from './storage'; + +const DRIVE_API = 'https://www.googleapis.com/drive/v3'; +const UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3'; +const SCOPE = 'https://www.googleapis.com/auth/drive.appdata'; +const SYNC_META_KEY = 'gdriveSyncMeta'; +const REMOTE_BACKUP_NAME = 'source-of-truth.jwlibrary'; +const REMOTE_META_NAME = 'sync-meta.json'; + +// ── Types ──────────────────────────────────────────────── + +export interface SyncMeta { + remoteFileId: string | null; + remoteMetaFileId: string | null; + lastSyncedAt: string | null; + localBackupMeta: BackupMeta | null; +} + +export type SyncStatus = 'disconnected' | 'connecting' | 'connected' | 'syncing' | 'error'; + +export interface RemoteStatus { + hasRemote: boolean; + remoteModifiedAt: string | null; + needsDownload: boolean; + needsUpload: boolean; + hasConflict: boolean; +} + +// ── Token management ───────────────────────────────────── + +let accessToken: string | null = null; +let tokenExpiresAt = 0; + +function isTokenValid(): boolean { + return !!accessToken && Date.now() < tokenExpiresAt; +} + +export function getAccessToken(): string | null { + return isTokenValid() ? accessToken : null; +} + +function getClientId(): string { + return PUBLIC_GOOGLE_CLIENT_ID ?? ''; +} + +function isGisLoaded(): boolean { + return typeof google !== 'undefined' && !!google?.accounts?.oauth2; +} + +export function signIn(): Promise { + return new Promise((resolve, reject) => { + if (!isGisLoaded()) { + reject(new Error('Google Identity Services not loaded')); + return; + } + const clientId = getClientId(); + if (!clientId) { + reject(new Error('Google Client ID not configured')); + return; + } + const client = google.accounts.oauth2.initTokenClient({ + client_id: clientId, + scope: SCOPE, + callback: (response) => { + if (response.error) { + reject(new Error(response.error_description ?? response.error)); + return; + } + accessToken = response.access_token; + tokenExpiresAt = Date.now() + response.expires_in * 1000; + resolve(response.access_token); + }, + error_callback: (error) => { + reject(new Error(error.message)); + }, + }); + client.requestAccessToken(); + }); +} + +export function trySilentAuth(): Promise { + return new Promise((resolve) => { + if (!isGisLoaded() || !getClientId()) { + resolve(false); + return; + } + const client = google.accounts.oauth2.initTokenClient({ + client_id: getClientId(), + scope: SCOPE, + callback: (response) => { + if (response.error) { + resolve(false); + return; + } + accessToken = response.access_token; + tokenExpiresAt = Date.now() + response.expires_in * 1000; + resolve(true); + }, + error_callback: () => resolve(false), + }); + client.requestAccessToken({ prompt: '' }); + }); +} + +export function signOut(): Promise { + return new Promise((resolve) => { + if (accessToken && isGisLoaded()) { + google.accounts.oauth2.revoke(accessToken, () => resolve()); + } else { + resolve(); + } + accessToken = null; + tokenExpiresAt = 0; + }); +} + +// ── Drive API helpers ──────────────────────────────────── + +async function ensureToken(): Promise { + if (isTokenValid()) return accessToken!; + const ok = await trySilentAuth(); + if (ok && accessToken) return accessToken; + throw new Error('Not authenticated'); +} + +async function driveGet(path: string, params?: Record): Promise { + const token = await ensureToken(); + const url = new URL(`${DRIVE_API}${path}`); + if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Drive API ${res.status}: ${await res.text()}`); + return res; +} + +interface DriveFile { + id: string; + name: string; + modifiedTime: string; +} + +async function listAppDataFiles(): Promise { + const res = await driveGet('/files', { + spaces: 'appDataFolder', + fields: 'files(id,name,modifiedTime)', + pageSize: '20', + }); + const data = await res.json(); + return data.files ?? []; +} + +async function uploadFile( + name: string, + bytes: Uint8Array, + mimeType: string, + existingId?: string, +): Promise { + const token = await ensureToken(); + const metadata = existingId + ? { name } + : { name, parents: ['appDataFolder'] }; + + const boundary = '---jwnotessync' + Date.now(); + const metaJson = JSON.stringify(metadata); + + const encoder = new TextEncoder(); + const parts = [ + encoder.encode(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metaJson}\r\n--${boundary}\r\nContent-Type: ${mimeType}\r\nContent-Transfer-Encoding: binary\r\n\r\n`), + bytes, + encoder.encode(`\r\n--${boundary}--`), + ]; + + const body = new Uint8Array(parts.reduce((s, p) => s + p.byteLength, 0)); + let offset = 0; + for (const part of parts) { + body.set(part, offset); + offset += part.byteLength; + } + + const url = existingId + ? `${UPLOAD_API}/files/${existingId}?uploadType=multipart` + : `${UPLOAD_API}/files?uploadType=multipart`; + + const method = existingId ? 'PATCH' : 'POST'; + + const res = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': `multipart/related; boundary=${boundary}`, + }, + body, + }); + + if (!res.ok) throw new Error(`Upload failed: ${res.status} ${await res.text()}`); + const data = await res.json(); + return data.id; +} + +async function downloadFile(fileId: string): Promise { + const token = await ensureToken(); + const res = await fetch(`${DRIVE_API}/files/${fileId}?alt=media`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Download failed: ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} + +// ── Sync metadata persistence ──────────────────────────── + +export async function loadSyncMeta(): Promise { + return loadConfig(SYNC_META_KEY); +} + +export async function saveSyncMeta(meta: SyncMeta): Promise { + // Ensure plain object for IndexedDB structured clone + await saveConfig(SYNC_META_KEY, JSON.parse(JSON.stringify(meta))); +} + +export async function clearSyncMeta(): Promise { + await deleteConfig(SYNC_META_KEY); +} + +// ── High-level sync operations ─────────────────────────── + +export async function uploadSourceOfTruth( + backupMeta: BackupMeta, + bytes: Uint8Array, +): Promise { + const syncMeta = (await loadSyncMeta()) ?? { + remoteFileId: null, + remoteMetaFileId: null, + lastSyncedAt: null, + localBackupMeta: null, + }; + + // Upload the backup file + const fileId = await uploadFile( + REMOTE_BACKUP_NAME, + bytes, + 'application/zip', + syncMeta.remoteFileId ?? undefined, + ); + + // Upload the metadata sidecar + const metaJson = JSON.stringify(backupMeta); + const metaBytes = new TextEncoder().encode(metaJson); + const metaFileId = await uploadFile( + REMOTE_META_NAME, + metaBytes, + 'application/json', + syncMeta.remoteMetaFileId ?? undefined, + ); + + // Update sync metadata + await saveSyncMeta({ + remoteFileId: fileId, + remoteMetaFileId: metaFileId, + lastSyncedAt: new Date().toISOString(), + localBackupMeta: backupMeta, + }); +} + +export async function checkRemoteStatus(localTruth: BackupMeta | null): Promise { + const files = await listAppDataFiles(); + const remoteBackup = files.find((f) => f.name === REMOTE_BACKUP_NAME); + + if (!remoteBackup) { + return { + hasRemote: false, + remoteModifiedAt: null, + needsDownload: false, + needsUpload: !!localTruth, + hasConflict: false, + }; + } + + const syncMeta = await loadSyncMeta(); + const lastSynced = syncMeta?.lastSyncedAt; + + const remoteIsNewer = !lastSynced || remoteBackup.modifiedTime > lastSynced; + const localIsNewer = localTruth && lastSynced && localTruth.storedAt > lastSynced; + + return { + hasRemote: true, + remoteModifiedAt: remoteBackup.modifiedTime, + needsDownload: remoteIsNewer && !localIsNewer, + needsUpload: !!localIsNewer && !remoteIsNewer, + hasConflict: !!(remoteIsNewer && localIsNewer), + }; +} + +export async function downloadFromDrive(): Promise<{ bytes: Uint8Array; meta: BackupMeta } | null> { + const files = await listAppDataFiles(); + const remoteBackup = files.find((f) => f.name === REMOTE_BACKUP_NAME); + const remoteMeta = files.find((f) => f.name === REMOTE_META_NAME); + + if (!remoteBackup) return null; + + const bytes = await downloadFile(remoteBackup.id); + + let meta: BackupMeta | null = null; + if (remoteMeta) { + try { + const metaBytes = await downloadFile(remoteMeta.id); + meta = JSON.parse(new TextDecoder().decode(metaBytes)); + } catch { /* ignore bad metadata */ } + } + + if (!meta) { + // Fallback: construct basic metadata + meta = { + id: crypto.randomUUID(), + fileName: REMOTE_BACKUP_NAME, + deviceName: 'Cloud Backup', + date: new Date().toISOString(), + stats: { notes: 0, highlights: 0, bookmarks: 0, tags: 0 }, + storedAt: new Date().toISOString(), + sizeBytes: bytes.byteLength, + isMerged: true, + parentIds: [], + isSourceOfTruth: true, + }; + } + + // Update sync meta with remote file IDs + await saveSyncMeta({ + remoteFileId: remoteBackup.id, + remoteMetaFileId: remoteMeta?.id ?? null, + lastSyncedAt: new Date().toISOString(), + localBackupMeta: meta, + }); + + return { bytes, meta }; +} diff --git a/apps/web/src/lib/merge.ts b/apps/web/src/lib/merge.ts index f002a42..e60f138 100644 --- a/apps/web/src/lib/merge.ts +++ b/apps/web/src/lib/merge.ts @@ -209,6 +209,11 @@ export async function runMerge(archiveA: JWLibraryArchive, archiveB: JWLibraryAr // Merge succeeded — pending backups are now committed appState.pendingBackupIds = []; + // Auto-sync to cloud if connected + if (appState.syncStatus === 'connected' && appState.mergeMode === 'library') { + appState.syncToCloud().catch(() => {}); + } + appState.goTo('export'); } } catch (err) { diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 481a9bb..80cc1f7 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -290,6 +290,23 @@ export async function loadMergeConfig(): Promise { return config ?? null; } +/** Generic config get/set for any key in the config store. */ +export async function saveConfig(key: string, value: unknown): Promise { + const db = await openDB(); + await idbPut(db, CONFIG_STORE, value, key); +} + +export async function loadConfig(key: string): Promise { + const db = await openDB(); + const val = await idbGet(db, CONFIG_STORE, key); + return val ?? null; +} + +export async function deleteConfig(key: string): Promise { + const db = await openDB(); + await idbDelete(db, CONFIG_STORE, key); +} + /** Estimate total storage used by backups (bytes). */ export async function getStorageUsage(): Promise<{ used: number; quota: number }> { try { diff --git a/apps/web/src/lib/stores/app.svelte.ts b/apps/web/src/lib/stores/app.svelte.ts index 5a294b1..dc562c9 100644 --- a/apps/web/src/lib/stores/app.svelte.ts +++ b/apps/web/src/lib/stores/app.svelte.ts @@ -15,6 +15,18 @@ import { } from '$lib/storage'; import { parseJWLibrary } from '@jw-notes-sync/core'; import { webAdapter } from '$lib/adapter'; +import { + signIn, + signOut, + trySilentAuth, + loadSyncMeta, + clearSyncMeta, + uploadSourceOfTruth, + checkRemoteStatus, + downloadFromDrive, + type SyncStatus, + type SyncMeta, +} from '$lib/gdrive'; export interface ImportedBackup { id: string; @@ -103,6 +115,9 @@ class AppState { pendingBackupIds = $state([]); mergeConfig = $state({ ...DEFAULT_MERGE_CONFIG }); dryRun = $state(false); + syncStatus = $state('disconnected'); + syncError = $state(null); + syncMeta = $state(null); mergeHistory = $state(loadHistory()); showOnboarding = $state( typeof window !== 'undefined' ? !localStorage.getItem(ONBOARDING_KEY) : false, @@ -204,6 +219,108 @@ class AppState { persistMergeConfig({ ...this.mergeConfig }); } + // ── Cloud sync ────────────────────────────────────────── + + async initSync() { + this.syncMeta = await loadSyncMeta(); + if (this.syncMeta?.lastSyncedAt) { + // User previously connected — show as connected (lazy re-auth on actual API calls) + this.syncStatus = 'connected'; + + // Try silent re-auth in background (may fail if GIS not loaded yet or popup blocked) + this.waitForGisAndAuth(); + } + } + + private async waitForGisAndAuth() { + // Wait for GIS script to load (up to 5 seconds) + for (let i = 0; i < 50; i++) { + if (typeof google !== 'undefined' && google?.accounts?.oauth2) break; + await new Promise((r) => setTimeout(r, 100)); + } + try { + const ok = await trySilentAuth(); + if (!ok) { + // Silent auth failed — still show as connected but will re-auth on next API call + } + } catch { + // GIS not loaded or auth failed — will prompt on next sync attempt + } + } + + async connectGoogleDrive() { + this.syncStatus = 'connecting'; + this.syncError = null; + try { + await signIn(); + this.syncStatus = 'connected'; + this.syncMeta = await loadSyncMeta(); + } catch (err) { + this.syncStatus = 'error'; + this.syncError = err instanceof Error ? err.message : String(err); + } + } + + async disconnectGoogleDrive() { + await signOut(); + await clearSyncMeta(); + this.syncStatus = 'disconnected'; + this.syncMeta = null; + this.syncError = null; + } + + async syncToCloud() { + if (this.syncStatus !== 'connected' || !this.sourceOfTruth) return; + this.syncStatus = 'syncing'; + this.syncError = null; + try { + // Ensure we have a valid token (may prompt user if expired) + const { getAccessToken, signIn: gSignIn } = await import('$lib/gdrive'); + if (!getAccessToken()) await gSignIn(); + + const bytes = await loadBackupBytes(this.sourceOfTruth.id); + if (!bytes) throw new Error('Source of truth not found in storage'); + await uploadSourceOfTruth(this.sourceOfTruth, bytes); + this.syncMeta = await loadSyncMeta(); + this.syncStatus = 'connected'; + } catch (err) { + this.syncStatus = 'error'; + this.syncError = err instanceof Error ? err.message : String(err); + } + } + + async syncFromCloud() { + if (this.syncStatus !== 'connected') return; + this.syncStatus = 'syncing'; + this.syncError = null; + try { + // Ensure we have a valid token + const { getAccessToken, signIn: gSignIn } = await import('$lib/gdrive'); + if (!getAccessToken()) await gSignIn(); + + const result = await downloadFromDrive(); + if (!result) throw new Error('No backup found on Google Drive'); + // Save as new source of truth + await saveMergedBackup(result.meta.id, result.bytes, result.meta); + await this.refreshSourceOfTruth(); + await this.refreshStorage(); + this.syncMeta = await loadSyncMeta(); + this.syncStatus = 'connected'; + } catch (err) { + this.syncStatus = 'error'; + this.syncError = err instanceof Error ? err.message : String(err); + } + } + + async checkCloudStatus() { + if (this.syncStatus !== 'connected') return null; + try { + return await checkRemoteStatus(this.sourceOfTruth); + } catch { + return null; + } + } + goToTab(tab: Tab) { this.tab = tab; if (tab === 'home') { diff --git a/apps/web/src/lib/types/google.d.ts b/apps/web/src/lib/types/google.d.ts new file mode 100644 index 0000000..439dcc4 --- /dev/null +++ b/apps/web/src/lib/types/google.d.ts @@ -0,0 +1,24 @@ +declare namespace google.accounts.oauth2 { + interface TokenClient { + requestAccessToken(overrides?: { prompt?: string }): void; + } + + interface TokenClientConfig { + client_id: string; + scope: string; + callback: (response: TokenResponse) => void; + error_callback?: (error: { type: string; message: string }) => void; + } + + interface TokenResponse { + access_token: string; + expires_in: number; + scope: string; + token_type: string; + error?: string; + error_description?: string; + } + + function initTokenClient(config: TokenClientConfig): TokenClient; + function revoke(token: string, callback?: () => void): void; +} diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 8e42365..4b66b21 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -11,7 +11,7 @@ onMount(() => { initI18n(); - appState.initStorage(); + appState.initStorage().then(() => appState.initSync()); }); diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index a72ccc5..2b2ad4e 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -194,6 +194,25 @@ "storage.clear": "Clear all stored backups", "storage.empty": "No stored backups", + "sync.title": "Cloud sync", + "sync.description": "Sync your source of truth across devices using Google Drive.", + "sync.connect": "Connect Google Drive", + "sync.disconnect": "Disconnect", + "sync.connected": "Connected to Google Drive", + "sync.lastSync": "Last synced: {{date}}", + "sync.neverSynced": "Never synced", + "sync.syncNow": "Sync now", + "sync.uploading": "Uploading…", + "sync.downloading": "Downloading…", + "sync.error": "Sync error", + "sync.retry": "Retry", + "sync.conflict.title": "Sync conflict", + "sync.conflict.desc": "Both your local backup and the remote copy have changed since the last sync.", + "sync.conflict.keepLocal": "Keep local", + "sync.conflict.keepRemote": "Keep remote", + "sync.synced": "Synced", + "sync.notSynced": "Not synced", + "common.cancel": "Cancel", "common.error": "Error" } diff --git a/packages/i18n/src/locales/fr.json b/packages/i18n/src/locales/fr.json index 6df281f..98b4573 100644 --- a/packages/i18n/src/locales/fr.json +++ b/packages/i18n/src/locales/fr.json @@ -194,6 +194,25 @@ "storage.clear": "Supprimer toutes les sauvegardes stockées", "storage.empty": "Aucune sauvegarde stockée", + "sync.title": "Synchronisation cloud", + "sync.description": "Synchronisez votre source de vérité entre vos appareils via Google Drive.", + "sync.connect": "Connecter Google Drive", + "sync.disconnect": "Déconnecter", + "sync.connected": "Connecté à Google Drive", + "sync.lastSync": "Dernière synchro : {{date}}", + "sync.neverSynced": "Jamais synchronisé", + "sync.syncNow": "Synchroniser", + "sync.uploading": "Envoi en cours…", + "sync.downloading": "Téléchargement…", + "sync.error": "Erreur de synchronisation", + "sync.retry": "Réessayer", + "sync.conflict.title": "Conflit de synchronisation", + "sync.conflict.desc": "Votre sauvegarde locale et la copie distante ont toutes deux changé depuis la dernière synchronisation.", + "sync.conflict.keepLocal": "Garder la version locale", + "sync.conflict.keepRemote": "Garder la version distante", + "sync.synced": "Synchronisé", + "sync.notSynced": "Non synchronisé", + "common.cancel": "Annuler", "common.error": "Erreur" }