diff --git a/README.md b/README.md index b3c0582..0660ca1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,66 @@ JW Notes Sync lets you: 5. **Export** a single merged `.jwlibrary` file ready to import on any device 6. **Sync** automatically via Google Drive or iCloud (optional, Phase 3) +## How It Works + +### First Visit — Initial Merge + +```mermaid +flowchart LR + A["📱 Device A\n.jwlibrary"] --> IMPORT[Import] + B["📱 Device B\n.jwlibrary"] --> IMPORT + IMPORT --> MERGE["⚙️ Merge Engine"] + MERGE --> TRUTH["⭐ Source of Truth\n(saved locally)"] + TRUTH --> DL["📥 Download\nmerged file"] +``` + +On your first visit, import two or more `.jwlibrary` backup files. The app merges them and saves the result as your **source of truth** — a single file that contains everything from all your devices. + +### Next Visits — Add & Sync + +```mermaid +flowchart LR + NEW["📱 New backup\n.jwlibrary"] --> MERGE["⚙️ Auto-merge"] + TRUTH["⭐ Current\nSource of Truth"] --> MERGE + MERGE --> NEW_TRUTH["⭐ New\nSource of Truth"] + NEW_TRUTH --> DL["📥 Download"] +``` + +Each time you return with a new backup, just add it. The app automatically merges it against your existing source of truth, producing an updated version. + +### Persistent Library + +```mermaid +flowchart TD + subgraph LIBRARY["📚 Your Library (stored locally)"] + SOT["⭐ Source of Truth\nLatest merged backup"] + O1["📱 Google Pixel 6a"] + O2["📱 iPad"] + O3["📱 Galaxy Tab"] + end + + ADD["+ Add a backup"] -->|"new file"| AUTO["Auto-merge\nwith ⭐"] + AUTO --> SOT + QM["⚡ Quick merge"] -->|"pick 2 files"| STANDALONE["One-off merge\n(not saved)"] +``` + +Your library persists across sessions using OPFS/IndexedDB. The source of truth cannot be deleted. You can also do standalone "quick merges" without affecting your library. + +### Storage Architecture + +```mermaid +flowchart TD + subgraph BROWSER["Browser Storage"] + OPFS["OPFS\n(raw .jwlibrary bytes)"] + IDB["IndexedDB\n(metadata + config)"] + LS["localStorage\n(theme, language, onboarding)"] + end + + OPFS -->|"fallback"| IDB +``` + +All data stays in your browser. Nothing is sent to any server. + ## Privacy & Architecture - **No backend.** Zero. Nothing is sent to any server we control. diff --git a/apps/web/src/lib/components/ExportScreen.svelte b/apps/web/src/lib/components/ExportScreen.svelte index 046e18a..8ae0dd5 100644 --- a/apps/web/src/lib/components/ExportScreen.svelte +++ b/apps/web/src/lib/components/ExportScreen.svelte @@ -1,7 +1,7 @@ + + + + +

{t('library.title')}

+ + +{#if appState.sourceOfTruth} + {@const truth = appState.sourceOfTruth} +
+
+

{truth.deviceName}

+ {formatDate(truth.date)} +
+
+ {truth.fileName} · {formatBytes(truth.sizeBytes)} +
+
+ {#each [{ count: truth.stats.notes, label: t('stats.notes'), color: 'var(--stat-1)' }, { count: truth.stats.highlights, label: t('stats.highlights'), color: 'var(--stat-2)' }, { count: truth.stats.bookmarks, label: t('stats.bookmarks'), color: 'var(--stat-3)' }, { count: truth.stats.tags, label: t('stats.tags'), color: 'var(--stat-4)' }] as stat} + + {stat.count} + {stat.label} + + {/each} +
+

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

+
+ + +
+
+{/if} + + +
+ + +
+ + +{#if originals.length > 0} +

+ {t('library.originals')} +

+
+ {#each originals as meta} +
+
+ + {meta.deviceName} + {formatDate(meta.date)} +
+
+ {meta.fileName} · {formatBytes(meta.sizeBytes)} +
+
+ {#each [{ count: meta.stats.notes, label: t('stats.notes'), color: 'var(--stat-1)' }, { count: meta.stats.highlights, label: t('stats.highlights'), color: 'var(--stat-2)' }, { count: meta.stats.bookmarks, label: t('stats.bookmarks'), color: 'var(--stat-3)' }, { count: meta.stats.tags, label: t('stats.tags'), color: 'var(--stat-4)' }] as stat} + + {stat.count} + {stat.label} + + {/each} +
+
+ {/each} +
+{/if} + diff --git a/apps/web/src/lib/components/MergeScreen.svelte b/apps/web/src/lib/components/MergeScreen.svelte index dd4e1b5..6c40dcb 100644 --- a/apps/web/src/lib/components/MergeScreen.svelte +++ b/apps/web/src/lib/components/MergeScreen.svelte @@ -238,6 +238,15 @@ > {appState.dryRun ? t('config.dryRun') : t('merge.button')} +
+ +
{:else if appState.mergeStatus === 'merging'} diff --git a/apps/web/src/lib/merge.ts b/apps/web/src/lib/merge.ts index 31faad3..f002a42 100644 --- a/apps/web/src/lib/merge.ts +++ b/apps/web/src/lib/merge.ts @@ -23,6 +23,7 @@ import { } from '@jw-notes-sync/core'; import { webAdapter } from '$lib/adapter'; import { appState, type MergeResult } from '$lib/stores/app.svelte'; +import { saveMergedBackup } from '$lib/storage'; import { t } from '$lib/i18n.svelte'; const MERGE_STEP_KEYS = [ @@ -162,6 +163,7 @@ export async function runMerge(archiveA: JWLibraryArchive, archiveB: JWLibraryAr dryRun: true, }); + appState.pendingBackupIds = []; appState.openExplorer(contents, t('config.dryRun.badge')); } else { // Full merge: build archive @@ -178,6 +180,22 @@ export async function runMerge(archiveA: JWLibraryArchive, archiveB: JWLibraryAr steps: MERGE_STEP_KEYS.map((key) => ({ name: t(key), status: 'done' })), }; + // Save as source of truth in library mode + if (appState.mergeMode === 'library') { + const mergedId = crypto.randomUUID(); + const now = new Date().toISOString().split('T')[0]; + await saveMergedBackup(mergedId, archiveBytes, { + id: mergedId, + fileName: `MergedBackup_${now}.jwlibrary`, + deviceName: 'JW Notes Sync', + date: new Date().toISOString(), + stats: mergeStats, + parentIds: appState.backups.map((b) => b.id), + }); + await appState.refreshSourceOfTruth(); + await appState.refreshStorage(); + } + // Log to history appState.addMergeHistory({ id: crypto.randomUUID(), @@ -188,6 +206,9 @@ export async function runMerge(archiveA: JWLibraryArchive, archiveB: JWLibraryAr dryRun: false, }); + // Merge succeeded — pending backups are now committed + appState.pendingBackupIds = []; + appState.goTo('export'); } } catch (err) { diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index e6d8634..481a9bb 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -4,7 +4,7 @@ */ const DB_NAME = 'jw-notes-sync'; -const DB_VERSION = 1; +const DB_VERSION = 2; const META_STORE = 'backup-meta'; const BLOB_STORE = 'backup-blobs'; const CONFIG_STORE = 'config'; @@ -17,6 +17,9 @@ export interface BackupMeta { stats: { notes: number; highlights: number; bookmarks: number; tags: number }; storedAt: string; // ISO timestamp sizeBytes: number; + isMerged: boolean; + parentIds: string[]; + isSourceOfTruth: boolean; } // ── IndexedDB helpers ──────────────────────────────────── @@ -27,8 +30,9 @@ function openDB(): Promise { if (cachedDB) return Promise.resolve(cachedDB); return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); - req.onupgradeneeded = () => { + req.onupgradeneeded = (event) => { const db = req.result; + const oldVersion = event.oldVersion; if (!db.objectStoreNames.contains(META_STORE)) { db.createObjectStore(META_STORE, { keyPath: 'id' }); } @@ -38,6 +42,24 @@ function openDB(): Promise { if (!db.objectStoreNames.contains(CONFIG_STORE)) { db.createObjectStore(CONFIG_STORE); } + // v1→v2: backfill new fields on existing metas + if (oldVersion < 2 && req.transaction) { + const store = req.transaction.objectStore(META_STORE); + const cursor = store.openCursor(); + cursor.onsuccess = () => { + const c = cursor.result; + if (c) { + const meta = c.value as Record; + if (meta.isMerged === undefined) { + meta.isMerged = false; + meta.parentIds = []; + meta.isSourceOfTruth = false; + c.update(meta); + } + c.continue(); + } + }; + } }; req.onsuccess = () => { cachedDB = req.result; @@ -151,10 +173,13 @@ async function opfsDelete(name: string): Promise { export async function saveBackup( id: string, rawBytes: Uint8Array, - meta: Omit, + meta: Omit & Partial>, ): Promise { const db = await openDB(); const fullMeta: BackupMeta = { + isMerged: false, + parentIds: [], + isSourceOfTruth: false, ...meta, storedAt: new Date().toISOString(), sizeBytes: rawBytes.byteLength, @@ -193,13 +218,47 @@ export async function listBackupMetas(): Promise { return metas.sort((a, b) => b.storedAt.localeCompare(a.storedAt)); } -/** Delete a backup from both OPFS and IDB. */ +/** Delete a backup from both OPFS and IDB. Rejects merged files. */ export async function deleteBackup(id: string): Promise { - await opfsDelete(`backup-${id}.jwlibrary`); const db = await openDB(); + const meta = await idbGet(db, META_STORE, id); + if (meta?.isMerged) { + throw new Error('Cannot delete a merged backup'); + } + await opfsDelete(`backup-${id}.jwlibrary`); await idbDelete(db, META_STORE, id); await idbDelete(db, BLOB_STORE, id); +} +/** Get the current source of truth (latest merged file). */ +export async function getSourceOfTruth(): Promise { + const metas = await listBackupMetas(); + return metas.find((m) => m.isSourceOfTruth) ?? null; +} + +/** Save a merged backup as the new source of truth. Clears the flag on the old truth. */ +export async function saveMergedBackup( + id: string, + rawBytes: Uint8Array, + meta: Omit, +): Promise { + const db = await openDB(); + + // Clear old source of truth + const allMetas = await idbGetAll(db, META_STORE); + for (const m of allMetas) { + if (m.isSourceOfTruth) { + m.isSourceOfTruth = false; + await idbPut(db, META_STORE, m); + } + } + + // Save new merged file as truth + await saveBackup(id, rawBytes, { + ...meta, + isMerged: true, + isSourceOfTruth: true, + }); } /** Delete all stored backups. */ diff --git a/apps/web/src/lib/stores/app.svelte.ts b/apps/web/src/lib/stores/app.svelte.ts index 9905d31..5a294b1 100644 --- a/apps/web/src/lib/stores/app.svelte.ts +++ b/apps/web/src/lib/stores/app.svelte.ts @@ -3,10 +3,12 @@ import { DEFAULT_MERGE_CONFIG } from '@jw-notes-sync/core'; import { saveBackup, deleteBackup, + saveMergedBackup, saveMergeConfig as persistMergeConfig, loadMergeConfig, listBackupMetas, loadBackupBytes, + getSourceOfTruth, clearAllBackups, getStorageUsage, type BackupMeta, @@ -30,7 +32,9 @@ export interface ImportedBackup { export type Tab = 'home' | 'settings'; -export type Screen = 'import' | 'merge' | 'export' | 'explore'; +export type Screen = 'library' | 'import' | 'merge' | 'export' | 'explore'; + +export type MergeMode = 'library' | 'quick'; export type MergeStatus = 'idle' | 'merging' | 'done' | 'error'; @@ -81,7 +85,9 @@ function saveHistory(history: MergeHistoryEntry[]) { class AppState { tab = $state('home'); - screen = $state('import'); + screen = $state('library'); + mergeMode = $state('library'); + sourceOfTruth = $state(null); backups = $state([]); mergeStatus = $state('idle'); mergeProgress = $state({ @@ -93,6 +99,8 @@ class AppState { mergeError = $state(null); theme = $state<'light' | 'dark'>('light'); archiveBytes = $state(null); + /** IDs of backups added during the current library merge flow (cleaned up on cancel). */ + pendingBackupIds = $state([]); mergeConfig = $state({ ...DEFAULT_MERGE_CONFIG }); dryRun = $state(false); mergeHistory = $state(loadHistory()); @@ -128,6 +136,7 @@ class AppState { addBackup(backup: ImportedBackup, rawBytes?: Uint8Array) { this.backups = [...this.backups, backup]; if (rawBytes) { + this.pendingBackupIds = [...this.pendingBackupIds, backup.id]; saveBackup(backup.id, rawBytes, { id: backup.id, fileName: backup.fileName, @@ -178,11 +187,13 @@ class AppState { async clearStorage() { await clearAllBackups(); + this.sourceOfTruth = null; await this.refreshStorage(); } async initStorage() { await this.refreshStorage(); + this.sourceOfTruth = await getSourceOfTruth(); const savedConfig = await loadMergeConfig(); if (savedConfig) { this.mergeConfig = savedConfig as MergeConfig; @@ -195,6 +206,9 @@ class AppState { goToTab(tab: Tab) { this.tab = tab; + if (tab === 'home') { + this.screen = 'library'; + } } goTo(screen: Screen) { @@ -244,13 +258,25 @@ class AppState { } canMerge(): boolean { + if (this.mergeMode === 'library' && this.sourceOfTruth && this.backups.length >= 1) { + return true; + } return this.backups.length >= 2; } - reset() { + async reset() { + // Clean up any pending backups that were saved during a cancelled merge + if (this.pendingBackupIds.length > 0) { + for (const id of this.pendingBackupIds) { + try { await deleteBackup(id); } catch { /* may not exist yet */ } + } + await this.refreshStorage(); + } + this.pendingBackupIds = []; this.tab = 'home'; - this.screen = 'import'; + this.screen = 'library'; this.backups = []; + this.mergeMode = 'library'; this.mergeStatus = 'idle'; this.mergeProgress = { step: '', percent: 0, steps: [] }; this.mergeResult = null; @@ -258,7 +284,44 @@ class AppState { this.archiveBytes = null; this.explorerData = null; this.explorerLabel = ''; - this.previousScreen = 'import'; + this.previousScreen = 'library'; + } + + /** Start a library merge: load the truth as backup A, new file becomes B. */ + async startLibraryMerge(newBackup: ImportedBackup): Promise { + if (!this.sourceOfTruth) return false; + const truthBytes = await loadBackupBytes(this.sourceOfTruth.id); + if (!truthBytes) return false; + const truthArchive = await parseJWLibrary(webAdapter, truthBytes); + const truthBackup: ImportedBackup = { + id: this.sourceOfTruth.id, + fileName: this.sourceOfTruth.fileName, + deviceName: this.sourceOfTruth.deviceName, + date: this.sourceOfTruth.date, + archive: truthArchive, + stats: this.sourceOfTruth.stats, + }; + this.backups = [truthBackup, newBackup]; + this.mergeMode = 'library'; + this.goTo('merge'); + return true; + } + + /** Start quick merge mode (standalone, no persistence). */ + startQuickMerge() { + this.backups = []; + this.mergeMode = 'quick'; + this.mergeStatus = 'idle'; + this.mergeResult = null; + this.mergeError = null; + this.archiveBytes = null; + this.mergeProgress = { step: '', percent: 0, steps: [] }; + this.goTo('import'); + } + + /** Refresh source of truth from storage. */ + async refreshSourceOfTruth() { + this.sourceOfTruth = await getSourceOfTruth(); } } diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index acdf3a0..8e42365 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -26,7 +26,7 @@ class="sticky top-0 z-50 flex items-center justify-between border-b px-6 py-3" style="background: var(--surface-0); border-color: var(--border);" > -