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%
+
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"
}