Add WebDAV sync provider (manual + auto-sync, top-bar button)

This commit is contained in:
Yuri Karamian
2026-06-07 23:14:04 +02:00
parent ccc5e3b7c6
commit aeafd55491
14 changed files with 1168 additions and 8 deletions
+11
View File
@@ -404,6 +404,17 @@ body.resizing {
user-select: none;
}
/* Spinner for the sync button while a sync is running. */
.sync-spin {
animation: sync-spin 1s linear infinite;
transform-origin: 50% 50%;
}
@keyframes sync-spin {
to {
transform: rotate(360deg);
}
}
/* ── Mobile (Android/iOS) ── */
@media (max-width: 768px) {
body {
+22
View File
@@ -327,6 +327,28 @@ export async function setBackupSettings(
});
}
// ── Sync (WebDAV) ──
export async function setSyncSettings(
provider: string | null,
url: string | null,
username: string | null,
password: string | null,
syncOnOpen: boolean,
syncOnChange: boolean,
syncIntervalMinutes: number,
): Promise<void> {
return invoke("set_sync_settings", { provider, url, username, password, syncOnOpen, syncOnChange, syncIntervalMinutes });
}
export async function testSyncConnection(): Promise<void> {
return invoke("test_sync_connection");
}
export async function syncNow(): Promise<void> {
return invoke("sync_now");
}
// ── Version History ──
export async function getNoteVersions(noteId: string): Promise<VersionEntry[]> {
+68 -2
View File
@@ -40,14 +40,15 @@
navHistory,
viewerNote,
notebookSortMode,
notebookOrder
notebookOrder,
syncState
} from '$lib/stores/app';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api';
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store';
@@ -63,6 +64,11 @@
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null;
let syncInterval: ReturnType<typeof setInterval> | null = null;
let unlistenSync: Array<() => void> = [];
let unsubDirty: (() => void) | null = null;
let onChangeSyncTimer: ReturnType<typeof setTimeout> | null = null;
let prevDirty = false;
@@ -92,6 +98,27 @@
}
}
function syncConfigured(): boolean {
return get(appConfig)?.sync_provider === 'webdav';
}
// Auto-sync on a timer (only when configured and an interval is set).
async function checkScheduledSync() {
const config = get(appConfig);
if (config?.sync_provider !== 'webdav') return;
const mins = config.sync_interval_minutes ?? 0;
if (!mins || get(syncState).running) return;
const last = config.last_sync_time ? new Date(config.last_sync_time).getTime() : 0;
if (Date.now() - last >= mins * 60 * 1000) {
try { await syncNow(); } catch (_) {}
}
}
export async function triggerSyncNow() {
if (!syncConfigured() || get(syncState).running) return;
try { await syncNow(); } catch (_) {}
}
// Track note navigation in history stack
$effect(() => {
const path = $activeNotePath;
@@ -486,12 +513,44 @@
// Scheduled backup: check on startup and every 5 minutes
checkScheduledBackup();
backupInterval = setInterval(checkScheduledBackup, 5 * 60 * 1000);
// ── WebDAV sync: global status + auto-sync triggers ──
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
unlistenSync.push(await listen('sync-done', (event: any) => {
syncState.set({ running: false, error: null });
const cur = get(appConfig);
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time });
}));
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
// Sync when the vault opens (if enabled)
if (syncConfigured() && get(appConfig)?.sync_on_open) {
try { await syncNow(); } catch (_) {}
}
// Auto-sync interval: check on startup and every minute
checkScheduledSync();
syncInterval = setInterval(checkScheduledSync, 60 * 1000);
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
unsubDirty = editorDirty.subscribe((d) => {
const config = get(appConfig);
if (prevDirty && !d && config?.sync_provider === 'webdav' && config?.sync_on_change) {
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
}
prevDirty = d;
});
});
onDestroy(() => {
unlistenFileChange?.();
unlistenOpenFile?.();
if (backupInterval) clearInterval(backupInterval);
if (syncInterval) clearInterval(syncInterval);
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
unsubDirty?.();
unlistenSync.forEach((u) => u());
});
</script>
@@ -592,6 +651,13 @@
</svg>
</button>
{/if}
{#if $appConfig?.sync_provider === 'webdav'}
<button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
</svg>
</button>
{/if}
<button class="mobile-header-btn" class:active={$sourceMode} onclick={() => ($sourceMode = !$sourceMode)} title={$sourceMode ? 'Rich Editor' : 'Source Mode'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
+15 -2
View File
@@ -38,8 +38,8 @@
import { readFile } from '@tauri-apps/plugin-fs';
import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd, writeBytesTo, copyPngToClipboard } from '$lib/api';
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward, viewerNote, notebooks } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward, viewerNote, notebooks, syncState } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote, syncNow } from '$lib/api';
import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types';
import { listen } from '@tauri-apps/api/event';
import { debounce } from '$lib/utils/debounce';
@@ -4287,6 +4287,19 @@
</svg>
</button>
{/if}
{#if $appConfig?.sync_provider === 'webdav'}
<button
class="icon-btn"
class:active={$syncState.running}
onclick={() => { if (!$syncState.running) syncNow().catch(() => {}); }}
disabled={$syncState.running}
title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
</svg>
</button>
{/if}
<button
class="icon-btn"
class:active={$sourceMode}
+191 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app';
@@ -10,7 +10,7 @@
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'updates';
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
let activeTab = $state<Tab>('styling');
// Updates state
@@ -300,6 +300,78 @@
}
}
// ── WebDAV sync ──
let syncProvider = $state<string | null>($appConfig?.sync_provider ?? null);
let syncUrl = $state($appConfig?.webdav_url ?? '');
let syncUsername = $state($appConfig?.webdav_username ?? '');
let syncPassword = $state($appConfig?.webdav_password ?? '');
let syncShowPassword = $state(false);
let syncTestLoading = $state(false);
let syncTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
let syncRunning = $state(false);
let syncMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
let syncOnOpen = $state($appConfig?.sync_on_open ?? false);
let syncOnChange = $state($appConfig?.sync_on_change ?? false);
let syncIntervalMinutes = $state($appConfig?.sync_interval_minutes ?? 0);
async function saveSyncSettings() {
await setSyncSettings(syncProvider, syncUrl || null, syncUsername || null, syncPassword || null, syncOnOpen, syncOnChange, syncIntervalMinutes);
}
async function handleTestSync() {
syncTestLoading = true;
syncTestMessage = null;
const unlisten = await listen<{ success: boolean; message?: string; error?: string }>('sync-test-result', (event) => {
const data = event.payload;
syncTestMessage = data.success
? { type: 'success', text: data.message ?? 'Connection successful' }
: { type: 'error', text: data.error ?? 'Connection failed' };
syncTestLoading = false;
unlisten();
});
try {
await saveSyncSettings();
await testSyncConnection();
} catch (e) {
syncTestMessage = { type: 'error', text: String(e) };
syncTestLoading = false;
unlisten();
}
}
async function handleSyncNow() {
syncRunning = true;
syncMessage = null;
const unlisteners: Array<() => void> = [];
const cleanup = () => { unlisteners.forEach((u) => u()); };
unlisteners.push(await listen<{ success: boolean; summary?: { uploaded?: number; downloaded?: number; deleted_local?: number; deleted_remote?: number; conflicts?: number }; last_sync_time?: string }>('sync-done', (event) => {
const s = event.payload.summary ?? {};
const parts: string[] = [];
if (s.uploaded) parts.push(`${s.uploaded} uploaded`);
if (s.downloaded) parts.push(`${s.downloaded} downloaded`);
const deleted = (s.deleted_local ?? 0) + (s.deleted_remote ?? 0);
if (deleted) parts.push(`${deleted} deleted`);
if (s.conflicts) parts.push(`${s.conflicts} conflict copies`);
syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' };
syncRunning = false;
if ($appConfig && event.payload.last_sync_time) $appConfig = { ...$appConfig, last_sync_time: event.payload.last_sync_time };
cleanup();
}));
unlisteners.push(await listen<{ error?: string }>('sync-error', (event) => {
syncMessage = { type: 'error', text: event.payload.error ?? 'Sync failed' };
syncRunning = false;
cleanup();
}));
try {
await saveSyncSettings();
await syncNow();
} catch (e) {
syncMessage = { type: 'error', text: String(e) };
syncRunning = false;
cleanup();
}
}
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
let isThemeDark = $derived(
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -638,6 +710,12 @@
</svg>
AI
</button>
<button class="tab-btn" class:active={activeTab === 'sync'} onclick={() => activeTab = 'sync'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
</svg>
Sync
</button>
<button class="tab-btn" class:active={activeTab === 'updates'} onclick={() => activeTab = 'updates'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 3"/>
@@ -1299,6 +1377,117 @@
{/if}
</div>
{:else if activeTab === 'sync'}
<div class="tab-content">
<div class="settings-section">
<h3>Provider</h3>
<div class="setting-options">
<button class="option-btn" class:active={!syncProvider} onclick={() => { syncProvider = null; syncMessage = null; syncTestMessage = null; saveSyncSettings(); }}>Disabled</button>
<button class="option-btn" class:active={syncProvider === 'webdav'} onclick={() => { syncProvider = 'webdav'; syncMessage = null; syncTestMessage = null; saveSyncSettings(); }}>WebDAV</button>
</div>
<p class="setting-hint">Sync your notes to your own WebDAV server (Nextcloud, ownCloud, a NAS). Your vault stays local on each device; the server is the shared hub.</p>
</div>
{#if syncProvider === 'webdav'}
<div class="settings-section">
<h3>Server URL</h3>
<input type="text" class="ai-key-input" placeholder="https://cloud.example.com/remote.php/dav/files/USER/HelixNotes" value={syncUrl} oninput={(e) => { syncUrl = (e.target as HTMLInputElement).value; }} onblur={saveSyncSettings} />
<p class="setting-hint">Full WebDAV URL of the folder to sync into. On Nextcloud: <code>https://your-server/remote.php/dav/files/USERNAME/FolderName</code></p>
</div>
<div class="settings-section">
<h3>Username</h3>
<input type="text" class="ai-key-input" placeholder="your username" value={syncUsername} oninput={(e) => { syncUsername = (e.target as HTMLInputElement).value; }} onblur={saveSyncSettings} />
</div>
<div class="settings-section">
<h3>Password</h3>
<div class="ai-key-row">
<input type={syncShowPassword ? 'text' : 'password'} class="ai-key-input" placeholder="app password" value={syncPassword} oninput={(e) => { syncPassword = (e.target as HTMLInputElement).value; }} onblur={saveSyncSettings} />
<button class="ai-key-toggle" onclick={() => syncShowPassword = !syncShowPassword} title={syncShowPassword ? 'Hide' : 'Show'}>
{#if syncShowPassword}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
<p class="setting-hint">Use an app password / token, not your main account password. Stored locally on this device (like AI keys).</p>
</div>
<div class="settings-section">
<h3>Connection</h3>
<button class="import-btn" onclick={handleTestSync} disabled={syncTestLoading || !syncUrl}>
{#if syncTestLoading}
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
Testing...
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
Test Connection
{/if}
</button>
{#if syncTestMessage}
<div class="import-result {syncTestMessage.type}">
{#if syncTestMessage.type === 'success'}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
{/if}
<span>{syncTestMessage.text}</span>
</div>
{/if}
</div>
<div class="settings-section">
<h3>Sync</h3>
<button class="import-btn" onclick={handleSyncNow} disabled={syncRunning || !syncUrl}>
{#if syncRunning}
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
Syncing...
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/></svg>
Sync Now
{/if}
</button>
{#if syncMessage}
<div class="import-result {syncMessage.type}">
{#if syncMessage.type === 'success'}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
{/if}
<span>{syncMessage.text}</span>
</div>
{/if}
{#if $appConfig?.last_sync_time}
<p class="setting-hint">Last sync: {new Date($appConfig.last_sync_time).toLocaleString()}</p>
{/if}
<p class="setting-hint">Manual sync. If the same note was edited on two devices, the second version is kept as a "(conflict ...)" copy so nothing is lost.</p>
</div>
<div class="settings-section">
<h3>Automatic Sync</h3>
<div class="setting-options">
{#each [{ v: 0, l: 'Off' }, { v: 5, l: '5 min' }, { v: 15, l: '15 min' }, { v: 30, l: '30 min' }, { v: 60, l: '60 min' }] as opt}
<button class="option-btn" class:active={syncIntervalMinutes === opt.v} onclick={() => { syncIntervalMinutes = opt.v; saveSyncSettings(); }}>{opt.l}</button>
{/each}
</div>
<p class="setting-hint">Sync automatically on this interval.</p>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Sync when a note changes</span>
<span class="setting-desc">Sync a few seconds after you edit a note.</span>
</span>
<button class="toggle-switch" class:on={syncOnChange} onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
</label>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Sync when the vault opens</span>
<span class="setting-desc">Pull the latest changes on startup.</span>
</span>
<button class="toggle-switch" class:on={syncOnOpen} onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
</label>
</div>
{/if}
</div>
{:else if activeTab === 'updates'}
<div class="tab-content">
<div class="settings-section">
+7
View File
@@ -67,6 +67,13 @@ export const readOnly = writable(false);
// Theme
export const theme = writable<string>("system");
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
// whoever triggered it (manual button, settings, interval, on-change).
export const syncState = writable<{ running: boolean; error: string | null }>({
running: false,
error: null,
});
// Update state
export const updateAvailable = writable<{
version: string;
+8
View File
@@ -82,6 +82,14 @@ export interface AppConfig {
default_view_mode: boolean;
show_tray_icon: boolean;
enable_wiki_links: boolean;
sync_provider: string | null;
webdav_url: string | null;
webdav_username: string | null;
webdav_password: string | null;
sync_on_open: boolean;
sync_on_change: boolean;
sync_interval_minutes: number;
last_sync_time: string | null;
}
export interface VaultState {