From eabbaced80def8a1dc6d487febd2f26601a512de Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 18:53:32 +0000 Subject: [PATCH 01/14] feat: persistent library with source of truth and auto-merge Redesigns the app from a one-shot merge tool to a persistent library: - Home screen is now a Library showing all stored files - Merged files are marked with a star badge and cannot be deleted - Latest merged file is the "source of truth" - Adding a new backup auto-merges against the truth - Quick merge button for standalone merges without persistence - BackupMeta extended with isMerged, parentIds, isSourceOfTruth - IndexedDB migrated to v2 with field backfill - saveMergedBackup() saves result and promotes to truth - Export screen shows "Saved as source of truth" in library mode - 15+ new i18n keys in EN and FR --- .../src/lib/components/ExportScreen.svelte | 10 +- .../src/lib/components/ImportScreen.svelte | 7 + .../src/lib/components/LibraryScreen.svelte | 225 ++++++++++++++++++ apps/web/src/lib/merge.ts | 17 ++ apps/web/src/lib/storage.ts | 69 +++++- apps/web/src/lib/stores/app.svelte.ts | 55 ++++- apps/web/src/routes/+layout.svelte | 2 +- apps/web/src/routes/+page.svelte | 3 + packages/i18n/src/locales/en.json | 16 ++ packages/i18n/src/locales/fr.json | 16 ++ 10 files changed, 408 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/lib/components/LibraryScreen.svelte 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 @@ + +{#if !hasLibrary} + +
+
+ +
+

{t('library.empty.title')}

+

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

+ +
+{:else} +

{t('library.title')}

+ + + {#if appState.sourceOfTruth} + {@const truth = appState.sourceOfTruth} +
+
+ + + {t('library.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 >= 2} + + {/if} +
+ + + {#if originals.length > 0} +

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

+
+ {#each originals as meta} +
+ +
+
{meta.deviceName}
+
+ {meta.fileName} · {formatBytes(meta.sizeBytes)} · {formatDate(meta.date)} +
+
+ +
+ {/each} +
+ {/if} + + + {#if mergedFiles.length > 0} +

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

+
+ {#each mergedFiles as meta} +
+ +
+
{meta.deviceName}
+
+ {formatDate(meta.date)} · {formatBytes(meta.sizeBytes)} +
+
+ + {t('library.badge.merged')} + +
+ {/each} +
+ {/if} +{/if} diff --git a/apps/web/src/lib/merge.ts b/apps/web/src/lib/merge.ts index 31faad3..85ecf3a 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 = [ @@ -178,6 +179,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(), 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..96af2ad 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({ @@ -178,11 +184,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 +203,9 @@ class AppState { goToTab(tab: Tab) { this.tab = tab; + if (tab === 'home') { + this.screen = 'library'; + } } goTo(screen: Screen) { @@ -244,13 +255,17 @@ class AppState { } canMerge(): boolean { + if (this.mergeMode === 'library' && this.sourceOfTruth && this.backups.length >= 1) { + return true; + } return this.backups.length >= 2; } reset() { 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 +273,39 @@ 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.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);" > - - -{:else} -

{t('library.title')}

+ + + +

{t('library.title')}

{#if appState.sourceOfTruth} @@ -146,9 +160,14 @@ {#if originals.length >= 2} @@ -222,4 +241,3 @@ {/each} {/if} -{/if} diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 434082f..04b346c 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -12,9 +12,9 @@ {:else if appState.screen === 'explore'} -{:else if appState.screen === 'library'} +{:else if appState.screen === 'library' && appState.sourceOfTruth} -{:else if appState.screen === 'import'} +{:else if appState.screen === 'library' || appState.screen === 'import'} {:else if appState.screen === 'merge'} From a5935cbe03b34a751b8feaa7e3e716173f8036f2 Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 19:14:38 +0000 Subject: [PATCH 03/14] fix: hide recent files list in quick merge mode --- apps/web/src/lib/components/ImportScreen.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/components/ImportScreen.svelte b/apps/web/src/lib/components/ImportScreen.svelte index 45a899d..2693fb8 100644 --- a/apps/web/src/lib/components/ImportScreen.svelte +++ b/apps/web/src/lib/components/ImportScreen.svelte @@ -225,7 +225,7 @@ {/if} -{#if availableRecent.length > 0 && appState.backups.length === 0} +{#if availableRecent.length > 0 && appState.backups.length === 0 && appState.mergeMode !== 'quick'}

{t('import.recentFiles')} From 569df107df8f3e5a06c3d9efcb125a6d00820937 Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 20:21:04 +0000 Subject: [PATCH 04/14] fix: show stats badges on imported backup cards in library --- .../src/lib/components/LibraryScreen.svelte | 298 ++++++++++-------- 1 file changed, 159 insertions(+), 139 deletions(-) diff --git a/apps/web/src/lib/components/LibraryScreen.svelte b/apps/web/src/lib/components/LibraryScreen.svelte index 17afbb3..15fb9b5 100644 --- a/apps/web/src/lib/components/LibraryScreen.svelte +++ b/apps/web/src/lib/components/LibraryScreen.svelte @@ -32,8 +32,8 @@ notes: db.notes.length, highlights: db.userMarks.length, bookmarks: db.bookmarks.length, - tags: db.tags.length, - }, + tags: db.tags.length + } }; appState.addBackup(backup, bytes); await appState.startLibraryMerge(backup); @@ -47,18 +47,16 @@ return new Date(iso).toLocaleDateString(getLocale(), { day: 'numeric', month: 'long', - year: 'numeric', + year: 'numeric' }); } catch { return iso; } } - const originals = $derived( - appState.recentFiles.filter((m) => !m.isMerged), - ); + const originals = $derived(appState.recentFiles.filter((m) => !m.isMerged)); const mergedFiles = $derived( - appState.recentFiles.filter((m) => m.isMerged && !m.isSourceOfTruth), + appState.recentFiles.filter((m) => m.isMerged && !m.isSourceOfTruth) ); async function downloadTruth() { if (!appState.sourceOfTruth || loadingTruth) return; @@ -90,116 +88,115 @@

{t('library.title')}

- - {#if appState.sourceOfTruth} - {@const truth = appState.sourceOfTruth} -
-
- - - {t('library.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')} -

-
- - -
+ {stat.count} + {stat.label} + + {/each}
- {/if} - - -
- - {#if originals.length >= 2} +

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

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

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

-
- {#each originals as meta} -
- -
-
{meta.deviceName}
-
- {meta.fileName} · {formatBytes(meta.sizeBytes)} · {formatDate(meta.date)} -
-
+ +
+ + {#if originals.length >= 2} + + {/if} +
+ + +{#if originals.length > 0} +

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

+
+ {#each originals as meta} +
+
+ + {meta.deviceName} + {formatDate(meta.date)}
- {/each} -
- {/if} +
+ {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} - - {#if mergedFiles.length > 0} -

- {t('library.merged')} -

-
- {#each mergedFiles as meta} -
- -
-
{meta.deviceName}
-
- {formatDate(meta.date)} · {formatBytes(meta.sizeBytes)} -
+ +{#if mergedFiles.length > 0} +

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

+
+ {#each mergedFiles as meta} +
+ +
+
{meta.deviceName}
+
+ {formatDate(meta.date)} · {formatBytes(meta.sizeBytes)}
- - {t('library.badge.merged')} -
- {/each} -
- {/if} + + {t('library.badge.merged')} + +
+ {/each} +
+{/if} From c7703b8c7ad8d31475fd1623965b302ace8f3849 Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 20:22:03 +0000 Subject: [PATCH 05/14] fix: show stats badges on merged backup cards in library --- .../src/lib/components/LibraryScreen.svelte | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/web/src/lib/components/LibraryScreen.svelte b/apps/web/src/lib/components/LibraryScreen.svelte index 15fb9b5..1e2ba73 100644 --- a/apps/web/src/lib/components/LibraryScreen.svelte +++ b/apps/web/src/lib/components/LibraryScreen.svelte @@ -241,22 +241,39 @@
{#each mergedFiles as meta}
- -
-
{meta.deviceName}
-
- {formatDate(meta.date)} · {formatBytes(meta.sizeBytes)} -
+
+ + {meta.deviceName} + {formatDate(meta.date)} + + {t('library.badge.merged')} + +
+
+ {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}
- - {t('library.badge.merged')} -
{/each}
From a13d80f8ad90720325cfcc630a6e365c065a1110 Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 20:26:26 +0000 Subject: [PATCH 06/14] fix: always show quick merge button, remove unused imports --- .../src/lib/components/LibraryScreen.svelte | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/components/LibraryScreen.svelte b/apps/web/src/lib/components/LibraryScreen.svelte index 1e2ba73..8109bb0 100644 --- a/apps/web/src/lib/components/LibraryScreen.svelte +++ b/apps/web/src/lib/components/LibraryScreen.svelte @@ -4,8 +4,8 @@ import { appState, type ImportedBackup } from '$lib/stores/app.svelte'; import { t, getLocale } from '$lib/i18n.svelte'; import { formatBytes } from '$lib/format'; - import { loadBackupBytes, saveBackup } from '$lib/storage'; - import { Star, Plus, Merge, Download, Eye, X, Smartphone, Loader } from 'lucide-svelte'; + import { loadBackupBytes } from '$lib/storage'; + import { Plus, Merge, Download, Eye, X, Smartphone, Loader } from 'lucide-svelte'; let loadingTruth = $state(false); let addingFile = $state(false); @@ -167,8 +167,7 @@ {/if} {t('library.addBackup')} - {#if originals.length >= 2} - - {/if}
@@ -210,12 +208,7 @@ {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} + {#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}
- {#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} + {#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} Date: Sun, 22 Mar 2026 20:28:09 +0000 Subject: [PATCH 07/14] fix: reset merge state when starting quick merge to avoid stale UI --- apps/web/src/lib/stores/app.svelte.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/lib/stores/app.svelte.ts b/apps/web/src/lib/stores/app.svelte.ts index 96af2ad..c2c91a1 100644 --- a/apps/web/src/lib/stores/app.svelte.ts +++ b/apps/web/src/lib/stores/app.svelte.ts @@ -300,6 +300,11 @@ class AppState { 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'); } From 097c12601f8d2a67862692ba5cb63898798ac50e Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 20:54:24 +0000 Subject: [PATCH 08/14] fix: don't persist backups to storage in quick merge mode --- apps/web/src/lib/components/ImportScreen.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/components/ImportScreen.svelte b/apps/web/src/lib/components/ImportScreen.svelte index 2693fb8..7da330f 100644 --- a/apps/web/src/lib/components/ImportScreen.svelte +++ b/apps/web/src/lib/components/ImportScreen.svelte @@ -65,7 +65,8 @@ }, }; - appState.addBackup(backup, bytes); + // Only persist to storage in library mode + appState.addBackup(backup, appState.mergeMode === 'library' ? bytes : undefined); // In library mode with a source of truth, auto-merge the first new file if (appState.mergeMode === 'library' && appState.sourceOfTruth) { @@ -146,7 +147,7 @@ }, }; - appState.addBackup(backup, bytes); + appState.addBackup(backup, appState.mergeMode === 'library' ? bytes : undefined); } } catch (err) { error = err instanceof Error ? err.message : t('import.error.generic'); From 73c3f4e5cccc1cd5f9020f2ac39607967c4502ec Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 20:58:12 +0000 Subject: [PATCH 09/14] fix: remove merged backups section from library view --- .../src/lib/components/LibraryScreen.svelte | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/apps/web/src/lib/components/LibraryScreen.svelte b/apps/web/src/lib/components/LibraryScreen.svelte index 8109bb0..6cf8ea2 100644 --- a/apps/web/src/lib/components/LibraryScreen.svelte +++ b/apps/web/src/lib/components/LibraryScreen.svelte @@ -55,9 +55,6 @@ } const originals = $derived(appState.recentFiles.filter((m) => !m.isMerged)); - const mergedFiles = $derived( - appState.recentFiles.filter((m) => m.isMerged && !m.isSourceOfTruth) - ); async function downloadTruth() { if (!appState.sourceOfTruth || loadingTruth) return; loadingTruth = true; @@ -223,46 +220,3 @@
{/if} - -{#if mergedFiles.length > 0} -

- {t('library.merged')} -

-
- {#each mergedFiles as meta} -
-
- - {meta.deviceName} - {formatDate(meta.date)} - - {t('library.badge.merged')} - -
-
- {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} From 5b6cd57ea6e9ff472d9bc3cd3538072db6f8f8ca Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 20:59:52 +0000 Subject: [PATCH 10/14] fix: remove delete button from imported backups in library --- apps/web/src/lib/components/LibraryScreen.svelte | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/components/LibraryScreen.svelte b/apps/web/src/lib/components/LibraryScreen.svelte index 6cf8ea2..541ea4d 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, X, Smartphone, Loader } from 'lucide-svelte'; + import { Plus, Merge, Download, Eye, Smartphone, Loader } from 'lucide-svelte'; let loadingTruth = $state(false); let addingFile = $state(false); @@ -192,15 +192,7 @@ {meta.deviceName} {formatDate(meta.date)} - -
+
{meta.fileName} · {formatBytes(meta.sizeBytes)}
From 86a4ee5378e198a62902035034e56a38c19530da Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 21:13:03 +0000 Subject: [PATCH 11/14] feat: add cancel button on merge screen to abort the flow --- apps/web/src/lib/components/MergeScreen.svelte | 9 +++++++++ packages/i18n/src/locales/en.json | 1 + packages/i18n/src/locales/fr.json | 1 + 3 files changed, 11 insertions(+) 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/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index acc9a82..a72ccc5 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -194,5 +194,6 @@ "storage.clear": "Clear all stored backups", "storage.empty": "No stored backups", + "common.cancel": "Cancel", "common.error": "Error" } diff --git a/packages/i18n/src/locales/fr.json b/packages/i18n/src/locales/fr.json index 542c6a3..6df281f 100644 --- a/packages/i18n/src/locales/fr.json +++ b/packages/i18n/src/locales/fr.json @@ -194,5 +194,6 @@ "storage.clear": "Supprimer toutes les sauvegardes stockées", "storage.empty": "Aucune sauvegarde stockée", + "common.cancel": "Annuler", "common.error": "Erreur" } From 3e09405311e820e4a0deb6211f1575d31e8e5467 Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 21:16:48 +0000 Subject: [PATCH 12/14] fix: cancel merge removes pending backups from storage --- apps/web/src/lib/merge.ts | 4 ++++ apps/web/src/lib/stores/app.svelte.ts | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/apps/web/src/lib/merge.ts b/apps/web/src/lib/merge.ts index 85ecf3a..f002a42 100644 --- a/apps/web/src/lib/merge.ts +++ b/apps/web/src/lib/merge.ts @@ -163,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 @@ -205,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/stores/app.svelte.ts b/apps/web/src/lib/stores/app.svelte.ts index c2c91a1..8e1a7f8 100644 --- a/apps/web/src/lib/stores/app.svelte.ts +++ b/apps/web/src/lib/stores/app.svelte.ts @@ -99,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()); @@ -134,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, @@ -262,6 +265,14 @@ class AppState { } reset() { + // Clean up any pending backups that were saved during a cancelled merge + if (this.pendingBackupIds.length > 0) { + for (const id of this.pendingBackupIds) { + deleteBackup(id).catch(() => {}); + } + this.refreshStorage(); + } + this.pendingBackupIds = []; this.tab = 'home'; this.screen = 'library'; this.backups = []; From c1f7d3b3f2619ac050c7bc1a9e93e93e48b7f3a2 Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 21:21:56 +0000 Subject: [PATCH 13/14] fix: await pending backup deletion on cancel before refreshing storage --- apps/web/src/lib/stores/app.svelte.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/stores/app.svelte.ts b/apps/web/src/lib/stores/app.svelte.ts index 8e1a7f8..5a294b1 100644 --- a/apps/web/src/lib/stores/app.svelte.ts +++ b/apps/web/src/lib/stores/app.svelte.ts @@ -264,13 +264,13 @@ class AppState { 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) { - deleteBackup(id).catch(() => {}); + try { await deleteBackup(id); } catch { /* may not exist yet */ } } - this.refreshStorage(); + await this.refreshStorage(); } this.pendingBackupIds = []; this.tab = 'home'; From 1fc8bcb2ee8a39befdcf64aca9d0702a610d5c0c Mon Sep 17 00:00:00 2001 From: Dipanda Aser Date: Sun, 22 Mar 2026 21:26:49 +0000 Subject: [PATCH 14/14] docs: add mermaid diagrams explaining app flow to README --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) 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.