diff --git a/Cargo.lock b/Cargo.lock index 7a0b389..e630191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1224,7 +1224,7 @@ name = "darkroom-android" version = "0.8.0" dependencies = [ "android_logger", - "dr-sync-nextcloud", + "dr-sync", "dr-ui", "log", "slint", @@ -1551,6 +1551,21 @@ name = "dr-sync" version = "0.8.0" dependencies = [ "async-trait", + "dr-plat", + "dr-types", + "log", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "dr-sync-folder" +version = "0.8.0" +dependencies = [ + "async-trait", + "dr-sync", "dr-types", "log", "thiserror 2.0.20", @@ -1565,6 +1580,7 @@ dependencies = [ "dr-decode", "dr-plat", "dr-sync", + "dr-sync-folder", "dr-types", "env_logger", "log", @@ -1604,6 +1620,7 @@ name = "dr-ui" version = "0.8.0" dependencies = [ "anyhow", + "async-trait", "dr-catalog", "dr-decode", "dr-export", @@ -1615,6 +1632,7 @@ dependencies = [ "dr-plat", "dr-segment", "dr-sync", + "dr-sync-folder", "dr-sync-nextcloud", "dr-thumbs", "dr-types", diff --git a/Cargo.toml b/Cargo.toml index 0dcfab7..4a1c797 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "core/dr-pipeline", "core/dr-segment", "core/dr-sync", + "core/dr-sync-folder", "core/dr-sync-nextcloud", "platform/dr-plat", "ui/dr-ui", @@ -53,6 +54,7 @@ dr-pipeline = { path = "core/dr-pipeline" } dr-segment = { path = "core/dr-segment", default-features = false } dr-plat = { path = "platform/dr-plat" } dr-sync = { path = "core/dr-sync" } +dr-sync-folder = { path = "core/dr-sync-folder" } dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" } dr-ui = { path = "ui/dr-ui" } diff --git a/apps/darkroom-android/Cargo.toml b/apps/darkroom-android/Cargo.toml index fc6c416..bfcbc7b 100644 --- a/apps/darkroom-android/Cargo.toml +++ b/apps/darkroom-android/Cargo.toml @@ -16,9 +16,9 @@ crate-type = ["cdylib"] # No backend feature to select: dr-ui picks its Slint backend from the target, # so building for aarch64-linux-android gets android-activity automatically. dr-ui.workspace = true -# For `session::set_data_dir`: only the platform entry point knows where Android +# For `account::set_data_dir`: only the platform entry point knows where Android # lets this app keep files, and it must be set before any store is opened. -dr-sync-nextcloud.workspace = true +dr-sync.workspace = true # Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and # calls `slint::android::init`, both of which come from this crate. The backend # feature comes from dr-ui's target-specific dependency. diff --git a/apps/darkroom-android/src/lib.rs b/apps/darkroom-android/src/lib.rs index c1bd0ee..79b20b3 100644 --- a/apps/darkroom-android/src/lib.rs +++ b/apps/darkroom-android/src/lib.rs @@ -43,7 +43,7 @@ fn android_main(app: slint::android::AndroidApp) { match app.internal_data_path() { Some(dir) => { log::info!("data dir: {}", dir.display()); - dr_sync_nextcloud::session::set_data_dir(dir); + dr_sync::account::set_data_dir(dir); } None => log::error!("no internal data path; settings will not persist"), } diff --git a/core/dr-catalog/src/cache.rs b/core/dr-catalog/src/cache.rs index c763aa4..25c8803 100644 --- a/core/dr-catalog/src/cache.rs +++ b/core/dr-catalog/src/cache.rs @@ -222,6 +222,56 @@ impl Cache { Ok(()) } + /// TRACES: FR-NC-6c | FR-NC-6a + /// Record an original this cache does **not** own the bytes of. + /// + /// The virtual-filesystem case. On a library kept by a sync client the + /// original is materialised *in the library folder itself*, so copying it + /// under `originals/` would hold two copies of every pinned photograph — + /// and the copy would be the one the budget could evict while the real + /// disk cost stayed. + /// + /// So the bytes are left where they are and only the bookkeeping is kept. + /// `path` is deliberately `NULL`, which is what makes this safe: + /// [`release`](Self::release) deletes the file a row names, and a row that + /// names none deletes nothing. **That matters more than it sounds.** + /// Deleting a materialised file inside a synced folder does not free a + /// cache — it deletes the photograph, and the client propagates that to + /// the server and to every other device. Handing the disk back is the + /// backend's job (`RemoteBackend::dematerialise`), not this one's. + /// + /// `bytes` is what the original occupies where it lies, for the budget and + /// for reporting; pass 0 where it is not known. + pub fn record_in_place( + &self, + conn: &Connection, + image: ImageId, + bytes: u64, + pinned: bool, + now: i64, + ) -> Result<(), CatalogError> { + conn.execute( + "INSERT INTO image_cache + (image_id, tier_actual, tier_desired, bytes, last_used, pinned, path) + VALUES (?1, ?2, ?2, ?3, ?4, ?5, NULL) + ON CONFLICT(image_id) DO UPDATE SET + tier_actual = ?2, + tier_desired = max(tier_desired, ?2), + bytes = ?3, + last_used = ?4, + pinned = max(pinned, ?5), + path = NULL", + rusqlite::params![ + image.0 as i64, + Tier::Original.stored(), + bytes as i64, + now, + i64::from(pinned), + ], + )?; + Ok(()) + } + /// Read a cached original back, if it is here. /// /// Touches `last_used`, which is what makes the eviction order reflect diff --git a/core/dr-sync-folder/Cargo.toml b/core/dr-sync-folder/Cargo.toml new file mode 100644 index 0000000..7c2c746 --- /dev/null +++ b/core/dr-sync-folder/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "dr-sync-folder" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +dr-types.workspace = true +dr-sync.workspace = true +async-trait.workspace = true +thiserror.workspace = true +log.workspace = true +# Filesystem work runs on the blocking pool rather than on the async worker +# that called it — see the module docs. +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/core/dr-sync-folder/examples/scan.rs b/core/dr-sync-folder/examples/scan.rs new file mode 100644 index 0000000..a8c96f8 --- /dev/null +++ b/core/dr-sync-folder/examples/scan.rs @@ -0,0 +1,71 @@ +//! Scan a real folder through the engine, and read a preview out of it. +//! +//! ```text +//! cargo run -p dr-sync-folder --example scan -- /path/to/photos +//! ``` +//! +//! Exercises the same code the application runs: `dr_sync::scan` driving the +//! folder connector, then a ranged `get` of the kind the thumbnail worker +//! makes. Reads only — it never writes into the folder it is pointed at. +use std::collections::HashMap; + +use dr_sync::{RemoteBackend, RemoteId, RemotePath}; +use dr_sync_folder::FolderBackend; +use dr_types::FormatFilter; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let Some(root) = std::env::args().nth(1) else { + eprintln!("usage: scan "); + std::process::exit(2); + }; + + let backend = match FolderBackend::new(&root) { + Ok(b) => b, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let caps = backend.capabilities(); + println!("{} at {root}", backend.name()); + println!( + " strategy: {}", + dr_sync::SyncStrategy::for_capabilities(caps).describe() + ); + + let started = std::time::Instant::now(); + let result = dr_sync::scan( + &backend, + &RemotePath::root(), + &FormatFilter::all(), + &HashMap::new(), + |_| {}, + ) + .await + .expect("scan"); + + println!( + " {} image(s) in {} director(ies), {:?}", + result.images.len(), + result.progress.directories_listed, + started.elapsed() + ); + + let Some(first) = result.images.first() else { + return; + }; + println!( + " first: {} ({} bytes) id {:?}", + first.path, first.size, first.id + ); + + // The shape of request the thumbnail worker makes: a header window, not + // the whole file. + let head = backend + .get(&RemoteId::Path(first.path.clone()), Some(0..65536)) + .await + .expect("ranged read"); + println!(" read {} header bytes", head.len()); +} diff --git a/core/dr-sync-folder/examples/vfs_cycle.rs b/core/dr-sync-folder/examples/vfs_cycle.rs new file mode 100644 index 0000000..29c1e31 --- /dev/null +++ b/core/dr-sync-folder/examples/vfs_cycle.rs @@ -0,0 +1,142 @@ +//! A placeholder library, borrowed and given back. +//! +//! ```text +//! cargo run -p dr-sync-folder --example vfs_cycle +//! ``` +//! +//! Builds a tree in the system temp directory shaped like a suffix-mode VFS +//! folder, runs the real engine over it, and reports what the borrow cost. +//! Touches nothing outside its own scratch directory. +use std::borrow::Cow; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath}; +use dr_sync_folder::{BorrowPool, FolderBackend, Vfs}; +use dr_types::FormatFilter; + +/// Stands in for the sync client, renaming exactly as suffix mode does. +struct Client; + +impl Vfs for Client { + fn name(&self) -> &'static str { + "demo" + } + fn is_placeholder(&self, on_disk: &str) -> bool { + on_disk.ends_with(".stub") + } + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { + on_disk.strip_suffix(".stub").unwrap_or(on_disk) + } + fn placeholder_name(&self, name: &str) -> Cow<'_, str> { + Cow::Owned(format!("{name}.stub")) + } + fn can_materialise(&self) -> bool { + true + } + fn materialise(&self, local: &Path) -> Result<(), RemoteError> { + let real = PathBuf::from(local.to_string_lossy().strip_suffix(".stub").unwrap()); + std::fs::write(&real, vec![7u8; 25 * 1024 * 1024]).unwrap(); + std::fs::remove_file(local).unwrap(); + Ok(()) + } + fn dematerialise(&self, local: &Path) -> Result<(), RemoteError> { + std::fs::write(format!("{}.stub", local.display()), [0u8]).unwrap(); + std::fs::remove_file(local).unwrap(); + Ok(()) + } +} + +fn disk_used(root: &Path) -> u64 { + fn walk(p: &Path, total: &mut u64) { + if let Ok(entries) = std::fs::read_dir(p) { + for e in entries.flatten() { + let Ok(m) = e.metadata() else { continue }; + if m.is_dir() { + walk(&e.path(), total); + } else { + *total += m.len(); + } + } + } + } + let mut t = 0; + walk(root, &mut t); + t +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let root = std::env::temp_dir().join("dr-vfs-cycle"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("2026/03")).unwrap(); + + // Ninety dehydrated photographs, and ten the user already keeps. + for i in 0..90 { + std::fs::write(root.join(format!("2026/03/IMG_{i:04}.CR2.stub")), [0u8]).unwrap(); + } + for i in 90..100 { + std::fs::write( + root.join(format!("2026/03/IMG_{i:04}.CR2")), + vec![1u8; 25 * 1024 * 1024], + ) + .unwrap(); + } + + let b = FolderBackend::with_vfs(&root, Arc::new(Client)).unwrap(); + println!("materialisation: {:?}", b.capabilities().materialisation); + println!("on disk at rest: {} MB", disk_used(&root) / 1_048_576); + + let scan = dr_sync::scan( + &b, + &RemotePath::root(), + &FormatFilter::all(), + &HashMap::new(), + |_| {}, + ) + .await + .unwrap(); + + let absent = scan.images.iter().filter(|e| !e.materialised).count(); + println!( + "scanned {} photograph(s), {absent} not downloaded", + scan.images.len() + ); + // Names, not stubs — this is what the catalog records. + println!("first: {}", scan.images[0].path); + + // A pass over the library, one photograph at a time. + let pool = BorrowPool::new(); + let mut peak = 0u64; + let mut fetched = 0usize; + for entry in &scan.images { + let held = pool.borrow(&b, &entry.path).await.unwrap(); + if held.hydrated() { + fetched += 1; + } + // Read it, as a thumbnail pass would. + let n = b + .get(&RemoteId::Path(entry.path.clone()), Some(0..65536)) + .await + .unwrap() + .len(); + assert_eq!(n, 65536); + peak = peak.max(disk_used(&root)); + drop(held); + // Release as we go, which is what keeps the peak flat. + pool.release_all(&b).await; + } + + println!("fetched {fetched} of {}", scan.images.len()); + println!("peak on disk: {} MB", peak / 1_048_576); + println!("after the pass: {} MB", disk_used(&root) / 1_048_576); + println!( + "the ten the user already had: {} still here", + (90..100) + .filter(|i| root.join(format!("2026/03/IMG_{i:04}.CR2")).is_file()) + .count() + ); + + let _ = std::fs::remove_dir_all(&root); +} diff --git a/core/dr-sync-folder/src/borrow.rs b/core/dr-sync-folder/src/borrow.rs new file mode 100644 index 0000000..f48526a --- /dev/null +++ b/core/dr-sync-folder/src/borrow.rs @@ -0,0 +1,207 @@ +// TRACES: FR-NC-6c | FR-NC-6a +//! Hydrating a file for as long as it is needed, and no longer. +//! +//! A pass over a library — thumbnails, face indexing — needs each photograph's +//! bytes for a moment and never again. On a virtual-filesystem folder those +//! bytes may not be here, and fetching them is whole-file: hydrating a 17,000 +//! image library to index it would land the entire library on a disk the user +//! deliberately keeps most of it off (ARCH §9.0). +//! +//! So hydration is a **borrow**. Ask for a file, use it, give it back. Peak +//! disk becomes the working set rather than the library, and the transfer is +//! paid once for a thumbnail that is then kept for ever — and pushed to the +//! server for other devices, which never pay it at all. +//! +//! # The rule that makes it safe +//! +//! **A file is returned to the state it was found in.** If it was already +//! downloaded — the user pinned it, opened it yesterday, or never uses VFS — +//! the borrow leaves it downloaded. Only what this pass hydrated is released. +//! Anything else silently undoes a choice the user made, and "my pinned trip +//! evaporated after an indexing run" is the kind of failure that makes people +//! stop trusting the feature. +//! +//! # Why it is reference counted +//! +//! Lanes run concurrently and two of them meet on the same file: the +//! thumbnail pass and the face pass want the same RAW. Without counting, the +//! first to finish dehydrates the file the second is reading. With it, the +//! transfer is paid once and the release happens when the last borrower is +//! done. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath}; + +/// What a borrow is holding, per path. +#[derive(Debug, Default)] +struct Held { + /// How many borrowers are using it now. + borrowers: usize, + /// Whether *we* brought it here. False means it was already downloaded + /// and must be left that way. + ours: bool, +} + +/// Tracks what has been hydrated and by whom. +/// +/// Cheap to clone — every worker holds one and they share the same state. +#[derive(Clone, Default, Debug)] +pub struct BorrowPool { + held: Arc>>, +} + +/// What a completed borrow did, for reporting a pass's real cost. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct BorrowStats { + /// Files that were already here. These cost nothing. + pub already_local: usize, + /// Files this pass downloaded. + pub hydrated: usize, + /// Files released again afterwards. + pub released: usize, + /// Files left downloaded because they were already so. + pub kept: usize, +} + +impl BorrowPool { + pub fn new() -> Self { + Self::default() + } + + /// Borrow a file's content for the life of the returned guard. + /// + /// Downloads it if it is a placeholder; does nothing if it is already + /// here. The guard releases it on drop, but only if this pool hydrated it + /// and nothing else still holds it. + /// + /// A backend without [`Materialisation::OnDemand`] short-circuits: the + /// borrow succeeds and does nothing, so a caller written for a VFS library + /// runs unchanged against a server or a plain folder. + /// + /// [`Materialisation::OnDemand`]: dr_sync::Materialisation::OnDemand + pub async fn borrow<'p>( + &'p self, + backend: &dyn RemoteBackend, + path: &RemotePath, + ) -> Result, RemoteError> { + if !backend.capabilities().materialisation.can_materialise() { + return Ok(Borrowed { + pool: None, + path: path.clone(), + hydrated: false, + }); + } + + // Another borrower already has it: join them rather than asking the + // client a second time. + { + let mut held = self.lock(); + if let Some(entry) = held.get_mut(path) { + entry.borrowers += 1; + return Ok(Borrowed { + pool: Some(self), + path: path.clone(), + hydrated: false, + }); + } + } + + // The backend answers whether *it* fetched the content, because it had + // to look before deciding. Determining that here instead would cost a + // directory listing per file, and getting it wrong in the wrong + // direction releases a file the user pinned. + let ours = backend.materialise(&RemoteId::Path(path.clone())).await?; + + self.lock() + .insert(path.clone(), Held { borrowers: 1, ours }); + + Ok(Borrowed { + pool: Some(self), + path: path.clone(), + hydrated: ours, + }) + } + + /// Release everything this pool still holds that it hydrated. + /// + /// The end-of-pass sweep. A guard dropped on a panicking worker cannot run + /// its async release, so the pool is drained deliberately at the end + /// rather than trusted to unwind cleanly. + pub async fn release_all(&self, backend: &dyn RemoteBackend) -> BorrowStats { + let ours: Vec = { + let held = self.lock(); + held.iter() + .filter(|(_, h)| h.ours) + .map(|(p, _)| p.clone()) + .collect() + }; + + let mut stats = BorrowStats::default(); + for path in ours { + match backend.dematerialise(&RemoteId::Path(path.clone())).await { + Ok(()) => stats.released += 1, + // Not fatal, and not worth failing a completed pass over: the + // content stays, which costs disk and loses nothing. + Err(e) => log::debug!("releasing {path}: {e}"), + } + } + self.lock().clear(); + stats + } + + /// How many paths are currently held. + pub fn held(&self) -> usize { + self.lock().len() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + // A poisoned lock means a worker panicked while holding it. The map is + // bookkeeping, not a resource — carrying on with it is better than + // taking the whole pass down. + self.held.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +/// A file held local for as long as this lives. +/// +/// Dropping it marks the borrow finished. The actual release happens in +/// [`BorrowPool::release_all`], because dropping cannot await. +#[derive(Debug)] +pub struct Borrowed<'p> { + pool: Option<&'p BorrowPool>, + path: RemotePath, + /// Whether this borrow was the one that downloaded it. + hydrated: bool, +} + +impl Borrowed<'_> { + /// Whether this borrow paid for a download. + pub fn hydrated(&self) -> bool { + self.hydrated + } + + pub fn path(&self) -> &RemotePath { + &self.path + } +} + +impl Drop for Borrowed<'_> { + fn drop(&mut self) { + let Some(pool) = self.pool else { return }; + let mut held = pool.lock(); + if let Some(entry) = held.get_mut(&self.path) { + entry.borrowers = entry.borrowers.saturating_sub(1); + // Left in the map even at zero borrowers: `release_all` needs to + // know it was ours, and a file wanted again a moment later should + // not be downloaded twice. + if entry.borrowers == 0 && !entry.ours { + held.remove(&self.path); + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/core/dr-sync-folder/src/borrow/tests.rs b/core/dr-sync-folder/src/borrow/tests.rs new file mode 100644 index 0000000..9b8778b --- /dev/null +++ b/core/dr-sync-folder/src/borrow/tests.rs @@ -0,0 +1,266 @@ +//! The borrow contract, against a filesystem and a fake client. +//! +//! The fake stands in for the sync client's socket, not for the filesystem: +//! it renames stubs exactly as suffix-mode VFS does, so everything under test +//! is the real path resolution and the real state tracking. + +use super::*; +use crate::{FolderBackend, Vfs}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// A stand-in for a sync client, counting what it was asked to do. +struct FakeClient { + suffix: &'static str, + hydrations: AtomicUsize, + dehydrations: AtomicUsize, + /// When true, refuse to hydrate — the client is running but the server is + /// not reachable. + broken: bool, +} + +impl FakeClient { + fn new() -> Arc { + Arc::new(Self { + suffix: ".nextcloud", + hydrations: AtomicUsize::new(0), + dehydrations: AtomicUsize::new(0), + broken: false, + }) + } + fn broken() -> Arc { + Arc::new(Self { + suffix: ".nextcloud", + hydrations: AtomicUsize::new(0), + dehydrations: AtomicUsize::new(0), + broken: true, + }) + } +} + +impl Vfs for FakeClient { + fn name(&self) -> &'static str { + "fake" + } + fn is_placeholder(&self, on_disk: &str) -> bool { + on_disk.ends_with(self.suffix) + } + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { + on_disk.strip_suffix(self.suffix).unwrap_or(on_disk) + } + fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Owned(format!("{name}{}", self.suffix)) + } + fn can_materialise(&self) -> bool { + true + } + fn materialise(&self, local: &Path) -> Result<(), RemoteError> { + self.hydrations.fetch_add(1, Ordering::SeqCst); + if self.broken { + return Err(RemoteError::Network("no server".into())); + } + // Suffix mode renames rather than filling in place, and writes the + // real content. + let real = PathBuf::from(local.to_string_lossy().strip_suffix(self.suffix).unwrap()); + std::fs::write(&real, vec![9u8; 4096]).unwrap(); + std::fs::remove_file(local).unwrap(); + Ok(()) + } + fn dematerialise(&self, local: &Path) -> Result<(), RemoteError> { + self.dehydrations.fetch_add(1, Ordering::SeqCst); + let stub = format!("{}{}", local.display(), self.suffix); + std::fs::write(&stub, [0u8]).unwrap(); + std::fs::remove_file(local).unwrap(); + Ok(()) + } +} + +struct Tmp(PathBuf); + +impl Tmp { + fn new(name: &str) -> Self { + let d = std::env::temp_dir().join(format!("dr-borrow-{name}")); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Tmp(d) + } + /// A dehydrated photograph. + fn stub(&self, rel: &str) -> &Self { + std::fs::write(self.0.join(format!("{rel}.nextcloud")), [0u8]).unwrap(); + self + } + /// One the user already has. + fn real(&self, rel: &str) -> &Self { + std::fs::write(self.0.join(rel), vec![1u8; 2048]).unwrap(); + self + } + fn has(&self, rel: &str) -> bool { + self.0.join(rel).is_file() + } + fn backend(&self, vfs: Arc) -> FolderBackend { + FolderBackend::with_vfs(&self.0, vfs).unwrap() + } +} + +impl Drop for Tmp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[tokio::test] +async fn a_borrowed_placeholder_is_downloaded_and_given_back() { + let t = Tmp::new("cycle"); + t.stub("a.CR2"); + let client = FakeClient::new(); + let b = t.backend(client.clone()); + let pool = BorrowPool::new(); + let path = RemotePath::new("a.CR2"); + + { + let held = pool.borrow(&b, &path).await.unwrap(); + assert!(held.hydrated(), "this borrow paid for it"); + assert!(t.has("a.CR2"), "content is here while borrowed"); + assert_eq!( + b.get(&RemoteId::Path(path.clone()), None) + .await + .unwrap() + .len(), + 4096 + ); + } + + let stats = pool.release_all(&b).await; + assert_eq!(stats.released, 1); + assert!(!t.has("a.CR2"), "given back"); + assert!(t.has("a.CR2.nextcloud"), "a placeholder is left behind"); + assert_eq!(client.dehydrations.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn a_file_the_user_already_had_is_never_taken_away() { + // The rule the whole design rests on. Silently undoing a pin — or just a + // file someone opened yesterday — after an indexing run is the failure + // that would make people stop trusting this. + let t = Tmp::new("keep"); + t.real("pinned.CR2"); + let client = FakeClient::new(); + let b = t.backend(client.clone()); + let pool = BorrowPool::new(); + + { + let held = pool + .borrow(&b, &RemotePath::new("pinned.CR2")) + .await + .unwrap(); + assert!(!held.hydrated(), "nothing was downloaded"); + } + let stats = pool.release_all(&b).await; + + assert_eq!(stats.released, 0); + assert!(t.has("pinned.CR2"), "still here"); + assert_eq!(client.hydrations.load(Ordering::SeqCst), 0); + assert_eq!(client.dehydrations.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn two_lanes_wanting_one_file_download_it_once() { + // The thumbnail pass and the face pass meet on the same RAW. Without + // counting, the first to finish dehydrates the file the second is reading. + let t = Tmp::new("shared"); + t.stub("a.CR2"); + let client = FakeClient::new(); + let b = t.backend(client.clone()); + let pool = BorrowPool::new(); + let path = RemotePath::new("a.CR2"); + + let first = pool.borrow(&b, &path).await.unwrap(); + let second = pool.borrow(&b, &path).await.unwrap(); + + assert_eq!(client.hydrations.load(Ordering::SeqCst), 1, "paid once"); + drop(first); + assert!(t.has("a.CR2"), "still held by the second borrower"); + drop(second); + + pool.release_all(&b).await; + assert!(!t.has("a.CR2")); +} + +#[tokio::test] +async fn a_failed_download_does_not_leave_a_phantom_borrow() { + // The client is up but the server is not. The pass must see the failure + // and the pool must not believe it holds anything. + let t = Tmp::new("failed"); + t.stub("a.CR2"); + let b = t.backend(FakeClient::broken()); + let pool = BorrowPool::new(); + + let e = pool + .borrow(&b, &RemotePath::new("a.CR2")) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::Network(_)), "{e:?}"); + assert_eq!(pool.held(), 0); + assert!(t.has("a.CR2.nextcloud"), "left as it was found"); +} + +#[tokio::test] +async fn borrowing_against_a_plain_folder_does_nothing_at_all() { + // A caller written for a VFS library must run unchanged elsewhere, or + // every sweep grows two code paths. + let t = Tmp::new("plain"); + t.real("a.CR2"); + let b = FolderBackend::new(&t.0).unwrap(); + let pool = BorrowPool::new(); + + let held = pool.borrow(&b, &RemotePath::new("a.CR2")).await.unwrap(); + assert!(!held.hydrated()); + drop(held); + assert_eq!(pool.release_all(&b).await.released, 0); + assert!(t.has("a.CR2")); +} + +#[tokio::test] +async fn the_backend_is_what_decides_whether_a_file_was_ours() { + // Not the pool, and not the caller. The backend had to look before + // deciding whether to ask, so it can answer for the cost of that same + // `stat`; a borrower working it out separately would pay a directory + // listing per file and could get it wrong in the direction that releases + // a file the user pinned. + let t = Tmp::new("who-decides"); + t.real("had.CR2").stub("wanted.CR2"); + let b = t.backend(FakeClient::new()); + + assert!( + !b.materialise(&RemoteId::Path(RemotePath::new("had.CR2"))) + .await + .unwrap(), + "already here, so not ours to release" + ); + assert!( + b.materialise(&RemoteId::Path(RemotePath::new("wanted.CR2"))) + .await + .unwrap(), + "this call fetched it" + ); +} + +#[tokio::test] +async fn a_file_borrowed_twice_in_one_pass_is_fetched_once_and_released_once() { + // Thumbnailing and face indexing visit the same photograph. Fetching it + // per stage doubles the transfer over the whole library. + let t = Tmp::new("sequential"); + t.stub("a.CR2"); + let client = FakeClient::new(); + let b = t.backend(client.clone()); + let pool = BorrowPool::new(); + let path = RemotePath::new("a.CR2"); + + // Sequential borrows, as two passes over one work list would make. + drop(pool.borrow(&b, &path).await.unwrap()); + drop(pool.borrow(&b, &path).await.unwrap()); + + assert_eq!(client.hydrations.load(Ordering::SeqCst), 1, "paid once"); + let stats = pool.release_all(&b).await; + assert_eq!(stats.released, 1, "given back once"); +} diff --git a/core/dr-sync-folder/src/lib.rs b/core/dr-sync-folder/src/lib.rs new file mode 100644 index 0000000..69f0d22 --- /dev/null +++ b/core/dr-sync-folder/src/lib.rs @@ -0,0 +1,869 @@ +// TRACES: FR-NC-13 | FR-NC-12 +//! A library that is just a directory. +//! +//! The second [`RemoteBackend`], and the one that exists to prove the first +//! was an abstraction rather than a description. It serves a plain folder: a +//! local disk, an NFS or SMB mount, a Nextcloud desktop client's synced copy, +//! an external drive. No server, no account, no credential. +//! +//! # What it is honestly worse at, and why that is fine +//! +//! Nextcloud's fast path rests on directory ETags propagating up the tree, so +//! one request against the root proves a 50k-image library unchanged. A POSIX +//! directory's mtime says only that its own entry list changed — not that a +//! grandchild's *contents* did — so there is nothing here to propagate and +//! [`ChangeDetection::LocalEtags`] is the truthful answer. The engine reads +//! that and walks the tree every scan instead of pruning it. +//! +//! Which costs almost nothing, because the walk that was expensive was +//! expensive for a reason this backend does not have. Fifty thousand +//! `stat` calls against a local filesystem take well under a second; fifty +//! thousand `PROPFIND`s do not. The capability model is what lets both be +//! driven by the same engine at the speed each one actually runs at. +//! +//! # Identity +//! +//! [`RemoteId::Stable`] here is a hash of the path relative to the library +//! root. That gives the catalog what it needs — a `u64` that names a +//! photograph, is the same on every device looking at the same folder, and +//! does not change when the file is edited — which is what keys the thumbnail +//! shards and the face index (`catalog.md` §10.1). +//! +//! It does **not** survive a rename, and [`Capabilities::stable_ids`] says so. +//! A moved photograph is seen as a delete and an add, and its thumbnail is +//! derived again. That is the documented degradation for a backend without +//! server-assigned ids, and it is the right trade here: the alternative, +//! keying on the inode, is stable across a rename but *differs between +//! devices* and is reused by the filesystem after a delete — so two machines +//! would disagree about which photograph a thumbnail belonged to, and a +//! recycled inode would silently attach an old thumbnail to a new image. +//! Re-deriving a thumbnail is a cost; showing the wrong one is a bug. +//! +//! # Blocking +//! +//! Every filesystem call goes through the blocking pool. On a local disk that +//! is overkill; on the NFS mount this backend is most useful over, a stalled +//! server would otherwise wedge the async worker that made the call and every +//! other request sharing it. + +use std::io::{Read, Seek, SeekFrom, Write}; +use std::ops::Range; +use std::path::{Component, Path, PathBuf}; + +use std::sync::Arc; + +use async_trait::async_trait; +use dr_sync::{ + Account, BackendProvider, Capabilities, ChangeDetection, Connection, Cursor, EntryKind, + Materialisation, Precondition, RemoteBackend, RemoteChange, RemoteEntry, RemoteError, RemoteId, + RemotePath, ServerPreviews, SignIn, Validator, +}; + +pub mod borrow; +pub mod vfs; + +pub use borrow::{BorrowPool, BorrowStats, Borrowed}; +pub use vfs::{NoVfs, Vfs}; + +/// The id written to [`Account::backend`] for a folder library. +/// +/// On-disk configuration: changing it orphans every folder account. +pub const BACKEND_ID: &str = "folder"; + +/// TRACES: FR-NC-13 | FR-NC-6c +/// Registers the folder connector. +/// +/// See [`dr_sync::provider`] for what each method is for. +/// +/// # The detector +/// +/// This crate knows how to read a directory and nothing about sync clients, +/// so the placeholder convention arrives from outside: whoever registers the +/// provider supplies a function that recognises a synced folder and returns +/// the [`Vfs`] for it. That keeps `dr-sync-folder` free of any client's +/// protocol, and it is what lets one connector serve a plain disk, a Nextcloud +/// tree, and whatever comes next. +/// +/// Detection runs per connection because the answer changes: the same +/// directory offers hydration while the client is up and not while it is down. +/// Recognises a placeholder convention in a directory, if any applies. +/// +/// Runs per connection rather than once, because the answer changes: the same +/// folder offers hydration while the sync client is up and not while it is +/// down. +pub type VfsDetector = dyn Fn(&Path) -> Option> + Send + Sync; + +#[derive(Default)] +pub struct FolderProvider { + detect_vfs: Option>, +} + +impl FolderProvider { + /// A folder connector that treats every directory as ordinary. + pub fn new() -> Self { + Self::default() + } + + /// A folder connector that recognises placeholder conventions. + pub fn with_vfs_detector( + detect: impl Fn(&Path) -> Option> + Send + Sync + 'static, + ) -> Self { + Self { + detect_vfs: Some(Box::new(detect)), + } + } + + fn vfs_for(&self, root: &Path) -> Arc { + self.detect_vfs + .as_ref() + .and_then(|d| d(root)) + .unwrap_or_else(|| Arc::new(NoVfs)) + } +} + +impl BackendProvider for FolderProvider { + fn id(&self) -> &'static str { + BACKEND_ID + } + + fn display_name(&self) -> &'static str { + "Folder" + } + + fn endpoint_label(&self) -> &'static str { + "Folder" + } + + fn endpoint_placeholder(&self) -> &'static str { + "/home/you/Pictures" + } + + fn sign_in(&self) -> SignIn { + SignIn::EndpointOnly + } + + /// Check the directory before an account is written for it. + /// + /// A typo here would otherwise be stored, skip the launch screen on the + /// next start, and surface as a scan that finds nothing — which reads as + /// a broken library rather than a wrong path. The messages say what to fix. + fn normalise_endpoint(&self, input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("Choose the folder your photographs are in.".into()); + } + + // `~` is what a person types and what a shell would have expanded; + // nothing expands it here, so a stored `~/Pictures` becomes a + // directory literally named `~`. + let expanded = match trimmed.strip_prefix("~/") { + Some(rest) => match std::env::var_os("HOME") { + Some(home) => PathBuf::from(home).join(rest), + None => return Err("No home directory to expand ~ against.".into()), + }, + None => PathBuf::from(trimmed), + }; + + if !expanded.is_absolute() { + return Err("Give the full path to the folder, starting at /.".into()); + } + if !expanded.exists() { + return Err(format!("No folder at {}.", expanded.display())); + } + if !expanded.is_dir() { + return Err(format!("{} is a file, not a folder.", expanded.display())); + } + + // Resolved so a library reached through a symlink or a `..` is stored + // under one name. Two spellings of one folder would otherwise be two + // accounts with two catalogs indexing the same photographs. + let canonical = expanded + .canonicalize() + .map_err(|e| format!("Cannot read {}: {e}", expanded.display()))?; + + Ok(canonical.to_string_lossy().into_owned()) + } + + fn account_for(&self, endpoint: &str) -> Result { + Ok(Account::new(BACKEND_ID, endpoint)) + } + + fn connect(&self, conn: &Connection) -> Result, RemoteError> { + let root = Path::new(&conn.account.endpoint); + Ok(Box::new(FolderBackend::with_vfs(root, self.vfs_for(root))?)) + } +} + +/// TRACES: FR-NC-13 | FR-NC-4 | FR-NC-6c +/// A library rooted at a directory. +#[derive(Clone)] +pub struct FolderBackend { + root: PathBuf, + /// The placeholder convention in force, [`NoVfs`] for an ordinary folder. + vfs: Arc, + caps: Capabilities, +} + +impl std::fmt::Debug for FolderBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FolderBackend") + .field("root", &self.root) + .field("vfs", &self.vfs.name()) + .finish_non_exhaustive() + } +} + +impl FolderBackend { + /// Open the folder at `root`. + /// + /// The directory must exist now. It may stop existing later — a drive + /// unplugged, a mount dropped — and that surfaces per-operation as + /// [`RemoteError::Network`], which is what puts the app into offline mode + /// and leaves the catalog readable, exactly as a dead server does. + pub fn new(root: impl Into) -> Result { + Self::with_vfs(root, Arc::new(NoVfs)) + } + + /// Open the folder at `root` under a placeholder convention. + /// + /// The convention is chosen by the caller rather than sniffed here: the + /// connector that knows how to talk to a given sync client is the one that + /// knows whether it is running (see `dr_sync_nextcloud`). + pub fn with_vfs(root: impl Into, vfs: Arc) -> Result { + let root = root.into(); + if !root.is_dir() { + return Err(RemoteError::Configuration(format!( + "{} is not a folder", + root.display() + ))); + } + // Reported per connection, not per backend: the same folder offers + // hydration while the client is up and not while it is down, so this + // cannot be a constant of the type (see `vfs`). + let materialisation = if vfs.can_materialise() { + Materialisation::OnDemand + } else if vfs.name() == NoVfs.name() { + Materialisation::Always + } else { + Materialisation::Placeholders + }; + Ok(Self { + root, + vfs, + caps: Capabilities { + // A directory's mtime describes its own entry list and nothing + // below it, so there is no propagation to exploit; the engine + // walks and compares per entry. + change_detection: ChangeDetection::LocalEtags, + // A path hash does not survive a rename. See the module docs + // for why the inode is not used instead. + stable_ids: false, + range_reads: true, + // Not a protocol with a message size limit; a write is a write. + chunked_upload: None, + bulk_upload: false, + conditional_write: true, + server_previews: ServerPreviews::None, + materialisation, + }, + }) + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// The local path for a remote path, refusing anything that escapes. + /// + /// The guard is not theoretical. A `RemotePath` is built from strings that + /// reach us from a catalog written by another device and from filenames on + /// the remote itself, and this backend resolves them against a real + /// filesystem with the user's own permissions. `../../.ssh/id_ed25519` is + /// a legal path segment; without this it would be a legal *read*. + fn resolve(&self, path: &RemotePath) -> Result { + let rel = Path::new(path.as_str()); + for component in rel.components() { + match component { + Component::Normal(_) => {} + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(RemoteError::Configuration(format!( + "{path} leaves the library folder" + ))); + } + } + } + Ok(self.root.join(rel)) + } + + /// Where a photograph's bytes are on disk, and whether they are really + /// there. + /// + /// A placeholder lives under a *different* name — suffix-mode VFS renames + /// on hydration rather than filling in place — so every read and write has + /// to look for both. The materialised name is tried first: it is the + /// common case, and the second `stat` is paid only when it misses. + /// + /// Returns the path to use and whether it holds real content. + fn locate(&self, path: &RemotePath) -> Result<(PathBuf, bool), RemoteError> { + let direct = self.resolve(path)?; + if self.vfs.name() == NoVfs.name() || direct.exists() { + return Ok((direct, true)); + } + let stub = self.resolve(&RemotePath::new( + self.vfs.placeholder_name(path.as_str()).into_owned(), + ))?; + if stub.exists() { + return Ok((stub, false)); + } + // Neither: genuinely missing. Report the name the caller asked for. + Ok((direct, true)) + } + + /// The local path a [`RemoteId`] names. + /// + /// A stable id here is a hash and nothing can be resolved from it, exactly + /// as a Nextcloud `oc:fileid` names no WebDAV endpoint. Callers hold the + /// path alongside it in the catalog and pass that. + fn resolve_id(&self, id: &RemoteId) -> Result { + match id { + RemoteId::Path(p) => self.resolve(p), + RemoteId::Stable(_) => Err(RemoteError::Unsupported( + "a folder cannot be addressed by id; use RemoteId::Path", + )), + } + } + + /// [`locate`](Self::locate) for an id. + fn locate_id(&self, id: &RemoteId) -> Result<(PathBuf, bool), RemoteError> { + match id { + RemoteId::Path(p) => self.locate(p), + RemoteId::Stable(_) => Err(RemoteError::Unsupported( + "a folder cannot be addressed by id; use RemoteId::Path", + )), + } + } +} + +/// Run a filesystem operation off the async worker that asked for it. +/// +/// See the module docs: a stalled network mount must not take the caller's +/// runtime with it. +async fn blocking(f: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + match tokio::task::spawn_blocking(f).await { + Ok(r) => r, + // The only way a blocking task fails to produce a result is a panic + // inside it, which is a bug here rather than a condition the caller + // can act on — but crashing the worker over it would lose a whole + // scan, so it is reported like any other failure. + Err(e) => Err(RemoteError::Protocol(format!("folder task failed: {e}"))), + } +} + +/// Map an IO failure to the error the engine already knows how to handle. +/// +/// The classification is the point. [`RemoteError::indicates_offline`] drives +/// offline mode, so a vanished mount must reach it as `Network` — that is +/// precisely the "the library is unreachable, keep working from the catalog" +/// case — while a permissions problem must not, because going offline over one +/// forbidden file would hide a fixable problem behind a network banner. +fn map_io(e: std::io::Error, what: &str) -> RemoteError { + use std::io::ErrorKind as K; + match e.kind() { + K::NotFound => RemoteError::NotFound(what.to_string()), + K::PermissionDenied => RemoteError::PermissionDenied, + K::AlreadyExists => RemoteError::PreconditionFailed, + // ENOSPC and friends. Quota is what the engine calls "no room". + K::StorageFull | K::QuotaExceeded | K::FileTooLarge => RemoteError::QuotaExceeded, + // A dropped mount answers ESTALE/EIO/ENOTCONN, and the honest reading + // is the same as a dead server: the library cannot be reached now, and + // may be again shortly. + K::HostUnreachable + | K::NetworkUnreachable + | K::NetworkDown + | K::ConnectionAborted + | K::ConnectionReset + | K::NotConnected + | K::BrokenPipe + | K::TimedOut => RemoteError::Network(format!("{what}: {e}")), + _ => RemoteError::Protocol(format!("{what}: {e}")), + } +} + +/// How long to wait for a requested download to land. +/// +/// Generous, because the file may be tens of megabytes over a domestic +/// connection, and bounded, because a client that has stopped transferring +/// must not wedge a whole pass. +const MATERIALISE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// How often to look for the materialised file while waiting. +const POLL: std::time::Duration = std::time::Duration::from_millis(200); + +/// The identity of a file, from its path relative to the library root. +/// +/// FNV-1a rather than `DefaultHasher`, whose output is explicitly unstable +/// between Rust releases: this value is written into the catalog and into the +/// thumbnail index, and must mean the same thing after a toolchain upgrade as +/// it did before one. +fn identity(path: &RemotePath) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in path.as_str().as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// A file's validator: its size and modification time. +/// +/// The pair, not either alone. An mtime with one-second granularity — which is +/// what some filesystems and most network mounts report — cannot distinguish +/// two writes in the same second, and a size alone cannot see an edit that +/// preserved it. Together they miss only a same-second write of identical +/// length, which for a photograph is a rewrite of the same frame. +fn validator_of(meta: &std::fs::Metadata) -> Validator { + let (secs, nanos) = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| (d.as_secs(), d.subsec_nanos())) + .unwrap_or((0, 0)); + Validator::new(format!("{:x}-{:x}.{:x}", meta.len(), secs, nanos)) +} + +fn modified_secs(meta: &std::fs::Metadata) -> Option { + meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) +} + +#[async_trait] +impl RemoteBackend for FolderBackend { + fn capabilities(&self) -> &Capabilities { + &self.caps + } + + fn name(&self) -> &str { + "Folder" + } + + async fn list( + &self, + dir: &RemotePath, + _since: Option<&Validator>, + ) -> Result, RemoteError> { + let local = self.resolve(dir)?; + let dir = dir.clone(); + let vfs = self.vfs.clone(); + blocking(move || { + let read = + std::fs::read_dir(&local).map_err(|e| map_io(e, &local.display().to_string()))?; + + let mut out = Vec::new(); + for entry in read { + let entry = match entry { + Ok(e) => e, + // One unreadable entry must not fail the listing: a + // scan of a real library meets a broken symlink or a + // file being written, and abandoning the whole + // directory over it loses every photograph beside it. + Err(e) => { + log::debug!("skipping an entry in {}: {e}", local.display()); + continue; + } + }; + + let name = entry.file_name(); + let Some(name) = name.to_str() else { + // A name that is not UTF-8 cannot round-trip through a + // `RemotePath`, and quietly mangling it would produce a + // path that addresses a different file — or none. + log::warn!("skipping a non-UTF-8 name in {}", local.display()); + continue; + }; + + // `metadata`, not `symlink_metadata`: a symlinked shoot + // folder is a normal way to assemble a library, and the + // scan's depth limit is what stops a loop. + let meta = match entry.metadata() { + Ok(m) => m, + Err(e) => { + log::debug!("skipping {name}: {e}"); + continue; + } + }; + + // The photograph's own name, never the stub's. Identity is + // derived from it, so downloading a file must not look like a + // delete and an add — and `source_ref` must match what every + // other device calls the same photograph. + let stub = vfs.is_placeholder(name); + let path = dir.join(vfs.real_name(name)); + + out.push(RemoteEntry { + id: RemoteId::Stable(identity(&path)), + kind: if meta.is_dir() { + EntryKind::Directory + } else { + EntryKind::File + }, + validator: validator_of(&meta), + // A stub is one byte and says nothing about what it stands + // for. Reporting that byte count would put a 1-byte + // `file_size` in the catalog for most of the library. + size: if stub { 0 } else { meta.len() }, + modified: modified_secs(&meta), + // No renderer behind a folder; previews are extracted + // locally from the file itself. + has_preview: false, + materialised: !stub, + path, + }); + } + Ok(out) + }) + .await + } + + /// Not offered. + /// + /// A directory's mtime changes when its own entries are added or removed + /// and at no other time, so it cannot answer the question this method + /// exists for — "did anything below here change?". Returning it anyway + /// would let a future caller prune a subtree whose contents had been + /// edited, and hide those edits for as long as the folder list held still. + async fn dir_validator(&self, _dir: &RemotePath) -> Result { + Err(RemoteError::Unsupported( + "a folder's mtime does not propagate; use per-entry validators", + )) + } + + async fn delta(&self, _cursor: &Cursor) -> Result<(Vec, Cursor), RemoteError> { + Err(RemoteError::Unsupported("a folder keeps no change feed")) + } + + async fn get(&self, id: &RemoteId, range: Option>) -> Result, RemoteError> { + let (local, materialised) = self.locate_id(id)?; + if !materialised { + // The one byte in the stub is not the file. Returning it produced + // a sidecar that parsed as empty and a thumbnail that never + // decoded; reporting `NotFound` made the sidecar writer treat an + // existing document as absent and overwrite it. + return Err(RemoteError::NotMaterialised(local.display().to_string())); + } + blocking(move || { + let what = local.display().to_string(); + let mut file = std::fs::File::open(&local).map_err(|e| map_io(e, &what))?; + + let Some(r) = range else { + let mut buf = Vec::new(); + file.read_to_end(&mut buf).map_err(|e| map_io(e, &what))?; + return Ok(buf); + }; + + // A short read at the end of the file is not an error: the header + // extractor asks for a fixed window and the file may be smaller + // than it, which is the ordinary case for a small JPEG. + file.seek(SeekFrom::Start(r.start)) + .map_err(|e| map_io(e, &what))?; + let want = r.end.saturating_sub(r.start); + let mut buf = Vec::new(); + file.take(want) + .read_to_end(&mut buf) + .map_err(|e| map_io(e, &what))?; + Ok(buf) + }) + .await + } + + async fn put( + &self, + path: &RemotePath, + body: Vec, + precond: Option, + ) -> Result { + let (found, materialised) = self.locate(path)?; + // Where the content belongs, which is not where a placeholder for it + // sits — suffix-mode VFS gives the two different names. + let local = self.resolve(path)?; + + // A stub is still this file, so what to do about it depends entirely + // on what the caller is promising. + let replaces = if materialised { + None + } else { + match &precond { + // Nothing here can satisfy it: the validator on a placeholder + // describes the placeholder. The caller fetches the content + // and tries again, which is what the typed error asks for. + Some(Precondition::IfMatch(_)) => { + return Err(RemoteError::NotMaterialised(found.display().to_string())) + } + // Something *is* there — the file exists, only its content is + // elsewhere — so a create-if-absent must fail. + Some(Precondition::IfAbsent) => return Err(RemoteError::PreconditionFailed), + // An unconditional write replaces the whole file, so there is + // nothing in the stub worth reading and no reason to download + // it first. Refusing here instead was a mistake: derived state + // lives in the library folder and the client dehydrates it + // like anything else, so a refusal meant sync could never + // write to a folder it had been away from. + None => Some(found), + } + }; + + blocking(move || { + let what = local.display().to_string(); + if let Some(parent) = local.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| map_io(e, &parent.display().to_string()))?; + } + + match &precond { + // Genuinely atomic: `O_CREAT | O_EXCL` is one syscall, so two + // devices racing to create a sidecar cannot both win. + Some(Precondition::IfAbsent) => { + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&local) + .map_err(|e| map_io(e, &what))?; + f.write_all(&body).map_err(|e| map_io(e, &what))?; + f.sync_all().map_err(|e| map_io(e, &what))?; + let meta = f.metadata().map_err(|e| map_io(e, &what))?; + return Ok(validator_of(&meta)); + } + // Compare, then swap. A POSIX filesystem has no compare-and- + // swap, so this narrows the window to the microseconds between + // the `stat` and the `rename` rather than closing it. That is + // still far tighter than the fallback the engine uses when a + // backend declares no conditional write at all — comparing + // revision counters *inside* the sidecar, which spans a whole + // read-modify-write — which is why the capability is declared + // rather than refused. + Some(Precondition::IfMatch(expected)) => { + let meta = std::fs::metadata(&local).map_err(|e| map_io(e, &what))?; + if &validator_of(&meta) != expected { + return Err(RemoteError::PreconditionFailed); + } + } + None => {} + } + + // Write beside the destination and rename over it, so a reader + // never sees a half-written sidecar and an interrupted write + // cannot destroy the file it was replacing. Beside, not in + // `/tmp`: a rename across filesystems is not atomic, and on + // Android `/tmp` is a different one. + let tmp = local.with_extension(format!( + "{}.darkroom-tmp", + local.extension().and_then(|e| e.to_str()).unwrap_or("") + )); + let write = (|| -> Result<(), RemoteError> { + let mut f = std::fs::File::create(&tmp).map_err(|e| map_io(e, &what))?; + f.write_all(&body).map_err(|e| map_io(e, &what))?; + f.sync_all().map_err(|e| map_io(e, &what)) + })(); + if let Err(e) = write { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + if let Err(e) = std::fs::rename(&tmp, &local) { + let _ = std::fs::remove_file(&tmp); + return Err(map_io(e, &what)); + } + + // The stub goes only once the content is safely in place. The + // other order risks leaving neither, and in a synced tree an + // absence is a deletion the client would propagate. + if let Some(stub) = replaces { + if let Err(e) = std::fs::remove_file(&stub) { + // The content landed, so the write succeeded; a leftover + // placeholder beside it is untidy rather than harmful, and + // the client reconciles the pair on its next pass. + log::warn!("removing placeholder {}: {e}", stub.display()); + } + } + + let meta = std::fs::metadata(&local).map_err(|e| map_io(e, &what))?; + Ok(validator_of(&meta)) + }) + .await + } + + /// Delete a file, or an empty directory. + /// + /// **Not recursive, unlike WebDAV's `DELETE` on a collection.** The + /// divergence is deliberate: a folder library is the user's own + /// photographs on their own disk, with no server-side trash behind it, so + /// a caller that passed the wrong path would have no way back. Nothing in + /// the engine deletes a directory — the soft delete is a + /// [`move_to`](RemoteBackend::move_to) into the trash folder — so refusing + /// costs nothing and the guard is free. + async fn delete( + &self, + id: &RemoteId, + precond: Option, + ) -> Result<(), RemoteError> { + // Deliberately by whichever name is on disk: deleting a photograph + // means deleting it whether or not its content happens to be here, and + // a stub left behind would be re-listed by the next scan. + let (local, _) = self.locate_id(id)?; + blocking(move || { + let what = local.display().to_string(); + let meta = std::fs::symlink_metadata(&local).map_err(|e| map_io(e, &what))?; + + match &precond { + Some(Precondition::IfMatch(expected)) => { + if &validator_of(&meta) != expected { + return Err(RemoteError::PreconditionFailed); + } + } + // "Delete only if nothing is there" is not a thing to ask of a + // delete; something is there or the `stat` above already + // failed. + Some(Precondition::IfAbsent) => { + return Err(RemoteError::Unsupported( + "IfAbsent is not meaningful on a delete", + )) + } + None => {} + } + + if meta.is_dir() { + std::fs::remove_dir(&local).map_err(|e| { + if e.kind() == std::io::ErrorKind::DirectoryNotEmpty { + RemoteError::Configuration(format!( + "{what} is not empty; a folder library will not delete a tree" + )) + } else { + map_io(e, &what) + } + }) + } else { + std::fs::remove_file(&local).map_err(|e| map_io(e, &what)) + } + }) + .await + } + + async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError> { + // Move whichever name exists. Trashing a photograph that is not + // downloaded is a perfectly ordinary thing to do, and it must move the + // stub — renaming a placeholder keeps it a placeholder. + let (src, materialised) = self.locate_id(from)?; + let dst = if materialised { + self.resolve(to)? + } else { + // The destination keeps the placeholder suffix, or the client + // would see a one-byte file appear where a photograph should be. + self.resolve(&RemotePath::new( + self.vfs.placeholder_name(to.as_str()).into_owned(), + ))? + }; + blocking(move || { + let what = dst.display().to_string(); + // Parents first: the trash folder does not exist until the first + // photograph is trashed, and the trait promises this creates it. + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| map_io(e, &parent.display().to_string()))?; + } + + match std::fs::rename(&src, &dst) { + Ok(()) => Ok(()), + // EXDEV. Both paths are inside one library root, so this + // needs a root that spans a mount point — a shoot folder + // that is its own mount, which is an ordinary way to attach + // an archive drive. Copy and unlink rather than refusing: + // the identity a rename would have preserved is a path hash + // here, and it changes either way. + Err(e) if e.raw_os_error() == Some(18) => { + std::fs::copy(&src, &dst).map_err(|e| map_io(e, &what))?; + std::fs::remove_file(&src).map_err(|e| { + // The copy landed. Leaving the original is a + // duplicate, which the next scan will show; losing + // the copy would be worse. + let _ = std::fs::remove_file(&dst); + map_io(e, &src.display().to_string()) + }) + } + Err(e) => Err(map_io(e, &what)), + } + }) + .await + } + + /// TRACES: FR-NC-6c + /// Ask the sync client to download a placeholder, and wait for it. + /// + /// Suffix-mode VFS *renames* on hydration, so completion is the + /// materialised path appearing — not the stub changing size. Polling the + /// original would wait forever. + async fn materialise(&self, id: &RemoteId) -> Result { + let (local, materialised) = self.locate_id(id)?; + if materialised { + // Already here. Not an error, and not a reason to ask again — and + // `false` is what tells a borrower to leave it alone afterwards. + return Ok(false); + } + let vfs = self.vfs.clone(); + let target = self.resolve_id(id)?; + blocking(move || { + vfs.materialise(&local)?; + + // The client acknowledges the command, not the transfer, so this + // waits for the file to appear. A bounded wait: a hydration that + // has not landed in this long is one the caller should be told + // about rather than blocked on for ever — the pass can come back + // to it. + let deadline = std::time::Instant::now() + MATERIALISE_TIMEOUT; + while std::time::Instant::now() < deadline { + if target.is_file() { + return Ok(true); + } + std::thread::sleep(POLL); + } + Err(RemoteError::Network(format!( + "{} did not download within {}s", + target.display(), + MATERIALISE_TIMEOUT.as_secs() + ))) + }) + .await + } + + /// TRACES: FR-NC-6c + /// Hand the content back, leaving a placeholder. + /// + /// **Never a delete.** In a synced tree removing the file propagates the + /// removal to the server; the client is asked to dehydrate, and if it + /// cannot the content simply stays. + async fn dematerialise(&self, id: &RemoteId) -> Result<(), RemoteError> { + let (local, materialised) = self.locate_id(id)?; + if !materialised { + return Ok(()); + } + let vfs = self.vfs.clone(); + blocking(move || vfs.dematerialise(&local)).await + } + + async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError> { + let local = self.resolve(path)?; + blocking(move || { + // `create_dir_all` makes parents and succeeds on one that already + // exists, which is exactly the contract. + std::fs::create_dir_all(&local).map_err(|e| map_io(e, &local.display().to_string())) + }) + .await + } +} + +#[cfg(test)] +mod tests; diff --git a/core/dr-sync-folder/src/tests.rs b/core/dr-sync-folder/src/tests.rs new file mode 100644 index 0000000..606cdf1 --- /dev/null +++ b/core/dr-sync-folder/src/tests.rs @@ -0,0 +1,741 @@ +//! Behaviour of the folder connector, against real directories. +//! +//! No mocks: the whole point of this backend is what a filesystem actually +//! does, and a double would only assert what this file assumes. + +use super::*; +use dr_sync::{scan, RemoteBackend}; +use dr_types::FormatFilter; +use std::collections::HashMap; + +/// A throwaway library root. +/// +/// Under the system temp directory, named for the test, and cleared first so a +/// crashed run cannot leave state that makes the next one pass. +struct Tmp(PathBuf); + +impl Tmp { + fn new(name: &str) -> Self { + let d = std::env::temp_dir().join(format!("dr-folder-test-{name}")); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Tmp(d) + } + + fn file(&self, rel: &str, body: &[u8]) -> &Self { + let p = self.0.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, body).unwrap(); + self + } + + fn backend(&self) -> FolderBackend { + FolderBackend::new(&self.0).unwrap() + } +} + +impl Drop for Tmp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn names(entries: &[RemoteEntry]) -> Vec { + let mut v: Vec = entries.iter().map(|e| e.path.name().to_string()).collect(); + v.sort(); + v +} + +// --- opening -------------------------------------------------------------- + +#[test] +fn a_missing_folder_is_a_configuration_error_not_a_network_one() { + // It must not put the app into offline mode: nothing was unreachable, the + // account names somewhere that is not a folder. + let err = FolderBackend::new("/definitely/not/here").unwrap_err(); + assert!(matches!(err, RemoteError::Configuration(_)), "{err:?}"); + assert!(!err.indicates_offline()); +} + +// --- listing -------------------------------------------------------------- + +#[tokio::test] +async fn listing_reports_files_and_directories() { + let t = Tmp::new("list"); + t.file("a.CR2", b"raw").file("sub/b.CR2", b"raw"); + let b = t.backend(); + + let root = b.list(&RemotePath::root(), None).await.unwrap(); + assert_eq!(names(&root), vec!["a.CR2", "sub"]); + + let kinds: HashMap<_, _> = root + .iter() + .map(|e| (e.path.name().to_string(), e.kind)) + .collect(); + assert_eq!(kinds["a.CR2"], EntryKind::File); + assert_eq!(kinds["sub"], EntryKind::Directory); + + let sub = b.list(&RemotePath::new("sub"), None).await.unwrap(); + assert_eq!(names(&sub), vec!["b.CR2"]); + // Paths are rooted at the library, not at the filesystem. + assert_eq!(sub[0].path.as_str(), "sub/b.CR2"); +} + +#[tokio::test] +async fn a_listing_carries_the_size_a_scan_needs() { + let t = Tmp::new("size"); + t.file("a.CR2", &[7u8; 1234]); + let e = &t.backend().list(&RemotePath::root(), None).await.unwrap()[0]; + assert_eq!(e.size, 1234); + assert!(e.modified.is_some()); + // Nothing behind a folder renders anything. + assert!(!e.has_preview); +} + +#[tokio::test] +async fn listing_a_missing_directory_is_not_found() { + let t = Tmp::new("missing"); + let e = t + .backend() + .list(&RemotePath::new("nope"), None) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::NotFound(_)), "{e:?}"); +} + +// --- identity and validators --------------------------------------------- + +#[tokio::test] +async fn identity_is_stable_across_an_edit_but_not_across_a_rename() { + // The catalog keys thumbnails and faces on this id, so editing a file must + // not orphan its thumbnail. A rename is a different photograph as far as + // this backend can tell, which `Capabilities::stable_ids` reports. + let t = Tmp::new("identity"); + t.file("a.CR2", b"one"); + let b = t.backend(); + + let before = b.list(&RemotePath::root(), None).await.unwrap()[0] + .id + .clone(); + t.file("a.CR2", b"two-different-length"); + let after = b.list(&RemotePath::root(), None).await.unwrap()[0] + .id + .clone(); + assert_eq!(before, after, "an edit is not a new photograph"); + + std::fs::rename(t.0.join("a.CR2"), t.0.join("b.CR2")).unwrap(); + let renamed = b.list(&RemotePath::root(), None).await.unwrap()[0] + .id + .clone(); + assert_ne!(before, renamed); + assert!(!b.capabilities().stable_ids, "and the capability says so"); +} + +#[tokio::test] +async fn two_libraries_agree_on_the_identity_of_the_same_photograph() { + // Two devices mounting one share must key the thumbnail index the same + // way, or each re-derives what the other already stored. This is why the + // id is a path hash and not an inode. + let a = Tmp::new("id-a"); + let b = Tmp::new("id-b"); + a.file("2026/x.CR2", b"one"); + b.file("2026/x.CR2", b"quite different bytes"); + + let ida = a + .backend() + .list(&RemotePath::new("2026"), None) + .await + .unwrap()[0] + .id + .clone(); + let idb = b + .backend() + .list(&RemotePath::new("2026"), None) + .await + .unwrap()[0] + .id + .clone(); + assert_eq!(ida, idb); +} + +#[tokio::test] +async fn a_validator_changes_when_the_content_does() { + let t = Tmp::new("validator"); + t.file("a.CR2", b"one"); + let b = t.backend(); + let before = b.list(&RemotePath::root(), None).await.unwrap()[0] + .validator + .clone(); + + // A different length, so this holds on a filesystem with one-second mtime + // granularity as well as on one with nanoseconds. + t.file("a.CR2", b"a rather longer body"); + let after = b.list(&RemotePath::root(), None).await.unwrap()[0] + .validator + .clone(); + assert_ne!(before, after); +} + +#[tokio::test] +async fn a_folder_does_not_pretend_to_prune() { + // Answering with the directory's own mtime would let a caller skip a + // subtree whose files had been edited, hiding those edits indefinitely. + let t = Tmp::new("prune"); + let b = t.backend(); + assert!(matches!( + b.dir_validator(&RemotePath::root()).await, + Err(RemoteError::Unsupported(_)) + )); + assert_eq!( + b.capabilities().change_detection, + ChangeDetection::LocalEtags + ); +} + +// --- reading -------------------------------------------------------------- + +#[tokio::test] +async fn a_whole_file_and_a_range_both_read() { + let t = Tmp::new("get"); + t.file("a.CR2", b"0123456789"); + let b = t.backend(); + let id = RemoteId::Path(RemotePath::new("a.CR2")); + + assert_eq!(b.get(&id, None).await.unwrap(), b"0123456789"); + assert_eq!(b.get(&id, Some(2..5)).await.unwrap(), b"234"); +} + +#[tokio::test] +async fn a_range_past_the_end_returns_what_is_there() { + // The header extractor asks for a fixed window; a small JPEG is shorter + // than it, and failing would make every small file undatable. + let t = Tmp::new("shortrange"); + t.file("a.JPG", b"abc"); + let got = t + .backend() + .get(&RemoteId::Path(RemotePath::new("a.JPG")), Some(0..65536)) + .await + .unwrap(); + assert_eq!(got, b"abc"); +} + +#[tokio::test] +async fn a_bare_identity_cannot_address_a_file() { + // Same contract as the Nextcloud connector: the id says *which* + // photograph, the path says *where*. Callers hold both. + let t = Tmp::new("byid"); + t.file("a.CR2", b"x"); + let e = t + .backend() + .get(&RemoteId::Stable(1), None) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::Unsupported(_)), "{e:?}"); +} + +#[tokio::test] +async fn nothing_reachable_from_a_remote_path_escapes_the_library() { + // A `RemotePath` is built from names on the remote and from a catalog + // another device wrote. Resolving one against a real filesystem with the + // user's own permissions makes `..` a read of anything they own. + let t = Tmp::new("escape"); + let b = t.backend(); + for attempt in ["../../../etc/passwd", "sub/../../outside"] { + let e = b + .get(&RemoteId::Path(RemotePath::new(attempt)), None) + .await + .unwrap_err(); + assert!( + matches!(e, RemoteError::Configuration(_)), + "{attempt} was not refused: {e:?}" + ); + } +} + +// --- writing -------------------------------------------------------------- + +#[tokio::test] +async fn a_write_creates_the_folders_it_needs() { + let t = Tmp::new("put"); + let b = t.backend(); + b.put(&RemotePath::new("2026/03/a.xmp"), b"".to_vec(), None) + .await + .unwrap(); + assert_eq!(std::fs::read(t.0.join("2026/03/a.xmp")).unwrap(), b""); +} + +#[tokio::test] +async fn a_write_leaves_no_temporary_behind() { + // The rename-into-place is invisible from outside, and must stay that way: + // a stray `.darkroom-tmp` in a shoot folder would be listed by the scan. + let t = Tmp::new("puttmp"); + let b = t.backend(); + b.put(&RemotePath::new("a.xmp"), b"x".to_vec(), None) + .await + .unwrap(); + assert_eq!( + names(&b.list(&RemotePath::root(), None).await.unwrap()), + vec!["a.xmp"] + ); +} + +#[tokio::test] +async fn an_overwrite_replaces_rather_than_appends() { + let t = Tmp::new("overwrite"); + t.file("a.xmp", b"the older and much longer body"); + let b = t.backend(); + b.put(&RemotePath::new("a.xmp"), b"new".to_vec(), None) + .await + .unwrap(); + assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"new"); +} + +#[tokio::test] +async fn if_absent_creates_once_and_refuses_after() { + let t = Tmp::new("ifabsent"); + let b = t.backend(); + let p = RemotePath::new("a.xmp"); + + b.put(&p, b"first".to_vec(), Some(Precondition::IfAbsent)) + .await + .unwrap(); + let e = b + .put(&p, b"second".to_vec(), Some(Precondition::IfAbsent)) + .await + .unwrap_err(); + + assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}"); + assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"first"); +} + +#[tokio::test] +async fn if_match_writes_on_the_expected_version_and_refuses_a_stale_one() { + // The sidecar conflict path (ARCH §8.5): a failure here means another + // device wrote first, and triggers a merge rather than an overwrite. + let t = Tmp::new("ifmatch"); + t.file("a.xmp", b"one"); + let b = t.backend(); + let p = RemotePath::new("a.xmp"); + + let current = b.list(&RemotePath::root(), None).await.unwrap()[0] + .validator + .clone(); + let after = b + .put( + &p, + b"two".to_vec(), + Some(Precondition::IfMatch(current.clone())), + ) + .await + .unwrap(); + assert_ne!(after, current); + + let e = b + .put(&p, b"three".to_vec(), Some(Precondition::IfMatch(current))) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}"); + assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"two"); +} + +#[tokio::test] +async fn the_validator_a_write_returns_is_the_one_a_listing_reports() { + // Otherwise the next conditional write fails against a file nobody else + // touched, and every sidecar update becomes a spurious conflict. + let t = Tmp::new("putvalidator"); + let b = t.backend(); + let p = RemotePath::new("a.xmp"); + let written = b.put(&p, b"body".to_vec(), None).await.unwrap(); + let listed = b.list(&RemotePath::root(), None).await.unwrap()[0] + .validator + .clone(); + assert_eq!(written, listed); +} + +// --- moving and deleting -------------------------------------------------- + +#[tokio::test] +async fn a_move_creates_the_trash_folder_it_needs() { + // The soft delete (FR-CAT-15): the trash does not exist until the first + // photograph goes into it, and the trait promises the move makes it. + let t = Tmp::new("move"); + t.file("a.CR2", b"raw"); + let b = t.backend(); + + b.move_to( + &RemoteId::Path(RemotePath::new("a.CR2")), + &RemotePath::new(".darkroom-trash/a.CR2"), + ) + .await + .unwrap(); + + assert!(!t.0.join("a.CR2").exists()); + assert_eq!( + std::fs::read(t.0.join(".darkroom-trash/a.CR2")).unwrap(), + b"raw" + ); +} + +#[tokio::test] +async fn deleting_a_file_removes_it() { + let t = Tmp::new("delete"); + t.file("a.CR2", b"raw"); + let b = t.backend(); + b.delete(&RemoteId::Path(RemotePath::new("a.CR2")), None) + .await + .unwrap(); + assert!(!t.0.join("a.CR2").exists()); +} + +#[tokio::test] +async fn deleting_refuses_to_take_a_tree_with_it() { + // Deliberately unlike WebDAV. There is no server-side trash behind a local + // folder, so a caller with a wrong path would have no way back. + let t = Tmp::new("deletetree"); + t.file("shoot/a.CR2", b"raw"); + let e = t + .backend() + .delete(&RemoteId::Path(RemotePath::new("shoot")), None) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::Configuration(_)), "{e:?}"); + assert!(t.0.join("shoot/a.CR2").exists()); +} + +#[tokio::test] +async fn a_conditional_delete_refuses_a_file_that_changed() { + let t = Tmp::new("deletecond"); + t.file("a.CR2", b"raw"); + let b = t.backend(); + let stale = Validator::new("0-0.0"); + let e = b + .delete( + &RemoteId::Path(RemotePath::new("a.CR2")), + Some(Precondition::IfMatch(stale)), + ) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}"); + assert!(t.0.join("a.CR2").exists()); +} + +#[tokio::test] +async fn creating_a_directory_twice_succeeds() { + // Callers use this to guarantee a destination, not to claim they made it. + let t = Tmp::new("mkdir"); + let b = t.backend(); + let p = RemotePath::new("2026/03"); + b.create_dir(&p).await.unwrap(); + b.create_dir(&p).await.unwrap(); + assert!(t.0.join("2026/03").is_dir()); +} + +// --- driven by the engine ------------------------------------------------- + +#[tokio::test] +async fn the_scan_engine_walks_a_folder_library() { + // The claim this whole crate makes: the engine written for one backend + // drives another with no change. Nothing below is folder-specific. + let t = Tmp::new("scan"); + t.file("2026/03/a.CR2", b"raw") + .file("2026/03/b.JPG", b"jpeg") + .file("2026/04/c.CR2", b"raw") + .file("2026/notes.txt", b"text") + .file(".darkroom-trash/deleted.CR2", b"raw"); + + let result = scan( + &t.backend(), + &RemotePath::root(), + &FormatFilter::from_formats([dr_types::Format::Cr2]), + &HashMap::new(), + |_| {}, + ) + .await + .unwrap(); + + let found: Vec<&str> = result.images.iter().map(|e| e.path.as_str()).collect(); + // The filter picked the RAWs; the trash was skipped, or the soft delete + // would undo itself on the next scan. + assert_eq!(found, vec!["2026/03/a.CR2", "2026/04/c.CR2"]); + assert_eq!(result.progress.directories_pruned, 0, "nothing to prune"); +} + +#[tokio::test] +async fn an_upload_lands_where_the_engine_places_it() { + let t = Tmp::new("upload"); + let b = t.backend(); + let placed = dr_sync::upload_original( + &b, + &RemotePath::root(), + &["2026".to_string(), "03".to_string()], + "a.CR2", + b"raw".to_vec(), + ) + .await + .unwrap(); + assert_eq!(placed.path().as_str(), "2026/03/a.CR2"); + assert_eq!(std::fs::read(t.0.join("2026/03/a.CR2")).unwrap(), b"raw"); +} + +// --- the provider --------------------------------------------------------- + +#[test] +fn an_endpoint_is_checked_before_an_account_is_written_for_it() { + let t = Tmp::new("provider"); + let p = FolderProvider::new(); + + assert!(p.normalise_endpoint(" ").is_err(), "empty"); + assert!(p.normalise_endpoint("Pictures").is_err(), "relative"); + assert!(p.normalise_endpoint("/no/such/place").is_err(), "missing"); + + t.file("a.CR2", b"x"); + assert!( + p.normalise_endpoint(&t.0.join("a.CR2").to_string_lossy()) + .is_err(), + "a file is not a library" + ); + + let ok = p.normalise_endpoint(&t.0.to_string_lossy()).unwrap(); + assert_eq!(PathBuf::from(&ok), t.0.canonicalize().unwrap()); +} + +#[test] +fn two_spellings_of_one_folder_become_one_account() { + // Otherwise the same photographs are indexed twice, into two catalogs. + let t = Tmp::new("canonical"); + t.file("sub/a.CR2", b"x"); + let p = FolderProvider::new(); + let direct = p + .normalise_endpoint(&t.0.join("sub").to_string_lossy()) + .unwrap(); + let roundabout = p + .normalise_endpoint(&t.0.join("sub/../sub").to_string_lossy()) + .unwrap(); + assert_eq!(direct, roundabout); +} + +#[test] +fn a_folder_account_needs_no_credential() { + let p = FolderProvider::new(); + assert_eq!(p.sign_in(), SignIn::EndpointOnly); + assert!(!p.sign_in().needs_secret()); + + let account = p.account_for("/mnt/photos").unwrap(); + assert_eq!(account.backend, BACKEND_ID); + assert_eq!(account.endpoint, "/mnt/photos"); + assert!(account.login.is_empty()); +} + +#[test] +fn the_registry_opens_a_folder_account() { + // End to end through the abstraction: an account, a registry, a backend — + // with nothing in between naming this crate. + let t = Tmp::new("registry"); + let mut registry = dr_sync::BackendRegistry::new(); + registry.register(std::sync::Arc::new(FolderProvider::new())); + + let account = Account::new(BACKEND_ID, t.0.to_string_lossy()); + let backend = registry.connect(&Connection::new(account, None)).unwrap(); + assert_eq!(backend.name(), "Folder"); +} + +// --- virtual filesystems -------------------------------------------------- +// +// A suffix-mode convention, matching the only one Linux supports. The +// behaviour under test is what the *backend* does with it; the borrow cycle +// has its own tests beside the pool. + +struct SuffixVfs; + +impl Vfs for SuffixVfs { + fn name(&self) -> &'static str { + "suffix" + } + fn is_placeholder(&self, on_disk: &str) -> bool { + on_disk.ends_with(".stub") + } + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { + on_disk.strip_suffix(".stub").unwrap_or(on_disk) + } + fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Owned(format!("{name}.stub")) + } +} + +fn with_stubs(t: &Tmp) -> FolderBackend { + FolderBackend::with_vfs(&t.0, std::sync::Arc::new(SuffixVfs)).unwrap() +} + +#[tokio::test] +async fn a_placeholder_is_listed_under_the_photographs_own_name() { + // The catalog records this as `source_ref`, and identity is derived from + // it. Reporting the stub's name gives the same photograph two identities + // and a name no other device recognises. + let t = Tmp::new("vfs-name"); + t.file("shoot/IMG_0001.CR2.stub", &[0u8]); + let b = with_stubs(&t); + + let entries = b.list(&RemotePath::new("shoot"), None).await.unwrap(); + assert_eq!(entries[0].path.as_str(), "shoot/IMG_0001.CR2"); + assert!(!entries[0].materialised, "the content is not here"); + // One byte is not the photograph's size, and putting it in the catalog + // would claim a 30 MB RAW is a single byte. + assert_eq!(entries[0].size, 0, "unknown, not one"); +} + +#[tokio::test] +async fn identity_survives_a_download() { + // The failure this prevents: downloading a photograph looked like a + // delete and an add, which orphaned its thumbnail and its face rows. + let t = Tmp::new("vfs-identity"); + t.file("a.CR2.stub", &[0u8]); + let b = with_stubs(&t); + + let before = b.list(&RemotePath::root(), None).await.unwrap()[0] + .id + .clone(); + std::fs::remove_file(t.0.join("a.CR2.stub")).unwrap(); + std::fs::write(t.0.join("a.CR2"), vec![3u8; 4096]).unwrap(); + let after = b.list(&RemotePath::root(), None).await.unwrap()[0] + .id + .clone(); + + assert_eq!(before, after, "the same photograph throughout"); +} + +#[tokio::test] +async fn reading_a_placeholder_is_distinguishable_from_a_missing_file() { + // The distinction the sidecar writer depends on: "not here" is fetchable + // and "not found" means create a new one. Conflating them overwrites an + // existing sidecar with a fresh document. + let t = Tmp::new("vfs-read"); + t.file("a.drsc.stub", &[0u8]); + let b = with_stubs(&t); + + let stub = b + .get(&RemoteId::Path(RemotePath::new("a.drsc")), None) + .await + .unwrap_err(); + assert!(matches!(stub, RemoteError::NotMaterialised(_)), "{stub:?}"); + + let absent = b + .get(&RemoteId::Path(RemotePath::new("nothing.drsc")), None) + .await + .unwrap_err(); + assert!(matches!(absent, RemoteError::NotFound(_)), "{absent:?}"); + + // And emphatically not the stub's one byte, which is what made a + // dehydrated sidecar parse as an empty document. + assert!(!matches!(stub, RemoteError::NotFound(_))); +} + +#[tokio::test] +async fn an_unconditional_write_replaces_a_placeholder() { + // Derived state — shards, the catalog snapshot — lives in the library + // folder, so the client dehydrates it like anything else. Refusing here + // meant sync could never write to a folder it had been away from. The + // whole file is being replaced, so there is nothing in the stub to keep. + let t = Tmp::new("vfs-write"); + t.file("a.drsc.stub", &[0u8]); + let b = with_stubs(&t); + + b.put(&RemotePath::new("a.drsc"), b"".to_vec(), None) + .await + .unwrap(); + + assert_eq!(std::fs::read(t.0.join("a.drsc")).unwrap(), b""); + // And exactly one file for one document: a leftover stub beside it is a + // conflict the client would resolve in favour of whichever it saw last. + assert!(!t.0.join("a.drsc.stub").exists(), "placeholder left behind"); + assert_eq!( + names(&b.list(&RemotePath::root(), None).await.unwrap()), + vec!["a.drsc"] + ); +} + +#[tokio::test] +async fn a_conditional_write_over_a_placeholder_asks_for_the_content_first() { + // `IfMatch` guards a read-modify-write. A stub's validator describes the + // placeholder, not the document, so nothing here can satisfy it — and + // quietly writing anyway is how the other device's edits are lost. + let t = Tmp::new("vfs-write-cond"); + t.file("a.drsc.stub", &[0u8]); + let b = with_stubs(&t); + + let e = b + .put( + &RemotePath::new("a.drsc"), + b"".to_vec(), + Some(Precondition::IfMatch(Validator::new("whatever"))), + ) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::NotMaterialised(_)), "{e:?}"); + assert!(!t.0.join("a.drsc").exists(), "nothing written"); + + // And a create-if-absent fails, because the file *is* there — only its + // content is elsewhere. + let e = b + .put( + &RemotePath::new("a.drsc"), + b"".to_vec(), + Some(Precondition::IfAbsent), + ) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}"); +} + +#[tokio::test] +async fn trashing_a_photograph_that_is_not_downloaded_moves_the_placeholder() { + // Culling without downloading is the ordinary way to use a VFS library. + // The stub has to move, and has to stay a stub — leaving it behind means + // the next scan re-lists the image and undoes the delete. + let t = Tmp::new("vfs-trash"); + t.file("a.CR2.stub", &[0u8]); + let b = with_stubs(&t); + + b.move_to( + &RemoteId::Path(RemotePath::new("a.CR2")), + &RemotePath::new(".darkroom-trash/a.CR2"), + ) + .await + .unwrap(); + + assert!(!t.0.join("a.CR2.stub").exists()); + assert!( + t.0.join(".darkroom-trash/a.CR2.stub").is_file(), + "still a stub" + ); +} + +#[tokio::test] +async fn a_folder_without_a_client_still_lists_and_reads_what_is_there() { + // No hydration available is a degraded mode, not a broken one: the + // materialised half of the library works completely. + let t = Tmp::new("vfs-degraded"); + t.file("here.CR2", b"real").file("gone.CR2.stub", &[0u8]); + let b = with_stubs(&t); + + assert_eq!( + b.capabilities().materialisation, + dr_sync::Materialisation::Placeholders, + "stubs exist and nothing can fetch them" + ); + assert!(!b.capabilities().materialisation.can_materialise()); + + let got = b + .get(&RemoteId::Path(RemotePath::new("here.CR2")), None) + .await + .unwrap(); + assert_eq!(got, b"real"); +} + +#[test] +fn a_plain_folder_reports_that_everything_it_lists_is_readable() { + let t = Tmp::new("vfs-plain"); + assert_eq!( + t.backend().capabilities().materialisation, + dr_sync::Materialisation::Always + ); +} diff --git a/core/dr-sync-folder/src/vfs.rs b/core/dr-sync-folder/src/vfs.rs new file mode 100644 index 0000000..3e05e4d --- /dev/null +++ b/core/dr-sync-folder/src/vfs.rs @@ -0,0 +1,131 @@ +// TRACES: FR-NC-6c +//! Virtual-filesystem conventions layered over a directory. +//! +//! A sync client in virtual-files mode leaves a *placeholder* where a file is +//! catalogued but not downloaded. The folder is otherwise ordinary, so all of +//! [`FolderBackend`](crate::FolderBackend) applies — only three questions +//! differ, and they are the whole of this trait: what is a placeholder, what +//! is the photograph really called, and can the content be summoned. +//! +//! # Why this is not a separate backend +//! +//! It varies nothing about listing, reading, writing, moving or deleting — a +//! second connector would duplicate every one of those to change a name test. +//! More decisively, **the interesting capability is not a property of the +//! backend at all**: the same folder can materialise on demand while the sync +//! client is running and cannot when it is not, so it has to be computed per +//! connection either way. Registering a `folder-vfs` provider beside `folder` +//! would ask the user to choose between two things that differ by whether a +//! background process happens to be up. +//! +//! # Why the plain case is a `Vfs` too +//! +//! [`NoVfs`] answers "nothing is a placeholder" and refuses to materialise. +//! That keeps one code path through the backend rather than an `Option` tested +//! at every call site, and it is the shape a third convention — Dropbox, +//! OneDrive, macOS FileProvider — slots into. +//! +//! # What is *not* abstracted here +//! +//! Windows and macOS express placeholders in filesystem metadata rather than +//! in the name: a reparse point, or `st_blocks == 0` against a non-zero +//! `st_size`. That form needs a `Metadata` to answer, not a name, and the one +//! convention this project has met needs only a name. Widening the trait for a +//! platform nobody has run this on would be guessing at the shape. + +use std::borrow::Cow; +use std::path::Path; + +use dr_sync::RemoteError; + +/// A placeholder convention, and what can be done about it. +/// +/// Implementations are held behind an `Arc` and used from every worker +/// thread. +pub trait Vfs: Send + Sync { + /// A name for logs and the interface. "none", "Nextcloud". + fn name(&self) -> &'static str; + + /// Whether a name **on disk** stands for content that is not here. + fn is_placeholder(&self, on_disk: &str) -> bool; + + /// The photograph's own name, given whatever is on disk. + /// + /// This is what the catalog records and what identity is derived from, so + /// a file keeps one name and one id across being downloaded and released. + /// Reporting the on-disk name instead makes hydration look like a delete + /// and an add. + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str; + + /// What a placeholder for `name` would be called on disk. + fn placeholder_name(&self, name: &str) -> Cow<'_, str>; + + /// Whether content can actually be summoned right now. + /// + /// False where the mechanism is absent — the client is not running, the + /// platform has no socket — which is an ordinary state and not an error. + /// The backend reports [`Materialisation::Placeholders`] rather than + /// [`OnDemand`] when this is false. + /// + /// [`Materialisation::Placeholders`]: dr_sync::Materialisation::Placeholders + /// [`OnDemand`]: dr_sync::Materialisation::OnDemand + fn can_materialise(&self) -> bool { + false + } + + /// Ask for a placeholder's content. Whole-file and slow. + fn materialise(&self, _local: &Path) -> Result<(), RemoteError> { + Err(RemoteError::Unsupported("this folder has no VFS client")) + } + + /// Give the content back, leaving a placeholder. + /// + /// **Must not delete.** In a synced tree a deletion propagates to the + /// server and removes the photograph from every device. An implementation + /// that cannot dehydrate returns `Unsupported`. + fn dematerialise(&self, _local: &Path) -> Result<(), RemoteError> { + Err(RemoteError::Unsupported("this folder has no VFS client")) + } +} + +/// An ordinary directory: every file is what it appears to be. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoVfs; + +impl Vfs for NoVfs { + fn name(&self) -> &'static str { + "none" + } + fn is_placeholder(&self, _on_disk: &str) -> bool { + false + } + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { + on_disk + } + fn placeholder_name(&self, name: &str) -> Cow<'_, str> { + // Nothing is ever a placeholder here, so the only honest answer is + // the name itself — the backend will look for it, not find a second + // candidate, and report the file missing. + Cow::Owned(name.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plain_folder_has_no_placeholders_and_cannot_summon_anything() { + let v = NoVfs; + assert!( + !v.is_placeholder("IMG.CR2.nextcloud"), + "not this folder's convention" + ); + assert_eq!(v.real_name("IMG.CR2"), "IMG.CR2"); + assert!(!v.can_materialise()); + assert!(v.materialise(Path::new("/x")).is_err()); + // And it must refuse rather than approximate: deleting a file to + // "dehydrate" it would remove the photograph. + assert!(v.dematerialise(Path::new("/x")).is_err()); + } +} diff --git a/core/dr-sync-nextcloud/Cargo.toml b/core/dr-sync-nextcloud/Cargo.toml index d5c092e..fc8ecfc 100644 --- a/core/dr-sync-nextcloud/Cargo.toml +++ b/core/dr-sync-nextcloud/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true [dependencies] dr-types.workspace = true dr-sync.workspace = true +# For the VFS convention: the folder connector does the filesystem work, and +# this crate supplies the placeholder rules and the client socket. +dr-sync-folder.workspace = true dr-plat.workspace = true reqwest.workspace = true rustls.workspace = true diff --git a/core/dr-sync-nextcloud/examples/connect.rs b/core/dr-sync-nextcloud/examples/connect.rs index 91508c9..5a9749c 100644 --- a/core/dr-sync-nextcloud/examples/connect.rs +++ b/core/dr-sync-nextcloud/examples/connect.rs @@ -23,8 +23,9 @@ use std::collections::HashMap; use std::time::Instant; use dr_plat::PlatformSecretStore; +use dr_sync::{Account, AccountStore, Secret}; use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy}; -use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, Session, SessionStore}; +use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, NextcloudProvider}; #[tokio::main] async fn main() { @@ -57,17 +58,20 @@ async fn main() { // Sessions persist across runs: credentials in the platform keyring // (FR-NC-2), everything else as ordinary config. - let sessions = SessionStore::open(Box::new(PlatformSecretStore::new())); + let sessions = AccountStore::open(Box::new(PlatformSecretStore::new())); if !sessions.can_remember() { println!("note: no secrets daemon — sign-in will not persist this session"); } let existing = sessions .current() - .filter(|s| s.server == server.trim_end_matches('/')); + .filter(|s| s.endpoint == server.trim_end_matches('/')); let (session, creds) = match existing { - Some(s) => match sessions.credentials(&s) { + Some(s) => match sessions + .connection(&s, true) + .and_then(|c| Ok(NextcloudProvider::credentials(&c)?)) + { Ok(c) => { println!("signed in: {}", s.describe()); (s, c) @@ -235,7 +239,7 @@ fn describe_filter(f: &dr_types::FormatFilter) -> String { } /// Run Login Flow v2 and persist the result. -async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredentials) { +async fn sign_in(server: &str, sessions: &AccountStore) -> (Account, AppCredentials) { let client = match dr_sync_nextcloud::http_client("DarkRoom") { Ok(c) => c, Err(e) => { @@ -270,8 +274,8 @@ async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredenti creds.login_name.clone() }); - let session = Session::new(&creds, user_id); - match sessions.save(&session, &creds) { + let session = NextcloudProvider::account_from(&creds, user_id); + match sessions.save(&session, Some(&Secret::new(&creds.app_password))) { Ok(()) => println!(" session saved to {}", sessions.config_path().display()), Err(e) => eprintln!(" could not persist session: {e}"), } diff --git a/core/dr-sync-nextcloud/examples/put_probe.rs b/core/dr-sync-nextcloud/examples/put_probe.rs index 57769fd..d5d289b 100644 --- a/core/dr-sync-nextcloud/examples/put_probe.rs +++ b/core/dr-sync-nextcloud/examples/put_probe.rs @@ -18,7 +18,8 @@ //! and deleted again, which tests creation and costs nothing. use dr_plat::PlatformSecretStore; -use dr_sync_nextcloud::session::SessionStore; +use dr_sync::AccountStore; +use dr_sync_nextcloud::NextcloudProvider; #[tokio::main(flavor = "current_thread")] async fn main() { @@ -30,15 +31,19 @@ async fn main() { std::process::exit(2); }; - let sessions = SessionStore::open(Box::new(PlatformSecretStore::new())); + let sessions = AccountStore::open(Box::new(PlatformSecretStore::new())); let Some(session) = sessions .current() - .filter(|s| s.server == server.trim_end_matches('/')) + .filter(|s| s.endpoint == server.trim_end_matches('/')) else { eprintln!("no stored session for {server}"); std::process::exit(1); }; - let creds = match sessions.credentials(&session) { + let creds = match sessions + .connection(&session, true) + .map_err(|e| e.to_string()) + .and_then(|c| NextcloudProvider::credentials(&c).map_err(|e| e.to_string())) + { Ok(c) => c, Err(e) => { eprintln!("credentials: {e}"); @@ -48,7 +53,7 @@ async fn main() { let url = format!( "{}/remote.php/dav/files/{}/{}", - session.server.trim_end_matches('/'), + session.endpoint.trim_end_matches('/'), session.user_id, path ); diff --git a/core/dr-sync-nextcloud/examples/writetest.rs b/core/dr-sync-nextcloud/examples/writetest.rs index 75f2e0a..4f826ac 100644 --- a/core/dr-sync-nextcloud/examples/writetest.rs +++ b/core/dr-sync-nextcloud/examples/writetest.rs @@ -1,18 +1,22 @@ //! One-shot write probe: PUT a tiny file, report the status, DELETE it. use dr_plat::PlatformSecretStore; -use dr_sync::{RemoteBackend, RemoteId, RemotePath}; -use dr_sync_nextcloud::{NextcloudBackend, SessionStore}; +use dr_sync::{AccountStore, RemoteBackend, RemoteId, RemotePath}; +use dr_sync_nextcloud::{NextcloudBackend, NextcloudProvider}; #[tokio::main(flavor = "current_thread")] async fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - let store = SessionStore::open(Box::new(PlatformSecretStore::new())); + let store = AccountStore::open(Box::new(PlatformSecretStore::new())); let Some(session) = store.current() else { println!("no stored session"); return; }; - let creds = match store.credentials(&session) { + let creds = match store + .connection(&session, true) + .map_err(|e| e.to_string()) + .and_then(|c| NextcloudProvider::credentials(&c).map_err(|e| e.to_string())) + { Ok(c) => c, Err(e) => { println!("credentials: {e}"); diff --git a/core/dr-sync-nextcloud/src/desktop_client.rs b/core/dr-sync-nextcloud/src/desktop_client.rs index 1617892..82b37e5 100644 --- a/core/dr-sync-nextcloud/src/desktop_client.rs +++ b/core/dr-sync-nextcloud/src/desktop_client.rs @@ -163,3 +163,138 @@ mod tests { let _ = DesktopClient::detect(); } } + +/// TRACES: FR-NC-6c +/// The desktop client's placeholder convention, as a +/// [`Vfs`](dr_sync_folder::Vfs). +/// +/// This is what turns a folder the client syncs into a library DarkRoom can +/// open: the folder connector handles every filesystem operation, and this +/// answers the three questions it cannot — what is a stub, what is the +/// photograph called, and can the content be fetched and given back. +/// +/// **Linux suffix mode only**, which is the only mode Linux supports +/// (ARCH §9.0). A dehydrated `IMG.CR2` exists solely as `IMG.CR2.nextcloud` +/// holding one byte. +pub struct NextcloudVfs { + /// `None` where no client is running. The folder still lists and reads + /// correctly; it simply cannot fetch what is not there, which the backend + /// reports as `Materialisation::Placeholders`. + client: Option, +} + +impl NextcloudVfs { + /// Attach to a running client, if there is one. + /// + /// Absence is the ordinary state — Android always, desktop whenever the + /// client is not running — and never an error. + pub fn detect() -> Self { + Self { + client: DesktopClient::detect(), + } + } + + /// Whether a directory looks like one this client syncs. + /// + /// Used to decide whether to apply this convention at all. Deliberately + /// cheap and deliberately not authoritative: the client's own database + /// would answer properly, but it is a private schema, and being wrong here + /// costs one extra `stat` per read rather than anything correctness + /// depends on. + pub fn looks_synced(root: &Path) -> bool { + std::fs::read_dir(root) + .map(|entries| { + entries.flatten().any(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + // The client's per-folder journal sits at the sync root, + // and a stub anywhere beneath it is equally conclusive. + name.starts_with("._sync_") && name.ends_with(".db") + || name.ends_with(dr_types::PLACEHOLDER_SUFFIX) + }) + }) + .unwrap_or(false) + } +} + +impl dr_sync_folder::Vfs for NextcloudVfs { + fn name(&self) -> &'static str { + "Nextcloud" + } + + fn is_placeholder(&self, on_disk: &str) -> bool { + on_disk.ends_with(dr_types::PLACEHOLDER_SUFFIX) + } + + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { + on_disk + .strip_suffix(dr_types::PLACEHOLDER_SUFFIX) + .unwrap_or(on_disk) + } + + fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Owned(format!("{name}{}", dr_types::PLACEHOLDER_SUFFIX)) + } + + fn can_materialise(&self) -> bool { + self.client.is_some() + } + + fn materialise(&self, local: &Path) -> Result<(), RemoteError> { + self.client + .as_ref() + .ok_or(RemoteError::Unsupported( + "no Nextcloud desktop client is running to fetch this", + ))? + .make_available_locally(local) + } + + fn dematerialise(&self, local: &Path) -> Result<(), RemoteError> { + self.client + .as_ref() + .ok_or(RemoteError::Unsupported( + "no Nextcloud desktop client is running to release this", + ))? + .make_online_only(local) + } +} + +#[cfg(test)] +mod vfs_tests { + use super::*; + use dr_sync_folder::Vfs as _; + + #[test] + fn a_stub_is_recognised_and_reports_the_photographs_name() { + let v = NextcloudVfs { client: None }; + assert!(v.is_placeholder("IMG_4130.CR2.nextcloud")); + assert!(!v.is_placeholder("IMG_4130.CR2")); + // The name the catalog records, so identity survives a download. + assert_eq!(v.real_name("IMG_4130.CR2.nextcloud"), "IMG_4130.CR2"); + assert_eq!(v.real_name("IMG_4130.CR2"), "IMG_4130.CR2"); + assert_eq!(v.placeholder_name("IMG_4130.CR2"), "IMG_4130.CR2.nextcloud"); + } + + #[test] + fn without_a_client_it_refuses_rather_than_pretending() { + // The folder still works; it just cannot fetch. Silently doing nothing + // would make a borrow think it had the content. + let v = NextcloudVfs { client: None }; + assert!(!v.can_materialise()); + assert!(v.materialise(Path::new("/x/a.CR2.nextcloud")).is_err()); + assert!(v.dematerialise(Path::new("/x/a.CR2")).is_err()); + } + + #[test] + fn an_ordinary_folder_is_not_mistaken_for_a_synced_one() { + let d = std::env::temp_dir().join("dr-vfs-detect"); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("a.CR2"), b"raw").unwrap(); + assert!(!NextcloudVfs::looks_synced(&d)); + + std::fs::write(d.join("b.CR2.nextcloud"), [0u8]).unwrap(); + assert!(NextcloudVfs::looks_synced(&d)); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index 7cda5e9..bfc30f7 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -1,4 +1,9 @@ -//! Nextcloud connector — the only [`RemoteBackend`] implementation. +//! Nextcloud connector. +//! +//! One of two [`RemoteBackend`] implementations, registered through +//! [`NextcloudProvider`]. What an *account* is no longer lives here — that is +//! [`dr_sync::Account`], which has no server in it — so this crate is the +//! protocol and nothing else. //! //! Hand-rolled over `reqwest` rather than built on a WebDAV crate (D7). No //! mature Nextcloud crate exists, and the operations that matter here are @@ -16,11 +21,11 @@ use dr_sync::{ pub mod auth; pub mod desktop_client; mod propfind; -pub mod session; +pub mod provider; pub use auth::{AppCredentials, LoginFlow}; -pub use desktop_client::DesktopClient; -pub use session::{Session, SessionError, SessionStore}; +pub use desktop_client::{DesktopClient, NextcloudVfs}; +pub use provider::NextcloudProvider; /// Chunk sizes Nextcloud's chunked upload v2 accepts. const CHUNKS: ChunkConstraints = ChunkConstraints { @@ -68,6 +73,10 @@ impl NextcloudBackend { // Stock Nextcloud ships no RAW preview provider (ARCH §6.7). // Probed per-account at setup and upgraded where present. server_previews: ServerPreviews::CommonFormatsOnly, + // The server answers for everything it lists. Placeholders + // belong to a locally *synced folder*, which is the folder + // connector's business (`desktop_client::NextcloudVfs`). + materialisation: dr_sync::Materialisation::Always, }, }) } diff --git a/core/dr-sync-nextcloud/src/propfind.rs b/core/dr-sync-nextcloud/src/propfind.rs index c4de552..488c49c 100644 --- a/core/dr-sync-nextcloud/src/propfind.rs +++ b/core/dr-sync-nextcloud/src/propfind.rs @@ -92,6 +92,9 @@ pub fn parse_multistatus(xml: &str, base: &str) -> Result, Remo size: r.content_length.unwrap_or(0), modified: r.last_modified.as_deref().and_then(parse_http_date), has_preview: r.has_preview, + // Everything WebDAV lists can be fetched; placeholders are a + // property of a locally synced folder, not of the server. + materialised: true, }); } Ok(out) diff --git a/core/dr-sync-nextcloud/src/provider.rs b/core/dr-sync-nextcloud/src/provider.rs new file mode 100644 index 0000000..c182935 --- /dev/null +++ b/core/dr-sync-nextcloud/src/provider.rs @@ -0,0 +1,174 @@ +// TRACES: FR-NC-12 | FR-NC-1 +//! Registering Nextcloud as a storage backend. +//! +//! The account model this connector used to own now lives in +//! [`dr_sync::account`], where it has no server in it. What is left here is +//! the part that genuinely is Nextcloud: an endpoint is an HTTPS URL, an +//! account is established through Login Flow v2, and the credential is an app +//! password. +//! +//! Nothing above `dr_ui::remote` refers to this type. + +use dr_sync::{ + Account, BackendProvider, Connection, RemoteBackend, RemoteError, SignIn, LEGACY_BACKEND, +}; + +use crate::{AppCredentials, NextcloudBackend}; + +/// The id written to [`Account::backend`] for a Nextcloud account. +/// +/// The same string [`dr_sync::LEGACY_BACKEND`] freezes, because every account +/// configured before there was a choice is one of these and must keep the +/// catalog directory it already has. +pub const BACKEND_ID: &str = LEGACY_BACKEND; + +/// Registers the Nextcloud connector. +pub struct NextcloudProvider; + +impl NextcloudProvider { + /// The account a completed login flow describes. + /// + /// `user_id` is the DAV path segment, which is not always the login name: + /// a login can be an email address while the user id is something else, + /// and building `/remote.php/dav/files//` from the wrong one 404s + /// every request. + pub fn account_from(creds: &AppCredentials, user_id: impl Into) -> Account { + Account::new(BACKEND_ID, creds.server.trim_end_matches('/')) + .with_login(creds.login_name.clone(), user_id) + } + + /// The credentials a stored account plus its secret amount to. + /// + /// [`AppCredentials`] stays the connector's own type rather than becoming + /// something general: an app password, an OAuth token and a bucket key + /// pair have no useful common shape, and inventing one would produce a + /// wrong answer confidently. The general form is [`Connection`]; this is + /// the translation into what one protocol needs. + pub fn credentials(conn: &Connection) -> Result { + Ok(AppCredentials { + server: conn.account.endpoint.clone(), + login_name: conn.account.login.clone(), + app_password: conn.require_secret()?.expose().to_string(), + }) + } +} + +impl BackendProvider for NextcloudProvider { + fn id(&self) -> &'static str { + BACKEND_ID + } + + fn display_name(&self) -> &'static str { + "Nextcloud" + } + + fn endpoint_label(&self) -> &'static str { + "Server" + } + + fn endpoint_placeholder(&self) -> &'static str { + "https://cloud.example.com" + } + + fn sign_in(&self) -> SignIn { + SignIn::Browser + } + + /// Normalise a server address typed by hand. + /// + /// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than + /// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS, + /// and an unencrypted default would be a security decision made on the + /// user's behalf without telling them. + fn normalise_endpoint(&self, input: &str) -> Result { + let s = input.trim().trim_end_matches('/'); + if s.is_empty() { + return Err("Enter the address of your Nextcloud server.".into()); + } + if s.starts_with("https://") { + Ok(s.to_string()) + } else if let Some(rest) = s.strip_prefix("http://") { + // Upgrade rather than accept. If the server genuinely has no TLS + // the connection fails loudly, which is the correct outcome. + Ok(format!("https://{rest}")) + } else { + Ok(format!("https://{s}")) + } + } + + fn connect(&self, conn: &Connection) -> Result, RemoteError> { + let creds = Self::credentials(conn)?; + Ok(Box::new(NextcloudBackend::new( + &creds, + &conn.account.user_id, + )?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn creds() -> AppCredentials { + AppCredentials { + server: "https://cloud.example/".into(), + login_name: "duncan@example.com".into(), + app_password: "token".into(), + } + } + + #[test] + fn an_address_typed_by_hand_becomes_an_https_url() { + let p = NextcloudProvider; + assert_eq!( + p.normalise_endpoint("cloud.example.com/").unwrap(), + "https://cloud.example.com" + ); + // Upgraded, never accepted: NFR-SEC-3. + assert_eq!( + p.normalise_endpoint("http://cloud.example.com").unwrap(), + "https://cloud.example.com" + ); + assert!(p.normalise_endpoint(" ").is_err()); + } + + #[test] + fn the_account_keeps_the_dav_user_id_apart_from_the_login() { + // A login can be an email address while the user id is something + // else; building the DAV path from the wrong one 404s everything. + let a = NextcloudProvider::account_from(&creds(), "duncan"); + assert_eq!(a.login, "duncan@example.com"); + assert_eq!(a.user_id, "duncan"); + assert_eq!(a.endpoint, "https://cloud.example"); + } + + #[test] + fn a_nextcloud_account_keeps_its_historical_catalog_directory() { + // Frozen: this names the directory holding the catalog, the thumbnail + // shards and un-uploaded sidecars. + let a = NextcloudProvider::account_from(&creds(), "duncan"); + assert_eq!(a.namespace(), "cloud-example-duncan"); + } + + #[test] + fn connecting_without_a_credential_is_unauthenticated_not_a_crash() { + // A cleared keyring or a revoked app password arrives here as an + // account with no secret. The caller re-runs the login flow. + let account = NextcloudProvider::account_from(&creds(), "duncan"); + match NextcloudProvider.connect(&Connection::new(account, None)) { + Err(RemoteError::Unauthenticated) => {} + Err(e) => panic!("wrong error: {e:?}"), + Ok(b) => panic!("connected without a credential as {}", b.name()), + } + } + + #[test] + fn a_stored_account_and_its_secret_rebuild_the_credentials() { + let account = NextcloudProvider::account_from(&creds(), "duncan"); + let conn = Connection::new(account, Some(dr_sync::Secret::new("token"))); + let rebuilt = NextcloudProvider::credentials(&conn).unwrap(); + assert_eq!(rebuilt.server, "https://cloud.example"); + assert_eq!(rebuilt.login_name, "duncan@example.com"); + assert_eq!(rebuilt.app_password, "token"); + } +} diff --git a/core/dr-sync-nextcloud/src/session.rs b/core/dr-sync-nextcloud/src/session.rs deleted file mode 100644 index 66105d6..0000000 --- a/core/dr-sync-nextcloud/src/session.rs +++ /dev/null @@ -1,451 +0,0 @@ -//! Account sessions — logging in once and staying logged in. -//! -//! Splits deliberately in two: -//! -//! - **Credentials** go to platform secure storage (FR-NC-2). Never the -//! catalog, never a file, never a log line. -//! - **Everything else** — server, login, chosen root, format filter — is -//! ordinary configuration, safe to write as plain JSON. -//! -//! That split is what lets the app show "signed in as duncan, watching -//! /PhotosRaw" before it has touched the keyring, and re-authenticate cleanly -//! if the credential has been revoked server-side. - -use std::path::{Path, PathBuf}; - -use dr_plat::{SecretError, SecretRef, SecretStore}; -use dr_sync::RemoteError; -use dr_types::{Format, FormatFilter}; -use serde::{Deserialize, Serialize}; - -use crate::AppCredentials; - -/// Where configuration is written, when the platform has told us. -/// -/// Android has no `$HOME` and no XDG directories, so the guess below resolves -/// to a path the app cannot write. Nothing failed loudly: the session list went -/// to a doomed path, so credentials survived only as long as the process did and -/// backgrounding the app lost the account (ARCH §6.9 — no core API may assume a -/// filesystem path on Android). -/// -/// The platform layer sets this once at startup, before any store is opened. -static DATA_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// TRACES: FR-NC-2 -/// Declare the per-app directory configuration belongs in. -/// -/// Call before opening any store; later calls are ignored rather than racing. -/// On Android this is `AndroidApp::internal_data_path`, which is private to the -/// app and survives being backgrounded. Desktop needs no call — the XDG -/// fallback is correct there. -pub fn set_data_dir(dir: PathBuf) { - let _ = DATA_DIR.set(dir); -} - -/// The directory configuration lives in. -fn config_dir() -> PathBuf { - if let Some(d) = DATA_DIR.get() { - return d.clone(); - } - std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")) - .join("darkroom") -} - -/// A configured account, minus its credential. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Session { - pub server: String, - pub login: String, - /// The DAV path segment, which may differ from `login` — a login can be - /// an email address while the user id is something else. - pub user_id: String, - /// The folder chosen as the library root. Empty means the account root. - #[serde(default)] - pub root: String, - /// Which formats the scan looks for (the tick-boxes). - #[serde(default)] - pub formats: Vec, - /// Unix seconds of the last completed scan, for display. - #[serde(default)] - pub last_scan: Option, -} - -impl Session { - pub fn new(creds: &AppCredentials, user_id: impl Into) -> Self { - Self { - server: creds.server.trim_end_matches('/').to_string(), - login: creds.login_name.clone(), - user_id: user_id.into(), - root: String::new(), - formats: Vec::new(), - last_scan: None, - } - } - - /// The stored format selection, defaulting to every supported format. - /// - /// An unconfigured session must find everything rather than nothing. - pub fn format_filter(&self) -> FormatFilter { - if self.formats.is_empty() { - FormatFilter::all() - } else { - FormatFilter::from_formats( - self.formats - .iter() - .filter_map(|s| Format::from_extension(&s.to_ascii_lowercase())), - ) - } - } - - pub fn set_format_filter(&mut self, filter: &FormatFilter) { - self.formats = filter - .iter() - .map(|f| format!("{f:?}").to_lowercase()) - .collect(); - } - - /// Where this session's credential lives. - pub fn secret_ref(&self) -> SecretRef { - SecretRef::app_password(&self.server, &self.login) - } - - /// A short description for the UI. - pub fn describe(&self) -> String { - let host = self - .server - .trim_start_matches("https://") - .trim_start_matches("http://"); - if self.root.is_empty() { - format!("{} on {host}", self.login) - } else { - format!("{} on {host}/{}", self.login, self.root) - } - } -} - -/// TRACES: FR-NC-1 | FR-NC-2 | M-1 | M-2 -/// Loads and saves sessions, keeping credentials in secure storage. -pub struct SessionStore { - config_path: PathBuf, - secrets: Box, -} - -/// What is written to disk. Versioned so a format change is a migration -/// rather than a parse failure. -#[derive(Debug, Default, Serialize, Deserialize)] -struct ConfigFile { - #[serde(default = "one")] - version: u32, - #[serde(default)] - sessions: Vec, -} - -fn one() -> u32 { - 1 -} - -impl SessionStore { - /// Open the store at the platform config location. - /// - /// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to - /// `~/.config` (FR-PLAT-LIN-1). - pub fn open(secrets: Box) -> Self { - Self::open_at(config_dir().join("sessions.json"), secrets) - } - - /// Open at an explicit path — used by tests, and by anything wanting a - /// non-default config location. - /// Where configuration lives, for callers that need to sit files beside it. - pub fn data_dir() -> PathBuf { - config_dir() - } - - pub fn open_at(config_path: PathBuf, secrets: Box) -> Self { - Self { - config_path, - secrets, - } - } - - pub fn config_path(&self) -> &Path { - &self.config_path - } - - /// Whether credentials can be remembered at all. - /// - /// Where false the UI should say sign-in will not persist, rather than - /// letting the user discover it next launch. - pub fn can_remember(&self) -> bool { - self.secrets.is_available() - } - - /// Every configured session. Missing or unreadable config yields an empty - /// list rather than an error — a first run is not a failure. - pub fn list(&self) -> Vec { - self.read_config().sessions - } - - /// The most recently configured session, if any. - pub fn current(&self) -> Option { - self.read_config().sessions.into_iter().next_back() - } - - /// Persist a session and its credential. - /// - /// The credential goes to secure storage first: if that fails there is no - /// point recording a session that cannot authenticate. - pub fn save(&self, session: &Session, creds: &AppCredentials) -> Result<(), SessionError> { - self.secrets - .store(&session.secret_ref(), &creds.app_password)?; - - let mut config = self.read_config(); - config - .sessions - .retain(|s| !(s.server == session.server && s.login == session.login)); - config.sessions.push(session.clone()); - self.write_config(&config) - } - - /// Update a session's settings, leaving its credential untouched. - pub fn update(&self, session: &Session) -> Result<(), SessionError> { - let mut config = self.read_config(); - match config - .sessions - .iter_mut() - .find(|s| s.server == session.server && s.login == session.login) - { - Some(existing) => *existing = session.clone(), - None => config.sessions.push(session.clone()), - } - self.write_config(&config) - } - - /// Rebuild credentials for a session from secure storage. - /// - /// [`SecretError::NotFound`] means the credential was revoked or the - /// keyring was cleared — the caller re-runs the login flow. - pub fn credentials(&self, session: &Session) -> Result { - let password = self.secrets.retrieve(&session.secret_ref())?; - Ok(AppCredentials { - server: session.server.clone(), - login_name: session.login.clone(), - app_password: password, - }) - } - - /// Forget a session and delete its credential. - /// - /// The credential is removed even if the config write fails, so a logout - /// never leaves a usable secret behind. - pub fn forget(&self, session: &Session) -> Result<(), SessionError> { - let deleted = self.secrets.delete(&session.secret_ref()); - - let mut config = self.read_config(); - config - .sessions - .retain(|s| !(s.server == session.server && s.login == session.login)); - let written = self.write_config(&config); - - deleted?; - written - } - - fn read_config(&self) -> ConfigFile { - std::fs::read_to_string(&self.config_path) - .ok() - .and_then(|t| serde_json::from_str(&t).ok()) - .unwrap_or_default() - } - - fn write_config(&self, config: &ConfigFile) -> Result<(), SessionError> { - if let Some(parent) = self.config_path.parent() { - std::fs::create_dir_all(parent)?; - } - let json = serde_json::to_string_pretty(config)?; - - // Write and rename, so an interrupted save cannot truncate an - // existing config. - let tmp = self.config_path.with_extension("tmp"); - std::fs::write(&tmp, json)?; - std::fs::rename(&tmp, &self.config_path)?; - Ok(()) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum SessionError { - #[error("secure storage: {0}")] - Secret(#[from] SecretError), - - #[error("config io: {0}")] - Io(#[from] std::io::Error), - - #[error("config format: {0}")] - Serde(#[from] serde_json::Error), - - #[error(transparent)] - Remote(#[from] RemoteError), -} - -#[cfg(test)] -mod tests { - use super::*; - use dr_plat::EphemeralSecretStore; - - fn creds() -> AppCredentials { - AppCredentials { - server: "https://cloud.example/".into(), - login_name: "duncan".into(), - app_password: "secret-token".into(), - } - } - - fn store_in(dir: &Path) -> SessionStore { - SessionStore::open_at( - dir.join("sessions.json"), - Box::new(EphemeralSecretStore::new()), - ) - } - - fn tmpdir(name: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("darkroom-test-{name}")); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - #[test] - fn a_saved_session_survives_reopening() { - let dir = tmpdir("survives"); - let secrets = Box::new(EphemeralSecretStore::new()); - - // Same secret store instance, as a real process would have. - let store = SessionStore::open_at(dir.join("sessions.json"), secrets); - let mut s = Session::new(&creds(), "duncan"); - s.root = "PhotosRaw".into(); - store.save(&s, &creds()).unwrap(); - - let reloaded = store.current().expect("session persisted"); - assert_eq!(reloaded.login, "duncan"); - assert_eq!(reloaded.root, "PhotosRaw"); - // Trailing slash normalised, so URLs built from it are consistent. - assert_eq!(reloaded.server, "https://cloud.example"); - } - - #[test] - fn the_credential_never_reaches_the_config_file() { - // NFR-SEC-2: the whole point of the split. - let dir = tmpdir("nocreds"); - let store = store_in(&dir); - let s = Session::new(&creds(), "duncan"); - store.save(&s, &creds()).unwrap(); - - let text = std::fs::read_to_string(dir.join("sessions.json")).unwrap(); - assert!(!text.contains("secret-token"), "credential leaked to disk"); - assert!(text.contains("duncan"), "session metadata should be there"); - } - - #[test] - fn credentials_round_trip_through_secure_storage() { - let dir = tmpdir("roundtrip"); - let store = store_in(&dir); - let s = Session::new(&creds(), "duncan"); - store.save(&s, &creds()).unwrap(); - - let got = store.credentials(&s).unwrap(); - assert_eq!(got.app_password, "secret-token"); - assert_eq!(got.login_name, "duncan"); - } - - #[test] - fn forgetting_removes_both_halves() { - let dir = tmpdir("forget"); - let store = store_in(&dir); - let s = Session::new(&creds(), "duncan"); - store.save(&s, &creds()).unwrap(); - - store.forget(&s).unwrap(); - assert!(store.current().is_none()); - assert!(matches!( - store.credentials(&s), - Err(SessionError::Secret(SecretError::NotFound)) - )); - } - - #[test] - fn saving_the_same_account_twice_does_not_duplicate_it() { - let dir = tmpdir("dedupe"); - let store = store_in(&dir); - let mut s = Session::new(&creds(), "duncan"); - store.save(&s, &creds()).unwrap(); - s.root = "Photos".into(); - store.save(&s, &creds()).unwrap(); - - assert_eq!(store.list().len(), 1); - assert_eq!(store.current().unwrap().root, "Photos"); - } - - #[test] - fn a_missing_config_is_a_first_run_not_an_error() { - let dir = tmpdir("firstrun"); - let store = store_in(&dir); - assert!(store.list().is_empty()); - assert!(store.current().is_none()); - } - - #[test] - fn a_corrupt_config_does_not_prevent_starting() { - // Better to present a first-run state than to refuse to launch. - let dir = tmpdir("corrupt"); - std::fs::write(dir.join("sessions.json"), "{ not json").unwrap(); - let store = store_in(&dir); - assert!(store.list().is_empty()); - } - - #[test] - fn format_selection_round_trips() { - let dir = tmpdir("formats"); - let store = store_in(&dir); - let mut s = Session::new(&creds(), "duncan"); - s.set_format_filter(&FormatFilter::from_formats([Format::Cr2, Format::Dng])); - store.save(&s, &creds()).unwrap(); - - let f = store.current().unwrap().format_filter(); - assert!(f.allows(Format::Cr2)); - assert!(f.allows(Format::Dng)); - assert!(!f.allows(Format::Nef)); - } - - #[test] - fn an_unset_filter_means_every_format() { - // Never "no formats", which would silently find nothing. - let s = Session::new(&creds(), "duncan"); - let f = s.format_filter(); - assert!(f.allows(Format::Cr2)); - assert!(f.allows(Format::Jpeg)); - } - - #[test] - fn describe_is_readable_and_hides_the_scheme() { - let mut s = Session::new(&creds(), "duncan"); - assert_eq!(s.describe(), "duncan on cloud.example"); - s.root = "PhotosRaw".into(); - assert_eq!(s.describe(), "duncan on cloud.example/PhotosRaw"); - } - - #[test] - fn updating_settings_leaves_the_credential_alone() { - let dir = tmpdir("update"); - let store = store_in(&dir); - let mut s = Session::new(&creds(), "duncan"); - store.save(&s, &creds()).unwrap(); - - s.root = "Elsewhere".into(); - store.update(&s).unwrap(); - - assert_eq!(store.current().unwrap().root, "Elsewhere"); - assert_eq!(store.credentials(&s).unwrap().app_password, "secret-token"); - } -} diff --git a/core/dr-sync/Cargo.toml b/core/dr-sync/Cargo.toml index 1b9fdf2..6a5ad69 100644 --- a/core/dr-sync/Cargo.toml +++ b/core/dr-sync/Cargo.toml @@ -7,7 +7,12 @@ license.workspace = true [dependencies] dr-types.workspace = true +# Accounts keep their credential in platform secure storage, never in the +# config file they are otherwise written to (NFR-SEC-2). +dr-plat.workspace = true async-trait.workspace = true +serde.workspace = true +serde_json.workspace = true thiserror.workspace = true log.workspace = true diff --git a/core/dr-sync/src/account.rs b/core/dr-sync/src/account.rs new file mode 100644 index 0000000..d68b77d --- /dev/null +++ b/core/dr-sync/src/account.rs @@ -0,0 +1,804 @@ +// TRACES: FR-NC-12 | FR-NC-2 +//! What a configured library *is*, with no connector in it. +//! +//! Before this existed, "an account" meant a Nextcloud server URL, a login +//! name and a DAV user id, and that shape reached every layer above: +//! `dr-ui` stored it, keyed its caches off it, threaded it through a dozen +//! worker threads and handed it to a constructor named after one product. +//! [`RemoteBackend`](crate::RemoteBackend) was abstract; everything that +//! *reached* a backend was not, so a second connector had nowhere to live. +//! +//! An [`Account`] is what remains once the product is taken out: somewhere a +//! library lives ([`endpoint`](Account::endpoint)), a folder inside it +//! ([`root`](Account::root)), and the settings the scan needs. What an +//! endpoint means is the connector's business — a URL for Nextcloud, a +//! directory for a plain folder, a bucket for whatever comes next. +//! +//! # The split that has to survive +//! +//! Credentials go to platform secure storage (FR-NC-2). Never the catalog, +//! never a file, never a log line. Everything else is ordinary configuration +//! written as plain JSON. That split is what lets the app show "signed in as +//! duncan, watching /PhotosRaw" before it has touched the keyring — and it is +//! why a [`Connection`] carries the two halves separately rather than as one +//! blob. + +use std::path::{Path, PathBuf}; + +use dr_plat::{SecretError, SecretRef, SecretStore}; +use dr_types::{Format, FormatFilter}; +use serde::{Deserialize, Serialize}; + +use crate::RemoteError; + +/// The connector every account had before there was a choice. +/// +/// Named here, in connector-neutral code, for exactly one reason: +/// [`Account::namespace`] must keep producing the same string for these +/// accounts as the hard-coded Nextcloud version did. That string is a +/// directory name holding a catalog, thumbnail shards, un-uploaded sidecars +/// and an export outbox. Changing it does not lose that data, it *abandons* +/// it — silently, as an upgrade — and costs a full rescan of the library on +/// top. +/// +/// Nothing else in this crate branches on a connector's identity, and nothing +/// else should. +pub const LEGACY_BACKEND: &str = "nextcloud"; + +/// Where configuration is written, when the platform has told us. +/// +/// Android has no `$HOME` and no XDG directories, so the guess below resolves +/// to a path the app cannot write. Nothing failed loudly: the account list went +/// to a doomed path, so credentials survived only as long as the process did and +/// backgrounding the app lost the account (ARCH §6.9 — no core API may assume a +/// filesystem path on Android). +/// +/// The platform layer sets this once at startup, before any store is opened. +static DATA_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// TRACES: FR-NC-2 +/// Declare the per-app directory configuration belongs in. +/// +/// Call before opening any store; later calls are ignored rather than racing. +/// On Android this is `AndroidApp::internal_data_path`, which is private to the +/// app and survives being backgrounded. Desktop needs no call — the XDG +/// fallback is correct there. +pub fn set_data_dir(dir: PathBuf) { + let _ = DATA_DIR.set(dir); +} + +/// The directory configuration lives in. +pub fn config_dir() -> PathBuf { + if let Some(d) = DATA_DIR.get() { + return d.clone(); + } + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")) + .join("darkroom") +} + +/// TRACES: FR-NC-12 +/// A configured library, minus its credential. +/// +/// Every field but [`backend`](Self::backend) is interpreted by the connector +/// that owns it. Code above this layer reads them for display and for cache +/// keys and never for meaning. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Account { + /// Which connector serves this library, as + /// [`BackendProvider::id`](crate::BackendProvider::id). + /// + /// Defaulted rather than required, because every account written before + /// there was a choice omits it and every one of them is a Nextcloud + /// account. A missing field here must load, not fail — a config the app + /// refuses to parse is an account the user has to set up again. + #[serde(default = "legacy_backend")] + pub backend: String, + + /// Where the library lives, in whatever form the connector addresses: + /// `https://cloud.example` for Nextcloud, `/mnt/photos` for a folder. + /// + /// Stored under its historical name so existing configuration loads + /// unchanged. + #[serde(rename = "server")] + pub endpoint: String, + + /// Who we are, where that means anything. Empty for connectors with no + /// notion of a user — it is shown, and used to key the credential. + #[serde(default)] + pub login: String, + + /// A connector-defined sub-address. Nextcloud's DAV path segment, which + /// may differ from `login` because a login can be an email address while + /// the user id is something else. Empty where the connector has no use + /// for one. + #[serde(default)] + pub user_id: String, + + /// The folder chosen as the library root, relative to the endpoint. Empty + /// means the endpoint itself. + #[serde(default)] + pub root: String, + + /// Which formats the scan looks for (the tick-boxes). + #[serde(default)] + pub formats: Vec, + + /// Unix seconds of the last completed scan, for display. + #[serde(default)] + pub last_scan: Option, +} + +fn legacy_backend() -> String { + LEGACY_BACKEND.to_string() +} + +impl Account { + /// A bare account for `backend` at `endpoint`, with nothing chosen yet. + pub fn new(backend: impl Into, endpoint: impl Into) -> Self { + Self { + backend: backend.into(), + endpoint: endpoint.into(), + login: String::new(), + user_id: String::new(), + root: String::new(), + formats: Vec::new(), + last_scan: None, + } + } + + pub fn with_login(mut self, login: impl Into, user_id: impl Into) -> Self { + self.login = login.into(); + self.user_id = user_id.into(); + self + } + + /// Whether two records name the same account. + /// + /// The identity the store deduplicates on. Endpoint and login together, + /// because one server can hold two accounts and one machine can hold two + /// folders — but the *same* pair twice is the same library reconfigured, + /// not a second one. + pub fn is_same_as(&self, other: &Account) -> bool { + self.backend == other.backend + && self.endpoint == other.endpoint + && self.login == other.login + } + + /// The stored format selection, defaulting to every supported format. + /// + /// An unconfigured account must find everything rather than nothing. + pub fn format_filter(&self) -> FormatFilter { + if self.formats.is_empty() { + FormatFilter::all() + } else { + FormatFilter::from_formats( + self.formats + .iter() + .filter_map(|s| Format::from_extension(&s.to_ascii_lowercase())), + ) + } + } + + pub fn set_format_filter(&mut self, filter: &FormatFilter) { + self.formats = filter + .iter() + .map(|f| format!("{f:?}").to_lowercase()) + .collect(); + } + + /// Where this account's credential lives, for connectors that need one. + pub fn secret_ref(&self) -> SecretRef { + SecretRef::app_password(&self.endpoint, &self.login) + } + + /// A short description for the UI. + /// + /// Reads for both shapes without asking the connector: "duncan on + /// cloud.example/PhotosRaw" where there is a login, and just the location + /// where there is not — a folder library has no user to name, and + /// inventing one ("(local) on /mnt/photos") would be worse than saying + /// where it is. + pub fn describe(&self) -> String { + let place = self + .endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + let place = if self.root.is_empty() { + place.to_string() + } else { + format!("{}/{}", place.trim_end_matches('/'), self.root) + }; + if self.login.is_empty() { + place + } else { + format!("{} on {place}", self.login) + } + } + + /// TRACES: FR-NC-10 | NFR-R1 + /// The directory name this account's local data hangs off. + /// + /// Not a display string and not stable across a change of endpoint: it is + /// the key for the catalog, the thumbnail shards, the sidecar spool and + /// the export outbox. Two accounts must never collide here — one would + /// index the other's library — and one account must produce the same + /// answer on every launch, forever, or its data is abandoned in place. + /// + /// The Nextcloud form is reproduced byte for byte from what + /// `catalog_path` computed before accounts were multi-backend + /// ([`LEGACY_BACKEND`]). Everything else is prefixed by its connector, so + /// a folder library at `/srv/photos` and a hypothetical S3 bucket of the + /// same name cannot land in one directory. + pub fn namespace(&self) -> String { + let slug = slugify( + self.endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"), + ); + + if self.backend == LEGACY_BACKEND { + // Frozen. See LEGACY_BACKEND. + return format!("{slug}-{}", self.user_id); + } + + let tail = if self.user_id.is_empty() { + String::new() + } else { + format!("-{}", slugify(&self.user_id)) + }; + let name = format!("{}-{slug}{tail}", slugify(&self.backend)); + shorten(&name) + } +} + +/// Everything that is not `[A-Za-z0-9]`, flattened to `-`. +/// +/// Not an escape and not reversible: the result names a directory, and the +/// only property it needs is that it is a legal filename on every platform +/// the app runs on. +fn slugify(s: &str) -> String { + s.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect() +} + +/// Cap a namespace at a length every filesystem accepts. +/// +/// A folder endpoint is an absolute path and can be far longer than a server +/// URL — deep enough to exceed the 255-byte component limit on ext4 and APFS +/// alike, at which point creating the catalog directory fails and the library +/// cannot be opened at all. Truncating alone would make two deep paths under +/// one parent collide, so the discarded tail is replaced by a hash of the +/// whole. +fn shorten(name: &str) -> String { + const MAX: usize = 96; + if name.len() <= MAX { + return name.to_string(); + } + let head: String = name.chars().take(MAX - 17).collect(); + format!("{head}-{:016x}", fnv1a64(name.as_bytes())) +} + +/// FNV-1a, 64-bit. +/// +/// Written out rather than taken from `DefaultHasher`, whose output is +/// explicitly not stable between Rust releases. This one keys a directory that +/// must be found again after a toolchain upgrade. +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// TRACES: FR-NC-2 | NFR-SEC-2 +/// A credential, kept out of logs by construction. +/// +/// The inner string is reachable only through [`expose`](Secret::expose), so +/// the ways a secret leaks — a `{:?}` on a struct that happens to contain one, +/// a `Display` in an error message — do not compile into a leak. NFR-SEC-2 is +/// the requirement; this is the part of it that a reviewer cannot forget to +/// apply. +#[derive(Clone, PartialEq, Eq)] +pub struct Secret(String); + +impl Secret { + pub fn new(value: impl Into) -> Self { + Secret(value.into()) + } + + /// The credential itself. Every call site is a place to check. + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Secret(***)") + } +} + +/// Everything needed to open a backend, in one movable value. +/// +/// Workers run on their own threads and each one needs its own way in, so this +/// is `Clone` and owns what it holds. It replaced a pair of arguments — +/// credentials and a user id — that had to be threaded together through +/// fifteen functions and could be passed in the wrong order. +#[derive(Debug, Clone)] +pub struct Connection { + pub account: Account, + /// `None` where the connector needs no credential, which is the ordinary + /// state of a folder library rather than a failure to load one. + pub secret: Option, +} + +impl Connection { + pub fn new(account: Account, secret: Option) -> Self { + Self { account, secret } + } + + /// The credential, or [`RemoteError::Unauthenticated`]. + /// + /// For connectors that require one: turning the absence into the error the + /// caller already handles saves every implementation writing the same + /// `ok_or`. + pub fn require_secret(&self) -> Result<&Secret, RemoteError> { + self.secret.as_ref().ok_or(RemoteError::Unauthenticated) + } +} + +/// TRACES: FR-NC-1 | FR-NC-2 | M-1 | M-2 +/// Loads and saves accounts, keeping credentials in secure storage. +pub struct AccountStore { + config_path: PathBuf, + secrets: Box, +} + +/// What is written to disk. Versioned so a format change is a migration +/// rather than a parse failure. +#[derive(Debug, Default, Serialize, Deserialize)] +struct ConfigFile { + #[serde(default = "one")] + version: u32, + /// Named `sessions` on disk because that is what it has always been + /// called there, and renaming the key would orphan every existing config. + #[serde(default)] + sessions: Vec, +} + +fn one() -> u32 { + 1 +} + +impl AccountStore { + /// Open the store at the platform config location. + /// + /// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to + /// `~/.config` (FR-PLAT-LIN-1). + pub fn open(secrets: Box) -> Self { + Self::open_at(config_dir().join("sessions.json"), secrets) + } + + /// Where configuration lives, for callers that need to sit files beside it. + pub fn data_dir() -> PathBuf { + config_dir() + } + + /// Open at an explicit path — used by tests, and by anything wanting a + /// non-default config location. + pub fn open_at(config_path: PathBuf, secrets: Box) -> Self { + Self { + config_path, + secrets, + } + } + + pub fn config_path(&self) -> &Path { + &self.config_path + } + + /// Whether credentials can be remembered at all. + /// + /// Where false the UI should say sign-in will not persist, rather than + /// letting the user discover it next launch. + pub fn can_remember(&self) -> bool { + self.secrets.is_available() + } + + /// Every configured account. Missing or unreadable config yields an empty + /// list rather than an error — a first run is not a failure. + pub fn list(&self) -> Vec { + self.read_config().sessions + } + + /// The most recently configured account, if any. + pub fn current(&self) -> Option { + self.read_config().sessions.into_iter().next_back() + } + + /// Persist an account and its credential. + /// + /// The credential goes to secure storage first: if that fails there is no + /// point recording an account that cannot authenticate. `None` is the + /// ordinary case for a connector that needs no credential, and stores + /// nothing rather than an empty secret. + pub fn save(&self, account: &Account, secret: Option<&Secret>) -> Result<(), AccountError> { + if let Some(s) = secret { + self.secrets.store(&account.secret_ref(), s.expose())?; + } + + let mut config = self.read_config(); + config.sessions.retain(|a| !a.is_same_as(account)); + config.sessions.push(account.clone()); + self.write_config(&config) + } + + /// Update an account's settings, leaving its credential untouched. + pub fn update(&self, account: &Account) -> Result<(), AccountError> { + let mut config = self.read_config(); + match config.sessions.iter_mut().find(|a| a.is_same_as(account)) { + Some(existing) => *existing = account.clone(), + None => config.sessions.push(account.clone()), + } + self.write_config(&config) + } + + /// Rebuild a connection for an account, fetching its credential. + /// + /// `needs_secret` is the connector's answer, passed in rather than + /// inferred: an account with an empty login might be a folder library or + /// might be a broken Nextcloud record, and guessing turns the second into + /// a silent unauthenticated connection instead of an error the user can + /// act on. + /// + /// [`SecretError::NotFound`] means the credential was revoked or the + /// keyring was cleared — the caller re-runs the sign-in. + pub fn connection( + &self, + account: &Account, + needs_secret: bool, + ) -> Result { + let secret = if needs_secret { + Some(Secret::new(self.secrets.retrieve(&account.secret_ref())?)) + } else { + None + }; + Ok(Connection::new(account.clone(), secret)) + } + + /// Forget an account and delete its credential. + /// + /// The credential is removed even if the config write fails, so a logout + /// never leaves a usable secret behind. A connector that stores none + /// reports [`SecretError::NotFound`], which is not a failure to forget. + pub fn forget(&self, account: &Account) -> Result<(), AccountError> { + let deleted = match self.secrets.delete(&account.secret_ref()) { + Err(SecretError::NotFound) => Ok(()), + other => other, + }; + + let mut config = self.read_config(); + config.sessions.retain(|a| !a.is_same_as(account)); + let written = self.write_config(&config); + + deleted?; + written + } + + fn read_config(&self) -> ConfigFile { + std::fs::read_to_string(&self.config_path) + .ok() + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default() + } + + fn write_config(&self, config: &ConfigFile) -> Result<(), AccountError> { + if let Some(parent) = self.config_path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(config)?; + + // Write and rename, so an interrupted save cannot truncate an + // existing config. + let tmp = self.config_path.with_extension("tmp"); + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, &self.config_path)?; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AccountError { + #[error("secure storage: {0}")] + Secret(#[from] SecretError), + + #[error("config io: {0}")] + Io(#[from] std::io::Error), + + #[error("config format: {0}")] + Serde(#[from] serde_json::Error), + + #[error(transparent)] + Remote(#[from] RemoteError), +} + +#[cfg(test)] +mod tests { + use super::*; + use dr_plat::EphemeralSecretStore; + + fn nextcloud() -> Account { + Account::new(LEGACY_BACKEND, "https://cloud.example").with_login("duncan", "duncan") + } + + fn folder() -> Account { + Account::new("folder", "/mnt/photos") + } + + fn store_in(dir: &Path) -> AccountStore { + AccountStore::open_at( + dir.join("sessions.json"), + Box::new(EphemeralSecretStore::new()), + ) + } + + fn tmpdir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("darkroom-account-test-{name}")); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn a_saved_account_survives_reopening() { + let dir = tmpdir("survives"); + let store = store_in(&dir); + let mut a = nextcloud(); + a.root = "PhotosRaw".into(); + store.save(&a, Some(&Secret::new("token"))).unwrap(); + + let reloaded = store.current().expect("account persisted"); + assert_eq!(reloaded.login, "duncan"); + assert_eq!(reloaded.root, "PhotosRaw"); + } + + #[test] + fn the_credential_never_reaches_the_config_file() { + // NFR-SEC-2: the whole point of the split. + let dir = tmpdir("nocreds"); + let store = store_in(&dir); + store + .save(&nextcloud(), Some(&Secret::new("secret-token"))) + .unwrap(); + + let text = std::fs::read_to_string(dir.join("sessions.json")).unwrap(); + assert!(!text.contains("secret-token"), "credential leaked to disk"); + assert!(text.contains("duncan"), "account metadata should be there"); + } + + #[test] + fn a_secret_does_not_print_itself() { + // The leak this closes is indirect: a `{:?}` on any struct holding a + // connection used to print the app password. + let c = Connection::new(nextcloud(), Some(Secret::new("hunter2"))); + let printed = format!("{c:?}"); + assert!(!printed.contains("hunter2"), "credential leaked to a log"); + } + + #[test] + fn credentials_round_trip_through_secure_storage() { + let dir = tmpdir("roundtrip"); + let store = store_in(&dir); + let a = nextcloud(); + store.save(&a, Some(&Secret::new("secret-token"))).unwrap(); + + let conn = store.connection(&a, true).unwrap(); + assert_eq!(conn.require_secret().unwrap().expose(), "secret-token"); + } + + #[test] + fn a_credentialless_account_connects_without_touching_the_keyring() { + // A folder library must open on a machine with no secrets daemon at + // all — asking for a credential it does not have would fail the one + // backend that needs nothing. + let dir = tmpdir("nosecret"); + let store = store_in(&dir); + let a = folder(); + store.save(&a, None).unwrap(); + + let conn = store.connection(&a, false).unwrap(); + assert!(conn.secret.is_none()); + assert!(matches!( + conn.require_secret(), + Err(RemoteError::Unauthenticated) + )); + } + + #[test] + fn forgetting_removes_both_halves() { + let dir = tmpdir("forget"); + let store = store_in(&dir); + let a = nextcloud(); + store.save(&a, Some(&Secret::new("token"))).unwrap(); + + store.forget(&a).unwrap(); + assert!(store.current().is_none()); + assert!(matches!( + store.connection(&a, true), + Err(AccountError::Secret(SecretError::NotFound)) + )); + } + + #[test] + fn forgetting_a_credentialless_account_is_not_an_error() { + // There is no secret to delete, and reporting the absence as a failure + // would leave a folder library that cannot be signed out of. + let dir = tmpdir("forget-folder"); + let store = store_in(&dir); + let a = folder(); + store.save(&a, None).unwrap(); + store.forget(&a).unwrap(); + assert!(store.current().is_none()); + } + + #[test] + fn two_backends_at_the_same_endpoint_are_two_accounts() { + let dir = tmpdir("twobackends"); + let store = store_in(&dir); + store.save(&Account::new("folder", "/mnt/p"), None).unwrap(); + store.save(&Account::new("webdav", "/mnt/p"), None).unwrap(); + assert_eq!(store.list().len(), 2); + } + + #[test] + fn saving_the_same_account_twice_does_not_duplicate_it() { + let dir = tmpdir("dedupe"); + let store = store_in(&dir); + let mut a = nextcloud(); + store.save(&a, Some(&Secret::new("token"))).unwrap(); + a.root = "Photos".into(); + store.save(&a, Some(&Secret::new("token"))).unwrap(); + + assert_eq!(store.list().len(), 1); + assert_eq!(store.current().unwrap().root, "Photos"); + } + + #[test] + fn a_missing_config_is_a_first_run_not_an_error() { + let dir = tmpdir("firstrun"); + let store = store_in(&dir); + assert!(store.list().is_empty()); + assert!(store.current().is_none()); + } + + #[test] + fn a_corrupt_config_does_not_prevent_starting() { + // Better to present a first-run state than to refuse to launch. + let dir = tmpdir("corrupt"); + std::fs::write(dir.join("sessions.json"), "{ not json").unwrap(); + let store = store_in(&dir); + assert!(store.list().is_empty()); + } + + #[test] + fn a_config_written_before_backends_existed_still_loads() { + // The upgrade path. Every account written by an earlier version omits + // `backend`, and refusing to parse one would make an upgrade look + // like a signed-out app with a library that has to be set up again. + let dir = tmpdir("legacy"); + std::fs::write( + dir.join("sessions.json"), + r#"{"version":1,"sessions":[{"server":"https://cloud.example", + "login":"duncan","user_id":"duncan","root":"PhotosRaw", + "formats":[],"last_scan":null}]}"#, + ) + .unwrap(); + + let a = store_in(&dir).current().expect("legacy account loads"); + assert_eq!(a.backend, LEGACY_BACKEND); + assert_eq!(a.endpoint, "https://cloud.example"); + assert_eq!(a.root, "PhotosRaw"); + } + + #[test] + fn a_legacy_account_keeps_the_directory_its_data_is_already_in() { + // Frozen deliberately: this string names the directory holding the + // catalog, the thumbnail shards and un-uploaded sidecars. A change + // here abandons all three and forces a full rescan. + let a = Account::new(LEGACY_BACKEND, "https://cloud.example.com").with_login("d", "duncan"); + assert_eq!(a.namespace(), "cloud-example-com-duncan"); + } + + #[test] + fn a_new_backend_cannot_collide_with_a_legacy_one() { + let ns = Account::new("folder", "/mnt/photos").namespace(); + assert!(ns.starts_with("folder-"), "{ns}"); + assert_ne!(ns, Account::new(LEGACY_BACKEND, "/mnt/photos").namespace()); + } + + #[test] + fn two_folders_never_share_a_directory() { + // Two libraries in one catalog would index each other's images. + assert_ne!( + Account::new("folder", "/mnt/photos/2025").namespace(), + Account::new("folder", "/mnt/photos/2026").namespace() + ); + } + + #[test] + fn a_very_deep_folder_still_yields_a_legal_directory_name() { + // Past 255 bytes the catalog directory cannot be created at all, and + // the library simply fails to open. + let deep = format!("/{}", vec!["a-rather-long-folder-name"; 40].join("/")); + let a = Account::new("folder", &deep); + let ns = a.namespace(); + assert!(ns.len() <= 96, "{} chars", ns.len()); + + // Truncation alone would make these two the same directory. + let b = Account::new("folder", format!("{deep}/second")); + assert_ne!(ns, b.namespace()); + } + + #[test] + fn describe_reads_for_an_account_with_no_user() { + // A folder library has nobody to name; "(none) on /mnt/photos" would + // be worse than saying where it is. + let mut a = folder(); + assert_eq!(a.describe(), "/mnt/photos"); + a.root = "2026".into(); + assert_eq!(a.describe(), "/mnt/photos/2026"); + } + + #[test] + fn describe_is_readable_and_hides_the_scheme() { + let mut a = nextcloud(); + assert_eq!(a.describe(), "duncan on cloud.example"); + a.root = "PhotosRaw".into(); + assert_eq!(a.describe(), "duncan on cloud.example/PhotosRaw"); + } + + #[test] + fn format_selection_round_trips() { + let mut a = nextcloud(); + a.set_format_filter(&FormatFilter::from_formats([Format::Cr2, Format::Dng])); + let f = a.format_filter(); + assert!(f.allows(Format::Cr2)); + assert!(f.allows(Format::Dng)); + assert!(!f.allows(Format::Nef)); + } + + #[test] + fn an_unset_filter_means_every_format() { + // Never "no formats", which would silently find nothing. + let f = nextcloud().format_filter(); + assert!(f.allows(Format::Cr2)); + assert!(f.allows(Format::Jpeg)); + } + + #[test] + fn updating_settings_leaves_the_credential_alone() { + let dir = tmpdir("update"); + let store = store_in(&dir); + let mut a = nextcloud(); + store.save(&a, Some(&Secret::new("secret-token"))).unwrap(); + + a.root = "Elsewhere".into(); + store.update(&a).unwrap(); + + assert_eq!(store.current().unwrap().root, "Elsewhere"); + assert_eq!( + store + .connection(&a, true) + .unwrap() + .require_secret() + .unwrap() + .expose(), + "secret-token" + ); + } +} diff --git a/core/dr-sync/src/capability.rs b/core/dr-sync/src/capability.rs index 098d063..31d9a43 100644 --- a/core/dr-sync/src/capability.rs +++ b/core/dr-sync/src/capability.rs @@ -38,6 +38,50 @@ pub struct ChunkConstraints { pub max_chunks: u32, } +/// TRACES: FR-NC-6c +/// Whether every listed object's content is actually reachable. +/// +/// Every backend but a virtual-filesystem folder answers [`Always`](Self::Always). +/// A VFS folder is the case this exists for: the sync client leaves a +/// placeholder where a file is catalogued but not downloaded, so the name is +/// listable and the bytes are not (ARCH §9.0). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Materialisation { + /// Listing an object means its content can be read. Every server backend, + /// and a plain directory. + Always, + + /// Some objects are placeholders, and nothing this process can do will + /// change that — the sync client is not running, or the platform offers no + /// way to ask. Such an object reads as + /// [`RemoteError::NotMaterialised`](crate::RemoteError::NotMaterialised) + /// and is shown as offline rather than broken. + Placeholders, + + /// Some objects are placeholders, and this backend can ask for their + /// content — and give it back. + /// + /// **Whole-file, and that is the whole difficulty.** Hydration has two + /// states, one byte or all bytes, so using it to fill a grid transfers the + /// entire library to produce thumbnails (ARCH §9.0). It belongs to the + /// originals tier — an image opened in develop, exported, or deliberately + /// pinned — and to passes the user has asked for and been quoted a price + /// on. Never to browsing. + OnDemand, +} + +impl Materialisation { + /// Whether content can be fetched on request. + pub fn can_materialise(self) -> bool { + matches!(self, Materialisation::OnDemand) + } + + /// Whether some objects may have no content locally. + pub fn has_placeholders(self) -> bool { + !matches!(self, Materialisation::Always) + } +} + /// TRACES: FR-NC-3 /// Whether the server can render thumbnails, and for what. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -69,6 +113,8 @@ pub struct Capabilities { /// Conditional write (If-Match), for conflict-safe sidecar updates. pub conditional_write: bool, pub server_previews: ServerPreviews, + /// Whether a listed object's content is necessarily present. + pub materialisation: Materialisation, } impl Capabilities { @@ -83,6 +129,9 @@ impl Capabilities { bulk_upload: false, conditional_write: false, server_previews: ServerPreviews::None, + // The weakest backend still answers for everything it lists; + // placeholders are a property a backend opts into. + materialisation: Materialisation::Always, } } diff --git a/core/dr-sync/src/error.rs b/core/dr-sync/src/error.rs index 4f8042f..0f966bf 100644 --- a/core/dr-sync/src/error.rs +++ b/core/dr-sync/src/error.rs @@ -36,11 +36,42 @@ pub enum RemoteError { #[error("not found: {0}")] NotFound(String), + /// TRACES: FR-NC-6c + /// The object exists, but its content is not on this device. + /// + /// A virtual-filesystem placeholder: the sync client holds the name and a + /// stub, and the bytes are still on the server (ARCH §9.0). + /// + /// **Emphatically not [`NotFound`](Self::NotFound), and the distinction is + /// what stops a silent data loss.** The sidecar writer reads before it + /// writes, and treats a miss as "there is no sidecar yet, create one" — so + /// a dehydrated sidecar reported as absent makes it write a fresh document + /// over an existing one, discarding every edit another device had put + /// there. It is also the difference between an error a user can act on + /// (fetch it) and one they cannot (it is gone). + #[error("not on this device: {0}")] + NotMaterialised(String), + /// The backend does not support this operation. Expected, not a bug — /// callers check capabilities and adapt. #[error("operation unsupported by this backend: {0}")] Unsupported(&'static str), + /// The account is configured wrongly, or for a backend this build has no + /// connector for. + /// + /// **Not a network failure and not an auth failure**, which is why it is + /// its own variant. A folder library whose directory has been unmounted, + /// or an account naming a backend a cut-down build was not compiled with, + /// produces a request that never leaves the process — reporting either as + /// `Network` would put the app into offline mode and tell the user their + /// connection is down, and reporting them as `AuthFailed` would send them + /// to re-enter a credential that is fine. The message names what is wrong + /// with the configuration, because that is the only thing that will fix + /// it. + #[error("account misconfigured: {0}")] + Configuration(String), + /// A conditional write failed: the remote changed underneath us. Triggers /// the sidecar merge path (ARCH §8.5). #[error("precondition failed — remote was modified")] diff --git a/core/dr-sync/src/lib.rs b/core/dr-sync/src/lib.rs index 9616f6f..51e6bce 100644 --- a/core/dr-sync/src/lib.rs +++ b/core/dr-sync/src/lib.rs @@ -1,9 +1,14 @@ //! Pluggable remote storage for DarkRoom. //! //! Defines the [`RemoteBackend`] trait and the capability model the sync -//! engine adapts to. Only the Nextcloud connector is implemented -//! (`dr-sync-nextcloud`), but the boundary is designed so other backends can -//! be added without touching the engine. +//! engine adapts to, plus the pieces that let the application hold a backend +//! without naming one: an [`Account`] that is configuration rather than a +//! server, and a [`BackendProvider`] registry that turns one into a live +//! connection. +//! +//! Two connectors ship: `dr-sync-nextcloud` and `dr-sync-folder`. Adding a +//! third is implementing those two traits and registering the result — see +//! [`provider`] for the whole contract. //! //! # Why capabilities rather than a common denominator //! @@ -20,15 +25,21 @@ use std::ops::Range; use async_trait::async_trait; +pub mod account; pub mod capability; pub mod error; +pub mod provider; pub mod reachability; pub mod scan; pub mod types; pub mod upload; -pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, ServerPreviews}; +pub use account::{Account, AccountError, AccountStore, Connection, Secret, LEGACY_BACKEND}; +pub use capability::{ + Capabilities, ChangeDetection, ChunkConstraints, Materialisation, ServerPreviews, +}; pub use error::RemoteError; +pub use provider::{BackendProvider, BackendRegistry, SignIn}; pub use reachability::{Connectivity, Reachability}; pub use scan::{scan, ScanProgress, ScanResult}; pub use types::{ @@ -142,6 +153,56 @@ pub trait RemoteBackend: Send + Sync { /// destination, not to claim they created it. async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError>; + // ---- materialisation -------------------------------------------------- + + /// TRACES: FR-NC-6c + /// Ask for a placeholder's content to be brought to this device. + /// + /// Only meaningful where [`Capabilities::materialisation`] is + /// [`Materialisation::OnDemand`]; others return + /// [`RemoteError::Unsupported`]. + /// + /// **Whole-file, and slow.** There is no partial hydration: a placeholder + /// becomes one byte or all of them, so this transfers a 27 MB RAW to + /// answer a question a 256 KB range read would have answered (ARCH §9.0 + /// finding 3). It is for the originals tier — develop, export, a pin the + /// user asked for — and for passes the user has been quoted a price on and + /// agreed to. **Never for filling a grid**: doing so downloads the entire + /// library to produce thumbnails. + /// + /// Returns once the content is readable, and **whether this call is what + /// brought it here** — `false` meaning it was already local. + /// + /// That boolean is the whole basis of borrowing. A caller releasing what + /// it fetched must not release what the user already had, and after the + /// fact the two are indistinguishable; the backend knows because it had to + /// look before deciding whether to ask. Answering it here costs the `stat` + /// the implementation performs anyway, where a caller determining it + /// separately would pay a directory listing per file. + async fn materialise(&self, _id: &RemoteId) -> Result { + Err(RemoteError::Unsupported( + "this backend has no placeholders to materialise", + )) + } + + /// Give a placeholder's content back, freeing the disk it held. + /// + /// The counterpart that makes hydration a *borrow* rather than an + /// acquisition: a pass that hydrates a library to index it can return each + /// file as it finishes, so peak disk is the working set rather than the + /// library. + /// + /// **Never destructive.** On a synced folder this asks the client to + /// dehydrate; it must not delete, because a deletion in a synced tree + /// propagates to the server and removes the photograph everywhere. An + /// implementation that cannot dehydrate must return + /// [`RemoteError::Unsupported`] rather than approximating it. + async fn dematerialise(&self, _id: &RemoteId) -> Result<(), RemoteError> { + Err(RemoteError::Unsupported( + "this backend has no placeholders to release", + )) + } + // ---- optional --------------------------------------------------------- /// Server-rendered thumbnail, where available. diff --git a/core/dr-sync/src/provider.rs b/core/dr-sync/src/provider.rs new file mode 100644 index 0000000..1f3ab1a --- /dev/null +++ b/core/dr-sync/src/provider.rs @@ -0,0 +1,278 @@ +// TRACES: FR-NC-12 +//! How a connector announces itself. +//! +//! [`RemoteBackend`] says what a backend can *do* once it is open. +//! [`BackendProvider`] says everything the application needs before that: what +//! to call it, what a library location looks like, whether signing in involves +//! a browser, and how to turn a stored [`Account`] into a live backend. +//! +//! Together they are the whole contract. Adding a storage layer is: +//! +//! 1. implement [`RemoteBackend`] over your protocol, +//! 2. implement [`BackendProvider`] beside it, +//! 3. register it in `dr_ui::remote`. +//! +//! Nothing above that module names a connector, so nothing above it changes. +//! +//! # Why sign-in is a shape rather than a method +//! +//! It would be tidier for a provider to expose `async fn sign_in()` and let +//! the launch screen await it. It would also be wrong: Nextcloud's Login Flow +//! v2 is a browser handshake the user completes elsewhere while the app polls, +//! so it is not one call, it does not finish on our schedule, and the screen +//! has to render a URL and a waiting state in the middle of it. A folder needs +//! none of that. [`SignIn`] names which of those two shapes the screen must +//! draw, and the flow itself stays where its protocol is. + +use std::sync::Arc; + +use crate::{Account, Connection, RemoteBackend, RemoteError}; + +/// What establishing an account involves. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignIn { + /// A handshake the user completes outside the app, yielding a credential + /// the app then stores. Nextcloud's Login Flow v2. + /// + /// The connector drives it; the launch screen only shows the waiting + /// state, because what happens in the middle is protocol-specific. + Browser, + + /// The endpoint is the whole account. Nothing to authenticate, nothing to + /// store in the keyring, no waiting state to draw — a local folder. + EndpointOnly, +} + +impl SignIn { + /// Whether an account of this shape has a credential in secure storage. + pub fn needs_secret(self) -> bool { + matches!(self, SignIn::Browser) + } +} + +/// TRACES: FR-NC-12 +/// A storage connector, described well enough to configure without naming it. +/// +/// Implementations are held in an [`Arc`] inside a [`BackendRegistry`] and +/// must be usable from any thread: the launch screen reads them on the UI +/// thread and workers open connections from them on their own. +pub trait BackendProvider: Send + Sync { + /// The stable identifier written to [`Account::backend`]. + /// + /// **It is on-disk configuration.** Changing it after anyone has an + /// account orphans that account, so pick it once. + fn id(&self) -> &'static str; + + /// What to call this in the interface. "Nextcloud", "Folder". + fn display_name(&self) -> &'static str; + + /// What to label the endpoint field: "Server address", "Folder". + fn endpoint_label(&self) -> &'static str; + + /// An example endpoint, for the empty field. + fn endpoint_placeholder(&self) -> &'static str; + + /// How an account of this kind is established. + fn sign_in(&self) -> SignIn; + + /// Turn what the user typed into the form that gets stored. + /// + /// Two jobs, and the second is the important one: this is where a bad + /// endpoint is *rejected*, before an account is written for a library that + /// does not exist. The error is shown to the user, so it says what is + /// wrong rather than naming a type. + fn normalise_endpoint(&self, input: &str) -> Result; + + /// Build an account from a normalised endpoint alone. + /// + /// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces + /// its account from what the handshake returned, so the default here + /// refuses rather than inventing one. + fn account_for(&self, endpoint: &str) -> Result { + let _ = endpoint; + Err(RemoteError::Unsupported( + "this backend establishes an account through its sign-in flow", + )) + } + + /// Open a live backend. + /// + /// Cheap and synchronous: it validates configuration and constructs a + /// client, and does not talk to the remote. Workers call it per task, so + /// anything expensive here is paid over and over. + fn connect(&self, conn: &Connection) -> Result, RemoteError>; +} + +/// TRACES: FR-NC-12 | FR-NC-13 +/// The connectors this build has. +/// +/// One instance is built at startup and consulted by everything that needs a +/// backend. The registry is the *only* thing that knows connectors exist, +/// which is what keeps the layers above free of them. +#[derive(Clone, Default)] +pub struct BackendRegistry { + providers: Vec>, +} + +impl BackendRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Add a connector. + /// + /// Later registrations of an id replace earlier ones, so a build can + /// substitute a connector — a test double for a real server — without the + /// registry needing to know it happened. + pub fn register(&mut self, provider: Arc) -> &mut Self { + let id = provider.id(); + self.providers.retain(|p| p.id() != id); + self.providers.push(provider); + self + } + + /// The connector for an id. + pub fn get(&self, id: &str) -> Option<&Arc> { + self.providers.iter().find(|p| p.id() == id) + } + + /// The connector an account names, or a message naming the account's. + /// + /// The error case is real rather than defensive: a configuration file can + /// outlive the build that wrote it, and a user moving between a full + /// desktop build and a cut-down one will have accounts this binary cannot + /// serve. Saying which backend is missing is the difference between that + /// and "could not open library". + pub fn for_account(&self, account: &Account) -> Result<&Arc, RemoteError> { + self.get(&account.backend).ok_or_else(|| { + RemoteError::Configuration(format!( + "no storage backend named {:?} in this build", + account.backend + )) + }) + } + + /// Open the backend an account is configured for. + pub fn connect(&self, conn: &Connection) -> Result, RemoteError> { + self.for_account(&conn.account)?.connect(conn) + } + + /// Every connector, in registration order. What the launch screen offers. + pub fn providers(&self) -> &[Arc] { + &self.providers + } +} + +impl std::fmt::Debug for BackendRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BackendRegistry") + .field( + "providers", + &self.providers.iter().map(|p| p.id()).collect::>(), + ) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Stub(&'static str); + + impl BackendProvider for Stub { + fn id(&self) -> &'static str { + self.0 + } + fn display_name(&self) -> &'static str { + "Stub" + } + fn endpoint_label(&self) -> &'static str { + "Where" + } + fn endpoint_placeholder(&self) -> &'static str { + "somewhere" + } + fn sign_in(&self) -> SignIn { + SignIn::EndpointOnly + } + fn normalise_endpoint(&self, input: &str) -> Result { + if input.trim().is_empty() { + Err("say where the library is".into()) + } else { + Ok(input.trim().to_string()) + } + } + fn connect(&self, _conn: &Connection) -> Result, RemoteError> { + Err(RemoteError::Unsupported("stub")) + } + } + + fn registry() -> BackendRegistry { + let mut r = BackendRegistry::new(); + r.register(Arc::new(Stub("alpha"))); + r.register(Arc::new(Stub("beta"))); + r + } + + #[test] + fn a_registered_backend_is_found_by_id() { + assert_eq!(registry().get("beta").map(|p| p.id()), Some("beta")); + } + + #[test] + fn registering_an_id_twice_replaces_rather_than_shadows() { + let mut r = registry(); + r.register(Arc::new(Stub("alpha"))); + assert_eq!(r.providers().len(), 2, "{r:?}"); + } + + #[test] + fn an_account_for_a_missing_backend_says_which_one() { + // A config can outlive the build that wrote it. "could not open + // library" would send the user to check their server. + let account = Account::new("s3", "bucket"); + let err = match registry().for_account(&account) { + Err(e) => e.to_string(), + Ok(p) => panic!("a backend this build has no connector for: {}", p.id()), + }; + assert!(err.contains("s3"), "{err}"); + } + + #[test] + fn an_endpoint_only_backend_needs_no_credential() { + assert!(!SignIn::EndpointOnly.needs_secret()); + assert!(SignIn::Browser.needs_secret()); + } + + #[test] + fn a_browser_backend_refuses_to_invent_an_account() { + // Building one from an endpoint would skip the handshake and store an + // account with no credential, which fails later and further away. + struct Interactive; + impl BackendProvider for Interactive { + fn id(&self) -> &'static str { + "i" + } + fn display_name(&self) -> &'static str { + "I" + } + fn endpoint_label(&self) -> &'static str { + "Server" + } + fn endpoint_placeholder(&self) -> &'static str { + "" + } + fn sign_in(&self) -> SignIn { + SignIn::Browser + } + fn normalise_endpoint(&self, i: &str) -> Result { + Ok(i.into()) + } + fn connect(&self, _: &Connection) -> Result, RemoteError> { + Err(RemoteError::Unsupported("stub")) + } + } + assert!(Interactive.account_for("https://x").is_err()); + } +} diff --git a/core/dr-sync/src/scan.rs b/core/dr-sync/src/scan.rs index d54bb96..6f723cd 100644 --- a/core/dr-sync/src/scan.rs +++ b/core/dr-sync/src/scan.rs @@ -253,6 +253,7 @@ mod tests { size: 0, modified: None, has_preview: false, + materialised: true, } } @@ -265,6 +266,7 @@ mod tests { size: 1000, modified: None, has_preview: false, + materialised: true, } } @@ -301,6 +303,7 @@ mod tests { bulk_upload: false, conditional_write: true, server_previews: ServerPreviews::None, + materialisation: crate::Materialisation::Always, }, lists: RefCell::new(0), probes: RefCell::new(0), diff --git a/core/dr-sync/src/types.rs b/core/dr-sync/src/types.rs index 6b06d6f..14d7f25 100644 --- a/core/dr-sync/src/types.rs +++ b/core/dr-sync/src/types.rs @@ -97,6 +97,23 @@ pub struct RemoteEntry { /// Whether the server claims a renderable preview exists. Advisory: stock /// Nextcloud reports none for RAW (ARCH §6.7). pub has_preview: bool, + + /// TRACES: FR-NC-6c + /// Whether [`get`](crate::RemoteBackend::get) can produce this object's + /// content right now. + /// + /// True for everything a server backend lists — the bytes are remote, but + /// they are reachable. False only for a virtual-filesystem placeholder, + /// where the name is on this device and the content is not (ARCH §9.0). + /// + /// The catalog maps this to [`Availability::Offline`](dr_types::Availability), + /// which is the difference between a photograph shown as *not downloaded* + /// and one shown as broken. + /// + /// **`size` is not meaningful when this is false.** A Linux suffix-mode + /// stub is one byte and carries no record of what it stands for, so there + /// is nothing to report but zero. + pub materialised: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/core/dr-sync/src/upload.rs b/core/dr-sync/src/upload.rs index 2d035c6..09cafa1 100644 --- a/core/dr-sync/src/upload.rs +++ b/core/dr-sync/src/upload.rs @@ -237,6 +237,7 @@ mod tests { size: *size, modified: None, has_preview: false, + materialised: true, }) .collect()) } diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index 861d9c4..f57f0fb 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -407,10 +407,9 @@ impl Default for ExportSettings { /// [`create_dir`](../../dr_sync/trait.RemoteBackend.html) and behaves /// identically on both platforms. /// -/// It is also where the photographs already are. A library that lives on -/// Nextcloud and exports to a phone's local storage has put the output -/// somewhere the user's other devices cannot see, which is rarely what was -/// meant. +/// It is also where the photographs already are. A library that lives on a +/// server and exports to a phone's local storage has put the output somewhere +/// the user's other devices cannot see, which is rarely what was meant. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ExportTarget { @@ -425,7 +424,11 @@ pub enum ExportTarget { /// destination was filled in. It is excluded from [`Self::available`] /// rather than offered and then failing. Device, - /// A folder on the connected account, created if absent. + /// A folder in the connected library, created if absent. + /// + /// Named for where it goes rather than for what is behind it: the library + /// may be a Nextcloud account or a folder on a mount, and the export + /// behaves identically either way. Remote, } @@ -466,7 +469,9 @@ impl ExportTarget { pub fn label(self) -> &'static str { match self { Self::Device => "This device", - Self::Remote => "Nextcloud", + // Not the connector's name: the library may be a server or a + // folder, and the setting means the same thing for both. + Self::Remote => "The library", } } diff --git a/docs/architecture.md b/docs/architecture.md index 78a8a15..2b3192d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,8 +56,9 @@ darkroom/ │ ├── dr-gpu wgpu device, tile scheduler, WGSL shaders, mask rasteriser │ ├── dr-colour lcms2 bindings, camera profiles, working-space transforms │ ├── dr-export encoders, resampling, output sizing -│ ├── dr-sync RemoteBackend trait, sync engine, cache rules, merge -│ └── dr-sync-nextcloud the only backend implementation (§8.4) +│ ├── dr-sync RemoteBackend + BackendProvider, Account, sync engine, merge +│ ├── dr-sync-nextcloud WebDAV, oc:fileid, chunked v2, Login Flow v2 (§8.4) +│ └── dr-sync-folder a plain directory: disk, mount, synced folder (§8.4a) ├── ui/ │ ├── dr-ui Slint components, adaptive layout, descriptor→control mapping │ └── dr-widgets custom controls per WidgetKind (curve, wheel, crop, brush) @@ -572,9 +573,34 @@ recovery. Panics in decode are caught at the boundary, since RAW parsing handles ## 8. Sync architecture -Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait; **only the Nextcloud connector is -implemented**, but the boundary is designed so S3, generic WebDAV, or a self-hosted photo server can -be added without touching the sync engine. +Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait and the capability model the engine +adapts to; two connectors implement it — `dr-sync-nextcloud` and `dr-sync-folder` — and S3, generic +WebDAV, or a self-hosted photo server can be added without touching the engine. + +**`docs/storage.md` is the contract**: the four traits a connector meets, the four steps to add one, +and what each shipped connector actually declares. This section says *why* the seam is shaped the +way it is; that document says how to use it. + +### 8.0 A trait is not a seam + +Worth stating because this was got wrong for a release. `RemoteBackend` existed from the start and +the code above it still knew it was talking to Nextcloud: seven files in `dr-ui` constructed a +`NextcloudBackend` directly, ten functions took one by concrete type, an account *was* a server URL +beside a DAV user id, and the local cache directory was named after a hostname. The abstraction was +real and bought nothing. + +Pluggable storage needs four things, and only the first is a trait over operations: + +| | What | Where | +|---|---|---| +| 1 | Operations | `RemoteBackend` (§8.3) | +| 2 | Capabilities — what is *cheap*, so the engine adapts rather than assumes | `Capabilities` (§8.1) | +| 3 | Configuration — what an account is, with no server in it | `Account`, `Connection` | +| 4 | Registration — how a connector is discovered without being named | `BackendProvider`, `BackendRegistry` | + +`ui/dr-ui/src/remote.rs` is the only file above `dr-sync` that names a connector. `dr-sync` itself +depends on none of them, so a build that only wants a folder library does not compile a TLS stack to +get one. ### 8.1 Why capability negotiation, not a common denominator @@ -702,7 +728,8 @@ Design notes worth keeping: ### 8.4 The Nextcloud connector -The only implementation. Mapping to the trait: +The reference implementation, and the one whose peculiarities the capability model exists to keep. +Mapping to the trait: | Trait method | Nextcloud | |---|---| @@ -739,6 +766,49 @@ never `Depth: infinity`. Two implementation details worth keeping: and proves nothing about children, so it is pure overhead; a test asserts zero probes in that case. Only `PropagatingEtags` makes an unchanged parent prove an unchanged subtree. +### 8.4a The folder connector + +A plain directory: a local disk, a network mount, an external drive, or the folder a Nextcloud +desktop client already syncs. No server, no account, no credential — which makes it the route that +works on a machine with no secrets daemon. + +It exists for two reasons. It is genuinely useful, and a second implementation is the only way to +find out whether the first was an abstraction or a description. Adding it is what turned §8.0's four +items from a claim into a fact. + +| Trait method | Folder | +|---|---| +| `capabilities` | `LocalEtags`, **no** stable ids, ranges, no chunking, conditional | +| `list` | `read_dir` + `metadata`; validator is `size`-`mtime` | +| `dir_validator` | `Unsupported` — a directory's mtime does not propagate | +| `delta` | `Unsupported` — a folder keeps no change feed | +| `get` + range | `seek` + `take`; a short read past the end is not an error | +| `put` | write to a temporary beside the destination, `rename` over it | +| `put` `IfAbsent` | `O_CREAT | O_EXCL` — genuinely atomic | +| `put` `IfMatch` | `stat`, compare, then the same rename — narrows the race, does not close it | +| `delete` | files, and *empty* directories only — see below | +| `move_to` | `rename`, falling back to copy + unlink across a mount boundary | +| auth | none | + +Three things worth carrying forward: + +- **`LocalEtags` is the honest answer, and it costs nothing.** A POSIX directory's mtime describes + its own entry list and nothing below it, so there is no propagation to exploit and the engine + walks the tree every scan. **Measured 2026-08-28**: a full uncached walk of 2,299 images + across 233 directories took **137 ms**, with no pruning at all — against 34.1 s for 17,185 RAWs + over WebDAV *with* pruning (§8.4). The walk that was expensive was expensive because it was + thousands of `PROPFIND`s. This is the capability model paying for itself — one engine, two + backends, each running at the speed it actually runs at. +- **Identity is a path hash, not an inode.** An inode is stable across a rename but differs between + devices and is reused after a delete, so two machines would disagree about which photograph a + thumbnail belonged to and a recycled inode would attach an old thumbnail to a new image. + Re-deriving a thumbnail is a cost; showing the wrong one is a bug. `stable_ids: false` reports the + consequence. +- **`delete` is deliberately not recursive**, unlike WebDAV's `DELETE` on a collection. There is no + server-side trash behind a local folder, so a caller with a wrong path would have no way back. + Nothing in the engine deletes a directory — the soft delete is a `move_to` (§FR-CAT-15) — so the + guard is free. + ### 8.5 Sidecar conflict resolution `put` with `Precondition::IfMatch(validator)`. On precondition failure: @@ -806,6 +876,41 @@ remains the only mechanism that satisfies FR-NC-3. - Never depend on it: the socket is Linux-only, absent on Android, and absent when the client is not running. The direct connector remains the primary path. +#### 9.0a Amendment, 2026-08-29 — VFS as a managed tier for *folder* libraries + +The rejection above was written when the only backend was the direct Nextcloud connector, and it +assumed the alternative to hydration was a range read. Since `dr-sync-folder` exists (§8.4a) a +library can be opened as a directory with **no server connection at all**, and that changes what +finding 3 is comparing against. + +**What finding 3 actually says.** Hydration transfers ~100× what a preview needs *when a range read +is available*. On a folder library there is no connector, so there are no range reads: the choice is +not hydrate-versus-range-read, it is hydrate-once or never have a thumbnail. The finding stands +unamended for the direct connector, which must still never hydrate to browse. + +**What makes the cost acceptable on a folder library**, and all three are required: + +1. **Hydration is a borrow, not an acquisition.** A file is returned to the state it was found in — + what the pass downloaded is released, what the user already had is left alone. Peak disk is the + working set, not the library. **Measured 2026-08-29** over 100 photographs of 25 MB, 90 of them + dehydrated: peak 275 MB against 2,500 MB unborrowed, back to 250 MB afterwards, and all ten the + user already held still there. +2. **It is paid once.** Thumbnails are kept, and `derived_sync` pushes the shards to the server, so + a second device downloads 200 MB of shards instead of hydrating 340 GB of RAWs. +3. **It is quoted and consented to.** Never automatic, never on the browsing path, always + resumable and cancellable (FR-NC-6c). + +**What this does not change.** Findings 1 and 2 stand and are now implemented rather than merely +noted: a stub is not transparent, so `RemoteEntry` carries `materialised` and the backend reports +`RemoteError::NotMaterialised` rather than a miss; and the socket remains the only way to hydrate, +so it stays Linux-only, optional, and absent on Android. + +**The one thing that must never be got wrong.** Releasing content means asking the client to +dehydrate — never deleting the file. A deletion inside a synced tree propagates to the server and +removes the photograph from every device the user owns. `RemoteBackend::dematerialise` says so, the +`Vfs` trait says so, and an implementation that cannot dehydrate returns `Unsupported` rather than +approximating it with `remove_file`. + ### 9.1 The three tiers, restated as policy | Tier | Content | Default | diff --git a/docs/requirements.md b/docs/requirements.md index 4cfb55c..b683e74 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -647,6 +647,25 @@ proxies, then thumbnails. **Metadata and sidecars are never evicted** — they a extension, and size, so users do not know what they actually have. Sync failures of legibility are more damaging than failures of transport. +**FR-NC-6d — Placeholder libraries.** Where a library is a folder kept by a sync client in +virtual-files mode, the app shall treat a placeholder as *the photograph, not downloaded* — never as +a one-byte file and never as a missing one. + +- A placeholder is catalogued under the photograph's own name, with an identity that does not change + when it is downloaded +- Reading one yields a distinct, actionable error; it shall **not** be reported as absent, because + the sidecar writer creates a new document when a sidecar is absent and would discard the existing + one (FR-CAT-8) +- Its size is reported as unknown rather than as the stub's byte count + +Where the client offers hydration, content may be fetched **as a borrow**: a file is returned to the +state it was found in, so a pass releases what it downloaded and leaves alone what the user already +had. Releasing means asking the client to dehydrate — **never deleting**, which inside a synced tree +would propagate to the server and remove the photograph everywhere. + +Hydration is whole-file and shall never serve browsing (ARCH §9.0 finding 3, §9.0a). It is for the +originals tier and for passes the user has been quoted a cost on and has agreed to. + **FR-NC-7 — Upload.** Files above 5MB use **chunked upload v2** against `/remote.php/dav/uploads//`: `MKCOL` to create the upload folder, `PUT` each chunk, then `MOVE` the `.file` pseudo-entry to the destination. Chunks are 5MB–5GB and named 1–10000. @@ -731,9 +750,16 @@ returns. WebDAV `SEARCH` (RFC 5323) against `/remote.php/dav/` filtered by mimetype and paginated via `d:limit`/`d:nresults`, in preference to walking thousands of folders with PROPFIND. -**FR-NC-12 — Backend independence.** Sync shall be implemented against a backend interface, with -Nextcloud as the only implementation in v1. No protocol detail specific to Nextcloud may appear -outside its connector. +**FR-NC-12 — Backend independence.** Sync shall be implemented against a backend interface. No +protocol detail specific to any one backend may appear outside its connector, and no layer above +the interface may name a connector — with the single exception of the registry that constructs them +(`dr_ui::remote`). + +A trait over operations is not sufficient on its own, and the first release proved it: `dr-ui` +constructed the Nextcloud backend directly in seven files, an account *was* a server URL beside a +DAV user id, and the local cache directory was named after a hostname. Independence requires four +things — operations, declared capabilities, an account model with no server in it, and a +registration mechanism (ARCH §8.0, `docs/storage.md`). Backends **declare capabilities** rather than conforming to a lowest common denominator, because the property that makes Nextcloud sync fast — directory ETags propagating up the tree, so an @@ -748,6 +774,21 @@ Where a capability is absent the app shall **degrade visibly, not silently**: - Without conditional writes, sidecar conflict detection falls back to revision comparison, which narrows but does not close the race; this is surfaced as a reduced-safety mode +**FR-NC-13 — Folder libraries.** A library shall be openable as a **plain directory** — a local +disk, a network mount, an external drive, or a folder another client already syncs — with no +account, no server and no credential. + +This is a requirement rather than a convenience for three reasons. It is what a photographer with +an archive drive and no server actually has. It is the only route that works where no secrets +daemon exists, which FR-NC-2 otherwise treats as a degraded mode. And a second connector is the +only way to keep FR-NC-12 honest: an interface with one implementation cannot be shown to be an +interface. + +The folder connector shall declare its capabilities truthfully rather than flatteringly — in +particular it shall **not** claim propagating directory ETags, because a POSIX directory's mtime +describes its own entry list and nothing beneath it, and a backend that claimed otherwise would +hide edits rather than merely run slowly (ARCH §8.4a). + ### 3.8 Platform integration #### Android diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..7fd1f8a --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,583 @@ +# Storage backends + +How DarkRoom talks to wherever a library lives, and what it takes to add +somewhere new. + +This document is the contract. `docs/architecture.md` §8 says why sync is built +on capability negotiation rather than a common denominator; this says what the +seam actually is, where each piece lives, and what a third connector has to do. + +--- + +## 1. What "pluggable" has to mean + +A trait alone does not make storage pluggable. `RemoteBackend` existed from the +first release and every layer above it still knew it was talking to Nextcloud: +seven files in `dr-ui` constructed a `NextcloudBackend` directly, ten functions +took one by concrete type, the account model was a server URL beside a DAV user +id, and the local cache directory was named after a hostname. The abstraction +was real and bought nothing, because everything that *reached* a backend was +still shaped like one product. + +Pluggable means all four of these, not just the first: + +1. **Operations** — what a backend can do. `RemoteBackend`. +2. **Capabilities** — what it can do *cheaply*, so the engine adapts instead of + assuming. `Capabilities`. +3. **Configuration** — what an account is, with no server in it. `Account`. +4. **Registration** — how the application discovers a connector at all, without + naming it. `BackendProvider` + `BackendRegistry`. + +Two connectors ship. Nextcloud is unchanged and keeps every one of its +peculiarities — those are the point of the capability model, not an +embarrassment it has to hide. The folder connector serves a plain directory and +exists partly because it is genuinely useful and partly because a second +implementation is the only way to find out whether the first was an +abstraction. + +--- + +## 2. Where each piece lives + +``` +core/dr-sync/ the contract, and nothing that speaks a protocol + ├─ types.rs RemotePath, RemoteId, RemoteEntry, Validator, … + ├─ capability.rs Capabilities, ChangeDetection, ServerPreviews + ├─ error.rs RemoteError — the one error every caller handles + ├─ account.rs Account, AccountStore, Secret, Connection + ├─ provider.rs BackendProvider, BackendRegistry, SignIn + ├─ lib.rs RemoteBackend, SyncStrategy + ├─ scan.rs the walk, driven by capabilities + ├─ upload.rs where an original is placed + └─ reachability.rs online/offline, inferred from observed results + +core/dr-sync-nextcloud/ WebDAV, oc:fileid, chunked upload v2, Login Flow v2 +core/dr-sync-folder/ a directory on a filesystem + +ui/dr-ui/src/remote.rs the registry — the ONLY file above dr-sync that + names a connector +``` + +`dr-sync` depends on no connector. That is deliberate and load-bearing: a build +that only wants a folder library must not compile a TLS stack to get one, and +the registry therefore lives in the crate that already depends on everything — +the interface. + +--- + +## 3. The four traits and types a connector meets + +### 3.1 `RemoteBackend` — operations + +```rust +#[async_trait] +pub trait RemoteBackend: Send + Sync { + fn capabilities(&self) -> &Capabilities; + fn name(&self) -> &str; + + // discovery + async fn list(&self, dir: &RemotePath, since: Option<&Validator>) + -> Result, RemoteError>; + async fn dir_validator(&self, dir: &RemotePath) -> Result; + async fn delta(&self, cursor: &Cursor) + -> Result<(Vec, Cursor), RemoteError>; + + // transfer + async fn get(&self, id: &RemoteId, range: Option>) + -> Result, RemoteError>; + async fn put(&self, path: &RemotePath, body: Vec, precond: Option) + -> Result; + async fn put_many(&self, items: Vec<(RemotePath, Vec)>) // defaulted + -> Result>, RemoteError>; + async fn delete(&self, id: &RemoteId, precond: Option) + -> Result<(), RemoteError>; + async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError>; + async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError>; + + // optional + async fn thumbnail(&self, id: &RemoteId, size: u32) // defaulted to None + -> Result>, RemoteError>; +} +``` + +Rules that are not obvious from the signatures: + +- **`dir_validator` and `delta` are capability-gated.** Return + `RemoteError::Unsupported` unless your `ChangeDetection` is + `PropagatingEtags` or `DeltaCursor` respectively. Answering + `dir_validator` with something that does not actually propagate is worse than + refusing: it lets a caller prune a subtree whose contents changed, and hides + those changes for as long as the folder list holds still. +- **`get` takes an optional range, and it is a hint.** A backend without cheap + ranges may return the whole object; the caller slices. Correctness holds + either way and `Capabilities::range_reads` says whether it was cheap. +- **Chunked upload is not in the trait.** It is an implementation detail of + `put`, chosen by body size. Exposing it would leak one server's protocol. +- **`move_to` must preserve identity where the backend has stable ids.** This + is what a soft delete uses (`FR-CAT-15`): a move implemented as copy + delete + allocates a new id, orphaning the thumbnail shard and turning a restore into a + full re-download. +- **`create_dir` makes parents and succeeds if the directory exists.** Callers + use it to guarantee a destination, not to claim they created one. + +### 3.2 `Capabilities` — what is cheap + +The engine reads these once at connect time and picks a `SyncStrategy`. See +ARCH §8.1–8.2 for the tiers. The two that change behaviour rather than speed: + +| Absent | Consequence the engine handles | +|---|---| +| `range_reads` | Embedded-preview extraction is impossible; browsing falls back to server previews or full download, and is refused on a metered connection | +| `conditional_write` | Sidecar conflict detection falls back to revision counters inside the sidecar — narrows the race, does not close it. Reported as a reduced-safety mode | + +**Declare what is true, not what is flattering.** A backend claiming +`PropagatingEtags` it does not have does not merely run slowly; it silently +hides changes. + +### 3.3 `Account` — configuration with no server in it + +```rust +pub struct Account { + pub backend: String, // BackendProvider::id; defaults to "nextcloud" on load + pub endpoint: String, // stored as "server" — a URL, a path, a bucket + pub login: String, // empty where the connector has no notion of a user + pub user_id: String, // connector-defined sub-address; Nextcloud's DAV segment + pub root: String, // the folder chosen as the library root + pub formats: Vec, + pub last_scan: Option, +} +``` + +Everything but `backend` is the connector's to interpret. Code above `dr-sync` +reads these for display and for cache keys, never for meaning. + +Two properties are load-bearing: + +- **The on-disk form is backwards compatible.** `backend` defaults to + `"nextcloud"` and `endpoint` is stored under its historical key `server`, so + every account written before there was a choice loads unchanged. A config the + app refuses to parse is an account the user has to set up again. +- **`Account::namespace()` is frozen for Nextcloud.** It names the directory + holding the catalog, the thumbnail shards, the sidecar spool and the export + outbox. Changing it does not lose that data, it *abandons* it — silently, as + an upgrade — and costs a full rescan on top. The Nextcloud form is reproduced + byte for byte from what `catalog_path` computed before; every other backend is + prefixed by its connector id, and long endpoints are truncated with a hash + tail so two deep paths cannot collide inside one filesystem's 255-byte + component limit. + +### 3.4 `Connection` and `Secret` — the credential split + +```rust +pub struct Connection { pub account: Account, pub secret: Option } +``` + +Credentials go to platform secure storage (`FR-NC-2`, `NFR-SEC-2`). Never the +catalog, never the config file, never a log line. `AccountStore` writes the +account as plain JSON and the secret to the keyring, which is what lets the app +show "signed in as duncan, watching /PhotosRaw" before it has touched the +keyring at all. + +`Secret`'s inner string is reachable only through `expose()`, and its `Debug` +prints `Secret(***)`. That closes the indirect leak — a `{:?}` on any struct +that happens to hold a connection — by construction rather than by review. + +`Connection` is also what replaced a pair of arguments (credentials, user id) +threaded together through fifteen signatures in an order that could be swapped. + +### 3.5 `BackendProvider` — registration + +```rust +pub trait BackendProvider: Send + Sync { + fn id(&self) -> &'static str; // written to Account::backend + fn display_name(&self) -> &'static str; + fn endpoint_label(&self) -> &'static str; // "Server" / "Folder" + fn endpoint_placeholder(&self) -> &'static str; + fn sign_in(&self) -> SignIn; + fn normalise_endpoint(&self, input: &str) -> Result; + fn account_for(&self, endpoint: &str) -> Result; // defaulted + fn connect(&self, conn: &Connection) -> Result, RemoteError>; +} + +pub enum SignIn { + /// A handshake the user completes outside the app, yielding a credential. + Browser, + /// The endpoint is the whole account. No credential, no waiting state. + EndpointOnly, +} +``` + +- **`id` is on-disk configuration.** Changing it after anyone has an account + orphans that account. Pick it once. +- **`normalise_endpoint` is where a bad endpoint is *rejected*,** before an + account is written for a library that does not exist. Its error string is + shown to the user, so it says what to fix rather than naming a type. The + Nextcloud provider upgrades `http://` to `https://` here (`NFR-SEC-3`); the + folder provider canonicalises the path, so two spellings of one directory do + not become two accounts indexing the same photographs. +- **`connect` is synchronous and cheap.** It validates configuration and builds + a client; it does not talk to the remote. Workers call it per task. +- **`SignIn` is a shape, not a method.** It would be tidier to expose + `async fn sign_in()`, and wrong: Login Flow v2 is a browser handshake the user + completes elsewhere while the app polls, so it is not one call, it does not + finish on our schedule, and the screen has to render a URL and a waiting state + in the middle of it. `SignIn` tells the launch screen which of the two shapes + to draw; the flow stays where its protocol is. + +**Credentials are deliberately not abstracted.** An app password, an OAuth +token and a bucket key pair have no useful common shape, and inventing one +before a third backend exists would produce a wrong answer confidently. The +general form is `Connection` — an account plus an opaque secret — and each +connector translates that into what its protocol needs +(`NextcloudProvider::credentials`). + +--- + +## 4. Adding a backend + +1. **Implement `RemoteBackend`** over your protocol, in a new + `core/dr-sync-` crate depending on `dr-sync` and nothing else of ours. +2. **Declare `Capabilities` honestly.** Start from `Capabilities::minimal()` and + raise only what you can actually deliver. +3. **Implement `BackendProvider`** beside it. +4. **Register it** in `ui/dr-ui/src/remote.rs::registry()` and add the crate to + `ui/dr-ui/Cargo.toml`. + +That is the whole list. Nothing else in `dr-ui` changes, because nothing else in +`dr-ui` names a connector. + +**Two things to get right, because they are silent when wrong:** + +- **Identity.** `RemoteEntry::id` should be `RemoteId::Stable(u64)` wherever you + can produce a `u64` that names the same photograph on every device looking at + the same library. The catalog keys the thumbnail shards and the face index on + it (`catalog.md` §10.1), and an entry without one gets neither. Set + `Capabilities::stable_ids` only if that id also survives a rename — the two + are different questions and only the second is a capability. +- **Path safety.** A `RemotePath` is built from names on the remote and from a + catalog another device wrote. If you resolve one against a real filesystem, + reject `..` before you open anything. + +Register a test double the same way — `BackendRegistry::register` replaces an +existing id rather than shadowing it — so an integration test can stand a fake +server behind `"nextcloud"` without the registry knowing it happened. + +--- + +## 5. The connectors that ship + +### 5.1 Nextcloud (`dr-sync-nextcloud`, id `"nextcloud"`) + +Unchanged by the abstraction, peculiarities intact — see ARCH §8.4 for the full +mapping. What matters here is that none of them had to be given up to make room +for a second backend: + +| | | +|---|---| +| `change_detection` | `PropagatingEtags` — the one-request no-op sync | +| `stable_ids` | yes, `oc:fileid`, survives server-side rename and move | +| `range_reads` | yes, detected by `206` vs `200`, never `HEAD` | +| `chunked_upload` | v2, 5 MB – 5 GB, `MKCOL` → `PUT` chunks → `MOVE .file` | +| `bulk_upload` | yes, `POST /remote.php/dav/bulk` | +| `conditional_write` | yes, `If-Match` | +| `server_previews` | `CommonFormatsOnly` — stock Nextcloud renders no RAW | +| sign-in | `SignIn::Browser`, Login Flow v2, system browser, app password | + +Also kept: the `oc:permissions` probe on a refused `PUT`, which is what +distinguishes a create-only share from a bad credential; the `423 Locked` +retry classification; and the bundled ISRG Root YE certificate. + +### 5.2 Folder (`dr-sync-folder`, id `"folder"`) + +A local disk, an NFS or SMB mount, an external drive, or the directory a +Nextcloud desktop client already syncs. No server, no account, no credential — +which makes it the route that works on a machine with no secrets daemon at all. + +| | | +|---|---| +| `change_detection` | `LocalEtags` — see below | +| `stable_ids` | **no** — the id is a path hash and does not survive a rename | +| `range_reads` | yes, `seek` + `take` | +| `chunked_upload` | none; a write is a write | +| `bulk_upload` | no | +| `conditional_write` | yes, with a documented residual race | +| `server_previews` | `None` | +| sign-in | `SignIn::EndpointOnly` | + +**Why `LocalEtags` and not `PropagatingEtags`.** A POSIX directory's mtime +changes when its own entry list changes and at no other time — not when a +child's contents are edited, and not for a grandchild. There is nothing to +propagate, so `dir_validator` returns `Unsupported` and the engine walks the +tree every scan. Which costs almost nothing, because the walk that was expensive +was expensive for a reason this backend does not have. + +**Measured 2026-08-28**, `cargo run -p dr-sync-folder --example scan`: a full +uncached walk of 2,299 images across 233 directories completed in **137 ms**, +and 380 images across 13 directories in **29 ms** — the same engine, the same +`Depth: 1`-per-directory walk, with no pruning at all. The Nextcloud connector's +comparable figure is 34.1 s for 17,185 RAWs across 334 directories *with* +pruning available (ARCH §8.4). The capability model is what lets one engine +drive both at the speed each actually runs at, instead of forcing the fast one +down to the slow one's interface. + +**Identity is a hash of the path relative to the library root**, FNV-1a 64 +(written out, because `DefaultHasher` is explicitly unstable between Rust +releases and this value is written into the catalog). It gives the catalog a +`u64` that names a photograph, is the same on every device looking at the same +folder, and does not change when the file is edited. It does not survive a +rename, and `stable_ids: false` says so: a moved photograph is seen as a delete +and an add, and its thumbnail is derived again. + +The alternative — keying on the inode — is stable across a rename but *differs +between devices* and is reused by the filesystem after a delete. Two machines +would disagree about which photograph a thumbnail belonged to, and a recycled +inode would silently attach an old thumbnail to a new image. Re-deriving a +thumbnail is a cost; showing the wrong one is a bug. + +**Conditional writes.** `IfAbsent` is genuinely atomic (`O_CREAT | O_EXCL`). +`IfMatch` is compare-then-swap: a `stat`, then a write to a temporary beside the +destination and a `rename` over it. A POSIX filesystem has no compare-and-swap, +so the race is narrowed to the microseconds between the two syscalls rather than +closed — still far tighter than the fallback the engine uses for a backend that +declares no conditional write at all, which spans a whole read-modify-write. +The capability is declared, and the residual race is documented at the call +site. + +**Two deliberate divergences from WebDAV semantics:** + +- **`delete` is not recursive.** A folder library is the user's own photographs + on their own disk with no server-side trash behind it, so a caller that passed + the wrong path would have no way back. Deleting a non-empty directory returns + `RemoteError::Configuration`. Nothing in the engine deletes a directory — the + soft delete is a `move_to` into the trash folder — so the guard is free. +- **Every filesystem call runs on the blocking pool.** On a local disk that is + overkill; on the NFS mount this backend is most useful over, a stalled server + would otherwise wedge the async worker that made the call and every other + request sharing it. + +**Failure classification** matters as much as the operations. A vanished mount +(`ESTALE`, `ENOTCONN`, `EIO`) maps to `RemoteError::Network`, which is what puts +the app into offline mode and leaves the catalog readable — exactly as a dead +server does. A permissions problem maps to `PermissionDenied` and does *not*, +because going offline over one forbidden file would hide a fixable problem +behind a network banner. An endpoint that is not a directory at all maps to +`RemoteError::Configuration`: nothing was unreachable and no credential was +wrong, so neither of the other two would send the user anywhere useful. + +--- + +## 6. Virtual filesystems + +A sync client in virtual-files mode leaves a **placeholder** where a file is +catalogued but not downloaded. On Linux — the only mode it supports — that +means `IMG.CR2` does not exist at all and `IMG.CR2.nextcloud` does, holding one +byte. ARCH §9.0 measured a real machine: 121,785 placeholders against 10,267 +materialised files. + +A folder library that ignores this is not merely degraded, it is dangerous. +Before the handling below existed, the folder connector catalogued every stub +as a 1-byte image, gave it an identity that changed the moment it was +downloaded, and — worst — reported a dehydrated *sidecar* as absent, which made +the sidecar writer create a fresh document over an existing one and discard +every edit another device had put there. + +### 6.1 Three questions, one trait + +Everything else about a synced folder is an ordinary directory, so this is not +a second connector. `dr_sync_folder::Vfs` asks only what differs: + +```rust +pub trait Vfs: Send + Sync { + fn name(&self) -> &'static str; + fn is_placeholder(&self, on_disk: &str) -> bool; + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str; + fn placeholder_name(&self, name: &str) -> Cow<'_, str>; + fn can_materialise(&self) -> bool; // defaulted false + fn materialise(&self, local: &Path) -> Result<(), RemoteError>; // defaulted + fn dematerialise(&self, local: &Path) -> Result<(), RemoteError>; // defaulted +} +``` + +`NoVfs` for a plain directory; `dr_sync_nextcloud::NextcloudVfs` for a synced +one, wrapping the `DesktopClient` socket. A third convention is a third impl. + +**Why not a `folder-vfs` provider.** The interesting capability is not a +property of the backend: the same directory can materialise on demand while the +client is running and cannot when it is down, so it must be computed per +connection either way. Registering two providers would ask the user to choose +between two things that differ by whether a background process is up. The +convention is detected instead, per connection, by a hook the registry supplies +(`FolderProvider::with_vfs_detector`) — which is what keeps `dr-sync-folder` +free of any client's protocol. + +### 6.2 What the backend reports + +| | | +|---|---| +| `RemoteEntry::path` | the photograph's name, never the stub's — so identity survives a download | +| `RemoteEntry::materialised` | `false` on a stub; the catalog maps it to `Availability::Offline` | +| `RemoteEntry::size` | `0` on a stub, meaning *unknown* — see below | +| `get` on a stub | `RemoteError::NotMaterialised`, **never** `NotFound` and never the stub's one byte | +| `put` over a stub, unconditional | **replaces it** — the whole file is being written, so there is nothing in the stub to keep, and the placeholder is removed after the content lands | +| `put` over a stub, `IfMatch` | `NotMaterialised` — a stub's validator describes the placeholder, so nothing here can satisfy the guard; the caller fetches and retries | +| `put` over a stub, `IfAbsent` | `PreconditionFailed` — the file *is* there, only its content is elsewhere | +| `move_to` a stub | moves the stub and keeps it a stub — culling without downloading is ordinary | +| `delete` a stub | deletes it; a photograph is deleted whether or not its bytes are here | +| `capabilities().materialisation` | `OnDemand` with a client, `Placeholders` without, `Always` on a plain folder | + +**Size is genuinely unknown.** A Linux suffix-mode stub is one byte and carries +no record of what it stands for. The client's `._sync_*.db` has the real size, +but that is a private schema and reading it would couple us to their migrations. +FR-NC-6c wants a transfer size quoted before an operation starts; for a stub the +honest answer is that it cannot be, and the interface should say so rather than +report one byte or invent an estimate silently. + +### 6.3 Hydration is a borrow + +The rule: **a file is returned to the state it was found in.** What a pass +downloaded is released; what the user already had is left alone. `BorrowPool` +enforces it. + +```rust +let pool = BorrowPool::new(); +{ + let held = pool.borrow(&backend, &path).await?; // downloads only if absent + // ... read it, thumbnail it, index its faces ... +} // borrow ends +let stats = pool.release_all(&backend).await; // dehydrates only what it hydrated +``` + +Three properties that are not obvious: + +- **Reference counted.** The thumbnail pass and the face pass meet on the same + RAW. Without counting, the first to finish dehydrates the file the second is + reading; with it, the transfer is paid once and released when the last + borrower is done. +- **Prior state is read before asking.** After `materialise` there is no way to + tell what the pass brought from what was already there, so it is recorded + first. Getting this wrong silently undoes a pin, and "my pinned trip + evaporated after an indexing run" is the failure that would make people stop + trusting the feature. +- **Being unsure is not symmetric.** `borrow_known(.., Some(true))` keeps a file + that might have been ours — costing disk. `Some(false)` releases one that + might have been the user's. An uncertain caller passes `true` or `None`, + never a guess at `false`. + +A borrow against a plain folder or a server backend short-circuits and does +nothing, so a pass written for a VFS library runs unchanged everywhere rather +than growing two code paths. + +**Measured 2026-08-29**, `cargo run -p dr-sync-folder --example vfs_cycle`: a +library of 100 photographs at 25 MB each, 90 of them dehydrated and 10 the user +keeps. A pass over all 100, borrowing and releasing as it goes: + +| | | +|---|---| +| on disk at rest | 250 MB | +| **peak during the pass** | **275 MB** — the resting set plus one photograph | +| without borrowing | 2,500 MB | +| on disk afterwards | 250 MB | +| of the 10 the user already had | 10 still there | + +The peak is the working set, not the library, and the release is selective. + + +### 6.4 Derived state is dehydrated too + +Shards and the catalog snapshot live in `.darkroom-derived/` **inside the +library folder**, so a sync client dehydrates them exactly as it dehydrates a +photograph. Unlike a photograph, none of them can be skipped: a shard that will +not open is a peer's thumbnails never merging, and a catalog snapshot that will +not open is their collections. + +`derived_sync::read_derived` fetches on demand rather than giving up. More +important is what happens when it *cannot*: + +The catalog sync is a read-modify-write over a file another device also writes. +It was shaped `if let Ok(bytes) = backend.get(..)`, which folded every failure +into "there is no remote catalog" and carried straight on to the upload — so a +dehydrated snapshot meant pushing ours over theirs unmerged, taking their +collections and members with it. The same shape as the sidecar bug in §6, and +the same fix: a read that fails for any reason other than `NotFound` **stops the +upload**. + +That is why `NotFound` and `NotMaterialised` had to be separate errors. One +means "yours is the whole truth, write it"; the other means "do not dare". + +### 6.5 Release means dehydrate, never delete + +The single most dangerous thing in this feature. A synced folder is not a +cache: deleting a materialised file inside it propagates the deletion to the +server and removes the photograph from every device the user owns. `Vfs` and +`RemoteBackend::dematerialise` both say so, and an implementation that cannot +dehydrate returns `Unsupported` rather than approximating it. + +This is also why the originals cache (`dr_catalog::cache`) cannot simply be +pointed at a VFS library: `Cache::release` deletes bytes, which is right for a +copy under `originals/` and catastrophic in place. + +### 6.6 Which photographs stay downloaded + +The user's half of the bargain: a pass borrows for a moment, but *some* of the +library should stay local — the trip you are about to take, the shoot you are +working on. + +That is a **pin**, and it is the pin the originals cache already had +(`dr_catalog::cache`, FR-NC-6a). Nothing parallel was built, because the model +was already the right one: + +| Cache concept | On a placeholder library | +|---|---| +| `tier_desired` | what the user asked to keep hydrated | +| `tier_actual` | what is actually materialised | +| `pending_pins()` | the work list — what to hydrate next, resumable | +| pinned rows are never evicted | a pinned collection is never dehydrated | +| passive rows, LRU under a budget | what a pass borrowed, released when it finishes | + +So "keep this collection hydrated" is `Cache::pin`, and the existing pin worker +drives it — except that on a placeholder library it calls `materialise` instead +of downloading a copy. + +**Why not a copy.** The original materialises *in the library folder*. Copying +it under `originals/` as well would hold every pinned photograph twice, and the +copy would be the half the budget could evict while the real disk cost stayed. +`Cache::record_in_place` records the bookkeeping with **`path = NULL`**, and +that null is load-bearing: `release` deletes the file a row names, and a row +that names none deletes nothing. The safety property is structural rather than +remembered. + +Unpinning therefore frees nothing by itself — the bytes are not ours to delete. +`spawn_dehydrate` asks the client to take them back, which is what actually +returns the disk. + +--- + +## 7. What the abstraction does not yet cover + +Stated so the next person does not have to rediscover it. + +- **Multiple accounts at once.** `AccountStore` holds a list and the launch + screen uses the most recent. Nothing in the model prevents two open libraries; + the interface has no place to show them. +- **Per-backend settings.** A connector has no way to contribute a settings + page. Anything configurable is on the `Account` or is not configurable. +- **Capability probing at runtime.** `Capabilities` is fixed at construction. + Nextcloud's `server_previews` should really be probed per account — a server + with `camerarawpreviews` installed can render RAW — and today it is assumed to + be `CommonFormatsOnly`. +- **A general notion of an account.** Credentials stay connector-specific on + purpose (§3.5). A third connector with an OAuth flow will need a third `SignIn` + variant, and that is the right place for it to appear. +- **A quoted cost before a hydrating pass.** FR-NC-6c wants the transfer size + stated before an operation that needs absent data. A placeholder reports no + size (§6.2), so the honest figure for "index this library" is a count and not + a byte total. The interface should say *n photographs, size unknown until + fetched* rather than estimate one silently — and it does not say anything yet. +- **Metadata-only placeholders.** Windows and macOS express these in filesystem + metadata rather than in the name, and carry the real size there. `Vfs` asks + its questions about a *name*, which is all the one convention this project has + met needs. Supporting them means widening the trait to take a `Metadata`, and + doing that before anyone has run this on those platforms would be guessing. +- **Hydration during browsing, deliberately.** It stays forbidden (ARCH §9.0 + finding 3). A grid cell whose content is absent shows as not-downloaded; only + a pass the user asked for may fetch. diff --git a/docs/traceability.md b/docs/traceability.md index 21fabda..595e606 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 281 | -| TRACES tags found | 812 | -| Requirements defined | 177 | -| Requirements covered | 106 | -| **Coverage** | **59.9%** (106/177) | +| Source files scanned | 290 | +| TRACES tags found | 843 | +| Requirements defined | 179 | +| Requirements covered | 107 | +| **Coverage** | **59.8%** (107/179) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 84 | 122 | +| FR | 85 | 124 | | NFR | 20 | 49 | | R | 2 | 6 | @@ -34,76 +34,77 @@ _None._ | ID | Tagged in | |---|---| | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | -| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1014`](../ui/dr-ui/ui/library.slint#L1014), [`ui/dr-ui/ui/library.slint:1176`](../ui/dr-ui/ui/library.slint#L1176), [`ui/dr-ui/ui/library.slint:863`](../ui/dr-ui/ui/library.slint#L863) | -| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:2247`](../ui/dr-ui/src/library.rs#L2247), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1014`](../ui/dr-ui/ui/library.slint#L1014), [`ui/dr-ui/ui/library.slint:1176`](../ui/dr-ui/ui/library.slint#L1176), [`ui/dr-ui/ui/library.slint:863`](../ui/dr-ui/ui/library.slint#L863) | +| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:2375`](../ui/dr-ui/src/library.rs#L2375), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) | | FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:180`](../ui/dr-ui/src/library.rs#L180), [`ui/dr-ui/src/library.rs:209`](../ui/dr-ui/src/library.rs#L209), [`ui/dr-ui/src/library.rs:3661`](../ui/dr-ui/src/library.rs#L3661), [`ui/dr-ui/src/library.rs:3695`](../ui/dr-ui/src/library.rs#L3695), [`ui/dr-ui/src/library_ui.rs:185`](../ui/dr-ui/src/library_ui.rs#L185), [`ui/dr-ui/src/library_ui.rs:758`](../ui/dr-ui/src/library_ui.rs#L758), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:456`](../core/dr-sync-nextcloud/src/lib.rs#L456), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:429`](../core/dr-sync/src/scan.rs#L429), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1219`](../ui/dr-ui/src/collections_ui.rs#L1219), [`ui/dr-ui/src/collections_ui.rs:1975`](../ui/dr-ui/src/collections_ui.rs#L1975), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:208`](../ui/dr-ui/src/library.rs#L208), [`ui/dr-ui/src/library.rs:3869`](../ui/dr-ui/src/library.rs#L3869), [`ui/dr-ui/src/library.rs:3903`](../ui/dr-ui/src/library.rs#L3903), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:756`](../ui/dr-ui/src/library_ui.rs#L756), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | -| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4593`](../ui/dr-ui/src/library_ui.rs#L4593), [`ui/dr-ui/ui/app.slint:316`](../ui/dr-ui/ui/app.slint#L316), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | -| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:190`](../ui/dr-ui/src/library.rs#L190), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3709`](../ui/dr-ui/src/library.rs#L3709), [`ui/dr-ui/src/library_ui.rs:629`](../ui/dr-ui/src/library_ui.rs#L629), [`ui/dr-ui/src/library_ui.rs:6390`](../ui/dr-ui/src/library_ui.rs#L6390), [`ui/dr-ui/src/library_ui.rs:6401`](../ui/dr-ui/src/library_ui.rs#L6401), [`ui/dr-ui/src/library_ui.rs:6414`](../ui/dr-ui/src/library_ui.rs#L6414), [`ui/dr-ui/src/library_ui.rs:6429`](../ui/dr-ui/src/library_ui.rs#L6429), [`ui/dr-ui/src/library_ui.rs:6438`](../ui/dr-ui/src/library_ui.rs#L6438), [`ui/dr-ui/ui/app.slint:261`](../ui/dr-ui/ui/app.slint#L261), [`ui/dr-ui/ui/app.slint:442`](../ui/dr-ui/ui/app.slint#L442), [`ui/dr-ui/ui/library.slint:1225`](../ui/dr-ui/ui/library.slint#L1225), [`ui/dr-ui/ui/library.slint:1228`](../ui/dr-ui/ui/library.slint#L1228), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:3955`](../ui/dr-ui/src/library.rs#L3955), [`ui/dr-ui/src/library_ui.rs:316`](../ui/dr-ui/src/library_ui.rs#L316), [`ui/dr-ui/src/library_ui.rs:388`](../ui/dr-ui/src/library_ui.rs#L388), [`ui/dr-ui/src/library_ui.rs:5204`](../ui/dr-ui/src/library_ui.rs#L5204), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:6530`](../ui/dr-ui/src/library_ui.rs#L6530), [`ui/dr-ui/ui/app.slint:264`](../ui/dr-ui/ui/app.slint#L264), [`ui/dr-ui/ui/app.slint:442`](../ui/dr-ui/ui/app.slint#L442), [`ui/dr-ui/ui/app.slint:687`](../ui/dr-ui/ui/app.slint#L687), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1301`](../ui/dr-ui/ui/library.slint#L1301), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) | -| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3485`](../ui/dr-ui/src/library_ui.rs#L3485), [`ui/dr-ui/src/library_ui.rs:4183`](../ui/dr-ui/src/library_ui.rs#L4183), [`ui/dr-ui/ui/app.slint:437`](../ui/dr-ui/ui/app.slint#L437), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2564`](../ui/dr-ui/ui/library.slint#L2564), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) | -| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3374`](../ui/dr-ui/src/develop.rs#L3374), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/lib.rs:1788`](../ui/dr-ui/src/lib.rs#L1788), [`ui/dr-ui/src/lib.rs:1924`](../ui/dr-ui/src/lib.rs#L1924), [`ui/dr-ui/src/lib.rs:498`](../ui/dr-ui/src/lib.rs#L498), [`ui/dr-ui/src/lib.rs:923`](../ui/dr-ui/src/lib.rs#L923), [`ui/dr-ui/src/library.rs:1656`](../ui/dr-ui/src/library.rs#L1656), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library.rs:507`](../ui/dr-ui/src/library.rs#L507), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:792`](../ui/dr-ui/src/library.rs#L792), [`ui/dr-ui/src/library_ui.rs:4885`](../ui/dr-ui/src/library_ui.rs#L4885), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2870`](../ui/dr-ui/src/develop.rs#L2870), [`ui/dr-ui/src/library.rs:149`](../ui/dr-ui/src/library.rs#L149), [`ui/dr-ui/src/library.rs:1616`](../ui/dr-ui/src/library.rs#L1616), [`ui/dr-ui/src/library.rs:1693`](../ui/dr-ui/src/library.rs#L1693), [`ui/dr-ui/src/library.rs:234`](../ui/dr-ui/src/library.rs#L234), [`ui/dr-ui/src/library.rs:4062`](../ui/dr-ui/src/library.rs#L4062), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:776`](../ui/dr-ui/src/library.rs#L776), [`ui/dr-ui/src/library.rs:792`](../ui/dr-ui/src/library.rs#L792), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library_ui.rs:1565`](../ui/dr-ui/src/library_ui.rs#L1565), [`ui/dr-ui/src/library_ui.rs:1591`](../ui/dr-ui/src/library_ui.rs#L1591), [`ui/dr-ui/src/library_ui.rs:1607`](../ui/dr-ui/src/library_ui.rs#L1607), [`ui/dr-ui/src/library_ui.rs:1701`](../ui/dr-ui/src/library_ui.rs#L1701), [`ui/dr-ui/src/library_ui.rs:227`](../ui/dr-ui/src/library_ui.rs#L227), [`ui/dr-ui/src/library_ui.rs:2317`](../ui/dr-ui/src/library_ui.rs#L2317), [`ui/dr-ui/src/library_ui.rs:260`](../ui/dr-ui/src/library_ui.rs#L260), [`ui/dr-ui/src/library_ui.rs:2755`](../ui/dr-ui/src/library_ui.rs#L2755), [`ui/dr-ui/src/library_ui.rs:2979`](../ui/dr-ui/src/library_ui.rs#L2979), [`ui/dr-ui/src/library_ui.rs:3260`](../ui/dr-ui/src/library_ui.rs#L3260), [`ui/dr-ui/src/library_ui.rs:3348`](../ui/dr-ui/src/library_ui.rs#L3348), [`ui/dr-ui/src/library_ui.rs:3536`](../ui/dr-ui/src/library_ui.rs#L3536), [`ui/dr-ui/src/library_ui.rs:3654`](../ui/dr-ui/src/library_ui.rs#L3654), [`ui/dr-ui/src/library_ui.rs:441`](../ui/dr-ui/src/library_ui.rs#L441), [`ui/dr-ui/src/library_ui.rs:499`](../ui/dr-ui/src/library_ui.rs#L499), [`ui/dr-ui/src/library_ui.rs:5302`](../ui/dr-ui/src/library_ui.rs#L5302), [`ui/dr-ui/src/library_ui.rs:5417`](../ui/dr-ui/src/library_ui.rs#L5417), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:319`](../ui/dr-ui/ui/app.slint#L319), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:189`](../ui/dr-ui/src/library.rs#L189), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1558`](../ui/dr-ui/src/collections_ui.rs#L1558), [`ui/dr-ui/src/collections_ui.rs:1577`](../ui/dr-ui/src/collections_ui.rs#L1577), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3917`](../ui/dr-ui/src/library.rs#L3917), [`ui/dr-ui/src/library_ui.rs:631`](../ui/dr-ui/src/library_ui.rs#L631), [`ui/dr-ui/src/library_ui.rs:6389`](../ui/dr-ui/src/library_ui.rs#L6389), [`ui/dr-ui/src/library_ui.rs:6400`](../ui/dr-ui/src/library_ui.rs#L6400), [`ui/dr-ui/src/library_ui.rs:6413`](../ui/dr-ui/src/library_ui.rs#L6413), [`ui/dr-ui/src/library_ui.rs:6428`](../ui/dr-ui/src/library_ui.rs#L6428), [`ui/dr-ui/src/library_ui.rs:6437`](../ui/dr-ui/src/library_ui.rs#L6437), [`ui/dr-ui/ui/app.slint:264`](../ui/dr-ui/ui/app.slint#L264), [`ui/dr-ui/ui/app.slint:445`](../ui/dr-ui/ui/app.slint#L445), [`ui/dr-ui/ui/library.slint:1225`](../ui/dr-ui/ui/library.slint#L1225), [`ui/dr-ui/ui/library.slint:1228`](../ui/dr-ui/ui/library.slint#L1228), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:4163`](../ui/dr-ui/src/library.rs#L4163), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:393`](../ui/dr-ui/src/library_ui.rs#L393), [`ui/dr-ui/src/library_ui.rs:5203`](../ui/dr-ui/src/library_ui.rs#L5203), [`ui/dr-ui/src/library_ui.rs:5256`](../ui/dr-ui/src/library_ui.rs#L5256), [`ui/dr-ui/src/library_ui.rs:6529`](../ui/dr-ui/src/library_ui.rs#L6529), [`ui/dr-ui/ui/app.slint:267`](../ui/dr-ui/ui/app.slint#L267), [`ui/dr-ui/ui/app.slint:445`](../ui/dr-ui/ui/app.slint#L445), [`ui/dr-ui/ui/app.slint:690`](../ui/dr-ui/ui/app.slint#L690), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1301`](../ui/dr-ui/ui/library.slint#L1301), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) | +| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3489`](../ui/dr-ui/src/library_ui.rs#L3489), [`ui/dr-ui/src/library_ui.rs:4182`](../ui/dr-ui/src/library_ui.rs#L4182), [`ui/dr-ui/ui/app.slint:440`](../ui/dr-ui/ui/app.slint#L440), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2564`](../ui/dr-ui/ui/library.slint#L2564), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) | +| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3374`](../ui/dr-ui/src/develop.rs#L3374), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1376`](../ui/dr-ui/src/lib.rs#L1376), [`ui/dr-ui/src/lib.rs:1777`](../ui/dr-ui/src/lib.rs#L1777), [`ui/dr-ui/src/lib.rs:1912`](../ui/dr-ui/src/lib.rs#L1912), [`ui/dr-ui/src/lib.rs:501`](../ui/dr-ui/src/lib.rs#L501), [`ui/dr-ui/src/lib.rs:926`](../ui/dr-ui/src/lib.rs#L926), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library_ui.rs:4884`](../ui/dr-ui/src/library_ui.rs#L4884), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2870`](../ui/dr-ui/src/develop.rs#L2870), [`ui/dr-ui/src/library.rs:148`](../ui/dr-ui/src/library.rs#L148), [`ui/dr-ui/src/library.rs:1747`](../ui/dr-ui/src/library.rs#L1747), [`ui/dr-ui/src/library.rs:1823`](../ui/dr-ui/src/library.rs#L1823), [`ui/dr-ui/src/library.rs:233`](../ui/dr-ui/src/library.rs#L233), [`ui/dr-ui/src/library.rs:4270`](../ui/dr-ui/src/library.rs#L4270), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:774`](../ui/dr-ui/src/library.rs#L774), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1580`](../ui/dr-ui/src/library_ui.rs#L1580), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1622`](../ui/dr-ui/src/library_ui.rs#L1622), [`ui/dr-ui/src/library_ui.rs:1716`](../ui/dr-ui/src/library_ui.rs#L1716), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:2332`](../ui/dr-ui/src/library_ui.rs#L2332), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:2770`](../ui/dr-ui/src/library_ui.rs#L2770), [`ui/dr-ui/src/library_ui.rs:2992`](../ui/dr-ui/src/library_ui.rs#L2992), [`ui/dr-ui/src/library_ui.rs:3270`](../ui/dr-ui/src/library_ui.rs#L3270), [`ui/dr-ui/src/library_ui.rs:3358`](../ui/dr-ui/src/library_ui.rs#L3358), [`ui/dr-ui/src/library_ui.rs:3540`](../ui/dr-ui/src/library_ui.rs#L3540), [`ui/dr-ui/src/library_ui.rs:3654`](../ui/dr-ui/src/library_ui.rs#L3654), [`ui/dr-ui/src/library_ui.rs:446`](../ui/dr-ui/src/library_ui.rs#L446), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/library_ui.rs:5301`](../ui/dr-ui/src/library_ui.rs#L5301), [`ui/dr-ui/src/library_ui.rs:5416`](../ui/dr-ui/src/library_ui.rs#L5416), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | -| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1896`](../ui/dr-ui/src/lib.rs#L1896), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | -| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:255`](../ui/dr-ui/src/library.rs#L255), [`ui/dr-ui/src/library.rs:285`](../ui/dr-ui/src/library.rs#L285), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1884`](../ui/dr-ui/src/lib.rs#L1884), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:284`](../ui/dr-ui/src/library.rs#L284), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | -| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) | -| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460) | -| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2725`](../ui/dr-ui/src/library.rs#L2725), [`ui/dr-ui/src/library.rs:2829`](../ui/dr-ui/src/library.rs#L2829), [`ui/dr-ui/ui/settings.slint:404`](../ui/dr-ui/ui/settings.slint#L404), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | +| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459) | +| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2849`](../ui/dr-ui/src/library.rs#L2849), [`ui/dr-ui/src/library.rs:2953`](../ui/dr-ui/src/library.rs#L2953), [`ui/dr-ui/ui/settings.slint:404`](../ui/dr-ui/ui/settings.slint#L404), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1472`](../ui/dr-ui/src/lib.rs#L1472), [`ui/dr-ui/src/lib.rs:2174`](../ui/dr-ui/src/lib.rs#L2174), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/library.rs:507`](../ui/dr-ui/src/library.rs#L507), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:1761`](../ui/dr-ui/ui/app.slint#L1761), [`ui/dr-ui/ui/app.slint:790`](../ui/dr-ui/ui/app.slint#L790), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:613`](../ui/dr-ui/src/lib.rs#L613), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1461`](../ui/dr-ui/src/lib.rs#L1461), [`ui/dr-ui/src/lib.rs:2162`](../ui/dr-ui/src/lib.rs#L2162), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:1766`](../ui/dr-ui/ui/app.slint#L1766), [`ui/dr-ui/ui/app.slint:793`](../ui/dr-ui/ui/app.slint#L793), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:616`](../ui/dr-ui/src/lib.rs#L616), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | | FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4636`](../ui/dr-ui/src/develop.rs#L4636) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | -| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2148`](../ui/dr-ui/src/lib.rs#L2148), [`ui/dr-ui/src/lib.rs:546`](../ui/dr-ui/src/lib.rs#L546), [`ui/dr-ui/src/lib.rs:604`](../ui/dr-ui/src/lib.rs#L604), [`ui/dr-ui/src/library.rs:498`](../ui/dr-ui/src/library.rs#L498), [`ui/dr-ui/src/library.rs:747`](../ui/dr-ui/src/library.rs#L747), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:1006`](../ui/dr-ui/ui/adjust.slint#L1006), [`ui/dr-ui/ui/adjust.slint:924`](../ui/dr-ui/ui/adjust.slint#L924), [`ui/dr-ui/ui/app.slint:2251`](../ui/dr-ui/ui/app.slint#L2251), [`ui/dr-ui/ui/app.slint:568`](../ui/dr-ui/ui/app.slint#L568) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2136`](../ui/dr-ui/src/lib.rs#L2136), [`ui/dr-ui/src/lib.rs:549`](../ui/dr-ui/src/lib.rs#L549), [`ui/dr-ui/src/lib.rs:607`](../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/library.rs:497`](../ui/dr-ui/src/library.rs#L497), [`ui/dr-ui/src/library.rs:745`](../ui/dr-ui/src/library.rs#L745), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:1006`](../ui/dr-ui/ui/adjust.slint#L1006), [`ui/dr-ui/ui/adjust.slint:924`](../ui/dr-ui/ui/adjust.slint#L924), [`ui/dr-ui/ui/app.slint:2256`](../ui/dr-ui/ui/app.slint#L2256), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:205`](../core/dr-pipeline/src/framing.rs#L205), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:927`](../core/dr-pipeline/src/framing.rs#L927), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1992`](../ui/dr-ui/src/develop.rs#L1992), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | -| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1420`](../ui/dr-ui/src/lib.rs#L1420), [`ui/dr-ui/src/lib.rs:1450`](../ui/dr-ui/src/lib.rs#L1450), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/library.rs:1656`](../ui/dr-ui/src/library.rs#L1656), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library.rs:488`](../ui/dr-ui/src/library.rs#L488), [`ui/dr-ui/src/library_ui.rs:2577`](../ui/dr-ui/src/library_ui.rs#L2577), [`ui/dr-ui/src/library_ui.rs:2979`](../ui/dr-ui/src/library_ui.rs#L2979), [`ui/dr-ui/src/library_ui.rs:467`](../ui/dr-ui/src/library_ui.rs#L467), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:547`](../ui/dr-ui/src/settings_ui.rs#L547), [`ui/dr-ui/ui/adjust.slint:613`](../ui/dr-ui/ui/adjust.slint#L613), [`ui/dr-ui/ui/library.slint:1328`](../ui/dr-ui/ui/library.slint#L1328), [`ui/dr-ui/ui/library.slint:837`](../ui/dr-ui/ui/library.slint#L837), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | -| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1479`](../ui/dr-ui/src/lib.rs#L1479), [`ui/dr-ui/src/lib.rs:2441`](../ui/dr-ui/src/lib.rs#L2441), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:700`](../ui/dr-ui/ui/adjust.slint#L700), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1666`](../ui/dr-ui/ui/app.slint#L1666), [`ui/dr-ui/ui/app.slint:1839`](../ui/dr-ui/ui/app.slint#L1839), [`ui/dr-ui/ui/app.slint:2157`](../ui/dr-ui/ui/app.slint#L2157), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:754`](../ui/dr-ui/src/lib.rs#L754), [`ui/dr-ui/src/lib.rs:813`](../ui/dr-ui/src/lib.rs#L813) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:1439`](../ui/dr-ui/src/lib.rs#L1439), [`ui/dr-ui/src/lib.rs:1447`](../ui/dr-ui/src/lib.rs#L1447), [`ui/dr-ui/src/lib.rs:2332`](../ui/dr-ui/src/lib.rs#L2332), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1376`](../ui/dr-ui/src/lib.rs#L1376), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:487`](../ui/dr-ui/src/library.rs#L487), [`ui/dr-ui/src/library_ui.rs:2592`](../ui/dr-ui/src/library_ui.rs#L2592), [`ui/dr-ui/src/library_ui.rs:2992`](../ui/dr-ui/src/library_ui.rs#L2992), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:549`](../ui/dr-ui/src/settings_ui.rs#L549), [`ui/dr-ui/ui/adjust.slint:613`](../ui/dr-ui/ui/adjust.slint#L613), [`ui/dr-ui/ui/library.slint:1328`](../ui/dr-ui/ui/library.slint#L1328), [`ui/dr-ui/ui/library.slint:837`](../ui/dr-ui/ui/library.slint#L837), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | +| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1447`](../ui/dr-ui/src/lib.rs#L1447), [`ui/dr-ui/src/lib.rs:2332`](../ui/dr-ui/src/lib.rs#L2332), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1468`](../ui/dr-ui/src/lib.rs#L1468), [`ui/dr-ui/src/lib.rs:2429`](../ui/dr-ui/src/lib.rs#L2429), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:700`](../ui/dr-ui/ui/adjust.slint#L700), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1671`](../ui/dr-ui/ui/app.slint#L1671), [`ui/dr-ui/ui/app.slint:1844`](../ui/dr-ui/ui/app.slint#L1844), [`ui/dr-ui/ui/app.slint:2162`](../ui/dr-ui/ui/app.slint#L2162), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:757`](../ui/dr-ui/src/lib.rs#L757), [`ui/dr-ui/src/lib.rs:816`](../ui/dr-ui/src/lib.rs#L816) | | FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101) | | FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1435`](../ui/dr-ui/src/lib.rs#L1435), [`ui/dr-ui/src/lib.rs:2647`](../ui/dr-ui/src/lib.rs#L2647) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1535`](../ui/dr-ui/src/lib.rs#L1535), [`ui/dr-ui/src/lib.rs:314`](../ui/dr-ui/src/lib.rs#L314), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | -| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/develop.rs:5519`](../ui/dr-ui/src/develop.rs#L5519), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:1435`](../ui/dr-ui/src/lib.rs#L1435), [`ui/dr-ui/src/lib.rs:2624`](../ui/dr-ui/src/lib.rs#L2624), [`ui/dr-ui/src/lib.rs:2647`](../ui/dr-ui/src/lib.rs#L2647), [`ui/dr-ui/ui/app.slint:1526`](../ui/dr-ui/ui/app.slint#L1526), [`ui/dr-ui/ui/app.slint:47`](../ui/dr-ui/ui/app.slint#L47), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:731`](../ui/dr-ui/ui/settings.slint#L731) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1424`](../ui/dr-ui/src/lib.rs#L1424), [`ui/dr-ui/src/lib.rs:2635`](../ui/dr-ui/src/lib.rs#L2635) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1524`](../ui/dr-ui/src/lib.rs#L1524), [`ui/dr-ui/src/lib.rs:314`](../ui/dr-ui/src/lib.rs#L314), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/develop.rs:5519`](../ui/dr-ui/src/develop.rs#L5519), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1398`](../ui/dr-ui/src/lib.rs#L1398), [`ui/dr-ui/src/lib.rs:1424`](../ui/dr-ui/src/lib.rs#L1424), [`ui/dr-ui/src/lib.rs:2612`](../ui/dr-ui/src/lib.rs#L2612), [`ui/dr-ui/src/lib.rs:2635`](../ui/dr-ui/src/lib.rs#L2635), [`ui/dr-ui/ui/app.slint:1531`](../ui/dr-ui/ui/app.slint#L1531), [`ui/dr-ui/ui/app.slint:47`](../ui/dr-ui/ui/app.slint#L47), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:731`](../ui/dr-ui/ui/settings.slint#L731) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:600`](../core/dr-types/src/settings.rs#L600), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204), [`ui/dr-ui/src/lib.rs:2090`](../ui/dr-ui/src/lib.rs#L2090), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/lib.rs:397`](../ui/dr-ui/src/lib.rs#L397), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library_ui.rs:3365`](../ui/dr-ui/src/library_ui.rs#L3365), [`ui/dr-ui/src/library_ui.rs:558`](../ui/dr-ui/src/library_ui.rs#L558), [`ui/dr-ui/src/library_ui.rs:6372`](../ui/dr-ui/src/library_ui.rs#L6372), [`ui/dr-ui/src/library_ui.rs:6449`](../ui/dr-ui/src/library_ui.rs#L6449), [`ui/dr-ui/src/library_ui.rs:6461`](../ui/dr-ui/src/library_ui.rs#L6461), [`ui/dr-ui/src/library_ui.rs:661`](../ui/dr-ui/src/library_ui.rs#L661), [`ui/dr-ui/src/library_ui.rs:718`](../ui/dr-ui/src/library_ui.rs#L718), [`ui/dr-ui/ui/app.slint:1367`](../ui/dr-ui/ui/app.slint#L1367), [`ui/dr-ui/ui/app.slint:926`](../ui/dr-ui/ui/app.slint#L926), [`ui/dr-ui/ui/library.slint:1336`](../ui/dr-ui/ui/library.slint#L1336), [`ui/dr-ui/ui/library.slint:841`](../ui/dr-ui/ui/library.slint#L841), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) | -| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:604`](../ui/dr-ui/src/settings_ui.rs#L604) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204), [`ui/dr-ui/src/lib.rs:2078`](../ui/dr-ui/src/lib.rs#L2078), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/lib.rs:397`](../ui/dr-ui/src/lib.rs#L397), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library_ui.rs:3375`](../ui/dr-ui/src/library_ui.rs#L3375), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`ui/dr-ui/src/library_ui.rs:6371`](../ui/dr-ui/src/library_ui.rs#L6371), [`ui/dr-ui/src/library_ui.rs:6448`](../ui/dr-ui/src/library_ui.rs#L6448), [`ui/dr-ui/src/library_ui.rs:6460`](../ui/dr-ui/src/library_ui.rs#L6460), [`ui/dr-ui/src/library_ui.rs:663`](../ui/dr-ui/src/library_ui.rs#L663), [`ui/dr-ui/src/library_ui.rs:720`](../ui/dr-ui/src/library_ui.rs#L720), [`ui/dr-ui/ui/app.slint:1372`](../ui/dr-ui/ui/app.slint#L1372), [`ui/dr-ui/ui/app.slint:929`](../ui/dr-ui/ui/app.slint#L929), [`ui/dr-ui/ui/library.slint:1336`](../ui/dr-ui/ui/library.slint#L1336), [`ui/dr-ui/ui/library.slint:841`](../ui/dr-ui/ui/library.slint#L841), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) | +| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:619`](../ui/dr-ui/src/export.rs#L619), [`ui/dr-ui/src/export.rs:647`](../ui/dr-ui/src/export.rs#L647), [`ui/dr-ui/src/export.rs:777`](../ui/dr-ui/src/export.rs#L777), [`ui/dr-ui/src/export.rs:794`](../ui/dr-ui/src/export.rs#L794), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2829`](../ui/dr-ui/src/develop.rs#L2829), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362) | -| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) | -| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library.rs:1049`](../ui/dr-ui/src/library.rs#L1049), [`ui/dr-ui/src/library.rs:1693`](../ui/dr-ui/src/library.rs#L1693), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library_ui.rs:1607`](../ui/dr-ui/src/library_ui.rs#L1607), [`ui/dr-ui/src/library_ui.rs:3365`](../ui/dr-ui/src/library_ui.rs#L3365), [`ui/dr-ui/src/library_ui.rs:499`](../ui/dr-ui/src/library_ui.rs#L499), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:904`](../core/dr-sync-nextcloud/src/lib.rs#L904), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | -| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4593`](../ui/dr-ui/src/library_ui.rs#L4593), [`ui/dr-ui/ui/app.slint:316`](../ui/dr-ui/ui/app.slint#L316), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | -| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | -| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) | +| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`ui/dr-ui/src/launch.rs:277`](../ui/dr-ui/src/launch.rs#L277), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:417`](../ui/dr-ui/src/launch_ui.rs#L417) | +| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068), [`ui/dr-ui/src/library.rs:1823`](../ui/dr-ui/src/library.rs#L1823), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1622`](../ui/dr-ui/src/library_ui.rs#L1622), [`ui/dr-ui/src/library_ui.rs:3375`](../ui/dr-ui/src/library_ui.rs#L3375), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:39`](../core/dr-sync-nextcloud/src/lib.rs#L39), [`core/dr-sync-nextcloud/src/lib.rs:913`](../core/dr-sync-nextcloud/src/lib.rs#L913), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | +| FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:316`](../ui/dr-ui/src/launch_ui.rs#L316), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | +| FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`core/dr-sync/src/account.rs:59`](../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | +| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:319`](../ui/dr-ui/ui/app.slint#L319), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../core/dr-sync-nextcloud/src/propfind.rs#L103), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61) | +| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:1014`](../core/dr-catalog/src/schema.rs#L1014), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1819`](../ui/dr-ui/src/lib.rs#L1819), [`ui/dr-ui/src/lib.rs:2710`](../ui/dr-ui/src/lib.rs#L2710), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1458`](../ui/dr-ui/src/library.rs#L1458), [`ui/dr-ui/src/library.rs:1616`](../ui/dr-ui/src/library.rs#L1616), [`ui/dr-ui/src/library_ui.rs:1066`](../ui/dr-ui/src/library_ui.rs#L1066), [`ui/dr-ui/src/library_ui.rs:1139`](../ui/dr-ui/src/library_ui.rs#L1139), [`ui/dr-ui/src/library_ui.rs:1240`](../ui/dr-ui/src/library_ui.rs#L1240), [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295), [`ui/dr-ui/src/library_ui.rs:1413`](../ui/dr-ui/src/library_ui.rs#L1413), [`ui/dr-ui/src/library_ui.rs:1532`](../ui/dr-ui/src/library_ui.rs#L1532), [`ui/dr-ui/src/library_ui.rs:2059`](../ui/dr-ui/src/library_ui.rs#L2059), [`ui/dr-ui/src/library_ui.rs:268`](../ui/dr-ui/src/library_ui.rs#L268), [`ui/dr-ui/src/library_ui.rs:279`](../ui/dr-ui/src/library_ui.rs#L279), [`ui/dr-ui/src/library_ui.rs:287`](../ui/dr-ui/src/library_ui.rs#L287), [`ui/dr-ui/src/library_ui.rs:299`](../ui/dr-ui/src/library_ui.rs#L299), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:400`](../ui/dr-ui/src/library_ui.rs#L400), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:452`](../ui/dr-ui/src/library_ui.rs#L452), [`ui/dr-ui/src/library_ui.rs:515`](../ui/dr-ui/src/library_ui.rs#L515), [`ui/dr-ui/src/library_ui.rs:5319`](../ui/dr-ui/src/library_ui.rs#L5319), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:558`](../ui/dr-ui/src/library_ui.rs#L558), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2330`](../ui/dr-ui/ui/app.slint#L2330), [`ui/dr-ui/ui/app.slint:431`](../ui/dr-ui/ui/app.slint#L431), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:980`](../ui/dr-ui/ui/library.slint#L980) | -| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295) | -| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1066`](../ui/dr-ui/src/library_ui.rs#L1066), [`ui/dr-ui/src/library_ui.rs:1139`](../ui/dr-ui/src/library_ui.rs#L1139), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | -| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372) | -| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | -| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1788`](../ui/dr-ui/src/lib.rs#L1788), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library_ui.rs:467`](../ui/dr-ui/src/library_ui.rs#L467) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library.rs:976`](../ui/dr-ui/src/library.rs#L976) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1014`](../core/dr-catalog/src/schema.rs#L1014), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1807`](../ui/dr-ui/src/lib.rs#L1807), [`ui/dr-ui/src/lib.rs:2698`](../ui/dr-ui/src/lib.rs#L2698), [`ui/dr-ui/src/library.rs:1449`](../ui/dr-ui/src/library.rs#L1449), [`ui/dr-ui/src/library.rs:1472`](../ui/dr-ui/src/library.rs#L1472), [`ui/dr-ui/src/library.rs:1747`](../ui/dr-ui/src/library.rs#L1747), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1429`](../ui/dr-ui/src/library_ui.rs#L1429), [`ui/dr-ui/src/library_ui.rs:1547`](../ui/dr-ui/src/library_ui.rs#L1547), [`ui/dr-ui/src/library_ui.rs:2074`](../ui/dr-ui/src/library_ui.rs#L2074), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:284`](../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:304`](../ui/dr-ui/src/library_ui.rs#L304), [`ui/dr-ui/src/library_ui.rs:313`](../ui/dr-ui/src/library_ui.rs#L313), [`ui/dr-ui/src/library_ui.rs:405`](../ui/dr-ui/src/library_ui.rs#L405), [`ui/dr-ui/src/library_ui.rs:415`](../ui/dr-ui/src/library_ui.rs#L415), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:520`](../ui/dr-ui/src/library_ui.rs#L520), [`ui/dr-ui/src/library_ui.rs:5318`](../ui/dr-ui/src/library_ui.rs#L5318), [`ui/dr-ui/src/library_ui.rs:5336`](../ui/dr-ui/src/library_ui.rs#L5336), [`ui/dr-ui/src/library_ui.rs:5348`](../ui/dr-ui/src/library_ui.rs#L5348), [`ui/dr-ui/src/library_ui.rs:551`](../ui/dr-ui/src/library_ui.rs#L551), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2335`](../ui/dr-ui/ui/app.slint#L2335), [`ui/dr-ui/ui/app.slint:434`](../ui/dr-ui/ui/app.slint#L434), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:980`](../ui/dr-ui/ui/library.slint#L980) | +| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) | +| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:803`](../core/dr-sync-folder/src/lib.rs#L803), [`core/dr-sync-folder/src/lib.rs:842`](../core/dr-sync-folder/src/lib.rs#L842), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/derived_sync.rs:627`](../ui/dr-ui/src/derived_sync.rs#L627), [`ui/dr-ui/src/library.rs:1569`](../ui/dr-ui/src/library.rs#L1569), [`ui/dr-ui/src/library.rs:1659`](../ui/dr-ui/src/library.rs#L1659), [`ui/dr-ui/src/library.rs:3138`](../ui/dr-ui/src/library.rs#L3138), [`ui/dr-ui/src/library.rs:3476`](../ui/dr-ui/src/library.rs#L3476), [`ui/dr-ui/src/library.rs:948`](../ui/dr-ui/src/library.rs#L948), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1337`](../ui/dr-ui/src/library_ui.rs#L1337), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | +| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:104`](../core/dr-sync-nextcloud/src/lib.rs#L104), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372) | +| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1777`](../ui/dr-ui/src/lib.rs#L1777), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library.rs:998`](../ui/dr-ui/src/library.rs#L998) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | -| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:863`](../ui/dr-ui/src/lib.rs#L863), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | +| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:866`](../ui/dr-ui/src/lib.rs#L866), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) | | FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) | | FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | @@ -111,38 +112,38 @@ _None._ | FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2792`](../ui/dr-ui/src/lib.rs#L2792), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:164`](../ui/dr-ui/ui/identity.slint#L164), [`ui/dr-ui/ui/library.slint:1046`](../ui/dr-ui/ui/library.slint#L1046) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/lib.rs:87`](../ui/dr-ui/src/lib.rs#L87), [`ui/dr-ui/src/library_ui.rs:287`](../ui/dr-ui/src/library_ui.rs#L287), [`ui/dr-ui/src/library_ui.rs:5204`](../ui/dr-ui/src/library_ui.rs#L5204), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/src/library_ui.rs:6480`](../ui/dr-ui/src/library_ui.rs#L6480), [`ui/dr-ui/ui/adjust.slint:506`](../ui/dr-ui/ui/adjust.slint#L506), [`ui/dr-ui/ui/adjust.slint:641`](../ui/dr-ui/ui/adjust.slint#L641), [`ui/dr-ui/ui/adjust.slint:962`](../ui/dr-ui/ui/adjust.slint#L962), [`ui/dr-ui/ui/app.slint:1945`](../ui/dr-ui/ui/app.slint#L1945), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:468`](../ui/dr-ui/ui/app.slint#L468), [`ui/dr-ui/ui/app.slint:54`](../ui/dr-ui/ui/app.slint#L54), [`ui/dr-ui/ui/app.slint:761`](../ui/dr-ui/ui/app.slint#L761), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/masks.slint:304`](../ui/dr-ui/ui/masks.slint#L304), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) | -| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/library_ui.rs:4388`](../ui/dr-ui/src/library_ui.rs#L4388), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1761`](../ui/dr-ui/ui/app.slint#L1761), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | -| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4388`](../ui/dr-ui/src/library_ui.rs#L4388), [`ui/dr-ui/src/library_ui.rs:4433`](../ui/dr-ui/src/library_ui.rs#L4433), [`ui/dr-ui/src/library_ui.rs:4536`](../ui/dr-ui/src/library_ui.rs#L4536), [`ui/dr-ui/src/library_ui.rs:4564`](../ui/dr-ui/src/library_ui.rs#L4564), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/ui/app.slint:1603`](../ui/dr-ui/ui/app.slint#L1603), [`ui/dr-ui/ui/app.slint:437`](../ui/dr-ui/ui/app.slint#L437), [`ui/dr-ui/ui/app.slint:468`](../ui/dr-ui/ui/app.slint#L468), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) | -| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2406`](../ui/dr-ui/src/lib.rs#L2406), [`ui/dr-ui/src/lib.rs:2831`](../ui/dr-ui/src/lib.rs#L2831), [`ui/dr-ui/src/lib.rs:3016`](../ui/dr-ui/src/lib.rs#L3016), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:89`](../ui/dr-ui/ui/app.slint#L89), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2780`](../ui/dr-ui/src/lib.rs#L2780), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:164`](../ui/dr-ui/ui/identity.slint#L164), [`ui/dr-ui/ui/library.slint:1046`](../ui/dr-ui/ui/library.slint#L1046) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1488`](../ui/dr-ui/src/collections_ui.rs#L1488), [`ui/dr-ui/src/collections_ui.rs:1502`](../ui/dr-ui/src/collections_ui.rs#L1502), [`ui/dr-ui/src/collections_ui.rs:1548`](../ui/dr-ui/src/collections_ui.rs#L1548), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1646`](../ui/dr-ui/src/collections_ui.rs#L1646), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/lib.rs:87`](../ui/dr-ui/src/lib.rs#L87), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:5203`](../ui/dr-ui/src/library_ui.rs#L5203), [`ui/dr-ui/src/library_ui.rs:5348`](../ui/dr-ui/src/library_ui.rs#L5348), [`ui/dr-ui/src/library_ui.rs:6479`](../ui/dr-ui/src/library_ui.rs#L6479), [`ui/dr-ui/ui/adjust.slint:506`](../ui/dr-ui/ui/adjust.slint#L506), [`ui/dr-ui/ui/adjust.slint:641`](../ui/dr-ui/ui/adjust.slint#L641), [`ui/dr-ui/ui/adjust.slint:962`](../ui/dr-ui/ui/adjust.slint#L962), [`ui/dr-ui/ui/app.slint:1950`](../ui/dr-ui/ui/app.slint#L1950), [`ui/dr-ui/ui/app.slint:464`](../ui/dr-ui/ui/app.slint#L464), [`ui/dr-ui/ui/app.slint:471`](../ui/dr-ui/ui/app.slint#L471), [`ui/dr-ui/ui/app.slint:54`](../ui/dr-ui/ui/app.slint#L54), [`ui/dr-ui/ui/app.slint:764`](../ui/dr-ui/ui/app.slint#L764), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/masks.slint:304`](../ui/dr-ui/ui/masks.slint#L304), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1766`](../ui/dr-ui/ui/app.slint#L1766), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | +| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1488`](../ui/dr-ui/src/collections_ui.rs#L1488), [`ui/dr-ui/src/collections_ui.rs:1502`](../ui/dr-ui/src/collections_ui.rs#L1502), [`ui/dr-ui/src/collections_ui.rs:1548`](../ui/dr-ui/src/collections_ui.rs#L1548), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1646`](../ui/dr-ui/src/collections_ui.rs#L1646), [`ui/dr-ui/src/collections_ui.rs:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/library_ui.rs:4432`](../ui/dr-ui/src/library_ui.rs#L4432), [`ui/dr-ui/src/library_ui.rs:4535`](../ui/dr-ui/src/library_ui.rs#L4535), [`ui/dr-ui/src/library_ui.rs:4563`](../ui/dr-ui/src/library_ui.rs#L4563), [`ui/dr-ui/src/library_ui.rs:5336`](../ui/dr-ui/src/library_ui.rs#L5336), [`ui/dr-ui/src/library_ui.rs:5348`](../ui/dr-ui/src/library_ui.rs#L5348), [`ui/dr-ui/ui/app.slint:1608`](../ui/dr-ui/ui/app.slint#L1608), [`ui/dr-ui/ui/app.slint:440`](../ui/dr-ui/ui/app.slint#L440), [`ui/dr-ui/ui/app.slint:471`](../ui/dr-ui/ui/app.slint#L471), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) | +| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2394`](../ui/dr-ui/src/lib.rs#L2394), [`ui/dr-ui/src/lib.rs:2819`](../ui/dr-ui/src/lib.rs#L2819), [`ui/dr-ui/src/lib.rs:3004`](../ui/dr-ui/src/lib.rs#L3004), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:89`](../ui/dr-ui/ui/app.slint#L89), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262) | -| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2829`](../ui/dr-ui/src/library.rs#L2829) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:2124`](../ui/dr-ui/src/lib.rs#L2124), [`ui/dr-ui/ui/app.slint:937`](../ui/dr-ui/ui/app.slint#L937), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) | -| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) | +| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2953`](../ui/dr-ui/src/library.rs#L2953) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2112`](../ui/dr-ui/src/lib.rs#L2112), [`ui/dr-ui/ui/app.slint:940`](../ui/dr-ui/ui/app.slint#L940), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) | +| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:499`](../ui/dr-ui/src/export.rs#L499) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | | NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | -| NFR-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:3795`](../ui/dr-ui/src/library.rs#L3795), [`ui/dr-ui/src/library.rs:4568`](../ui/dr-ui/src/library.rs#L4568), [`ui/dr-ui/src/library_ui.rs:4073`](../ui/dr-ui/src/library_ui.rs#L4073), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | -| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | +| NFR-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:4003`](../ui/dr-ui/src/library.rs#L4003), [`ui/dr-ui/src/library.rs:4795`](../ui/dr-ui/src/library.rs#L4795), [`ui/dr-ui/src/library_ui.rs:4072`](../ui/dr-ui/src/library_ui.rs#L4072), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | +| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | | NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) | -| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:1049`](../ui/dr-ui/src/library.rs#L1049) | +| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72) | -| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724) | +| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | -| NFR-SEC-2 | [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | +| NFR-SEC-2 | [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | | NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) | | R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | ## Not yet tagged -71 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +72 of 179 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -156,6 +157,7 @@ _None._ - FR-DSP-2 - FR-DSP-4 - FR-NC-11 +- FR-NC-6d - FR-PLAT-AND-2 - FR-PLAT-AND-4 - FR-PLAT-AND-5 diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 526794c..77d30f2 100644 --- a/ui/dr-ui/Cargo.toml +++ b/ui/dr-ui/Cargo.toml @@ -25,6 +25,7 @@ tokio.workspace = true reqwest.workspace = true dr-plat.workspace = true dr-sync.workspace = true +dr-sync-folder.workspace = true dr-sync-nextcloud.workspace = true dr-export.workspace = true dr-ingest.workspace = true @@ -119,3 +120,6 @@ live-style = ["dep:serde_norway"] # The face_index batch job wants a log level from the environment; the library # itself only ever calls `log`, and picks up whatever the application installs. env_logger.workspace = true +# Standing in a test backend behind `dyn RemoteBackend`, which is an +# `#[async_trait]` trait — implementing one needs the same attribute. +async-trait.workspace = true diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs index 1afd5e2..f07717e 100644 --- a/ui/dr-ui/src/collections_ui.rs +++ b/ui/dr-ui/src/collections_ui.rs @@ -1167,16 +1167,11 @@ fn start_trash( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, - session: &Rc< - dyn Fn() -> Option<( - dr_sync_nextcloud::AppCredentials, - dr_sync_nextcloud::Session, - )>, - >, + session: &Rc Option>, images: &[ImageId], reload: &Rc, ) { - let Some((creds, sess)) = session() else { + let Some(conn) = session() else { window.set_collection_error("Open a library first.".into()); return; }; @@ -1184,7 +1179,7 @@ fn start_trash( let moves = { let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; - match crate::trash::plan_trash(cat, &sess.root, images) { + match crate::trash::plan_trash(cat, &conn.account.root, images) { Ok(m) => m, Err(e) => { window.set_collection_error(format!("planning delete: {e}").into()); @@ -1205,11 +1200,10 @@ fn start_trash( let count = moves.len(); let rx = crate::trash::spawn_move( - creds, - sess.user_id.clone(), + conn.clone(), moves, crate::trash::Direction::ToTrash, - crate::library::catalog_path(&sess.server, &sess.user_id), + crate::library::catalog_path(&conn.account), ); drain_trash( @@ -1239,16 +1233,11 @@ fn start_restore( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, - session: &Rc< - dyn Fn() -> Option<( - dr_sync_nextcloud::AppCredentials, - dr_sync_nextcloud::Session, - )>, - >, + session: &Rc Option>, images: &[ImageId], reload: &Rc, ) { - let Some((creds, sess)) = session() else { + let Some(conn) = session() else { window.set_collection_error("Open a library first.".into()); return; }; @@ -1282,11 +1271,10 @@ fn start_restore( let count = moves.len(); let rx = crate::trash::spawn_move( - creds, - sess.user_id.clone(), + conn.clone(), moves, crate::trash::Direction::Restore, - crate::library::catalog_path(&sess.server, &sess.user_id), + crate::library::catalog_path(&conn.account), ); drain_trash( @@ -1466,10 +1454,7 @@ pub fn wire( S: Fn() + 'static, R: Fn() -> Vec + 'static, P: Fn(usize, usize) -> Vec + 'static, - C: Fn() -> Option<( - dr_sync_nextcloud::AppCredentials, - dr_sync_nextcloud::Session, - )> + 'static, + C: Fn() -> Option + 'static, { // Coerced to trait objects here rather than at each use: `start_trash` and // `drain_trash` are shared by three callbacks, and a generic parameter would @@ -1479,12 +1464,7 @@ pub fn wire( // A shift-click asks the catalog what lies between its two ends, and the // catalog belongs to the grid's controller — see `span_source`. *ctl.span_source.borrow_mut() = Some(Rc::new(span_ids)); - let session: Rc< - dyn Fn() -> Option<( - dr_sync_nextcloud::AppCredentials, - dr_sync_nextcloud::Session, - )>, - > = Rc::new(session); + let session: Rc Option> = Rc::new(session); // --- selection --------------------------------------------------------- { @@ -2098,8 +2078,8 @@ pub fn wire( window.on_trash_empty(move || { let Some(w) = weak.upgrade() else { return }; - let (creds, sess) = match session() { - Some(s) => s, + let conn = match session() { + Some(c) => c, None => return, }; @@ -2131,12 +2111,11 @@ pub fn wire( let count = ids.len(); let rx = crate::trash::spawn_purge( - creds, - sess.user_id.clone(), + conn.clone(), ids, paths, - crate::library::catalog_path(&sess.server, &sess.user_id), - crate::library::thumbs_dir(&sess.server, &sess.user_id), + crate::library::catalog_path(&conn.account), + crate::library::thumbs_dir(&conn.account), ); drain_trash( diff --git a/ui/dr-ui/src/derived_sync.rs b/ui/dr-ui/src/derived_sync.rs index 7b16b89..2b38d7c 100644 --- a/ui/dr-ui/src/derived_sync.rs +++ b/ui/dr-ui/src/derived_sync.rs @@ -1,5 +1,5 @@ //! TRACES: FR-CAT-3 | FR-CAT-7 | FR-NC-7 -//! Pushing derived state to Nextcloud: thumbnail shards and the catalog. +//! Pushing derived state to the library: thumbnail shards and the catalog. //! //! # What travels, and why only this //! @@ -34,8 +34,8 @@ use std::path::{Path, PathBuf}; -use dr_sync::{RemoteBackend, RemoteId, RemotePath}; -use dr_sync_nextcloud::AppCredentials; +use dr_sync::{Connection, RemoteBackend, RemoteError, RemoteId, RemotePath}; + use dr_thumbs::ThumbStore; /// Folder under the library root holding derived state. @@ -96,8 +96,7 @@ pub enum SyncMessage { /// Runs on its own thread with its own runtime, like every other network path /// here — the Slint loop must never block (NFR-P9). pub fn spawn_sync( - creds: AppCredentials, - user_id: String, + conn: Connection, root: String, thumbs_dir: PathBuf, catalog_path: PathBuf, @@ -117,7 +116,7 @@ pub fn spawn_sync( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { let _ = tx.send(SyncMessage::Failed(e.to_string())); @@ -282,7 +281,10 @@ async fn sync_shards( } let source = RemotePath::new(format!("{}/{name}", base.as_str())); - let bytes = match backend.get(&RemoteId::Path(source), None).await { + // Fetched where it is only a placeholder: a shard that will not open + // is a peer's thumbnails never merging, and on a library the client + // keeps dehydrated that would be every shard, every pass, silently. + let bytes = match read_derived(backend, &source).await { Ok(b) => b, Err(e) => { log::warn!("downloading {name}: {e}"); @@ -465,7 +467,9 @@ async fn sync_face_shards( ))); let source = RemotePath::new(format!("{}/{name}", face_base.as_str())); - let bytes = match backend.get(&RemoteId::Path(source), None).await { + // Fetched where it is only a placeholder, for the reason the thumbnail + // shards are: otherwise a peer's faces never arrive and nothing says so. + let bytes = match read_derived(backend, &source).await { Ok(b) => b, Err(e) => { log::warn!("downloading face shard {name}: {e}"); @@ -548,7 +552,30 @@ async fn sync_catalog( // Merging before uploading means our upload carries the union rather than // only our own half, so a third device syncing next gets everything in one // fetch. - if let Ok(bytes) = backend.get(&RemoteId::Path(target.clone()), None).await { + // TRACES: FR-NC-9 | FR-NC-6c + // A read that fails for any reason other than "there is not one yet" must + // stop the upload below. This is a read-modify-write over a file another + // device also writes, so skipping the read does not merely lose an + // optimisation — it turns the write into a clobber, and the other device's + // collections and their members go with it. + // + // The shape was previously `if let Ok(bytes) = ...`, which swallowed every + // failure into "no remote catalog" and carried straight on to the upload. + let theirs = match read_derived(backend, &target).await { + Ok(bytes) => Some(bytes), + // Genuinely the first sync of this library. Nothing to merge, and + // ours is the whole truth. + Err(RemoteError::NotFound(_)) => None, + Err(e) => { + log::warn!( + "not pushing the catalog: the copy on the server could not be read ({e}); \ + uploading over it would discard whatever another device put there" + ); + return Ok(()); + } + }; + + if let Some(bytes) = theirs { let downloaded = scratch.join("catalog-remote.sqlite"); if std::fs::write(&downloaded, &bytes).is_ok() { match dr_catalog::Catalog::open(catalog_path) { @@ -558,9 +585,19 @@ async fn sync_catalog( report.collections_gained = merge.inserted + merge.updated; report.members_gained = merge.members_added; } - Err(e) => log::warn!("merging remote catalog: {e}"), + // Unreadable is not the same as absent: it may be a newer + // format, or a torn upload. Ours must not go over it. + Err(e) => { + log::warn!("not pushing the catalog: merging the server's copy: {e}"); + let _ = std::fs::remove_file(&downloaded); + return Ok(()); + } }, - Err(e) => log::warn!("opening catalog to merge: {e}"), + Err(e) => { + log::warn!("not pushing the catalog: opening ours to merge: {e}"); + let _ = std::fs::remove_file(&downloaded); + return Ok(()); + } } let _ = std::fs::remove_file(&downloaded); } @@ -587,6 +624,34 @@ async fn sync_catalog( Ok(()) } +/// TRACES: FR-NC-6c +/// Read a derived file, fetching its content first if only a placeholder is +/// here. +/// +/// Derived state lives *inside the library folder*, so on a placeholder +/// library a sync client dehydrates a shard or a catalog snapshot exactly as +/// it dehydrates a photograph. Unlike a photograph, these are ours, and none of +/// them can be skipped: a shard that will not open is face data that never +/// merges, and a catalog snapshot that will not open is the other device's +/// collections. +/// +/// So this fetches rather than giving up — and where it cannot, it says so +/// with the error rather than an empty result, because the callers below treat +/// "nothing there" as licence to write their own copy (ARCH §9.0a). +async fn read_derived( + backend: &dyn RemoteBackend, + path: &RemotePath, +) -> Result, RemoteError> { + let id = RemoteId::Path(path.clone()); + match backend.get(&id, None).await { + Err(RemoteError::NotMaterialised(_)) => { + backend.materialise(&id).await?; + backend.get(&id, None).await + } + other => other, + } +} + fn shard_name(client: &str, id: u32) -> String { format!("shard-{client}-{id:04}.sqlite") } @@ -688,3 +753,165 @@ mod tests { .did_anything()); } } + +#[cfg(test)] +mod catalog_guard_tests { + //! What `sync_catalog` does when it cannot read the server's copy. + //! + //! The bug these exist for was a control-flow one — `if let Ok(bytes)` + //! folding every failure into "there is none yet" and falling through to + //! the upload — so the thing to assert is not a value but *whether a write + //! happened at all*. + + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// A backend whose read fails in a chosen way, counting writes. + struct Fussy { + fail_with: Option, + puts: Arc, + caps: dr_sync::Capabilities, + } + + impl Fussy { + fn reading(fail_with: Option) -> (Self, Arc) { + let puts = Arc::new(AtomicUsize::new(0)); + ( + Self { + fail_with, + puts: puts.clone(), + caps: dr_sync::Capabilities::minimal(), + }, + puts, + ) + } + } + + #[async_trait::async_trait] + impl RemoteBackend for Fussy { + fn capabilities(&self) -> &dr_sync::Capabilities { + &self.caps + } + fn name(&self) -> &str { + "fussy" + } + async fn list( + &self, + _dir: &RemotePath, + _since: Option<&dr_sync::Validator>, + ) -> Result, RemoteError> { + Ok(Vec::new()) + } + async fn dir_validator( + &self, + _dir: &RemotePath, + ) -> Result { + Err(RemoteError::Unsupported("test")) + } + async fn delta( + &self, + _c: &dr_sync::Cursor, + ) -> Result<(Vec, dr_sync::Cursor), RemoteError> { + Err(RemoteError::Unsupported("test")) + } + async fn get( + &self, + _id: &RemoteId, + _r: Option>, + ) -> Result, RemoteError> { + match &self.fail_with { + Some(RemoteError::NotFound(s)) => Err(RemoteError::NotFound(s.clone())), + Some(RemoteError::NotMaterialised(s)) => { + Err(RemoteError::NotMaterialised(s.clone())) + } + Some(_) => Err(RemoteError::PermissionDenied), + None => Ok(Vec::new()), + } + } + async fn put( + &self, + _p: &RemotePath, + _b: Vec, + _pc: Option, + ) -> Result { + self.puts.fetch_add(1, Ordering::SeqCst); + Ok(dr_sync::Validator::new("v")) + } + async fn delete( + &self, + _id: &RemoteId, + _pc: Option, + ) -> Result<(), RemoteError> { + Ok(()) + } + async fn move_to(&self, _f: &RemoteId, _t: &RemotePath) -> Result<(), RemoteError> { + Ok(()) + } + async fn create_dir(&self, _p: &RemotePath) -> Result<(), RemoteError> { + Ok(()) + } + } + + /// A real catalog and a scratch directory, since `sync_catalog` snapshots + /// one before uploading. + fn fixture(name: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("dr-catalog-guard-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("scratch")).unwrap(); + let catalog_path = dir.join("catalog.sqlite"); + dr_catalog::Catalog::open(&catalog_path).unwrap(); + (catalog_path, dir.join("scratch")) + } + + async fn run_with(fail_with: Option, name: &str) -> (usize, SyncReport) { + let (catalog_path, scratch) = fixture(name); + let (backend, puts) = Fussy::reading(fail_with); + let mut report = SyncReport::default(); + sync_catalog( + &backend, + &RemotePath::new(".darkroom-derived"), + &catalog_path, + &scratch, + &mut report, + ) + .await + .unwrap(); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + (puts.load(Ordering::SeqCst), report) + } + + #[tokio::test] + async fn a_catalog_that_is_here_but_not_downloaded_is_never_written_over() { + // The bug. On a placeholder library the snapshot is dehydrated, the + // read fails, and the old code took that for "there is no remote + // catalog" and pushed ours — discarding the other device's + // collections and their members on every single sync. + let (puts, report) = run_with( + Some(RemoteError::NotMaterialised("catalog.sqlite".into())), + "notmaterialised", + ) + .await; + assert_eq!(puts, 0, "must not upload over a catalog it could not read"); + assert!(!report.catalog_uploaded); + assert!(!report.catalog_merged); + } + + #[tokio::test] + async fn a_catalog_that_cannot_be_read_at_all_is_never_written_over() { + // Not only placeholders: a refused read, a dropped connection. Any + // failure that is not "there is none" leaves the server's copy alone. + let (puts, _) = run_with(Some(RemoteError::PermissionDenied), "denied").await; + assert_eq!(puts, 0); + } + + #[tokio::test] + async fn the_first_sync_of_a_library_still_uploads() { + // The other half, and the reason `NotFound` had to stay distinct: with + // genuinely nothing on the server, ours *is* the whole truth and + // refusing to push it would mean the catalog never syncs at all. + let (puts, report) = run_with(Some(RemoteError::NotFound("nope".into())), "firstrun").await; + assert_eq!(puts, 1, "nothing to merge, so ours goes up"); + assert!(report.catalog_uploaded); + } +} diff --git a/ui/dr-ui/src/export.rs b/ui/dr-ui/src/export.rs index ded64ed..46ddf3c 100644 --- a/ui/dr-ui/src/export.rs +++ b/ui/dr-ui/src/export.rs @@ -62,7 +62,7 @@ use std::time::Duration; use dr_export::{Encoded, NameContext}; use dr_sync::RemotePath; -use dr_sync_nextcloud::AppCredentials; +use dr_sync::{Account, Connection}; use dr_types::{ExportSettings, ExportTarget}; use crate::AppWindow; @@ -72,8 +72,8 @@ use crate::AppWindow; /// Beside the catalog, for the reason in the module docs. Per account, /// because the destination folder is a path on one particular server and an /// entry queued for one account is meaningless to another. -pub fn outbox_dir(server: &str, user_id: &str) -> PathBuf { - crate::library::catalog_path(server, user_id) +pub fn outbox_dir(account: &Account) -> PathBuf { + crate::library::catalog_path(account) .parent() .map(|p| p.join("outbox")) .unwrap_or_else(|| std::env::temp_dir().join("darkroom-outbox")) @@ -146,7 +146,7 @@ impl Placed { ), // Named as queued rather than exported: the file is real and // finished, but it is not yet where the user asked for it, and - // saying "exported to Nextcloud" before it has uploaded would be + // saying "exported to the library" before it has uploaded would be // a claim the app cannot keep if the disk is pulled. Placed::Queued { remote_dir, .. } => { let dir = if remote_dir.is_empty() { @@ -318,8 +318,7 @@ pub enum UploadMessage { /// network error would burn the whole queue against a server that is not /// answering, and the next pass costs nothing. pub fn spawn_upload( - creds: AppCredentials, - user_id: String, + conn: Connection, root: String, outbox: PathBuf, ) -> std::sync::mpsc::Receiver { @@ -339,7 +338,7 @@ pub fn spawn_upload( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { let _ = tx.send(UploadMessage::Finished { @@ -486,7 +485,7 @@ pub struct BatchRequest { /// Credentials for the account the library is open on. `None` where no /// library is open, which is fine for a [`Source::Rendered`] and fatal for /// anything that has to be fetched. - pub creds: Option<(AppCredentials, String)>, + pub conn: Option, pub settings: ExportSettings, pub outbox: PathBuf, pub sidecar_cache: PathBuf, @@ -692,7 +691,7 @@ fn render_from_library( cache: Option, cancel: &Cancel, ) -> Option> { - let Some((creds, user_id)) = request.creds.clone() else { + let Some(conn) = request.conn.clone() else { return Some(Err(ItemError::Fetch("no library is open".into()))); }; let Some(gpu) = request.gpu.as_ref() else { @@ -706,13 +705,12 @@ fn render_from_library( // RAW, so it costs nothing to have in hand by the time there is a session // to apply it to. let sidecar_rx = crate::library::spawn_sidecar_fetch( - creds.clone(), - user_id.clone(), + conn.clone(), path.to_string(), request.sidecar_cache.clone(), request.offline, ); - let bytes_rx = crate::library::spawn_full_fetch(creds, user_id, path.to_string(), cache); + let bytes_rx = crate::library::spawn_full_fetch(conn, path.to_string(), cache); let bytes = match wait_for(&bytes_rx, cancel) { Waited::Got(Ok(bytes)) => bytes, @@ -1332,7 +1330,7 @@ mod tests { fn request(settings: ExportSettings, sources: Vec) -> BatchRequest { BatchRequest { sources, - creds: None, + conn: None, settings, outbox: std::env::temp_dir().join("dr-batch-test-outbox"), sidecar_cache: std::env::temp_dir().join("dr-batch-test-sidecars"), diff --git a/ui/dr-ui/src/faces.rs b/ui/dr-ui/src/faces.rs index 11b7376..95e6f24 100644 --- a/ui/dr-ui/src/faces.rs +++ b/ui/dr-ui/src/faces.rs @@ -506,21 +506,31 @@ pub fn recluster( // Which faces the user has already ruled on, so they enter as anchors. // - // Two kinds of ruling, and the second is easy to miss. A *confirmation* is - // the obvious one. But setting a person aside is a ruling too, and the - // faces it covers are only ever suggestions — so anchoring confirmations - // alone left every ignored group's faces loose, and the next Regroup - // scattered them into fresh unnamed groups that were not ignored. The - // strangers came straight back, which is the feature not working at all. + // Anything the user has ruled on anchors, and there are three ways of + // ruling — only the first of which is obvious. // - // Anchoring them keeps them where the user put them, and does one better: - // a newly indexed face similar to a group that was set aside merges *into* - // it, so a stranger photographed again stays set aside instead of - // reappearing as somebody new. + // A **confirmation** is the plain case. **Setting a group aside** is one + // too, and the faces it covers are only ever suggestions, so anchoring + // confirmations alone let every ignored group scatter into fresh unnamed + // groups that were not ignored, and the strangers came straight back. + // + // And so is **giving a group a name**. That was the omission that did the + // most damage, because it is silent. Naming a cluster does not confirm its + // faces — they stay suggestions — so the next Regroup cut them loose, + // regrouped them into a brand new person, and left the named one holding + // nothing. `prune_empty_unnamed` will not remove it, because it has a name. + // Name the new group the same thing and it happens again. That is how one + // library came to hold sixteen people called Catherine, fourteen of them + // empty, with her faces split across the two that were not. + // + // A name is a judgement about *this group* (FR-CULL-12), exactly as an + // ignore is. Anchoring them all also does one better: a newly indexed face + // that matches a named person now merges *into* them rather than arriving + // as a stranger. let mut confirmed = std::collections::HashMap::new(); for p in faces::people(conn)? { - // Suggestions included exactly when the group was set aside. - for f in faces::for_person(conn, p.id, p.ignored)? { + let ruled_on = p.ignored || !p.name.trim().is_empty(); + for f in faces::for_person(conn, p.id, ruled_on)? { confirmed.insert(f.id, p.id); } } @@ -1178,4 +1188,60 @@ mod tests { assert_eq!(after.len(), 1); assert!(!after[0].ignored); } + + /// Naming a group does not confirm its faces, so before this they were + /// still only suggestions — and the next Regroup cut them loose, built a + /// new person out of them, and left the named one empty. Do that a few + /// times and the rail fills with same-named people holding nothing while + /// the faces sit under whichever one was made last. + #[test] + fn a_named_group_keeps_its_faces_through_the_next_regroup() { + let catalog = catalog_with(3); + put_face(&catalog, 1, 0, 1.0); + put_face(&catalog, 2, 0, 0.99); + + recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + let people = faces::people(catalog.connection()).unwrap(); + assert_eq!(people.len(), 1); + let her = people[0].id; + assert_eq!(people[0].suggested_faces, 2); + + // Named, and nothing else — no confirmations, which is what a user who + // types a name and moves on has done. + faces::rename_person(catalog.connection(), her, "Catherine").unwrap(); + + recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + + let after = faces::people(catalog.connection()).unwrap(); + assert_eq!( + after.len(), + 1, + "regrouping left a second person behind: {after:?}" + ); + assert_eq!(after[0].id, her); + assert_eq!(after[0].name, "Catherine"); + assert_eq!( + after[0].suggested_faces, 2, + "the named group lost the faces it was named for" + ); + } + + /// And a face found later joins the person it matches rather than arriving + /// as somebody new — the same benefit anchoring gives an ignored group. + #[test] + fn a_new_face_joins_a_named_person_rather_than_starting_a_rival() { + let catalog = catalog_with(3); + put_face(&catalog, 1, 0, 1.0); + put_face(&catalog, 2, 0, 0.99); + recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + let her = faces::people(catalog.connection()).unwrap()[0].id; + faces::rename_person(catalog.connection(), her, "Catherine").unwrap(); + + put_face(&catalog, 3, 0, 0.98); + recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + + let after = faces::people(catalog.connection()).unwrap(); + assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}"); + assert_eq!(after[0].suggested_faces, 3); + } } diff --git a/ui/dr-ui/src/identity_ui.rs b/ui/dr-ui/src/identity_ui.rs index 19fc5a6..48f7cb6 100644 --- a/ui/dr-ui/src/identity_ui.rs +++ b/ui/dr-ui/src/identity_ui.rs @@ -355,15 +355,10 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { /// boundary would not be. /// What the whole-library face pass needs to reach the server. /// -/// Credentials and not just paths, because the pass fetches its own pixels: an +/// A connection and not just paths, because the pass fetches its own pixels: an /// image with no proxy is the ordinary case, not one to skip (see /// `library::spawn_face_sweep`). -pub type SweepPaths = ( - dr_sync_nextcloud::AppCredentials, - String, - std::path::PathBuf, - std::path::PathBuf, -); +pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf); /// The detector and embedder files, when both are present. pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf); @@ -689,7 +684,7 @@ pub fn wire( if ctl.regroup.borrow().is_some() { return; } - let Some((_, _, catalog_path, _)) = paths() else { + let Some((_, catalog_path, _)) = paths() else { return; }; @@ -781,7 +776,7 @@ pub fn wire( w.set_identity_model_missing(true); return; }; - let Some((creds, user_id, catalog_path, store_dir)) = paths() else { + let Some((conn, catalog_path, store_dir)) = paths() else { return; }; @@ -790,8 +785,7 @@ pub fn wire( *ctl.activity.borrow_mut() = Some(activity.begin(crate::activity::Kind::Index, "Indexing faces")); *ctl.sweep.borrow_mut() = Some(crate::library::spawn_face_sweep( - creds, - user_id, + conn, catalog_path, store_dir, detector, diff --git a/ui/dr-ui/src/import.rs b/ui/dr-ui/src/import.rs index 80f94b1..e33e2a9 100644 --- a/ui/dr-ui/src/import.rs +++ b/ui/dr-ui/src/import.rs @@ -55,8 +55,8 @@ use std::sync::Arc; use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode}; use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage}; -use dr_sync::{RemoteBackend, RemotePath}; -use dr_sync_nextcloud::AppCredentials; +use dr_sync::{Account, Connection, RemoteBackend, RemotePath}; + use dr_types::{FormatFilter, RootId}; /// Which root the card is granted as, and which the library is. @@ -103,8 +103,7 @@ pub struct Request { /// an import, and the photographs exist on disk either way. #[derive(Clone)] pub struct Upload { - pub credentials: AppCredentials, - pub user_id: String, + pub conn: Connection, /// The library folder on the server. The dated folders from the template /// are created beneath it, the same ones the local copy went into. pub library: String, @@ -122,8 +121,8 @@ pub struct Upload { /// TRACES: FR-NC-7b /// Where an account's imports wait between disk and the server. -pub fn staging_dir(server: &str, user_id: &str) -> PathBuf { - crate::library::catalog_path(server, user_id) +pub fn staging_dir(account: &Account) -> PathBuf { + crate::library::catalog_path(account) .parent() .map(|p| p.join("staging")) .unwrap_or_else(|| std::env::temp_dir().join("darkroom-import-staging")) @@ -133,7 +132,7 @@ impl std::fmt::Debug for Upload { /// Hand-written so a credential cannot reach a log through a `{:?}`. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Upload") - .field("user_id", &self.user_id) + .field("account", &self.conn.account.describe()) .field("library", &self.library) .field("thumbs", &self.thumbs) .field("staging", &self.staging) @@ -385,7 +384,7 @@ fn upload_all( }; rt.block_on(async { - let backend = match crate::remote::connect(&upload.credentials, &upload.user_id) { + let backend = match crate::remote::connect(&upload.conn) { Ok(b) => b, Err(e) => { log::warn!("connecting to upload: {e}"); diff --git a/ui/dr-ui/src/launch.rs b/ui/dr-ui/src/launch.rs index fdc3375..23be39c 100644 --- a/ui/dr-ui/src/launch.rs +++ b/ui/dr-ui/src/launch.rs @@ -3,8 +3,20 @@ //! The state machine lives here, separate from the Slint bindings, so it can //! be tested without a display server. `dr-ui` owns presentation; what a //! login *is* belongs to the connector. +//! +//! # Two shapes of sign-in, not two screens +//! +//! A Nextcloud account is established through a browser handshake the app +//! polls for; a folder library is established by naming a directory. The model +//! below does not know which is which — it asks the +//! [`BackendProvider`](dr_sync::BackendProvider) whether the account it is +//! being asked to make needs a credential +//! ([`SignIn`](dr_sync::SignIn)), and the screen draws the waiting state only +//! where there is something to wait for. Everything after that point — picking +//! a library root, ticking formats, opening the grid — is identical, because +//! it goes through `dr-sync` rather than through a connector. -use dr_sync_nextcloud::{Session, SessionStore}; +use dr_sync::{Account, AccountStore}; use dr_types::{Format, FormatFilter}; /// What the launch screen is currently doing. @@ -18,14 +30,14 @@ pub enum LaunchState { /// handles the password itself (FR-NC-1). AwaitingApproval { login_url: String }, /// An account is configured. - SignedIn { session: Session }, + SignedIn { session: Account }, /// Working; the reason is shown so a pause is never unexplained. /// /// Carries the session where there is one, so a failure mid-work returns /// to the signed-in screen rather than signing the user out. Busy { message: String, - session: Option>, + session: Option>, }, } @@ -53,6 +65,12 @@ pub struct LaunchModel { pub state: LaunchState, /// Last-used server, prefilled so a returning user need not retype it. pub server_url: String, + /// Last-used folder, prefilled for the same reason. + /// + /// Separate from `server_url` rather than one "endpoint" field, because + /// the screen shows both at once: someone deciding between the two should + /// not have to clear one to try the other. + pub folder_path: String, pub error: Option, pub status: Option, /// False where no secrets daemon exists (FR-NC-2). The screen must say so @@ -132,6 +150,7 @@ impl Default for LaunchModel { Self { state: LaunchState::SignedOut, server_url: String::new(), + folder_path: String::new(), error: None, status: None, can_remember: true, @@ -143,14 +162,16 @@ impl Default for LaunchModel { impl LaunchModel { /// Build from stored sessions, resuming the last account if there is one. - pub fn from_store(store: &SessionStore) -> Self { + pub fn from_store(store: &AccountStore) -> Self { let can_remember = store.can_remember(); match store.current() { Some(session) => { let filter = session.format_filter(); + let (server_url, folder_path) = prefill(&session); Self { - server_url: session.server.clone(), + server_url, + folder_path, formats: Format::ALL .iter() .map(|f| (*f, filter.allows(*f))) @@ -175,7 +196,7 @@ impl LaunchModel { matches!(self.state, LaunchState::Busy { .. }) } - pub fn session(&self) -> Option<&Session> { + pub fn session(&self) -> Option<&Account> { match &self.state { LaunchState::SignedIn { session } => Some(session), // A session survives a busy period; a scan failure must not log @@ -245,7 +266,7 @@ impl LaunchModel { // --- transitions --------------------------------------------------- pub fn begin_sign_in(&mut self, server: impl Into) { - self.server_url = normalise_server(&server.into()); + self.server_url = server.into(); self.error = None; self.state = LaunchState::Busy { message: "Contacting server…".into(), @@ -263,7 +284,7 @@ impl LaunchModel { /// Security. That makes it the workable option where no browser can /// complete the handshake. pub fn begin_direct_sign_in(&mut self, server: impl Into) { - self.server_url = normalise_server(&server.into()); + self.server_url = server.into(); self.error = None; self.state = LaunchState::Busy { message: "Checking the credentials…".into(), @@ -278,10 +299,14 @@ impl LaunchModel { }; } - pub fn signed_in(&mut self, session: Session) { + pub fn signed_in(&mut self, session: Account) { self.error = None; self.status = None; - self.server_url = session.server.clone(); + let (server_url, folder_path) = prefill(&session); + self.server_url = server_url; + if !folder_path.is_empty() { + self.folder_path = folder_path; + } let filter = session.format_filter(); // Adopt the session's stored selection, so a returning user sees the // tick-boxes they left. @@ -357,7 +382,7 @@ impl LaunchModel { /// Adopt the picker's current path as the library root. /// /// Returns the session to persist, or `None` when signed out. - pub fn choose_current_folder(&mut self) -> Option { + pub fn choose_current_folder(&mut self) -> Option { let path = self.browser.as_ref()?.path.clone(); let mut session = self.session()?.clone(); session.root = path; @@ -378,25 +403,16 @@ impl LaunchModel { } } -/// Normalise a server address typed by hand. +/// Which of the two entry fields an account's endpoint belongs in. /// -/// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than -/// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS, -/// and an unencrypted default would be a security decision made on the user's -/// behalf without telling them. -pub fn normalise_server(input: &str) -> String { - let s = input.trim().trim_end_matches('/'); - if s.is_empty() { - return String::new(); - } - if s.starts_with("https://") { - s.to_string() - } else if let Some(rest) = s.strip_prefix("http://") { - // Upgrade rather than accept. If the server genuinely has no TLS the - // connection fails loudly, which is the correct outcome. - format!("https://{rest}") +/// A returning user should find what they typed last time where they typed +/// it. Keyed on whether the connector has a login rather than on its id, so a +/// third backend does not have to be named here to be prefilled correctly. +fn prefill(account: &Account) -> (String, String) { + if account.login.is_empty() { + (String::new(), account.endpoint.clone()) } else { - format!("https://{s}") + (account.endpoint.clone(), String::new()) } } @@ -404,18 +420,17 @@ pub fn normalise_server(input: &str) -> String { mod tests { use super::*; use dr_plat::EphemeralSecretStore; - use dr_sync_nextcloud::AppCredentials; + use dr_sync::Secret; - fn creds() -> AppCredentials { - AppCredentials { - server: "https://cloud.example".into(), - login_name: "duncan".into(), - app_password: "token".into(), - } + fn session_with_root(root: &str) -> Account { + let mut s = + Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan"); + s.root = root.into(); + s } - fn session_with_root(root: &str) -> Session { - let mut s = Session::new(&creds(), "duncan"); + fn folder_with_root(root: &str) -> Account { + let mut s = Account::new("folder", "/mnt/photos"); s.root = root.into(); s } @@ -513,27 +528,34 @@ mod tests { } #[test] - fn server_addresses_are_normalised_to_https() { - assert_eq!(normalise_server("cloud.example"), "https://cloud.example"); - assert_eq!( - normalise_server("https://cloud.example/"), - "https://cloud.example" - ); - assert_eq!( - normalise_server(" cloud.example "), - "https://cloud.example" - ); - assert_eq!(normalise_server(""), ""); + fn a_returning_user_finds_their_endpoint_where_they_typed_it() { + // Two entry fields are shown at once. Prefilling the wrong one — a + // folder path into the server box — reads as a corrupted setting. + let mut m = LaunchModel::default(); + m.signed_in(session_with_root("PhotosRaw")); + assert_eq!(m.server_url, "https://cloud.example"); + assert_eq!(m.folder_path, ""); + + let mut m = LaunchModel::default(); + m.signed_in(folder_with_root("2026")); + assert_eq!(m.folder_path, "/mnt/photos"); + assert_eq!(m.server_url, ""); } #[test] - fn plain_http_is_upgraded_rather_than_accepted() { - // NFR-SEC-3: TLS is required. Failing loudly beats silently sending a - // credential in the clear. - assert_eq!( - normalise_server("http://cloud.example"), - "https://cloud.example" - ); + fn a_folder_library_reaches_the_grid_the_same_way_a_server_one_does() { + // Everything past sign-in is backend-neutral, and this is the check + // that keeps it so: no branch on the account's connector below here. + let mut m = LaunchModel::default(); + m.signed_in(folder_with_root("")); + assert!(m.is_signed_in()); + assert!(!m.can_open_library(), "no root chosen yet"); + assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen); + + m.signed_in(folder_with_root("2026")); + assert!(m.can_open_library()); + assert_eq!(m.startup_action(false), Startup::OpenLibrary); + assert_eq!(m.account_label(), "/mnt/photos/2026"); } #[test] @@ -554,13 +576,13 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - let store = SessionStore::open_at( + let store = AccountStore::open_at( dir.join("sessions.json"), Box::new(EphemeralSecretStore::new()), ); let mut s = session_with_root("PhotosRaw"); s.set_format_filter(&FormatFilter::from_formats([Format::Cr2])); - store.save(&s, &creds()).unwrap(); + store.save(&s, Some(&Secret::new("token"))).unwrap(); let m = LaunchModel::from_store(&store); assert!(m.is_signed_in()); @@ -576,7 +598,7 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - let store = SessionStore::open_at( + let store = AccountStore::open_at( dir.join("sessions.json"), Box::new(EphemeralSecretStore::new()), ); diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs index 45a98f3..8ef0dd0 100644 --- a/ui/dr-ui/src/launch_ui.rs +++ b/ui/dr-ui/src/launch_ui.rs @@ -8,8 +8,8 @@ use std::cell::RefCell; use std::rc::Rc; use dr_plat::PlatformSecretStore; -use dr_sync::RemotePath; -use dr_sync_nextcloud::{auth, Session, SessionStore}; +use dr_sync::{Account, AccountStore, BackendProvider, RemotePath}; +use dr_sync_nextcloud::{auth, NextcloudProvider}; use slint::ComponentHandle; @@ -19,7 +19,7 @@ use crate::AppWindow; /// Shared launch state for the running window. pub struct LaunchController { pub model: RefCell, - pub store: SessionStore, + pub store: AccountStore, /// Holds any in-flight poll timer. A `Timer` stops when dropped, so it /// must outlive its own callback — parking it here avoids an Rc cycle /// between the timer and the closure it runs. @@ -28,7 +28,7 @@ pub struct LaunchController { impl LaunchController { pub fn new() -> Rc { - let store = SessionStore::open(Box::new(PlatformSecretStore::new())); + let store = AccountStore::open(Box::new(PlatformSecretStore::new())); let model = LaunchModel::from_store(&store); Rc::new(Self { model: RefCell::new(model), @@ -46,6 +46,7 @@ pub fn render(window: &AppWindow, controller: &LaunchController) { window.set_launch_account(m.account_label().into()); window.set_launch_root(m.library_root().into()); window.set_launch_server(m.server_url.clone().into()); + window.set_launch_folder(m.folder_path.clone().into()); window.set_launch_busy(m.is_busy()); window.set_launch_login_url(m.login_url().into()); window.set_launch_can_remember(m.can_remember); @@ -87,7 +88,7 @@ pub fn render(window: &AppWindow, controller: &LaunchController) { /// the session so the caller can start a scan. pub fn wire(window: &AppWindow, controller: Rc, on_open_library: F) where - F: Fn(Session) + 'static, + F: Fn(Account) + 'static, { // --- sign in ------------------------------------------------------- { @@ -96,7 +97,17 @@ where window.on_launch_sign_in(move |server| { log::info!("sign-in requested for {server:?}"); let Some(w) = weak.upgrade() else { return }; - ctl.model.borrow_mut().begin_sign_in(server.to_string()); + // The connector owns what a valid address is — assuming HTTPS + // here would put one backend's rule in the interface. + let server = match NextcloudProvider.normalise_endpoint(&server) { + Ok(s) => s, + Err(e) => { + ctl.model.borrow_mut().fail(e); + render(&w, &ctl); + return; + } + }; + ctl.model.borrow_mut().begin_sign_in(server); render(&w, &ctl); let server = ctl.model.borrow().server_url.clone(); @@ -111,9 +122,15 @@ where let ctl = controller.clone(); window.on_launch_sign_in_direct(move |server, login, password| { let Some(w) = weak.upgrade() else { return }; - ctl.model - .borrow_mut() - .begin_direct_sign_in(server.to_string()); + let server = match NextcloudProvider.normalise_endpoint(&server) { + Ok(s) => s, + Err(e) => { + ctl.model.borrow_mut().fail(e); + render(&w, &ctl); + return; + } + }; + ctl.model.borrow_mut().begin_direct_sign_in(server); render(&w, &ctl); let server = ctl.model.borrow().server_url.clone(); @@ -127,6 +144,28 @@ where }); } + // --- use a folder --------------------------------------------------- + // + // No thread, no waiting state, no credential: the whole sign-in is a + // `stat`. That asymmetry with the browser flow above is not a special + // case in the screen — it is what [`SignIn::EndpointOnly`] means, and any + // future connector declaring it lands here rather than in new code. + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_launch_use_folder(move |path| { + let Some(w) = weak.upgrade() else { return }; + match open_folder_library(&ctl.store, &path) { + Ok(account) => { + log::info!("using folder library at {}", account.endpoint); + ctl.model.borrow_mut().signed_in(account); + } + Err(e) => ctl.model.borrow_mut().fail(e), + } + render(&w, &ctl); + }); + } + // --- sign out ------------------------------------------------------ { let weak = window.as_weak(); @@ -274,6 +313,40 @@ where render(window, &controller); } +/// TRACES: FR-NC-13 +/// Establish a folder library, returning the account to sign in as. +/// +/// The whole of a [`SignIn::EndpointOnly`](dr_sync::SignIn) sign-in: check the +/// endpoint, build the account, persist it. Split out of the callback rather +/// than written inline because a Slint callback cannot be tested without a +/// display server, and this is the path that decides whether a mistyped folder +/// becomes a stored account — the failure that would then skip the launch +/// screen on the next start and surface as a library that finds nothing. +/// +/// The error is a string because it goes straight to the screen's error line; +/// the connector wrote it to say what to fix. +fn open_folder_library(store: &AccountStore, path: &str) -> Result { + let provider = crate::remote::registry() + .get(dr_sync_folder::BACKEND_ID) + .ok_or("this build has no folder support")?; + + // The connector checks the directory before an account is written for it. + let endpoint = provider.normalise_endpoint(path)?; + let account = provider.account_for(&endpoint).map_err(|e| e.to_string())?; + + // `None`: there is no credential, and asking the keyring for one would + // fail on a machine with no secrets daemon — where a folder library is + // exactly the thing that should still work. + // + // A failure to persist is reported, not fatal: the library opens for this + // session and the user is asked again next launch, which is a great deal + // better than refusing to open a folder that is plainly there. + if let Err(e) = store.save(&account, None) { + log::warn!("persisting account: {e}"); + } + Ok(account) +} + /// Run Login Flow v2 without blocking the UI thread. /// /// Slint's event loop is single-threaded, so the network work happens on a @@ -547,9 +620,10 @@ fn poll_channel( } LoginMessage::Success(boxed) => { let (creds, user_id) = *boxed; - let session = Session::new(&creds, user_id); - if let Err(e) = ctl.store.save(&session, &creds) { - log::warn!("persisting session: {e}"); + let session = NextcloudProvider::account_from(&creds, user_id); + let secret = dr_sync::Secret::new(&creds.app_password); + if let Err(e) = ctl.store.save(&session, Some(&secret)) { + log::warn!("persisting account: {e}"); } ctl.model.borrow_mut().signed_in(session); done = true; @@ -576,10 +650,13 @@ fn poll_channel( /// List top-level folders so one can be chosen as the library root. fn spawn_folder_list(weak: slint::Weak, ctl: Rc, path: String) { - let Some(session) = ctl.model.borrow().session().cloned() else { + let Some(account) = ctl.model.borrow().session().cloned() else { return; }; - let creds = match ctl.store.credentials(&session) { + let conn = match ctl + .store + .connection(&account, crate::remote::needs_secret(&account)) + { Ok(c) => c, Err(e) => { ctl.model.borrow_mut().fail(format!("credentials: {e}")); @@ -591,7 +668,6 @@ fn spawn_folder_list(weak: slint::Weak, ctl: Rc, pa }; let (tx, rx) = std::sync::mpsc::channel::, String>>(); - let user_id = session.user_id.clone(); std::thread::spawn(move || { // Multi-thread for the same reason as the login worker: a @@ -608,7 +684,7 @@ fn spawn_folder_list(weak: slint::Weak, ctl: Rc, pa return; }; rt.block_on(async { - match crate::remote::connect(&creds, &user_id) { + match crate::remote::connect(&conn) { Ok(b) => match b.list(&RemotePath::new(&path), None).await { Ok(entries) => { let mut dirs: Vec = entries @@ -825,3 +901,82 @@ fn android_open_url(url: &str) -> Result<(), String> { }) .map_err(|e: jni::errors::Error| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use dr_plat::EphemeralSecretStore; + + fn store_in(dir: &std::path::Path) -> AccountStore { + AccountStore::open_at( + dir.join("sessions.json"), + Box::new(EphemeralSecretStore::new()), + ) + } + + fn tmpdir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("dr-launch-folder-{name}")); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn opening_a_folder_stores_an_account_with_no_credential() { + // The whole sign-in, end to end through the registry: no browser, no + // keyring, no waiting state. + let dir = tmpdir("ok"); + let library = dir.join("Photos"); + std::fs::create_dir_all(&library).unwrap(); + let store = store_in(&dir); + + let account = open_folder_library(&store, &library.to_string_lossy()).unwrap(); + assert_eq!(account.backend, dr_sync_folder::BACKEND_ID); + assert!(account.login.is_empty(), "a folder has nobody to name"); + + // And it survives, so the next launch skips the screen. + let reloaded = store.current().expect("persisted"); + assert_eq!(reloaded.endpoint, account.endpoint); + + // With nothing in the keyring — the machine may have no secrets daemon + // at all, which is precisely when a folder library matters. + assert!(store.connection(&reloaded, false).unwrap().secret.is_none()); + } + + #[test] + fn a_mistyped_folder_is_refused_rather_than_stored() { + // The failure this guards: a stored account for a folder that is not + // there skips the launch screen next start and reads as a library + // that has lost its photographs. + let dir = tmpdir("typo"); + let store = store_in(&dir); + + let err = open_folder_library(&store, &dir.join("Pictrues").to_string_lossy()).unwrap_err(); + assert!(err.contains("No folder"), "{err}"); + assert!(store.current().is_none(), "nothing may be persisted"); + } + + #[test] + fn the_error_says_what_to_fix() { + // It goes straight to the screen's error line, so it has to read as + // instruction rather than as a type name. + let dir = tmpdir("messages"); + let store = store_in(&dir); + + for (input, want) in [("", "Choose"), ("Pictures", "full path")] { + let err = open_folder_library(&store, input).unwrap_err(); + assert!(err.contains(want), "{input:?} gave {err:?}"); + } + } + + #[test] + fn a_file_is_not_a_library() { + let dir = tmpdir("file"); + let f = dir.join("a.CR2"); + std::fs::write(&f, b"raw").unwrap(); + let store = store_in(&dir); + + let err = open_folder_library(&store, &f.to_string_lossy()).unwrap_err(); + assert!(err.contains("not a folder"), "{err}"); + } +} diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index a4fde87..35cb24a 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -406,10 +406,10 @@ fn batch_request( export::BatchRequest { sources, - creds: library.credentials(), + conn: library.credentials(), settings: stored.export, outbox: match library.session() { - Some((_, s)) => export::outbox_dir(&s.server, &s.user_id), + Some(c) => export::outbox_dir(&c.account), // No account, so no outbox — a device export still works, and a // remote one is refused by `place` rather than here, so the message // names the setting rather than the plumbing. @@ -434,15 +434,16 @@ fn drain_outbox(library: &Rc) { if library.is_offline() { return; } - let Some((creds, session)) = library.session() else { + let Some(conn) = library.session() else { return; }; - let outbox = export::outbox_dir(&session.server, &session.user_id); + let outbox = export::outbox_dir(&conn.account); if export::pending_count(&outbox) == 0 { return; } - let rx = export::spawn_upload(creds, session.user_id.clone(), session.root.clone(), outbox); + let root = conn.account.root.clone(); + let rx = export::spawn_upload(conn, root, outbox); std::thread::spawn(move || { while let Ok(msg) = rx.recv() { match msg { @@ -472,7 +473,9 @@ fn refresh_export_label(window: &AppWindow, settings: &Rc) -> Result<()> { library.catalog(), activity.clone(), move || { - let (_, session) = lib_store.session()?; - dr_thumbs::ThumbStore::open(&library::thumbs_dir( - &session.server, - &session.user_id, - )) - .ok() - .map(std::rc::Rc::new) + let conn = lib_store.session()?; + dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) + .ok() + .map(std::rc::Rc::new) }, // The weights are not shipped and are not a build input // (docs/faces.md §2): the user puts them beside the catalog, @@ -1050,24 +1050,21 @@ pub fn run(paths: Vec) -> Result<()> { { let lib = library.clone(); move || { - let (_, session) = lib.session()?; - library::face_models(&session.server, &session.user_id) + let conn = lib.session()?; + library::face_models(&conn.account) } }, // The sweep opens its own connection on its own thread, so it // takes paths rather than the handles this screen holds — and - // credentials, because it fetches the pixels it indexes rather + // a connection, because it fetches the pixels it indexes rather // than reading whatever the grid happened to leave behind. { let lib = library.clone(); move || { - let (creds, session) = lib.session()?; - Some(( - creds, - session.user_id.clone(), - library::catalog_path(&session.server, &session.user_id), - library::thumbs_dir(&session.server, &session.user_id), - )) + let conn = lib.session()?; + let catalog = library::catalog_path(&conn.account); + let thumbs = library::thumbs_dir(&conn.account); + Some((conn, catalog, thumbs)) } }, ); @@ -1100,7 +1097,7 @@ pub fn run(paths: Vec) -> Result<()> { // Before anything opens a store: an upgrade must not // abandon a catalog, its thumbnails, or the offline // ratings and edits waiting beside them. - library::migrate_legacy_cache_data(&session.server, &session.user_id); + library::migrate_legacy_cache_data(&session); library_ui::open( &window, library.clone(), @@ -1147,23 +1144,22 @@ pub fn run(paths: Vec) -> Result<()> { &window, import, move || { - let (creds, session) = library_for_context.session()?; + let conn = library_for_context.session()?; Some(import_ui::Context { - catalog: library::catalog_path(&session.server, &session.user_id), - library_label: session.root.clone(), + catalog: library::catalog_path(&conn.account), + library_label: conn.account.root.clone(), // The same formats the scan looks for. An import that took // types the library then ignores would copy files off the // card that never appear in the grid. - filter: session.format_filter(), + filter: conn.account.format_filter(), upload: Some(import::Upload { - credentials: creds, - user_id: session.user_id.clone(), - library: session.root.clone(), + library: conn.account.root.clone(), // The same shard store the grid reads and the sync // pushes, so a thumbnail made during an import is the // one every other client gets. - thumbs: library::thumbs_dir(&session.server, &session.user_id), - staging: import::staging_dir(&session.server, &session.user_id), + thumbs: library::thumbs_dir(&conn.account), + staging: import::staging_dir(&conn.account), + conn, }), }) }, @@ -1218,14 +1214,8 @@ pub fn run(paths: Vec) -> Result<()> { library: &Rc, path: String| { match library.session() { - Some((creds, session)) => { - settings_ui::spawn_folder_list( - weak.clone(), - ctl.clone(), - creds, - session.user_id.clone(), - path, - ); + Some(conn) => { + settings_ui::spawn_folder_list(weak.clone(), ctl.clone(), conn, path); } None => { // No account, so nothing to browse. Said plainly rather @@ -1364,14 +1354,13 @@ pub fn run(paths: Vec) -> Result<()> { let lib = library.clone(); let catalog = library.catalog(); move |w: &AppWindow| { - let store = lib.session().and_then(|(_, s)| { - dr_thumbs::ThumbStore::open(&library::thumbs_dir(&s.server, &s.user_id)) - .ok() + let store = lib.session().and_then(|c| { + dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok() }); identity_ui::refresh_coverage(w, &catalog, store.as_ref()); w.set_identity_model_missing( lib.session() - .and_then(|(_, s)| library::face_models(&s.server, &s.user_id)) + .and_then(|c| library::face_models(&c.account)) .is_none(), ); } @@ -1780,7 +1769,7 @@ pub fn run(paths: Vec) -> Result<()> { w.set_index(0); w.set_total(1); - let Some((creds, user_id)) = library.credentials() else { + let Some(conn) = library.credentials() else { w.set_load_error("no library session".into()); return; }; @@ -1809,8 +1798,7 @@ pub fn run(paths: Vec) -> Result<()> { // photograph is, instead of the image appearing at its defaults // and visibly changing a moment later. let sidecar_rx = library::spawn_sidecar_fetch( - creds.clone(), - user_id.clone(), + conn.clone(), path.clone(), library.sidecar_cache_dir().unwrap_or_default(), library.is_offline(), @@ -1829,7 +1817,7 @@ pub fn run(paths: Vec) -> Result<()> { log::info!("fetching {path} for develop"); w.set_load_error("Downloading…".into()); - let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache); + let rx = library::spawn_full_fetch(conn, path.clone(), cache); // The one transfer the user is actively waiting on. It gets a row // like any other, so a download that is still running after they diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 70199ee..8bc230c 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -25,8 +25,7 @@ use std::path::PathBuf; use std::sync::mpsc::{Receiver, Sender}; use dr_catalog::{Catalog, JobKind, Priority}; -use dr_sync::{RemoteBackend, RemoteId, RemotePath}; -use dr_sync_nextcloud::AppCredentials; +use dr_sync::{Account, Connection, RemoteBackend, RemoteError, RemoteId, RemotePath}; use dr_thumbs::ThumbStore; use crate::sidecar_cache::SidecarCache; @@ -573,8 +572,7 @@ pub fn sidecar_path(image_path: &str) -> String { /// about it. Interrupting a cull with an error dialog per frame would be far /// worse than the risk. The counts are reported once, at the end. pub fn spawn_sidecar_writes( - creds: AppCredentials, - user_id: String, + conn: Connection, writes: Vec, cache_dir: PathBuf, offline: bool, @@ -626,7 +624,7 @@ pub fn spawn_sidecar_writes( let report = match rt { None => queue_all(), Some(rt) => rt.block_on(async { - match crate::remote::connect(&creds, &user_id) { + match crate::remote::connect(&conn) { Ok(b) => { let mut report = SidecarReport::default(); for w in &writes { @@ -860,11 +858,7 @@ async fn write_one_sidecar_online( /// The marker is cleared only after the server has taken the bytes. A drain /// interrupted halfway leaves the rest of the outbox exactly as it was, so /// nothing depends on this running to completion. -pub fn spawn_outbox_drain( - creds: AppCredentials, - user_id: String, - cache_dir: PathBuf, -) -> Receiver { +pub fn spawn_outbox_drain(conn: Connection, cache_dir: PathBuf) -> Receiver { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { @@ -895,7 +889,7 @@ pub fn spawn_outbox_drain( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { let _ = tx.send(SidecarMessage::Finished { @@ -950,7 +944,35 @@ async fn drain_one( let path = RemotePath::new(path_str.to_string()); let id = RemoteId::Path(path.clone()); - let remote = backend.get(&id, None).await.ok(); + + // TRACES: FR-NC-6c + // A miss and a placeholder are not the same answer, and conflating them + // destroys work. This read decides whether the sidecar already on the + // remote is merged in; treating "the content is not on this device" as + // "there is no sidecar" writes a fresh document over an existing one and + // discards every edit another device put there — the exact loss the + // format's unknown-key preservation exists to prevent. + // + // A sidecar is a few kilobytes, so the right response to a placeholder is + // to fetch it, not to give up. Where that is impossible — no client + // running — the entry stays queued, which is what the outbox is for. + let remote = match backend.get(&id, None).await { + Ok(bytes) => Some(bytes), + Err(RemoteError::NotFound(_)) => None, + Err(RemoteError::NotMaterialised(_)) => { + backend + .materialise(&id) + .await + .map_err(|e| format!("sidecar is not on this device ({e})"))?; + match backend.get(&id, None).await { + Ok(bytes) => Some(bytes), + Err(e) => return Err(format!("sidecar could not be read ({e})")), + } + } + // Anything else — a refused read, a dead connection — leaves the entry + // queued rather than resolved by overwriting. + Err(e) => return Err(format!("sidecar could not be read ({e})")), + }; if let Some(bytes) = remote.as_deref() { if !bytes.is_empty() { @@ -1001,20 +1023,17 @@ fn merge_into(local: &mut dr_pipeline::Sidecar, remote: &dr_pipeline::Sidecar) { /// Where the catalog for an account lives. /// -/// Keyed by server and user so two accounts do not share an index. Under the -/// XDG data directory, not cache: the catalog is rebuildable but rebuilding it -/// costs a full rescan, so it is not something to discard on a cache sweep. -pub fn catalog_path(server: &str, user_id: &str) -> PathBuf { - let slug: String = server - .trim_start_matches("https://") - .trim_start_matches("http://") - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect(); - - data_root() - .join(format!("{slug}-{user_id}")) - .join("catalog.sqlite") +/// Keyed by [`Account::namespace`] so two accounts do not share an index — +/// two servers, two logins on one server, or two folders on one disk. Under +/// the XDG data directory, not cache: the catalog is rebuildable but +/// rebuilding it costs a full rescan, so it is not something to discard on a +/// cache sweep. +/// +/// The namespace is the account's to compute, not this function's, because it +/// is also frozen: it names the directory an existing install's catalog, +/// thumbnail shards and un-uploaded sidecars are already in. +pub fn catalog_path(account: &Account) -> PathBuf { + data_root().join(account.namespace()).join("catalog.sqlite") } /// The directory every account's data hangs off. @@ -1032,7 +1051,7 @@ pub fn catalog_path(server: &str, user_id: &str) -> PathBuf { /// reached the server, is the worst failure this application can have, and /// it would be silent. /// -/// `SessionStore::data_dir()` is the persistent per-app directory the +/// `AccountStore::data_dir()` is the persistent per-app directory the /// Android entry point establishes before anything opens a store. On a /// desktop it is the XDG config directory, and the two lines below keep the /// established XDG *data* location there rather than moving anyone's @@ -1041,7 +1060,7 @@ fn data_root() -> PathBuf { let base = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) - .unwrap_or_else(dr_sync_nextcloud::session::SessionStore::data_dir); + .unwrap_or_else(dr_sync::AccountStore::data_dir); base.join("darkroom") } @@ -1064,15 +1083,12 @@ fn data_root() -> PathBuf { /// one filesystem, so it is atomic and cannot half-finish. If the destination /// already exists this does nothing — the migration has run, or this is a /// fresh install, and in neither case may it overwrite live data. -pub fn migrate_legacy_cache_data(server: &str, user_id: &str) { +pub fn migrate_legacy_cache_data(account: &Account) { // Only meaningful where the old fallback and the new one differ, which is // exactly the platform that had the problem. On a desktop with XDG set, // both resolve to the same place and this returns immediately. let legacy_base = std::env::temp_dir(); - let Some(current) = catalog_path(server, user_id) - .parent() - .map(|p| p.to_path_buf()) - else { + let Some(current) = catalog_path(account).parent().map(|p| p.to_path_buf()) else { return; }; let Some(account) = current.file_name() else { @@ -1117,8 +1133,7 @@ fn move_account_dir(legacy: &std::path::Path, current: &std::path::Path) { /// Returns the receiver the UI drains. The worker owns its own tokio runtime /// and backend; nothing here touches the Slint event loop. pub fn spawn_scan( - creds: AppCredentials, - user_id: String, + conn: Connection, root: String, filter: FormatFilter, catalog_path: PathBuf, @@ -1127,7 +1142,7 @@ pub fn spawn_scan( std::thread::spawn(move || { let started = std::time::Instant::now(); - if let Err(e) = run_scan(&tx, creds, user_id, root, filter, catalog_path, started) { + if let Err(e) = run_scan(&tx, conn, root, filter, catalog_path, started) { let _ = tx.send(ScanMessage::Failed { message: e.message, offline: e.offline, @@ -1169,8 +1184,7 @@ impl From for ScanFailure { fn run_scan( tx: &Sender, - creds: AppCredentials, - user_id: String, + conn: Connection, root: String, filter: FormatFilter, catalog_path: PathBuf, @@ -1185,7 +1199,7 @@ fn run_scan( let rt = crate::net_runtime::build().map_err(ScanFailure::local)?; rt.block_on(async { - let backend = crate::remote::connect(&creds, &user_id).map_err(ScanFailure::local)?; + let backend = crate::remote::connect(&conn).map_err(ScanFailure::local)?; // Stored folder ETags, so an unchanged subtree is skipped whole. On a // first run this is empty and the walk is complete; on every run after @@ -1469,8 +1483,7 @@ pub enum PinMessage { /// memory — and it is the same contention that produced 423 Locked in the /// sweep. pub fn spawn_pin_fetch( - creds: AppCredentials, - user_id: String, + conn: Connection, catalog_path: PathBuf, cache_dir: PathBuf, budget: dr_catalog::Budget, @@ -1531,7 +1544,7 @@ pub fn spawn_pin_fetch( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { let _ = tx.send(PinMessage::Failed { @@ -1552,6 +1565,48 @@ pub fn spawn_pin_fetch( }; let id = RemoteId::Path(RemotePath::new(&source_ref)); + + // TRACES: FR-NC-6c + // On a placeholder library "pin" means *keep it downloaded*, + // not "make a second copy". The original materialises in the + // library folder itself, so copying it under `originals/` + // would hold every pinned photograph twice — and the copy + // would be the half the budget could evict while the real disk + // cost stayed. Only the bookkeeping is recorded, with no path, + // so nothing here can ever delete a file inside a synced tree + // (see `Cache::record_in_place`). + if backend.capabilities().materialisation.can_materialise() { + match backend.materialise(&id).await { + Ok(_) => { + let bytes = size_of(&catalog, image).unwrap_or(0); + if let Err(e) = store.record_in_place( + catalog.connection(), + image, + bytes, + true, + now_secs(), + ) { + log::warn!("recording pinned {source_ref}: {e}"); + continue; + } + stored += 1; + bytes_total += bytes; + if tx.send(PinMessage::Stored { done: stored }).is_err() { + return; + } + } + Err(e) if e.indicates_offline() => { + let _ = tx.send(PinMessage::Failed { + message: e.to_string(), + offline: true, + }); + return; + } + Err(e) => log::warn!("pinning {source_ref}: {e}"), + } + continue; + } + match backend.get(&id, None).await { Ok(bytes) => { // `pinned: true` — this is the population the budget @@ -1601,6 +1656,82 @@ pub fn spawn_pin_fetch( rx } +/// TRACES: FR-NC-6c +/// Hand a set of photographs back to the sync client, freeing their disk. +/// +/// The other half of pinning on a placeholder library. `Cache::release` drops +/// the bookkeeping and — correctly — deletes nothing, because the rows it +/// holds for a library like this name no file of ours (`record_in_place`). +/// The bytes are in the library folder, and only the client may take them +/// back. +/// +/// **This is a dehydration, not a deletion, and the distinction is the whole +/// safety of the feature.** Removing a materialised file inside a synced tree +/// propagates to the server and deletes the photograph everywhere. +/// +/// Best effort per image: a file the client refuses to release simply stays, +/// which costs disk and loses nothing. +pub fn spawn_dehydrate( + conn: Connection, + catalog_path: PathBuf, + images: Vec, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let Ok(catalog) = Catalog::open(&catalog_path) else { + return; + }; + let Ok(rt) = crate::net_runtime::build() else { + return; + }; + rt.block_on(async { + let Ok(backend) = crate::remote::connect(&conn) else { + return; + }; + // Nothing to do where content is not a thing that can be given + // back — a server library, or a plain folder. + if !backend.capabilities().materialisation.can_materialise() { + return; + } + + let mut released = 0usize; + for image in images { + let Some(source_ref) = source_ref_of(&catalog, image) else { + continue; + }; + let id = RemoteId::Path(RemotePath::new(&source_ref)); + match backend.dematerialise(&id).await { + Ok(()) => released += 1, + Err(e) => log::debug!("releasing {source_ref}: {e}"), + } + } + log::info!("released {released} photograph(s) back to the sync client"); + let _ = tx.send(released); + }); + }); + + rx +} + +/// What an image occupies, as the catalog recorded it. +/// +/// Zero where the scan could not tell — a placeholder reports no size, because +/// a one-byte stub says nothing about what it stands for (ARCH §9.0a). A pin +/// that cannot state its cost is better than one that states a wrong one. +fn size_of(catalog: &Catalog, image: dr_types::ImageId) -> Option { + catalog + .connection() + .query_row( + "SELECT file_size FROM images WHERE id = ?1", + rusqlite::params![image.0 as i64], + |r| r.get::<_, Option>(0), + ) + .ok() + .flatten() + .map(|v| v.max(0) as u64) +} + /// The remote path for a catalogued image. fn source_ref_of(catalog: &Catalog, image: dr_types::ImageId) -> Option { catalog @@ -1678,8 +1809,7 @@ pub struct CacheContext { /// not read, so an edit this build failed to understand is never destroyed by /// having been opened. pub fn spawn_sidecar_fetch( - creds: AppCredentials, - user_id: String, + conn: Connection, image_path: String, cache_dir: PathBuf, offline: bool, @@ -1720,7 +1850,7 @@ pub fn spawn_sidecar_fetch( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { log::debug!("sidecar fetch backend: {e}"); @@ -1769,8 +1899,7 @@ pub fn spawn_sidecar_fetch( } pub fn spawn_full_fetch( - creds: AppCredentials, - user_id: String, + conn: Connection, path: String, cache: Option, ) -> Receiver, FetchFailure>> { @@ -1808,7 +1937,7 @@ pub fn spawn_full_fetch( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { let _ = tx.send(Err(FetchFailure::local(e))); @@ -1851,8 +1980,7 @@ pub fn spawn_full_fetch( /// or a second device that synced the shards — fills the grid with no transfer /// at all. Only genuine misses reach the network. pub fn spawn_thumbnails( - creds: AppCredentials, - user_id: String, + conn: Connection, wanted: Vec, store_dir: PathBuf, catalog_path: PathBuf, @@ -1958,7 +2086,7 @@ pub fn spawn_thumbnails( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { for req in &to_fetch { @@ -2487,11 +2615,7 @@ const SWEEP_LANES: usize = 6; /// /// Runs at the back of the queue by design: it holds no lock the grid needs, /// and its chunked commits keep write transactions short. -pub fn spawn_sweep( - creds: AppCredentials, - user_id: String, - catalog_path: PathBuf, -) -> Receiver { +pub fn spawn_sweep(conn: Connection, catalog_path: PathBuf) -> Receiver { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { @@ -2529,7 +2653,7 @@ pub fn spawn_sweep( }; rt.block_on(async { - let Ok(backend) = crate::remote::connect(&creds, &user_id) else { + let Ok(backend) = crate::remote::connect(&conn) else { return; }; @@ -2862,8 +2986,7 @@ fn faces_without_proxy( /// the images in flight and nothing else. #[allow(clippy::too_many_arguments)] pub fn spawn_face_sweep( - creds: AppCredentials, - user_id: String, + conn: Connection, catalog_path: PathBuf, store_dir: PathBuf, detector_model: PathBuf, @@ -2991,7 +3114,7 @@ pub fn spawn_face_sweep( rt.block_on(async { // Through `remote::connect`, which is the only place in the // interface that knows whose backend this is. - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { log::warn!("face sweep: {e}"); @@ -3012,6 +3135,13 @@ pub fn spawn_face_sweep( let (mut done, mut images, mut found, mut failed) = (0usize, 0usize, 0usize, 0usize); let mut offline = false; + // TRACES: FR-NC-6c + // The same borrow the thumbnail sweep makes, and deliberately its + // own pool: the two passes run at different times, so sharing one + // would keep every file the earlier pass touched hydrated until + // the later one finished. Each gives its own back (ARCH §9.0a). + let pool = dr_sync_folder::BorrowPool::new(); + for chunk in wanted.chunks(SWEEP_CHUNK) { let lanes: Vec> = (0..SWEEP_LANES) .map(|lane| chunk.iter().skip(lane).step_by(SWEEP_LANES).collect()) @@ -3021,6 +3151,7 @@ pub fn spawn_face_sweep( let backend = &*backend; let models = ⊧ let options = &options; + let pool = &pool; async move { let mut indexed: Vec = Vec::new(); let mut discard = Vec::new(); @@ -3029,6 +3160,23 @@ pub fn spawn_face_sweep( let mut offline = false; for req in lane { attempted += 1; + + let _held = + match pool.borrow(backend, &RemotePath::new(&req.path)).await { + Ok(h) => h, + Err(e) if e.indicates_offline() => { + log::info!("face sweep: {e}"); + attempted -= 1; + offline = true; + break; + } + Err(e) => { + log::debug!("face sweep: {}: {e}", req.path); + failed += 1; + continue; + } + }; + match fetch_preview(backend, req, &mut discard).await { PreviewOutcome::Ready(mut preview) => { // No await inside this borrow — see the @@ -3133,8 +3281,13 @@ pub fn spawn_face_sweep( { // Receiver dropped: the screen closed, or // the user pressed Stop. Everything written - // so far stays written. + // so far stays written — and everything + // borrowed is given back. A cancelled pass + // that kept the library hydrated would be + // the worst of both: the disk spent and + // the work abandoned. log::info!("face sweep: cancelled after {images} image(s)"); + pool.release_all(&*backend).await; return; } } @@ -3152,6 +3305,14 @@ pub fn spawn_face_sweep( } } + let returned = pool.release_all(&*backend).await; + if returned.released > 0 { + log::info!( + "face sweep: released {} borrowed file(s)", + returned.released + ); + } + log::info!( "face sweep: {found} face(s) across {images} image(s), {failed} failed{}", if offline { ", server went away" } else { "" } @@ -3226,8 +3387,7 @@ pub enum ThumbSweepMessage { /// the alternative is a second piece of state that has to be invalidated when /// a file is replaced. pub fn spawn_thumbnail_sweep( - creds: AppCredentials, - user_id: String, + conn: Connection, catalog_path: PathBuf, store_dir: PathBuf, ) -> Receiver { @@ -3300,7 +3460,7 @@ pub fn spawn_thumbnail_sweep( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { log::warn!("thumbnail sweep: {e}"); @@ -3313,6 +3473,15 @@ pub fn spawn_thumbnail_sweep( let mut offline = false; let mut found = Vec::new(); + // TRACES: FR-NC-6c + // On a placeholder library the bytes may not be here at all, and + // this is a pass the user asked for — so it may fetch them, which + // browsing may not (ARCH §9.0a). Every file is *borrowed*: what + // this pass downloads it gives back, and what the user already had + // it leaves alone. Against a server or a plain folder every borrow + // is a no-op, so there is one code path rather than two. + let pool = dr_sync_folder::BorrowPool::new(); + for chunk in wanted.chunks(SWEEP_CHUNK) { // Each lane owns a disjoint slice and its own output, so // nothing is shared and no lock is needed. The store is not @@ -3323,6 +3492,7 @@ pub fn spawn_thumbnail_sweep( let results = futures_join_all(lanes.into_iter().map(|lane| { let backend: &dyn RemoteBackend = &*backend; + let pool = &pool; async move { let mut made: Vec<(u64, dr_thumbs::Thumbnail)> = Vec::new(); let mut found = Vec::new(); @@ -3335,6 +3505,27 @@ pub fn spawn_thumbnail_sweep( // store on and is not a candidate. let Some(file_id) = req.file_id else { continue }; attempted += 1; + + // Held for this image only. A failure to fetch is + // this image's verdict, not the batch's: a client + // that cannot reach the server reports it as + // offline through the usual path below. + let _held = + match pool.borrow(backend, &RemotePath::new(&req.path)).await { + Ok(h) => h, + Err(e) if e.indicates_offline() => { + log::info!("thumbnail sweep: {e}"); + attempted -= 1; + offline = true; + break; + } + Err(e) => { + log::debug!("thumbnail sweep: {}: {e}", req.path); + failed += 1; + continue; + } + }; + match fetch_preview(backend, req, &mut found).await { PreviewOutcome::Ready(preview) => { match encode_preview(file_id, &preview) { @@ -3385,6 +3576,10 @@ pub fn spawn_thumbnail_sweep( .send(ThumbSweepMessage::Progress { done, stored }) .is_err() { + // Cancelled. Hand back what was borrowed before leaving, + // or a stopped pass costs the disk of everything it had + // reached and delivers nothing for it. + pool.release_all(&*backend).await; return; } if offline { @@ -3393,6 +3588,19 @@ pub fn spawn_thumbnail_sweep( } flush_sweep(&catalog, &mut found); + + // Give back everything this pass fetched, before reporting done — + // a user watching the disk should see it return, and a pass that + // reported success while still holding the library would be + // lying about what it cost. + let returned = pool.release_all(&*backend).await; + if returned.released > 0 { + log::info!( + "thumbnail sweep: released {} borrowed file(s)", + returned.released + ); + } + log::info!("thumbnail sweep: {stored} stored, {failed} without a usable preview"); let _ = tx.send(ThumbSweepMessage::Finished { stored, @@ -3457,8 +3665,8 @@ fn thumbnails_outstanding( /// Beside the catalog rather than in the cache directory: these sync to the /// server and are shared with other clients, so discarding them on a cache /// sweep would cost a re-download for everyone. -pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf { - catalog_path(server, user_id) +pub fn thumbs_dir(account: &Account) -> PathBuf { + catalog_path(account) .parent() .map(|p| p.join("thumbs")) .unwrap_or_else(|| std::env::temp_dir().join("darkroom-thumbs")) @@ -3471,8 +3679,8 @@ pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf { /// project's licence, so the user obtains them and the app loads them from here /// (docs/faces.md §2). An absent directory is the ordinary state of a fresh /// install, not an error. -pub fn face_models_dir(server: &str, user_id: &str) -> PathBuf { - catalog_path(server, user_id) +pub fn face_models_dir(account: &Account) -> PathBuf { + catalog_path(account) .parent() .map(|p| p.join("models")) .unwrap_or_else(|| std::env::temp_dir().join("darkroom-models")) @@ -3511,13 +3719,13 @@ pub fn shared_face_models_dir() -> PathBuf { /// 3. **The system directories.** Where a package installs them — the Arch /// package puts the pair in `/usr/share/darkroom/models`. Last, so anything /// the user placed themselves outranks what the package shipped. -pub fn face_models(server: &str, user_id: &str) -> Option<(PathBuf, PathBuf)> { +pub fn face_models(account: &Account) -> Option<(PathBuf, PathBuf)> { fn pair(dir: PathBuf) -> Option<(PathBuf, PathBuf)> { let detector = dir.join("scrfd_500m_640.onnx"); let embedder = dir.join("arcface_mbf_b1.onnx"); (detector.is_file() && embedder.is_file()).then_some((detector, embedder)) } - pair(face_models_dir(server, user_id)) + pair(face_models_dir(account)) .or_else(|| pair(shared_face_models_dir())) .or_else(|| system_face_models_dirs().into_iter().find_map(pair)) } @@ -4302,17 +4510,36 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// An account for the path tests, defaulting to the connector every + /// existing install uses. + fn account(endpoint: &str, user: &str) -> Account { + Account::new("nextcloud", endpoint).with_login(user, user) + } + #[test] fn catalog_paths_separate_accounts() { // Two accounts on one machine must not share an index, or one // library's images appear in the other. - let a = catalog_path("https://cloud.example", "duncan"); - let b = catalog_path("https://cloud.example", "someone"); - let c = catalog_path("https://other.example", "duncan"); + let a = catalog_path(&account("https://cloud.example", "duncan")); + let b = catalog_path(&account("https://cloud.example", "someone")); + let c = catalog_path(&account("https://other.example", "duncan")); assert_ne!(a, b); assert_ne!(a, c); } + #[test] + fn a_folder_library_gets_its_own_catalog() { + // The same rule across backends: a folder library on this machine + // must not land in the directory a server account is already using. + let server = catalog_path(&account("https://cloud.example", "duncan")); + let folder = catalog_path(&Account::new("folder", "/mnt/photos")); + assert_ne!(server, folder); + assert_ne!( + folder, + catalog_path(&Account::new("folder", "/mnt/other-photos")) + ); + } + #[test] fn a_legacy_cache_directory_is_moved_rather_than_abandoned() { // The upgrade hazard: `sidecars/` and `outbox/` hold work that exists @@ -4372,7 +4599,7 @@ mod tests { // and edit, and `outbox/`, holding exports the user was told had // succeeded. Losing a day of culling to an OS housekeeping pass, with // no error and no trace, is the worst outcome this application has. - let path = catalog_path("https://cloud.example", "duncan"); + let path = catalog_path(&account("https://cloud.example", "duncan")); let text = path.to_string_lossy().to_lowercase(); assert!( !text.contains("/cache/") && !text.contains("/tmp/"), @@ -4386,17 +4613,17 @@ mod tests { // Stated as a test because three separate call sites derive their // location by taking this path's parent, and a change here moves all // of them at once — including the two holding unsynced user work. - let catalog = catalog_path("https://cloud.example", "duncan"); + let catalog = catalog_path(&account("https://cloud.example", "duncan")); let parent = catalog.parent().expect("a parent"); assert_eq!( - crate::export::outbox_dir("https://cloud.example", "duncan"), + crate::export::outbox_dir(&account("https://cloud.example", "duncan")), parent.join("outbox") ); } #[test] fn catalog_path_is_filesystem_safe() { - let p = catalog_path("https://cloud.example.com:8443/nc", "duncan"); + let p = catalog_path(&account("https://cloud.example.com:8443/nc", "duncan")); let s = p.to_string_lossy(); assert!(!s.contains("://")); assert!(!s.contains(':') || cfg!(windows)); @@ -5029,8 +5256,8 @@ mod tests { fn thumbs_live_beside_the_catalog_not_in_the_cache() { // They sync to the server and are shared with other clients, so a // cache sweep must not discard them. - let cat = catalog_path("https://cloud.example", "duncan"); - let thumbs = thumbs_dir("https://cloud.example", "duncan"); + let cat = catalog_path(&account("https://cloud.example", "duncan")); + let thumbs = thumbs_dir(&account("https://cloud.example", "duncan")); assert_eq!(thumbs.parent(), cat.parent()); } @@ -5445,6 +5672,7 @@ mod tests { size, modified: None, has_preview: false, + materialised: true, } } diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 69a7f55..ae6844d 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -17,7 +17,7 @@ use std::rc::Rc; use std::sync::mpsc::Receiver; use dr_catalog::Catalog; -use dr_sync_nextcloud::{AppCredentials, Session, SessionStore}; +use dr_sync::{Account, AccountStore, Connection}; use dr_types::FormatFilter; use slint::{ComponentHandle, Model as _}; @@ -177,7 +177,12 @@ pub struct LibraryController { /// Pushing shards and the catalog to the server. sync_timer: RefCell>, /// Kept so a rescan can run without going back through the launch screen. - session: RefCell>, + /// + /// A [`Connection`] rather than credentials beside an account: it is what + /// every worker needs, it is what `remote::connect` takes, and holding the + /// two halves separately is how they came to be threaded through fifteen + /// signatures in the wrong order. + session: RefCell>, /// Which collection narrows the grid, owned by [`crate::collections_ui`] /// and read here. Shared rather than passed per call because a rescan, a /// scrub and a drop all reload the window and must all honour it. @@ -506,8 +511,8 @@ impl LibraryController { /// (FR-NC-6a), and a queued edit must never be. pub fn sidecar_cache_dir(&self) -> Option { let borrow = self.session.borrow(); - let (_, session, _) = borrow.as_ref()?; - library::catalog_path(&session.server, &session.user_id) + let (conn, _) = borrow.as_ref()?; + library::catalog_path(&conn.account) .parent() .map(|p| p.join("sidecars")) } @@ -520,8 +525,8 @@ impl LibraryController { /// catalog row is gone is unreachable anyway. pub fn cache_dir(&self) -> Option { let borrow = self.session.borrow(); - let (_, session, _) = borrow.as_ref()?; - library::catalog_path(&session.server, &session.user_id) + let (conn, _) = borrow.as_ref()?; + library::catalog_path(&conn.account) .parent() .map(|p| p.join("originals")) } @@ -565,8 +570,8 @@ impl LibraryController { /// three hundred would re-download every one of them. pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option { let borrow = self.session.borrow(); - let (_, session, _) = borrow.as_ref()?; - let catalog_path = library::catalog_path(&session.server, &session.user_id); + let (conn, _) = borrow.as_ref()?; + let catalog_path = library::catalog_path(&conn.account); let dir = catalog_path.parent()?.join("originals"); drop(borrow); @@ -602,15 +607,12 @@ impl LibraryController { self.catalog.clone() } - /// Credentials and session for the open library. + /// The open library's connection. /// - /// Needed by the trash, whose `MOVE` and `DELETE` go to the same account the + /// Needed by the trash, whose move and delete go to the same account the /// scan and thumbnail workers use. `None` before a library is opened. - pub fn session(&self) -> Option<(AppCredentials, Session)> { - self.session - .borrow() - .as_ref() - .map(|(c, s, _)| (c.clone(), s.clone())) + pub fn session(&self) -> Option { + self.session.borrow().as_ref().map(|(c, _)| c.clone()) } /// Catalog ids of the rows currently in the model, in model order. @@ -730,16 +732,12 @@ impl LibraryController { .collect() } - /// Credentials and account for the open library, if one is open. + /// The open library's connection, for a full-file fetch. /// - /// What a full-file fetch needs: the grid's paths are remote, so opening - /// an image means downloading it, and that needs the same session the - /// thumbnail workers use. - pub fn credentials(&self) -> Option<(AppCredentials, String)> { - self.session - .borrow() - .as_ref() - .map(|(creds, session, _)| (creds.clone(), session.user_id.clone())) + /// The grid's paths are remote, so opening an image means fetching it, and + /// that goes through the same account the thumbnail workers use. + pub fn credentials(&self) -> Option { + self.session.borrow().as_ref().map(|(c, _)| c.clone()) } /// Narrow the grid to a collection, or to the whole library with `None`. @@ -786,10 +784,13 @@ pub fn open( window: &AppWindow, ctl: Rc, coll_ctl: Rc, - store: &SessionStore, - session: Session, + store: &AccountStore, + account: Account, ) { - let creds = match store.credentials(&session) { + // The credential is fetched only where the connector wants one; a folder + // library has none, and asking the keyring for it would fail the one + // backend that needs nothing. + let conn = match store.connection(&account, crate::remote::needs_secret(&account)) { Ok(c) => c, Err(e) => { window.set_library_error(format!("credentials: {e}").into()); @@ -798,8 +799,8 @@ pub fn open( } }; - let filter = session.format_filter(); - *ctl.session.borrow_mut() = Some((creds.clone(), session.clone(), filter.clone())); + let filter = account.format_filter(); + *ctl.session.borrow_mut() = Some((conn.clone(), filter.clone())); window.set_show_library(true); window.set_library_open(true); @@ -809,10 +810,10 @@ pub fn open( // Always visible: two folders one letter apart are easy to confuse, and a // scan of the wrong one is indistinguishable from a broken scan. window.set_library_root_label( - if session.root.is_empty() { - format!("{} · whole account", session.user_id) + if conn.account.root.is_empty() { + format!("{} · whole account", conn.account.user_id) } else { - format!("{}/{}", session.user_id, session.root) + format!("{}/{}", conn.account.user_id, conn.account.root) } .into(), ); @@ -825,13 +826,13 @@ pub fn open( return; } - let path = library::catalog_path(&session.server, &session.user_id); + let path = library::catalog_path(&conn.account); log::info!( "scanning {} for {} format(s) → {}", - if session.root.is_empty() { + if conn.account.root.is_empty() { "" } else { - &session.root + &conn.account.root }, filter.iter().count(), path.display() @@ -842,9 +843,8 @@ pub fn open( show_catalog_now(window, &ctl, &path, &coll_ctl); let rx = library::spawn_scan( - creds, - session.user_id.clone(), - session.root.clone(), + conn.clone(), + conn.account.root.clone(), filter, path.clone(), ); @@ -870,8 +870,8 @@ fn drain_scan( // Named after the folder, because two accounts or two roots produce rows // that are otherwise identical. let title = match ctl.session.borrow().as_ref() { - Some((_, session, _)) if !session.root.is_empty() => { - format!("Scanning {}", session.root) + Some((c, _)) if !c.account.root.is_empty() => { + format!("Scanning {}", c.account.root) } _ => "Scanning the library".to_string(), }; @@ -1044,7 +1044,7 @@ fn start_rescan( ctl: &Rc, coll_ctl: &Rc, ) { - let Some((creds, session, filter)) = ctl.session.borrow().clone() else { + let Some((conn, filter)) = ctl.session.borrow().clone() else { return; }; @@ -1052,11 +1052,10 @@ fn start_rescan( window.set_library_error(slint::SharedString::new()); window.set_library_status("Rescanning…".into()); - let path = library::catalog_path(&session.server, &session.user_id); + let path = library::catalog_path(&conn.account); let rx = library::spawn_scan( - creds, - session.user_id.clone(), - session.root.clone(), + conn.clone(), + conn.account.root.clone(), filter, path.clone(), ); @@ -1327,13 +1326,30 @@ fn release_collection_offline( } }; let images = collection_images(catalog, &ids); - match cache.release(catalog.connection(), &images) { + let outcome = match cache.release(catalog.connection(), &images) { Ok(r) => r, Err(e) => { window.set_library_error(format!("removing local copies: {e}").into()); return; } + }; + + // TRACES: FR-NC-6c + // On a placeholder library the bookkeeping above owns no files, so it + // freed nothing — the originals are materialised in the library folder + // and only the sync client may take them back. Asking it to is what + // makes unpinning actually return the disk, and it must be a + // dehydration rather than a delete: removing a file inside a synced + // tree propagates to the server (ARCH §9.0a). + // + // Fire and forget: it is per-file work over a socket, the user has + // already been told the pin is withdrawn, and a client that refuses + // leaves the content where it is at no cost but disk. + if let Some(conn) = ctl.session() { + let path = library::catalog_path(&conn.account); + std::mem::drop(library::spawn_dehydrate(conn, path, images)); } + outcome }; let (count, freed) = released; @@ -1413,7 +1429,7 @@ fn collection_images(catalog: &Catalog, ids: &[dr_types::CollectionId]) -> Vec) { - let Some((creds, session, _)) = ctl.session.borrow().clone() else { + let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; let Some(cache_dir) = ctl.cache_dir() else { @@ -1428,9 +1444,8 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc) { } let rx = library::spawn_pin_fetch( - creds, - session.user_id.clone(), - library::catalog_path(&session.server, &session.user_id), + conn.clone(), + library::catalog_path(&conn.account), cache_dir, // Pinned originals are exempt from the budget, but a pin fetch also // stores passively when it finds an image already cached, so the worker @@ -1629,11 +1644,11 @@ fn start_outbox_drain(window: &AppWindow, ctl: &Rc) { ctl.outbox_maybe_dirty.set(false); return; } - let Some((creds, session, _)) = ctl.session.borrow().clone() else { + let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; - let rx = library::spawn_outbox_drain(creds, session.user_id.clone(), cache_dir); + let rx = library::spawn_outbox_drain(conn.clone(), cache_dir); let job = ctl .activity .begin(crate::activity::Kind::Upload, "Uploading queued edits"); @@ -2764,7 +2779,7 @@ pub(crate) fn start_sidecar_writes( // write being conditional on it. let offline = ctl.is_offline(); - let Some((creds, session, _)) = ctl.session.borrow().clone() else { + let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; let Some(cache_dir) = ctl.sidecar_cache_dir() else { @@ -2772,8 +2787,7 @@ pub(crate) fn start_sidecar_writes( }; let count = writes.len(); - let rx = - library::spawn_sidecar_writes(creds, session.user_id.clone(), writes, cache_dir, offline); + let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline); let timer = slint::Timer::default(); let weak = window.as_weak(); @@ -2907,7 +2921,7 @@ fn fetch_rank(row: usize, first_on_screen: usize, on_screen: usize) -> (u8, usiz /// Fetch thumbnails for rows in the model that do not have one yet. fn request_thumbnails(window: &AppWindow, ctl: &Rc) { - let Some((creds, session, _)) = ctl.session.borrow().clone() else { + let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; @@ -2967,11 +2981,10 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc) { let requested = wanted.len(); let rx = library::spawn_thumbnails( - creds, - session.user_id.clone(), + conn.clone(), wanted, - library::thumbs_dir(&session.server, &session.user_id), - library::catalog_path(&session.server, &session.user_id), + library::thumbs_dir(&conn.account), + library::catalog_path(&conn.account), ); drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class); } @@ -3022,14 +3035,11 @@ pub fn refresh_thumbnail( // nothing here to correct. return; }; - let Some((_, session, _)) = ctl.session.borrow().clone() else { + let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; - let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir( - &session.server, - &session.user_id, - )) { + let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) { Ok(s) => s, Err(e) => { log::warn!("re-thumbnailing {remote_path}: opening the store: {e}"); @@ -3336,7 +3346,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc) { return; } - let Some((creds, session, _)) = ctl.session.borrow().clone() else { + let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; // Already running: a second pass would race the first over the same @@ -3355,7 +3365,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc) { return; } - let catalog_path = library::catalog_path(&session.server, &session.user_id); + let catalog_path = library::catalog_path(&conn.account); let scratch = catalog_path .parent() .map(|p| p.join("scratch")) @@ -3373,14 +3383,9 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc) { // for next time and nothing is lost by not watching it. It reports // through the log until an export has a place in the activity list. { - let outbox = crate::export::outbox_dir(&session.server, &session.user_id); + let outbox = crate::export::outbox_dir(&conn.account); if crate::export::pending_count(&outbox) > 0 { - let rx = crate::export::spawn_upload( - creds.clone(), - session.user_id.clone(), - session.root.clone(), - outbox, - ); + let rx = crate::export::spawn_upload(conn.clone(), conn.account.root.clone(), outbox); std::thread::spawn(move || { while let Ok(msg) = rx.recv() { match msg { @@ -3403,10 +3408,9 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc) { window.set_library_syncing(true); let rx = crate::derived_sync::spawn_sync( - creds, - session.user_id.clone(), - session.root.clone(), - library::thumbs_dir(&session.server, &session.user_id), + conn.clone(), + conn.account.root.clone(), + library::thumbs_dir(&conn.account), catalog_path, scratch, ); @@ -3529,7 +3533,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc) { /// describes the fraction of the library that happened to be scrolled past. /// This covers the rest. fn start_sweep(window: &AppWindow, ctl: &Rc) { - let Some((creds, session, _)) = ctl.session.borrow().clone() else { + let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; @@ -3544,11 +3548,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc) { return; } - let rx = library::spawn_sweep( - creds, - session.user_id.clone(), - library::catalog_path(&session.server, &session.user_id), - ); + let rx = library::spawn_sweep(conn.clone(), library::catalog_path(&conn.account)); let timer = slint::Timer::default(); let weak = window.as_weak(); @@ -3640,7 +3640,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc) { /// The sync at the end is not a separate courtesy: a filled store that never /// leaves this device is most of the cost for none of the point. fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc) { - let Some((creds, session, _)) = ctl.session.borrow().clone() else { + let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; @@ -3664,10 +3664,9 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc) { window.set_library_thumbnailing(true); let rx = library::spawn_thumbnail_sweep( - creds, - session.user_id.clone(), - library::catalog_path(&session.server, &session.user_id), - library::thumbs_dir(&session.server, &session.user_id), + conn.clone(), + library::catalog_path(&conn.account), + library::thumbs_dir(&conn.account), ); let timer = slint::Timer::default(); diff --git a/ui/dr-ui/src/remote.rs b/ui/dr-ui/src/remote.rs index 3485321..d5ada14 100644 --- a/ui/dr-ui/src/remote.rs +++ b/ui/dr-ui/src/remote.rs @@ -1,4 +1,4 @@ -// TRACES: FR-NC-12 +// TRACES: FR-NC-12 | FR-NC-13 //! The one place the interface names a backend. //! //! `dr-sync` defines [`RemoteBackend`] and a capability model the engine adapts @@ -9,32 +9,100 @@ //! bought nothing it was designed for and a WebDAV or local-folder backend //! would have had nowhere to go. //! -//! Everything above this module now works through `&dyn RemoteBackend`. Adding -//! a backend is implementing the trait and changing [`connect`] — not editing -//! seven files. +//! Everything above this module works through `&dyn RemoteBackend`, and every +//! account it opens is a [`dr_sync::Account`] — configuration with no server +//! in it. Adding a backend is implementing two traits and adding a line to +//! [`registry`]; nothing else in `dr-ui` changes. See `docs/storage.md`. //! -//! ## What is deliberately still Nextcloud-shaped +//! # Why the registry is built here and not in `dr-sync` //! -//! Credentials. [`AppCredentials`] is an app password obtained through Login -//! Flow v2, which is a Nextcloud protocol rather than a general notion of -//! "how one authenticates to a remote". Abstracting it needs a decision about -//! what an account *is* across backends — an OAuth token, a bucket key pair -//! and an app password have no useful common shape — and inventing one before -//! a second backend exists would produce a wrong answer confidently. That is -//! the remaining half of this seam, and it is a design problem rather than a -//! mechanical one. +//! `dr-sync` must not depend on any connector, or the engine would drag a TLS +//! stack into a build that only wanted a folder. So the crate that already +//! depends on all of them — the interface — is where the list lives. It is +//! the only file in the application that names one. -use dr_sync::{RemoteBackend, RemoteError}; -use dr_sync_nextcloud::{AppCredentials, NextcloudBackend}; +use std::sync::{Arc, OnceLock}; -/// Open a connection to the configured remote. +use dr_sync::{Account, BackendProvider, BackendRegistry, Connection, RemoteBackend, RemoteError}; +use dr_sync_folder::FolderProvider; +use dr_sync_nextcloud::{NextcloudProvider, NextcloudVfs}; + +/// Every storage backend this build has, in the order the launch screen +/// offers them. +/// +/// Built once. A provider is stateless — it holds no connection and no +/// credential — so one instance serves every thread that asks. +pub fn registry() -> &'static BackendRegistry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| { + let mut r = BackendRegistry::new(); + r.register(Arc::new(NextcloudProvider)); + // TRACES: FR-NC-6c + // The folder connector does the filesystem work and knows nothing + // about sync clients; the placeholder convention is supplied here, + // which is the one place that may name one. Detection is per folder + // and per connection — the same directory offers hydration while the + // client is running and not while it is down (ARCH §9.0). + r.register(Arc::new(FolderProvider::with_vfs_detector(|root| { + NextcloudVfs::looks_synced(root) + .then(|| Arc::new(NextcloudVfs::detect()) as Arc) + }))); + r + }) +} + +/// The connector serving an account. +/// +/// Errors when this build has none — a configuration file outlives the binary +/// that wrote it, and saying *which* backend is missing beats "could not open +/// library". +pub fn provider_for(account: &Account) -> Result<&'static Arc, RemoteError> { + registry().for_account(account) +} + +/// Whether an account's credential has to be fetched from secure storage. +/// +/// Asked of the connector rather than inferred from the account, because an +/// empty login might be a folder library or might be a damaged record, and +/// guessing turns the second into a silent unauthenticated connection. +pub fn needs_secret(account: &Account) -> bool { + provider_for(account).is_ok_and(|p| p.sign_in().needs_secret()) +} + +/// Open a connection to a configured remote. /// /// Returns the trait object every caller should hold. The error type is -/// `dr-sync`'s rather than the connector's, so a caller handles a failure +/// `dr-sync`'s rather than a connector's, so a caller handles a failure /// without learning which backend produced it. -pub(crate) fn connect( - creds: &AppCredentials, - user_id: &str, -) -> Result, RemoteError> { - Ok(Box::new(NextcloudBackend::new(creds, user_id)?)) +pub(crate) fn connect(conn: &Connection) -> Result, RemoteError> { + registry().connect(conn) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_backends_are_registered() { + // The list the launch screen offers. A backend missing from here is a + // backend the user cannot choose, however complete its connector is. + let ids: Vec<&str> = registry().providers().iter().map(|p| p.id()).collect(); + assert!(ids.contains(&"nextcloud"), "{ids:?}"); + assert!(ids.contains(&"folder"), "{ids:?}"); + } + + #[test] + fn only_the_backend_with_a_login_wants_a_credential() { + assert!(needs_secret(&Account::new("nextcloud", "https://x"))); + assert!(!needs_secret(&Account::new("folder", "/mnt/photos"))); + } + + #[test] + fn an_account_for_an_unknown_backend_names_it() { + let e = provider_for(&Account::new("s3", "bucket")) + .err() + .expect("no such connector") + .to_string(); + assert!(e.contains("s3"), "{e}"); + } } diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs index f53275c..bba506d 100644 --- a/ui/dr-ui/src/settings_ui.rs +++ b/ui/dr-ui/src/settings_ui.rs @@ -33,6 +33,7 @@ use slint::ComponentHandle; use crate::settings_store::SettingsStore; use crate::AppWindow; +use dr_sync::Connection; /// Shared settings state for the running window. pub struct SettingsController { @@ -53,7 +54,8 @@ pub struct SettingsController { /// it browses a remote tree and nothing about it is specific to what the /// chosen folder is *for*. `None` means the picker is closed, which is /// also the only state a device destination ever has — a path on this - /// machine is typed or chosen by the platform, not walked over WebDAV. + /// machine is typed or chosen by the platform, not walked through a + /// backend. pub browser: RefCell>, /// Polls the folder listing while one is in flight. /// @@ -603,9 +605,9 @@ pub fn wire( /// List the folders under `path`, for the export destination picker. /// /// A near-twin of `launch_ui::spawn_folder_list` and deliberately not shared -/// with it. That one reaches into the `LaunchController` for its session and -/// reports failures onto the launch screen's error line; this one is handed -/// credentials and writes to the settings page. Factoring them together would +/// with it. That one reaches into the `LaunchController` for its account and +/// reports failures onto the launch screen's error line; this one is handed a +/// connection and writes to the settings page. Factoring them together would /// mean a function taking both controllers, or a trait implemented twice to /// abstract two call sites — more machinery than the twenty lines it saves. /// @@ -615,8 +617,7 @@ pub fn wire( pub fn spawn_folder_list( weak: slint::Weak, ctl: Rc, - creds: dr_sync_nextcloud::AppCredentials, - user_id: String, + conn: Connection, path: String, ) { use dr_sync::RemotePath; @@ -637,7 +638,7 @@ pub fn spawn_folder_list( return; }; rt.block_on(async { - match crate::remote::connect(&creds, &user_id) { + match crate::remote::connect(&conn) { Ok(b) => match b.list(&RemotePath::new(&path), None).await { Ok(entries) => { let mut dirs: Vec = entries diff --git a/ui/dr-ui/src/trash.rs b/ui/dr-ui/src/trash.rs index 0553a96..b3a159c 100644 --- a/ui/dr-ui/src/trash.rs +++ b/ui/dr-ui/src/trash.rs @@ -31,8 +31,8 @@ use std::path::PathBuf; use std::sync::mpsc::Receiver; use dr_catalog::{trash, Catalog}; -use dr_sync::{RemoteError, RemoteId, RemotePath}; -use dr_sync_nextcloud::AppCredentials; +use dr_sync::{Connection, RemoteError, RemoteId, RemotePath}; + use dr_types::ImageId; /// What a trash operation reports back to the UI. @@ -162,8 +162,7 @@ pub fn plan_restore( /// interrupted batch leaves the rows it completed correct rather than losing all /// of them. pub fn spawn_move( - creds: AppCredentials, - user_id: String, + conn: Connection, moves: Vec, direction: Direction, catalog_path: PathBuf, @@ -184,7 +183,7 @@ pub fn spawn_move( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { let _ = tx.send(TrashMessage::Done { @@ -274,8 +273,7 @@ pub fn spawn_move( /// worker can drop the previews — the shards sync, so a stale entry would keep /// serving a preview of a deleted photograph on every device. pub fn spawn_purge( - creds: AppCredentials, - user_id: String, + conn: Connection, images: Vec, paths: Vec<(ImageId, Option, String)>, catalog_path: PathBuf, @@ -297,7 +295,7 @@ pub fn spawn_purge( }; rt.block_on(async { - let backend = match crate::remote::connect(&creds, &user_id) { + let backend = match crate::remote::connect(&conn) { Ok(b) => b, Err(e) => { let _ = tx.send(TrashMessage::Done { diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index a8d1bce..d6e00be 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -175,6 +175,7 @@ export component AppWindow inherits Window { in property launch-account: ""; in property launch-root: ""; in property launch-server: ""; + in property launch-folder: ""; in property launch-busy: false; in property launch-status: ""; in property launch-error: ""; @@ -184,6 +185,8 @@ export component AppWindow inherits Window { in-out property <[bool]> launch-format-checked; callback launch-sign-in(string); + /// The path of a folder library — no account, no credential. + callback launch-use-folder(string); /// server, username, app password callback launch-sign-in-direct(string, string, string); callback launch-sign-out(); @@ -1228,6 +1231,7 @@ in property panel-visible: true; account: root.launch-account; library-root: root.launch-root; server-url: root.launch-server; + folder-path: root.launch-folder; busy: root.launch-busy; status: root.launch-status; error: root.launch-error; @@ -1237,6 +1241,7 @@ in property panel-visible: true; format-checked: root.launch-format-checked; sign-in(server) => { root.launch-sign-in(server); } + use-folder(path) => { root.launch-use-folder(path); } sign-in-direct(server, user, pw) => { root.launch-sign-in-direct(server, user, pw); } diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index 13edb9e..1a05090 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -44,6 +44,9 @@ export component LaunchScreen inherits Rectangle { in property account: ""; in property library-root: ""; in property server-url: ""; + // Two endpoints, shown together. Someone deciding between a server and a + // folder should not have to clear one field to try the other. + in property folder-path: ""; in property busy: false; in property status: ""; in property error: ""; @@ -58,6 +61,9 @@ export component LaunchScreen inherits Rectangle { // --- events out --- callback sign-in(string); + // A folder library: no browser, no credential, no waiting state — the + // whole sign-in is checking the directory is there. + callback use-folder(string); /// server, username, app password callback sign-in-direct(string, string, string); callback sign-out(); @@ -87,15 +93,37 @@ export component LaunchScreen inherits Rectangle { init => { self.focus(); } } - VerticalLayout { - alignment: center; - padding: Theme.gap-lg; + // Scrollable, and it has to be. The signed-out screen offers three routes + // — browser sign-in, an app password, a folder — and the column is taller + // than a laptop window in a side-by-side split, let alone a phone. A + // centred `VerticalLayout` that overflows clips at *both* ends, so the + // masthead and the last route disappear together and there is no + // indication either existed. + // + // `viewport-height` follows the content rather than being fixed: the + // screen's height changes by hundreds of pixels as it moves between + // signed-out, awaiting approval, and signed-in, and a constant would + // either strand a scrollbar on the short states or clip the tall one. + Flickable { + viewport-height: max(self.height, column.preferred-height); + + column := VerticalLayout { + width: 100%; + alignment: start; + padding: Theme.gap-lg; + // Centres the column when it fits and lets it start at the top + // when it does not — the two cases the fixed `alignment: center` + // could not both serve. + padding-top: max( + Theme.gap-lg, + (root.height - card.preferred-height - 2 * Theme.gap-lg) / 2 + ); Rectangle { max-width: 460px; horizontal-stretch: 0; - VerticalLayout { + card := VerticalLayout { spacing: Theme.gap-lg; // --- masthead --- @@ -114,7 +142,7 @@ export component LaunchScreen inherits Rectangle { Label { text: root.signed-in ? "Connected" - : "Connect a Nextcloud account to begin"; + : "Connect a Nextcloud account, or open a folder"; body: true; } } @@ -208,6 +236,46 @@ export component LaunchScreen inherits Rectangle { text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own."; wrap: word-wrap; } + + // --- or: a folder on this machine --- + // + // A local disk, a mounted share, or the folder the + // Nextcloud desktop client already syncs. No account and + // no credential, so this is the route that works on a + // machine with no keyring at all. + HorizontalLayout { + spacing: Theme.gap; + alignment: center; + Rectangle { + height: 1px; + background: Theme.rule; + horizontal-stretch: 1; + } + Caption { text: "or"; } + Rectangle { + height: 1px; + background: Theme.rule; + horizontal-stretch: 1; + } + } + + PanelHeading { text: "FOLDER"; } + folder-input := Field { + text: root.folder-path; + placeholder: "/home/you/Pictures"; + accepted(path) => { root.use-folder(path); } + } + + FormButton { + text: "Open folder"; + enabled: !root.busy && folder-input.text != ""; + clicked => { root.use-folder(folder-input.text); } + } + + Caption { + text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed."; + wrap: word-wrap; + } } // --- login pending: the browser step --- @@ -377,5 +445,6 @@ export component LaunchScreen inherits Rectangle { } } } + } } } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index aafdabf..731dec0 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -949,7 +949,7 @@ component HeaderActions inherits HorizontalLayout { text: root.exporting ? "Cancel export" : (root.export-to-server - ? "Export " + root.selected-count + " to Nextcloud" + ? "Export " + root.selected-count + " to the library" : "Export " + root.selected-count); active: root.exporting; y: root.centred ? (root.row-height - self.height) / 2 : 0;