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:
Generated
+20
-2
@@ -1861,6 +1861,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"notify",
|
"notify",
|
||||||
"png 0.17.16",
|
"png 0.17.16",
|
||||||
|
"quick-xml 0.36.2",
|
||||||
"rayon",
|
"rayon",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -1868,6 +1869,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
"sha2",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"tantivy",
|
"tantivy",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -1880,6 +1882,7 @@ dependencies = [
|
|||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
@@ -3418,7 +3421,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"quick-xml",
|
"quick-xml 0.38.4",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -3612,6 +3615,15 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.36.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -5911,6 +5923,12 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -6222,7 +6240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml",
|
"quick-xml 0.38.4",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
|
quick-xml = "0.36"
|
||||||
|
urlencoding = "2"
|
||||||
|
sha2 = "0.10"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|||||||
@@ -1742,6 +1742,128 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sync (WebDAV) ──
|
||||||
|
|
||||||
|
fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, String> {
|
||||||
|
if config.sync_provider.as_deref() != Some("webdav") {
|
||||||
|
return Err("Sync is not configured".to_string());
|
||||||
|
}
|
||||||
|
let url = config
|
||||||
|
.webdav_url
|
||||||
|
.clone()
|
||||||
|
.filter(|u| !u.trim().is_empty())
|
||||||
|
.ok_or("WebDAV URL is not set")?;
|
||||||
|
Ok(crate::sync::WebdavConfig {
|
||||||
|
url,
|
||||||
|
username: config.webdav_username.clone().unwrap_or_default(),
|
||||||
|
password: config.webdav_password.clone().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_sync_settings(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
provider: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
sync_on_open: bool,
|
||||||
|
sync_on_change: bool,
|
||||||
|
sync_interval_minutes: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
config.sync_provider = provider.filter(|p| !p.is_empty());
|
||||||
|
config.webdav_url = url.filter(|u| !u.trim().is_empty());
|
||||||
|
config.webdav_username = username.filter(|u| !u.is_empty());
|
||||||
|
config.webdav_password = password.filter(|p| !p.is_empty());
|
||||||
|
config.sync_on_open = sync_on_open;
|
||||||
|
config.sync_on_change = sync_on_change;
|
||||||
|
config.sync_interval_minutes = sync_interval_minutes;
|
||||||
|
save_app_config(&config)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn test_sync_connection(app: AppHandle) -> Result<(), String> {
|
||||||
|
let cfg = {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
sync_config_from(&config)?
|
||||||
|
};
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use tauri::Emitter;
|
||||||
|
match crate::sync::test_connection(cfg) {
|
||||||
|
Ok(msg) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"sync-test-result",
|
||||||
|
serde_json::json!({ "success": true, "message": msg }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"sync-test-result",
|
||||||
|
serde_json::json!({ "success": false, "error": e }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
// Guard against overlapping syncs (manual button + interval + on-change can collide).
|
||||||
|
if app.state::<AppState>().syncing.swap(true, Ordering::SeqCst) {
|
||||||
|
return Ok(()); // a sync is already running
|
||||||
|
}
|
||||||
|
let (vault, cfg) = {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let config = match state.config.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
state.syncing.store(false, Ordering::SeqCst);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let gathered = config
|
||||||
|
.active_vault
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "No active vault".to_string())
|
||||||
|
.and_then(|v| sync_config_from(&config).map(|c| (v, c)));
|
||||||
|
match gathered {
|
||||||
|
Ok(vc) => vc,
|
||||||
|
Err(e) => {
|
||||||
|
drop(config);
|
||||||
|
state.syncing.store(false, Ordering::SeqCst);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use tauri::Emitter;
|
||||||
|
let result = crate::sync::run_sync(app.clone(), vault, cfg);
|
||||||
|
app.state::<AppState>().syncing.store(false, Ordering::SeqCst);
|
||||||
|
match result {
|
||||||
|
Ok(summary) => {
|
||||||
|
let ts = chrono::Utc::now().to_rfc3339();
|
||||||
|
if let Ok(mut config) = app.state::<AppState>().config.lock() {
|
||||||
|
config.last_sync_time = Some(ts.clone());
|
||||||
|
let _ = save_app_config(&config);
|
||||||
|
}
|
||||||
|
let _ = app.emit(
|
||||||
|
"sync-done",
|
||||||
|
serde_json::json!({ "success": true, "summary": summary, "last_sync_time": ts }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = app.emit("sync-error", serde_json::json!({ "success": false, "error": e }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn ai_ask(
|
pub fn ai_ask(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod commands;
|
|||||||
mod history;
|
mod history;
|
||||||
mod search;
|
mod search;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod sync;
|
||||||
mod types;
|
mod types;
|
||||||
mod vault;
|
mod vault;
|
||||||
|
|
||||||
@@ -159,6 +160,9 @@ pub fn run() {
|
|||||||
commands::set_ai_settings,
|
commands::set_ai_settings,
|
||||||
commands::test_ai_connection,
|
commands::test_ai_connection,
|
||||||
commands::ai_ask,
|
commands::ai_ask,
|
||||||
|
commands::set_sync_settings,
|
||||||
|
commands::test_sync_connection,
|
||||||
|
commands::sync_now,
|
||||||
commands::get_install_type,
|
commands::get_install_type,
|
||||||
commands::get_pending_open_file,
|
commands::get_pending_open_file,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub struct AppState {
|
|||||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||||
pub importing: AtomicBool,
|
pub importing: AtomicBool,
|
||||||
|
pub syncing: AtomicBool,
|
||||||
pub pending_open_file: Mutex<Option<String>>,
|
pub pending_open_file: Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ impl AppState {
|
|||||||
search_index: Mutex::new(None),
|
search_index: Mutex::new(None),
|
||||||
watcher: Mutex::new(None),
|
watcher: Mutex::new(None),
|
||||||
importing: AtomicBool::new(false),
|
importing: AtomicBool::new(false),
|
||||||
|
syncing: AtomicBool::new(false),
|
||||||
pending_open_file: Mutex::new(None),
|
pending_open_file: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,670 @@
|
|||||||
|
// WebDAV sync provider.
|
||||||
|
//
|
||||||
|
// Model: the vault stays local; this mirrors it to/from a user-configured WebDAV
|
||||||
|
// remote (Nextcloud/ownCloud/NAS). A local manifest (.helixnotes/sync_state.json)
|
||||||
|
// records what was in sync last time, so we can do a three-way diff (local vs remote
|
||||||
|
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||||
|
// conflict copies so nothing is ever lost.
|
||||||
|
//
|
||||||
|
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
||||||
|
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
||||||
|
// manifest itself) is local-only and never synced.
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::vault::operations::helixnotes_dir;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
// ───────────────────────── config ─────────────────────────
|
||||||
|
|
||||||
|
pub struct WebdavConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Serialize)]
|
||||||
|
pub struct SyncSummary {
|
||||||
|
pub uploaded: u32,
|
||||||
|
pub downloaded: u32,
|
||||||
|
pub deleted_local: u32,
|
||||||
|
pub deleted_remote: u32,
|
||||||
|
pub conflicts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── manifest ─────────────────────────
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
struct SyncManifest {
|
||||||
|
files: HashMap<String, ManifestEntry>, // relpath (forward-slash) -> entry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
struct ManifestEntry {
|
||||||
|
local_hash: String, // sha256 of local content at last sync
|
||||||
|
remote_etag: String, // remote etag at last sync
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_path(vault: &str) -> PathBuf {
|
||||||
|
helixnotes_dir(vault).join("sync_state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_manifest(vault: &str) -> SyncManifest {
|
||||||
|
fs::read_to_string(manifest_path(vault))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_manifest(vault: &str, m: &SyncManifest) {
|
||||||
|
if let Ok(s) = serde_json::to_string(m) {
|
||||||
|
let _ = fs::write(manifest_path(vault), s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── helpers ─────────────────────────
|
||||||
|
|
||||||
|
fn sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update(bytes);
|
||||||
|
let digest = h.finalize();
|
||||||
|
let mut s = String::with_capacity(64);
|
||||||
|
for b in digest {
|
||||||
|
s.push_str(&format!("{:02x}", b));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_etag(s: &str) -> String {
|
||||||
|
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent-encode each path segment, keeping the `/` separators.
|
||||||
|
fn encode_path(relpath: &str) -> String {
|
||||||
|
relpath
|
||||||
|
.split('/')
|
||||||
|
.map(|seg| urlencoding::encode(seg).into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the conflict-copy relpath: `dir/Note (conflict <stamp>).md`.
|
||||||
|
fn conflict_relpath(relpath: &str, stamp: &str) -> String {
|
||||||
|
let (dir, name) = match relpath.rfind('/') {
|
||||||
|
Some(i) => (&relpath[..=i], &relpath[i + 1..]),
|
||||||
|
None => ("", relpath),
|
||||||
|
};
|
||||||
|
let (stem, ext) = match name.rfind('.') {
|
||||||
|
Some(i) => (&name[..i], &name[i..]),
|
||||||
|
None => (name, ""),
|
||||||
|
};
|
||||||
|
format!("{}{} (conflict {}){}", dir, stem, stamp, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_local(vault: &Path, relpath: &str, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
let target = vault.join(relpath);
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
fs::write(&target, bytes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocalFile {
|
||||||
|
hash: String,
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
||||||
|
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
||||||
|
/// folder with unrelated files never drags them into the vault.
|
||||||
|
fn is_synced_relpath(rel: &str) -> bool {
|
||||||
|
if rel.starts_with(".helixnotes/") {
|
||||||
|
rel.starts_with(".helixnotes/attachments/")
|
||||||
|
} else {
|
||||||
|
rel.ends_with(".md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_local(vault: &str) -> Result<HashMap<String, LocalFile>, String> {
|
||||||
|
let vault_path = Path::new(vault);
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for entry in WalkDir::new(vault_path).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let p = entry.path();
|
||||||
|
if !p.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rel = match p.strip_prefix(vault_path) {
|
||||||
|
Ok(r) => r.to_string_lossy().replace('\\', "/"),
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !is_synced_relpath(&rel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bytes = match fs::read(p) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
map.insert(
|
||||||
|
rel,
|
||||||
|
LocalFile {
|
||||||
|
hash: sha256_hex(&bytes),
|
||||||
|
path: p.to_path_buf(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── WebDAV client ─────────────────────────
|
||||||
|
|
||||||
|
struct RemoteEntry {
|
||||||
|
relpath: String,
|
||||||
|
etag: String,
|
||||||
|
is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WebdavClient {
|
||||||
|
base: String, // normalized, no trailing slash
|
||||||
|
base_path: String, // path component of base, no trailing slash (percent-encoded)
|
||||||
|
client: reqwest::blocking::Client,
|
||||||
|
user: String,
|
||||||
|
pass: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebdavClient {
|
||||||
|
fn new(cfg: WebdavConfig) -> Result<Self, String> {
|
||||||
|
let base = cfg.url.trim().trim_end_matches('/').to_string();
|
||||||
|
if !base.starts_with("http://") && !base.starts_with("https://") {
|
||||||
|
return Err("WebDAV URL must start with http:// or https://".to_string());
|
||||||
|
}
|
||||||
|
let base_url = reqwest::Url::parse(&base).map_err(|e| format!("Invalid URL: {e}"))?;
|
||||||
|
let base_path = base_url.path().trim_end_matches('/').to_string();
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(Self {
|
||||||
|
base,
|
||||||
|
base_path,
|
||||||
|
client,
|
||||||
|
user: cfg.username,
|
||||||
|
pass: cfg.password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_for(&self, relpath: &str) -> String {
|
||||||
|
if relpath.is_empty() {
|
||||||
|
self.base.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", self.base, encode_path(relpath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn propfind(&self, relpath: &str, depth: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||||
|
let method = reqwest::Method::from_bytes(b"PROPFIND").unwrap();
|
||||||
|
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.request(method, self.url_for(relpath))
|
||||||
|
.basic_auth(&self.user, Some(&self.pass))
|
||||||
|
.header("Depth", depth)
|
||||||
|
.header("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let status = resp.status();
|
||||||
|
if status.as_u16() == 401 {
|
||||||
|
return Err("Authentication failed (check username / app password)".to_string());
|
||||||
|
}
|
||||||
|
if status.as_u16() == 404 {
|
||||||
|
return Err("WebDAV path not found (check the URL)".to_string());
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("WebDAV PROPFIND failed: HTTP {}", status.as_u16()));
|
||||||
|
}
|
||||||
|
let text = resp.text().map_err(|e| e.to_string())?;
|
||||||
|
parse_multistatus(&text, &self.base_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively list all files (relpath -> etag) by walking collections at Depth 1
|
||||||
|
/// (Depth: infinity is disabled on some servers, so we walk instead).
|
||||||
|
fn list(&self) -> Result<HashMap<String, String>, String> {
|
||||||
|
let mut files = HashMap::new();
|
||||||
|
let mut visited: HashSet<String> = HashSet::new();
|
||||||
|
let mut queue: Vec<String> = vec![String::new()];
|
||||||
|
while let Some(dir) = queue.pop() {
|
||||||
|
if !visited.insert(dir.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for e in self.propfind(&dir, "1")? {
|
||||||
|
if e.relpath == dir {
|
||||||
|
continue; // the collection itself
|
||||||
|
}
|
||||||
|
if e.is_dir {
|
||||||
|
if !visited.contains(&e.relpath) {
|
||||||
|
queue.push(e.relpath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
files.insert(e.relpath, e.etag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, relpath: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(self.url_for(relpath))
|
||||||
|
.basic_auth(&self.user, Some(&self.pass))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(&self, relpath: &str, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.put(self.url_for(relpath))
|
||||||
|
.basic_auth(&self.user, Some(&self.pass))
|
||||||
|
.body(bytes.to_vec())
|
||||||
|
.send()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
Ok(resp
|
||||||
|
.headers()
|
||||||
|
.get("etag")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.map(normalize_etag))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, relpath: &str) -> Result<(), String> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.delete(self.url_for(relpath))
|
||||||
|
.basic_auth(&self.user, Some(&self.pass))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let s = resp.status();
|
||||||
|
if s.is_success() || s.as_u16() == 404 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("DELETE {} failed: HTTP {}", relpath, s.as_u16()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mkcol(&self, relpath: &str) -> Result<(), String> {
|
||||||
|
let method = reqwest::Method::from_bytes(b"MKCOL").unwrap();
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.request(method, self.url_for(relpath))
|
||||||
|
.basic_auth(&self.user, Some(&self.pass))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let s = resp.status().as_u16();
|
||||||
|
// 201 created; 405 already exists; 301 redirect-to-existing.
|
||||||
|
if resp.status().is_success() || s == 405 || s == 301 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure every ancestor collection of `relpath` exists on the remote.
|
||||||
|
fn ensure_dirs(&self, relpath: &str, created: &mut HashSet<String>) -> Result<(), String> {
|
||||||
|
let parts: Vec<&str> = relpath.split('/').collect();
|
||||||
|
if parts.len() <= 1 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut cur = String::new();
|
||||||
|
for part in &parts[..parts.len() - 1] {
|
||||||
|
if !cur.is_empty() {
|
||||||
|
cur.push('/');
|
||||||
|
}
|
||||||
|
cur.push_str(part);
|
||||||
|
if created.insert(cur.clone()) {
|
||||||
|
self.mkcol(&cur)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_with_dirs(
|
||||||
|
&self,
|
||||||
|
relpath: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
created: &mut HashSet<String>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
self.ensure_dirs(relpath, created)?;
|
||||||
|
self.put(relpath, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a WebDAV `href` to a vault-relative path (forward slash). Returns None for
|
||||||
|
/// the base collection itself.
|
||||||
|
fn href_to_relpath(href: &str, base_path: &str) -> Option<String> {
|
||||||
|
let path = if href.starts_with("http://") || href.starts_with("https://") {
|
||||||
|
reqwest::Url::parse(href).ok()?.path().to_string()
|
||||||
|
} else {
|
||||||
|
href.to_string()
|
||||||
|
};
|
||||||
|
let path = path.trim_end_matches('/');
|
||||||
|
let rest = path.strip_prefix(base_path)?.trim_start_matches('/');
|
||||||
|
if rest.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(urlencoding::decode(rest).ok()?.into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
use quick_xml::reader::Reader;
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum Cap {
|
||||||
|
None,
|
||||||
|
Href,
|
||||||
|
Etag,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reader = Reader::from_str(xml);
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut href: Option<String> = None;
|
||||||
|
let mut etag: Option<String> = None;
|
||||||
|
let mut is_dir = false;
|
||||||
|
let mut cap = Cap::None;
|
||||||
|
let mut buf = String::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match reader.read_event() {
|
||||||
|
Ok(Event::Start(e)) => match e.local_name().as_ref() {
|
||||||
|
b"response" => {
|
||||||
|
href = None;
|
||||||
|
etag = None;
|
||||||
|
is_dir = false;
|
||||||
|
}
|
||||||
|
b"href" => {
|
||||||
|
cap = Cap::Href;
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
b"getetag" => {
|
||||||
|
cap = Cap::Etag;
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
b"collection" => is_dir = true,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::Empty(e)) => {
|
||||||
|
if e.local_name().as_ref() == b"collection" {
|
||||||
|
is_dir = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Text(e)) => {
|
||||||
|
if cap != Cap::None {
|
||||||
|
if let Ok(t) = e.unescape() {
|
||||||
|
buf.push_str(&t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::End(e)) => match e.local_name().as_ref() {
|
||||||
|
b"href" => {
|
||||||
|
href = Some(buf.clone());
|
||||||
|
cap = Cap::None;
|
||||||
|
}
|
||||||
|
b"getetag" => {
|
||||||
|
etag = Some(buf.clone());
|
||||||
|
cap = Cap::None;
|
||||||
|
}
|
||||||
|
b"response" => {
|
||||||
|
if let Some(h) = href.take() {
|
||||||
|
if let Some(rel) = href_to_relpath(&h, base_path) {
|
||||||
|
entries.push(RemoteEntry {
|
||||||
|
relpath: rel,
|
||||||
|
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
|
||||||
|
is_dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::Eof) => break,
|
||||||
|
Err(e) => return Err(format!("XML parse error: {e}")),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── sync engine ─────────────────────────
|
||||||
|
|
||||||
|
fn apply_changes(
|
||||||
|
client: &WebdavClient,
|
||||||
|
vault: &str,
|
||||||
|
local: &HashMap<String, LocalFile>,
|
||||||
|
remote: &HashMap<String, String>,
|
||||||
|
manifest: &SyncManifest,
|
||||||
|
) -> Result<(SyncManifest, SyncSummary), String> {
|
||||||
|
let vault_path = Path::new(vault);
|
||||||
|
let mut new_m = SyncManifest::default();
|
||||||
|
let mut sum = SyncSummary::default();
|
||||||
|
let mut created: HashSet<String> = HashSet::new();
|
||||||
|
let stamp = chrono::Local::now().format("%Y-%m-%d %H-%M-%S").to_string();
|
||||||
|
|
||||||
|
let mut keys: HashSet<&String> = HashSet::new();
|
||||||
|
keys.extend(local.keys());
|
||||||
|
keys.extend(remote.keys());
|
||||||
|
keys.extend(manifest.files.keys());
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
let l = local.get(key);
|
||||||
|
let r = remote.get(key);
|
||||||
|
let m = manifest.files.get(key);
|
||||||
|
|
||||||
|
match (l, r) {
|
||||||
|
(Some(lf), Some(re)) => {
|
||||||
|
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
||||||
|
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
||||||
|
if !local_changed && !remote_changed {
|
||||||
|
new_m.files.insert(
|
||||||
|
key.clone(),
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: lf.hash.clone(),
|
||||||
|
remote_etag: re.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if local_changed && !remote_changed {
|
||||||
|
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
|
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
||||||
|
new_m.files.insert(
|
||||||
|
key.clone(),
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: lf.hash.clone(),
|
||||||
|
remote_etag: etag.unwrap_or_else(|| re.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sum.uploaded += 1;
|
||||||
|
} else if !local_changed && remote_changed {
|
||||||
|
let bytes = client.get(key)?;
|
||||||
|
write_local(vault_path, key, &bytes)?;
|
||||||
|
new_m.files.insert(
|
||||||
|
key.clone(),
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: sha256_hex(&bytes),
|
||||||
|
remote_etag: re.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sum.downloaded += 1;
|
||||||
|
} else {
|
||||||
|
// both changed: compare contents; identical => no real conflict.
|
||||||
|
let rbytes = client.get(key)?;
|
||||||
|
let rhash = sha256_hex(&rbytes);
|
||||||
|
if rhash == lf.hash {
|
||||||
|
new_m.files.insert(
|
||||||
|
key.clone(),
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: lf.hash.clone(),
|
||||||
|
remote_etag: re.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// keep-both: remote becomes canonical; local saved as a conflict copy.
|
||||||
|
let lbytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
|
write_local(vault_path, key, &rbytes)?;
|
||||||
|
new_m.files.insert(
|
||||||
|
key.clone(),
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: rhash,
|
||||||
|
remote_etag: re.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let ckey = conflict_relpath(key, &stamp);
|
||||||
|
write_local(vault_path, &ckey, &lbytes)?;
|
||||||
|
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
|
||||||
|
new_m.files.insert(
|
||||||
|
ckey,
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: sha256_hex(&lbytes),
|
||||||
|
remote_etag: cetag.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sum.conflicts += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(lf), None) => match m {
|
||||||
|
None => {
|
||||||
|
// new local -> upload
|
||||||
|
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
|
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
||||||
|
new_m.files.insert(
|
||||||
|
key.clone(),
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: lf.hash.clone(),
|
||||||
|
remote_etag: etag.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sum.uploaded += 1;
|
||||||
|
}
|
||||||
|
Some(me) => {
|
||||||
|
if me.local_hash != lf.hash {
|
||||||
|
// remote deleted but local edited -> resurrect (upload)
|
||||||
|
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||||
|
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
|
||||||
|
new_m.files.insert(
|
||||||
|
key.clone(),
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: lf.hash.clone(),
|
||||||
|
remote_etag: etag.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sum.uploaded += 1;
|
||||||
|
} else {
|
||||||
|
// remote deleted, local unchanged -> delete local
|
||||||
|
let _ = fs::remove_file(&lf.path);
|
||||||
|
sum.deleted_local += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(None, Some(re)) => match m {
|
||||||
|
None => {
|
||||||
|
// new remote -> download
|
||||||
|
let bytes = client.get(key)?;
|
||||||
|
write_local(vault_path, key, &bytes)?;
|
||||||
|
new_m.files.insert(
|
||||||
|
key.clone(),
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: sha256_hex(&bytes),
|
||||||
|
remote_etag: re.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sum.downloaded += 1;
|
||||||
|
}
|
||||||
|
Some(me) => {
|
||||||
|
if &me.remote_etag != re {
|
||||||
|
// local deleted but remote changed -> resurrect (download)
|
||||||
|
let bytes = client.get(key)?;
|
||||||
|
write_local(vault_path, key, &bytes)?;
|
||||||
|
new_m.files.insert(
|
||||||
|
key.clone(),
|
||||||
|
ManifestEntry {
|
||||||
|
local_hash: sha256_hex(&bytes),
|
||||||
|
remote_etag: re.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sum.downloaded += 1;
|
||||||
|
} else {
|
||||||
|
// local deleted, remote unchanged -> delete remote
|
||||||
|
client.delete(key)?;
|
||||||
|
sum.deleted_remote += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
// gone on both sides -> drop from manifest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((new_m, sum))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the connection (auth + URL) with a cheap PROPFIND.
|
||||||
|
pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
||||||
|
let client = WebdavClient::new(cfg)?;
|
||||||
|
client.propfind("", "0")?;
|
||||||
|
Ok("Connection successful".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||||
|
/// rebuilds the search index. Returns a summary of what changed.
|
||||||
|
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let client = WebdavClient::new(cfg)?;
|
||||||
|
|
||||||
|
let _ = app.emit("sync-progress", serde_json::json!({ "status": "listing" }));
|
||||||
|
let remote: HashMap<String, String> = client
|
||||||
|
.list()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(k, _)| is_synced_relpath(k))
|
||||||
|
.collect();
|
||||||
|
let local = scan_local(&vault)?;
|
||||||
|
let manifest = load_manifest(&vault);
|
||||||
|
|
||||||
|
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
|
||||||
|
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
|
||||||
|
state.importing.store(true, Ordering::Relaxed);
|
||||||
|
let applied = apply_changes(&client, &vault, &local, &remote, &manifest);
|
||||||
|
state.importing.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let (new_manifest, summary) = applied?;
|
||||||
|
save_manifest(&vault, &new_manifest);
|
||||||
|
|
||||||
|
// Re-index after files changed (search is non-critical; ignore failures).
|
||||||
|
if let Ok(guard) = state.search_index.lock() {
|
||||||
|
if let Some(search) = guard.as_ref() {
|
||||||
|
let _ = search.rebuild(&vault);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The watcher was muted during apply, so nudge the UI to refresh its lists.
|
||||||
|
let _ = app.emit(
|
||||||
|
"file-changed",
|
||||||
|
crate::types::FileEvent {
|
||||||
|
event_type: "modify".to_string(),
|
||||||
|
path: vault.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
@@ -129,6 +129,23 @@ pub struct AppConfig {
|
|||||||
pub close_to_tray: bool,
|
pub close_to_tray: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enable_wiki_links: bool,
|
pub enable_wiki_links: bool,
|
||||||
|
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_provider: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_username: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_on_open: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_on_change: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_interval_minutes: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_sync_time: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -199,6 +216,14 @@ impl Default for AppConfig {
|
|||||||
show_tray_icon: false,
|
show_tray_icon: false,
|
||||||
close_to_tray: false,
|
close_to_tray: false,
|
||||||
enable_wiki_links: true,
|
enable_wiki_links: true,
|
||||||
|
sync_provider: None,
|
||||||
|
webdav_url: None,
|
||||||
|
webdav_username: None,
|
||||||
|
webdav_password: None,
|
||||||
|
sync_on_open: false,
|
||||||
|
sync_on_change: false,
|
||||||
|
sync_interval_minutes: 0,
|
||||||
|
last_sync_time: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -404,6 +404,17 @@ body.resizing {
|
|||||||
user-select: none;
|
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) ── */
|
/* ── Mobile (Android/iOS) ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body {
|
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 ──
|
// ── Version History ──
|
||||||
|
|
||||||
export async function getNoteVersions(noteId: string): Promise<VersionEntry[]> {
|
export async function getNoteVersions(noteId: string): Promise<VersionEntry[]> {
|
||||||
|
|||||||
@@ -40,14 +40,15 @@
|
|||||||
navHistory,
|
navHistory,
|
||||||
viewerNote,
|
viewerNote,
|
||||||
notebookSortMode,
|
notebookSortMode,
|
||||||
notebookOrder
|
notebookOrder,
|
||||||
|
syncState
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||||
const isAndroid = /android/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 { debounce } from '$lib/utils/debounce';
|
||||||
import { openNoteWindow } from '$lib/utils/window';
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@@ -63,6 +64,11 @@
|
|||||||
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
|
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
|
||||||
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
|
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
|
||||||
let backupInterval: ReturnType<typeof setInterval> | null = null;
|
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
|
// Track note navigation in history stack
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const path = $activeNotePath;
|
const path = $activeNotePath;
|
||||||
@@ -486,12 +513,44 @@
|
|||||||
// Scheduled backup: check on startup and every 5 minutes
|
// Scheduled backup: check on startup and every 5 minutes
|
||||||
checkScheduledBackup();
|
checkScheduledBackup();
|
||||||
backupInterval = setInterval(checkScheduledBackup, 5 * 60 * 1000);
|
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(() => {
|
onDestroy(() => {
|
||||||
unlistenFileChange?.();
|
unlistenFileChange?.();
|
||||||
unlistenOpenFile?.();
|
unlistenOpenFile?.();
|
||||||
if (backupInterval) clearInterval(backupInterval);
|
if (backupInterval) clearInterval(backupInterval);
|
||||||
|
if (syncInterval) clearInterval(syncInterval);
|
||||||
|
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
|
||||||
|
unsubDirty?.();
|
||||||
|
unlistenSync.forEach((u) => u());
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -592,6 +651,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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'}>
|
<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">
|
<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" />
|
<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 { readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd, writeBytesTo, copyPngToClipboard } from '$lib/api';
|
import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd, writeBytesTo, copyPngToClipboard } from '$lib/api';
|
||||||
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
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 { 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 } from '$lib/api';
|
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 type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
@@ -4287,6 +4287,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
class:active={$sourceMode}
|
class:active={$sourceMode}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode } from '$lib/stores/app';
|
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 { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
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');
|
let activeTab = $state<Tab>('styling');
|
||||||
|
|
||||||
// Updates state
|
// 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'];
|
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
|
||||||
let isThemeDark = $derived(
|
let isThemeDark = $derived(
|
||||||
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
@@ -638,6 +710,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
AI
|
AI
|
||||||
</button>
|
</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'}>
|
<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">
|
<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"/>
|
<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}
|
{/if}
|
||||||
</div>
|
</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'}
|
{:else if activeTab === 'updates'}
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ export const readOnly = writable(false);
|
|||||||
// Theme
|
// Theme
|
||||||
export const theme = writable<string>("system");
|
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
|
// Update state
|
||||||
export const updateAvailable = writable<{
|
export const updateAvailable = writable<{
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ export interface AppConfig {
|
|||||||
default_view_mode: boolean;
|
default_view_mode: boolean;
|
||||||
show_tray_icon: boolean;
|
show_tray_icon: boolean;
|
||||||
enable_wiki_links: 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 {
|
export interface VaultState {
|
||||||
|
|||||||
Reference in New Issue
Block a user