diff --git a/Cargo.lock b/Cargo.lock index 52180ad..ee1f518 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1580,6 +1580,7 @@ dependencies = [ "dr-decode", "dr-plat", "dr-sync", + "dr-sync-folder", "dr-types", "env_logger", "log", diff --git a/core/dr-sync-folder/src/borrow.rs b/core/dr-sync-folder/src/borrow.rs new file mode 100644 index 0000000..b3e9006 --- /dev/null +++ b/core/dr-sync-folder/src/borrow.rs @@ -0,0 +1,264 @@ +// TRACES: FR-NC-6c | FR-NC-6a +//! Hydrating a file for as long as it is needed, and no longer. +//! +//! A pass over a library — thumbnails, face indexing — needs each photograph's +//! bytes for a moment and never again. On a virtual-filesystem folder those +//! bytes may not be here, and fetching them is whole-file: hydrating a 17,000 +//! image library to index it would land the entire library on a disk the user +//! deliberately keeps most of it off (ARCH §9.0). +//! +//! So hydration is a **borrow**. Ask for a file, use it, give it back. Peak +//! disk becomes the working set rather than the library, and the transfer is +//! paid once for a thumbnail that is then kept for ever — and pushed to the +//! server for other devices, which never pay it at all. +//! +//! # The rule that makes it safe +//! +//! **A file is returned to the state it was found in.** If it was already +//! downloaded — the user pinned it, opened it yesterday, or never uses VFS — +//! the borrow leaves it downloaded. Only what this pass hydrated is released. +//! Anything else silently undoes a choice the user made, and "my pinned trip +//! evaporated after an indexing run" is the kind of failure that makes people +//! stop trusting the feature. +//! +//! # Why it is reference counted +//! +//! Lanes run concurrently and two of them meet on the same file: the +//! thumbnail pass and the face pass want the same RAW. Without counting, the +//! first to finish dehydrates the file the second is reading. With it, the +//! transfer is paid once and the release happens when the last borrower is +//! done. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath}; + +/// What a borrow is holding, per path. +#[derive(Debug, Default)] +struct Held { + /// How many borrowers are using it now. + borrowers: usize, + /// Whether *we* brought it here. False means it was already downloaded + /// and must be left that way. + ours: bool, +} + +/// Tracks what has been hydrated and by whom. +/// +/// Cheap to clone — every worker holds one and they share the same state. +#[derive(Clone, Default, Debug)] +pub struct BorrowPool { + held: Arc>>, +} + +/// What a completed borrow did, for reporting a pass's real cost. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct BorrowStats { + /// Files that were already here. These cost nothing. + pub already_local: usize, + /// Files this pass downloaded. + pub hydrated: usize, + /// Files released again afterwards. + pub released: usize, + /// Files left downloaded because they were already so. + pub kept: usize, +} + +impl BorrowPool { + pub fn new() -> Self { + Self::default() + } + + /// Borrow a file's content for the life of the returned guard. + /// + /// Downloads it if it is a placeholder; does nothing if it is already + /// here. The guard releases it on drop, but only if this pool hydrated it + /// and nothing else still holds it. + /// + /// A backend without [`Materialisation::OnDemand`] short-circuits: the + /// borrow succeeds and does nothing, so a caller written for a VFS library + /// runs unchanged against a server or a plain folder. + /// + /// [`Materialisation::OnDemand`]: dr_sync::Materialisation::OnDemand + pub async fn borrow<'p>( + &'p self, + backend: &dyn RemoteBackend, + path: &RemotePath, + ) -> Result, RemoteError> { + self.borrow_known(backend, path, None).await + } + + /// [`borrow`](Self::borrow) where the caller already knows whether the + /// content is here. + /// + /// A sweep reads availability out of the catalog on its way to building a + /// work list, so making the pool re-derive it costs a directory listing + /// per file for an answer already in hand. + /// + /// **`None` means "find out", and being wrong is not symmetric.** Claiming + /// a file was already local leaves it downloaded — disk, and nothing else. + /// Claiming it was not releases a file the user may have pinned. So an + /// uncertain caller passes `Some(true)` or `None`, never a guess at + /// `false`. + pub async fn borrow_known<'p>( + &'p self, + backend: &dyn RemoteBackend, + path: &RemotePath, + already_local: Option, + ) -> Result, RemoteError> { + if !backend.capabilities().materialisation.can_materialise() { + return Ok(Borrowed { + pool: None, + path: path.clone(), + hydrated: false, + }); + } + + // Another borrower already has it: join them rather than asking the + // client a second time. + { + let mut held = self.lock(); + if let Some(entry) = held.get_mut(path) { + entry.borrowers += 1; + return Ok(Borrowed { + pool: Some(self), + path: path.clone(), + hydrated: false, + }); + } + } + + let id = RemoteId::Path(path.clone()); + + // What was here *before* we asked. The whole contract rests on this + // being read first: after `materialise` there is no way to tell what + // we brought from what was already there. + let was_local = match already_local { + Some(known) => known, + None => is_materialised(backend, path).await, + }; + + if !was_local { + backend.materialise(&id).await?; + } + + self.lock().insert( + path.clone(), + Held { + borrowers: 1, + ours: !was_local, + }, + ); + + Ok(Borrowed { + pool: Some(self), + path: path.clone(), + hydrated: !was_local, + }) + } + + /// Release everything this pool still holds that it hydrated. + /// + /// The end-of-pass sweep. A guard dropped on a panicking worker cannot run + /// its async release, so the pool is drained deliberately at the end + /// rather than trusted to unwind cleanly. + pub async fn release_all(&self, backend: &dyn RemoteBackend) -> BorrowStats { + let ours: Vec = { + let held = self.lock(); + held.iter() + .filter(|(_, h)| h.ours) + .map(|(p, _)| p.clone()) + .collect() + }; + + let mut stats = BorrowStats::default(); + for path in ours { + match backend.dematerialise(&RemoteId::Path(path.clone())).await { + Ok(()) => stats.released += 1, + // Not fatal, and not worth failing a completed pass over: the + // content stays, which costs disk and loses nothing. + Err(e) => log::debug!("releasing {path}: {e}"), + } + } + self.lock().clear(); + stats + } + + /// How many paths are currently held. + pub fn held(&self) -> usize { + self.lock().len() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + // A poisoned lock means a worker panicked while holding it. The map is + // bookkeeping, not a resource — carrying on with it is better than + // taking the whole pass down. + self.held.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +/// A file held local for as long as this lives. +/// +/// Dropping it marks the borrow finished. The actual release happens in +/// [`BorrowPool::release_all`], because dropping cannot await. +#[derive(Debug)] +pub struct Borrowed<'p> { + pool: Option<&'p BorrowPool>, + path: RemotePath, + /// Whether this borrow was the one that downloaded it. + hydrated: bool, +} + +impl Borrowed<'_> { + /// Whether this borrow paid for a download. + pub fn hydrated(&self) -> bool { + self.hydrated + } + + pub fn path(&self) -> &RemotePath { + &self.path + } +} + +impl Drop for Borrowed<'_> { + fn drop(&mut self) { + let Some(pool) = self.pool else { return }; + let mut held = pool.lock(); + if let Some(entry) = held.get_mut(&self.path) { + entry.borrowers = entry.borrowers.saturating_sub(1); + // Left in the map even at zero borrowers: `release_all` needs to + // know it was ours, and a file wanted again a moment later should + // not be downloaded twice. + if entry.borrowers == 0 && !entry.ours { + held.remove(&self.path); + } + } + } +} + +/// Whether the backend currently holds this file's content. +/// +/// Asked by listing its parent, because the trait has no "stat one object" — +/// and adding one for this would put a method on every backend to serve a case +/// only one of them has. +async fn is_materialised(backend: &dyn RemoteBackend, path: &RemotePath) -> bool { + // `parent()` is `None` for a file directly under the library root, whose + // parent *is* the root — not "no parent". Treating the two alike reported + // every top-level photograph as already downloaded, so nothing was ever + // hydrated and nothing was ever released. + let parent = path.parent().unwrap_or_else(RemotePath::root); + match backend.list(&parent, None).await { + Ok(entries) => entries + .iter() + .find(|e| &e.path == path) + .map(|e| e.materialised) + // Not listed at all: nothing to hydrate, and the caller's own read + // will report the miss properly. + .unwrap_or(true), + Err(_) => true, + } +} + +#[cfg(test)] +mod tests; diff --git a/core/dr-sync-folder/src/borrow/tests.rs b/core/dr-sync-folder/src/borrow/tests.rs new file mode 100644 index 0000000..0baff06 --- /dev/null +++ b/core/dr-sync-folder/src/borrow/tests.rs @@ -0,0 +1,242 @@ +//! The borrow contract, against a filesystem and a fake client. +//! +//! The fake stands in for the sync client's socket, not for the filesystem: +//! it renames stubs exactly as suffix-mode VFS does, so everything under test +//! is the real path resolution and the real state tracking. + +use super::*; +use crate::{FolderBackend, Vfs}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// A stand-in for a sync client, counting what it was asked to do. +struct FakeClient { + suffix: &'static str, + hydrations: AtomicUsize, + dehydrations: AtomicUsize, + /// When true, refuse to hydrate — the client is running but the server is + /// not reachable. + broken: bool, +} + +impl FakeClient { + fn new() -> Arc { + Arc::new(Self { + suffix: ".nextcloud", + hydrations: AtomicUsize::new(0), + dehydrations: AtomicUsize::new(0), + broken: false, + }) + } + fn broken() -> Arc { + Arc::new(Self { + suffix: ".nextcloud", + hydrations: AtomicUsize::new(0), + dehydrations: AtomicUsize::new(0), + broken: true, + }) + } +} + +impl Vfs for FakeClient { + fn name(&self) -> &'static str { + "fake" + } + fn is_placeholder(&self, on_disk: &str) -> bool { + on_disk.ends_with(self.suffix) + } + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { + on_disk.strip_suffix(self.suffix).unwrap_or(on_disk) + } + fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Owned(format!("{name}{}", self.suffix)) + } + fn can_materialise(&self) -> bool { + true + } + fn materialise(&self, local: &Path) -> Result<(), RemoteError> { + self.hydrations.fetch_add(1, Ordering::SeqCst); + if self.broken { + return Err(RemoteError::Network("no server".into())); + } + // Suffix mode renames rather than filling in place, and writes the + // real content. + let real = PathBuf::from(local.to_string_lossy().strip_suffix(self.suffix).unwrap()); + std::fs::write(&real, vec![9u8; 4096]).unwrap(); + std::fs::remove_file(local).unwrap(); + Ok(()) + } + fn dematerialise(&self, local: &Path) -> Result<(), RemoteError> { + self.dehydrations.fetch_add(1, Ordering::SeqCst); + let stub = format!("{}{}", local.display(), self.suffix); + std::fs::write(&stub, [0u8]).unwrap(); + std::fs::remove_file(local).unwrap(); + Ok(()) + } +} + +struct Tmp(PathBuf); + +impl Tmp { + fn new(name: &str) -> Self { + let d = std::env::temp_dir().join(format!("dr-borrow-{name}")); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Tmp(d) + } + /// A dehydrated photograph. + fn stub(&self, rel: &str) -> &Self { + std::fs::write(self.0.join(format!("{rel}.nextcloud")), [0u8]).unwrap(); + self + } + /// One the user already has. + fn real(&self, rel: &str) -> &Self { + std::fs::write(self.0.join(rel), vec![1u8; 2048]).unwrap(); + self + } + fn has(&self, rel: &str) -> bool { + self.0.join(rel).is_file() + } + fn backend(&self, vfs: Arc) -> FolderBackend { + FolderBackend::with_vfs(&self.0, vfs).unwrap() + } +} + +impl Drop for Tmp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[tokio::test] +async fn a_borrowed_placeholder_is_downloaded_and_given_back() { + let t = Tmp::new("cycle"); + t.stub("a.CR2"); + let client = FakeClient::new(); + let b = t.backend(client.clone()); + let pool = BorrowPool::new(); + let path = RemotePath::new("a.CR2"); + + { + let held = pool.borrow(&b, &path).await.unwrap(); + assert!(held.hydrated(), "this borrow paid for it"); + assert!(t.has("a.CR2"), "content is here while borrowed"); + assert_eq!( + b.get(&RemoteId::Path(path.clone()), None) + .await + .unwrap() + .len(), + 4096 + ); + } + + let stats = pool.release_all(&b).await; + assert_eq!(stats.released, 1); + assert!(!t.has("a.CR2"), "given back"); + assert!(t.has("a.CR2.nextcloud"), "a placeholder is left behind"); + assert_eq!(client.dehydrations.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn a_file_the_user_already_had_is_never_taken_away() { + // The rule the whole design rests on. Silently undoing a pin — or just a + // file someone opened yesterday — after an indexing run is the failure + // that would make people stop trusting this. + let t = Tmp::new("keep"); + t.real("pinned.CR2"); + let client = FakeClient::new(); + let b = t.backend(client.clone()); + let pool = BorrowPool::new(); + + { + let held = pool + .borrow(&b, &RemotePath::new("pinned.CR2")) + .await + .unwrap(); + assert!(!held.hydrated(), "nothing was downloaded"); + } + let stats = pool.release_all(&b).await; + + assert_eq!(stats.released, 0); + assert!(t.has("pinned.CR2"), "still here"); + assert_eq!(client.hydrations.load(Ordering::SeqCst), 0); + assert_eq!(client.dehydrations.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn two_lanes_wanting_one_file_download_it_once() { + // The thumbnail pass and the face pass meet on the same RAW. Without + // counting, the first to finish dehydrates the file the second is reading. + let t = Tmp::new("shared"); + t.stub("a.CR2"); + let client = FakeClient::new(); + let b = t.backend(client.clone()); + let pool = BorrowPool::new(); + let path = RemotePath::new("a.CR2"); + + let first = pool.borrow(&b, &path).await.unwrap(); + let second = pool.borrow(&b, &path).await.unwrap(); + + assert_eq!(client.hydrations.load(Ordering::SeqCst), 1, "paid once"); + drop(first); + assert!(t.has("a.CR2"), "still held by the second borrower"); + drop(second); + + pool.release_all(&b).await; + assert!(!t.has("a.CR2")); +} + +#[tokio::test] +async fn a_failed_download_does_not_leave_a_phantom_borrow() { + // The client is up but the server is not. The pass must see the failure + // and the pool must not believe it holds anything. + let t = Tmp::new("failed"); + t.stub("a.CR2"); + let b = t.backend(FakeClient::broken()); + let pool = BorrowPool::new(); + + let e = pool + .borrow(&b, &RemotePath::new("a.CR2")) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::Network(_)), "{e:?}"); + assert_eq!(pool.held(), 0); + assert!(t.has("a.CR2.nextcloud"), "left as it was found"); +} + +#[tokio::test] +async fn borrowing_against_a_plain_folder_does_nothing_at_all() { + // A caller written for a VFS library must run unchanged elsewhere, or + // every sweep grows two code paths. + let t = Tmp::new("plain"); + t.real("a.CR2"); + let b = FolderBackend::new(&t.0).unwrap(); + let pool = BorrowPool::new(); + + let held = pool.borrow(&b, &RemotePath::new("a.CR2")).await.unwrap(); + assert!(!held.hydrated()); + drop(held); + assert_eq!(pool.release_all(&b).await.released, 0); + assert!(t.has("a.CR2")); +} + +#[tokio::test] +async fn an_uncertain_caller_keeps_the_file_rather_than_releasing_it() { + // The asymmetry stated on `borrow_known`: claiming "already local" costs + // disk, claiming "not local" can release a pin. + let t = Tmp::new("uncertain"); + t.real("a.CR2"); + let client = FakeClient::new(); + let b = t.backend(client.clone()); + let pool = BorrowPool::new(); + + { + let _held = pool + .borrow_known(&b, &RemotePath::new("a.CR2"), Some(true)) + .await + .unwrap(); + } + pool.release_all(&b).await; + assert!(t.has("a.CR2"), "kept"); + assert_eq!(client.dehydrations.load(Ordering::SeqCst), 0); +} diff --git a/core/dr-sync-folder/src/lib.rs b/core/dr-sync-folder/src/lib.rs index 9da08bd..a55a571 100644 --- a/core/dr-sync-folder/src/lib.rs +++ b/core/dr-sync-folder/src/lib.rs @@ -50,23 +50,76 @@ use std::io::{Read, Seek, SeekFrom, Write}; use std::ops::Range; use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + use async_trait::async_trait; use dr_sync::{ Account, BackendProvider, Capabilities, ChangeDetection, Connection, Cursor, EntryKind, - Precondition, RemoteBackend, RemoteChange, RemoteEntry, RemoteError, RemoteId, RemotePath, - ServerPreviews, SignIn, Validator, + Materialisation, Precondition, RemoteBackend, RemoteChange, RemoteEntry, RemoteError, RemoteId, + RemotePath, ServerPreviews, SignIn, Validator, }; +pub mod borrow; +pub mod vfs; + +pub use borrow::{BorrowPool, BorrowStats, Borrowed}; +pub use vfs::{NoVfs, Vfs}; + /// The id written to [`Account::backend`] for a folder library. /// /// On-disk configuration: changing it orphans every folder account. pub const BACKEND_ID: &str = "folder"; -/// TRACES: FR-NC-13 +/// TRACES: FR-NC-13 | FR-NC-6c /// Registers the folder connector. /// /// See [`dr_sync::provider`] for what each method is for. -pub struct FolderProvider; +/// +/// # The detector +/// +/// This crate knows how to read a directory and nothing about sync clients, +/// so the placeholder convention arrives from outside: whoever registers the +/// provider supplies a function that recognises a synced folder and returns +/// the [`Vfs`] for it. That keeps `dr-sync-folder` free of any client's +/// protocol, and it is what lets one connector serve a plain disk, a Nextcloud +/// tree, and whatever comes next. +/// +/// Detection runs per connection because the answer changes: the same +/// directory offers hydration while the client is up and not while it is down. +/// Recognises a placeholder convention in a directory, if any applies. +/// +/// Runs per connection rather than once, because the answer changes: the same +/// folder offers hydration while the sync client is up and not while it is +/// down. +pub type VfsDetector = dyn Fn(&Path) -> Option> + Send + Sync; + +#[derive(Default)] +pub struct FolderProvider { + detect_vfs: Option>, +} + +impl FolderProvider { + /// A folder connector that treats every directory as ordinary. + pub fn new() -> Self { + Self::default() + } + + /// A folder connector that recognises placeholder conventions. + pub fn with_vfs_detector( + detect: impl Fn(&Path) -> Option> + Send + Sync + 'static, + ) -> Self { + Self { + detect_vfs: Some(Box::new(detect)), + } + } + + fn vfs_for(&self, root: &Path) -> Arc { + self.detect_vfs + .as_ref() + .and_then(|d| d(root)) + .unwrap_or_else(|| Arc::new(NoVfs)) + } +} impl BackendProvider for FolderProvider { fn id(&self) -> &'static str { @@ -136,18 +189,30 @@ impl BackendProvider for FolderProvider { } fn connect(&self, conn: &Connection) -> Result, RemoteError> { - Ok(Box::new(FolderBackend::new(&conn.account.endpoint)?)) + let root = Path::new(&conn.account.endpoint); + Ok(Box::new(FolderBackend::with_vfs(root, self.vfs_for(root))?)) } } -/// TRACES: FR-NC-13 | FR-NC-4 +/// TRACES: FR-NC-13 | FR-NC-4 | FR-NC-6c /// A library rooted at a directory. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct FolderBackend { root: PathBuf, + /// The placeholder convention in force, [`NoVfs`] for an ordinary folder. + vfs: Arc, caps: Capabilities, } +impl std::fmt::Debug for FolderBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FolderBackend") + .field("root", &self.root) + .field("vfs", &self.vfs.name()) + .finish_non_exhaustive() + } +} + impl FolderBackend { /// Open the folder at `root`. /// @@ -156,6 +221,15 @@ impl FolderBackend { /// [`RemoteError::Network`], which is what puts the app into offline mode /// and leaves the catalog readable, exactly as a dead server does. pub fn new(root: impl Into) -> Result { + Self::with_vfs(root, Arc::new(NoVfs)) + } + + /// Open the folder at `root` under a placeholder convention. + /// + /// The convention is chosen by the caller rather than sniffed here: the + /// connector that knows how to talk to a given sync client is the one that + /// knows whether it is running (see `dr_sync_nextcloud`). + pub fn with_vfs(root: impl Into, vfs: Arc) -> Result { let root = root.into(); if !root.is_dir() { return Err(RemoteError::Configuration(format!( @@ -163,8 +237,19 @@ impl FolderBackend { root.display() ))); } + // Reported per connection, not per backend: the same folder offers + // hydration while the client is up and not while it is down, so this + // cannot be a constant of the type (see `vfs`). + let materialisation = if vfs.can_materialise() { + Materialisation::OnDemand + } else if vfs.name() == NoVfs.name() { + Materialisation::Always + } else { + Materialisation::Placeholders + }; Ok(Self { root, + vfs, caps: Capabilities { // A directory's mtime describes its own entry list and nothing // below it, so there is no propagation to exploit; the engine @@ -179,6 +264,7 @@ impl FolderBackend { bulk_upload: false, conditional_write: true, server_previews: ServerPreviews::None, + materialisation, }, }) } @@ -210,6 +296,30 @@ impl FolderBackend { Ok(self.root.join(rel)) } + /// Where a photograph's bytes are on disk, and whether they are really + /// there. + /// + /// A placeholder lives under a *different* name — suffix-mode VFS renames + /// on hydration rather than filling in place — so every read and write has + /// to look for both. The materialised name is tried first: it is the + /// common case, and the second `stat` is paid only when it misses. + /// + /// Returns the path to use and whether it holds real content. + fn locate(&self, path: &RemotePath) -> Result<(PathBuf, bool), RemoteError> { + let direct = self.resolve(path)?; + if self.vfs.name() == NoVfs.name() || direct.exists() { + return Ok((direct, true)); + } + let stub = self.resolve(&RemotePath::new( + self.vfs.placeholder_name(path.as_str()).into_owned(), + ))?; + if stub.exists() { + return Ok((stub, false)); + } + // Neither: genuinely missing. Report the name the caller asked for. + Ok((direct, true)) + } + /// The local path a [`RemoteId`] names. /// /// A stable id here is a hash and nothing can be resolved from it, exactly @@ -223,6 +333,16 @@ impl FolderBackend { )), } } + + /// [`locate`](Self::locate) for an id. + fn locate_id(&self, id: &RemoteId) -> Result<(PathBuf, bool), RemoteError> { + match id { + RemoteId::Path(p) => self.locate(p), + RemoteId::Stable(_) => Err(RemoteError::Unsupported( + "a folder cannot be addressed by id; use RemoteId::Path", + )), + } + } } /// Run a filesystem operation off the async worker that asked for it. @@ -274,6 +394,16 @@ fn map_io(e: std::io::Error, what: &str) -> RemoteError { } } +/// How long to wait for a requested download to land. +/// +/// Generous, because the file may be tens of megabytes over a domestic +/// connection, and bounded, because a client that has stopped transferring +/// must not wedge a whole pass. +const MATERIALISE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// How often to look for the materialised file while waiting. +const POLL: std::time::Duration = std::time::Duration::from_millis(200); + /// The identity of a file, from its path relative to the library root. /// /// FNV-1a rather than `DefaultHasher`, whose output is explicitly unstable @@ -330,6 +460,7 @@ impl RemoteBackend for FolderBackend { ) -> Result, RemoteError> { let local = self.resolve(dir)?; let dir = dir.clone(); + let vfs = self.vfs.clone(); blocking(move || { let read = std::fs::read_dir(&local).map_err(|e| map_io(e, &local.display().to_string()))?; @@ -368,7 +499,13 @@ impl RemoteBackend for FolderBackend { } }; - let path = dir.join(name); + // The photograph's own name, never the stub's. Identity is + // derived from it, so downloading a file must not look like a + // delete and an add — and `source_ref` must match what every + // other device calls the same photograph. + let stub = vfs.is_placeholder(name); + let path = dir.join(vfs.real_name(name)); + out.push(RemoteEntry { id: RemoteId::Stable(identity(&path)), kind: if meta.is_dir() { @@ -377,11 +514,15 @@ impl RemoteBackend for FolderBackend { EntryKind::File }, validator: validator_of(&meta), - size: meta.len(), + // A stub is one byte and says nothing about what it stands + // for. Reporting that byte count would put a 1-byte + // `file_size` in the catalog for most of the library. + size: if stub { 0 } else { meta.len() }, modified: modified_secs(&meta), // No renderer behind a folder; previews are extracted // locally from the file itself. has_preview: false, + materialised: !stub, path, }); } @@ -408,7 +549,14 @@ impl RemoteBackend for FolderBackend { } async fn get(&self, id: &RemoteId, range: Option>) -> Result, RemoteError> { - let local = self.resolve_id(id)?; + let (local, materialised) = self.locate_id(id)?; + if !materialised { + // The one byte in the stub is not the file. Returning it produced + // a sidecar that parsed as empty and a thumbnail that never + // decoded; reporting `NotFound` made the sidecar writer treat an + // existing document as absent and overwrite it. + return Err(RemoteError::NotMaterialised(local.display().to_string())); + } blocking(move || { let what = local.display().to_string(); let mut file = std::fs::File::open(&local).map_err(|e| map_io(e, &what))?; @@ -440,7 +588,15 @@ impl RemoteBackend for FolderBackend { body: Vec, precond: Option, ) -> Result { - let local = self.resolve(path)?; + let (local, materialised) = self.locate(path)?; + if !materialised { + // Writing `a.drsc` while `a.drsc.nextcloud` sits beside it creates + // two files for one document and hands the sync client a conflict + // to resolve — in favour of whichever it sees last. The caller + // must materialise it and merge, which is what the typed error is + // for. + return Err(RemoteError::NotMaterialised(local.display().to_string())); + } blocking(move || { let what = local.display().to_string(); if let Some(parent) = local.parent() { @@ -522,7 +678,10 @@ impl RemoteBackend for FolderBackend { id: &RemoteId, precond: Option, ) -> Result<(), RemoteError> { - let local = self.resolve_id(id)?; + // Deliberately by whichever name is on disk: deleting a photograph + // means deleting it whether or not its content happens to be here, and + // a stub left behind would be re-listed by the next scan. + let (local, _) = self.locate_id(id)?; blocking(move || { let what = local.display().to_string(); let meta = std::fs::symlink_metadata(&local).map_err(|e| map_io(e, &what))?; @@ -562,8 +721,19 @@ impl RemoteBackend for FolderBackend { } async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError> { - let src = self.resolve_id(from)?; - let dst = self.resolve(to)?; + // Move whichever name exists. Trashing a photograph that is not + // downloaded is a perfectly ordinary thing to do, and it must move the + // stub — renaming a placeholder keeps it a placeholder. + let (src, materialised) = self.locate_id(from)?; + let dst = if materialised { + self.resolve(to)? + } else { + // The destination keeps the placeholder suffix, or the client + // would see a one-byte file appear where a photograph should be. + self.resolve(&RemotePath::new( + self.vfs.placeholder_name(to.as_str()).into_owned(), + ))? + }; blocking(move || { let what = dst.display().to_string(); // Parents first: the trash folder does not exist until the first @@ -597,6 +767,60 @@ impl RemoteBackend for FolderBackend { .await } + /// TRACES: FR-NC-6c + /// Ask the sync client to download a placeholder, and wait for it. + /// + /// Suffix-mode VFS *renames* on hydration, so completion is the + /// materialised path appearing — not the stub changing size. Polling the + /// original would wait forever. + async fn materialise(&self, id: &RemoteId) -> Result<(), RemoteError> { + let (local, materialised) = self.locate_id(id)?; + if materialised { + // Already here. Not an error, and not a reason to ask again: the + // borrow pool relies on this being idempotent. + return Ok(()); + } + let vfs = self.vfs.clone(); + let target = self.resolve_id(id)?; + blocking(move || { + vfs.materialise(&local)?; + + // The client acknowledges the command, not the transfer, so this + // waits for the file to appear. A bounded wait: a hydration that + // has not landed in this long is one the caller should be told + // about rather than blocked on for ever — the pass can come back + // to it. + let deadline = std::time::Instant::now() + MATERIALISE_TIMEOUT; + while std::time::Instant::now() < deadline { + if target.is_file() { + return Ok(()); + } + std::thread::sleep(POLL); + } + Err(RemoteError::Network(format!( + "{} did not download within {}s", + target.display(), + MATERIALISE_TIMEOUT.as_secs() + ))) + }) + .await + } + + /// TRACES: FR-NC-6c + /// Hand the content back, leaving a placeholder. + /// + /// **Never a delete.** In a synced tree removing the file propagates the + /// removal to the server; the client is asked to dehydrate, and if it + /// cannot the content simply stays. + async fn dematerialise(&self, id: &RemoteId) -> Result<(), RemoteError> { + let (local, materialised) = self.locate_id(id)?; + if !materialised { + return Ok(()); + } + let vfs = self.vfs.clone(); + blocking(move || vfs.dematerialise(&local)).await + } + async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError> { let local = self.resolve(path)?; blocking(move || { diff --git a/core/dr-sync-folder/src/tests.rs b/core/dr-sync-folder/src/tests.rs index 66563ba..e45aa54 100644 --- a/core/dr-sync-folder/src/tests.rs +++ b/core/dr-sync-folder/src/tests.rs @@ -482,7 +482,7 @@ async fn an_upload_lands_where_the_engine_places_it() { #[test] fn an_endpoint_is_checked_before_an_account_is_written_for_it() { let t = Tmp::new("provider"); - let p = FolderProvider; + let p = FolderProvider::new(); assert!(p.normalise_endpoint(" ").is_err(), "empty"); assert!(p.normalise_endpoint("Pictures").is_err(), "relative"); @@ -504,7 +504,7 @@ 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 p = FolderProvider::new(); let direct = p .normalise_endpoint(&t.0.join("sub").to_string_lossy()) .unwrap(); @@ -516,7 +516,7 @@ fn two_spellings_of_one_folder_become_one_account() { #[test] fn a_folder_account_needs_no_credential() { - let p = FolderProvider; + let p = FolderProvider::new(); assert_eq!(p.sign_in(), SignIn::EndpointOnly); assert!(!p.sign_in().needs_secret()); @@ -532,9 +532,169 @@ fn the_registry_opens_a_folder_account() { // 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)); + registry.register(std::sync::Arc::new(FolderProvider::new())); let account = Account::new(BACKEND_ID, t.0.to_string_lossy()); let backend = registry.connect(&Connection::new(account, None)).unwrap(); assert_eq!(backend.name(), "Folder"); } + +// --- virtual filesystems -------------------------------------------------- +// +// A suffix-mode convention, matching the only one Linux supports. The +// behaviour under test is what the *backend* does with it; the borrow cycle +// has its own tests beside the pool. + +struct SuffixVfs; + +impl Vfs for SuffixVfs { + fn name(&self) -> &'static str { + "suffix" + } + fn is_placeholder(&self, on_disk: &str) -> bool { + on_disk.ends_with(".stub") + } + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { + on_disk.strip_suffix(".stub").unwrap_or(on_disk) + } + fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Owned(format!("{name}.stub")) + } +} + +fn with_stubs(t: &Tmp) -> FolderBackend { + FolderBackend::with_vfs(&t.0, std::sync::Arc::new(SuffixVfs)).unwrap() +} + +#[tokio::test] +async fn a_placeholder_is_listed_under_the_photographs_own_name() { + // The catalog records this as `source_ref`, and identity is derived from + // it. Reporting the stub's name gives the same photograph two identities + // and a name no other device recognises. + let t = Tmp::new("vfs-name"); + t.file("shoot/IMG_0001.CR2.stub", &[0u8]); + let b = with_stubs(&t); + + let entries = b.list(&RemotePath::new("shoot"), None).await.unwrap(); + assert_eq!(entries[0].path.as_str(), "shoot/IMG_0001.CR2"); + assert!(!entries[0].materialised, "the content is not here"); + // One byte is not the photograph's size, and putting it in the catalog + // would claim a 30 MB RAW is a single byte. + assert_eq!(entries[0].size, 0, "unknown, not one"); +} + +#[tokio::test] +async fn identity_survives_a_download() { + // The failure this prevents: downloading a photograph looked like a + // delete and an add, which orphaned its thumbnail and its face rows. + let t = Tmp::new("vfs-identity"); + t.file("a.CR2.stub", &[0u8]); + let b = with_stubs(&t); + + let before = b.list(&RemotePath::root(), None).await.unwrap()[0] + .id + .clone(); + std::fs::remove_file(t.0.join("a.CR2.stub")).unwrap(); + std::fs::write(t.0.join("a.CR2"), vec![3u8; 4096]).unwrap(); + let after = b.list(&RemotePath::root(), None).await.unwrap()[0] + .id + .clone(); + + assert_eq!(before, after, "the same photograph throughout"); +} + +#[tokio::test] +async fn reading_a_placeholder_is_distinguishable_from_a_missing_file() { + // The distinction the sidecar writer depends on: "not here" is fetchable + // and "not found" means create a new one. Conflating them overwrites an + // existing sidecar with a fresh document. + let t = Tmp::new("vfs-read"); + t.file("a.drsc.stub", &[0u8]); + let b = with_stubs(&t); + + let stub = b + .get(&RemoteId::Path(RemotePath::new("a.drsc")), None) + .await + .unwrap_err(); + assert!(matches!(stub, RemoteError::NotMaterialised(_)), "{stub:?}"); + + let absent = b + .get(&RemoteId::Path(RemotePath::new("nothing.drsc")), None) + .await + .unwrap_err(); + assert!(matches!(absent, RemoteError::NotFound(_)), "{absent:?}"); + + // And emphatically not the stub's one byte, which is what made a + // dehydrated sidecar parse as an empty document. + assert!(!matches!(stub, RemoteError::NotFound(_))); +} + +#[tokio::test] +async fn writing_over_a_placeholder_is_refused() { + // Writing `a.drsc` beside `a.drsc.stub` makes two files for one document + // and hands the sync client a conflict it resolves arbitrarily. + let t = Tmp::new("vfs-write"); + t.file("a.drsc.stub", &[0u8]); + let b = with_stubs(&t); + + let e = b + .put(&RemotePath::new("a.drsc"), b"".to_vec(), None) + .await + .unwrap_err(); + assert!(matches!(e, RemoteError::NotMaterialised(_)), "{e:?}"); + assert!(!t.0.join("a.drsc").exists(), "no rival file created"); +} + +#[tokio::test] +async fn trashing_a_photograph_that_is_not_downloaded_moves_the_placeholder() { + // Culling without downloading is the ordinary way to use a VFS library. + // The stub has to move, and has to stay a stub — leaving it behind means + // the next scan re-lists the image and undoes the delete. + let t = Tmp::new("vfs-trash"); + t.file("a.CR2.stub", &[0u8]); + let b = with_stubs(&t); + + b.move_to( + &RemoteId::Path(RemotePath::new("a.CR2")), + &RemotePath::new(".darkroom-trash/a.CR2"), + ) + .await + .unwrap(); + + assert!(!t.0.join("a.CR2.stub").exists()); + assert!( + t.0.join(".darkroom-trash/a.CR2.stub").is_file(), + "still a stub" + ); +} + +#[tokio::test] +async fn a_folder_without_a_client_still_lists_and_reads_what_is_there() { + // No hydration available is a degraded mode, not a broken one: the + // materialised half of the library works completely. + let t = Tmp::new("vfs-degraded"); + t.file("here.CR2", b"real").file("gone.CR2.stub", &[0u8]); + let b = with_stubs(&t); + + assert_eq!( + b.capabilities().materialisation, + dr_sync::Materialisation::Placeholders, + "stubs exist and nothing can fetch them" + ); + assert!(!b.capabilities().materialisation.can_materialise()); + + let got = b + .get(&RemoteId::Path(RemotePath::new("here.CR2")), None) + .await + .unwrap(); + assert_eq!(got, b"real"); +} + +#[test] +fn a_plain_folder_reports_that_everything_it_lists_is_readable() { + let t = Tmp::new("vfs-plain"); + assert_eq!( + t.backend().capabilities().materialisation, + dr_sync::Materialisation::Always + ); +} diff --git a/core/dr-sync-folder/src/vfs.rs b/core/dr-sync-folder/src/vfs.rs new file mode 100644 index 0000000..3e05e4d --- /dev/null +++ b/core/dr-sync-folder/src/vfs.rs @@ -0,0 +1,131 @@ +// TRACES: FR-NC-6c +//! Virtual-filesystem conventions layered over a directory. +//! +//! A sync client in virtual-files mode leaves a *placeholder* where a file is +//! catalogued but not downloaded. The folder is otherwise ordinary, so all of +//! [`FolderBackend`](crate::FolderBackend) applies — only three questions +//! differ, and they are the whole of this trait: what is a placeholder, what +//! is the photograph really called, and can the content be summoned. +//! +//! # Why this is not a separate backend +//! +//! It varies nothing about listing, reading, writing, moving or deleting — a +//! second connector would duplicate every one of those to change a name test. +//! More decisively, **the interesting capability is not a property of the +//! backend at all**: the same folder can materialise on demand while the sync +//! client is running and cannot when it is not, so it has to be computed per +//! connection either way. Registering a `folder-vfs` provider beside `folder` +//! would ask the user to choose between two things that differ by whether a +//! background process happens to be up. +//! +//! # Why the plain case is a `Vfs` too +//! +//! [`NoVfs`] answers "nothing is a placeholder" and refuses to materialise. +//! That keeps one code path through the backend rather than an `Option` tested +//! at every call site, and it is the shape a third convention — Dropbox, +//! OneDrive, macOS FileProvider — slots into. +//! +//! # What is *not* abstracted here +//! +//! Windows and macOS express placeholders in filesystem metadata rather than +//! in the name: a reparse point, or `st_blocks == 0` against a non-zero +//! `st_size`. That form needs a `Metadata` to answer, not a name, and the one +//! convention this project has met needs only a name. Widening the trait for a +//! platform nobody has run this on would be guessing at the shape. + +use std::borrow::Cow; +use std::path::Path; + +use dr_sync::RemoteError; + +/// A placeholder convention, and what can be done about it. +/// +/// Implementations are held behind an `Arc` and used from every worker +/// thread. +pub trait Vfs: Send + Sync { + /// A name for logs and the interface. "none", "Nextcloud". + fn name(&self) -> &'static str; + + /// Whether a name **on disk** stands for content that is not here. + fn is_placeholder(&self, on_disk: &str) -> bool; + + /// The photograph's own name, given whatever is on disk. + /// + /// This is what the catalog records and what identity is derived from, so + /// a file keeps one name and one id across being downloaded and released. + /// Reporting the on-disk name instead makes hydration look like a delete + /// and an add. + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str; + + /// What a placeholder for `name` would be called on disk. + fn placeholder_name(&self, name: &str) -> Cow<'_, str>; + + /// Whether content can actually be summoned right now. + /// + /// False where the mechanism is absent — the client is not running, the + /// platform has no socket — which is an ordinary state and not an error. + /// The backend reports [`Materialisation::Placeholders`] rather than + /// [`OnDemand`] when this is false. + /// + /// [`Materialisation::Placeholders`]: dr_sync::Materialisation::Placeholders + /// [`OnDemand`]: dr_sync::Materialisation::OnDemand + fn can_materialise(&self) -> bool { + false + } + + /// Ask for a placeholder's content. Whole-file and slow. + fn materialise(&self, _local: &Path) -> Result<(), RemoteError> { + Err(RemoteError::Unsupported("this folder has no VFS client")) + } + + /// Give the content back, leaving a placeholder. + /// + /// **Must not delete.** In a synced tree a deletion propagates to the + /// server and removes the photograph from every device. An implementation + /// that cannot dehydrate returns `Unsupported`. + fn dematerialise(&self, _local: &Path) -> Result<(), RemoteError> { + Err(RemoteError::Unsupported("this folder has no VFS client")) + } +} + +/// An ordinary directory: every file is what it appears to be. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoVfs; + +impl Vfs for NoVfs { + fn name(&self) -> &'static str { + "none" + } + fn is_placeholder(&self, _on_disk: &str) -> bool { + false + } + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { + on_disk + } + fn placeholder_name(&self, name: &str) -> Cow<'_, str> { + // Nothing is ever a placeholder here, so the only honest answer is + // the name itself — the backend will look for it, not find a second + // candidate, and report the file missing. + Cow::Owned(name.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plain_folder_has_no_placeholders_and_cannot_summon_anything() { + let v = NoVfs; + assert!( + !v.is_placeholder("IMG.CR2.nextcloud"), + "not this folder's convention" + ); + assert_eq!(v.real_name("IMG.CR2"), "IMG.CR2"); + assert!(!v.can_materialise()); + assert!(v.materialise(Path::new("/x")).is_err()); + // And it must refuse rather than approximate: deleting a file to + // "dehydrate" it would remove the photograph. + assert!(v.dematerialise(Path::new("/x")).is_err()); + } +} diff --git a/core/dr-sync-nextcloud/Cargo.toml b/core/dr-sync-nextcloud/Cargo.toml index d5c092e..fc8ecfc 100644 --- a/core/dr-sync-nextcloud/Cargo.toml +++ b/core/dr-sync-nextcloud/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true [dependencies] dr-types.workspace = true dr-sync.workspace = true +# For the VFS convention: the folder connector does the filesystem work, and +# this crate supplies the placeholder rules and the client socket. +dr-sync-folder.workspace = true dr-plat.workspace = true reqwest.workspace = true rustls.workspace = true diff --git a/core/dr-sync-nextcloud/src/desktop_client.rs b/core/dr-sync-nextcloud/src/desktop_client.rs index 1617892..82b37e5 100644 --- a/core/dr-sync-nextcloud/src/desktop_client.rs +++ b/core/dr-sync-nextcloud/src/desktop_client.rs @@ -163,3 +163,138 @@ mod tests { let _ = DesktopClient::detect(); } } + +/// TRACES: FR-NC-6c +/// The desktop client's placeholder convention, as a +/// [`Vfs`](dr_sync_folder::Vfs). +/// +/// This is what turns a folder the client syncs into a library DarkRoom can +/// open: the folder connector handles every filesystem operation, and this +/// answers the three questions it cannot — what is a stub, what is the +/// photograph called, and can the content be fetched and given back. +/// +/// **Linux suffix mode only**, which is the only mode Linux supports +/// (ARCH §9.0). A dehydrated `IMG.CR2` exists solely as `IMG.CR2.nextcloud` +/// holding one byte. +pub struct NextcloudVfs { + /// `None` where no client is running. The folder still lists and reads + /// correctly; it simply cannot fetch what is not there, which the backend + /// reports as `Materialisation::Placeholders`. + client: Option, +} + +impl NextcloudVfs { + /// Attach to a running client, if there is one. + /// + /// Absence is the ordinary state — Android always, desktop whenever the + /// client is not running — and never an error. + pub fn detect() -> Self { + Self { + client: DesktopClient::detect(), + } + } + + /// Whether a directory looks like one this client syncs. + /// + /// Used to decide whether to apply this convention at all. Deliberately + /// cheap and deliberately not authoritative: the client's own database + /// would answer properly, but it is a private schema, and being wrong here + /// costs one extra `stat` per read rather than anything correctness + /// depends on. + pub fn looks_synced(root: &Path) -> bool { + std::fs::read_dir(root) + .map(|entries| { + entries.flatten().any(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + // The client's per-folder journal sits at the sync root, + // and a stub anywhere beneath it is equally conclusive. + name.starts_with("._sync_") && name.ends_with(".db") + || name.ends_with(dr_types::PLACEHOLDER_SUFFIX) + }) + }) + .unwrap_or(false) + } +} + +impl dr_sync_folder::Vfs for NextcloudVfs { + fn name(&self) -> &'static str { + "Nextcloud" + } + + fn is_placeholder(&self, on_disk: &str) -> bool { + on_disk.ends_with(dr_types::PLACEHOLDER_SUFFIX) + } + + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str { + on_disk + .strip_suffix(dr_types::PLACEHOLDER_SUFFIX) + .unwrap_or(on_disk) + } + + fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Owned(format!("{name}{}", dr_types::PLACEHOLDER_SUFFIX)) + } + + fn can_materialise(&self) -> bool { + self.client.is_some() + } + + fn materialise(&self, local: &Path) -> Result<(), RemoteError> { + self.client + .as_ref() + .ok_or(RemoteError::Unsupported( + "no Nextcloud desktop client is running to fetch this", + ))? + .make_available_locally(local) + } + + fn dematerialise(&self, local: &Path) -> Result<(), RemoteError> { + self.client + .as_ref() + .ok_or(RemoteError::Unsupported( + "no Nextcloud desktop client is running to release this", + ))? + .make_online_only(local) + } +} + +#[cfg(test)] +mod vfs_tests { + use super::*; + use dr_sync_folder::Vfs as _; + + #[test] + fn a_stub_is_recognised_and_reports_the_photographs_name() { + let v = NextcloudVfs { client: None }; + assert!(v.is_placeholder("IMG_4130.CR2.nextcloud")); + assert!(!v.is_placeholder("IMG_4130.CR2")); + // The name the catalog records, so identity survives a download. + assert_eq!(v.real_name("IMG_4130.CR2.nextcloud"), "IMG_4130.CR2"); + assert_eq!(v.real_name("IMG_4130.CR2"), "IMG_4130.CR2"); + assert_eq!(v.placeholder_name("IMG_4130.CR2"), "IMG_4130.CR2.nextcloud"); + } + + #[test] + fn without_a_client_it_refuses_rather_than_pretending() { + // The folder still works; it just cannot fetch. Silently doing nothing + // would make a borrow think it had the content. + let v = NextcloudVfs { client: None }; + assert!(!v.can_materialise()); + assert!(v.materialise(Path::new("/x/a.CR2.nextcloud")).is_err()); + assert!(v.dematerialise(Path::new("/x/a.CR2")).is_err()); + } + + #[test] + fn an_ordinary_folder_is_not_mistaken_for_a_synced_one() { + let d = std::env::temp_dir().join("dr-vfs-detect"); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("a.CR2"), b"raw").unwrap(); + assert!(!NextcloudVfs::looks_synced(&d)); + + std::fs::write(d.join("b.CR2.nextcloud"), [0u8]).unwrap(); + assert!(NextcloudVfs::looks_synced(&d)); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index ba3a48b..bfc30f7 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -24,7 +24,7 @@ mod propfind; pub mod provider; pub use auth::{AppCredentials, LoginFlow}; -pub use desktop_client::DesktopClient; +pub use desktop_client::{DesktopClient, NextcloudVfs}; pub use provider::NextcloudProvider; /// Chunk sizes Nextcloud's chunked upload v2 accepts. @@ -73,6 +73,10 @@ impl NextcloudBackend { // Stock Nextcloud ships no RAW preview provider (ARCH §6.7). // Probed per-account at setup and upgraded where present. server_previews: ServerPreviews::CommonFormatsOnly, + // The server answers for everything it lists. Placeholders + // belong to a locally *synced folder*, which is the folder + // connector's business (`desktop_client::NextcloudVfs`). + materialisation: dr_sync::Materialisation::Always, }, }) } diff --git a/core/dr-sync-nextcloud/src/propfind.rs b/core/dr-sync-nextcloud/src/propfind.rs index c4de552..488c49c 100644 --- a/core/dr-sync-nextcloud/src/propfind.rs +++ b/core/dr-sync-nextcloud/src/propfind.rs @@ -92,6 +92,9 @@ pub fn parse_multistatus(xml: &str, base: &str) -> Result, Remo size: r.content_length.unwrap_or(0), modified: r.last_modified.as_deref().and_then(parse_http_date), has_preview: r.has_preview, + // Everything WebDAV lists can be fetched; placeholders are a + // property of a locally synced folder, not of the server. + materialised: true, }); } Ok(out) diff --git a/core/dr-sync/src/capability.rs b/core/dr-sync/src/capability.rs index 098d063..31d9a43 100644 --- a/core/dr-sync/src/capability.rs +++ b/core/dr-sync/src/capability.rs @@ -38,6 +38,50 @@ pub struct ChunkConstraints { pub max_chunks: u32, } +/// TRACES: FR-NC-6c +/// Whether every listed object's content is actually reachable. +/// +/// Every backend but a virtual-filesystem folder answers [`Always`](Self::Always). +/// A VFS folder is the case this exists for: the sync client leaves a +/// placeholder where a file is catalogued but not downloaded, so the name is +/// listable and the bytes are not (ARCH §9.0). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Materialisation { + /// Listing an object means its content can be read. Every server backend, + /// and a plain directory. + Always, + + /// Some objects are placeholders, and nothing this process can do will + /// change that — the sync client is not running, or the platform offers no + /// way to ask. Such an object reads as + /// [`RemoteError::NotMaterialised`](crate::RemoteError::NotMaterialised) + /// and is shown as offline rather than broken. + Placeholders, + + /// Some objects are placeholders, and this backend can ask for their + /// content — and give it back. + /// + /// **Whole-file, and that is the whole difficulty.** Hydration has two + /// states, one byte or all bytes, so using it to fill a grid transfers the + /// entire library to produce thumbnails (ARCH §9.0). It belongs to the + /// originals tier — an image opened in develop, exported, or deliberately + /// pinned — and to passes the user has asked for and been quoted a price + /// on. Never to browsing. + OnDemand, +} + +impl Materialisation { + /// Whether content can be fetched on request. + pub fn can_materialise(self) -> bool { + matches!(self, Materialisation::OnDemand) + } + + /// Whether some objects may have no content locally. + pub fn has_placeholders(self) -> bool { + !matches!(self, Materialisation::Always) + } +} + /// TRACES: FR-NC-3 /// Whether the server can render thumbnails, and for what. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -69,6 +113,8 @@ pub struct Capabilities { /// Conditional write (If-Match), for conflict-safe sidecar updates. pub conditional_write: bool, pub server_previews: ServerPreviews, + /// Whether a listed object's content is necessarily present. + pub materialisation: Materialisation, } impl Capabilities { @@ -83,6 +129,9 @@ impl Capabilities { bulk_upload: false, conditional_write: false, server_previews: ServerPreviews::None, + // The weakest backend still answers for everything it lists; + // placeholders are a property a backend opts into. + materialisation: Materialisation::Always, } } diff --git a/core/dr-sync/src/error.rs b/core/dr-sync/src/error.rs index 9703a5b..0f966bf 100644 --- a/core/dr-sync/src/error.rs +++ b/core/dr-sync/src/error.rs @@ -36,6 +36,22 @@ pub enum RemoteError { #[error("not found: {0}")] NotFound(String), + /// TRACES: FR-NC-6c + /// The object exists, but its content is not on this device. + /// + /// A virtual-filesystem placeholder: the sync client holds the name and a + /// stub, and the bytes are still on the server (ARCH §9.0). + /// + /// **Emphatically not [`NotFound`](Self::NotFound), and the distinction is + /// what stops a silent data loss.** The sidecar writer reads before it + /// writes, and treats a miss as "there is no sidecar yet, create one" — so + /// a dehydrated sidecar reported as absent makes it write a fresh document + /// over an existing one, discarding every edit another device had put + /// there. It is also the difference between an error a user can act on + /// (fetch it) and one they cannot (it is gone). + #[error("not on this device: {0}")] + NotMaterialised(String), + /// The backend does not support this operation. Expected, not a bug — /// callers check capabilities and adapt. #[error("operation unsupported by this backend: {0}")] diff --git a/core/dr-sync/src/lib.rs b/core/dr-sync/src/lib.rs index d8920a8..60dc98a 100644 --- a/core/dr-sync/src/lib.rs +++ b/core/dr-sync/src/lib.rs @@ -35,7 +35,9 @@ 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 capability::{ + Capabilities, ChangeDetection, ChunkConstraints, Materialisation, ServerPreviews, +}; pub use error::RemoteError; pub use provider::{BackendProvider, BackendRegistry, SignIn}; pub use reachability::{Connectivity, Reachability}; @@ -151,6 +153,49 @@ pub trait RemoteBackend: Send + Sync { /// destination, not to claim they created it. async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError>; + // ---- materialisation -------------------------------------------------- + + /// TRACES: FR-NC-6c + /// Ask for a placeholder's content to be brought to this device. + /// + /// Only meaningful where [`Capabilities::materialisation`] is + /// [`Materialisation::OnDemand`]; others return + /// [`RemoteError::Unsupported`]. + /// + /// **Whole-file, and slow.** There is no partial hydration: a placeholder + /// becomes one byte or all of them, so this transfers a 27 MB RAW to + /// answer a question a 256 KB range read would have answered (ARCH §9.0 + /// finding 3). It is for the originals tier — develop, export, a pin the + /// user asked for — and for passes the user has been quoted a price on and + /// agreed to. **Never for filling a grid**: doing so downloads the entire + /// library to produce thumbnails. + /// + /// Returns once the content is readable. Callers that borrowed it should + /// hand it back with [`dematerialise`](Self::dematerialise). + async fn materialise(&self, _id: &RemoteId) -> Result<(), RemoteError> { + Err(RemoteError::Unsupported( + "this backend has no placeholders to materialise", + )) + } + + /// Give a placeholder's content back, freeing the disk it held. + /// + /// The counterpart that makes hydration a *borrow* rather than an + /// acquisition: a pass that hydrates a library to index it can return each + /// file as it finishes, so peak disk is the working set rather than the + /// library. + /// + /// **Never destructive.** On a synced folder this asks the client to + /// dehydrate; it must not delete, because a deletion in a synced tree + /// propagates to the server and removes the photograph everywhere. An + /// implementation that cannot dehydrate must return + /// [`RemoteError::Unsupported`] rather than approximating it. + async fn dematerialise(&self, _id: &RemoteId) -> Result<(), RemoteError> { + Err(RemoteError::Unsupported( + "this backend has no placeholders to release", + )) + } + // ---- optional --------------------------------------------------------- /// Server-rendered thumbnail, where available. diff --git a/core/dr-sync/src/scan.rs b/core/dr-sync/src/scan.rs index d54bb96..6f723cd 100644 --- a/core/dr-sync/src/scan.rs +++ b/core/dr-sync/src/scan.rs @@ -253,6 +253,7 @@ mod tests { size: 0, modified: None, has_preview: false, + materialised: true, } } @@ -265,6 +266,7 @@ mod tests { size: 1000, modified: None, has_preview: false, + materialised: true, } } @@ -301,6 +303,7 @@ mod tests { bulk_upload: false, conditional_write: true, server_previews: ServerPreviews::None, + materialisation: crate::Materialisation::Always, }, lists: RefCell::new(0), probes: RefCell::new(0), diff --git a/core/dr-sync/src/types.rs b/core/dr-sync/src/types.rs index 6b06d6f..14d7f25 100644 --- a/core/dr-sync/src/types.rs +++ b/core/dr-sync/src/types.rs @@ -97,6 +97,23 @@ pub struct RemoteEntry { /// Whether the server claims a renderable preview exists. Advisory: stock /// Nextcloud reports none for RAW (ARCH §6.7). pub has_preview: bool, + + /// TRACES: FR-NC-6c + /// Whether [`get`](crate::RemoteBackend::get) can produce this object's + /// content right now. + /// + /// True for everything a server backend lists — the bytes are remote, but + /// they are reachable. False only for a virtual-filesystem placeholder, + /// where the name is on this device and the content is not (ARCH §9.0). + /// + /// The catalog maps this to [`Availability::Offline`](dr_types::Availability), + /// which is the difference between a photograph shown as *not downloaded* + /// and one shown as broken. + /// + /// **`size` is not meaningful when this is false.** A Linux suffix-mode + /// stub is one byte and carries no record of what it stands for, so there + /// is nothing to report but zero. + pub materialised: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/core/dr-sync/src/upload.rs b/core/dr-sync/src/upload.rs index 2d035c6..09cafa1 100644 --- a/core/dr-sync/src/upload.rs +++ b/core/dr-sync/src/upload.rs @@ -237,6 +237,7 @@ mod tests { size: *size, modified: None, has_preview: false, + materialised: true, }) .collect()) } diff --git a/docs/architecture.md b/docs/architecture.md index 0a861ee..c189f1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -876,6 +876,39 @@ remains the only mechanism that satisfies FR-NC-3. - Never depend on it: the socket is Linux-only, absent on Android, and absent when the client is not running. The direct connector remains the primary path. +#### 9.0a Amendment, 2026-08-29 — VFS as a managed tier for *folder* libraries + +The rejection above was written when the only backend was the direct Nextcloud connector, and it +assumed the alternative to hydration was a range read. Since `dr-sync-folder` exists (§8.4a) a +library can be opened as a directory with **no server connection at all**, and that changes what +finding 3 is comparing against. + +**What finding 3 actually says.** Hydration transfers ~100× what a preview needs *when a range read +is available*. On a folder library there is no connector, so there are no range reads: the choice is +not hydrate-versus-range-read, it is hydrate-once or never have a thumbnail. The finding stands +unamended for the direct connector, which must still never hydrate to browse. + +**What makes the cost acceptable on a folder library**, and all three are required: + +1. **Hydration is a borrow, not an acquisition.** A file is returned to the state it was found in — + what the pass downloaded is released, what the user already had is left alone. Peak disk is the + working set, not the library. +2. **It is paid once.** Thumbnails are kept, and `derived_sync` pushes the shards to the server, so + a second device downloads 200 MB of shards instead of hydrating 340 GB of RAWs. +3. **It is quoted and consented to.** Never automatic, never on the browsing path, always + resumable and cancellable (FR-NC-6c). + +**What this does not change.** Findings 1 and 2 stand and are now implemented rather than merely +noted: a stub is not transparent, so `RemoteEntry` carries `materialised` and the backend reports +`RemoteError::NotMaterialised` rather than a miss; and the socket remains the only way to hydrate, +so it stays Linux-only, optional, and absent on Android. + +**The one thing that must never be got wrong.** Releasing content means asking the client to +dehydrate — never deleting the file. A deletion inside a synced tree propagates to the server and +removes the photograph from every device the user owns. `RemoteBackend::dematerialise` says so, the +`Vfs` trait says so, and an implementation that cannot dehydrate returns `Unsupported` rather than +approximating it with `remove_file`. + ### 9.1 The three tiers, restated as policy | Tier | Content | Default | diff --git a/docs/requirements.md b/docs/requirements.md index dd42189..25c3957 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -647,6 +647,25 @@ proxies, then thumbnails. **Metadata and sidecars are never evicted** — they a extension, and size, so users do not know what they actually have. Sync failures of legibility are more damaging than failures of transport. +**FR-NC-6d — Placeholder libraries.** Where a library is a folder kept by a sync client in +virtual-files mode, the app shall treat a placeholder as *the photograph, not downloaded* — never as +a one-byte file and never as a missing one. + +- A placeholder is catalogued under the photograph's own name, with an identity that does not change + when it is downloaded +- Reading one yields a distinct, actionable error; it shall **not** be reported as absent, because + the sidecar writer creates a new document when a sidecar is absent and would discard the existing + one (FR-CAT-8) +- Its size is reported as unknown rather than as the stub's byte count + +Where the client offers hydration, content may be fetched **as a borrow**: a file is returned to the +state it was found in, so a pass releases what it downloaded and leaves alone what the user already +had. Releasing means asking the client to dehydrate — **never deleting**, which inside a synced tree +would propagate to the server and remove the photograph everywhere. + +Hydration is whole-file and shall never serve browsing (ARCH §9.0 finding 3, §9.0a). It is for the +originals tier and for passes the user has been quoted a cost on and has agreed to. + **FR-NC-7 — Upload.** Files above 5MB use **chunked upload v2** against `/remote.php/dav/uploads//`: `MKCOL` to create the upload folder, `PUT` each chunk, then `MOVE` the `.file` pseudo-entry to the destination. Chunks are 5MB–5GB and named 1–10000. diff --git a/docs/storage.md b/docs/storage.md index 41c63a5..ee9decd 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -366,7 +366,120 @@ wrong, so neither of the other two would send the user anywhere useful. --- -## 6. What the abstraction does not yet cover +## 6. Virtual filesystems + +A sync client in virtual-files mode leaves a **placeholder** where a file is +catalogued but not downloaded. On Linux — the only mode it supports — that +means `IMG.CR2` does not exist at all and `IMG.CR2.nextcloud` does, holding one +byte. ARCH §9.0 measured a real machine: 121,785 placeholders against 10,267 +materialised files. + +A folder library that ignores this is not merely degraded, it is dangerous. +Before the handling below existed, the folder connector catalogued every stub +as a 1-byte image, gave it an identity that changed the moment it was +downloaded, and — worst — reported a dehydrated *sidecar* as absent, which made +the sidecar writer create a fresh document over an existing one and discard +every edit another device had put there. + +### 6.1 Three questions, one trait + +Everything else about a synced folder is an ordinary directory, so this is not +a second connector. `dr_sync_folder::Vfs` asks only what differs: + +```rust +pub trait Vfs: Send + Sync { + fn name(&self) -> &'static str; + fn is_placeholder(&self, on_disk: &str) -> bool; + fn real_name<'a>(&self, on_disk: &'a str) -> &'a str; + fn placeholder_name(&self, name: &str) -> Cow<'_, str>; + fn can_materialise(&self) -> bool; // defaulted false + fn materialise(&self, local: &Path) -> Result<(), RemoteError>; // defaulted + fn dematerialise(&self, local: &Path) -> Result<(), RemoteError>; // defaulted +} +``` + +`NoVfs` for a plain directory; `dr_sync_nextcloud::NextcloudVfs` for a synced +one, wrapping the `DesktopClient` socket. A third convention is a third impl. + +**Why not a `folder-vfs` provider.** The interesting capability is not a +property of the backend: the same directory can materialise on demand while the +client is running and cannot when it is down, so it must be computed per +connection either way. Registering two providers would ask the user to choose +between two things that differ by whether a background process is up. The +convention is detected instead, per connection, by a hook the registry supplies +(`FolderProvider::with_vfs_detector`) — which is what keeps `dr-sync-folder` +free of any client's protocol. + +### 6.2 What the backend reports + +| | | +|---|---| +| `RemoteEntry::path` | the photograph's name, never the stub's — so identity survives a download | +| `RemoteEntry::materialised` | `false` on a stub; the catalog maps it to `Availability::Offline` | +| `RemoteEntry::size` | `0` on a stub, meaning *unknown* — see below | +| `get` on a stub | `RemoteError::NotMaterialised`, **never** `NotFound` and never the stub's one byte | +| `put` over a stub | refused; writing a rival file hands the client a conflict to resolve arbitrarily | +| `move_to` a stub | moves the stub and keeps it a stub — culling without downloading is ordinary | +| `delete` a stub | deletes it; a photograph is deleted whether or not its bytes are here | +| `capabilities().materialisation` | `OnDemand` with a client, `Placeholders` without, `Always` on a plain folder | + +**Size is genuinely unknown.** A Linux suffix-mode stub is one byte and carries +no record of what it stands for. The client's `._sync_*.db` has the real size, +but that is a private schema and reading it would couple us to their migrations. +FR-NC-6c wants a transfer size quoted before an operation starts; for a stub the +honest answer is that it cannot be, and the interface should say so rather than +report one byte or invent an estimate silently. + +### 6.3 Hydration is a borrow + +The rule: **a file is returned to the state it was found in.** What a pass +downloaded is released; what the user already had is left alone. `BorrowPool` +enforces it. + +```rust +let pool = BorrowPool::new(); +{ + let held = pool.borrow(&backend, &path).await?; // downloads only if absent + // ... read it, thumbnail it, index its faces ... +} // borrow ends +let stats = pool.release_all(&backend).await; // dehydrates only what it hydrated +``` + +Three properties that are not obvious: + +- **Reference counted.** The thumbnail pass and the face pass meet on the same + RAW. Without counting, the first to finish dehydrates the file the second is + reading; with it, the transfer is paid once and released when the last + borrower is done. +- **Prior state is read before asking.** After `materialise` there is no way to + tell what the pass brought from what was already there, so it is recorded + first. Getting this wrong silently undoes a pin, and "my pinned trip + evaporated after an indexing run" is the failure that would make people stop + trusting the feature. +- **Being unsure is not symmetric.** `borrow_known(.., Some(true))` keeps a file + that might have been ours — costing disk. `Some(false)` releases one that + might have been the user's. An uncertain caller passes `true` or `None`, + never a guess at `false`. + +A borrow against a plain folder or a server backend short-circuits and does +nothing, so a pass written for a VFS library runs unchanged everywhere rather +than growing two code paths. + +### 6.4 Release means dehydrate, never delete + +The single most dangerous thing in this feature. A synced folder is not a +cache: deleting a materialised file inside it propagates the deletion to the +server and removes the photograph from every device the user owns. `Vfs` and +`RemoteBackend::dematerialise` both say so, and an implementation that cannot +dehydrate returns `Unsupported` rather than approximating it. + +This is also why the originals cache (`dr_catalog::cache`) cannot simply be +pointed at a VFS library: `Cache::release` deletes bytes, which is right for a +copy under `originals/` and catastrophic in place. + +--- + +## 7. What the abstraction does not yet cover Stated so the next person does not have to rediscover it. diff --git a/docs/traceability.md b/docs/traceability.md index 2346a68..0f31d0a 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 | 283 | -| TRACES tags found | 823 | -| Requirements defined | 178 | +| Source files scanned | 286 | +| TRACES tags found | 834 | +| Requirements defined | 179 | | Requirements covered | 107 | -| **Coverage** | **60.1%** (107/178) | +| **Coverage** | **59.8%** (107/179) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 85 | 123 | +| FR | 85 | 124 | | NFR | 20 | 49 | | R | 2 | 6 | @@ -35,26 +35,26 @@ _None._ |---|---| | 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: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-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:2257`](../ui/dr-ui/src/library.rs#L2257), [`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: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-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:456`](../core/dr-sync-nextcloud/src/lib.rs#L456), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:429`](../core/dr-sync/src/scan.rs#L429), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1219`](../ui/dr-ui/src/collections_ui.rs#L1219), [`ui/dr-ui/src/collections_ui.rs:1975`](../ui/dr-ui/src/collections_ui.rs#L1975), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:208`](../ui/dr-ui/src/library.rs#L208), [`ui/dr-ui/src/library.rs:3665`](../ui/dr-ui/src/library.rs#L3665), [`ui/dr-ui/src/library.rs:3699`](../ui/dr-ui/src/library.rs#L3699), [`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: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-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:2730`](../ui/dr-ui/src/library.rs#L2730), [`ui/dr-ui/src/library.rs:3200`](../ui/dr-ui/src/library.rs#L3200), [`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-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:3713`](../ui/dr-ui/src/library.rs#L3713), [`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:3959`](../ui/dr-ui/src/library.rs#L3959), [`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-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:1669`](../ui/dr-ui/src/library.rs#L1669), [`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:1629`](../ui/dr-ui/src/library.rs#L1629), [`ui/dr-ui/src/library.rs:1705`](../ui/dr-ui/src/library.rs#L1705), [`ui/dr-ui/src/library.rs:233`](../ui/dr-ui/src/library.rs#L233), [`ui/dr-ui/src/library.rs:4066`](../ui/dr-ui/src/library.rs#L4066), [`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: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: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-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:2731`](../ui/dr-ui/src/library.rs#L2731), [`ui/dr-ui/src/library.rs:2835`](../ui/dr-ui/src/library.rs#L2835), [`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: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) | @@ -67,7 +67,7 @@ _None._ | 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: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-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:1669`](../ui/dr-ui/src/library.rs#L1669), [`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) | @@ -86,22 +86,22 @@ _None._ | 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/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/launch_ui.rs:316`](../ui/dr-ui/src/launch_ui.rs#L316), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | +| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068), [`ui/dr-ui/src/library.rs:1705`](../ui/dr-ui/src/library.rs#L1705), [`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:913`](../core/dr-sync-nextcloud/src/lib.rs#L913), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:211`](../core/dr-sync/src/lib.rs#L211), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | +| FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:316`](../ui/dr-ui/src/launch_ui.rs#L316), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`core/dr-sync/src/account.rs:59`](../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs: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-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2730`](../ui/dr-ui/src/library.rs#L2730), [`ui/dr-ui/src/library.rs:3200`](../ui/dr-ui/src/library.rs#L3200), [`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:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../core/dr-sync-nextcloud/src/propfind.rs#L103), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:211`](../core/dr-sync/src/lib.rs#L211), [`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: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-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-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1807`](../ui/dr-ui/src/lib.rs#L1807), [`ui/dr-ui/src/lib.rs:2698`](../ui/dr-ui/src/lib.rs#L2698), [`ui/dr-ui/src/library.rs:1449`](../ui/dr-ui/src/library.rs#L1449), [`ui/dr-ui/src/library.rs:1472`](../ui/dr-ui/src/library.rs#L1472), [`ui/dr-ui/src/library.rs:1629`](../ui/dr-ui/src/library.rs#L1629), [`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-6c | [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:770`](../core/dr-sync-folder/src/lib.rs#L770), [`core/dr-sync-folder/src/lib.rs:809`](../core/dr-sync-folder/src/lib.rs#L809), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library.rs:948`](../ui/dr-ui/src/library.rs#L948), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | +| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:104`](../core/dr-sync-nextcloud/src/lib.rs#L104), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3200`](../ui/dr-ui/src/library.rs#L3200), [`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-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:998`](../ui/dr-ui/src/library.rs#L998) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:866`](../ui/dr-ui/src/lib.rs#L866), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | @@ -118,23 +118,23 @@ _None._ | 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:2807`](../ui/dr-ui/src/library.rs#L2807) | +| 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:2835`](../ui/dr-ui/src/library.rs#L2835) | | 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: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-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:3799`](../ui/dr-ui/src/library.rs#L3799), [`ui/dr-ui/src/library.rs:4591`](../ui/dr-ui/src/library.rs#L4591), [`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), [`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-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72) | -| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2702`](../ui/dr-ui/src/library.rs#L2702) | +| 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:2730`](../ui/dr-ui/src/library.rs#L2730) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | 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) | @@ -143,7 +143,7 @@ _None._ ## Not yet tagged -71 of 178 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +72 of 179 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -157,6 +157,7 @@ _None._ - FR-DSP-2 - FR-DSP-4 - FR-NC-11 +- FR-NC-6d - FR-PLAT-AND-2 - FR-PLAT-AND-4 - FR-PLAT-AND-5 diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index e775ec3..344f32c 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -25,7 +25,7 @@ use std::path::PathBuf; use std::sync::mpsc::{Receiver, Sender}; use dr_catalog::{Catalog, JobKind, Priority}; -use dr_sync::{Account, Connection, RemoteBackend, RemoteId, RemotePath}; +use dr_sync::{Account, Connection, RemoteBackend, RemoteError, RemoteId, RemotePath}; use dr_thumbs::ThumbStore; use crate::sidecar_cache::SidecarCache; @@ -944,7 +944,35 @@ async fn drain_one( let path = RemotePath::new(path_str.to_string()); let id = RemoteId::Path(path.clone()); - let remote = backend.get(&id, None).await.ok(); + + // TRACES: FR-NC-6c + // A miss and a placeholder are not the same answer, and conflating them + // destroys work. This read decides whether the sidecar already on the + // remote is merged in; treating "the content is not on this device" as + // "there is no sidecar" writes a fresh document over an existing one and + // discards every edit another device put there — the exact loss the + // format's unknown-key preservation exists to prevent. + // + // A sidecar is a few kilobytes, so the right response to a placeholder is + // to fetch it, not to give up. Where that is impossible — no client + // running — the entry stays queued, which is what the outbox is for. + let remote = match backend.get(&id, None).await { + Ok(bytes) => Some(bytes), + Err(RemoteError::NotFound(_)) => None, + Err(RemoteError::NotMaterialised(_)) => { + backend + .materialise(&id) + .await + .map_err(|e| format!("sidecar is not on this device ({e})"))?; + match backend.get(&id, None).await { + Ok(bytes) => Some(bytes), + Err(e) => return Err(format!("sidecar could not be read ({e})")), + } + } + // Anything else — a refused read, a dead connection — leaves the entry + // queued rather than resolved by overwriting. + Err(e) => return Err(format!("sidecar could not be read ({e})")), + }; if let Some(bytes) = remote.as_deref() { if !bytes.is_empty() { @@ -5440,6 +5468,7 @@ mod tests { size, modified: None, has_preview: false, + materialised: true, } } diff --git a/ui/dr-ui/src/remote.rs b/ui/dr-ui/src/remote.rs index 7370ef4..d5ada14 100644 --- a/ui/dr-ui/src/remote.rs +++ b/ui/dr-ui/src/remote.rs @@ -25,7 +25,7 @@ use std::sync::{Arc, OnceLock}; use dr_sync::{Account, BackendProvider, BackendRegistry, Connection, RemoteBackend, RemoteError}; use dr_sync_folder::FolderProvider; -use dr_sync_nextcloud::NextcloudProvider; +use dr_sync_nextcloud::{NextcloudProvider, NextcloudVfs}; /// Every storage backend this build has, in the order the launch screen /// offers them. @@ -37,7 +37,16 @@ pub fn registry() -> &'static BackendRegistry { REGISTRY.get_or_init(|| { let mut r = BackendRegistry::new(); r.register(Arc::new(NextcloudProvider)); - r.register(Arc::new(FolderProvider)); + // TRACES: FR-NC-6c + // The folder connector does the filesystem work and knows nothing + // about sync clients; the placeholder convention is supplied here, + // which is the one place that may name one. Detection is per folder + // and per connection — the same directory offers hydration while the + // client is running and not while it is down (ARCH §9.0). + r.register(Arc::new(FolderProvider::with_vfs_detector(|root| { + NextcloudVfs::looks_synced(root) + .then(|| Arc::new(NextcloudVfs::detect()) as Arc) + }))); r }) }