Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/lib/components/ExportScreen.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { appState } from '$lib/stores/app.svelte';
import { downloadArchive } from '$lib/merge';
import { Check, Smartphone, Download, Eye } from 'lucide-svelte';
import { Check, Smartphone, Download, Eye, Star } from 'lucide-svelte';
import { t, getLocale } from '$lib/i18n.svelte';

const result = $derived(appState.mergeResult);
Expand Down Expand Up @@ -39,6 +39,12 @@
<p class="text-base" style="color: var(--text-secondary);">
{t('export.subtitleFull', { count: appState.backups.length })}
</p>
{#if appState.mergeMode === 'library'}
<div class="mt-3 flex items-center justify-center gap-1.5 text-sm font-medium" style="color: var(--accent);">
<Star size={14} />
{t('export.savedAsTruth')}
</div>
{/if}
</div>

<!-- Stats grid -->
Expand Down Expand Up @@ -100,7 +106,7 @@
style="color: var(--text-tertiary);"
onclick={startOver}
>
{t('export.newMergeFull')}
{appState.mergeMode === 'library' ? t('export.backToLibrary') : t('export.newMergeFull')}
</button>
</div>
</div>
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/lib/components/ImportScreen.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,15 @@
},
};

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) {
loading = false;
await appState.startLibraryMerge(backup);
return;
}
}
} catch (err) {
error = err instanceof Error ? err.message : t('import.error.generic');
Expand Down Expand Up @@ -139,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');
Expand Down Expand Up @@ -218,7 +226,7 @@
{/if}

<!-- Recent files -->
{#if availableRecent.length > 0 && appState.backups.length === 0}
{#if availableRecent.length > 0 && appState.backups.length === 0 && appState.mergeMode !== 'quick'}
<div class="mb-8">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider" style="color: var(--text-tertiary);">
{t('import.recentFiles')}
Expand Down
214 changes: 214 additions & 0 deletions apps/web/src/lib/components/LibraryScreen.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<script lang="ts">
import { parseJWLibrary } from '@jw-notes-sync/core';
import { webAdapter } from '$lib/adapter';
import { appState, type ImportedBackup } from '$lib/stores/app.svelte';
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';

let loadingTruth = $state(false);
let addingFile = $state(false);
let fileInput: HTMLInputElement;

async function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file || !file.name.endsWith('.jwlibrary')) return;

addingFile = true;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const archive = await parseJWLibrary(webAdapter, bytes);
const db = archive.database;
const backup: ImportedBackup = {
id: crypto.randomUUID(),
fileName: file.name,
deviceName: archive.manifest.userDataBackup.deviceName,
date: archive.manifest.creationDate,
archive,
stats: {
notes: db.notes.length,
highlights: db.userMarks.length,
bookmarks: db.bookmarks.length,
tags: db.tags.length
}
};
appState.addBackup(backup, bytes);
await appState.startLibraryMerge(backup);
} finally {
addingFile = false;
}
}

function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(getLocale(), {
day: 'numeric',
month: 'long',
year: 'numeric'
});
} catch {
return iso;
}
}

const originals = $derived(appState.recentFiles.filter((m) => !m.isMerged));
async function downloadTruth() {
if (!appState.sourceOfTruth || loadingTruth) return;
loadingTruth = true;
try {
const bytes = await loadBackupBytes(appState.sourceOfTruth.id);
if (!bytes) return;
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = appState.sourceOfTruth.fileName;
link.click();
URL.revokeObjectURL(url);
} finally {
loadingTruth = false;
}
}
</script>

<!-- Hidden file input for adding backups -->
<input
type="file"
accept=".jwlibrary"
class="hidden"
bind:this={fileInput}
onchange={onFileSelected}
/>

<h1 class="mb-6 text-2xl font-bold">{t('library.title')}</h1>

<!-- Source of truth -->
{#if appState.sourceOfTruth}
{@const truth = appState.sourceOfTruth}
<div
class="mb-8 rounded-xl border-2 p-5"
style="background: var(--surface-1); border-color: var(--accent);"
>
<div class="mb-1 flex items-center gap-2">
<h3 class="flex-1 truncate text-lg font-bold">{truth.deviceName}</h3>
<span class="text-xs" style="color: var(--text-tertiary);">{formatDate(truth.date)}</span>
</div>
<div class="mb-3 text-xs" style="color: var(--text-tertiary);">
{truth.fileName} · {formatBytes(truth.sizeBytes)}
</div>
<div class="mb-4 flex flex-wrap gap-2">
{#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}
<span
class="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium"
style="background: color-mix(in srgb, {stat.color} 15%, transparent); color: {stat.color};"
>
<span class="font-bold">{stat.count}</span>
{stat.label}
</span>
{/each}
</div>
<p class="mb-4 text-xs" style="color: var(--text-tertiary);">
{t('library.sourceOfTruth.desc')}
</p>
<div class="flex gap-2">
<button
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition-all"
style="background: var(--accent); color: var(--accent-text);"
onclick={downloadTruth}
disabled={loadingTruth}
>
{#if loadingTruth}
<Loader size={14} class="animate-spin" />
{:else}
<Download size={14} />
{/if}
{t('library.download')}
</button>
<button
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition-all"
style="background: var(--surface-2); color: var(--text-secondary);"
onclick={() =>
appState
.restoreBackup(truth)
.then(
(ok) =>
ok &&
appState.openExplorer(
appState.backups[appState.backups.length - 1]!.archive.database,
truth.deviceName
)
)}
>
<Eye size={14} />
{t('explorer.explore')}
</button>
</div>
</div>
{/if}

<!-- Actions -->
<div class="mb-8 flex flex-wrap gap-3">
<button
class="flex items-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold transition-all hover:-translate-y-0.5"
style="background: var(--accent); color: var(--accent-text);"
onclick={() => fileInput.click()}
disabled={addingFile}
>
{#if addingFile}
<Loader size={18} class="animate-spin" />
{:else}
<Plus size={18} />
{/if}
{t('library.addBackup')}
</button>
<button
class="flex items-center gap-2 rounded-xl border px-6 py-3 text-sm font-semibold transition-all"
style="background: var(--surface-1); border-color: var(--border); color: var(--text-secondary);"
onclick={() => appState.startQuickMerge()}
>
<Merge size={18} />
{t('library.quickMerge')}
</button>
</div>

<!-- Original files -->
{#if originals.length > 0}
<h2
class="mb-3 text-sm font-semibold uppercase tracking-wider"
style="color: var(--text-tertiary);"
>
{t('library.originals')}
</h2>
<div class="mb-8 flex flex-col gap-2">
{#each originals as meta}
<div
class="rounded-xl border p-4"
style="background: var(--surface-1); border-color: var(--border);"
>
<div class="mb-1 flex items-center gap-2">
<Smartphone size={16} style="color: var(--text-tertiary);" />
<span class="min-w-0 flex-1 truncate text-sm font-semibold">{meta.deviceName}</span>
<span class="text-xs" style="color: var(--text-tertiary);">{formatDate(meta.date)}</span>
</div>
<div class="mb-3 text-xs" style="color: var(--text-tertiary);">
{meta.fileName} · {formatBytes(meta.sizeBytes)}
</div>
<div class="flex flex-wrap gap-2">
{#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}
<span
class="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium"
style="background: color-mix(in srgb, {stat.color} 15%, transparent); color: {stat.color};"
>
<span class="font-bold">{stat.count}</span>
{stat.label}
</span>
{/each}
</div>
</div>
{/each}
</div>
{/if}

9 changes: 9 additions & 0 deletions apps/web/src/lib/components/MergeScreen.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,15 @@
>
{appState.dryRun ? t('config.dryRun') : t('merge.button')}
</button>
<div class="mt-3">
<button
class="text-sm font-medium transition-all"
style="color: var(--text-tertiary);"
onclick={() => appState.reset()}
>
{t('common.cancel')}
</button>
</div>
</div>
{:else if appState.mergeStatus === 'merging'}
<!-- Progress -->
Expand Down
Loading