diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4e71bbc..f1122b7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1861,6 +1861,7 @@ dependencies = [ "log", "notify", "png 0.17.16", + "quick-xml 0.36.2", "rayon", "regex", "reqwest 0.12.28", @@ -1868,6 +1869,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "sys-locale", "tantivy", "tauri", @@ -1880,6 +1882,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tokio", + "urlencoding", "uuid", "walkdir", "zip 2.4.2", @@ -3418,7 +3421,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3612,6 +3615,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "quick-xml" version = "0.38.4" @@ -5911,6 +5923,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -6222,7 +6240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.38.4", "quote", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 58ce254..0b83aed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } futures = "0.3" rayon = "1" +quick-xml = "0.36" +urlencoding = "2" +sha2 = "0.10" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3a39494..7d6811d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1742,6 +1742,128 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> { Ok(()) } +// ── Sync (WebDAV) ── + +fn sync_config_from(config: &AppConfig) -> Result { + 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, + url: Option, + username: Option, + password: Option, + 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::(); + 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::().syncing.swap(true, Ordering::SeqCst) { + return Ok(()); // a sync is already running + } + let (vault, cfg) = { + let state = app.state::(); + 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::().syncing.store(false, Ordering::SeqCst); + match result { + Ok(summary) => { + let ts = chrono::Utc::now().to_rfc3339(); + if let Ok(mut config) = app.state::().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] pub fn ai_ask( app: AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3634df4..3dfa52e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod commands; mod history; mod search; mod state; +mod sync; mod types; mod vault; @@ -159,6 +160,9 @@ pub fn run() { commands::set_ai_settings, commands::test_ai_connection, commands::ai_ask, + commands::set_sync_settings, + commands::test_sync_connection, + commands::sync_now, commands::get_install_type, commands::get_pending_open_file, ]) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index a58c798..203853c 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -10,6 +10,7 @@ pub struct AppState { pub search_index: Mutex>>, pub watcher: Mutex>, pub importing: AtomicBool, + pub syncing: AtomicBool, pub pending_open_file: Mutex>, } @@ -20,6 +21,7 @@ impl AppState { search_index: Mutex::new(None), watcher: Mutex::new(None), importing: AtomicBool::new(false), + syncing: AtomicBool::new(false), pending_open_file: Mutex::new(None), } } diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs new file mode 100644 index 0000000..c0a0880 --- /dev/null +++ b/src-tauri/src/sync.rs @@ -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, // 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::>() + .join("/") +} + +/// Build the conflict-copy relpath: `dir/Note (conflict ).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, 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 { + 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, String> { + let method = reqwest::Method::from_bytes(b"PROPFIND").unwrap(); + let body = r#""#; + 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, String> { + let mut files = HashMap::new(); + let mut visited: HashSet = HashSet::new(); + let mut queue: Vec = 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, 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, 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) -> 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, + ) -> Result, 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 { + 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, 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 = None; + let mut etag: Option = 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, + remote: &HashMap, + 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 = 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 { + 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 { + use std::sync::atomic::Ordering; + + let state = app.state::(); + let client = WebdavClient::new(cfg)?; + + let _ = app.emit("sync-progress", serde_json::json!({ "status": "listing" })); + let remote: HashMap = 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) +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index ec01fa5..a56e69e 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -129,6 +129,23 @@ pub struct AppConfig { pub close_to_tray: bool, #[serde(default = "default_true")] pub enable_wiki_links: bool, + // WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured. + #[serde(default)] + pub sync_provider: Option, + #[serde(default)] + pub webdav_url: Option, + #[serde(default)] + pub webdav_username: Option, + #[serde(default)] + pub webdav_password: Option, + #[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, } fn default_true() -> bool { @@ -199,6 +216,14 @@ impl Default for AppConfig { show_tray_icon: false, close_to_tray: false, 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, } } } diff --git a/src/app.css b/src/app.css index 4ae9590..a941a00 100644 --- a/src/app.css +++ b/src/app.css @@ -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 { diff --git a/src/lib/api.ts b/src/lib/api.ts index 9ced10e..69430f1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { + return invoke("set_sync_settings", { provider, url, username, password, syncOnOpen, syncOnChange, syncIntervalMinutes }); +} + +export async function testSyncConnection(): Promise { + return invoke("test_sync_connection"); +} + +export async function syncNow(): Promise { + return invoke("sync_now"); +} + // ── Version History ── export async function getNoteVersions(noteId: string): Promise { diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index a2005af..2868c63 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -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 | null = null; + let syncInterval: ReturnType | null = null; + let unlistenSync: Array<() => void> = []; + let unsubDirty: (() => void) | null = null; + let onChangeSyncTimer: ReturnType | 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()); }); @@ -592,6 +651,13 @@ {/if} + {#if $appConfig?.sync_provider === 'webdav'} + + {/if} {/if} + {#if $appConfig?.sync_provider === 'webdav'} + + {/if} + + + +

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.

+ + + {#if syncProvider === 'webdav'} +
+

Server URL

+ { syncUrl = (e.target as HTMLInputElement).value; }} onblur={saveSyncSettings} /> +

Full WebDAV URL of the folder to sync into. On Nextcloud: https://your-server/remote.php/dav/files/USERNAME/FolderName

+
+
+

Username

+ { syncUsername = (e.target as HTMLInputElement).value; }} onblur={saveSyncSettings} /> +
+
+

Password

+
+ { syncPassword = (e.target as HTMLInputElement).value; }} onblur={saveSyncSettings} /> + +
+

Use an app password / token, not your main account password. Stored locally on this device (like AI keys).

+
+ +
+

Connection

+ + {#if syncTestMessage} +
+ {#if syncTestMessage.type === 'success'} + + {:else} + + {/if} + {syncTestMessage.text} +
+ {/if} +
+ +
+

Sync

+ + {#if syncMessage} +
+ {#if syncMessage.type === 'success'} + + {:else} + + {/if} + {syncMessage.text} +
+ {/if} + {#if $appConfig?.last_sync_time} +

Last sync: {new Date($appConfig.last_sync_time).toLocaleString()}

+ {/if} +

Manual sync. If the same note was edited on two devices, the second version is kept as a "(conflict ...)" copy so nothing is lost.

+
+
+

Automatic Sync

+
+ {#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} + + {/each} +
+

Sync automatically on this interval.

+ + +
+ {/if} + + {:else if activeTab === 'updates'}
diff --git a/src/lib/stores/app.ts b/src/lib/stores/app.ts index d2d872a..222c206 100644 --- a/src/lib/stores/app.ts +++ b/src/lib/stores/app.ts @@ -67,6 +67,13 @@ export const readOnly = writable(false); // Theme export const theme = writable("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; diff --git a/src/lib/types.ts b/src/lib/types.ts index a7772f7..42412e3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 {