diff --git a/Cargo.lock b/Cargo.lock index 7a0b389..52180ad 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", @@ -1615,6 +1630,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-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/src/lib.rs b/core/dr-sync-folder/src/lib.rs new file mode 100644 index 0000000..9da08bd --- /dev/null +++ b/core/dr-sync-folder/src/lib.rs @@ -0,0 +1,612 @@ +// 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 async_trait::async_trait; +use dr_sync::{ + Account, BackendProvider, Capabilities, ChangeDetection, Connection, Cursor, EntryKind, + Precondition, RemoteBackend, RemoteChange, RemoteEntry, RemoteError, RemoteId, RemotePath, + ServerPreviews, SignIn, Validator, +}; + +/// 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 +/// Registers the folder connector. +/// +/// See [`dr_sync::provider`] for what each method is for. +pub struct FolderProvider; + +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> { + Ok(Box::new(FolderBackend::new(&conn.account.endpoint)?)) + } +} + +/// TRACES: FR-NC-13 | FR-NC-4 +/// A library rooted at a directory. +#[derive(Debug, Clone)] +pub struct FolderBackend { + root: PathBuf, + caps: Capabilities, +} + +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 { + let root = root.into(); + if !root.is_dir() { + return Err(RemoteError::Configuration(format!( + "{} is not a folder", + root.display() + ))); + } + Ok(Self { + root, + 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, + }, + }) + } + + 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)) + } + + /// 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", + )), + } + } +} + +/// 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}")), + } +} + +/// 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(); + 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; + } + }; + + let path = dir.join(name); + out.push(RemoteEntry { + id: RemoteId::Stable(identity(&path)), + kind: if meta.is_dir() { + EntryKind::Directory + } else { + EntryKind::File + }, + validator: validator_of(&meta), + size: meta.len(), + modified: modified_secs(&meta), + // No renderer behind a folder; previews are extracted + // locally from the file itself. + has_preview: false, + 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 = self.resolve_id(id)?; + 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 local = self.resolve(path)?; + 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)); + } + + 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> { + let local = self.resolve_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> { + let src = self.resolve_id(from)?; + let dst = self.resolve(to)?; + 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 + } + + 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..66563ba --- /dev/null +++ b/core/dr-sync-folder/src/tests.rs @@ -0,0 +1,540 @@ +//! 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; + + 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; + 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; + 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)); + + 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"); +} 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/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index 7cda5e9..ba3a48b 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 provider::NextcloudProvider; /// Chunk sizes Nextcloud's chunked upload v2 accepts. const CHUNKS: ChunkConstraints = ChunkConstraints { 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/error.rs b/core/dr-sync/src/error.rs index 4f8042f..9703a5b 100644 --- a/core/dr-sync/src/error.rs +++ b/core/dr-sync/src/error.rs @@ -41,6 +41,21 @@ pub enum RemoteError { #[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..d8920a8 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,19 @@ 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 account::{Account, AccountError, AccountStore, Connection, Secret, LEGACY_BACKEND}; pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, 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::{ 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-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..bbca51c 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,47 @@ 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. The walk that was expensive was expensive because it was 50k + `PROPFIND`s; 50k `stat` calls take well under a second. 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: diff --git a/docs/requirements.md b/docs/requirements.md index 8ce0b58..dd42189 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -731,9 +731,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 +755,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..6cfff9d --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,378 @@ +# 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: fifty thousand `stat` +calls against a filesystem take well under a second, and fifty thousand +`PROPFIND`s do not. The capability model is what lets both be driven by the same +engine at the speed each actually runs at. + +**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. 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. diff --git a/docs/traceability.md b/docs/traceability.md index ca44ba4..73d80dd 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 | 278 | -| TRACES tags found | 811 | -| Requirements defined | 177 | -| Requirements covered | 106 | -| **Coverage** | **59.9%** (106/177) | +| Source files scanned | 282 | +| TRACES tags found | 822 | +| Requirements defined | 178 | +| Requirements covered | 107 | +| **Coverage** | **60.1%** (107/178) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 84 | 122 | +| FR | 85 | 123 | | 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:2229`](../ui/dr-ui/src/library.rs#L2229), [`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:452`](../core/dr-sync-nextcloud/src/lib.rs#L452), [`core/dr-sync/src/lib.rs:133`](../core/dr-sync/src/lib.rs#L133), [`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: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:3637`](../ui/dr-ui/src/library.rs#L3637), [`ui/dr-ui/src/library.rs:3671`](../ui/dr-ui/src/library.rs#L3671), [`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:2702`](../ui/dr-ui/src/library.rs#L2702), [`ui/dr-ui/src/library.rs:3172`](../ui/dr-ui/src/library.rs#L3172), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3610`](../ui/dr-ui/src/library_ui.rs#L3610), [`ui/dr-ui/src/library_ui.rs:4575`](../ui/dr-ui/src/library_ui.rs#L4575), [`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:3685`](../ui/dr-ui/src/library.rs#L3685), [`ui/dr-ui/src/library_ui.rs:631`](../ui/dr-ui/src/library_ui.rs#L631), [`ui/dr-ui/src/library_ui.rs:6372`](../ui/dr-ui/src/library_ui.rs#L6372), [`ui/dr-ui/src/library_ui.rs:6383`](../ui/dr-ui/src/library_ui.rs#L6383), [`ui/dr-ui/src/library_ui.rs:6396`](../ui/dr-ui/src/library_ui.rs#L6396), [`ui/dr-ui/src/library_ui.rs:6411`](../ui/dr-ui/src/library_ui.rs#L6411), [`ui/dr-ui/src/library_ui.rs:6420`](../ui/dr-ui/src/library_ui.rs#L6420), [`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:3931`](../ui/dr-ui/src/library.rs#L3931), [`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:5186`](../ui/dr-ui/src/library_ui.rs#L5186), [`ui/dr-ui/src/library_ui.rs:5239`](../ui/dr-ui/src/library_ui.rs#L5239), [`ui/dr-ui/src/library_ui.rs:6512`](../ui/dr-ui/src/library_ui.rs#L6512), [`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:3472`](../ui/dr-ui/src/library_ui.rs#L3472), [`ui/dr-ui/src/library_ui.rs:4165`](../ui/dr-ui/src/library_ui.rs#L4165), [`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:1641`](../ui/dr-ui/src/library.rs#L1641), [`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:4867`](../ui/dr-ui/src/library_ui.rs#L4867), [`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:1601`](../ui/dr-ui/src/library.rs#L1601), [`ui/dr-ui/src/library.rs:1677`](../ui/dr-ui/src/library.rs#L1677), [`ui/dr-ui/src/library.rs:233`](../ui/dr-ui/src/library.rs#L233), [`ui/dr-ui/src/library.rs:4038`](../ui/dr-ui/src/library.rs#L4038), [`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:1563`](../ui/dr-ui/src/library_ui.rs#L1563), [`ui/dr-ui/src/library_ui.rs:1589`](../ui/dr-ui/src/library_ui.rs#L1589), [`ui/dr-ui/src/library_ui.rs:1605`](../ui/dr-ui/src/library_ui.rs#L1605), [`ui/dr-ui/src/library_ui.rs:1699`](../ui/dr-ui/src/library_ui.rs#L1699), [`ui/dr-ui/src/library_ui.rs:2315`](../ui/dr-ui/src/library_ui.rs#L2315), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:2753`](../ui/dr-ui/src/library_ui.rs#L2753), [`ui/dr-ui/src/library_ui.rs:2975`](../ui/dr-ui/src/library_ui.rs#L2975), [`ui/dr-ui/src/library_ui.rs:3253`](../ui/dr-ui/src/library_ui.rs#L3253), [`ui/dr-ui/src/library_ui.rs:3341`](../ui/dr-ui/src/library_ui.rs#L3341), [`ui/dr-ui/src/library_ui.rs:3523`](../ui/dr-ui/src/library_ui.rs#L3523), [`ui/dr-ui/src/library_ui.rs:3637`](../ui/dr-ui/src/library_ui.rs#L3637), [`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:5284`](../ui/dr-ui/src/library_ui.rs#L5284), [`ui/dr-ui/src/library_ui.rs:5399`](../ui/dr-ui/src/library_ui.rs#L5399), [`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/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/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:2703`](../ui/dr-ui/src/library.rs#L2703), [`ui/dr-ui/src/library.rs:2807`](../ui/dr-ui/src/library.rs#L2807), [`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/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:1641`](../ui/dr-ui/src/library.rs#L1641), [`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:2575`](../ui/dr-ui/src/library_ui.rs#L2575), [`ui/dr-ui/src/library_ui.rs:2975`](../ui/dr-ui/src/library_ui.rs#L2975), [`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:3358`](../ui/dr-ui/src/library_ui.rs#L3358), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`ui/dr-ui/src/library_ui.rs:6354`](../ui/dr-ui/src/library_ui.rs#L6354), [`ui/dr-ui/src/library_ui.rs:6431`](../ui/dr-ui/src/library_ui.rs#L6431), [`ui/dr-ui/src/library_ui.rs:6443`](../ui/dr-ui/src/library_ui.rs#L6443), [`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:1040`](../ui/dr-ui/src/library.rs#L1040), [`ui/dr-ui/src/library.rs:1677`](../ui/dr-ui/src/library.rs#L1677), [`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:1605`](../ui/dr-ui/src/library_ui.rs#L1605), [`ui/dr-ui/src/library_ui.rs:3358`](../ui/dr-ui/src/library_ui.rs#L3358), [`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:909`](../core/dr-sync-nextcloud/src/lib.rs#L909), [`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:166`](../core/dr-sync/src/lib.rs#L166), [`core/dr-sync/src/lib.rs:49`](../core/dr-sync/src/lib.rs#L49), [`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:143`](../core/dr-sync-folder/src/lib.rs#L143), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:65`](../core/dr-sync-folder/src/lib.rs#L65), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`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: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:2702`](../ui/dr-ui/src/library.rs#L2702), [`ui/dr-ui/src/library.rs:3172`](../ui/dr-ui/src/library.rs#L3172), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3610`](../ui/dr-ui/src/library_ui.rs#L3610), [`ui/dr-ui/src/library_ui.rs:4575`](../ui/dr-ui/src/library_ui.rs#L4575), [`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:143`](../core/dr-sync-folder/src/lib.rs#L143), [`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:166`](../core/dr-sync/src/lib.rs#L166), [`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/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: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:1421`](../ui/dr-ui/src/library.rs#L1421), [`ui/dr-ui/src/library.rs:1444`](../ui/dr-ui/src/library.rs#L1444), [`ui/dr-ui/src/library.rs:1601`](../ui/dr-ui/src/library.rs#L1601), [`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:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1530`](../ui/dr-ui/src/library_ui.rs#L1530), [`ui/dr-ui/src/library_ui.rs:2057`](../ui/dr-ui/src/library_ui.rs#L2057), [`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:5301`](../ui/dr-ui/src/library_ui.rs#L5301), [`ui/dr-ui/src/library_ui.rs:5319`](../ui/dr-ui/src/library_ui.rs#L5319), [`ui/dr-ui/src/library_ui.rs:5331`](../ui/dr-ui/src/library_ui.rs#L5331), [`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-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: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/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/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:100`](../core/dr-sync-nextcloud/src/lib.rs#L100), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3172`](../ui/dr-ui/src/library.rs#L3172), [`ui/dr-ui/src/library_ui.rs:3610`](../ui/dr-ui/src/library_ui.rs#L3610), [`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/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library.rs:970`](../ui/dr-ui/src/library.rs#L970) | | 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:165`](../ui/dr-ui/ui/identity.slint#L165), [`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:165`](../ui/dr-ui/ui/identity.slint#L165), [`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:5186`](../ui/dr-ui/src/library_ui.rs#L5186), [`ui/dr-ui/src/library_ui.rs:5331`](../ui/dr-ui/src/library_ui.rs#L5331), [`ui/dr-ui/src/library_ui.rs:6462`](../ui/dr-ui/src/library_ui.rs#L6462), [`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:4370`](../ui/dr-ui/src/library_ui.rs#L4370), [`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:4370`](../ui/dr-ui/src/library_ui.rs#L4370), [`ui/dr-ui/src/library_ui.rs:4415`](../ui/dr-ui/src/library_ui.rs#L4415), [`ui/dr-ui/src/library_ui.rs:4518`](../ui/dr-ui/src/library_ui.rs#L4518), [`ui/dr-ui/src/library_ui.rs:4546`](../ui/dr-ui/src/library_ui.rs#L4546), [`ui/dr-ui/src/library_ui.rs:5319`](../ui/dr-ui/src/library_ui.rs#L5319), [`ui/dr-ui/src/library_ui.rs:5331`](../ui/dr-ui/src/library_ui.rs#L5331), [`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:2807`](../ui/dr-ui/src/library.rs#L2807) | +| 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:3771`](../ui/dr-ui/src/library.rs#L3771), [`ui/dr-ui/src/library.rs:4563`](../ui/dr-ui/src/library.rs#L4563), [`ui/dr-ui/src/library_ui.rs:4055`](../ui/dr-ui/src/library_ui.rs#L4055), [`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:1040`](../ui/dr-ui/src/library.rs#L1040) | | 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:2702`](../ui/dr-ui/src/library.rs#L2702) | | 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. +71 of 178 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 526794c..2baefce 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 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..b5597f6 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, 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())); 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/identity_ui.rs b/ui/dr-ui/src/identity_ui.rs index 87f503c..2b203f9 100644 --- a/ui/dr-ui/src/identity_ui.rs +++ b/ui/dr-ui/src/identity_ui.rs @@ -356,15 +356,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); @@ -690,7 +685,7 @@ pub fn wire( if ctl.regroup.borrow().is_some() { return; } - let Some((_, _, catalog_path, _)) = paths() else { + let Some((_, catalog_path, _)) = paths() else { return; }; @@ -782,7 +777,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; }; @@ -791,8 +786,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..9b6c9cf 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,62 @@ 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 }; + + let provider = match crate::remote::registry().get(dr_sync_folder::BACKEND_ID) { + Some(p) => p.clone(), + None => { + ctl.model + .borrow_mut() + .fail("this build has no folder support"); + render(&w, &ctl); + return; + } + }; + + // The connector checks the directory before an account is written + // for it. A typo stored here would skip the launch screen next + // start and surface as a library that finds nothing. + let endpoint = match provider.normalise_endpoint(&path) { + Ok(e) => e, + Err(e) => { + ctl.model.borrow_mut().fail(e); + render(&w, &ctl); + return; + } + }; + + let account = match provider.account_for(&endpoint) { + Ok(a) => a, + Err(e) => { + ctl.model.borrow_mut().fail(e.to_string()); + render(&w, &ctl); + return; + } + }; + + // `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. + if let Err(e) = ctl.store.save(&account, None) { + log::warn!("persisting account: {e}"); + } + log::info!("using folder library at {}", account.endpoint); + ctl.model.borrow_mut().signed_in(account); + render(&w, &ctl); + }); + } + // --- sign out ------------------------------------------------------ { let weak = window.as_weak(); @@ -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 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..e775ec3 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, 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 { @@ -1001,20 +995,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 +1023,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 +1032,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 +1055,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 +1105,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 +1114,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 +1156,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 +1171,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 +1455,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 +1516,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 { @@ -1678,8 +1663,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 +1704,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 +1753,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 +1791,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 +1834,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 +1940,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 +2469,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 +2507,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 +2840,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 +2968,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}"); @@ -3226,8 +3203,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 +3276,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}"); @@ -3457,8 +3433,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 +3447,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 +3487,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 +4278,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 +4367,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 +4381,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 +5024,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()); } diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 69a7f55..51ca77b 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(), ); @@ -1413,7 +1412,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 +1427,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 +1627,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 +2762,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 +2770,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 +2904,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 +2964,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 +3018,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 +3329,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 +3348,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 +3366,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 +3391,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 +3516,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 +3531,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 +3623,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 +3647,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..7370ef4 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,91 @@ //! 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; + +/// 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)); + r.register(Arc::new(FolderProvider)); + 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..6e3f669 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(); @@ -114,7 +120,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 +214,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 --- 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;