mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
WebDAV sync: match remote paths encoding-agnostically so Infomaniak kDrive (and other providers that vary percent-encoding case) sync instead of wiping local notes (#102)
This commit is contained in:
+50
-10
@@ -350,20 +350,46 @@ impl WebdavClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a WebDAV `href` to a vault-relative path (forward slash). Returns None for
|
/// Map a WebDAV `href` to a vault-relative path (forward slash).
|
||||||
/// the base collection itself.
|
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
|
||||||
fn href_to_relpath(href: &str, base_path: &str) -> Option<String> {
|
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
|
||||||
let path = if href.starts_with("http://") || href.starts_with("https://") {
|
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
|
||||||
reqwest::Url::parse(href).ok()?.path().to_string()
|
/// unmappable href makes that file look deleted on the remote, and the reconciler then
|
||||||
|
/// wipes the local copy. (issue #102)
|
||||||
|
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
|
||||||
|
// Pull out just the path component (some servers return absolute-URL hrefs).
|
||||||
|
let raw = if href.starts_with("http://") || href.starts_with("https://") {
|
||||||
|
reqwest::Url::parse(href)
|
||||||
|
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
|
||||||
|
.path()
|
||||||
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
href.to_string()
|
href.to_string()
|
||||||
};
|
};
|
||||||
let path = path.trim_end_matches('/');
|
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
|
||||||
let rest = path.strip_prefix(base_path)?.trim_start_matches('/');
|
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
|
||||||
|
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
|
||||||
|
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
|
||||||
|
// both sides first makes the prefix match encoding-agnostic. (issue #102)
|
||||||
|
let path = urlencoding::decode(raw.trim_end_matches('/'))
|
||||||
|
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
|
||||||
|
.into_owned();
|
||||||
|
let base = urlencoding::decode(base_path.trim_end_matches('/'))
|
||||||
|
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
|
||||||
|
.into_owned();
|
||||||
|
let rest = match path.strip_prefix(&base) {
|
||||||
|
Some(r) => r.trim_start_matches('/'),
|
||||||
|
None => {
|
||||||
|
return Err(format!(
|
||||||
|
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
|
||||||
|
Aborting before deleting anything locally - please report this URL."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
if rest.is_empty() {
|
if rest.is_empty() {
|
||||||
return None;
|
return Ok(None); // the base collection itself
|
||||||
}
|
}
|
||||||
Some(urlencoding::decode(rest).ok()?.into_owned())
|
Ok(Some(rest.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
|
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||||
@@ -427,7 +453,7 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
|||||||
}
|
}
|
||||||
b"response" => {
|
b"response" => {
|
||||||
if let Some(h) = href.take() {
|
if let Some(h) = href.take() {
|
||||||
if let Some(rel) = href_to_relpath(&h, base_path) {
|
if let Some(rel) = href_to_relpath(&h, base_path)? {
|
||||||
entries.push(RemoteEntry {
|
entries.push(RemoteEntry {
|
||||||
relpath: rel,
|
relpath: rel,
|
||||||
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
|
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
|
||||||
@@ -642,6 +668,20 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
|
|||||||
let local = scan_local(&vault)?;
|
let local = scan_local(&vault)?;
|
||||||
let manifest = load_manifest(&vault);
|
let manifest = load_manifest(&vault);
|
||||||
|
|
||||||
|
// Safety guard (issue #102): never treat an empty remote listing as "everything was
|
||||||
|
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
|
||||||
|
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
|
||||||
|
// map), not a real mass-deletion - so abort instead of wiping local notes.
|
||||||
|
if remote.is_empty() && !manifest.files.is_empty() {
|
||||||
|
return Err(
|
||||||
|
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
|
||||||
|
even though files were synced before, so no local files were deleted. This usually \
|
||||||
|
means the server reports paths in a format the app can't read yet - please report \
|
||||||
|
your WebDAV provider and folder URL."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
|
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.
|
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
|
||||||
state.importing.store(true, Ordering::Relaxed);
|
state.importing.store(true, Ordering::Relaxed);
|
||||||
|
|||||||
Reference in New Issue
Block a user