The destination for a Nextcloud export was a text field. Nobody recalls the exact spelling of a path three levels down, and getting it wrong does not fail — `create_dir` makes whatever was typed, so a misremembered folder becomes a new one at the root and the exports are somewhere nobody looks. So it is picked the way the library root is picked, using the same `FolderBrowser` model the launch screen drives: up, into, and "use this folder", confirming the folder currently *shown* rather than one selected in the list. Same rule in both places, so the phrase means one thing. The model is shared; the worker is not. `settings_ui::spawn_folder_list` is a near-twin of the launch screen's, because that one reaches into the `LaunchController` for its session and reports onto the launch screen's error line, while this one is handed credentials and writes to the settings page. Factoring them together needs a function taking both controllers or a trait implemented twice to abstract two call sites — more machinery than the twenty lines it saves. What matters is shared already: navigation behaves identically because both drive the same model. The callbacks are wired in `lib.rs` rather than in `settings_ui::wire`, because listing a remote folder needs credentials and the settings page holds no session on purpose — it is reachable before a library is opened and must not depend on one existing. With no account the picker says to sign in first, rather than showing an empty list that reads as a server with no folders. Details that are decisions rather than accidents: the picker opens at the library root rather than at whatever half-typed path is in the field, which would list nothing and look broken. The listing area is a fixed 180px, since a folder with sixty children would otherwise push the rest of the settings page off the bottom. "Up" is disabled at the root rather than hidden, so the row does not jump as the user navigates. A failed listing leaves the picker open on the folder it was showing — where the user had got to is not something to discard over a dropped request. And the chosen folder saves immediately like every other setting on a page that has no Save button. The poll timer lives on the controller for the reason `LaunchController` keeps its own there: a `slint::Timer` stops when dropped, so one local to the function that starts it would be collected before the listing arrived. Carries in-flight work from a parallel session — a segmentation pass in dr-gpu, a sidecar cache, and the develop panel's continuing changes. 1020 tests pass, fmt clean. One clippy warning remains and is not mine: `sidecar_cache::dir` is unused while that work is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
421 lines
16 KiB
Rust
421 lines
16 KiB
Rust
//! TRACES: FR-CAT-9 | FR-NC-10 | FR-CAT-8
|
|
//! A local copy of every sidecar this device has seen, and the outbox of the
|
|
//! ones the server has not.
|
|
//!
|
|
//! # Why a cache at all
|
|
//!
|
|
//! FR-CAT-9: *"edits queue and apply when the source returns."* Before this,
|
|
//! a rating or a pasted edit made with no connection was dropped — the
|
|
//! judgement survived in the catalog, which is disposable (ARCH §6.12), and a
|
|
//! pasted edit survived nowhere at all. An edit that vanishes because the
|
|
//! train went into a tunnel is the worst failure an authoritative store can
|
|
//! have, and it is silent.
|
|
//!
|
|
//! So the local file is the **commit point** and the upload is best-effort.
|
|
//! Writing is always a local write first; the network attempt follows and, if
|
|
//! it fails or was never possible, the entry stays marked and is retried when
|
|
//! the server comes back. Online and offline are therefore the same code path
|
|
//! differing only in whether the upload is attempted, rather than two paths
|
|
//! where one quietly does less.
|
|
//!
|
|
//! # Why the filesystem is the queue
|
|
//!
|
|
//! The catalog has a `jobs` table with backoff and coalescing, and
|
|
//! `JobKind::WriteSidecar` was reserved for this. It is deliberately not used:
|
|
//! the catalog is rebuildable and may be deleted at any time, and a queue of
|
|
//! *unuploaded edits* is the one thing in this system that cannot be
|
|
//! reconstructed from anywhere else. A pending marker sitting beside the
|
|
//! document it refers to survives a catalog deletion, an app reinstall that
|
|
//! keeps app data, and a crash — because there is no separate index that could
|
|
//! disagree with it.
|
|
//!
|
|
//! It is also debuggable in the way the sidecar format itself is meant to be:
|
|
//! the cache mirrors the server's layout, so the file holding an edit that
|
|
//! failed to upload is at the path you would guess, and a `.pending` marker
|
|
//! beside it says why it is still there.
|
|
//!
|
|
//! # Why there is no budget
|
|
//!
|
|
//! Unlike cached originals (FR-NC-6a), sidecars are hundreds of bytes. A
|
|
//! hundred-thousand-image library is tens of megabytes, and evicting one would
|
|
//! cost a round-trip to re-read an edit the user is about to open. The
|
|
//! originals cache exists because RAW files are 30 MB each; this does not have
|
|
//! the problem that motivates one.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use dr_pipeline::Sidecar;
|
|
|
|
/// Suffix marking a cached sidecar the server has not yet accepted.
|
|
///
|
|
/// A separate zero-byte file rather than a flag inside the document: the
|
|
/// document's bytes are what gets uploaded, and a marker inside it would have
|
|
/// to be stripped on the way out — one more chance to upload something subtly
|
|
/// different from what was stored.
|
|
const PENDING_SUFFIX: &str = ".pending";
|
|
|
|
/// The on-disk sidecar cache for one library.
|
|
pub struct SidecarCache {
|
|
dir: PathBuf,
|
|
}
|
|
|
|
impl SidecarCache {
|
|
/// Open the cache rooted at `dir`. Nothing is created until a write.
|
|
pub fn open(dir: PathBuf) -> Self {
|
|
Self { dir }
|
|
}
|
|
|
|
/// Where a remote sidecar is cached.
|
|
///
|
|
/// Mirrors the server's layout, so `photos/2024/a.drsc` is cached at
|
|
/// `<dir>/photos/2024/a.drsc`. Keyed on the **sidecar's** remote path
|
|
/// rather than the image's, because that is what an upload addresses —
|
|
/// deriving one from the other in two places is how they come to disagree.
|
|
///
|
|
/// Returns `None` for a path that would escape the cache directory. The
|
|
/// path comes from a server response, so it is not this process's to
|
|
/// trust: a `..` component would let a hostile or merely broken server
|
|
/// name a file anywhere this app can write.
|
|
pub fn path_for(&self, remote_sidecar_path: &str) -> Option<PathBuf> {
|
|
// No directory means no library is open. Every path would otherwise
|
|
// be relative and land in the process's working directory, which for a
|
|
// desktop launch is wherever the user happened to be standing.
|
|
if self.dir.as_os_str().is_empty() {
|
|
return None;
|
|
}
|
|
let mut out = self.dir.clone();
|
|
let mut depth = 0usize;
|
|
for part in remote_sidecar_path.split('/') {
|
|
match part {
|
|
// Empty from a leading or doubled slash, and `.`, both mean
|
|
// "here" and are simply skipped.
|
|
"" | "." => continue,
|
|
".." => return None,
|
|
// A Windows-style drive or a backslash cannot appear in a
|
|
// WebDAV path segment and would not mean what it looks like.
|
|
p if p.contains('\\') => return None,
|
|
p => {
|
|
out.push(p);
|
|
depth += 1;
|
|
}
|
|
}
|
|
}
|
|
(depth > 0).then_some(out)
|
|
}
|
|
|
|
/// The cached document, if there is one.
|
|
///
|
|
/// An unreadable cache entry answers `None` rather than an error: the
|
|
/// caller's fallback is to treat the photograph as unedited, and a cache
|
|
/// is by definition reconstructible from the server.
|
|
pub fn load(&self, remote_sidecar_path: &str) -> Option<Sidecar> {
|
|
let path = self.path_for(remote_sidecar_path)?;
|
|
let text = std::fs::read_to_string(&path).ok()?;
|
|
match Sidecar::parse(&text) {
|
|
Ok(s) => Some(s),
|
|
Err(e) => {
|
|
log::warn!("cached sidecar at {} is unreadable ({e})", path.display());
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Write a document into the cache.
|
|
///
|
|
/// `pending` records whether the server has this content. Passing `false`
|
|
/// after a successful upload is what takes an entry out of the outbox, so
|
|
/// the marker is *removed* here rather than only in a separate call — one
|
|
/// function owns the pair, and they cannot drift apart.
|
|
pub fn store(
|
|
&self,
|
|
remote_sidecar_path: &str,
|
|
sidecar: &Sidecar,
|
|
pending: bool,
|
|
) -> Result<(), String> {
|
|
let path = self
|
|
.path_for(remote_sidecar_path)
|
|
.ok_or_else(|| format!("unsafe sidecar path {remote_sidecar_path}"))?;
|
|
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
// Write and rename, so an interrupted save cannot truncate an edit
|
|
// that was already safely on disk — the same discipline the settings
|
|
// store and the local sidecar writer use.
|
|
let tmp = path.with_extension("drsc.tmp");
|
|
std::fs::write(&tmp, sidecar.to_text()).map_err(|e| e.to_string())?;
|
|
std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
|
|
|
|
// The marker is written *after* the document. The other order would
|
|
// leave a window where a crash produced a marker pointing at content
|
|
// that was never stored, and the drain would upload a stale document
|
|
// believing it to be the queued one.
|
|
let marker = marker_for(&path);
|
|
if pending {
|
|
std::fs::write(&marker, b"").map_err(|e| e.to_string())?;
|
|
} else if marker.exists() {
|
|
std::fs::remove_file(&marker).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether this entry is waiting to be uploaded.
|
|
pub fn is_pending(&self, remote_sidecar_path: &str) -> bool {
|
|
self.path_for(remote_sidecar_path)
|
|
.is_some_and(|p| marker_for(&p).exists())
|
|
}
|
|
|
|
/// Every sidecar waiting to be uploaded, as remote paths.
|
|
///
|
|
/// Walks the tree rather than consulting an index, which is the property
|
|
/// that makes the queue survive a catalog deletion: the markers *are* the
|
|
/// queue, so there is nothing to fall out of step with them.
|
|
///
|
|
/// Sorted, so a drain that is interrupted resumes in the same order rather
|
|
/// than retrying whichever entry the directory happened to yield first.
|
|
pub fn pending(&self) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
collect_pending(&self.dir, &self.dir, &mut out);
|
|
out.sort();
|
|
out
|
|
}
|
|
}
|
|
|
|
/// The marker path for a cached document.
|
|
fn marker_for(path: &Path) -> PathBuf {
|
|
let mut s = path.as_os_str().to_os_string();
|
|
s.push(PENDING_SUFFIX);
|
|
PathBuf::from(s)
|
|
}
|
|
|
|
/// Recursively gather remote paths whose marker exists.
|
|
///
|
|
/// A missing or unreadable directory contributes nothing rather than failing
|
|
/// the walk: a cache that has never been written has no directory at all, and
|
|
/// that is the normal state on a fresh install.
|
|
fn collect_pending(root: &Path, dir: &Path, out: &mut Vec<String>) {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
collect_pending(root, &path, out);
|
|
continue;
|
|
}
|
|
// The marker names the document; the document names the remote path.
|
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
continue;
|
|
};
|
|
let Some(stem) = name.strip_suffix(PENDING_SUFFIX) else {
|
|
continue;
|
|
};
|
|
let document = path.with_file_name(stem);
|
|
// A marker whose document has gone is not a queued upload; it is
|
|
// debris. Skipped rather than reported, since there is nothing to send
|
|
// and nothing the user could do about it.
|
|
if !document.exists() {
|
|
continue;
|
|
}
|
|
if let Ok(rel) = document.strip_prefix(root) {
|
|
// Back to a remote path: the cache mirrors the server's layout, so
|
|
// the relative path *is* the remote path.
|
|
let remote: Vec<&str> = rel.iter().filter_map(|c| c.to_str()).collect();
|
|
if !remote.is_empty() {
|
|
out.push(remote.join("/"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use dr_pipeline::sidecar::Version;
|
|
|
|
fn tempdir(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"dr-sidecar-cache-{name}-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
/// A cache and the directory it is rooted at.
|
|
fn cache(name: &str) -> (SidecarCache, PathBuf) {
|
|
let root = tempdir(name).join("sidecars");
|
|
(SidecarCache::open(root.clone()), root)
|
|
}
|
|
|
|
fn rated(stars: u8) -> Sidecar {
|
|
let mut s = Sidecar::new();
|
|
s.put(Version {
|
|
uuid: "u1".into(),
|
|
name: "Default".into(),
|
|
is_default: true,
|
|
revision: 1,
|
|
rating: stars,
|
|
..Default::default()
|
|
});
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn the_cache_mirrors_the_servers_layout() {
|
|
// What makes an entry findable by hand when an upload has gone wrong,
|
|
// and what lets `pending` recover a remote path with no index.
|
|
let (c, _d) = cache("layout");
|
|
let p = c.path_for("photos/2024/a.drsc").unwrap();
|
|
assert!(
|
|
p.ends_with("sidecars/photos/2024/a.drsc"),
|
|
"{}",
|
|
p.display()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_document_survives_a_store_and_a_load() {
|
|
let (c, _d) = cache("round-trip");
|
|
c.store("photos/a.drsc", &rated(4), true).unwrap();
|
|
|
|
let back = c.load("photos/a.drsc").expect("cached");
|
|
assert_eq!(back.default_version().unwrap().rating, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn an_absent_entry_loads_as_nothing() {
|
|
let (c, _d) = cache("absent");
|
|
assert!(c.load("photos/never-seen.drsc").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_pending_write_appears_in_the_outbox() {
|
|
// The whole point: an edit made offline is queued rather than dropped.
|
|
let (c, _d) = cache("outbox");
|
|
c.store("photos/a.drsc", &rated(3), true).unwrap();
|
|
|
|
assert!(c.is_pending("photos/a.drsc"));
|
|
assert_eq!(c.pending(), vec!["photos/a.drsc".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stored_upload_leaves_the_outbox() {
|
|
let (c, _d) = cache("drained");
|
|
c.store("photos/a.drsc", &rated(3), true).unwrap();
|
|
c.store("photos/a.drsc", &rated(3), false).unwrap();
|
|
|
|
assert!(!c.is_pending("photos/a.drsc"));
|
|
assert!(c.pending().is_empty());
|
|
// And the content is still cached, so an offline open still shows the
|
|
// edit. Clearing the marker must not clear the document.
|
|
assert_eq!(
|
|
c.load("photos/a.drsc")
|
|
.unwrap()
|
|
.default_version()
|
|
.unwrap()
|
|
.rating,
|
|
3
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_outbox_finds_entries_nested_at_any_depth() {
|
|
// The walk is what stands in for an index, so it has to reach an entry
|
|
// wherever the server's tree put it.
|
|
let (c, _d) = cache("nested");
|
|
c.store("a.drsc", &rated(1), true).unwrap();
|
|
c.store("photos/b.drsc", &rated(1), true).unwrap();
|
|
c.store("photos/2024/spain/c.drsc", &rated(1), true)
|
|
.unwrap();
|
|
c.store("photos/d.drsc", &rated(1), false).unwrap();
|
|
|
|
assert_eq!(
|
|
c.pending(),
|
|
vec![
|
|
"a.drsc".to_string(),
|
|
"photos/2024/spain/c.drsc".to_string(),
|
|
"photos/b.drsc".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_cache_has_an_empty_outbox() {
|
|
// A fresh install has no directory at all; the walk must not fail.
|
|
let (c, _d) = cache("fresh");
|
|
assert!(c.pending().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_marker_without_its_document_is_not_a_queued_upload() {
|
|
// Debris from an interrupted write. There is nothing to send, and
|
|
// reporting it as queued would leave the outbox permanently non-empty.
|
|
let (c, _d) = cache("debris");
|
|
c.store("photos/a.drsc", &rated(1), true).unwrap();
|
|
std::fs::remove_file(c.path_for("photos/a.drsc").unwrap()).unwrap();
|
|
|
|
assert!(c.pending().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_path_escaping_the_cache_is_refused() {
|
|
// The remote path comes from a server response and is not this
|
|
// process's to trust.
|
|
let (c, _d) = cache("escape");
|
|
assert!(c.path_for("../../etc/passwd.drsc").is_none());
|
|
assert!(c.path_for("photos/../../../a.drsc").is_none());
|
|
assert!(c.path_for("").is_none());
|
|
assert!(c.store("../evil.drsc", &rated(1), true).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_cache_with_no_directory_is_inert() {
|
|
// What a caller holds before a library is open. Writing relative to
|
|
// the working directory would scatter sidecars wherever the app was
|
|
// launched from.
|
|
let c = SidecarCache::open(PathBuf::new());
|
|
assert!(c.path_for("photos/a.drsc").is_none());
|
|
assert!(c.load("photos/a.drsc").is_none());
|
|
assert!(c.store("photos/a.drsc", &rated(1), true).is_err());
|
|
assert!(c.pending().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_leading_slash_is_not_an_absolute_path() {
|
|
// WebDAV paths often arrive with one. Treating it as absolute would
|
|
// push the write to the filesystem root.
|
|
let (c, root) = cache("leading-slash");
|
|
let p = c.path_for("/photos/a.drsc").unwrap();
|
|
assert!(p.starts_with(&root), "{}", p.display());
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_cache_entry_reads_as_absent_rather_than_failing() {
|
|
// A cache is reconstructible from the server by definition, so the
|
|
// fallback is to fetch — never to refuse to open the photograph.
|
|
let (c, _d) = cache("corrupt");
|
|
c.store("photos/a.drsc", &rated(1), false).unwrap();
|
|
std::fs::write(c.path_for("photos/a.drsc").unwrap(), "drsc 99\n").unwrap();
|
|
|
|
assert!(c.load("photos/a.drsc").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn storing_leaves_no_temporary_file_behind() {
|
|
let (c, root) = cache("no-temp");
|
|
c.store("photos/a.drsc", &rated(1), true).unwrap();
|
|
|
|
let leftovers: Vec<_> = std::fs::read_dir(root.join("photos"))
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
|
.filter(|n| n.ends_with(".tmp"))
|
|
.collect();
|
|
assert!(leftovers.is_empty(), "left {leftovers:?} behind");
|
|
}
|
|
}
|