mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
Add WebDAV sync provider (manual + auto-sync, top-bar button)
This commit is contained in:
+11
@@ -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 {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user