Merge master: pluggable storage, and a name that anchors

Conflicts were docs/traceability.md alone, and it is generated — so it
was regenerated rather than hand-merged. dr-face was untouched on the
other side; ui/dr-ui/src/faces.rs and identity_ui.rs auto-merged, the
first around recluster's anchoring and the second around load_faces.

Worth recording because the two branches met on the same problem from
different ends. Master's "Let a name hold a group together" is the fix
for the sixteen Catherines — fourteen of them empty — that this branch
found while measuring the library and reported without fixing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:30:01 +02:00
co-authored by Claude Opus 5
54 changed files with 6173 additions and 986 deletions
Generated
+19 -1
View File
@@ -1224,7 +1224,7 @@ name = "darkroom-android"
version = "0.8.0"
dependencies = [
"android_logger",
"dr-sync-nextcloud",
"dr-sync",
"dr-ui",
"log",
"slint",
@@ -1551,6 +1551,21 @@ name = "dr-sync"
version = "0.8.0"
dependencies = [
"async-trait",
"dr-plat",
"dr-types",
"log",
"serde",
"serde_json",
"thiserror 2.0.20",
"tokio",
]
[[package]]
name = "dr-sync-folder"
version = "0.8.0"
dependencies = [
"async-trait",
"dr-sync",
"dr-types",
"log",
"thiserror 2.0.20",
@@ -1565,6 +1580,7 @@ dependencies = [
"dr-decode",
"dr-plat",
"dr-sync",
"dr-sync-folder",
"dr-types",
"env_logger",
"log",
@@ -1604,6 +1620,7 @@ name = "dr-ui"
version = "0.8.0"
dependencies = [
"anyhow",
"async-trait",
"dr-catalog",
"dr-decode",
"dr-export",
@@ -1615,6 +1632,7 @@ dependencies = [
"dr-plat",
"dr-segment",
"dr-sync",
"dr-sync-folder",
"dr-sync-nextcloud",
"dr-thumbs",
"dr-types",
+2
View File
@@ -14,6 +14,7 @@ members = [
"core/dr-pipeline",
"core/dr-segment",
"core/dr-sync",
"core/dr-sync-folder",
"core/dr-sync-nextcloud",
"platform/dr-plat",
"ui/dr-ui",
@@ -53,6 +54,7 @@ dr-pipeline = { path = "core/dr-pipeline" }
dr-segment = { path = "core/dr-segment", default-features = false }
dr-plat = { path = "platform/dr-plat" }
dr-sync = { path = "core/dr-sync" }
dr-sync-folder = { path = "core/dr-sync-folder" }
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
dr-ui = { path = "ui/dr-ui" }
+2 -2
View File
@@ -16,9 +16,9 @@ crate-type = ["cdylib"]
# No backend feature to select: dr-ui picks its Slint backend from the target,
# so building for aarch64-linux-android gets android-activity automatically.
dr-ui.workspace = true
# For `session::set_data_dir`: only the platform entry point knows where Android
# For `account::set_data_dir`: only the platform entry point knows where Android
# lets this app keep files, and it must be set before any store is opened.
dr-sync-nextcloud.workspace = true
dr-sync.workspace = true
# Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and
# calls `slint::android::init`, both of which come from this crate. The backend
# feature comes from dr-ui's target-specific dependency.
+1 -1
View File
@@ -43,7 +43,7 @@ fn android_main(app: slint::android::AndroidApp) {
match app.internal_data_path() {
Some(dir) => {
log::info!("data dir: {}", dir.display());
dr_sync_nextcloud::session::set_data_dir(dir);
dr_sync::account::set_data_dir(dir);
}
None => log::error!("no internal data path; settings will not persist"),
}
+50
View File
@@ -222,6 +222,56 @@ impl Cache {
Ok(())
}
/// TRACES: FR-NC-6c | FR-NC-6a
/// Record an original this cache does **not** own the bytes of.
///
/// The virtual-filesystem case. On a library kept by a sync client the
/// original is materialised *in the library folder itself*, so copying it
/// under `originals/` would hold two copies of every pinned photograph —
/// and the copy would be the one the budget could evict while the real
/// disk cost stayed.
///
/// So the bytes are left where they are and only the bookkeeping is kept.
/// `path` is deliberately `NULL`, which is what makes this safe:
/// [`release`](Self::release) deletes the file a row names, and a row that
/// names none deletes nothing. **That matters more than it sounds.**
/// Deleting a materialised file inside a synced folder does not free a
/// cache — it deletes the photograph, and the client propagates that to
/// the server and to every other device. Handing the disk back is the
/// backend's job (`RemoteBackend::dematerialise`), not this one's.
///
/// `bytes` is what the original occupies where it lies, for the budget and
/// for reporting; pass 0 where it is not known.
pub fn record_in_place(
&self,
conn: &Connection,
image: ImageId,
bytes: u64,
pinned: bool,
now: i64,
) -> Result<(), CatalogError> {
conn.execute(
"INSERT INTO image_cache
(image_id, tier_actual, tier_desired, bytes, last_used, pinned, path)
VALUES (?1, ?2, ?2, ?3, ?4, ?5, NULL)
ON CONFLICT(image_id) DO UPDATE SET
tier_actual = ?2,
tier_desired = max(tier_desired, ?2),
bytes = ?3,
last_used = ?4,
pinned = max(pinned, ?5),
path = NULL",
rusqlite::params![
image.0 as i64,
Tier::Original.stored(),
bytes as i64,
now,
i64::from(pinned),
],
)?;
Ok(())
}
/// Read a cached original back, if it is here.
///
/// Touches `last_used`, which is what makes the eviction order reflect
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "dr-sync-folder"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
dr-sync.workspace = true
async-trait.workspace = true
thiserror.workspace = true
log.workspace = true
# Filesystem work runs on the blocking pool rather than on the async worker
# that called it — see the module docs.
tokio = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
+71
View File
@@ -0,0 +1,71 @@
//! Scan a real folder through the engine, and read a preview out of it.
//!
//! ```text
//! cargo run -p dr-sync-folder --example scan -- /path/to/photos
//! ```
//!
//! Exercises the same code the application runs: `dr_sync::scan` driving the
//! folder connector, then a ranged `get` of the kind the thumbnail worker
//! makes. Reads only — it never writes into the folder it is pointed at.
use std::collections::HashMap;
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_folder::FolderBackend;
use dr_types::FormatFilter;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let Some(root) = std::env::args().nth(1) else {
eprintln!("usage: scan <folder>");
std::process::exit(2);
};
let backend = match FolderBackend::new(&root) {
Ok(b) => b,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
let caps = backend.capabilities();
println!("{} at {root}", backend.name());
println!(
" strategy: {}",
dr_sync::SyncStrategy::for_capabilities(caps).describe()
);
let started = std::time::Instant::now();
let result = dr_sync::scan(
&backend,
&RemotePath::root(),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.expect("scan");
println!(
" {} image(s) in {} director(ies), {:?}",
result.images.len(),
result.progress.directories_listed,
started.elapsed()
);
let Some(first) = result.images.first() else {
return;
};
println!(
" first: {} ({} bytes) id {:?}",
first.path, first.size, first.id
);
// The shape of request the thumbnail worker makes: a header window, not
// the whole file.
let head = backend
.get(&RemoteId::Path(first.path.clone()), Some(0..65536))
.await
.expect("ranged read");
println!(" read {} header bytes", head.len());
}
+142
View File
@@ -0,0 +1,142 @@
//! A placeholder library, borrowed and given back.
//!
//! ```text
//! cargo run -p dr-sync-folder --example vfs_cycle
//! ```
//!
//! Builds a tree in the system temp directory shaped like a suffix-mode VFS
//! folder, runs the real engine over it, and reports what the borrow cost.
//! Touches nothing outside its own scratch directory.
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath};
use dr_sync_folder::{BorrowPool, FolderBackend, Vfs};
use dr_types::FormatFilter;
/// Stands in for the sync client, renaming exactly as suffix mode does.
struct Client;
impl Vfs for Client {
fn name(&self) -> &'static str {
"demo"
}
fn is_placeholder(&self, on_disk: &str) -> bool {
on_disk.ends_with(".stub")
}
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str {
on_disk.strip_suffix(".stub").unwrap_or(on_disk)
}
fn placeholder_name(&self, name: &str) -> Cow<'_, str> {
Cow::Owned(format!("{name}.stub"))
}
fn can_materialise(&self) -> bool {
true
}
fn materialise(&self, local: &Path) -> Result<(), RemoteError> {
let real = PathBuf::from(local.to_string_lossy().strip_suffix(".stub").unwrap());
std::fs::write(&real, vec![7u8; 25 * 1024 * 1024]).unwrap();
std::fs::remove_file(local).unwrap();
Ok(())
}
fn dematerialise(&self, local: &Path) -> Result<(), RemoteError> {
std::fs::write(format!("{}.stub", local.display()), [0u8]).unwrap();
std::fs::remove_file(local).unwrap();
Ok(())
}
}
fn disk_used(root: &Path) -> u64 {
fn walk(p: &Path, total: &mut u64) {
if let Ok(entries) = std::fs::read_dir(p) {
for e in entries.flatten() {
let Ok(m) = e.metadata() else { continue };
if m.is_dir() {
walk(&e.path(), total);
} else {
*total += m.len();
}
}
}
}
let mut t = 0;
walk(root, &mut t);
t
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let root = std::env::temp_dir().join("dr-vfs-cycle");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("2026/03")).unwrap();
// Ninety dehydrated photographs, and ten the user already keeps.
for i in 0..90 {
std::fs::write(root.join(format!("2026/03/IMG_{i:04}.CR2.stub")), [0u8]).unwrap();
}
for i in 90..100 {
std::fs::write(
root.join(format!("2026/03/IMG_{i:04}.CR2")),
vec![1u8; 25 * 1024 * 1024],
)
.unwrap();
}
let b = FolderBackend::with_vfs(&root, Arc::new(Client)).unwrap();
println!("materialisation: {:?}", b.capabilities().materialisation);
println!("on disk at rest: {} MB", disk_used(&root) / 1_048_576);
let scan = dr_sync::scan(
&b,
&RemotePath::root(),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
let absent = scan.images.iter().filter(|e| !e.materialised).count();
println!(
"scanned {} photograph(s), {absent} not downloaded",
scan.images.len()
);
// Names, not stubs — this is what the catalog records.
println!("first: {}", scan.images[0].path);
// A pass over the library, one photograph at a time.
let pool = BorrowPool::new();
let mut peak = 0u64;
let mut fetched = 0usize;
for entry in &scan.images {
let held = pool.borrow(&b, &entry.path).await.unwrap();
if held.hydrated() {
fetched += 1;
}
// Read it, as a thumbnail pass would.
let n = b
.get(&RemoteId::Path(entry.path.clone()), Some(0..65536))
.await
.unwrap()
.len();
assert_eq!(n, 65536);
peak = peak.max(disk_used(&root));
drop(held);
// Release as we go, which is what keeps the peak flat.
pool.release_all(&b).await;
}
println!("fetched {fetched} of {}", scan.images.len());
println!("peak on disk: {} MB", peak / 1_048_576);
println!("after the pass: {} MB", disk_used(&root) / 1_048_576);
println!(
"the ten the user already had: {} still here",
(90..100)
.filter(|i| root.join(format!("2026/03/IMG_{i:04}.CR2")).is_file())
.count()
);
let _ = std::fs::remove_dir_all(&root);
}
+207
View File
@@ -0,0 +1,207 @@
// TRACES: FR-NC-6c | FR-NC-6a
//! Hydrating a file for as long as it is needed, and no longer.
//!
//! A pass over a library — thumbnails, face indexing — needs each photograph's
//! bytes for a moment and never again. On a virtual-filesystem folder those
//! bytes may not be here, and fetching them is whole-file: hydrating a 17,000
//! image library to index it would land the entire library on a disk the user
//! deliberately keeps most of it off (ARCH §9.0).
//!
//! So hydration is a **borrow**. Ask for a file, use it, give it back. Peak
//! disk becomes the working set rather than the library, and the transfer is
//! paid once for a thumbnail that is then kept for ever — and pushed to the
//! server for other devices, which never pay it at all.
//!
//! # The rule that makes it safe
//!
//! **A file is returned to the state it was found in.** If it was already
//! downloaded — the user pinned it, opened it yesterday, or never uses VFS —
//! the borrow leaves it downloaded. Only what this pass hydrated is released.
//! Anything else silently undoes a choice the user made, and "my pinned trip
//! evaporated after an indexing run" is the kind of failure that makes people
//! stop trusting the feature.
//!
//! # Why it is reference counted
//!
//! Lanes run concurrently and two of them meet on the same file: the
//! thumbnail pass and the face pass want the same RAW. Without counting, the
//! first to finish dehydrates the file the second is reading. With it, the
//! transfer is paid once and the release happens when the last borrower is
//! done.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath};
/// What a borrow is holding, per path.
#[derive(Debug, Default)]
struct Held {
/// How many borrowers are using it now.
borrowers: usize,
/// Whether *we* brought it here. False means it was already downloaded
/// and must be left that way.
ours: bool,
}
/// Tracks what has been hydrated and by whom.
///
/// Cheap to clone — every worker holds one and they share the same state.
#[derive(Clone, Default, Debug)]
pub struct BorrowPool {
held: Arc<Mutex<HashMap<RemotePath, Held>>>,
}
/// 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<Borrowed<'p>, RemoteError> {
if !backend.capabilities().materialisation.can_materialise() {
return Ok(Borrowed {
pool: None,
path: path.clone(),
hydrated: false,
});
}
// Another borrower already has it: join them rather than asking the
// client a second time.
{
let mut held = self.lock();
if let Some(entry) = held.get_mut(path) {
entry.borrowers += 1;
return Ok(Borrowed {
pool: Some(self),
path: path.clone(),
hydrated: false,
});
}
}
// The backend answers whether *it* fetched the content, because it had
// to look before deciding. Determining that here instead would cost a
// directory listing per file, and getting it wrong in the wrong
// direction releases a file the user pinned.
let ours = backend.materialise(&RemoteId::Path(path.clone())).await?;
self.lock()
.insert(path.clone(), Held { borrowers: 1, ours });
Ok(Borrowed {
pool: Some(self),
path: path.clone(),
hydrated: ours,
})
}
/// Release everything this pool still holds that it hydrated.
///
/// The end-of-pass sweep. A guard dropped on a panicking worker cannot run
/// its async release, so the pool is drained deliberately at the end
/// rather than trusted to unwind cleanly.
pub async fn release_all(&self, backend: &dyn RemoteBackend) -> BorrowStats {
let ours: Vec<RemotePath> = {
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<RemotePath, Held>> {
// A poisoned lock means a worker panicked while holding it. The map is
// bookkeeping, not a resource — carrying on with it is better than
// taking the whole pass down.
self.held.lock().unwrap_or_else(|e| e.into_inner())
}
}
/// A file held local for as long as this lives.
///
/// Dropping it marks the borrow finished. The actual release happens in
/// [`BorrowPool::release_all`], because dropping cannot await.
#[derive(Debug)]
pub struct Borrowed<'p> {
pool: Option<&'p BorrowPool>,
path: RemotePath,
/// Whether this borrow was the one that downloaded it.
hydrated: bool,
}
impl Borrowed<'_> {
/// Whether this borrow paid for a download.
pub fn hydrated(&self) -> bool {
self.hydrated
}
pub fn path(&self) -> &RemotePath {
&self.path
}
}
impl Drop for Borrowed<'_> {
fn drop(&mut self) {
let Some(pool) = self.pool else { return };
let mut held = pool.lock();
if let Some(entry) = held.get_mut(&self.path) {
entry.borrowers = entry.borrowers.saturating_sub(1);
// Left in the map even at zero borrowers: `release_all` needs to
// know it was ours, and a file wanted again a moment later should
// not be downloaded twice.
if entry.borrowers == 0 && !entry.ours {
held.remove(&self.path);
}
}
}
}
#[cfg(test)]
mod tests;
+266
View File
@@ -0,0 +1,266 @@
//! The borrow contract, against a filesystem and a fake client.
//!
//! The fake stands in for the sync client's socket, not for the filesystem:
//! it renames stubs exactly as suffix-mode VFS does, so everything under test
//! is the real path resolution and the real state tracking.
use super::*;
use crate::{FolderBackend, Vfs};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
/// A stand-in for a sync client, counting what it was asked to do.
struct FakeClient {
suffix: &'static str,
hydrations: AtomicUsize,
dehydrations: AtomicUsize,
/// When true, refuse to hydrate — the client is running but the server is
/// not reachable.
broken: bool,
}
impl FakeClient {
fn new() -> Arc<Self> {
Arc::new(Self {
suffix: ".nextcloud",
hydrations: AtomicUsize::new(0),
dehydrations: AtomicUsize::new(0),
broken: false,
})
}
fn broken() -> Arc<Self> {
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<dyn Vfs>) -> FolderBackend {
FolderBackend::with_vfs(&self.0, vfs).unwrap()
}
}
impl Drop for Tmp {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[tokio::test]
async fn a_borrowed_placeholder_is_downloaded_and_given_back() {
let t = Tmp::new("cycle");
t.stub("a.CR2");
let client = FakeClient::new();
let b = t.backend(client.clone());
let pool = BorrowPool::new();
let path = RemotePath::new("a.CR2");
{
let held = pool.borrow(&b, &path).await.unwrap();
assert!(held.hydrated(), "this borrow paid for it");
assert!(t.has("a.CR2"), "content is here while borrowed");
assert_eq!(
b.get(&RemoteId::Path(path.clone()), None)
.await
.unwrap()
.len(),
4096
);
}
let stats = pool.release_all(&b).await;
assert_eq!(stats.released, 1);
assert!(!t.has("a.CR2"), "given back");
assert!(t.has("a.CR2.nextcloud"), "a placeholder is left behind");
assert_eq!(client.dehydrations.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_file_the_user_already_had_is_never_taken_away() {
// The rule the whole design rests on. Silently undoing a pin — or just a
// file someone opened yesterday — after an indexing run is the failure
// that would make people stop trusting this.
let t = Tmp::new("keep");
t.real("pinned.CR2");
let client = FakeClient::new();
let b = t.backend(client.clone());
let pool = BorrowPool::new();
{
let held = pool
.borrow(&b, &RemotePath::new("pinned.CR2"))
.await
.unwrap();
assert!(!held.hydrated(), "nothing was downloaded");
}
let stats = pool.release_all(&b).await;
assert_eq!(stats.released, 0);
assert!(t.has("pinned.CR2"), "still here");
assert_eq!(client.hydrations.load(Ordering::SeqCst), 0);
assert_eq!(client.dehydrations.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn two_lanes_wanting_one_file_download_it_once() {
// The thumbnail pass and the face pass meet on the same RAW. Without
// counting, the first to finish dehydrates the file the second is reading.
let t = Tmp::new("shared");
t.stub("a.CR2");
let client = FakeClient::new();
let b = t.backend(client.clone());
let pool = BorrowPool::new();
let path = RemotePath::new("a.CR2");
let first = pool.borrow(&b, &path).await.unwrap();
let second = pool.borrow(&b, &path).await.unwrap();
assert_eq!(client.hydrations.load(Ordering::SeqCst), 1, "paid once");
drop(first);
assert!(t.has("a.CR2"), "still held by the second borrower");
drop(second);
pool.release_all(&b).await;
assert!(!t.has("a.CR2"));
}
#[tokio::test]
async fn a_failed_download_does_not_leave_a_phantom_borrow() {
// The client is up but the server is not. The pass must see the failure
// and the pool must not believe it holds anything.
let t = Tmp::new("failed");
t.stub("a.CR2");
let b = t.backend(FakeClient::broken());
let pool = BorrowPool::new();
let e = pool
.borrow(&b, &RemotePath::new("a.CR2"))
.await
.unwrap_err();
assert!(matches!(e, RemoteError::Network(_)), "{e:?}");
assert_eq!(pool.held(), 0);
assert!(t.has("a.CR2.nextcloud"), "left as it was found");
}
#[tokio::test]
async fn borrowing_against_a_plain_folder_does_nothing_at_all() {
// A caller written for a VFS library must run unchanged elsewhere, or
// every sweep grows two code paths.
let t = Tmp::new("plain");
t.real("a.CR2");
let b = FolderBackend::new(&t.0).unwrap();
let pool = BorrowPool::new();
let held = pool.borrow(&b, &RemotePath::new("a.CR2")).await.unwrap();
assert!(!held.hydrated());
drop(held);
assert_eq!(pool.release_all(&b).await.released, 0);
assert!(t.has("a.CR2"));
}
#[tokio::test]
async fn the_backend_is_what_decides_whether_a_file_was_ours() {
// Not the pool, and not the caller. The backend had to look before
// deciding whether to ask, so it can answer for the cost of that same
// `stat`; a borrower working it out separately would pay a directory
// listing per file and could get it wrong in the direction that releases
// a file the user pinned.
let t = Tmp::new("who-decides");
t.real("had.CR2").stub("wanted.CR2");
let b = t.backend(FakeClient::new());
assert!(
!b.materialise(&RemoteId::Path(RemotePath::new("had.CR2")))
.await
.unwrap(),
"already here, so not ours to release"
);
assert!(
b.materialise(&RemoteId::Path(RemotePath::new("wanted.CR2")))
.await
.unwrap(),
"this call fetched it"
);
}
#[tokio::test]
async fn a_file_borrowed_twice_in_one_pass_is_fetched_once_and_released_once() {
// Thumbnailing and face indexing visit the same photograph. Fetching it
// per stage doubles the transfer over the whole library.
let t = Tmp::new("sequential");
t.stub("a.CR2");
let client = FakeClient::new();
let b = t.backend(client.clone());
let pool = BorrowPool::new();
let path = RemotePath::new("a.CR2");
// Sequential borrows, as two passes over one work list would make.
drop(pool.borrow(&b, &path).await.unwrap());
drop(pool.borrow(&b, &path).await.unwrap());
assert_eq!(client.hydrations.load(Ordering::SeqCst), 1, "paid once");
let stats = pool.release_all(&b).await;
assert_eq!(stats.released, 1, "given back once");
}
+869
View File
@@ -0,0 +1,869 @@
// TRACES: FR-NC-13 | FR-NC-12
//! A library that is just a directory.
//!
//! The second [`RemoteBackend`], and the one that exists to prove the first
//! was an abstraction rather than a description. It serves a plain folder: a
//! local disk, an NFS or SMB mount, a Nextcloud desktop client's synced copy,
//! an external drive. No server, no account, no credential.
//!
//! # What it is honestly worse at, and why that is fine
//!
//! Nextcloud's fast path rests on directory ETags propagating up the tree, so
//! one request against the root proves a 50k-image library unchanged. A POSIX
//! directory's mtime says only that its own entry list changed — not that a
//! grandchild's *contents* did — so there is nothing here to propagate and
//! [`ChangeDetection::LocalEtags`] is the truthful answer. The engine reads
//! that and walks the tree every scan instead of pruning it.
//!
//! Which costs almost nothing, because the walk that was expensive was
//! expensive for a reason this backend does not have. Fifty thousand
//! `stat` calls against a local filesystem take well under a second; fifty
//! thousand `PROPFIND`s do not. The capability model is what lets both be
//! driven by the same engine at the speed each one actually runs at.
//!
//! # Identity
//!
//! [`RemoteId::Stable`] here is a hash of the path relative to the library
//! root. That gives the catalog what it needs — a `u64` that names a
//! photograph, is the same on every device looking at the same folder, and
//! does not change when the file is edited — which is what keys the thumbnail
//! shards and the face index (`catalog.md` §10.1).
//!
//! It does **not** survive a rename, and [`Capabilities::stable_ids`] says so.
//! A moved photograph is seen as a delete and an add, and its thumbnail is
//! derived again. That is the documented degradation for a backend without
//! server-assigned ids, and it is the right trade here: the alternative,
//! keying on the inode, is stable across a rename but *differs between
//! devices* and is reused by the filesystem after a delete — so two machines
//! would disagree about which photograph a thumbnail belonged to, and a
//! recycled inode would silently attach an old thumbnail to a new image.
//! Re-deriving a thumbnail is a cost; showing the wrong one is a bug.
//!
//! # Blocking
//!
//! Every filesystem call goes through the blocking pool. On a local disk that
//! is overkill; on the NFS mount this backend is most useful over, a stalled
//! server would otherwise wedge the async worker that made the call and every
//! other request sharing it.
use std::io::{Read, Seek, SeekFrom, Write};
use std::ops::Range;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use dr_sync::{
Account, BackendProvider, Capabilities, ChangeDetection, Connection, Cursor, EntryKind,
Materialisation, Precondition, RemoteBackend, RemoteChange, RemoteEntry, RemoteError, RemoteId,
RemotePath, ServerPreviews, SignIn, Validator,
};
pub mod borrow;
pub mod vfs;
pub use borrow::{BorrowPool, BorrowStats, Borrowed};
pub use vfs::{NoVfs, Vfs};
/// The id written to [`Account::backend`] for a folder library.
///
/// On-disk configuration: changing it orphans every folder account.
pub const BACKEND_ID: &str = "folder";
/// TRACES: FR-NC-13 | FR-NC-6c
/// Registers the folder connector.
///
/// See [`dr_sync::provider`] for what each method is for.
///
/// # The detector
///
/// This crate knows how to read a directory and nothing about sync clients,
/// so the placeholder convention arrives from outside: whoever registers the
/// provider supplies a function that recognises a synced folder and returns
/// the [`Vfs`] for it. That keeps `dr-sync-folder` free of any client's
/// protocol, and it is what lets one connector serve a plain disk, a Nextcloud
/// tree, and whatever comes next.
///
/// Detection runs per connection because the answer changes: the same
/// directory offers hydration while the client is up and not while it is down.
/// Recognises a placeholder convention in a directory, if any applies.
///
/// Runs per connection rather than once, because the answer changes: the same
/// folder offers hydration while the sync client is up and not while it is
/// down.
pub type VfsDetector = dyn Fn(&Path) -> Option<Arc<dyn Vfs>> + Send + Sync;
#[derive(Default)]
pub struct FolderProvider {
detect_vfs: Option<Box<VfsDetector>>,
}
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<Arc<dyn Vfs>> + Send + Sync + 'static,
) -> Self {
Self {
detect_vfs: Some(Box::new(detect)),
}
}
fn vfs_for(&self, root: &Path) -> Arc<dyn Vfs> {
self.detect_vfs
.as_ref()
.and_then(|d| d(root))
.unwrap_or_else(|| Arc::new(NoVfs))
}
}
impl BackendProvider for FolderProvider {
fn id(&self) -> &'static str {
BACKEND_ID
}
fn display_name(&self) -> &'static str {
"Folder"
}
fn endpoint_label(&self) -> &'static str {
"Folder"
}
fn endpoint_placeholder(&self) -> &'static str {
"/home/you/Pictures"
}
fn sign_in(&self) -> SignIn {
SignIn::EndpointOnly
}
/// Check the directory before an account is written for it.
///
/// A typo here would otherwise be stored, skip the launch screen on the
/// next start, and surface as a scan that finds nothing — which reads as
/// a broken library rather than a wrong path. The messages say what to fix.
fn normalise_endpoint(&self, input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("Choose the folder your photographs are in.".into());
}
// `~` is what a person types and what a shell would have expanded;
// nothing expands it here, so a stored `~/Pictures` becomes a
// directory literally named `~`.
let expanded = match trimmed.strip_prefix("~/") {
Some(rest) => match std::env::var_os("HOME") {
Some(home) => PathBuf::from(home).join(rest),
None => return Err("No home directory to expand ~ against.".into()),
},
None => PathBuf::from(trimmed),
};
if !expanded.is_absolute() {
return Err("Give the full path to the folder, starting at /.".into());
}
if !expanded.exists() {
return Err(format!("No folder at {}.", expanded.display()));
}
if !expanded.is_dir() {
return Err(format!("{} is a file, not a folder.", expanded.display()));
}
// Resolved so a library reached through a symlink or a `..` is stored
// under one name. Two spellings of one folder would otherwise be two
// accounts with two catalogs indexing the same photographs.
let canonical = expanded
.canonicalize()
.map_err(|e| format!("Cannot read {}: {e}", expanded.display()))?;
Ok(canonical.to_string_lossy().into_owned())
}
fn account_for(&self, endpoint: &str) -> Result<Account, RemoteError> {
Ok(Account::new(BACKEND_ID, endpoint))
}
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
let root = Path::new(&conn.account.endpoint);
Ok(Box::new(FolderBackend::with_vfs(root, self.vfs_for(root))?))
}
}
/// TRACES: FR-NC-13 | FR-NC-4 | FR-NC-6c
/// A library rooted at a directory.
#[derive(Clone)]
pub struct FolderBackend {
root: PathBuf,
/// The placeholder convention in force, [`NoVfs`] for an ordinary folder.
vfs: Arc<dyn Vfs>,
caps: Capabilities,
}
impl std::fmt::Debug for FolderBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FolderBackend")
.field("root", &self.root)
.field("vfs", &self.vfs.name())
.finish_non_exhaustive()
}
}
impl FolderBackend {
/// Open the folder at `root`.
///
/// The directory must exist now. It may stop existing later — a drive
/// unplugged, a mount dropped — and that surfaces per-operation as
/// [`RemoteError::Network`], which is what puts the app into offline mode
/// and leaves the catalog readable, exactly as a dead server does.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, RemoteError> {
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<PathBuf>, vfs: Arc<dyn Vfs>) -> Result<Self, RemoteError> {
let root = root.into();
if !root.is_dir() {
return Err(RemoteError::Configuration(format!(
"{} is not a folder",
root.display()
)));
}
// Reported per connection, not per backend: the same folder offers
// hydration while the client is up and not while it is down, so this
// cannot be a constant of the type (see `vfs`).
let materialisation = if vfs.can_materialise() {
Materialisation::OnDemand
} else if vfs.name() == NoVfs.name() {
Materialisation::Always
} else {
Materialisation::Placeholders
};
Ok(Self {
root,
vfs,
caps: Capabilities {
// A directory's mtime describes its own entry list and nothing
// below it, so there is no propagation to exploit; the engine
// walks and compares per entry.
change_detection: ChangeDetection::LocalEtags,
// A path hash does not survive a rename. See the module docs
// for why the inode is not used instead.
stable_ids: false,
range_reads: true,
// Not a protocol with a message size limit; a write is a write.
chunked_upload: None,
bulk_upload: false,
conditional_write: true,
server_previews: ServerPreviews::None,
materialisation,
},
})
}
pub fn root(&self) -> &Path {
&self.root
}
/// The local path for a remote path, refusing anything that escapes.
///
/// The guard is not theoretical. A `RemotePath` is built from strings that
/// reach us from a catalog written by another device and from filenames on
/// the remote itself, and this backend resolves them against a real
/// filesystem with the user's own permissions. `../../.ssh/id_ed25519` is
/// a legal path segment; without this it would be a legal *read*.
fn resolve(&self, path: &RemotePath) -> Result<PathBuf, RemoteError> {
let rel = Path::new(path.as_str());
for component in rel.components() {
match component {
Component::Normal(_) => {}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(RemoteError::Configuration(format!(
"{path} leaves the library folder"
)));
}
}
}
Ok(self.root.join(rel))
}
/// Where a photograph's bytes are on disk, and whether they are really
/// there.
///
/// A placeholder lives under a *different* name — suffix-mode VFS renames
/// on hydration rather than filling in place — so every read and write has
/// to look for both. The materialised name is tried first: it is the
/// common case, and the second `stat` is paid only when it misses.
///
/// Returns the path to use and whether it holds real content.
fn locate(&self, path: &RemotePath) -> Result<(PathBuf, bool), RemoteError> {
let direct = self.resolve(path)?;
if self.vfs.name() == NoVfs.name() || direct.exists() {
return Ok((direct, true));
}
let stub = self.resolve(&RemotePath::new(
self.vfs.placeholder_name(path.as_str()).into_owned(),
))?;
if stub.exists() {
return Ok((stub, false));
}
// Neither: genuinely missing. Report the name the caller asked for.
Ok((direct, true))
}
/// The local path a [`RemoteId`] names.
///
/// A stable id here is a hash and nothing can be resolved from it, exactly
/// as a Nextcloud `oc:fileid` names no WebDAV endpoint. Callers hold the
/// path alongside it in the catalog and pass that.
fn resolve_id(&self, id: &RemoteId) -> Result<PathBuf, RemoteError> {
match id {
RemoteId::Path(p) => self.resolve(p),
RemoteId::Stable(_) => Err(RemoteError::Unsupported(
"a folder cannot be addressed by id; use RemoteId::Path",
)),
}
}
/// [`locate`](Self::locate) for an id.
fn locate_id(&self, id: &RemoteId) -> Result<(PathBuf, bool), RemoteError> {
match id {
RemoteId::Path(p) => self.locate(p),
RemoteId::Stable(_) => Err(RemoteError::Unsupported(
"a folder cannot be addressed by id; use RemoteId::Path",
)),
}
}
}
/// Run a filesystem operation off the async worker that asked for it.
///
/// See the module docs: a stalled network mount must not take the caller's
/// runtime with it.
async fn blocking<T, F>(f: F) -> Result<T, RemoteError>
where
F: FnOnce() -> Result<T, RemoteError> + Send + 'static,
T: Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(r) => r,
// The only way a blocking task fails to produce a result is a panic
// inside it, which is a bug here rather than a condition the caller
// can act on — but crashing the worker over it would lose a whole
// scan, so it is reported like any other failure.
Err(e) => Err(RemoteError::Protocol(format!("folder task failed: {e}"))),
}
}
/// Map an IO failure to the error the engine already knows how to handle.
///
/// The classification is the point. [`RemoteError::indicates_offline`] drives
/// offline mode, so a vanished mount must reach it as `Network` — that is
/// precisely the "the library is unreachable, keep working from the catalog"
/// case — while a permissions problem must not, because going offline over one
/// forbidden file would hide a fixable problem behind a network banner.
fn map_io(e: std::io::Error, what: &str) -> RemoteError {
use std::io::ErrorKind as K;
match e.kind() {
K::NotFound => RemoteError::NotFound(what.to_string()),
K::PermissionDenied => RemoteError::PermissionDenied,
K::AlreadyExists => RemoteError::PreconditionFailed,
// ENOSPC and friends. Quota is what the engine calls "no room".
K::StorageFull | K::QuotaExceeded | K::FileTooLarge => RemoteError::QuotaExceeded,
// A dropped mount answers ESTALE/EIO/ENOTCONN, and the honest reading
// is the same as a dead server: the library cannot be reached now, and
// may be again shortly.
K::HostUnreachable
| K::NetworkUnreachable
| K::NetworkDown
| K::ConnectionAborted
| K::ConnectionReset
| K::NotConnected
| K::BrokenPipe
| K::TimedOut => RemoteError::Network(format!("{what}: {e}")),
_ => RemoteError::Protocol(format!("{what}: {e}")),
}
}
/// How long to wait for a requested download to land.
///
/// Generous, because the file may be tens of megabytes over a domestic
/// connection, and bounded, because a client that has stopped transferring
/// must not wedge a whole pass.
const MATERIALISE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// How often to look for the materialised file while waiting.
const POLL: std::time::Duration = std::time::Duration::from_millis(200);
/// The identity of a file, from its path relative to the library root.
///
/// FNV-1a rather than `DefaultHasher`, whose output is explicitly unstable
/// between Rust releases: this value is written into the catalog and into the
/// thumbnail index, and must mean the same thing after a toolchain upgrade as
/// it did before one.
fn identity(path: &RemotePath) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in path.as_str().as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
/// A file's validator: its size and modification time.
///
/// The pair, not either alone. An mtime with one-second granularity — which is
/// what some filesystems and most network mounts report — cannot distinguish
/// two writes in the same second, and a size alone cannot see an edit that
/// preserved it. Together they miss only a same-second write of identical
/// length, which for a photograph is a rewrite of the same frame.
fn validator_of(meta: &std::fs::Metadata) -> Validator {
let (secs, nanos) = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| (d.as_secs(), d.subsec_nanos()))
.unwrap_or((0, 0));
Validator::new(format!("{:x}-{:x}.{:x}", meta.len(), secs, nanos))
}
fn modified_secs(meta: &std::fs::Metadata) -> Option<i64> {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
}
#[async_trait]
impl RemoteBackend for FolderBackend {
fn capabilities(&self) -> &Capabilities {
&self.caps
}
fn name(&self) -> &str {
"Folder"
}
async fn list(
&self,
dir: &RemotePath,
_since: Option<&Validator>,
) -> Result<Vec<RemoteEntry>, RemoteError> {
let local = self.resolve(dir)?;
let dir = dir.clone();
let vfs = self.vfs.clone();
blocking(move || {
let read =
std::fs::read_dir(&local).map_err(|e| map_io(e, &local.display().to_string()))?;
let mut out = Vec::new();
for entry in read {
let entry = match entry {
Ok(e) => e,
// One unreadable entry must not fail the listing: a
// scan of a real library meets a broken symlink or a
// file being written, and abandoning the whole
// directory over it loses every photograph beside it.
Err(e) => {
log::debug!("skipping an entry in {}: {e}", local.display());
continue;
}
};
let name = entry.file_name();
let Some(name) = name.to_str() else {
// A name that is not UTF-8 cannot round-trip through a
// `RemotePath`, and quietly mangling it would produce a
// path that addresses a different file — or none.
log::warn!("skipping a non-UTF-8 name in {}", local.display());
continue;
};
// `metadata`, not `symlink_metadata`: a symlinked shoot
// folder is a normal way to assemble a library, and the
// scan's depth limit is what stops a loop.
let meta = match entry.metadata() {
Ok(m) => m,
Err(e) => {
log::debug!("skipping {name}: {e}");
continue;
}
};
// The photograph's own name, never the stub's. Identity is
// derived from it, so downloading a file must not look like a
// delete and an add — and `source_ref` must match what every
// other device calls the same photograph.
let stub = vfs.is_placeholder(name);
let path = dir.join(vfs.real_name(name));
out.push(RemoteEntry {
id: RemoteId::Stable(identity(&path)),
kind: if meta.is_dir() {
EntryKind::Directory
} else {
EntryKind::File
},
validator: validator_of(&meta),
// A stub is one byte and says nothing about what it stands
// for. Reporting that byte count would put a 1-byte
// `file_size` in the catalog for most of the library.
size: if stub { 0 } else { meta.len() },
modified: modified_secs(&meta),
// No renderer behind a folder; previews are extracted
// locally from the file itself.
has_preview: false,
materialised: !stub,
path,
});
}
Ok(out)
})
.await
}
/// Not offered.
///
/// A directory's mtime changes when its own entries are added or removed
/// and at no other time, so it cannot answer the question this method
/// exists for — "did anything below here change?". Returning it anyway
/// would let a future caller prune a subtree whose contents had been
/// edited, and hide those edits for as long as the folder list held still.
async fn dir_validator(&self, _dir: &RemotePath) -> Result<Validator, RemoteError> {
Err(RemoteError::Unsupported(
"a folder's mtime does not propagate; use per-entry validators",
))
}
async fn delta(&self, _cursor: &Cursor) -> Result<(Vec<RemoteChange>, Cursor), RemoteError> {
Err(RemoteError::Unsupported("a folder keeps no change feed"))
}
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError> {
let (local, materialised) = self.locate_id(id)?;
if !materialised {
// The one byte in the stub is not the file. Returning it produced
// a sidecar that parsed as empty and a thumbnail that never
// decoded; reporting `NotFound` made the sidecar writer treat an
// existing document as absent and overwrite it.
return Err(RemoteError::NotMaterialised(local.display().to_string()));
}
blocking(move || {
let what = local.display().to_string();
let mut file = std::fs::File::open(&local).map_err(|e| map_io(e, &what))?;
let Some(r) = range else {
let mut buf = Vec::new();
file.read_to_end(&mut buf).map_err(|e| map_io(e, &what))?;
return Ok(buf);
};
// A short read at the end of the file is not an error: the header
// extractor asks for a fixed window and the file may be smaller
// than it, which is the ordinary case for a small JPEG.
file.seek(SeekFrom::Start(r.start))
.map_err(|e| map_io(e, &what))?;
let want = r.end.saturating_sub(r.start);
let mut buf = Vec::new();
file.take(want)
.read_to_end(&mut buf)
.map_err(|e| map_io(e, &what))?;
Ok(buf)
})
.await
}
async fn put(
&self,
path: &RemotePath,
body: Vec<u8>,
precond: Option<Precondition>,
) -> Result<Validator, RemoteError> {
let (found, materialised) = self.locate(path)?;
// Where the content belongs, which is not where a placeholder for it
// sits — suffix-mode VFS gives the two different names.
let local = self.resolve(path)?;
// A stub is still this file, so what to do about it depends entirely
// on what the caller is promising.
let replaces = if materialised {
None
} else {
match &precond {
// Nothing here can satisfy it: the validator on a placeholder
// describes the placeholder. The caller fetches the content
// and tries again, which is what the typed error asks for.
Some(Precondition::IfMatch(_)) => {
return Err(RemoteError::NotMaterialised(found.display().to_string()))
}
// Something *is* there — the file exists, only its content is
// elsewhere — so a create-if-absent must fail.
Some(Precondition::IfAbsent) => return Err(RemoteError::PreconditionFailed),
// An unconditional write replaces the whole file, so there is
// nothing in the stub worth reading and no reason to download
// it first. Refusing here instead was a mistake: derived state
// lives in the library folder and the client dehydrates it
// like anything else, so a refusal meant sync could never
// write to a folder it had been away from.
None => Some(found),
}
};
blocking(move || {
let what = local.display().to_string();
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| map_io(e, &parent.display().to_string()))?;
}
match &precond {
// Genuinely atomic: `O_CREAT | O_EXCL` is one syscall, so two
// devices racing to create a sidecar cannot both win.
Some(Precondition::IfAbsent) => {
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&local)
.map_err(|e| map_io(e, &what))?;
f.write_all(&body).map_err(|e| map_io(e, &what))?;
f.sync_all().map_err(|e| map_io(e, &what))?;
let meta = f.metadata().map_err(|e| map_io(e, &what))?;
return Ok(validator_of(&meta));
}
// Compare, then swap. A POSIX filesystem has no compare-and-
// swap, so this narrows the window to the microseconds between
// the `stat` and the `rename` rather than closing it. That is
// still far tighter than the fallback the engine uses when a
// backend declares no conditional write at all — comparing
// revision counters *inside* the sidecar, which spans a whole
// read-modify-write — which is why the capability is declared
// rather than refused.
Some(Precondition::IfMatch(expected)) => {
let meta = std::fs::metadata(&local).map_err(|e| map_io(e, &what))?;
if &validator_of(&meta) != expected {
return Err(RemoteError::PreconditionFailed);
}
}
None => {}
}
// Write beside the destination and rename over it, so a reader
// never sees a half-written sidecar and an interrupted write
// cannot destroy the file it was replacing. Beside, not in
// `/tmp`: a rename across filesystems is not atomic, and on
// Android `/tmp` is a different one.
let tmp = local.with_extension(format!(
"{}.darkroom-tmp",
local.extension().and_then(|e| e.to_str()).unwrap_or("")
));
let write = (|| -> Result<(), RemoteError> {
let mut f = std::fs::File::create(&tmp).map_err(|e| map_io(e, &what))?;
f.write_all(&body).map_err(|e| map_io(e, &what))?;
f.sync_all().map_err(|e| map_io(e, &what))
})();
if let Err(e) = write {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
if let Err(e) = std::fs::rename(&tmp, &local) {
let _ = std::fs::remove_file(&tmp);
return Err(map_io(e, &what));
}
// The stub goes only once the content is safely in place. The
// other order risks leaving neither, and in a synced tree an
// absence is a deletion the client would propagate.
if let Some(stub) = replaces {
if let Err(e) = std::fs::remove_file(&stub) {
// The content landed, so the write succeeded; a leftover
// placeholder beside it is untidy rather than harmful, and
// the client reconciles the pair on its next pass.
log::warn!("removing placeholder {}: {e}", stub.display());
}
}
let meta = std::fs::metadata(&local).map_err(|e| map_io(e, &what))?;
Ok(validator_of(&meta))
})
.await
}
/// Delete a file, or an empty directory.
///
/// **Not recursive, unlike WebDAV's `DELETE` on a collection.** The
/// divergence is deliberate: a folder library is the user's own
/// photographs on their own disk, with no server-side trash behind it, so
/// a caller that passed the wrong path would have no way back. Nothing in
/// the engine deletes a directory — the soft delete is a
/// [`move_to`](RemoteBackend::move_to) into the trash folder — so refusing
/// costs nothing and the guard is free.
async fn delete(
&self,
id: &RemoteId,
precond: Option<Precondition>,
) -> Result<(), RemoteError> {
// Deliberately by whichever name is on disk: deleting a photograph
// means deleting it whether or not its content happens to be here, and
// a stub left behind would be re-listed by the next scan.
let (local, _) = self.locate_id(id)?;
blocking(move || {
let what = local.display().to_string();
let meta = std::fs::symlink_metadata(&local).map_err(|e| map_io(e, &what))?;
match &precond {
Some(Precondition::IfMatch(expected)) => {
if &validator_of(&meta) != expected {
return Err(RemoteError::PreconditionFailed);
}
}
// "Delete only if nothing is there" is not a thing to ask of a
// delete; something is there or the `stat` above already
// failed.
Some(Precondition::IfAbsent) => {
return Err(RemoteError::Unsupported(
"IfAbsent is not meaningful on a delete",
))
}
None => {}
}
if meta.is_dir() {
std::fs::remove_dir(&local).map_err(|e| {
if e.kind() == std::io::ErrorKind::DirectoryNotEmpty {
RemoteError::Configuration(format!(
"{what} is not empty; a folder library will not delete a tree"
))
} else {
map_io(e, &what)
}
})
} else {
std::fs::remove_file(&local).map_err(|e| map_io(e, &what))
}
})
.await
}
async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError> {
// Move whichever name exists. Trashing a photograph that is not
// downloaded is a perfectly ordinary thing to do, and it must move the
// stub — renaming a placeholder keeps it a placeholder.
let (src, materialised) = self.locate_id(from)?;
let dst = if materialised {
self.resolve(to)?
} else {
// The destination keeps the placeholder suffix, or the client
// would see a one-byte file appear where a photograph should be.
self.resolve(&RemotePath::new(
self.vfs.placeholder_name(to.as_str()).into_owned(),
))?
};
blocking(move || {
let what = dst.display().to_string();
// Parents first: the trash folder does not exist until the first
// photograph is trashed, and the trait promises this creates it.
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| map_io(e, &parent.display().to_string()))?;
}
match std::fs::rename(&src, &dst) {
Ok(()) => Ok(()),
// EXDEV. Both paths are inside one library root, so this
// needs a root that spans a mount point — a shoot folder
// that is its own mount, which is an ordinary way to attach
// an archive drive. Copy and unlink rather than refusing:
// the identity a rename would have preserved is a path hash
// here, and it changes either way.
Err(e) if e.raw_os_error() == Some(18) => {
std::fs::copy(&src, &dst).map_err(|e| map_io(e, &what))?;
std::fs::remove_file(&src).map_err(|e| {
// The copy landed. Leaving the original is a
// duplicate, which the next scan will show; losing
// the copy would be worse.
let _ = std::fs::remove_file(&dst);
map_io(e, &src.display().to_string())
})
}
Err(e) => Err(map_io(e, &what)),
}
})
.await
}
/// TRACES: FR-NC-6c
/// Ask the sync client to download a placeholder, and wait for it.
///
/// Suffix-mode VFS *renames* on hydration, so completion is the
/// materialised path appearing — not the stub changing size. Polling the
/// original would wait forever.
async fn materialise(&self, id: &RemoteId) -> Result<bool, RemoteError> {
let (local, materialised) = self.locate_id(id)?;
if materialised {
// Already here. Not an error, and not a reason to ask again — and
// `false` is what tells a borrower to leave it alone afterwards.
return Ok(false);
}
let vfs = self.vfs.clone();
let target = self.resolve_id(id)?;
blocking(move || {
vfs.materialise(&local)?;
// The client acknowledges the command, not the transfer, so this
// waits for the file to appear. A bounded wait: a hydration that
// has not landed in this long is one the caller should be told
// about rather than blocked on for ever — the pass can come back
// to it.
let deadline = std::time::Instant::now() + MATERIALISE_TIMEOUT;
while std::time::Instant::now() < deadline {
if target.is_file() {
return Ok(true);
}
std::thread::sleep(POLL);
}
Err(RemoteError::Network(format!(
"{} did not download within {}s",
target.display(),
MATERIALISE_TIMEOUT.as_secs()
)))
})
.await
}
/// TRACES: FR-NC-6c
/// Hand the content back, leaving a placeholder.
///
/// **Never a delete.** In a synced tree removing the file propagates the
/// removal to the server; the client is asked to dehydrate, and if it
/// cannot the content simply stays.
async fn dematerialise(&self, id: &RemoteId) -> Result<(), RemoteError> {
let (local, materialised) = self.locate_id(id)?;
if !materialised {
return Ok(());
}
let vfs = self.vfs.clone();
blocking(move || vfs.dematerialise(&local)).await
}
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError> {
let local = self.resolve(path)?;
blocking(move || {
// `create_dir_all` makes parents and succeeds on one that already
// exists, which is exactly the contract.
std::fs::create_dir_all(&local).map_err(|e| map_io(e, &local.display().to_string()))
})
.await
}
}
#[cfg(test)]
mod tests;
+741
View File
@@ -0,0 +1,741 @@
//! Behaviour of the folder connector, against real directories.
//!
//! No mocks: the whole point of this backend is what a filesystem actually
//! does, and a double would only assert what this file assumes.
use super::*;
use dr_sync::{scan, RemoteBackend};
use dr_types::FormatFilter;
use std::collections::HashMap;
/// A throwaway library root.
///
/// Under the system temp directory, named for the test, and cleared first so a
/// crashed run cannot leave state that makes the next one pass.
struct Tmp(PathBuf);
impl Tmp {
fn new(name: &str) -> Self {
let d = std::env::temp_dir().join(format!("dr-folder-test-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
Tmp(d)
}
fn file(&self, rel: &str, body: &[u8]) -> &Self {
let p = self.0.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
self
}
fn backend(&self) -> FolderBackend {
FolderBackend::new(&self.0).unwrap()
}
}
impl Drop for Tmp {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn names(entries: &[RemoteEntry]) -> Vec<String> {
let mut v: Vec<String> = entries.iter().map(|e| e.path.name().to_string()).collect();
v.sort();
v
}
// --- opening --------------------------------------------------------------
#[test]
fn a_missing_folder_is_a_configuration_error_not_a_network_one() {
// It must not put the app into offline mode: nothing was unreachable, the
// account names somewhere that is not a folder.
let err = FolderBackend::new("/definitely/not/here").unwrap_err();
assert!(matches!(err, RemoteError::Configuration(_)), "{err:?}");
assert!(!err.indicates_offline());
}
// --- listing --------------------------------------------------------------
#[tokio::test]
async fn listing_reports_files_and_directories() {
let t = Tmp::new("list");
t.file("a.CR2", b"raw").file("sub/b.CR2", b"raw");
let b = t.backend();
let root = b.list(&RemotePath::root(), None).await.unwrap();
assert_eq!(names(&root), vec!["a.CR2", "sub"]);
let kinds: HashMap<_, _> = root
.iter()
.map(|e| (e.path.name().to_string(), e.kind))
.collect();
assert_eq!(kinds["a.CR2"], EntryKind::File);
assert_eq!(kinds["sub"], EntryKind::Directory);
let sub = b.list(&RemotePath::new("sub"), None).await.unwrap();
assert_eq!(names(&sub), vec!["b.CR2"]);
// Paths are rooted at the library, not at the filesystem.
assert_eq!(sub[0].path.as_str(), "sub/b.CR2");
}
#[tokio::test]
async fn a_listing_carries_the_size_a_scan_needs() {
let t = Tmp::new("size");
t.file("a.CR2", &[7u8; 1234]);
let e = &t.backend().list(&RemotePath::root(), None).await.unwrap()[0];
assert_eq!(e.size, 1234);
assert!(e.modified.is_some());
// Nothing behind a folder renders anything.
assert!(!e.has_preview);
}
#[tokio::test]
async fn listing_a_missing_directory_is_not_found() {
let t = Tmp::new("missing");
let e = t
.backend()
.list(&RemotePath::new("nope"), None)
.await
.unwrap_err();
assert!(matches!(e, RemoteError::NotFound(_)), "{e:?}");
}
// --- identity and validators ---------------------------------------------
#[tokio::test]
async fn identity_is_stable_across_an_edit_but_not_across_a_rename() {
// The catalog keys thumbnails and faces on this id, so editing a file must
// not orphan its thumbnail. A rename is a different photograph as far as
// this backend can tell, which `Capabilities::stable_ids` reports.
let t = Tmp::new("identity");
t.file("a.CR2", b"one");
let b = t.backend();
let before = b.list(&RemotePath::root(), None).await.unwrap()[0]
.id
.clone();
t.file("a.CR2", b"two-different-length");
let after = b.list(&RemotePath::root(), None).await.unwrap()[0]
.id
.clone();
assert_eq!(before, after, "an edit is not a new photograph");
std::fs::rename(t.0.join("a.CR2"), t.0.join("b.CR2")).unwrap();
let renamed = b.list(&RemotePath::root(), None).await.unwrap()[0]
.id
.clone();
assert_ne!(before, renamed);
assert!(!b.capabilities().stable_ids, "and the capability says so");
}
#[tokio::test]
async fn two_libraries_agree_on_the_identity_of_the_same_photograph() {
// Two devices mounting one share must key the thumbnail index the same
// way, or each re-derives what the other already stored. This is why the
// id is a path hash and not an inode.
let a = Tmp::new("id-a");
let b = Tmp::new("id-b");
a.file("2026/x.CR2", b"one");
b.file("2026/x.CR2", b"quite different bytes");
let ida = a
.backend()
.list(&RemotePath::new("2026"), None)
.await
.unwrap()[0]
.id
.clone();
let idb = b
.backend()
.list(&RemotePath::new("2026"), None)
.await
.unwrap()[0]
.id
.clone();
assert_eq!(ida, idb);
}
#[tokio::test]
async fn a_validator_changes_when_the_content_does() {
let t = Tmp::new("validator");
t.file("a.CR2", b"one");
let b = t.backend();
let before = b.list(&RemotePath::root(), None).await.unwrap()[0]
.validator
.clone();
// A different length, so this holds on a filesystem with one-second mtime
// granularity as well as on one with nanoseconds.
t.file("a.CR2", b"a rather longer body");
let after = b.list(&RemotePath::root(), None).await.unwrap()[0]
.validator
.clone();
assert_ne!(before, after);
}
#[tokio::test]
async fn a_folder_does_not_pretend_to_prune() {
// Answering with the directory's own mtime would let a caller skip a
// subtree whose files had been edited, hiding those edits indefinitely.
let t = Tmp::new("prune");
let b = t.backend();
assert!(matches!(
b.dir_validator(&RemotePath::root()).await,
Err(RemoteError::Unsupported(_))
));
assert_eq!(
b.capabilities().change_detection,
ChangeDetection::LocalEtags
);
}
// --- reading --------------------------------------------------------------
#[tokio::test]
async fn a_whole_file_and_a_range_both_read() {
let t = Tmp::new("get");
t.file("a.CR2", b"0123456789");
let b = t.backend();
let id = RemoteId::Path(RemotePath::new("a.CR2"));
assert_eq!(b.get(&id, None).await.unwrap(), b"0123456789");
assert_eq!(b.get(&id, Some(2..5)).await.unwrap(), b"234");
}
#[tokio::test]
async fn a_range_past_the_end_returns_what_is_there() {
// The header extractor asks for a fixed window; a small JPEG is shorter
// than it, and failing would make every small file undatable.
let t = Tmp::new("shortrange");
t.file("a.JPG", b"abc");
let got = t
.backend()
.get(&RemoteId::Path(RemotePath::new("a.JPG")), Some(0..65536))
.await
.unwrap();
assert_eq!(got, b"abc");
}
#[tokio::test]
async fn a_bare_identity_cannot_address_a_file() {
// Same contract as the Nextcloud connector: the id says *which*
// photograph, the path says *where*. Callers hold both.
let t = Tmp::new("byid");
t.file("a.CR2", b"x");
let e = t
.backend()
.get(&RemoteId::Stable(1), None)
.await
.unwrap_err();
assert!(matches!(e, RemoteError::Unsupported(_)), "{e:?}");
}
#[tokio::test]
async fn nothing_reachable_from_a_remote_path_escapes_the_library() {
// A `RemotePath` is built from names on the remote and from a catalog
// another device wrote. Resolving one against a real filesystem with the
// user's own permissions makes `..` a read of anything they own.
let t = Tmp::new("escape");
let b = t.backend();
for attempt in ["../../../etc/passwd", "sub/../../outside"] {
let e = b
.get(&RemoteId::Path(RemotePath::new(attempt)), None)
.await
.unwrap_err();
assert!(
matches!(e, RemoteError::Configuration(_)),
"{attempt} was not refused: {e:?}"
);
}
}
// --- writing --------------------------------------------------------------
#[tokio::test]
async fn a_write_creates_the_folders_it_needs() {
let t = Tmp::new("put");
let b = t.backend();
b.put(&RemotePath::new("2026/03/a.xmp"), b"<x/>".to_vec(), None)
.await
.unwrap();
assert_eq!(std::fs::read(t.0.join("2026/03/a.xmp")).unwrap(), b"<x/>");
}
#[tokio::test]
async fn a_write_leaves_no_temporary_behind() {
// The rename-into-place is invisible from outside, and must stay that way:
// a stray `.darkroom-tmp` in a shoot folder would be listed by the scan.
let t = Tmp::new("puttmp");
let b = t.backend();
b.put(&RemotePath::new("a.xmp"), b"x".to_vec(), None)
.await
.unwrap();
assert_eq!(
names(&b.list(&RemotePath::root(), None).await.unwrap()),
vec!["a.xmp"]
);
}
#[tokio::test]
async fn an_overwrite_replaces_rather_than_appends() {
let t = Tmp::new("overwrite");
t.file("a.xmp", b"the older and much longer body");
let b = t.backend();
b.put(&RemotePath::new("a.xmp"), b"new".to_vec(), None)
.await
.unwrap();
assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"new");
}
#[tokio::test]
async fn if_absent_creates_once_and_refuses_after() {
let t = Tmp::new("ifabsent");
let b = t.backend();
let p = RemotePath::new("a.xmp");
b.put(&p, b"first".to_vec(), Some(Precondition::IfAbsent))
.await
.unwrap();
let e = b
.put(&p, b"second".to_vec(), Some(Precondition::IfAbsent))
.await
.unwrap_err();
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"first");
}
#[tokio::test]
async fn if_match_writes_on_the_expected_version_and_refuses_a_stale_one() {
// The sidecar conflict path (ARCH §8.5): a failure here means another
// device wrote first, and triggers a merge rather than an overwrite.
let t = Tmp::new("ifmatch");
t.file("a.xmp", b"one");
let b = t.backend();
let p = RemotePath::new("a.xmp");
let current = b.list(&RemotePath::root(), None).await.unwrap()[0]
.validator
.clone();
let after = b
.put(
&p,
b"two".to_vec(),
Some(Precondition::IfMatch(current.clone())),
)
.await
.unwrap();
assert_ne!(after, current);
let e = b
.put(&p, b"three".to_vec(), Some(Precondition::IfMatch(current)))
.await
.unwrap_err();
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"two");
}
#[tokio::test]
async fn the_validator_a_write_returns_is_the_one_a_listing_reports() {
// Otherwise the next conditional write fails against a file nobody else
// touched, and every sidecar update becomes a spurious conflict.
let t = Tmp::new("putvalidator");
let b = t.backend();
let p = RemotePath::new("a.xmp");
let written = b.put(&p, b"body".to_vec(), None).await.unwrap();
let listed = b.list(&RemotePath::root(), None).await.unwrap()[0]
.validator
.clone();
assert_eq!(written, listed);
}
// --- moving and deleting --------------------------------------------------
#[tokio::test]
async fn a_move_creates_the_trash_folder_it_needs() {
// The soft delete (FR-CAT-15): the trash does not exist until the first
// photograph goes into it, and the trait promises the move makes it.
let t = Tmp::new("move");
t.file("a.CR2", b"raw");
let b = t.backend();
b.move_to(
&RemoteId::Path(RemotePath::new("a.CR2")),
&RemotePath::new(".darkroom-trash/a.CR2"),
)
.await
.unwrap();
assert!(!t.0.join("a.CR2").exists());
assert_eq!(
std::fs::read(t.0.join(".darkroom-trash/a.CR2")).unwrap(),
b"raw"
);
}
#[tokio::test]
async fn deleting_a_file_removes_it() {
let t = Tmp::new("delete");
t.file("a.CR2", b"raw");
let b = t.backend();
b.delete(&RemoteId::Path(RemotePath::new("a.CR2")), None)
.await
.unwrap();
assert!(!t.0.join("a.CR2").exists());
}
#[tokio::test]
async fn deleting_refuses_to_take_a_tree_with_it() {
// Deliberately unlike WebDAV. There is no server-side trash behind a local
// folder, so a caller with a wrong path would have no way back.
let t = Tmp::new("deletetree");
t.file("shoot/a.CR2", b"raw");
let e = t
.backend()
.delete(&RemoteId::Path(RemotePath::new("shoot")), None)
.await
.unwrap_err();
assert!(matches!(e, RemoteError::Configuration(_)), "{e:?}");
assert!(t.0.join("shoot/a.CR2").exists());
}
#[tokio::test]
async fn a_conditional_delete_refuses_a_file_that_changed() {
let t = Tmp::new("deletecond");
t.file("a.CR2", b"raw");
let b = t.backend();
let stale = Validator::new("0-0.0");
let e = b
.delete(
&RemoteId::Path(RemotePath::new("a.CR2")),
Some(Precondition::IfMatch(stale)),
)
.await
.unwrap_err();
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
assert!(t.0.join("a.CR2").exists());
}
#[tokio::test]
async fn creating_a_directory_twice_succeeds() {
// Callers use this to guarantee a destination, not to claim they made it.
let t = Tmp::new("mkdir");
let b = t.backend();
let p = RemotePath::new("2026/03");
b.create_dir(&p).await.unwrap();
b.create_dir(&p).await.unwrap();
assert!(t.0.join("2026/03").is_dir());
}
// --- driven by the engine -------------------------------------------------
#[tokio::test]
async fn the_scan_engine_walks_a_folder_library() {
// The claim this whole crate makes: the engine written for one backend
// drives another with no change. Nothing below is folder-specific.
let t = Tmp::new("scan");
t.file("2026/03/a.CR2", b"raw")
.file("2026/03/b.JPG", b"jpeg")
.file("2026/04/c.CR2", b"raw")
.file("2026/notes.txt", b"text")
.file(".darkroom-trash/deleted.CR2", b"raw");
let result = scan(
&t.backend(),
&RemotePath::root(),
&FormatFilter::from_formats([dr_types::Format::Cr2]),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
let found: Vec<&str> = result.images.iter().map(|e| e.path.as_str()).collect();
// The filter picked the RAWs; the trash was skipped, or the soft delete
// would undo itself on the next scan.
assert_eq!(found, vec!["2026/03/a.CR2", "2026/04/c.CR2"]);
assert_eq!(result.progress.directories_pruned, 0, "nothing to prune");
}
#[tokio::test]
async fn an_upload_lands_where_the_engine_places_it() {
let t = Tmp::new("upload");
let b = t.backend();
let placed = dr_sync::upload_original(
&b,
&RemotePath::root(),
&["2026".to_string(), "03".to_string()],
"a.CR2",
b"raw".to_vec(),
)
.await
.unwrap();
assert_eq!(placed.path().as_str(), "2026/03/a.CR2");
assert_eq!(std::fs::read(t.0.join("2026/03/a.CR2")).unwrap(), b"raw");
}
// --- the provider ---------------------------------------------------------
#[test]
fn an_endpoint_is_checked_before_an_account_is_written_for_it() {
let t = Tmp::new("provider");
let p = FolderProvider::new();
assert!(p.normalise_endpoint(" ").is_err(), "empty");
assert!(p.normalise_endpoint("Pictures").is_err(), "relative");
assert!(p.normalise_endpoint("/no/such/place").is_err(), "missing");
t.file("a.CR2", b"x");
assert!(
p.normalise_endpoint(&t.0.join("a.CR2").to_string_lossy())
.is_err(),
"a file is not a library"
);
let ok = p.normalise_endpoint(&t.0.to_string_lossy()).unwrap();
assert_eq!(PathBuf::from(&ok), t.0.canonicalize().unwrap());
}
#[test]
fn two_spellings_of_one_folder_become_one_account() {
// Otherwise the same photographs are indexed twice, into two catalogs.
let t = Tmp::new("canonical");
t.file("sub/a.CR2", b"x");
let p = FolderProvider::new();
let direct = p
.normalise_endpoint(&t.0.join("sub").to_string_lossy())
.unwrap();
let roundabout = p
.normalise_endpoint(&t.0.join("sub/../sub").to_string_lossy())
.unwrap();
assert_eq!(direct, roundabout);
}
#[test]
fn a_folder_account_needs_no_credential() {
let p = FolderProvider::new();
assert_eq!(p.sign_in(), SignIn::EndpointOnly);
assert!(!p.sign_in().needs_secret());
let account = p.account_for("/mnt/photos").unwrap();
assert_eq!(account.backend, BACKEND_ID);
assert_eq!(account.endpoint, "/mnt/photos");
assert!(account.login.is_empty());
}
#[test]
fn the_registry_opens_a_folder_account() {
// End to end through the abstraction: an account, a registry, a backend —
// with nothing in between naming this crate.
let t = Tmp::new("registry");
let mut registry = dr_sync::BackendRegistry::new();
registry.register(std::sync::Arc::new(FolderProvider::new()));
let account = Account::new(BACKEND_ID, t.0.to_string_lossy());
let backend = registry.connect(&Connection::new(account, None)).unwrap();
assert_eq!(backend.name(), "Folder");
}
// --- virtual filesystems --------------------------------------------------
//
// A suffix-mode convention, matching the only one Linux supports. The
// behaviour under test is what the *backend* does with it; the borrow cycle
// has its own tests beside the pool.
struct SuffixVfs;
impl Vfs for SuffixVfs {
fn name(&self) -> &'static str {
"suffix"
}
fn is_placeholder(&self, on_disk: &str) -> bool {
on_disk.ends_with(".stub")
}
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str {
on_disk.strip_suffix(".stub").unwrap_or(on_disk)
}
fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Owned(format!("{name}.stub"))
}
}
fn with_stubs(t: &Tmp) -> FolderBackend {
FolderBackend::with_vfs(&t.0, std::sync::Arc::new(SuffixVfs)).unwrap()
}
#[tokio::test]
async fn a_placeholder_is_listed_under_the_photographs_own_name() {
// The catalog records this as `source_ref`, and identity is derived from
// it. Reporting the stub's name gives the same photograph two identities
// and a name no other device recognises.
let t = Tmp::new("vfs-name");
t.file("shoot/IMG_0001.CR2.stub", &[0u8]);
let b = with_stubs(&t);
let entries = b.list(&RemotePath::new("shoot"), None).await.unwrap();
assert_eq!(entries[0].path.as_str(), "shoot/IMG_0001.CR2");
assert!(!entries[0].materialised, "the content is not here");
// One byte is not the photograph's size, and putting it in the catalog
// would claim a 30 MB RAW is a single byte.
assert_eq!(entries[0].size, 0, "unknown, not one");
}
#[tokio::test]
async fn identity_survives_a_download() {
// The failure this prevents: downloading a photograph looked like a
// delete and an add, which orphaned its thumbnail and its face rows.
let t = Tmp::new("vfs-identity");
t.file("a.CR2.stub", &[0u8]);
let b = with_stubs(&t);
let before = b.list(&RemotePath::root(), None).await.unwrap()[0]
.id
.clone();
std::fs::remove_file(t.0.join("a.CR2.stub")).unwrap();
std::fs::write(t.0.join("a.CR2"), vec![3u8; 4096]).unwrap();
let after = b.list(&RemotePath::root(), None).await.unwrap()[0]
.id
.clone();
assert_eq!(before, after, "the same photograph throughout");
}
#[tokio::test]
async fn reading_a_placeholder_is_distinguishable_from_a_missing_file() {
// The distinction the sidecar writer depends on: "not here" is fetchable
// and "not found" means create a new one. Conflating them overwrites an
// existing sidecar with a fresh document.
let t = Tmp::new("vfs-read");
t.file("a.drsc.stub", &[0u8]);
let b = with_stubs(&t);
let stub = b
.get(&RemoteId::Path(RemotePath::new("a.drsc")), None)
.await
.unwrap_err();
assert!(matches!(stub, RemoteError::NotMaterialised(_)), "{stub:?}");
let absent = b
.get(&RemoteId::Path(RemotePath::new("nothing.drsc")), None)
.await
.unwrap_err();
assert!(matches!(absent, RemoteError::NotFound(_)), "{absent:?}");
// And emphatically not the stub's one byte, which is what made a
// dehydrated sidecar parse as an empty document.
assert!(!matches!(stub, RemoteError::NotFound(_)));
}
#[tokio::test]
async fn an_unconditional_write_replaces_a_placeholder() {
// Derived state — shards, the catalog snapshot — lives in the library
// folder, so the client dehydrates it like anything else. Refusing here
// meant sync could never write to a folder it had been away from. The
// whole file is being replaced, so there is nothing in the stub to keep.
let t = Tmp::new("vfs-write");
t.file("a.drsc.stub", &[0u8]);
let b = with_stubs(&t);
b.put(&RemotePath::new("a.drsc"), b"<new/>".to_vec(), None)
.await
.unwrap();
assert_eq!(std::fs::read(t.0.join("a.drsc")).unwrap(), b"<new/>");
// And exactly one file for one document: a leftover stub beside it is a
// conflict the client would resolve in favour of whichever it saw last.
assert!(!t.0.join("a.drsc.stub").exists(), "placeholder left behind");
assert_eq!(
names(&b.list(&RemotePath::root(), None).await.unwrap()),
vec!["a.drsc"]
);
}
#[tokio::test]
async fn a_conditional_write_over_a_placeholder_asks_for_the_content_first() {
// `IfMatch` guards a read-modify-write. A stub's validator describes the
// placeholder, not the document, so nothing here can satisfy it — and
// quietly writing anyway is how the other device's edits are lost.
let t = Tmp::new("vfs-write-cond");
t.file("a.drsc.stub", &[0u8]);
let b = with_stubs(&t);
let e = b
.put(
&RemotePath::new("a.drsc"),
b"<new/>".to_vec(),
Some(Precondition::IfMatch(Validator::new("whatever"))),
)
.await
.unwrap_err();
assert!(matches!(e, RemoteError::NotMaterialised(_)), "{e:?}");
assert!(!t.0.join("a.drsc").exists(), "nothing written");
// And a create-if-absent fails, because the file *is* there — only its
// content is elsewhere.
let e = b
.put(
&RemotePath::new("a.drsc"),
b"<new/>".to_vec(),
Some(Precondition::IfAbsent),
)
.await
.unwrap_err();
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
}
#[tokio::test]
async fn trashing_a_photograph_that_is_not_downloaded_moves_the_placeholder() {
// Culling without downloading is the ordinary way to use a VFS library.
// The stub has to move, and has to stay a stub — leaving it behind means
// the next scan re-lists the image and undoes the delete.
let t = Tmp::new("vfs-trash");
t.file("a.CR2.stub", &[0u8]);
let b = with_stubs(&t);
b.move_to(
&RemoteId::Path(RemotePath::new("a.CR2")),
&RemotePath::new(".darkroom-trash/a.CR2"),
)
.await
.unwrap();
assert!(!t.0.join("a.CR2.stub").exists());
assert!(
t.0.join(".darkroom-trash/a.CR2.stub").is_file(),
"still a stub"
);
}
#[tokio::test]
async fn a_folder_without_a_client_still_lists_and_reads_what_is_there() {
// No hydration available is a degraded mode, not a broken one: the
// materialised half of the library works completely.
let t = Tmp::new("vfs-degraded");
t.file("here.CR2", b"real").file("gone.CR2.stub", &[0u8]);
let b = with_stubs(&t);
assert_eq!(
b.capabilities().materialisation,
dr_sync::Materialisation::Placeholders,
"stubs exist and nothing can fetch them"
);
assert!(!b.capabilities().materialisation.can_materialise());
let got = b
.get(&RemoteId::Path(RemotePath::new("here.CR2")), None)
.await
.unwrap();
assert_eq!(got, b"real");
}
#[test]
fn a_plain_folder_reports_that_everything_it_lists_is_readable() {
let t = Tmp::new("vfs-plain");
assert_eq!(
t.backend().capabilities().materialisation,
dr_sync::Materialisation::Always
);
}
+131
View File
@@ -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());
}
}
+3
View File
@@ -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
+11 -7
View File
@@ -23,8 +23,9 @@ use std::collections::HashMap;
use std::time::Instant;
use dr_plat::PlatformSecretStore;
use dr_sync::{Account, AccountStore, Secret};
use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy};
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, Session, SessionStore};
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, NextcloudProvider};
#[tokio::main]
async fn main() {
@@ -57,17 +58,20 @@ async fn main() {
// Sessions persist across runs: credentials in the platform keyring
// (FR-NC-2), everything else as ordinary config.
let sessions = SessionStore::open(Box::new(PlatformSecretStore::new()));
let sessions = AccountStore::open(Box::new(PlatformSecretStore::new()));
if !sessions.can_remember() {
println!("note: no secrets daemon — sign-in will not persist this session");
}
let existing = sessions
.current()
.filter(|s| s.server == server.trim_end_matches('/'));
.filter(|s| s.endpoint == server.trim_end_matches('/'));
let (session, creds) = match existing {
Some(s) => match sessions.credentials(&s) {
Some(s) => match sessions
.connection(&s, true)
.and_then(|c| Ok(NextcloudProvider::credentials(&c)?))
{
Ok(c) => {
println!("signed in: {}", s.describe());
(s, c)
@@ -235,7 +239,7 @@ fn describe_filter(f: &dr_types::FormatFilter) -> String {
}
/// Run Login Flow v2 and persist the result.
async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredentials) {
async fn sign_in(server: &str, sessions: &AccountStore) -> (Account, AppCredentials) {
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
Ok(c) => c,
Err(e) => {
@@ -270,8 +274,8 @@ async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredenti
creds.login_name.clone()
});
let session = Session::new(&creds, user_id);
match sessions.save(&session, &creds) {
let session = NextcloudProvider::account_from(&creds, user_id);
match sessions.save(&session, Some(&Secret::new(&creds.app_password))) {
Ok(()) => println!(" session saved to {}", sessions.config_path().display()),
Err(e) => eprintln!(" could not persist session: {e}"),
}
+10 -5
View File
@@ -18,7 +18,8 @@
//! and deleted again, which tests creation and costs nothing.
use dr_plat::PlatformSecretStore;
use dr_sync_nextcloud::session::SessionStore;
use dr_sync::AccountStore;
use dr_sync_nextcloud::NextcloudProvider;
#[tokio::main(flavor = "current_thread")]
async fn main() {
@@ -30,15 +31,19 @@ async fn main() {
std::process::exit(2);
};
let sessions = SessionStore::open(Box::new(PlatformSecretStore::new()));
let sessions = AccountStore::open(Box::new(PlatformSecretStore::new()));
let Some(session) = sessions
.current()
.filter(|s| s.server == server.trim_end_matches('/'))
.filter(|s| s.endpoint == server.trim_end_matches('/'))
else {
eprintln!("no stored session for {server}");
std::process::exit(1);
};
let creds = match sessions.credentials(&session) {
let creds = match sessions
.connection(&session, true)
.map_err(|e| e.to_string())
.and_then(|c| NextcloudProvider::credentials(&c).map_err(|e| e.to_string()))
{
Ok(c) => c,
Err(e) => {
eprintln!("credentials: {e}");
@@ -48,7 +53,7 @@ async fn main() {
let url = format!(
"{}/remote.php/dav/files/{}/{}",
session.server.trim_end_matches('/'),
session.endpoint.trim_end_matches('/'),
session.user_id,
path
);
+8 -4
View File
@@ -1,18 +1,22 @@
//! One-shot write probe: PUT a tiny file, report the status, DELETE it.
use dr_plat::PlatformSecretStore;
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::{NextcloudBackend, SessionStore};
use dr_sync::{AccountStore, RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::{NextcloudBackend, NextcloudProvider};
#[tokio::main(flavor = "current_thread")]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let store = SessionStore::open(Box::new(PlatformSecretStore::new()));
let store = AccountStore::open(Box::new(PlatformSecretStore::new()));
let Some(session) = store.current() else {
println!("no stored session");
return;
};
let creds = match store.credentials(&session) {
let creds = match store
.connection(&session, true)
.map_err(|e| e.to_string())
.and_then(|c| NextcloudProvider::credentials(&c).map_err(|e| e.to_string()))
{
Ok(c) => c,
Err(e) => {
println!("credentials: {e}");
@@ -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<DesktopClient>,
}
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);
}
}
+13 -4
View File
@@ -1,4 +1,9 @@
//! Nextcloud connector — the only [`RemoteBackend`] implementation.
//! Nextcloud connector.
//!
//! One of two [`RemoteBackend`] implementations, registered through
//! [`NextcloudProvider`]. What an *account* is no longer lives here — that is
//! [`dr_sync::Account`], which has no server in it — so this crate is the
//! protocol and nothing else.
//!
//! Hand-rolled over `reqwest` rather than built on a WebDAV crate (D7). No
//! mature Nextcloud crate exists, and the operations that matter here are
@@ -16,11 +21,11 @@ use dr_sync::{
pub mod auth;
pub mod desktop_client;
mod propfind;
pub mod session;
pub mod provider;
pub use auth::{AppCredentials, LoginFlow};
pub use desktop_client::DesktopClient;
pub use session::{Session, SessionError, SessionStore};
pub use desktop_client::{DesktopClient, NextcloudVfs};
pub use provider::NextcloudProvider;
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
const CHUNKS: ChunkConstraints = ChunkConstraints {
@@ -68,6 +73,10 @@ impl NextcloudBackend {
// Stock Nextcloud ships no RAW preview provider (ARCH §6.7).
// Probed per-account at setup and upgraded where present.
server_previews: ServerPreviews::CommonFormatsOnly,
// The server answers for everything it lists. Placeholders
// belong to a locally *synced folder*, which is the folder
// connector's business (`desktop_client::NextcloudVfs`).
materialisation: dr_sync::Materialisation::Always,
},
})
}
+3
View File
@@ -92,6 +92,9 @@ pub fn parse_multistatus(xml: &str, base: &str) -> Result<Vec<RemoteEntry>, 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)
+174
View File
@@ -0,0 +1,174 @@
// TRACES: FR-NC-12 | FR-NC-1
//! Registering Nextcloud as a storage backend.
//!
//! The account model this connector used to own now lives in
//! [`dr_sync::account`], where it has no server in it. What is left here is
//! the part that genuinely is Nextcloud: an endpoint is an HTTPS URL, an
//! account is established through Login Flow v2, and the credential is an app
//! password.
//!
//! Nothing above `dr_ui::remote` refers to this type.
use dr_sync::{
Account, BackendProvider, Connection, RemoteBackend, RemoteError, SignIn, LEGACY_BACKEND,
};
use crate::{AppCredentials, NextcloudBackend};
/// The id written to [`Account::backend`] for a Nextcloud account.
///
/// The same string [`dr_sync::LEGACY_BACKEND`] freezes, because every account
/// configured before there was a choice is one of these and must keep the
/// catalog directory it already has.
pub const BACKEND_ID: &str = LEGACY_BACKEND;
/// Registers the Nextcloud connector.
pub struct NextcloudProvider;
impl NextcloudProvider {
/// The account a completed login flow describes.
///
/// `user_id` is the DAV path segment, which is not always the login name:
/// a login can be an email address while the user id is something else,
/// and building `/remote.php/dav/files/<login>/` from the wrong one 404s
/// every request.
pub fn account_from(creds: &AppCredentials, user_id: impl Into<String>) -> Account {
Account::new(BACKEND_ID, creds.server.trim_end_matches('/'))
.with_login(creds.login_name.clone(), user_id)
}
/// The credentials a stored account plus its secret amount to.
///
/// [`AppCredentials`] stays the connector's own type rather than becoming
/// something general: an app password, an OAuth token and a bucket key
/// pair have no useful common shape, and inventing one would produce a
/// wrong answer confidently. The general form is [`Connection`]; this is
/// the translation into what one protocol needs.
pub fn credentials(conn: &Connection) -> Result<AppCredentials, RemoteError> {
Ok(AppCredentials {
server: conn.account.endpoint.clone(),
login_name: conn.account.login.clone(),
app_password: conn.require_secret()?.expose().to_string(),
})
}
}
impl BackendProvider for NextcloudProvider {
fn id(&self) -> &'static str {
BACKEND_ID
}
fn display_name(&self) -> &'static str {
"Nextcloud"
}
fn endpoint_label(&self) -> &'static str {
"Server"
}
fn endpoint_placeholder(&self) -> &'static str {
"https://cloud.example.com"
}
fn sign_in(&self) -> SignIn {
SignIn::Browser
}
/// Normalise a server address typed by hand.
///
/// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than
/// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS,
/// and an unencrypted default would be a security decision made on the
/// user's behalf without telling them.
fn normalise_endpoint(&self, input: &str) -> Result<String, String> {
let s = input.trim().trim_end_matches('/');
if s.is_empty() {
return Err("Enter the address of your Nextcloud server.".into());
}
if s.starts_with("https://") {
Ok(s.to_string())
} else if let Some(rest) = s.strip_prefix("http://") {
// Upgrade rather than accept. If the server genuinely has no TLS
// the connection fails loudly, which is the correct outcome.
Ok(format!("https://{rest}"))
} else {
Ok(format!("https://{s}"))
}
}
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
let creds = Self::credentials(conn)?;
Ok(Box::new(NextcloudBackend::new(
&creds,
&conn.account.user_id,
)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example/".into(),
login_name: "duncan@example.com".into(),
app_password: "token".into(),
}
}
#[test]
fn an_address_typed_by_hand_becomes_an_https_url() {
let p = NextcloudProvider;
assert_eq!(
p.normalise_endpoint("cloud.example.com/").unwrap(),
"https://cloud.example.com"
);
// Upgraded, never accepted: NFR-SEC-3.
assert_eq!(
p.normalise_endpoint("http://cloud.example.com").unwrap(),
"https://cloud.example.com"
);
assert!(p.normalise_endpoint(" ").is_err());
}
#[test]
fn the_account_keeps_the_dav_user_id_apart_from_the_login() {
// A login can be an email address while the user id is something
// else; building the DAV path from the wrong one 404s everything.
let a = NextcloudProvider::account_from(&creds(), "duncan");
assert_eq!(a.login, "duncan@example.com");
assert_eq!(a.user_id, "duncan");
assert_eq!(a.endpoint, "https://cloud.example");
}
#[test]
fn a_nextcloud_account_keeps_its_historical_catalog_directory() {
// Frozen: this names the directory holding the catalog, the thumbnail
// shards and un-uploaded sidecars.
let a = NextcloudProvider::account_from(&creds(), "duncan");
assert_eq!(a.namespace(), "cloud-example-duncan");
}
#[test]
fn connecting_without_a_credential_is_unauthenticated_not_a_crash() {
// A cleared keyring or a revoked app password arrives here as an
// account with no secret. The caller re-runs the login flow.
let account = NextcloudProvider::account_from(&creds(), "duncan");
match NextcloudProvider.connect(&Connection::new(account, None)) {
Err(RemoteError::Unauthenticated) => {}
Err(e) => panic!("wrong error: {e:?}"),
Ok(b) => panic!("connected without a credential as {}", b.name()),
}
}
#[test]
fn a_stored_account_and_its_secret_rebuild_the_credentials() {
let account = NextcloudProvider::account_from(&creds(), "duncan");
let conn = Connection::new(account, Some(dr_sync::Secret::new("token")));
let rebuilt = NextcloudProvider::credentials(&conn).unwrap();
assert_eq!(rebuilt.server, "https://cloud.example");
assert_eq!(rebuilt.login_name, "duncan@example.com");
assert_eq!(rebuilt.app_password, "token");
}
}
-451
View File
@@ -1,451 +0,0 @@
//! Account sessions — logging in once and staying logged in.
//!
//! Splits deliberately in two:
//!
//! - **Credentials** go to platform secure storage (FR-NC-2). Never the
//! catalog, never a file, never a log line.
//! - **Everything else** — server, login, chosen root, format filter — is
//! ordinary configuration, safe to write as plain JSON.
//!
//! That split is what lets the app show "signed in as duncan, watching
//! /PhotosRaw" before it has touched the keyring, and re-authenticate cleanly
//! if the credential has been revoked server-side.
use std::path::{Path, PathBuf};
use dr_plat::{SecretError, SecretRef, SecretStore};
use dr_sync::RemoteError;
use dr_types::{Format, FormatFilter};
use serde::{Deserialize, Serialize};
use crate::AppCredentials;
/// Where configuration is written, when the platform has told us.
///
/// Android has no `$HOME` and no XDG directories, so the guess below resolves
/// to a path the app cannot write. Nothing failed loudly: the session list went
/// to a doomed path, so credentials survived only as long as the process did and
/// backgrounding the app lost the account (ARCH §6.9 — no core API may assume a
/// filesystem path on Android).
///
/// The platform layer sets this once at startup, before any store is opened.
static DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// TRACES: FR-NC-2
/// Declare the per-app directory configuration belongs in.
///
/// Call before opening any store; later calls are ignored rather than racing.
/// On Android this is `AndroidApp::internal_data_path`, which is private to the
/// app and survives being backgrounded. Desktop needs no call — the XDG
/// fallback is correct there.
pub fn set_data_dir(dir: PathBuf) {
let _ = DATA_DIR.set(dir);
}
/// The directory configuration lives in.
fn config_dir() -> PathBuf {
if let Some(d) = DATA_DIR.get() {
return d.clone();
}
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"))
.join("darkroom")
}
/// A configured account, minus its credential.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
pub server: String,
pub login: String,
/// The DAV path segment, which may differ from `login` — a login can be
/// an email address while the user id is something else.
pub user_id: String,
/// The folder chosen as the library root. Empty means the account root.
#[serde(default)]
pub root: String,
/// Which formats the scan looks for (the tick-boxes).
#[serde(default)]
pub formats: Vec<String>,
/// Unix seconds of the last completed scan, for display.
#[serde(default)]
pub last_scan: Option<i64>,
}
impl Session {
pub fn new(creds: &AppCredentials, user_id: impl Into<String>) -> Self {
Self {
server: creds.server.trim_end_matches('/').to_string(),
login: creds.login_name.clone(),
user_id: user_id.into(),
root: String::new(),
formats: Vec::new(),
last_scan: None,
}
}
/// The stored format selection, defaulting to every supported format.
///
/// An unconfigured session must find everything rather than nothing.
pub fn format_filter(&self) -> FormatFilter {
if self.formats.is_empty() {
FormatFilter::all()
} else {
FormatFilter::from_formats(
self.formats
.iter()
.filter_map(|s| Format::from_extension(&s.to_ascii_lowercase())),
)
}
}
pub fn set_format_filter(&mut self, filter: &FormatFilter) {
self.formats = filter
.iter()
.map(|f| format!("{f:?}").to_lowercase())
.collect();
}
/// Where this session's credential lives.
pub fn secret_ref(&self) -> SecretRef {
SecretRef::app_password(&self.server, &self.login)
}
/// A short description for the UI.
pub fn describe(&self) -> String {
let host = self
.server
.trim_start_matches("https://")
.trim_start_matches("http://");
if self.root.is_empty() {
format!("{} on {host}", self.login)
} else {
format!("{} on {host}/{}", self.login, self.root)
}
}
}
/// TRACES: FR-NC-1 | FR-NC-2 | M-1 | M-2
/// Loads and saves sessions, keeping credentials in secure storage.
pub struct SessionStore {
config_path: PathBuf,
secrets: Box<dyn SecretStore>,
}
/// What is written to disk. Versioned so a format change is a migration
/// rather than a parse failure.
#[derive(Debug, Default, Serialize, Deserialize)]
struct ConfigFile {
#[serde(default = "one")]
version: u32,
#[serde(default)]
sessions: Vec<Session>,
}
fn one() -> u32 {
1
}
impl SessionStore {
/// Open the store at the platform config location.
///
/// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to
/// `~/.config` (FR-PLAT-LIN-1).
pub fn open(secrets: Box<dyn SecretStore>) -> Self {
Self::open_at(config_dir().join("sessions.json"), secrets)
}
/// Open at an explicit path — used by tests, and by anything wanting a
/// non-default config location.
/// Where configuration lives, for callers that need to sit files beside it.
pub fn data_dir() -> PathBuf {
config_dir()
}
pub fn open_at(config_path: PathBuf, secrets: Box<dyn SecretStore>) -> Self {
Self {
config_path,
secrets,
}
}
pub fn config_path(&self) -> &Path {
&self.config_path
}
/// Whether credentials can be remembered at all.
///
/// Where false the UI should say sign-in will not persist, rather than
/// letting the user discover it next launch.
pub fn can_remember(&self) -> bool {
self.secrets.is_available()
}
/// Every configured session. Missing or unreadable config yields an empty
/// list rather than an error — a first run is not a failure.
pub fn list(&self) -> Vec<Session> {
self.read_config().sessions
}
/// The most recently configured session, if any.
pub fn current(&self) -> Option<Session> {
self.read_config().sessions.into_iter().next_back()
}
/// Persist a session and its credential.
///
/// The credential goes to secure storage first: if that fails there is no
/// point recording a session that cannot authenticate.
pub fn save(&self, session: &Session, creds: &AppCredentials) -> Result<(), SessionError> {
self.secrets
.store(&session.secret_ref(), &creds.app_password)?;
let mut config = self.read_config();
config
.sessions
.retain(|s| !(s.server == session.server && s.login == session.login));
config.sessions.push(session.clone());
self.write_config(&config)
}
/// Update a session's settings, leaving its credential untouched.
pub fn update(&self, session: &Session) -> Result<(), SessionError> {
let mut config = self.read_config();
match config
.sessions
.iter_mut()
.find(|s| s.server == session.server && s.login == session.login)
{
Some(existing) => *existing = session.clone(),
None => config.sessions.push(session.clone()),
}
self.write_config(&config)
}
/// Rebuild credentials for a session from secure storage.
///
/// [`SecretError::NotFound`] means the credential was revoked or the
/// keyring was cleared — the caller re-runs the login flow.
pub fn credentials(&self, session: &Session) -> Result<AppCredentials, SessionError> {
let password = self.secrets.retrieve(&session.secret_ref())?;
Ok(AppCredentials {
server: session.server.clone(),
login_name: session.login.clone(),
app_password: password,
})
}
/// Forget a session and delete its credential.
///
/// The credential is removed even if the config write fails, so a logout
/// never leaves a usable secret behind.
pub fn forget(&self, session: &Session) -> Result<(), SessionError> {
let deleted = self.secrets.delete(&session.secret_ref());
let mut config = self.read_config();
config
.sessions
.retain(|s| !(s.server == session.server && s.login == session.login));
let written = self.write_config(&config);
deleted?;
written
}
fn read_config(&self) -> ConfigFile {
std::fs::read_to_string(&self.config_path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn write_config(&self, config: &ConfigFile) -> Result<(), SessionError> {
if let Some(parent) = self.config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(config)?;
// Write and rename, so an interrupted save cannot truncate an
// existing config.
let tmp = self.config_path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &self.config_path)?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("secure storage: {0}")]
Secret(#[from] SecretError),
#[error("config io: {0}")]
Io(#[from] std::io::Error),
#[error("config format: {0}")]
Serde(#[from] serde_json::Error),
#[error(transparent)]
Remote(#[from] RemoteError),
}
#[cfg(test)]
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example/".into(),
login_name: "duncan".into(),
app_password: "secret-token".into(),
}
}
fn store_in(dir: &Path) -> SessionStore {
SessionStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
)
}
fn tmpdir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("darkroom-test-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_saved_session_survives_reopening() {
let dir = tmpdir("survives");
let secrets = Box::new(EphemeralSecretStore::new());
// Same secret store instance, as a real process would have.
let store = SessionStore::open_at(dir.join("sessions.json"), secrets);
let mut s = Session::new(&creds(), "duncan");
s.root = "PhotosRaw".into();
store.save(&s, &creds()).unwrap();
let reloaded = store.current().expect("session persisted");
assert_eq!(reloaded.login, "duncan");
assert_eq!(reloaded.root, "PhotosRaw");
// Trailing slash normalised, so URLs built from it are consistent.
assert_eq!(reloaded.server, "https://cloud.example");
}
#[test]
fn the_credential_never_reaches_the_config_file() {
// NFR-SEC-2: the whole point of the split.
let dir = tmpdir("nocreds");
let store = store_in(&dir);
let s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
let text = std::fs::read_to_string(dir.join("sessions.json")).unwrap();
assert!(!text.contains("secret-token"), "credential leaked to disk");
assert!(text.contains("duncan"), "session metadata should be there");
}
#[test]
fn credentials_round_trip_through_secure_storage() {
let dir = tmpdir("roundtrip");
let store = store_in(&dir);
let s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
let got = store.credentials(&s).unwrap();
assert_eq!(got.app_password, "secret-token");
assert_eq!(got.login_name, "duncan");
}
#[test]
fn forgetting_removes_both_halves() {
let dir = tmpdir("forget");
let store = store_in(&dir);
let s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
store.forget(&s).unwrap();
assert!(store.current().is_none());
assert!(matches!(
store.credentials(&s),
Err(SessionError::Secret(SecretError::NotFound))
));
}
#[test]
fn saving_the_same_account_twice_does_not_duplicate_it() {
let dir = tmpdir("dedupe");
let store = store_in(&dir);
let mut s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
s.root = "Photos".into();
store.save(&s, &creds()).unwrap();
assert_eq!(store.list().len(), 1);
assert_eq!(store.current().unwrap().root, "Photos");
}
#[test]
fn a_missing_config_is_a_first_run_not_an_error() {
let dir = tmpdir("firstrun");
let store = store_in(&dir);
assert!(store.list().is_empty());
assert!(store.current().is_none());
}
#[test]
fn a_corrupt_config_does_not_prevent_starting() {
// Better to present a first-run state than to refuse to launch.
let dir = tmpdir("corrupt");
std::fs::write(dir.join("sessions.json"), "{ not json").unwrap();
let store = store_in(&dir);
assert!(store.list().is_empty());
}
#[test]
fn format_selection_round_trips() {
let dir = tmpdir("formats");
let store = store_in(&dir);
let mut s = Session::new(&creds(), "duncan");
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2, Format::Dng]));
store.save(&s, &creds()).unwrap();
let f = store.current().unwrap().format_filter();
assert!(f.allows(Format::Cr2));
assert!(f.allows(Format::Dng));
assert!(!f.allows(Format::Nef));
}
#[test]
fn an_unset_filter_means_every_format() {
// Never "no formats", which would silently find nothing.
let s = Session::new(&creds(), "duncan");
let f = s.format_filter();
assert!(f.allows(Format::Cr2));
assert!(f.allows(Format::Jpeg));
}
#[test]
fn describe_is_readable_and_hides_the_scheme() {
let mut s = Session::new(&creds(), "duncan");
assert_eq!(s.describe(), "duncan on cloud.example");
s.root = "PhotosRaw".into();
assert_eq!(s.describe(), "duncan on cloud.example/PhotosRaw");
}
#[test]
fn updating_settings_leaves_the_credential_alone() {
let dir = tmpdir("update");
let store = store_in(&dir);
let mut s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
s.root = "Elsewhere".into();
store.update(&s).unwrap();
assert_eq!(store.current().unwrap().root, "Elsewhere");
assert_eq!(store.credentials(&s).unwrap().app_password, "secret-token");
}
}
+5
View File
@@ -7,7 +7,12 @@ license.workspace = true
[dependencies]
dr-types.workspace = true
# Accounts keep their credential in platform secure storage, never in the
# config file they are otherwise written to (NFR-SEC-2).
dr-plat.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
+804
View File
@@ -0,0 +1,804 @@
// TRACES: FR-NC-12 | FR-NC-2
//! What a configured library *is*, with no connector in it.
//!
//! Before this existed, "an account" meant a Nextcloud server URL, a login
//! name and a DAV user id, and that shape reached every layer above:
//! `dr-ui` stored it, keyed its caches off it, threaded it through a dozen
//! worker threads and handed it to a constructor named after one product.
//! [`RemoteBackend`](crate::RemoteBackend) was abstract; everything that
//! *reached* a backend was not, so a second connector had nowhere to live.
//!
//! An [`Account`] is what remains once the product is taken out: somewhere a
//! library lives ([`endpoint`](Account::endpoint)), a folder inside it
//! ([`root`](Account::root)), and the settings the scan needs. What an
//! endpoint means is the connector's business — a URL for Nextcloud, a
//! directory for a plain folder, a bucket for whatever comes next.
//!
//! # The split that has to survive
//!
//! Credentials go to platform secure storage (FR-NC-2). Never the catalog,
//! never a file, never a log line. Everything else is ordinary configuration
//! written as plain JSON. That split is what lets the app show "signed in as
//! duncan, watching /PhotosRaw" before it has touched the keyring — and it is
//! why a [`Connection`] carries the two halves separately rather than as one
//! blob.
use std::path::{Path, PathBuf};
use dr_plat::{SecretError, SecretRef, SecretStore};
use dr_types::{Format, FormatFilter};
use serde::{Deserialize, Serialize};
use crate::RemoteError;
/// The connector every account had before there was a choice.
///
/// Named here, in connector-neutral code, for exactly one reason:
/// [`Account::namespace`] must keep producing the same string for these
/// accounts as the hard-coded Nextcloud version did. That string is a
/// directory name holding a catalog, thumbnail shards, un-uploaded sidecars
/// and an export outbox. Changing it does not lose that data, it *abandons*
/// it — silently, as an upgrade — and costs a full rescan of the library on
/// top.
///
/// Nothing else in this crate branches on a connector's identity, and nothing
/// else should.
pub const LEGACY_BACKEND: &str = "nextcloud";
/// Where configuration is written, when the platform has told us.
///
/// Android has no `$HOME` and no XDG directories, so the guess below resolves
/// to a path the app cannot write. Nothing failed loudly: the account list went
/// to a doomed path, so credentials survived only as long as the process did and
/// backgrounding the app lost the account (ARCH §6.9 — no core API may assume a
/// filesystem path on Android).
///
/// The platform layer sets this once at startup, before any store is opened.
static DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// TRACES: FR-NC-2
/// Declare the per-app directory configuration belongs in.
///
/// Call before opening any store; later calls are ignored rather than racing.
/// On Android this is `AndroidApp::internal_data_path`, which is private to the
/// app and survives being backgrounded. Desktop needs no call — the XDG
/// fallback is correct there.
pub fn set_data_dir(dir: PathBuf) {
let _ = DATA_DIR.set(dir);
}
/// The directory configuration lives in.
pub fn config_dir() -> PathBuf {
if let Some(d) = DATA_DIR.get() {
return d.clone();
}
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"))
.join("darkroom")
}
/// TRACES: FR-NC-12
/// A configured library, minus its credential.
///
/// Every field but [`backend`](Self::backend) is interpreted by the connector
/// that owns it. Code above this layer reads them for display and for cache
/// keys and never for meaning.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
/// Which connector serves this library, as
/// [`BackendProvider::id`](crate::BackendProvider::id).
///
/// Defaulted rather than required, because every account written before
/// there was a choice omits it and every one of them is a Nextcloud
/// account. A missing field here must load, not fail — a config the app
/// refuses to parse is an account the user has to set up again.
#[serde(default = "legacy_backend")]
pub backend: String,
/// Where the library lives, in whatever form the connector addresses:
/// `https://cloud.example` for Nextcloud, `/mnt/photos` for a folder.
///
/// Stored under its historical name so existing configuration loads
/// unchanged.
#[serde(rename = "server")]
pub endpoint: String,
/// Who we are, where that means anything. Empty for connectors with no
/// notion of a user — it is shown, and used to key the credential.
#[serde(default)]
pub login: String,
/// A connector-defined sub-address. Nextcloud's DAV path segment, which
/// may differ from `login` because a login can be an email address while
/// the user id is something else. Empty where the connector has no use
/// for one.
#[serde(default)]
pub user_id: String,
/// The folder chosen as the library root, relative to the endpoint. Empty
/// means the endpoint itself.
#[serde(default)]
pub root: String,
/// Which formats the scan looks for (the tick-boxes).
#[serde(default)]
pub formats: Vec<String>,
/// Unix seconds of the last completed scan, for display.
#[serde(default)]
pub last_scan: Option<i64>,
}
fn legacy_backend() -> String {
LEGACY_BACKEND.to_string()
}
impl Account {
/// A bare account for `backend` at `endpoint`, with nothing chosen yet.
pub fn new(backend: impl Into<String>, endpoint: impl Into<String>) -> Self {
Self {
backend: backend.into(),
endpoint: endpoint.into(),
login: String::new(),
user_id: String::new(),
root: String::new(),
formats: Vec::new(),
last_scan: None,
}
}
pub fn with_login(mut self, login: impl Into<String>, user_id: impl Into<String>) -> Self {
self.login = login.into();
self.user_id = user_id.into();
self
}
/// Whether two records name the same account.
///
/// The identity the store deduplicates on. Endpoint and login together,
/// because one server can hold two accounts and one machine can hold two
/// folders — but the *same* pair twice is the same library reconfigured,
/// not a second one.
pub fn is_same_as(&self, other: &Account) -> bool {
self.backend == other.backend
&& self.endpoint == other.endpoint
&& self.login == other.login
}
/// The stored format selection, defaulting to every supported format.
///
/// An unconfigured account must find everything rather than nothing.
pub fn format_filter(&self) -> FormatFilter {
if self.formats.is_empty() {
FormatFilter::all()
} else {
FormatFilter::from_formats(
self.formats
.iter()
.filter_map(|s| Format::from_extension(&s.to_ascii_lowercase())),
)
}
}
pub fn set_format_filter(&mut self, filter: &FormatFilter) {
self.formats = filter
.iter()
.map(|f| format!("{f:?}").to_lowercase())
.collect();
}
/// Where this account's credential lives, for connectors that need one.
pub fn secret_ref(&self) -> SecretRef {
SecretRef::app_password(&self.endpoint, &self.login)
}
/// A short description for the UI.
///
/// Reads for both shapes without asking the connector: "duncan on
/// cloud.example/PhotosRaw" where there is a login, and just the location
/// where there is not — a folder library has no user to name, and
/// inventing one ("(local) on /mnt/photos") would be worse than saying
/// where it is.
pub fn describe(&self) -> String {
let place = self
.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://");
let place = if self.root.is_empty() {
place.to_string()
} else {
format!("{}/{}", place.trim_end_matches('/'), self.root)
};
if self.login.is_empty() {
place
} else {
format!("{} on {place}", self.login)
}
}
/// TRACES: FR-NC-10 | NFR-R1
/// The directory name this account's local data hangs off.
///
/// Not a display string and not stable across a change of endpoint: it is
/// the key for the catalog, the thumbnail shards, the sidecar spool and
/// the export outbox. Two accounts must never collide here — one would
/// index the other's library — and one account must produce the same
/// answer on every launch, forever, or its data is abandoned in place.
///
/// The Nextcloud form is reproduced byte for byte from what
/// `catalog_path` computed before accounts were multi-backend
/// ([`LEGACY_BACKEND`]). Everything else is prefixed by its connector, so
/// a folder library at `/srv/photos` and a hypothetical S3 bucket of the
/// same name cannot land in one directory.
pub fn namespace(&self) -> String {
let slug = slugify(
self.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://"),
);
if self.backend == LEGACY_BACKEND {
// Frozen. See LEGACY_BACKEND.
return format!("{slug}-{}", self.user_id);
}
let tail = if self.user_id.is_empty() {
String::new()
} else {
format!("-{}", slugify(&self.user_id))
};
let name = format!("{}-{slug}{tail}", slugify(&self.backend));
shorten(&name)
}
}
/// Everything that is not `[A-Za-z0-9]`, flattened to `-`.
///
/// Not an escape and not reversible: the result names a directory, and the
/// only property it needs is that it is a legal filename on every platform
/// the app runs on.
fn slugify(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
/// Cap a namespace at a length every filesystem accepts.
///
/// A folder endpoint is an absolute path and can be far longer than a server
/// URL — deep enough to exceed the 255-byte component limit on ext4 and APFS
/// alike, at which point creating the catalog directory fails and the library
/// cannot be opened at all. Truncating alone would make two deep paths under
/// one parent collide, so the discarded tail is replaced by a hash of the
/// whole.
fn shorten(name: &str) -> String {
const MAX: usize = 96;
if name.len() <= MAX {
return name.to_string();
}
let head: String = name.chars().take(MAX - 17).collect();
format!("{head}-{:016x}", fnv1a64(name.as_bytes()))
}
/// FNV-1a, 64-bit.
///
/// Written out rather than taken from `DefaultHasher`, whose output is
/// explicitly not stable between Rust releases. This one keys a directory that
/// must be found again after a toolchain upgrade.
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
/// TRACES: FR-NC-2 | NFR-SEC-2
/// A credential, kept out of logs by construction.
///
/// The inner string is reachable only through [`expose`](Secret::expose), so
/// the ways a secret leaks — a `{:?}` on a struct that happens to contain one,
/// a `Display` in an error message — do not compile into a leak. NFR-SEC-2 is
/// the requirement; this is the part of it that a reviewer cannot forget to
/// apply.
#[derive(Clone, PartialEq, Eq)]
pub struct Secret(String);
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Secret(value.into())
}
/// The credential itself. Every call site is a place to check.
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(***)")
}
}
/// Everything needed to open a backend, in one movable value.
///
/// Workers run on their own threads and each one needs its own way in, so this
/// is `Clone` and owns what it holds. It replaced a pair of arguments —
/// credentials and a user id — that had to be threaded together through
/// fifteen functions and could be passed in the wrong order.
#[derive(Debug, Clone)]
pub struct Connection {
pub account: Account,
/// `None` where the connector needs no credential, which is the ordinary
/// state of a folder library rather than a failure to load one.
pub secret: Option<Secret>,
}
impl Connection {
pub fn new(account: Account, secret: Option<Secret>) -> Self {
Self { account, secret }
}
/// The credential, or [`RemoteError::Unauthenticated`].
///
/// For connectors that require one: turning the absence into the error the
/// caller already handles saves every implementation writing the same
/// `ok_or`.
pub fn require_secret(&self) -> Result<&Secret, RemoteError> {
self.secret.as_ref().ok_or(RemoteError::Unauthenticated)
}
}
/// TRACES: FR-NC-1 | FR-NC-2 | M-1 | M-2
/// Loads and saves accounts, keeping credentials in secure storage.
pub struct AccountStore {
config_path: PathBuf,
secrets: Box<dyn SecretStore>,
}
/// What is written to disk. Versioned so a format change is a migration
/// rather than a parse failure.
#[derive(Debug, Default, Serialize, Deserialize)]
struct ConfigFile {
#[serde(default = "one")]
version: u32,
/// Named `sessions` on disk because that is what it has always been
/// called there, and renaming the key would orphan every existing config.
#[serde(default)]
sessions: Vec<Account>,
}
fn one() -> u32 {
1
}
impl AccountStore {
/// Open the store at the platform config location.
///
/// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to
/// `~/.config` (FR-PLAT-LIN-1).
pub fn open(secrets: Box<dyn SecretStore>) -> Self {
Self::open_at(config_dir().join("sessions.json"), secrets)
}
/// Where configuration lives, for callers that need to sit files beside it.
pub fn data_dir() -> PathBuf {
config_dir()
}
/// Open at an explicit path — used by tests, and by anything wanting a
/// non-default config location.
pub fn open_at(config_path: PathBuf, secrets: Box<dyn SecretStore>) -> Self {
Self {
config_path,
secrets,
}
}
pub fn config_path(&self) -> &Path {
&self.config_path
}
/// Whether credentials can be remembered at all.
///
/// Where false the UI should say sign-in will not persist, rather than
/// letting the user discover it next launch.
pub fn can_remember(&self) -> bool {
self.secrets.is_available()
}
/// Every configured account. Missing or unreadable config yields an empty
/// list rather than an error — a first run is not a failure.
pub fn list(&self) -> Vec<Account> {
self.read_config().sessions
}
/// The most recently configured account, if any.
pub fn current(&self) -> Option<Account> {
self.read_config().sessions.into_iter().next_back()
}
/// Persist an account and its credential.
///
/// The credential goes to secure storage first: if that fails there is no
/// point recording an account that cannot authenticate. `None` is the
/// ordinary case for a connector that needs no credential, and stores
/// nothing rather than an empty secret.
pub fn save(&self, account: &Account, secret: Option<&Secret>) -> Result<(), AccountError> {
if let Some(s) = secret {
self.secrets.store(&account.secret_ref(), s.expose())?;
}
let mut config = self.read_config();
config.sessions.retain(|a| !a.is_same_as(account));
config.sessions.push(account.clone());
self.write_config(&config)
}
/// Update an account's settings, leaving its credential untouched.
pub fn update(&self, account: &Account) -> Result<(), AccountError> {
let mut config = self.read_config();
match config.sessions.iter_mut().find(|a| a.is_same_as(account)) {
Some(existing) => *existing = account.clone(),
None => config.sessions.push(account.clone()),
}
self.write_config(&config)
}
/// Rebuild a connection for an account, fetching its credential.
///
/// `needs_secret` is the connector's answer, passed in rather than
/// inferred: an account with an empty login might be a folder library or
/// might be a broken Nextcloud record, and guessing turns the second into
/// a silent unauthenticated connection instead of an error the user can
/// act on.
///
/// [`SecretError::NotFound`] means the credential was revoked or the
/// keyring was cleared — the caller re-runs the sign-in.
pub fn connection(
&self,
account: &Account,
needs_secret: bool,
) -> Result<Connection, AccountError> {
let secret = if needs_secret {
Some(Secret::new(self.secrets.retrieve(&account.secret_ref())?))
} else {
None
};
Ok(Connection::new(account.clone(), secret))
}
/// Forget an account and delete its credential.
///
/// The credential is removed even if the config write fails, so a logout
/// never leaves a usable secret behind. A connector that stores none
/// reports [`SecretError::NotFound`], which is not a failure to forget.
pub fn forget(&self, account: &Account) -> Result<(), AccountError> {
let deleted = match self.secrets.delete(&account.secret_ref()) {
Err(SecretError::NotFound) => Ok(()),
other => other,
};
let mut config = self.read_config();
config.sessions.retain(|a| !a.is_same_as(account));
let written = self.write_config(&config);
deleted?;
written
}
fn read_config(&self) -> ConfigFile {
std::fs::read_to_string(&self.config_path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn write_config(&self, config: &ConfigFile) -> Result<(), AccountError> {
if let Some(parent) = self.config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(config)?;
// Write and rename, so an interrupted save cannot truncate an
// existing config.
let tmp = self.config_path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &self.config_path)?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum AccountError {
#[error("secure storage: {0}")]
Secret(#[from] SecretError),
#[error("config io: {0}")]
Io(#[from] std::io::Error),
#[error("config format: {0}")]
Serde(#[from] serde_json::Error),
#[error(transparent)]
Remote(#[from] RemoteError),
}
#[cfg(test)]
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
fn nextcloud() -> Account {
Account::new(LEGACY_BACKEND, "https://cloud.example").with_login("duncan", "duncan")
}
fn folder() -> Account {
Account::new("folder", "/mnt/photos")
}
fn store_in(dir: &Path) -> AccountStore {
AccountStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
)
}
fn tmpdir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("darkroom-account-test-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_saved_account_survives_reopening() {
let dir = tmpdir("survives");
let store = store_in(&dir);
let mut a = nextcloud();
a.root = "PhotosRaw".into();
store.save(&a, Some(&Secret::new("token"))).unwrap();
let reloaded = store.current().expect("account persisted");
assert_eq!(reloaded.login, "duncan");
assert_eq!(reloaded.root, "PhotosRaw");
}
#[test]
fn the_credential_never_reaches_the_config_file() {
// NFR-SEC-2: the whole point of the split.
let dir = tmpdir("nocreds");
let store = store_in(&dir);
store
.save(&nextcloud(), Some(&Secret::new("secret-token")))
.unwrap();
let text = std::fs::read_to_string(dir.join("sessions.json")).unwrap();
assert!(!text.contains("secret-token"), "credential leaked to disk");
assert!(text.contains("duncan"), "account metadata should be there");
}
#[test]
fn a_secret_does_not_print_itself() {
// The leak this closes is indirect: a `{:?}` on any struct holding a
// connection used to print the app password.
let c = Connection::new(nextcloud(), Some(Secret::new("hunter2")));
let printed = format!("{c:?}");
assert!(!printed.contains("hunter2"), "credential leaked to a log");
}
#[test]
fn credentials_round_trip_through_secure_storage() {
let dir = tmpdir("roundtrip");
let store = store_in(&dir);
let a = nextcloud();
store.save(&a, Some(&Secret::new("secret-token"))).unwrap();
let conn = store.connection(&a, true).unwrap();
assert_eq!(conn.require_secret().unwrap().expose(), "secret-token");
}
#[test]
fn a_credentialless_account_connects_without_touching_the_keyring() {
// A folder library must open on a machine with no secrets daemon at
// all — asking for a credential it does not have would fail the one
// backend that needs nothing.
let dir = tmpdir("nosecret");
let store = store_in(&dir);
let a = folder();
store.save(&a, None).unwrap();
let conn = store.connection(&a, false).unwrap();
assert!(conn.secret.is_none());
assert!(matches!(
conn.require_secret(),
Err(RemoteError::Unauthenticated)
));
}
#[test]
fn forgetting_removes_both_halves() {
let dir = tmpdir("forget");
let store = store_in(&dir);
let a = nextcloud();
store.save(&a, Some(&Secret::new("token"))).unwrap();
store.forget(&a).unwrap();
assert!(store.current().is_none());
assert!(matches!(
store.connection(&a, true),
Err(AccountError::Secret(SecretError::NotFound))
));
}
#[test]
fn forgetting_a_credentialless_account_is_not_an_error() {
// There is no secret to delete, and reporting the absence as a failure
// would leave a folder library that cannot be signed out of.
let dir = tmpdir("forget-folder");
let store = store_in(&dir);
let a = folder();
store.save(&a, None).unwrap();
store.forget(&a).unwrap();
assert!(store.current().is_none());
}
#[test]
fn two_backends_at_the_same_endpoint_are_two_accounts() {
let dir = tmpdir("twobackends");
let store = store_in(&dir);
store.save(&Account::new("folder", "/mnt/p"), None).unwrap();
store.save(&Account::new("webdav", "/mnt/p"), None).unwrap();
assert_eq!(store.list().len(), 2);
}
#[test]
fn saving_the_same_account_twice_does_not_duplicate_it() {
let dir = tmpdir("dedupe");
let store = store_in(&dir);
let mut a = nextcloud();
store.save(&a, Some(&Secret::new("token"))).unwrap();
a.root = "Photos".into();
store.save(&a, Some(&Secret::new("token"))).unwrap();
assert_eq!(store.list().len(), 1);
assert_eq!(store.current().unwrap().root, "Photos");
}
#[test]
fn a_missing_config_is_a_first_run_not_an_error() {
let dir = tmpdir("firstrun");
let store = store_in(&dir);
assert!(store.list().is_empty());
assert!(store.current().is_none());
}
#[test]
fn a_corrupt_config_does_not_prevent_starting() {
// Better to present a first-run state than to refuse to launch.
let dir = tmpdir("corrupt");
std::fs::write(dir.join("sessions.json"), "{ not json").unwrap();
let store = store_in(&dir);
assert!(store.list().is_empty());
}
#[test]
fn a_config_written_before_backends_existed_still_loads() {
// The upgrade path. Every account written by an earlier version omits
// `backend`, and refusing to parse one would make an upgrade look
// like a signed-out app with a library that has to be set up again.
let dir = tmpdir("legacy");
std::fs::write(
dir.join("sessions.json"),
r#"{"version":1,"sessions":[{"server":"https://cloud.example",
"login":"duncan","user_id":"duncan","root":"PhotosRaw",
"formats":[],"last_scan":null}]}"#,
)
.unwrap();
let a = store_in(&dir).current().expect("legacy account loads");
assert_eq!(a.backend, LEGACY_BACKEND);
assert_eq!(a.endpoint, "https://cloud.example");
assert_eq!(a.root, "PhotosRaw");
}
#[test]
fn a_legacy_account_keeps_the_directory_its_data_is_already_in() {
// Frozen deliberately: this string names the directory holding the
// catalog, the thumbnail shards and un-uploaded sidecars. A change
// here abandons all three and forces a full rescan.
let a = Account::new(LEGACY_BACKEND, "https://cloud.example.com").with_login("d", "duncan");
assert_eq!(a.namespace(), "cloud-example-com-duncan");
}
#[test]
fn a_new_backend_cannot_collide_with_a_legacy_one() {
let ns = Account::new("folder", "/mnt/photos").namespace();
assert!(ns.starts_with("folder-"), "{ns}");
assert_ne!(ns, Account::new(LEGACY_BACKEND, "/mnt/photos").namespace());
}
#[test]
fn two_folders_never_share_a_directory() {
// Two libraries in one catalog would index each other's images.
assert_ne!(
Account::new("folder", "/mnt/photos/2025").namespace(),
Account::new("folder", "/mnt/photos/2026").namespace()
);
}
#[test]
fn a_very_deep_folder_still_yields_a_legal_directory_name() {
// Past 255 bytes the catalog directory cannot be created at all, and
// the library simply fails to open.
let deep = format!("/{}", vec!["a-rather-long-folder-name"; 40].join("/"));
let a = Account::new("folder", &deep);
let ns = a.namespace();
assert!(ns.len() <= 96, "{} chars", ns.len());
// Truncation alone would make these two the same directory.
let b = Account::new("folder", format!("{deep}/second"));
assert_ne!(ns, b.namespace());
}
#[test]
fn describe_reads_for_an_account_with_no_user() {
// A folder library has nobody to name; "(none) on /mnt/photos" would
// be worse than saying where it is.
let mut a = folder();
assert_eq!(a.describe(), "/mnt/photos");
a.root = "2026".into();
assert_eq!(a.describe(), "/mnt/photos/2026");
}
#[test]
fn describe_is_readable_and_hides_the_scheme() {
let mut a = nextcloud();
assert_eq!(a.describe(), "duncan on cloud.example");
a.root = "PhotosRaw".into();
assert_eq!(a.describe(), "duncan on cloud.example/PhotosRaw");
}
#[test]
fn format_selection_round_trips() {
let mut a = nextcloud();
a.set_format_filter(&FormatFilter::from_formats([Format::Cr2, Format::Dng]));
let f = a.format_filter();
assert!(f.allows(Format::Cr2));
assert!(f.allows(Format::Dng));
assert!(!f.allows(Format::Nef));
}
#[test]
fn an_unset_filter_means_every_format() {
// Never "no formats", which would silently find nothing.
let f = nextcloud().format_filter();
assert!(f.allows(Format::Cr2));
assert!(f.allows(Format::Jpeg));
}
#[test]
fn updating_settings_leaves_the_credential_alone() {
let dir = tmpdir("update");
let store = store_in(&dir);
let mut a = nextcloud();
store.save(&a, Some(&Secret::new("secret-token"))).unwrap();
a.root = "Elsewhere".into();
store.update(&a).unwrap();
assert_eq!(store.current().unwrap().root, "Elsewhere");
assert_eq!(
store
.connection(&a, true)
.unwrap()
.require_secret()
.unwrap()
.expose(),
"secret-token"
);
}
}
+49
View File
@@ -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,
}
}
+31
View File
@@ -36,11 +36,42 @@ pub enum RemoteError {
#[error("not found: {0}")]
NotFound(String),
/// TRACES: FR-NC-6c
/// The object exists, but its content is not on this device.
///
/// A virtual-filesystem placeholder: the sync client holds the name and a
/// stub, and the bytes are still on the server (ARCH §9.0).
///
/// **Emphatically not [`NotFound`](Self::NotFound), and the distinction is
/// what stops a silent data loss.** The sidecar writer reads before it
/// writes, and treats a miss as "there is no sidecar yet, create one" — so
/// a dehydrated sidecar reported as absent makes it write a fresh document
/// over an existing one, discarding every edit another device had put
/// there. It is also the difference between an error a user can act on
/// (fetch it) and one they cannot (it is gone).
#[error("not on this device: {0}")]
NotMaterialised(String),
/// The backend does not support this operation. Expected, not a bug —
/// callers check capabilities and adapt.
#[error("operation unsupported by this backend: {0}")]
Unsupported(&'static str),
/// The account is configured wrongly, or for a backend this build has no
/// connector for.
///
/// **Not a network failure and not an auth failure**, which is why it is
/// its own variant. A folder library whose directory has been unmounted,
/// or an account naming a backend a cut-down build was not compiled with,
/// produces a request that never leaves the process — reporting either as
/// `Network` would put the app into offline mode and tell the user their
/// connection is down, and reporting them as `AuthFailed` would send them
/// to re-enter a credential that is fine. The message names what is wrong
/// with the configuration, because that is the only thing that will fix
/// it.
#[error("account misconfigured: {0}")]
Configuration(String),
/// A conditional write failed: the remote changed underneath us. Triggers
/// the sidecar merge path (ARCH §8.5).
#[error("precondition failed — remote was modified")]
+65 -4
View File
@@ -1,9 +1,14 @@
//! Pluggable remote storage for DarkRoom.
//!
//! Defines the [`RemoteBackend`] trait and the capability model the sync
//! engine adapts to. Only the Nextcloud connector is implemented
//! (`dr-sync-nextcloud`), but the boundary is designed so other backends can
//! be added without touching the engine.
//! engine adapts to, plus the pieces that let the application hold a backend
//! without naming one: an [`Account`] that is configuration rather than a
//! server, and a [`BackendProvider`] registry that turns one into a live
//! connection.
//!
//! Two connectors ship: `dr-sync-nextcloud` and `dr-sync-folder`. Adding a
//! third is implementing those two traits and registering the result — see
//! [`provider`] for the whole contract.
//!
//! # Why capabilities rather than a common denominator
//!
@@ -20,15 +25,21 @@ use std::ops::Range;
use async_trait::async_trait;
pub mod account;
pub mod capability;
pub mod error;
pub mod provider;
pub mod reachability;
pub mod scan;
pub mod types;
pub mod upload;
pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, ServerPreviews};
pub use account::{Account, AccountError, AccountStore, Connection, Secret, LEGACY_BACKEND};
pub use capability::{
Capabilities, ChangeDetection, ChunkConstraints, Materialisation, ServerPreviews,
};
pub use error::RemoteError;
pub use provider::{BackendProvider, BackendRegistry, SignIn};
pub use reachability::{Connectivity, Reachability};
pub use scan::{scan, ScanProgress, ScanResult};
pub use types::{
@@ -142,6 +153,56 @@ pub trait RemoteBackend: Send + Sync {
/// destination, not to claim they created it.
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError>;
// ---- materialisation --------------------------------------------------
/// TRACES: FR-NC-6c
/// Ask for a placeholder's content to be brought to this device.
///
/// Only meaningful where [`Capabilities::materialisation`] is
/// [`Materialisation::OnDemand`]; others return
/// [`RemoteError::Unsupported`].
///
/// **Whole-file, and slow.** There is no partial hydration: a placeholder
/// becomes one byte or all of them, so this transfers a 27 MB RAW to
/// answer a question a 256 KB range read would have answered (ARCH §9.0
/// finding 3). It is for the originals tier — develop, export, a pin the
/// user asked for — and for passes the user has been quoted a price on and
/// agreed to. **Never for filling a grid**: doing so downloads the entire
/// library to produce thumbnails.
///
/// Returns once the content is readable, and **whether this call is what
/// brought it here** — `false` meaning it was already local.
///
/// That boolean is the whole basis of borrowing. A caller releasing what
/// it fetched must not release what the user already had, and after the
/// fact the two are indistinguishable; the backend knows because it had to
/// look before deciding whether to ask. Answering it here costs the `stat`
/// the implementation performs anyway, where a caller determining it
/// separately would pay a directory listing per file.
async fn materialise(&self, _id: &RemoteId) -> Result<bool, 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.
+278
View File
@@ -0,0 +1,278 @@
// TRACES: FR-NC-12
//! How a connector announces itself.
//!
//! [`RemoteBackend`] says what a backend can *do* once it is open.
//! [`BackendProvider`] says everything the application needs before that: what
//! to call it, what a library location looks like, whether signing in involves
//! a browser, and how to turn a stored [`Account`] into a live backend.
//!
//! Together they are the whole contract. Adding a storage layer is:
//!
//! 1. implement [`RemoteBackend`] over your protocol,
//! 2. implement [`BackendProvider`] beside it,
//! 3. register it in `dr_ui::remote`.
//!
//! Nothing above that module names a connector, so nothing above it changes.
//!
//! # Why sign-in is a shape rather than a method
//!
//! It would be tidier for a provider to expose `async fn sign_in()` and let
//! the launch screen await it. It would also be wrong: Nextcloud's Login Flow
//! v2 is a browser handshake the user completes elsewhere while the app polls,
//! so it is not one call, it does not finish on our schedule, and the screen
//! has to render a URL and a waiting state in the middle of it. A folder needs
//! none of that. [`SignIn`] names which of those two shapes the screen must
//! draw, and the flow itself stays where its protocol is.
use std::sync::Arc;
use crate::{Account, Connection, RemoteBackend, RemoteError};
/// What establishing an account involves.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignIn {
/// A handshake the user completes outside the app, yielding a credential
/// the app then stores. Nextcloud's Login Flow v2.
///
/// The connector drives it; the launch screen only shows the waiting
/// state, because what happens in the middle is protocol-specific.
Browser,
/// The endpoint is the whole account. Nothing to authenticate, nothing to
/// store in the keyring, no waiting state to draw — a local folder.
EndpointOnly,
}
impl SignIn {
/// Whether an account of this shape has a credential in secure storage.
pub fn needs_secret(self) -> bool {
matches!(self, SignIn::Browser)
}
}
/// TRACES: FR-NC-12
/// A storage connector, described well enough to configure without naming it.
///
/// Implementations are held in an [`Arc`] inside a [`BackendRegistry`] and
/// must be usable from any thread: the launch screen reads them on the UI
/// thread and workers open connections from them on their own.
pub trait BackendProvider: Send + Sync {
/// The stable identifier written to [`Account::backend`].
///
/// **It is on-disk configuration.** Changing it after anyone has an
/// account orphans that account, so pick it once.
fn id(&self) -> &'static str;
/// What to call this in the interface. "Nextcloud", "Folder".
fn display_name(&self) -> &'static str;
/// What to label the endpoint field: "Server address", "Folder".
fn endpoint_label(&self) -> &'static str;
/// An example endpoint, for the empty field.
fn endpoint_placeholder(&self) -> &'static str;
/// How an account of this kind is established.
fn sign_in(&self) -> SignIn;
/// Turn what the user typed into the form that gets stored.
///
/// Two jobs, and the second is the important one: this is where a bad
/// endpoint is *rejected*, before an account is written for a library that
/// does not exist. The error is shown to the user, so it says what is
/// wrong rather than naming a type.
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
/// Build an account from a normalised endpoint alone.
///
/// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces
/// its account from what the handshake returned, so the default here
/// refuses rather than inventing one.
fn account_for(&self, endpoint: &str) -> Result<Account, RemoteError> {
let _ = endpoint;
Err(RemoteError::Unsupported(
"this backend establishes an account through its sign-in flow",
))
}
/// Open a live backend.
///
/// Cheap and synchronous: it validates configuration and constructs a
/// client, and does not talk to the remote. Workers call it per task, so
/// anything expensive here is paid over and over.
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError>;
}
/// TRACES: FR-NC-12 | FR-NC-13
/// The connectors this build has.
///
/// One instance is built at startup and consulted by everything that needs a
/// backend. The registry is the *only* thing that knows connectors exist,
/// which is what keeps the layers above free of them.
#[derive(Clone, Default)]
pub struct BackendRegistry {
providers: Vec<Arc<dyn BackendProvider>>,
}
impl BackendRegistry {
pub fn new() -> Self {
Self::default()
}
/// Add a connector.
///
/// Later registrations of an id replace earlier ones, so a build can
/// substitute a connector — a test double for a real server — without the
/// registry needing to know it happened.
pub fn register(&mut self, provider: Arc<dyn BackendProvider>) -> &mut Self {
let id = provider.id();
self.providers.retain(|p| p.id() != id);
self.providers.push(provider);
self
}
/// The connector for an id.
pub fn get(&self, id: &str) -> Option<&Arc<dyn BackendProvider>> {
self.providers.iter().find(|p| p.id() == id)
}
/// The connector an account names, or a message naming the account's.
///
/// The error case is real rather than defensive: a configuration file can
/// outlive the build that wrote it, and a user moving between a full
/// desktop build and a cut-down one will have accounts this binary cannot
/// serve. Saying which backend is missing is the difference between that
/// and "could not open library".
pub fn for_account(&self, account: &Account) -> Result<&Arc<dyn BackendProvider>, RemoteError> {
self.get(&account.backend).ok_or_else(|| {
RemoteError::Configuration(format!(
"no storage backend named {:?} in this build",
account.backend
))
})
}
/// Open the backend an account is configured for.
pub fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
self.for_account(&conn.account)?.connect(conn)
}
/// Every connector, in registration order. What the launch screen offers.
pub fn providers(&self) -> &[Arc<dyn BackendProvider>] {
&self.providers
}
}
impl std::fmt::Debug for BackendRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BackendRegistry")
.field(
"providers",
&self.providers.iter().map(|p| p.id()).collect::<Vec<_>>(),
)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Stub(&'static str);
impl BackendProvider for Stub {
fn id(&self) -> &'static str {
self.0
}
fn display_name(&self) -> &'static str {
"Stub"
}
fn endpoint_label(&self) -> &'static str {
"Where"
}
fn endpoint_placeholder(&self) -> &'static str {
"somewhere"
}
fn sign_in(&self) -> SignIn {
SignIn::EndpointOnly
}
fn normalise_endpoint(&self, input: &str) -> Result<String, String> {
if input.trim().is_empty() {
Err("say where the library is".into())
} else {
Ok(input.trim().to_string())
}
}
fn connect(&self, _conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
Err(RemoteError::Unsupported("stub"))
}
}
fn registry() -> BackendRegistry {
let mut r = BackendRegistry::new();
r.register(Arc::new(Stub("alpha")));
r.register(Arc::new(Stub("beta")));
r
}
#[test]
fn a_registered_backend_is_found_by_id() {
assert_eq!(registry().get("beta").map(|p| p.id()), Some("beta"));
}
#[test]
fn registering_an_id_twice_replaces_rather_than_shadows() {
let mut r = registry();
r.register(Arc::new(Stub("alpha")));
assert_eq!(r.providers().len(), 2, "{r:?}");
}
#[test]
fn an_account_for_a_missing_backend_says_which_one() {
// A config can outlive the build that wrote it. "could not open
// library" would send the user to check their server.
let account = Account::new("s3", "bucket");
let err = match registry().for_account(&account) {
Err(e) => e.to_string(),
Ok(p) => panic!("a backend this build has no connector for: {}", p.id()),
};
assert!(err.contains("s3"), "{err}");
}
#[test]
fn an_endpoint_only_backend_needs_no_credential() {
assert!(!SignIn::EndpointOnly.needs_secret());
assert!(SignIn::Browser.needs_secret());
}
#[test]
fn a_browser_backend_refuses_to_invent_an_account() {
// Building one from an endpoint would skip the handshake and store an
// account with no credential, which fails later and further away.
struct Interactive;
impl BackendProvider for Interactive {
fn id(&self) -> &'static str {
"i"
}
fn display_name(&self) -> &'static str {
"I"
}
fn endpoint_label(&self) -> &'static str {
"Server"
}
fn endpoint_placeholder(&self) -> &'static str {
""
}
fn sign_in(&self) -> SignIn {
SignIn::Browser
}
fn normalise_endpoint(&self, i: &str) -> Result<String, String> {
Ok(i.into())
}
fn connect(&self, _: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
Err(RemoteError::Unsupported("stub"))
}
}
assert!(Interactive.account_for("https://x").is_err());
}
}
+3
View File
@@ -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),
+17
View File
@@ -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)]
+1
View File
@@ -237,6 +237,7 @@ mod tests {
size: *size,
modified: None,
has_preview: false,
materialised: true,
})
.collect())
}
+11 -6
View File
@@ -407,10 +407,9 @@ impl Default for ExportSettings {
/// [`create_dir`](../../dr_sync/trait.RemoteBackend.html) and behaves
/// identically on both platforms.
///
/// It is also where the photographs already are. A library that lives on
/// Nextcloud and exports to a phone's local storage has put the output
/// somewhere the user's other devices cannot see, which is rarely what was
/// meant.
/// It is also where the photographs already are. A library that lives on a
/// server and exports to a phone's local storage has put the output somewhere
/// the user's other devices cannot see, which is rarely what was meant.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportTarget {
@@ -425,7 +424,11 @@ pub enum ExportTarget {
/// destination was filled in. It is excluded from [`Self::available`]
/// rather than offered and then failing.
Device,
/// A folder on the connected account, created if absent.
/// A folder in the connected library, created if absent.
///
/// Named for where it goes rather than for what is behind it: the library
/// may be a Nextcloud account or a folder on a mount, and the export
/// behaves identically either way.
Remote,
}
@@ -466,7 +469,9 @@ impl ExportTarget {
pub fn label(self) -> &'static str {
match self {
Self::Device => "This device",
Self::Remote => "Nextcloud",
// Not the connector's name: the library may be a server or a
// folder, and the setting means the same thing for both.
Self::Remote => "The library",
}
}
+111 -6
View File
@@ -56,8 +56,9 @@ darkroom/
│ ├── dr-gpu wgpu device, tile scheduler, WGSL shaders, mask rasteriser
│ ├── dr-colour lcms2 bindings, camera profiles, working-space transforms
│ ├── dr-export encoders, resampling, output sizing
│ ├── dr-sync RemoteBackend trait, sync engine, cache rules, merge
│ └── dr-sync-nextcloud the only backend implementation (§8.4)
│ ├── dr-sync RemoteBackend + BackendProvider, Account, sync engine, merge
│ ├── dr-sync-nextcloud WebDAV, oc:fileid, chunked v2, Login Flow v2 (§8.4)
│ └── dr-sync-folder a plain directory: disk, mount, synced folder (§8.4a)
├── ui/
│ ├── dr-ui Slint components, adaptive layout, descriptor→control mapping
│ └── dr-widgets custom controls per WidgetKind (curve, wheel, crop, brush)
@@ -572,9 +573,34 @@ recovery. Panics in decode are caught at the boundary, since RAW parsing handles
## 8. Sync architecture
Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait; **only the Nextcloud connector is
implemented**, but the boundary is designed so S3, generic WebDAV, or a self-hosted photo server can
be added without touching the sync engine.
Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait and the capability model the engine
adapts to; two connectors implement it — `dr-sync-nextcloud` and `dr-sync-folder` — and S3, generic
WebDAV, or a self-hosted photo server can be added without touching the engine.
**`docs/storage.md` is the contract**: the four traits a connector meets, the four steps to add one,
and what each shipped connector actually declares. This section says *why* the seam is shaped the
way it is; that document says how to use it.
### 8.0 A trait is not a seam
Worth stating because this was got wrong for a release. `RemoteBackend` existed from the start and
the code above it still knew it was talking to Nextcloud: seven files in `dr-ui` constructed a
`NextcloudBackend` directly, ten functions took one by concrete type, an account *was* a server URL
beside a DAV user id, and the local cache directory was named after a hostname. The abstraction was
real and bought nothing.
Pluggable storage needs four things, and only the first is a trait over operations:
| | What | Where |
|---|---|---|
| 1 | Operations | `RemoteBackend` (§8.3) |
| 2 | Capabilities — what is *cheap*, so the engine adapts rather than assumes | `Capabilities` (§8.1) |
| 3 | Configuration — what an account is, with no server in it | `Account`, `Connection` |
| 4 | Registration — how a connector is discovered without being named | `BackendProvider`, `BackendRegistry` |
`ui/dr-ui/src/remote.rs` is the only file above `dr-sync` that names a connector. `dr-sync` itself
depends on none of them, so a build that only wants a folder library does not compile a TLS stack to
get one.
### 8.1 Why capability negotiation, not a common denominator
@@ -702,7 +728,8 @@ Design notes worth keeping:
### 8.4 The Nextcloud connector
The only implementation. Mapping to the trait:
The reference implementation, and the one whose peculiarities the capability model exists to keep.
Mapping to the trait:
| Trait method | Nextcloud |
|---|---|
@@ -739,6 +766,49 @@ never `Depth: infinity`. Two implementation details worth keeping:
and proves nothing about children, so it is pure overhead; a test asserts zero probes in that
case. Only `PropagatingEtags` makes an unchanged parent prove an unchanged subtree.
### 8.4a The folder connector
A plain directory: a local disk, a network mount, an external drive, or the folder a Nextcloud
desktop client already syncs. No server, no account, no credential — which makes it the route that
works on a machine with no secrets daemon.
It exists for two reasons. It is genuinely useful, and a second implementation is the only way to
find out whether the first was an abstraction or a description. Adding it is what turned §8.0's four
items from a claim into a fact.
| Trait method | Folder |
|---|---|
| `capabilities` | `LocalEtags`, **no** stable ids, ranges, no chunking, conditional |
| `list` | `read_dir` + `metadata`; validator is `size`-`mtime` |
| `dir_validator` | `Unsupported` — a directory's mtime does not propagate |
| `delta` | `Unsupported` — a folder keeps no change feed |
| `get` + range | `seek` + `take`; a short read past the end is not an error |
| `put` | write to a temporary beside the destination, `rename` over it |
| `put` `IfAbsent` | `O_CREAT | O_EXCL` — genuinely atomic |
| `put` `IfMatch` | `stat`, compare, then the same rename — narrows the race, does not close it |
| `delete` | files, and *empty* directories only — see below |
| `move_to` | `rename`, falling back to copy + unlink across a mount boundary |
| auth | none |
Three things worth carrying forward:
- **`LocalEtags` is the honest answer, and it costs nothing.** A POSIX directory's mtime describes
its own entry list and nothing below it, so there is no propagation to exploit and the engine
walks the tree every scan. **Measured 2026-08-28**: a full uncached walk of 2,299 images
across 233 directories took **137 ms**, with no pruning at all — against 34.1 s for 17,185 RAWs
over WebDAV *with* pruning (§8.4). The walk that was expensive was expensive because it was
thousands of `PROPFIND`s. This is the capability model paying for itself — one engine, two
backends, each running at the speed it actually runs at.
- **Identity is a path hash, not an inode.** An inode is stable across a rename but differs between
devices and is reused after a delete, so two machines would disagree about which photograph a
thumbnail belonged to and a recycled inode would attach an old thumbnail to a new image.
Re-deriving a thumbnail is a cost; showing the wrong one is a bug. `stable_ids: false` reports the
consequence.
- **`delete` is deliberately not recursive**, unlike WebDAV's `DELETE` on a collection. There is no
server-side trash behind a local folder, so a caller with a wrong path would have no way back.
Nothing in the engine deletes a directory — the soft delete is a `move_to` (§FR-CAT-15) — so the
guard is free.
### 8.5 Sidecar conflict resolution
`put` with `Precondition::IfMatch(validator)`. On precondition failure:
@@ -806,6 +876,41 @@ remains the only mechanism that satisfies FR-NC-3.
- Never depend on it: the socket is Linux-only, absent on Android, and absent when the client is
not running. The direct connector remains the primary path.
#### 9.0a Amendment, 2026-08-29 — VFS as a managed tier for *folder* libraries
The rejection above was written when the only backend was the direct Nextcloud connector, and it
assumed the alternative to hydration was a range read. Since `dr-sync-folder` exists (§8.4a) a
library can be opened as a directory with **no server connection at all**, and that changes what
finding 3 is comparing against.
**What finding 3 actually says.** Hydration transfers ~100× what a preview needs *when a range read
is available*. On a folder library there is no connector, so there are no range reads: the choice is
not hydrate-versus-range-read, it is hydrate-once or never have a thumbnail. The finding stands
unamended for the direct connector, which must still never hydrate to browse.
**What makes the cost acceptable on a folder library**, and all three are required:
1. **Hydration is a borrow, not an acquisition.** A file is returned to the state it was found in —
what the pass downloaded is released, what the user already had is left alone. Peak disk is the
working set, not the library. **Measured 2026-08-29** over 100 photographs of 25 MB, 90 of them
dehydrated: peak 275 MB against 2,500 MB unborrowed, back to 250 MB afterwards, and all ten the
user already held still there.
2. **It is paid once.** Thumbnails are kept, and `derived_sync` pushes the shards to the server, so
a second device downloads 200 MB of shards instead of hydrating 340 GB of RAWs.
3. **It is quoted and consented to.** Never automatic, never on the browsing path, always
resumable and cancellable (FR-NC-6c).
**What this does not change.** Findings 1 and 2 stand and are now implemented rather than merely
noted: a stub is not transparent, so `RemoteEntry` carries `materialised` and the backend reports
`RemoteError::NotMaterialised` rather than a miss; and the socket remains the only way to hydrate,
so it stays Linux-only, optional, and absent on Android.
**The one thing that must never be got wrong.** Releasing content means asking the client to
dehydrate — never deleting the file. A deletion inside a synced tree propagates to the server and
removes the photograph from every device the user owns. `RemoteBackend::dematerialise` says so, the
`Vfs` trait says so, and an implementation that cannot dehydrate returns `Unsupported` rather than
approximating it with `remove_file`.
### 9.1 The three tiers, restated as policy
| Tier | Content | Default |
+44 -3
View File
@@ -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/<userid>/`: `MKCOL` to create the upload folder, `PUT` each chunk, then
`MOVE` the `.file` pseudo-entry to the destination. Chunks are 5MB–5GB and named 1–10000.
@@ -731,9 +750,16 @@ returns.
WebDAV `SEARCH` (RFC 5323) against `/remote.php/dav/` filtered by mimetype and paginated via
`d:limit`/`d:nresults`, in preference to walking thousands of folders with PROPFIND.
**FR-NC-12 — Backend independence.** Sync shall be implemented against a backend interface, with
Nextcloud as the only implementation in v1. No protocol detail specific to Nextcloud may appear
outside its connector.
**FR-NC-12 — Backend independence.** Sync shall be implemented against a backend interface. No
protocol detail specific to any one backend may appear outside its connector, and no layer above
the interface may name a connector — with the single exception of the registry that constructs them
(`dr_ui::remote`).
A trait over operations is not sufficient on its own, and the first release proved it: `dr-ui`
constructed the Nextcloud backend directly in seven files, an account *was* a server URL beside a
DAV user id, and the local cache directory was named after a hostname. Independence requires four
things — operations, declared capabilities, an account model with no server in it, and a
registration mechanism (ARCH §8.0, `docs/storage.md`).
Backends **declare capabilities** rather than conforming to a lowest common denominator, because
the property that makes Nextcloud sync fast — directory ETags propagating up the tree, so an
@@ -748,6 +774,21 @@ Where a capability is absent the app shall **degrade visibly, not silently**:
- Without conditional writes, sidecar conflict detection falls back to revision comparison, which
narrows but does not close the race; this is surfaced as a reduced-safety mode
**FR-NC-13 — Folder libraries.** A library shall be openable as a **plain directory** — a local
disk, a network mount, an external drive, or a folder another client already syncs — with no
account, no server and no credential.
This is a requirement rather than a convenience for three reasons. It is what a photographer with
an archive drive and no server actually has. It is the only route that works where no secrets
daemon exists, which FR-NC-2 otherwise treats as a degraded mode. And a second connector is the
only way to keep FR-NC-12 honest: an interface with one implementation cannot be shown to be an
interface.
The folder connector shall declare its capabilities truthfully rather than flatteringly — in
particular it shall **not** claim propagating directory ETags, because a POSIX directory's mtime
describes its own entry list and nothing beneath it, and a backend that claimed otherwise would
hide edits rather than merely run slowly (ARCH §8.4a).
### 3.8 Platform integration
#### Android
+583
View File
@@ -0,0 +1,583 @@
# Storage backends
How DarkRoom talks to wherever a library lives, and what it takes to add
somewhere new.
This document is the contract. `docs/architecture.md` §8 says why sync is built
on capability negotiation rather than a common denominator; this says what the
seam actually is, where each piece lives, and what a third connector has to do.
---
## 1. What "pluggable" has to mean
A trait alone does not make storage pluggable. `RemoteBackend` existed from the
first release and every layer above it still knew it was talking to Nextcloud:
seven files in `dr-ui` constructed a `NextcloudBackend` directly, ten functions
took one by concrete type, the account model was a server URL beside a DAV user
id, and the local cache directory was named after a hostname. The abstraction
was real and bought nothing, because everything that *reached* a backend was
still shaped like one product.
Pluggable means all four of these, not just the first:
1. **Operations** — what a backend can do. `RemoteBackend`.
2. **Capabilities** — what it can do *cheaply*, so the engine adapts instead of
assuming. `Capabilities`.
3. **Configuration** — what an account is, with no server in it. `Account`.
4. **Registration** — how the application discovers a connector at all, without
naming it. `BackendProvider` + `BackendRegistry`.
Two connectors ship. Nextcloud is unchanged and keeps every one of its
peculiarities — those are the point of the capability model, not an
embarrassment it has to hide. The folder connector serves a plain directory and
exists partly because it is genuinely useful and partly because a second
implementation is the only way to find out whether the first was an
abstraction.
---
## 2. Where each piece lives
```
core/dr-sync/ the contract, and nothing that speaks a protocol
├─ types.rs RemotePath, RemoteId, RemoteEntry, Validator, …
├─ capability.rs Capabilities, ChangeDetection, ServerPreviews
├─ error.rs RemoteError — the one error every caller handles
├─ account.rs Account, AccountStore, Secret, Connection
├─ provider.rs BackendProvider, BackendRegistry, SignIn
├─ lib.rs RemoteBackend, SyncStrategy
├─ scan.rs the walk, driven by capabilities
├─ upload.rs where an original is placed
└─ reachability.rs online/offline, inferred from observed results
core/dr-sync-nextcloud/ WebDAV, oc:fileid, chunked upload v2, Login Flow v2
core/dr-sync-folder/ a directory on a filesystem
ui/dr-ui/src/remote.rs the registry — the ONLY file above dr-sync that
names a connector
```
`dr-sync` depends on no connector. That is deliberate and load-bearing: a build
that only wants a folder library must not compile a TLS stack to get one, and
the registry therefore lives in the crate that already depends on everything —
the interface.
---
## 3. The four traits and types a connector meets
### 3.1 `RemoteBackend` — operations
```rust
#[async_trait]
pub trait RemoteBackend: Send + Sync {
fn capabilities(&self) -> &Capabilities;
fn name(&self) -> &str;
// discovery
async fn list(&self, dir: &RemotePath, since: Option<&Validator>)
-> Result<Vec<RemoteEntry>, RemoteError>;
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError>;
async fn delta(&self, cursor: &Cursor)
-> Result<(Vec<RemoteChange>, Cursor), RemoteError>;
// transfer
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>)
-> Result<Vec<u8>, RemoteError>;
async fn put(&self, path: &RemotePath, body: Vec<u8>, precond: Option<Precondition>)
-> Result<Validator, RemoteError>;
async fn put_many(&self, items: Vec<(RemotePath, Vec<u8>)>) // defaulted
-> Result<Vec<Result<Validator, RemoteError>>, RemoteError>;
async fn delete(&self, id: &RemoteId, precond: Option<Precondition>)
-> Result<(), RemoteError>;
async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError>;
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError>;
// optional
async fn thumbnail(&self, id: &RemoteId, size: u32) // defaulted to None
-> Result<Option<Vec<u8>>, RemoteError>;
}
```
Rules that are not obvious from the signatures:
- **`dir_validator` and `delta` are capability-gated.** Return
`RemoteError::Unsupported` unless your `ChangeDetection` is
`PropagatingEtags` or `DeltaCursor` respectively. Answering
`dir_validator` with something that does not actually propagate is worse than
refusing: it lets a caller prune a subtree whose contents changed, and hides
those changes for as long as the folder list holds still.
- **`get` takes an optional range, and it is a hint.** A backend without cheap
ranges may return the whole object; the caller slices. Correctness holds
either way and `Capabilities::range_reads` says whether it was cheap.
- **Chunked upload is not in the trait.** It is an implementation detail of
`put`, chosen by body size. Exposing it would leak one server's protocol.
- **`move_to` must preserve identity where the backend has stable ids.** This
is what a soft delete uses (`FR-CAT-15`): a move implemented as copy + delete
allocates a new id, orphaning the thumbnail shard and turning a restore into a
full re-download.
- **`create_dir` makes parents and succeeds if the directory exists.** Callers
use it to guarantee a destination, not to claim they created one.
### 3.2 `Capabilities` — what is cheap
The engine reads these once at connect time and picks a `SyncStrategy`. See
ARCH §8.1–8.2 for the tiers. The two that change behaviour rather than speed:
| Absent | Consequence the engine handles |
|---|---|
| `range_reads` | Embedded-preview extraction is impossible; browsing falls back to server previews or full download, and is refused on a metered connection |
| `conditional_write` | Sidecar conflict detection falls back to revision counters inside the sidecar — narrows the race, does not close it. Reported as a reduced-safety mode |
**Declare what is true, not what is flattering.** A backend claiming
`PropagatingEtags` it does not have does not merely run slowly; it silently
hides changes.
### 3.3 `Account` — configuration with no server in it
```rust
pub struct Account {
pub backend: String, // BackendProvider::id; defaults to "nextcloud" on load
pub endpoint: String, // stored as "server" — a URL, a path, a bucket
pub login: String, // empty where the connector has no notion of a user
pub user_id: String, // connector-defined sub-address; Nextcloud's DAV segment
pub root: String, // the folder chosen as the library root
pub formats: Vec<String>,
pub last_scan: Option<i64>,
}
```
Everything but `backend` is the connector's to interpret. Code above `dr-sync`
reads these for display and for cache keys, never for meaning.
Two properties are load-bearing:
- **The on-disk form is backwards compatible.** `backend` defaults to
`"nextcloud"` and `endpoint` is stored under its historical key `server`, so
every account written before there was a choice loads unchanged. A config the
app refuses to parse is an account the user has to set up again.
- **`Account::namespace()` is frozen for Nextcloud.** It names the directory
holding the catalog, the thumbnail shards, the sidecar spool and the export
outbox. Changing it does not lose that data, it *abandons* it — silently, as
an upgrade — and costs a full rescan on top. The Nextcloud form is reproduced
byte for byte from what `catalog_path` computed before; every other backend is
prefixed by its connector id, and long endpoints are truncated with a hash
tail so two deep paths cannot collide inside one filesystem's 255-byte
component limit.
### 3.4 `Connection` and `Secret` — the credential split
```rust
pub struct Connection { pub account: Account, pub secret: Option<Secret> }
```
Credentials go to platform secure storage (`FR-NC-2`, `NFR-SEC-2`). Never the
catalog, never the config file, never a log line. `AccountStore` writes the
account as plain JSON and the secret to the keyring, which is what lets the app
show "signed in as duncan, watching /PhotosRaw" before it has touched the
keyring at all.
`Secret`'s inner string is reachable only through `expose()`, and its `Debug`
prints `Secret(***)`. That closes the indirect leak — a `{:?}` on any struct
that happens to hold a connection — by construction rather than by review.
`Connection` is also what replaced a pair of arguments (credentials, user id)
threaded together through fifteen signatures in an order that could be swapped.
### 3.5 `BackendProvider` — registration
```rust
pub trait BackendProvider: Send + Sync {
fn id(&self) -> &'static str; // written to Account::backend
fn display_name(&self) -> &'static str;
fn endpoint_label(&self) -> &'static str; // "Server" / "Folder"
fn endpoint_placeholder(&self) -> &'static str;
fn sign_in(&self) -> SignIn;
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
fn account_for(&self, endpoint: &str) -> Result<Account, RemoteError>; // defaulted
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError>;
}
pub enum SignIn {
/// A handshake the user completes outside the app, yielding a credential.
Browser,
/// The endpoint is the whole account. No credential, no waiting state.
EndpointOnly,
}
```
- **`id` is on-disk configuration.** Changing it after anyone has an account
orphans that account. Pick it once.
- **`normalise_endpoint` is where a bad endpoint is *rejected*,** before an
account is written for a library that does not exist. Its error string is
shown to the user, so it says what to fix rather than naming a type. The
Nextcloud provider upgrades `http://` to `https://` here (`NFR-SEC-3`); the
folder provider canonicalises the path, so two spellings of one directory do
not become two accounts indexing the same photographs.
- **`connect` is synchronous and cheap.** It validates configuration and builds
a client; it does not talk to the remote. Workers call it per task.
- **`SignIn` is a shape, not a method.** It would be tidier to expose
`async fn sign_in()`, and wrong: Login Flow v2 is a browser handshake the user
completes elsewhere while the app polls, so it is not one call, it does not
finish on our schedule, and the screen has to render a URL and a waiting state
in the middle of it. `SignIn` tells the launch screen which of the two shapes
to draw; the flow stays where its protocol is.
**Credentials are deliberately not abstracted.** An app password, an OAuth
token and a bucket key pair have no useful common shape, and inventing one
before a third backend exists would produce a wrong answer confidently. The
general form is `Connection` — an account plus an opaque secret — and each
connector translates that into what its protocol needs
(`NextcloudProvider::credentials`).
---
## 4. Adding a backend
1. **Implement `RemoteBackend`** over your protocol, in a new
`core/dr-sync-<name>` crate depending on `dr-sync` and nothing else of ours.
2. **Declare `Capabilities` honestly.** Start from `Capabilities::minimal()` and
raise only what you can actually deliver.
3. **Implement `BackendProvider`** beside it.
4. **Register it** in `ui/dr-ui/src/remote.rs::registry()` and add the crate to
`ui/dr-ui/Cargo.toml`.
That is the whole list. Nothing else in `dr-ui` changes, because nothing else in
`dr-ui` names a connector.
**Two things to get right, because they are silent when wrong:**
- **Identity.** `RemoteEntry::id` should be `RemoteId::Stable(u64)` wherever you
can produce a `u64` that names the same photograph on every device looking at
the same library. The catalog keys the thumbnail shards and the face index on
it (`catalog.md` §10.1), and an entry without one gets neither. Set
`Capabilities::stable_ids` only if that id also survives a rename — the two
are different questions and only the second is a capability.
- **Path safety.** A `RemotePath` is built from names on the remote and from a
catalog another device wrote. If you resolve one against a real filesystem,
reject `..` before you open anything.
Register a test double the same way — `BackendRegistry::register` replaces an
existing id rather than shadowing it — so an integration test can stand a fake
server behind `"nextcloud"` without the registry knowing it happened.
---
## 5. The connectors that ship
### 5.1 Nextcloud (`dr-sync-nextcloud`, id `"nextcloud"`)
Unchanged by the abstraction, peculiarities intact — see ARCH §8.4 for the full
mapping. What matters here is that none of them had to be given up to make room
for a second backend:
| | |
|---|---|
| `change_detection` | `PropagatingEtags` — the one-request no-op sync |
| `stable_ids` | yes, `oc:fileid`, survives server-side rename and move |
| `range_reads` | yes, detected by `206` vs `200`, never `HEAD` |
| `chunked_upload` | v2, 5 MB – 5 GB, `MKCOL` → `PUT` chunks → `MOVE .file` |
| `bulk_upload` | yes, `POST /remote.php/dav/bulk` |
| `conditional_write` | yes, `If-Match` |
| `server_previews` | `CommonFormatsOnly` — stock Nextcloud renders no RAW |
| sign-in | `SignIn::Browser`, Login Flow v2, system browser, app password |
Also kept: the `oc:permissions` probe on a refused `PUT`, which is what
distinguishes a create-only share from a bad credential; the `423 Locked`
retry classification; and the bundled ISRG Root YE certificate.
### 5.2 Folder (`dr-sync-folder`, id `"folder"`)
A local disk, an NFS or SMB mount, an external drive, or the directory a
Nextcloud desktop client already syncs. No server, no account, no credential —
which makes it the route that works on a machine with no secrets daemon at all.
| | |
|---|---|
| `change_detection` | `LocalEtags` — see below |
| `stable_ids` | **no** — the id is a path hash and does not survive a rename |
| `range_reads` | yes, `seek` + `take` |
| `chunked_upload` | none; a write is a write |
| `bulk_upload` | no |
| `conditional_write` | yes, with a documented residual race |
| `server_previews` | `None` |
| sign-in | `SignIn::EndpointOnly` |
**Why `LocalEtags` and not `PropagatingEtags`.** A POSIX directory's mtime
changes when its own entry list changes and at no other time — not when a
child's contents are edited, and not for a grandchild. There is nothing to
propagate, so `dir_validator` returns `Unsupported` and the engine walks the
tree every scan. Which costs almost nothing, because the walk that was expensive
was expensive for a reason this backend does not have.
**Measured 2026-08-28**, `cargo run -p dr-sync-folder --example scan`: a full
uncached walk of 2,299 images across 233 directories completed in **137 ms**,
and 380 images across 13 directories in **29 ms** — the same engine, the same
`Depth: 1`-per-directory walk, with no pruning at all. The Nextcloud connector's
comparable figure is 34.1 s for 17,185 RAWs across 334 directories *with*
pruning available (ARCH §8.4). The capability model is what lets one engine
drive both at the speed each actually runs at, instead of forcing the fast one
down to the slow one's interface.
**Identity is a hash of the path relative to the library root**, FNV-1a 64
(written out, because `DefaultHasher` is explicitly unstable between Rust
releases and this value is written into the catalog). It gives the catalog a
`u64` that names a photograph, is the same on every device looking at the same
folder, and does not change when the file is edited. It does not survive a
rename, and `stable_ids: false` says so: a moved photograph is seen as a delete
and an add, and its thumbnail is derived again.
The alternative — keying on the inode — is stable across a rename but *differs
between devices* and is reused by the filesystem after a delete. Two machines
would disagree about which photograph a thumbnail belonged to, and a recycled
inode would silently attach an old thumbnail to a new image. Re-deriving a
thumbnail is a cost; showing the wrong one is a bug.
**Conditional writes.** `IfAbsent` is genuinely atomic (`O_CREAT | O_EXCL`).
`IfMatch` is compare-then-swap: a `stat`, then a write to a temporary beside the
destination and a `rename` over it. A POSIX filesystem has no compare-and-swap,
so the race is narrowed to the microseconds between the two syscalls rather than
closed — still far tighter than the fallback the engine uses for a backend that
declares no conditional write at all, which spans a whole read-modify-write.
The capability is declared, and the residual race is documented at the call
site.
**Two deliberate divergences from WebDAV semantics:**
- **`delete` is not recursive.** A folder library is the user's own photographs
on their own disk with no server-side trash behind it, so a caller that passed
the wrong path would have no way back. Deleting a non-empty directory returns
`RemoteError::Configuration`. Nothing in the engine deletes a directory — the
soft delete is a `move_to` into the trash folder — so the guard is free.
- **Every filesystem call runs on the blocking pool.** On a local disk that is
overkill; on the NFS mount this backend is most useful over, a stalled server
would otherwise wedge the async worker that made the call and every other
request sharing it.
**Failure classification** matters as much as the operations. A vanished mount
(`ESTALE`, `ENOTCONN`, `EIO`) maps to `RemoteError::Network`, which is what puts
the app into offline mode and leaves the catalog readable — exactly as a dead
server does. A permissions problem maps to `PermissionDenied` and does *not*,
because going offline over one forbidden file would hide a fixable problem
behind a network banner. An endpoint that is not a directory at all maps to
`RemoteError::Configuration`: nothing was unreachable and no credential was
wrong, so neither of the other two would send the user anywhere useful.
---
## 6. Virtual filesystems
A sync client in virtual-files mode leaves a **placeholder** where a file is
catalogued but not downloaded. On Linux — the only mode it supports — that
means `IMG.CR2` does not exist at all and `IMG.CR2.nextcloud` does, holding one
byte. ARCH §9.0 measured a real machine: 121,785 placeholders against 10,267
materialised files.
A folder library that ignores this is not merely degraded, it is dangerous.
Before the handling below existed, the folder connector catalogued every stub
as a 1-byte image, gave it an identity that changed the moment it was
downloaded, and — worst — reported a dehydrated *sidecar* as absent, which made
the sidecar writer create a fresh document over an existing one and discard
every edit another device had put there.
### 6.1 Three questions, one trait
Everything else about a synced folder is an ordinary directory, so this is not
a second connector. `dr_sync_folder::Vfs` asks only what differs:
```rust
pub trait Vfs: Send + Sync {
fn name(&self) -> &'static str;
fn is_placeholder(&self, on_disk: &str) -> bool;
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str;
fn placeholder_name(&self, name: &str) -> Cow<'_, str>;
fn can_materialise(&self) -> bool; // defaulted false
fn materialise(&self, local: &Path) -> Result<(), RemoteError>; // defaulted
fn dematerialise(&self, local: &Path) -> Result<(), RemoteError>; // defaulted
}
```
`NoVfs` for a plain directory; `dr_sync_nextcloud::NextcloudVfs` for a synced
one, wrapping the `DesktopClient` socket. A third convention is a third impl.
**Why not a `folder-vfs` provider.** The interesting capability is not a
property of the backend: the same directory can materialise on demand while the
client is running and cannot when it is down, so it must be computed per
connection either way. Registering two providers would ask the user to choose
between two things that differ by whether a background process is up. The
convention is detected instead, per connection, by a hook the registry supplies
(`FolderProvider::with_vfs_detector`) — which is what keeps `dr-sync-folder`
free of any client's protocol.
### 6.2 What the backend reports
| | |
|---|---|
| `RemoteEntry::path` | the photograph's name, never the stub's — so identity survives a download |
| `RemoteEntry::materialised` | `false` on a stub; the catalog maps it to `Availability::Offline` |
| `RemoteEntry::size` | `0` on a stub, meaning *unknown* — see below |
| `get` on a stub | `RemoteError::NotMaterialised`, **never** `NotFound` and never the stub's one byte |
| `put` over a stub, unconditional | **replaces it** — the whole file is being written, so there is nothing in the stub to keep, and the placeholder is removed after the content lands |
| `put` over a stub, `IfMatch` | `NotMaterialised` — a stub's validator describes the placeholder, so nothing here can satisfy the guard; the caller fetches and retries |
| `put` over a stub, `IfAbsent` | `PreconditionFailed` — the file *is* there, only its content is elsewhere |
| `move_to` a stub | moves the stub and keeps it a stub — culling without downloading is ordinary |
| `delete` a stub | deletes it; a photograph is deleted whether or not its bytes are here |
| `capabilities().materialisation` | `OnDemand` with a client, `Placeholders` without, `Always` on a plain folder |
**Size is genuinely unknown.** A Linux suffix-mode stub is one byte and carries
no record of what it stands for. The client's `._sync_*.db` has the real size,
but that is a private schema and reading it would couple us to their migrations.
FR-NC-6c wants a transfer size quoted before an operation starts; for a stub the
honest answer is that it cannot be, and the interface should say so rather than
report one byte or invent an estimate silently.
### 6.3 Hydration is a borrow
The rule: **a file is returned to the state it was found in.** What a pass
downloaded is released; what the user already had is left alone. `BorrowPool`
enforces it.
```rust
let pool = BorrowPool::new();
{
let held = pool.borrow(&backend, &path).await?; // downloads only if absent
// ... read it, thumbnail it, index its faces ...
} // borrow ends
let stats = pool.release_all(&backend).await; // dehydrates only what it hydrated
```
Three properties that are not obvious:
- **Reference counted.** The thumbnail pass and the face pass meet on the same
RAW. Without counting, the first to finish dehydrates the file the second is
reading; with it, the transfer is paid once and released when the last
borrower is done.
- **Prior state is read before asking.** After `materialise` there is no way to
tell what the pass brought from what was already there, so it is recorded
first. Getting this wrong silently undoes a pin, and "my pinned trip
evaporated after an indexing run" is the failure that would make people stop
trusting the feature.
- **Being unsure is not symmetric.** `borrow_known(.., Some(true))` keeps a file
that might have been ours — costing disk. `Some(false)` releases one that
might have been the user's. An uncertain caller passes `true` or `None`,
never a guess at `false`.
A borrow against a plain folder or a server backend short-circuits and does
nothing, so a pass written for a VFS library runs unchanged everywhere rather
than growing two code paths.
**Measured 2026-08-29**, `cargo run -p dr-sync-folder --example vfs_cycle`: a
library of 100 photographs at 25 MB each, 90 of them dehydrated and 10 the user
keeps. A pass over all 100, borrowing and releasing as it goes:
| | |
|---|---|
| on disk at rest | 250 MB |
| **peak during the pass** | **275 MB** — the resting set plus one photograph |
| without borrowing | 2,500 MB |
| on disk afterwards | 250 MB |
| of the 10 the user already had | 10 still there |
The peak is the working set, not the library, and the release is selective.
### 6.4 Derived state is dehydrated too
Shards and the catalog snapshot live in `.darkroom-derived/` **inside the
library folder**, so a sync client dehydrates them exactly as it dehydrates a
photograph. Unlike a photograph, none of them can be skipped: a shard that will
not open is a peer's thumbnails never merging, and a catalog snapshot that will
not open is their collections.
`derived_sync::read_derived` fetches on demand rather than giving up. More
important is what happens when it *cannot*:
The catalog sync is a read-modify-write over a file another device also writes.
It was shaped `if let Ok(bytes) = backend.get(..)`, which folded every failure
into "there is no remote catalog" and carried straight on to the upload — so a
dehydrated snapshot meant pushing ours over theirs unmerged, taking their
collections and members with it. The same shape as the sidecar bug in §6, and
the same fix: a read that fails for any reason other than `NotFound` **stops the
upload**.
That is why `NotFound` and `NotMaterialised` had to be separate errors. One
means "yours is the whole truth, write it"; the other means "do not dare".
### 6.5 Release means dehydrate, never delete
The single most dangerous thing in this feature. A synced folder is not a
cache: deleting a materialised file inside it propagates the deletion to the
server and removes the photograph from every device the user owns. `Vfs` and
`RemoteBackend::dematerialise` both say so, and an implementation that cannot
dehydrate returns `Unsupported` rather than approximating it.
This is also why the originals cache (`dr_catalog::cache`) cannot simply be
pointed at a VFS library: `Cache::release` deletes bytes, which is right for a
copy under `originals/` and catastrophic in place.
### 6.6 Which photographs stay downloaded
The user's half of the bargain: a pass borrows for a moment, but *some* of the
library should stay local — the trip you are about to take, the shoot you are
working on.
That is a **pin**, and it is the pin the originals cache already had
(`dr_catalog::cache`, FR-NC-6a). Nothing parallel was built, because the model
was already the right one:
| Cache concept | On a placeholder library |
|---|---|
| `tier_desired` | what the user asked to keep hydrated |
| `tier_actual` | what is actually materialised |
| `pending_pins()` | the work list — what to hydrate next, resumable |
| pinned rows are never evicted | a pinned collection is never dehydrated |
| passive rows, LRU under a budget | what a pass borrowed, released when it finishes |
So "keep this collection hydrated" is `Cache::pin`, and the existing pin worker
drives it — except that on a placeholder library it calls `materialise` instead
of downloading a copy.
**Why not a copy.** The original materialises *in the library folder*. Copying
it under `originals/` as well would hold every pinned photograph twice, and the
copy would be the half the budget could evict while the real disk cost stayed.
`Cache::record_in_place` records the bookkeeping with **`path = NULL`**, and
that null is load-bearing: `release` deletes the file a row names, and a row
that names none deletes nothing. The safety property is structural rather than
remembered.
Unpinning therefore frees nothing by itself — the bytes are not ours to delete.
`spawn_dehydrate` asks the client to take them back, which is what actually
returns the disk.
---
## 7. What the abstraction does not yet cover
Stated so the next person does not have to rediscover it.
- **Multiple accounts at once.** `AccountStore` holds a list and the launch
screen uses the most recent. Nothing in the model prevents two open libraries;
the interface has no place to show them.
- **Per-backend settings.** A connector has no way to contribute a settings
page. Anything configurable is on the `Account` or is not configurable.
- **Capability probing at runtime.** `Capabilities` is fixed at construction.
Nextcloud's `server_previews` should really be probed per account — a server
with `camerarawpreviews` installed can render RAW — and today it is assumed to
be `CommonFormatsOnly`.
- **A general notion of an account.** Credentials stay connector-specific on
purpose (§3.5). A third connector with an OAuth flow will need a third `SignIn`
variant, and that is the right place for it to appear.
- **A quoted cost before a hydrating pass.** FR-NC-6c wants the transfer size
stated before an operation that needs absent data. A placeholder reports no
size (§6.2), so the honest figure for "index this library" is a count and not
a byte total. The interface should say *n photographs, size unknown until
fetched* rather than estimate one silently — and it does not say anything yet.
- **Metadata-only placeholders.** Windows and macOS express these in filesystem
metadata rather than in the name, and carry the real size there. `Vfs` asks
its questions about a *name*, which is all the one convention this project has
met needs. Supporting them means widening the trait to take a `Metadata`, and
doing that before anyone has run this on those platforms would be guessing.
- **Hydration during browsing, deliberately.** It stays forbidden (ARCH §9.0
finding 3). A grid cell whose content is absent shows as not-downloaded; only
a pass the user asked for may fetch.
+68 -66
View File
@@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value |
|---|---|
| Source files scanned | 281 |
| TRACES tags found | 812 |
| Requirements defined | 177 |
| Requirements covered | 106 |
| **Coverage** | **59.9%** (106/177) |
| Source files scanned | 290 |
| TRACES tags found | 843 |
| Requirements defined | 179 |
| Requirements covered | 107 |
| **Coverage** | **59.8%** (107/179) |
### By type
| Type | Covered | Defined |
|---|---|---|
| FR | 84 | 122 |
| FR | 85 | 124 |
| NFR | 20 | 49 |
| R | 2 | 6 |
@@ -34,76 +34,77 @@ _None._
| ID | Tagged in |
|---|---|
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1014`](../ui/dr-ui/ui/library.slint#L1014), [`ui/dr-ui/ui/library.slint:1176`](../ui/dr-ui/ui/library.slint#L1176), [`ui/dr-ui/ui/library.slint:863`](../ui/dr-ui/ui/library.slint#L863) |
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:2247`](../ui/dr-ui/src/library.rs#L2247), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1014`](../ui/dr-ui/ui/library.slint#L1014), [`ui/dr-ui/ui/library.slint:1176`](../ui/dr-ui/ui/library.slint#L1176), [`ui/dr-ui/ui/library.slint:863`](../ui/dr-ui/ui/library.slint#L863) |
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:2375`](../ui/dr-ui/src/library.rs#L2375), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) |
| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) |
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:180`](../ui/dr-ui/src/library.rs#L180), [`ui/dr-ui/src/library.rs:209`](../ui/dr-ui/src/library.rs#L209), [`ui/dr-ui/src/library.rs:3661`](../ui/dr-ui/src/library.rs#L3661), [`ui/dr-ui/src/library.rs:3695`](../ui/dr-ui/src/library.rs#L3695), [`ui/dr-ui/src/library_ui.rs:185`](../ui/dr-ui/src/library_ui.rs#L185), [`ui/dr-ui/src/library_ui.rs:758`](../ui/dr-ui/src/library_ui.rs#L758), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:456`](../core/dr-sync-nextcloud/src/lib.rs#L456), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:429`](../core/dr-sync/src/scan.rs#L429), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1219`](../ui/dr-ui/src/collections_ui.rs#L1219), [`ui/dr-ui/src/collections_ui.rs:1975`](../ui/dr-ui/src/collections_ui.rs#L1975), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:208`](../ui/dr-ui/src/library.rs#L208), [`ui/dr-ui/src/library.rs:3869`](../ui/dr-ui/src/library.rs#L3869), [`ui/dr-ui/src/library.rs:3903`](../ui/dr-ui/src/library.rs#L3903), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:756`](../ui/dr-ui/src/library_ui.rs#L756), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) |
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4593`](../ui/dr-ui/src/library_ui.rs#L4593), [`ui/dr-ui/ui/app.slint:316`](../ui/dr-ui/ui/app.slint#L316), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:190`](../ui/dr-ui/src/library.rs#L190), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3709`](../ui/dr-ui/src/library.rs#L3709), [`ui/dr-ui/src/library_ui.rs:629`](../ui/dr-ui/src/library_ui.rs#L629), [`ui/dr-ui/src/library_ui.rs:6390`](../ui/dr-ui/src/library_ui.rs#L6390), [`ui/dr-ui/src/library_ui.rs:6401`](../ui/dr-ui/src/library_ui.rs#L6401), [`ui/dr-ui/src/library_ui.rs:6414`](../ui/dr-ui/src/library_ui.rs#L6414), [`ui/dr-ui/src/library_ui.rs:6429`](../ui/dr-ui/src/library_ui.rs#L6429), [`ui/dr-ui/src/library_ui.rs:6438`](../ui/dr-ui/src/library_ui.rs#L6438), [`ui/dr-ui/ui/app.slint:261`](../ui/dr-ui/ui/app.slint#L261), [`ui/dr-ui/ui/app.slint:442`](../ui/dr-ui/ui/app.slint#L442), [`ui/dr-ui/ui/library.slint:1225`](../ui/dr-ui/ui/library.slint#L1225), [`ui/dr-ui/ui/library.slint:1228`](../ui/dr-ui/ui/library.slint#L1228), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908) |
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:3955`](../ui/dr-ui/src/library.rs#L3955), [`ui/dr-ui/src/library_ui.rs:316`](../ui/dr-ui/src/library_ui.rs#L316), [`ui/dr-ui/src/library_ui.rs:388`](../ui/dr-ui/src/library_ui.rs#L388), [`ui/dr-ui/src/library_ui.rs:5204`](../ui/dr-ui/src/library_ui.rs#L5204), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:6530`](../ui/dr-ui/src/library_ui.rs#L6530), [`ui/dr-ui/ui/app.slint:264`](../ui/dr-ui/ui/app.slint#L264), [`ui/dr-ui/ui/app.slint:442`](../ui/dr-ui/ui/app.slint#L442), [`ui/dr-ui/ui/app.slint:687`](../ui/dr-ui/ui/app.slint#L687), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1301`](../ui/dr-ui/ui/library.slint#L1301), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) |
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3485`](../ui/dr-ui/src/library_ui.rs#L3485), [`ui/dr-ui/src/library_ui.rs:4183`](../ui/dr-ui/src/library_ui.rs#L4183), [`ui/dr-ui/ui/app.slint:437`](../ui/dr-ui/ui/app.slint#L437), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2564`](../ui/dr-ui/ui/library.slint#L2564), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3374`](../ui/dr-ui/src/develop.rs#L3374), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/lib.rs:1788`](../ui/dr-ui/src/lib.rs#L1788), [`ui/dr-ui/src/lib.rs:1924`](../ui/dr-ui/src/lib.rs#L1924), [`ui/dr-ui/src/lib.rs:498`](../ui/dr-ui/src/lib.rs#L498), [`ui/dr-ui/src/lib.rs:923`](../ui/dr-ui/src/lib.rs#L923), [`ui/dr-ui/src/library.rs:1656`](../ui/dr-ui/src/library.rs#L1656), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library.rs:507`](../ui/dr-ui/src/library.rs#L507), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:792`](../ui/dr-ui/src/library.rs#L792), [`ui/dr-ui/src/library_ui.rs:4885`](../ui/dr-ui/src/library_ui.rs#L4885), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2870`](../ui/dr-ui/src/develop.rs#L2870), [`ui/dr-ui/src/library.rs:149`](../ui/dr-ui/src/library.rs#L149), [`ui/dr-ui/src/library.rs:1616`](../ui/dr-ui/src/library.rs#L1616), [`ui/dr-ui/src/library.rs:1693`](../ui/dr-ui/src/library.rs#L1693), [`ui/dr-ui/src/library.rs:234`](../ui/dr-ui/src/library.rs#L234), [`ui/dr-ui/src/library.rs:4062`](../ui/dr-ui/src/library.rs#L4062), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:776`](../ui/dr-ui/src/library.rs#L776), [`ui/dr-ui/src/library.rs:792`](../ui/dr-ui/src/library.rs#L792), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library_ui.rs:1565`](../ui/dr-ui/src/library_ui.rs#L1565), [`ui/dr-ui/src/library_ui.rs:1591`](../ui/dr-ui/src/library_ui.rs#L1591), [`ui/dr-ui/src/library_ui.rs:1607`](../ui/dr-ui/src/library_ui.rs#L1607), [`ui/dr-ui/src/library_ui.rs:1701`](../ui/dr-ui/src/library_ui.rs#L1701), [`ui/dr-ui/src/library_ui.rs:227`](../ui/dr-ui/src/library_ui.rs#L227), [`ui/dr-ui/src/library_ui.rs:2317`](../ui/dr-ui/src/library_ui.rs#L2317), [`ui/dr-ui/src/library_ui.rs:260`](../ui/dr-ui/src/library_ui.rs#L260), [`ui/dr-ui/src/library_ui.rs:2755`](../ui/dr-ui/src/library_ui.rs#L2755), [`ui/dr-ui/src/library_ui.rs:2979`](../ui/dr-ui/src/library_ui.rs#L2979), [`ui/dr-ui/src/library_ui.rs:3260`](../ui/dr-ui/src/library_ui.rs#L3260), [`ui/dr-ui/src/library_ui.rs:3348`](../ui/dr-ui/src/library_ui.rs#L3348), [`ui/dr-ui/src/library_ui.rs:3536`](../ui/dr-ui/src/library_ui.rs#L3536), [`ui/dr-ui/src/library_ui.rs:3654`](../ui/dr-ui/src/library_ui.rs#L3654), [`ui/dr-ui/src/library_ui.rs:441`](../ui/dr-ui/src/library_ui.rs#L441), [`ui/dr-ui/src/library_ui.rs:499`](../ui/dr-ui/src/library_ui.rs#L499), [`ui/dr-ui/src/library_ui.rs:5302`](../ui/dr-ui/src/library_ui.rs#L5302), [`ui/dr-ui/src/library_ui.rs:5417`](../ui/dr-ui/src/library_ui.rs#L5417), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:319`](../ui/dr-ui/ui/app.slint#L319), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:189`](../ui/dr-ui/src/library.rs#L189), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1558`](../ui/dr-ui/src/collections_ui.rs#L1558), [`ui/dr-ui/src/collections_ui.rs:1577`](../ui/dr-ui/src/collections_ui.rs#L1577), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3917`](../ui/dr-ui/src/library.rs#L3917), [`ui/dr-ui/src/library_ui.rs:631`](../ui/dr-ui/src/library_ui.rs#L631), [`ui/dr-ui/src/library_ui.rs:6389`](../ui/dr-ui/src/library_ui.rs#L6389), [`ui/dr-ui/src/library_ui.rs:6400`](../ui/dr-ui/src/library_ui.rs#L6400), [`ui/dr-ui/src/library_ui.rs:6413`](../ui/dr-ui/src/library_ui.rs#L6413), [`ui/dr-ui/src/library_ui.rs:6428`](../ui/dr-ui/src/library_ui.rs#L6428), [`ui/dr-ui/src/library_ui.rs:6437`](../ui/dr-ui/src/library_ui.rs#L6437), [`ui/dr-ui/ui/app.slint:264`](../ui/dr-ui/ui/app.slint#L264), [`ui/dr-ui/ui/app.slint:445`](../ui/dr-ui/ui/app.slint#L445), [`ui/dr-ui/ui/library.slint:1225`](../ui/dr-ui/ui/library.slint#L1225), [`ui/dr-ui/ui/library.slint:1228`](../ui/dr-ui/ui/library.slint#L1228), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908) |
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:4163`](../ui/dr-ui/src/library.rs#L4163), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:393`](../ui/dr-ui/src/library_ui.rs#L393), [`ui/dr-ui/src/library_ui.rs:5203`](../ui/dr-ui/src/library_ui.rs#L5203), [`ui/dr-ui/src/library_ui.rs:5256`](../ui/dr-ui/src/library_ui.rs#L5256), [`ui/dr-ui/src/library_ui.rs:6529`](../ui/dr-ui/src/library_ui.rs#L6529), [`ui/dr-ui/ui/app.slint:267`](../ui/dr-ui/ui/app.slint#L267), [`ui/dr-ui/ui/app.slint:445`](../ui/dr-ui/ui/app.slint#L445), [`ui/dr-ui/ui/app.slint:690`](../ui/dr-ui/ui/app.slint#L690), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1301`](../ui/dr-ui/ui/library.slint#L1301), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) |
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3489`](../ui/dr-ui/src/library_ui.rs#L3489), [`ui/dr-ui/src/library_ui.rs:4182`](../ui/dr-ui/src/library_ui.rs#L4182), [`ui/dr-ui/ui/app.slint:440`](../ui/dr-ui/ui/app.slint#L440), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2564`](../ui/dr-ui/ui/library.slint#L2564), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3374`](../ui/dr-ui/src/develop.rs#L3374), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1376`](../ui/dr-ui/src/lib.rs#L1376), [`ui/dr-ui/src/lib.rs:1777`](../ui/dr-ui/src/lib.rs#L1777), [`ui/dr-ui/src/lib.rs:1912`](../ui/dr-ui/src/lib.rs#L1912), [`ui/dr-ui/src/lib.rs:501`](../ui/dr-ui/src/lib.rs#L501), [`ui/dr-ui/src/lib.rs:926`](../ui/dr-ui/src/lib.rs#L926), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library_ui.rs:4884`](../ui/dr-ui/src/library_ui.rs#L4884), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2870`](../ui/dr-ui/src/develop.rs#L2870), [`ui/dr-ui/src/library.rs:148`](../ui/dr-ui/src/library.rs#L148), [`ui/dr-ui/src/library.rs:1747`](../ui/dr-ui/src/library.rs#L1747), [`ui/dr-ui/src/library.rs:1823`](../ui/dr-ui/src/library.rs#L1823), [`ui/dr-ui/src/library.rs:233`](../ui/dr-ui/src/library.rs#L233), [`ui/dr-ui/src/library.rs:4270`](../ui/dr-ui/src/library.rs#L4270), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:774`](../ui/dr-ui/src/library.rs#L774), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1580`](../ui/dr-ui/src/library_ui.rs#L1580), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1622`](../ui/dr-ui/src/library_ui.rs#L1622), [`ui/dr-ui/src/library_ui.rs:1716`](../ui/dr-ui/src/library_ui.rs#L1716), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:2332`](../ui/dr-ui/src/library_ui.rs#L2332), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:2770`](../ui/dr-ui/src/library_ui.rs#L2770), [`ui/dr-ui/src/library_ui.rs:2992`](../ui/dr-ui/src/library_ui.rs#L2992), [`ui/dr-ui/src/library_ui.rs:3270`](../ui/dr-ui/src/library_ui.rs#L3270), [`ui/dr-ui/src/library_ui.rs:3358`](../ui/dr-ui/src/library_ui.rs#L3358), [`ui/dr-ui/src/library_ui.rs:3540`](../ui/dr-ui/src/library_ui.rs#L3540), [`ui/dr-ui/src/library_ui.rs:3654`](../ui/dr-ui/src/library_ui.rs#L3654), [`ui/dr-ui/src/library_ui.rs:446`](../ui/dr-ui/src/library_ui.rs#L446), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/library_ui.rs:5301`](../ui/dr-ui/src/library_ui.rs#L5301), [`ui/dr-ui/src/library_ui.rs:5416`](../ui/dr-ui/src/library_ui.rs#L5416), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1896`](../ui/dr-ui/src/lib.rs#L1896), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:255`](../ui/dr-ui/src/library.rs#L255), [`ui/dr-ui/src/library.rs:285`](../ui/dr-ui/src/library.rs#L285), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1884`](../ui/dr-ui/src/lib.rs#L1884), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:284`](../ui/dr-ui/src/library.rs#L284), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
| FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) |
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460) |
| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2725`](../ui/dr-ui/src/library.rs#L2725), [`ui/dr-ui/src/library.rs:2829`](../ui/dr-ui/src/library.rs#L2829), [`ui/dr-ui/ui/settings.slint:404`](../ui/dr-ui/ui/settings.slint#L404), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) |
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463) |
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459) |
| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2849`](../ui/dr-ui/src/library.rs#L2849), [`ui/dr-ui/src/library.rs:2953`](../ui/dr-ui/src/library.rs#L2953), [`ui/dr-ui/ui/settings.slint:404`](../ui/dr-ui/ui/settings.slint#L404), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) |
| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) |
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) |
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1472`](../ui/dr-ui/src/lib.rs#L1472), [`ui/dr-ui/src/lib.rs:2174`](../ui/dr-ui/src/lib.rs#L2174), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/library.rs:507`](../ui/dr-ui/src/library.rs#L507), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:1761`](../ui/dr-ui/ui/app.slint#L1761), [`ui/dr-ui/ui/app.slint:790`](../ui/dr-ui/ui/app.slint#L790), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:613`](../ui/dr-ui/src/lib.rs#L613), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) |
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1461`](../ui/dr-ui/src/lib.rs#L1461), [`ui/dr-ui/src/lib.rs:2162`](../ui/dr-ui/src/lib.rs#L2162), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:1766`](../ui/dr-ui/ui/app.slint#L1766), [`ui/dr-ui/ui/app.slint:793`](../ui/dr-ui/ui/app.slint#L793), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:616`](../ui/dr-ui/src/lib.rs#L616), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) |
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) |
| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4636`](../ui/dr-ui/src/develop.rs#L4636) |
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) |
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) |
| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2148`](../ui/dr-ui/src/lib.rs#L2148), [`ui/dr-ui/src/lib.rs:546`](../ui/dr-ui/src/lib.rs#L546), [`ui/dr-ui/src/lib.rs:604`](../ui/dr-ui/src/lib.rs#L604), [`ui/dr-ui/src/library.rs:498`](../ui/dr-ui/src/library.rs#L498), [`ui/dr-ui/src/library.rs:747`](../ui/dr-ui/src/library.rs#L747), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:1006`](../ui/dr-ui/ui/adjust.slint#L1006), [`ui/dr-ui/ui/adjust.slint:924`](../ui/dr-ui/ui/adjust.slint#L924), [`ui/dr-ui/ui/app.slint:2251`](../ui/dr-ui/ui/app.slint#L2251), [`ui/dr-ui/ui/app.slint:568`](../ui/dr-ui/ui/app.slint#L568) |
| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2136`](../ui/dr-ui/src/lib.rs#L2136), [`ui/dr-ui/src/lib.rs:549`](../ui/dr-ui/src/lib.rs#L549), [`ui/dr-ui/src/lib.rs:607`](../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/library.rs:497`](../ui/dr-ui/src/library.rs#L497), [`ui/dr-ui/src/library.rs:745`](../ui/dr-ui/src/library.rs#L745), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:1006`](../ui/dr-ui/ui/adjust.slint#L1006), [`ui/dr-ui/ui/adjust.slint:924`](../ui/dr-ui/ui/adjust.slint#L924), [`ui/dr-ui/ui/app.slint:2256`](../ui/dr-ui/ui/app.slint#L2256), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571) |
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:205`](../core/dr-pipeline/src/framing.rs#L205), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:927`](../core/dr-pipeline/src/framing.rs#L927), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1992`](../ui/dr-ui/src/develop.rs#L1992), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) |
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) |
| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1420`](../ui/dr-ui/src/lib.rs#L1420), [`ui/dr-ui/src/lib.rs:1450`](../ui/dr-ui/src/lib.rs#L1450), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/library.rs:1656`](../ui/dr-ui/src/library.rs#L1656), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library.rs:488`](../ui/dr-ui/src/library.rs#L488), [`ui/dr-ui/src/library_ui.rs:2577`](../ui/dr-ui/src/library_ui.rs#L2577), [`ui/dr-ui/src/library_ui.rs:2979`](../ui/dr-ui/src/library_ui.rs#L2979), [`ui/dr-ui/src/library_ui.rs:467`](../ui/dr-ui/src/library_ui.rs#L467), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:547`](../ui/dr-ui/src/settings_ui.rs#L547), [`ui/dr-ui/ui/adjust.slint:613`](../ui/dr-ui/ui/adjust.slint#L613), [`ui/dr-ui/ui/library.slint:1328`](../ui/dr-ui/ui/library.slint#L1328), [`ui/dr-ui/ui/library.slint:837`](../ui/dr-ui/ui/library.slint#L837), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) |
| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1479`](../ui/dr-ui/src/lib.rs#L1479), [`ui/dr-ui/src/lib.rs:2441`](../ui/dr-ui/src/lib.rs#L2441), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:700`](../ui/dr-ui/ui/adjust.slint#L700), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1666`](../ui/dr-ui/ui/app.slint#L1666), [`ui/dr-ui/ui/app.slint:1839`](../ui/dr-ui/ui/app.slint#L1839), [`ui/dr-ui/ui/app.slint:2157`](../ui/dr-ui/ui/app.slint#L2157), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) |
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:754`](../ui/dr-ui/src/lib.rs#L754), [`ui/dr-ui/src/lib.rs:813`](../ui/dr-ui/src/lib.rs#L813) |
| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:1439`](../ui/dr-ui/src/lib.rs#L1439), [`ui/dr-ui/src/lib.rs:1447`](../ui/dr-ui/src/lib.rs#L1447), [`ui/dr-ui/src/lib.rs:2332`](../ui/dr-ui/src/lib.rs#L2332), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1376`](../ui/dr-ui/src/lib.rs#L1376), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:487`](../ui/dr-ui/src/library.rs#L487), [`ui/dr-ui/src/library_ui.rs:2592`](../ui/dr-ui/src/library_ui.rs#L2592), [`ui/dr-ui/src/library_ui.rs:2992`](../ui/dr-ui/src/library_ui.rs#L2992), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:549`](../ui/dr-ui/src/settings_ui.rs#L549), [`ui/dr-ui/ui/adjust.slint:613`](../ui/dr-ui/ui/adjust.slint#L613), [`ui/dr-ui/ui/library.slint:1328`](../ui/dr-ui/ui/library.slint#L1328), [`ui/dr-ui/ui/library.slint:837`](../ui/dr-ui/ui/library.slint#L837), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) |
| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1447`](../ui/dr-ui/src/lib.rs#L1447), [`ui/dr-ui/src/lib.rs:2332`](../ui/dr-ui/src/lib.rs#L2332), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1468`](../ui/dr-ui/src/lib.rs#L1468), [`ui/dr-ui/src/lib.rs:2429`](../ui/dr-ui/src/lib.rs#L2429), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:700`](../ui/dr-ui/ui/adjust.slint#L700), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1671`](../ui/dr-ui/ui/app.slint#L1671), [`ui/dr-ui/ui/app.slint:1844`](../ui/dr-ui/ui/app.slint#L1844), [`ui/dr-ui/ui/app.slint:2162`](../ui/dr-ui/ui/app.slint#L2162), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) |
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:757`](../ui/dr-ui/src/lib.rs#L757), [`ui/dr-ui/src/lib.rs:816`](../ui/dr-ui/src/lib.rs#L816) |
| FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101) |
| FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) |
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1435`](../ui/dr-ui/src/lib.rs#L1435), [`ui/dr-ui/src/lib.rs:2647`](../ui/dr-ui/src/lib.rs#L2647) |
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1535`](../ui/dr-ui/src/lib.rs#L1535), [`ui/dr-ui/src/lib.rs:314`](../ui/dr-ui/src/lib.rs#L314), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/develop.rs:5519`](../ui/dr-ui/src/develop.rs#L5519), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:1435`](../ui/dr-ui/src/lib.rs#L1435), [`ui/dr-ui/src/lib.rs:2624`](../ui/dr-ui/src/lib.rs#L2624), [`ui/dr-ui/src/lib.rs:2647`](../ui/dr-ui/src/lib.rs#L2647), [`ui/dr-ui/ui/app.slint:1526`](../ui/dr-ui/ui/app.slint#L1526), [`ui/dr-ui/ui/app.slint:47`](../ui/dr-ui/ui/app.slint#L47), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:731`](../ui/dr-ui/ui/settings.slint#L731) |
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1424`](../ui/dr-ui/src/lib.rs#L1424), [`ui/dr-ui/src/lib.rs:2635`](../ui/dr-ui/src/lib.rs#L2635) |
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1524`](../ui/dr-ui/src/lib.rs#L1524), [`ui/dr-ui/src/lib.rs:314`](../ui/dr-ui/src/lib.rs#L314), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/develop.rs:5519`](../ui/dr-ui/src/develop.rs#L5519), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1398`](../ui/dr-ui/src/lib.rs#L1398), [`ui/dr-ui/src/lib.rs:1424`](../ui/dr-ui/src/lib.rs#L1424), [`ui/dr-ui/src/lib.rs:2612`](../ui/dr-ui/src/lib.rs#L2612), [`ui/dr-ui/src/lib.rs:2635`](../ui/dr-ui/src/lib.rs#L2635), [`ui/dr-ui/ui/app.slint:1531`](../ui/dr-ui/ui/app.slint#L1531), [`ui/dr-ui/ui/app.slint:47`](../ui/dr-ui/ui/app.slint#L47), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:731`](../ui/dr-ui/ui/settings.slint#L731) |
| FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:600`](../core/dr-types/src/settings.rs#L600), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) |
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204), [`ui/dr-ui/src/lib.rs:2090`](../ui/dr-ui/src/lib.rs#L2090), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/lib.rs:397`](../ui/dr-ui/src/lib.rs#L397), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library_ui.rs:3365`](../ui/dr-ui/src/library_ui.rs#L3365), [`ui/dr-ui/src/library_ui.rs:558`](../ui/dr-ui/src/library_ui.rs#L558), [`ui/dr-ui/src/library_ui.rs:6372`](../ui/dr-ui/src/library_ui.rs#L6372), [`ui/dr-ui/src/library_ui.rs:6449`](../ui/dr-ui/src/library_ui.rs#L6449), [`ui/dr-ui/src/library_ui.rs:6461`](../ui/dr-ui/src/library_ui.rs#L6461), [`ui/dr-ui/src/library_ui.rs:661`](../ui/dr-ui/src/library_ui.rs#L661), [`ui/dr-ui/src/library_ui.rs:718`](../ui/dr-ui/src/library_ui.rs#L718), [`ui/dr-ui/ui/app.slint:1367`](../ui/dr-ui/ui/app.slint#L1367), [`ui/dr-ui/ui/app.slint:926`](../ui/dr-ui/ui/app.slint#L926), [`ui/dr-ui/ui/library.slint:1336`](../ui/dr-ui/ui/library.slint#L1336), [`ui/dr-ui/ui/library.slint:841`](../ui/dr-ui/ui/library.slint#L841), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:604`](../ui/dr-ui/src/settings_ui.rs#L604) |
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204), [`ui/dr-ui/src/lib.rs:2078`](../ui/dr-ui/src/lib.rs#L2078), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/lib.rs:397`](../ui/dr-ui/src/lib.rs#L397), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library_ui.rs:3375`](../ui/dr-ui/src/library_ui.rs#L3375), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`ui/dr-ui/src/library_ui.rs:6371`](../ui/dr-ui/src/library_ui.rs#L6371), [`ui/dr-ui/src/library_ui.rs:6448`](../ui/dr-ui/src/library_ui.rs#L6448), [`ui/dr-ui/src/library_ui.rs:6460`](../ui/dr-ui/src/library_ui.rs#L6460), [`ui/dr-ui/src/library_ui.rs:663`](../ui/dr-ui/src/library_ui.rs#L663), [`ui/dr-ui/src/library_ui.rs:720`](../ui/dr-ui/src/library_ui.rs#L720), [`ui/dr-ui/ui/app.slint:1372`](../ui/dr-ui/ui/app.slint#L1372), [`ui/dr-ui/ui/app.slint:929`](../ui/dr-ui/ui/app.slint#L929), [`ui/dr-ui/ui/library.slint:1336`](../ui/dr-ui/ui/library.slint#L1336), [`ui/dr-ui/ui/library.slint:841`](../ui/dr-ui/ui/library.slint#L841), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:619`](../ui/dr-ui/src/export.rs#L619), [`ui/dr-ui/src/export.rs:647`](../ui/dr-ui/src/export.rs#L647), [`ui/dr-ui/src/export.rs:777`](../ui/dr-ui/src/export.rs#L777), [`ui/dr-ui/src/export.rs:794`](../ui/dr-ui/src/export.rs#L794), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2829`](../ui/dr-ui/src/develop.rs#L2829), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362) |
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library.rs:1049`](../ui/dr-ui/src/library.rs#L1049), [`ui/dr-ui/src/library.rs:1693`](../ui/dr-ui/src/library.rs#L1693), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library_ui.rs:1607`](../ui/dr-ui/src/library_ui.rs#L1607), [`ui/dr-ui/src/library_ui.rs:3365`](../ui/dr-ui/src/library_ui.rs#L3365), [`ui/dr-ui/src/library_ui.rs:499`](../ui/dr-ui/src/library_ui.rs#L499), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:904`](../core/dr-sync-nextcloud/src/lib.rs#L904), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) |
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4593`](../ui/dr-ui/src/library_ui.rs#L4593), [`ui/dr-ui/ui/app.slint:316`](../ui/dr-ui/ui/app.slint#L316), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) |
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`ui/dr-ui/src/launch.rs:277`](../ui/dr-ui/src/launch.rs#L277), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:417`](../ui/dr-ui/src/launch_ui.rs#L417) |
| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068), [`ui/dr-ui/src/library.rs:1823`](../ui/dr-ui/src/library.rs#L1823), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1622`](../ui/dr-ui/src/library_ui.rs#L1622), [`ui/dr-ui/src/library_ui.rs:3375`](../ui/dr-ui/src/library_ui.rs#L3375), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:39`](../core/dr-sync-nextcloud/src/lib.rs#L39), [`core/dr-sync-nextcloud/src/lib.rs:913`](../core/dr-sync-nextcloud/src/lib.rs#L913), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) |
| FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:316`](../ui/dr-ui/src/launch_ui.rs#L316), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) |
| FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`core/dr-sync/src/account.rs:59`](../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:319`](../ui/dr-ui/ui/app.slint#L319), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
| FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../core/dr-sync-nextcloud/src/propfind.rs#L103), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61) |
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488) |
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:1014`](../core/dr-catalog/src/schema.rs#L1014), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1819`](../ui/dr-ui/src/lib.rs#L1819), [`ui/dr-ui/src/lib.rs:2710`](../ui/dr-ui/src/lib.rs#L2710), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1458`](../ui/dr-ui/src/library.rs#L1458), [`ui/dr-ui/src/library.rs:1616`](../ui/dr-ui/src/library.rs#L1616), [`ui/dr-ui/src/library_ui.rs:1066`](../ui/dr-ui/src/library_ui.rs#L1066), [`ui/dr-ui/src/library_ui.rs:1139`](../ui/dr-ui/src/library_ui.rs#L1139), [`ui/dr-ui/src/library_ui.rs:1240`](../ui/dr-ui/src/library_ui.rs#L1240), [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295), [`ui/dr-ui/src/library_ui.rs:1413`](../ui/dr-ui/src/library_ui.rs#L1413), [`ui/dr-ui/src/library_ui.rs:1532`](../ui/dr-ui/src/library_ui.rs#L1532), [`ui/dr-ui/src/library_ui.rs:2059`](../ui/dr-ui/src/library_ui.rs#L2059), [`ui/dr-ui/src/library_ui.rs:268`](../ui/dr-ui/src/library_ui.rs#L268), [`ui/dr-ui/src/library_ui.rs:279`](../ui/dr-ui/src/library_ui.rs#L279), [`ui/dr-ui/src/library_ui.rs:287`](../ui/dr-ui/src/library_ui.rs#L287), [`ui/dr-ui/src/library_ui.rs:299`](../ui/dr-ui/src/library_ui.rs#L299), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:400`](../ui/dr-ui/src/library_ui.rs#L400), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:452`](../ui/dr-ui/src/library_ui.rs#L452), [`ui/dr-ui/src/library_ui.rs:515`](../ui/dr-ui/src/library_ui.rs#L515), [`ui/dr-ui/src/library_ui.rs:5319`](../ui/dr-ui/src/library_ui.rs#L5319), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:558`](../ui/dr-ui/src/library_ui.rs#L558), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2330`](../ui/dr-ui/ui/app.slint#L2330), [`ui/dr-ui/ui/app.slint:431`](../ui/dr-ui/ui/app.slint#L431), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:980`](../ui/dr-ui/ui/library.slint#L980) |
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295) |
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1066`](../ui/dr-ui/src/library_ui.rs#L1066), [`ui/dr-ui/src/library_ui.rs:1139`](../ui/dr-ui/src/library_ui.rs#L1139), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372) |
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126) |
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1788`](../ui/dr-ui/src/lib.rs#L1788), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library_ui.rs:467`](../ui/dr-ui/src/library_ui.rs#L467) |
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library.rs:976`](../ui/dr-ui/src/library.rs#L976) |
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1014`](../core/dr-catalog/src/schema.rs#L1014), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1807`](../ui/dr-ui/src/lib.rs#L1807), [`ui/dr-ui/src/lib.rs:2698`](../ui/dr-ui/src/lib.rs#L2698), [`ui/dr-ui/src/library.rs:1449`](../ui/dr-ui/src/library.rs#L1449), [`ui/dr-ui/src/library.rs:1472`](../ui/dr-ui/src/library.rs#L1472), [`ui/dr-ui/src/library.rs:1747`](../ui/dr-ui/src/library.rs#L1747), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1429`](../ui/dr-ui/src/library_ui.rs#L1429), [`ui/dr-ui/src/library_ui.rs:1547`](../ui/dr-ui/src/library_ui.rs#L1547), [`ui/dr-ui/src/library_ui.rs:2074`](../ui/dr-ui/src/library_ui.rs#L2074), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:284`](../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:304`](../ui/dr-ui/src/library_ui.rs#L304), [`ui/dr-ui/src/library_ui.rs:313`](../ui/dr-ui/src/library_ui.rs#L313), [`ui/dr-ui/src/library_ui.rs:405`](../ui/dr-ui/src/library_ui.rs#L405), [`ui/dr-ui/src/library_ui.rs:415`](../ui/dr-ui/src/library_ui.rs#L415), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:520`](../ui/dr-ui/src/library_ui.rs#L520), [`ui/dr-ui/src/library_ui.rs:5318`](../ui/dr-ui/src/library_ui.rs#L5318), [`ui/dr-ui/src/library_ui.rs:5336`](../ui/dr-ui/src/library_ui.rs#L5336), [`ui/dr-ui/src/library_ui.rs:5348`](../ui/dr-ui/src/library_ui.rs#L5348), [`ui/dr-ui/src/library_ui.rs:551`](../ui/dr-ui/src/library_ui.rs#L551), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2335`](../ui/dr-ui/ui/app.slint#L2335), [`ui/dr-ui/ui/app.slint:434`](../ui/dr-ui/ui/app.slint#L434), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:980`](../ui/dr-ui/ui/library.slint#L980) |
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) |
| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:803`](../core/dr-sync-folder/src/lib.rs#L803), [`core/dr-sync-folder/src/lib.rs:842`](../core/dr-sync-folder/src/lib.rs#L842), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/derived_sync.rs:627`](../ui/dr-ui/src/derived_sync.rs#L627), [`ui/dr-ui/src/library.rs:1569`](../ui/dr-ui/src/library.rs#L1569), [`ui/dr-ui/src/library.rs:1659`](../ui/dr-ui/src/library.rs#L1659), [`ui/dr-ui/src/library.rs:3138`](../ui/dr-ui/src/library.rs#L3138), [`ui/dr-ui/src/library.rs:3476`](../ui/dr-ui/src/library.rs#L3476), [`ui/dr-ui/src/library.rs:948`](../ui/dr-ui/src/library.rs#L948), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1337`](../ui/dr-ui/src/library_ui.rs#L1337), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:104`](../core/dr-sync-nextcloud/src/lib.rs#L104), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372) |
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123) |
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1777`](../ui/dr-ui/src/lib.rs#L1777), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472) |
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library.rs:998`](../ui/dr-ui/src/library.rs#L998) |
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) |
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:863`](../ui/dr-ui/src/lib.rs#L863), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:866`](../ui/dr-ui/src/lib.rs#L866), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
| FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) |
| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) |
| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) |
@@ -111,38 +112,38 @@ _None._
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) |
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204) |
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) |
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2792`](../ui/dr-ui/src/lib.rs#L2792), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:164`](../ui/dr-ui/ui/identity.slint#L164), [`ui/dr-ui/ui/library.slint:1046`](../ui/dr-ui/ui/library.slint#L1046) |
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/lib.rs:87`](../ui/dr-ui/src/lib.rs#L87), [`ui/dr-ui/src/library_ui.rs:287`](../ui/dr-ui/src/library_ui.rs#L287), [`ui/dr-ui/src/library_ui.rs:5204`](../ui/dr-ui/src/library_ui.rs#L5204), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/src/library_ui.rs:6480`](../ui/dr-ui/src/library_ui.rs#L6480), [`ui/dr-ui/ui/adjust.slint:506`](../ui/dr-ui/ui/adjust.slint#L506), [`ui/dr-ui/ui/adjust.slint:641`](../ui/dr-ui/ui/adjust.slint#L641), [`ui/dr-ui/ui/adjust.slint:962`](../ui/dr-ui/ui/adjust.slint#L962), [`ui/dr-ui/ui/app.slint:1945`](../ui/dr-ui/ui/app.slint#L1945), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:468`](../ui/dr-ui/ui/app.slint#L468), [`ui/dr-ui/ui/app.slint:54`](../ui/dr-ui/ui/app.slint#L54), [`ui/dr-ui/ui/app.slint:761`](../ui/dr-ui/ui/app.slint#L761), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/masks.slint:304`](../ui/dr-ui/ui/masks.slint#L304), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) |
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/library_ui.rs:4388`](../ui/dr-ui/src/library_ui.rs#L4388), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1761`](../ui/dr-ui/ui/app.slint#L1761), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4388`](../ui/dr-ui/src/library_ui.rs#L4388), [`ui/dr-ui/src/library_ui.rs:4433`](../ui/dr-ui/src/library_ui.rs#L4433), [`ui/dr-ui/src/library_ui.rs:4536`](../ui/dr-ui/src/library_ui.rs#L4536), [`ui/dr-ui/src/library_ui.rs:4564`](../ui/dr-ui/src/library_ui.rs#L4564), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/ui/app.slint:1603`](../ui/dr-ui/ui/app.slint#L1603), [`ui/dr-ui/ui/app.slint:437`](../ui/dr-ui/ui/app.slint#L437), [`ui/dr-ui/ui/app.slint:468`](../ui/dr-ui/ui/app.slint#L468), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2406`](../ui/dr-ui/src/lib.rs#L2406), [`ui/dr-ui/src/lib.rs:2831`](../ui/dr-ui/src/lib.rs#L2831), [`ui/dr-ui/src/lib.rs:3016`](../ui/dr-ui/src/lib.rs#L3016), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:89`](../ui/dr-ui/ui/app.slint#L89), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2780`](../ui/dr-ui/src/lib.rs#L2780), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:164`](../ui/dr-ui/ui/identity.slint#L164), [`ui/dr-ui/ui/library.slint:1046`](../ui/dr-ui/ui/library.slint#L1046) |
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1488`](../ui/dr-ui/src/collections_ui.rs#L1488), [`ui/dr-ui/src/collections_ui.rs:1502`](../ui/dr-ui/src/collections_ui.rs#L1502), [`ui/dr-ui/src/collections_ui.rs:1548`](../ui/dr-ui/src/collections_ui.rs#L1548), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1646`](../ui/dr-ui/src/collections_ui.rs#L1646), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/lib.rs:87`](../ui/dr-ui/src/lib.rs#L87), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:5203`](../ui/dr-ui/src/library_ui.rs#L5203), [`ui/dr-ui/src/library_ui.rs:5348`](../ui/dr-ui/src/library_ui.rs#L5348), [`ui/dr-ui/src/library_ui.rs:6479`](../ui/dr-ui/src/library_ui.rs#L6479), [`ui/dr-ui/ui/adjust.slint:506`](../ui/dr-ui/ui/adjust.slint#L506), [`ui/dr-ui/ui/adjust.slint:641`](../ui/dr-ui/ui/adjust.slint#L641), [`ui/dr-ui/ui/adjust.slint:962`](../ui/dr-ui/ui/adjust.slint#L962), [`ui/dr-ui/ui/app.slint:1950`](../ui/dr-ui/ui/app.slint#L1950), [`ui/dr-ui/ui/app.slint:464`](../ui/dr-ui/ui/app.slint#L464), [`ui/dr-ui/ui/app.slint:471`](../ui/dr-ui/ui/app.slint#L471), [`ui/dr-ui/ui/app.slint:54`](../ui/dr-ui/ui/app.slint#L54), [`ui/dr-ui/ui/app.slint:764`](../ui/dr-ui/ui/app.slint#L764), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/masks.slint:304`](../ui/dr-ui/ui/masks.slint#L304), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) |
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1766`](../ui/dr-ui/ui/app.slint#L1766), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1488`](../ui/dr-ui/src/collections_ui.rs#L1488), [`ui/dr-ui/src/collections_ui.rs:1502`](../ui/dr-ui/src/collections_ui.rs#L1502), [`ui/dr-ui/src/collections_ui.rs:1548`](../ui/dr-ui/src/collections_ui.rs#L1548), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1646`](../ui/dr-ui/src/collections_ui.rs#L1646), [`ui/dr-ui/src/collections_ui.rs:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/library_ui.rs:4432`](../ui/dr-ui/src/library_ui.rs#L4432), [`ui/dr-ui/src/library_ui.rs:4535`](../ui/dr-ui/src/library_ui.rs#L4535), [`ui/dr-ui/src/library_ui.rs:4563`](../ui/dr-ui/src/library_ui.rs#L4563), [`ui/dr-ui/src/library_ui.rs:5336`](../ui/dr-ui/src/library_ui.rs#L5336), [`ui/dr-ui/src/library_ui.rs:5348`](../ui/dr-ui/src/library_ui.rs#L5348), [`ui/dr-ui/ui/app.slint:1608`](../ui/dr-ui/ui/app.slint#L1608), [`ui/dr-ui/ui/app.slint:440`](../ui/dr-ui/ui/app.slint#L440), [`ui/dr-ui/ui/app.slint:471`](../ui/dr-ui/ui/app.slint#L471), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2394`](../ui/dr-ui/src/lib.rs#L2394), [`ui/dr-ui/src/lib.rs:2819`](../ui/dr-ui/src/lib.rs#L2819), [`ui/dr-ui/src/lib.rs:3004`](../ui/dr-ui/src/lib.rs#L3004), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:89`](../ui/dr-ui/ui/app.slint#L89), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262) |
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2829`](../ui/dr-ui/src/library.rs#L2829) |
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:2124`](../ui/dr-ui/src/lib.rs#L2124), [`ui/dr-ui/ui/app.slint:937`](../ui/dr-ui/ui/app.slint#L937), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2953`](../ui/dr-ui/src/library.rs#L2953) |
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2112`](../ui/dr-ui/src/lib.rs#L2112), [`ui/dr-ui/ui/app.slint:940`](../ui/dr-ui/ui/app.slint#L940), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:499`](../ui/dr-ui/src/export.rs#L499) |
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
| NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
| NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
| NFR-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:3795`](../ui/dr-ui/src/library.rs#L3795), [`ui/dr-ui/src/library.rs:4568`](../ui/dr-ui/src/library.rs#L4568), [`ui/dr-ui/src/library_ui.rs:4073`](../ui/dr-ui/src/library_ui.rs#L4073), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) |
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
| NFR-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:4003`](../ui/dr-ui/src/library.rs#L4003), [`ui/dr-ui/src/library.rs:4795`](../ui/dr-ui/src/library.rs#L4795), [`ui/dr-ui/src/library_ui.rs:4072`](../ui/dr-ui/src/library_ui.rs#L4072), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) |
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) |
| NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) |
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:1049`](../ui/dr-ui/src/library.rs#L1049) |
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068) |
| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72) |
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724) |
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848) |
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
| NFR-SEC-2 | [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
| NFR-SEC-2 | [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
| NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
## Not yet tagged
71 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
72 of 179 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
<details><summary>Show untagged requirements</summary>
@@ -156,6 +157,7 @@ _None._
- FR-DSP-2
- FR-DSP-4
- FR-NC-11
- FR-NC-6d
- FR-PLAT-AND-2
- FR-PLAT-AND-4
- FR-PLAT-AND-5
+4
View File
@@ -25,6 +25,7 @@ tokio.workspace = true
reqwest.workspace = true
dr-plat.workspace = true
dr-sync.workspace = true
dr-sync-folder.workspace = true
dr-sync-nextcloud.workspace = true
dr-export.workspace = true
dr-ingest.workspace = true
@@ -119,3 +120,6 @@ live-style = ["dep:serde_norway"]
# The face_index batch job wants a log level from the environment; the library
# itself only ever calls `log`, and picks up whatever the application installs.
env_logger.workspace = true
# Standing in a test backend behind `dyn RemoteBackend`, which is an
# `#[async_trait]` trait — implementing one needs the same attribute.
async-trait.workspace = true
+16 -37
View File
@@ -1167,16 +1167,11 @@ fn start_trash(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some((creds, sess)) = session() else {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
@@ -1184,7 +1179,7 @@ fn start_trash(
let moves = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match crate::trash::plan_trash(cat, &sess.root, images) {
match crate::trash::plan_trash(cat, &conn.account.root, images) {
Ok(m) => m,
Err(e) => {
window.set_collection_error(format!("planning delete: {e}").into());
@@ -1205,11 +1200,10 @@ fn start_trash(
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
conn.clone(),
moves,
crate::trash::Direction::ToTrash,
crate::library::catalog_path(&sess.server, &sess.user_id),
crate::library::catalog_path(&conn.account),
);
drain_trash(
@@ -1239,16 +1233,11 @@ fn start_restore(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some((creds, sess)) = session() else {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
@@ -1282,11 +1271,10 @@ fn start_restore(
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
conn.clone(),
moves,
crate::trash::Direction::Restore,
crate::library::catalog_path(&sess.server, &sess.user_id),
crate::library::catalog_path(&conn.account),
);
drain_trash(
@@ -1466,10 +1454,7 @@ pub fn wire<S, R, P, C>(
S: Fn() + 'static,
R: Fn() -> Vec<ImageId> + 'static,
P: Fn(usize, usize) -> Vec<ImageId> + 'static,
C: Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)> + 'static,
C: Fn() -> Option<dr_sync::Connection> + 'static,
{
// Coerced to trait objects here rather than at each use: `start_trash` and
// `drain_trash` are shared by three callbacks, and a generic parameter would
@@ -1479,12 +1464,7 @@ pub fn wire<S, R, P, C>(
// A shift-click asks the catalog what lies between its two ends, and the
// catalog belongs to the grid's controller — see `span_source`.
*ctl.span_source.borrow_mut() = Some(Rc::new(span_ids));
let session: Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
> = Rc::new(session);
let session: Rc<dyn Fn() -> Option<dr_sync::Connection>> = Rc::new(session);
// --- selection ---------------------------------------------------------
{
@@ -2098,8 +2078,8 @@ pub fn wire<S, R, P, C>(
window.on_trash_empty(move || {
let Some(w) = weak.upgrade() else { return };
let (creds, sess) = match session() {
Some(s) => s,
let conn = match session() {
Some(c) => c,
None => return,
};
@@ -2131,12 +2111,11 @@ pub fn wire<S, R, P, C>(
let count = ids.len();
let rx = crate::trash::spawn_purge(
creds,
sess.user_id.clone(),
conn.clone(),
ids,
paths,
crate::library::catalog_path(&sess.server, &sess.user_id),
crate::library::thumbs_dir(&sess.server, &sess.user_id),
crate::library::catalog_path(&conn.account),
crate::library::thumbs_dir(&conn.account),
);
drain_trash(
+238 -11
View File
@@ -1,5 +1,5 @@
//! TRACES: FR-CAT-3 | FR-CAT-7 | FR-NC-7
//! Pushing derived state to Nextcloud: thumbnail shards and the catalog.
//! Pushing derived state to the library: thumbnail shards and the catalog.
//!
//! # What travels, and why only this
//!
@@ -34,8 +34,8 @@
use std::path::{Path, PathBuf};
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Connection, RemoteBackend, RemoteError, RemoteId, RemotePath};
use dr_thumbs::ThumbStore;
/// Folder under the library root holding derived state.
@@ -96,8 +96,7 @@ pub enum SyncMessage {
/// Runs on its own thread with its own runtime, like every other network path
/// here — the Slint loop must never block (NFR-P9).
pub fn spawn_sync(
creds: AppCredentials,
user_id: String,
conn: Connection,
root: String,
thumbs_dir: PathBuf,
catalog_path: PathBuf,
@@ -117,7 +116,7 @@ pub fn spawn_sync(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(SyncMessage::Failed(e.to_string()));
@@ -282,7 +281,10 @@ async fn sync_shards(
}
let source = RemotePath::new(format!("{}/{name}", base.as_str()));
let bytes = match backend.get(&RemoteId::Path(source), None).await {
// Fetched where it is only a placeholder: a shard that will not open
// is a peer's thumbnails never merging, and on a library the client
// keeps dehydrated that would be every shard, every pass, silently.
let bytes = match read_derived(backend, &source).await {
Ok(b) => b,
Err(e) => {
log::warn!("downloading {name}: {e}");
@@ -465,7 +467,9 @@ async fn sync_face_shards(
)));
let source = RemotePath::new(format!("{}/{name}", face_base.as_str()));
let bytes = match backend.get(&RemoteId::Path(source), None).await {
// Fetched where it is only a placeholder, for the reason the thumbnail
// shards are: otherwise a peer's faces never arrive and nothing says so.
let bytes = match read_derived(backend, &source).await {
Ok(b) => b,
Err(e) => {
log::warn!("downloading face shard {name}: {e}");
@@ -548,7 +552,30 @@ async fn sync_catalog(
// Merging before uploading means our upload carries the union rather than
// only our own half, so a third device syncing next gets everything in one
// fetch.
if let Ok(bytes) = backend.get(&RemoteId::Path(target.clone()), None).await {
// TRACES: FR-NC-9 | FR-NC-6c
// A read that fails for any reason other than "there is not one yet" must
// stop the upload below. This is a read-modify-write over a file another
// device also writes, so skipping the read does not merely lose an
// optimisation — it turns the write into a clobber, and the other device's
// collections and their members go with it.
//
// The shape was previously `if let Ok(bytes) = ...`, which swallowed every
// failure into "no remote catalog" and carried straight on to the upload.
let theirs = match read_derived(backend, &target).await {
Ok(bytes) => Some(bytes),
// Genuinely the first sync of this library. Nothing to merge, and
// ours is the whole truth.
Err(RemoteError::NotFound(_)) => None,
Err(e) => {
log::warn!(
"not pushing the catalog: the copy on the server could not be read ({e}); \
uploading over it would discard whatever another device put there"
);
return Ok(());
}
};
if let Some(bytes) = theirs {
let downloaded = scratch.join("catalog-remote.sqlite");
if std::fs::write(&downloaded, &bytes).is_ok() {
match dr_catalog::Catalog::open(catalog_path) {
@@ -558,9 +585,19 @@ async fn sync_catalog(
report.collections_gained = merge.inserted + merge.updated;
report.members_gained = merge.members_added;
}
Err(e) => log::warn!("merging remote catalog: {e}"),
// Unreadable is not the same as absent: it may be a newer
// format, or a torn upload. Ours must not go over it.
Err(e) => {
log::warn!("not pushing the catalog: merging the server's copy: {e}");
let _ = std::fs::remove_file(&downloaded);
return Ok(());
}
},
Err(e) => log::warn!("opening catalog to merge: {e}"),
Err(e) => {
log::warn!("not pushing the catalog: opening ours to merge: {e}");
let _ = std::fs::remove_file(&downloaded);
return Ok(());
}
}
let _ = std::fs::remove_file(&downloaded);
}
@@ -587,6 +624,34 @@ async fn sync_catalog(
Ok(())
}
/// TRACES: FR-NC-6c
/// Read a derived file, fetching its content first if only a placeholder is
/// here.
///
/// Derived state lives *inside the library folder*, so on a placeholder
/// library a sync client dehydrates a shard or a catalog snapshot exactly as
/// it dehydrates a photograph. Unlike a photograph, these are ours, and none of
/// them can be skipped: a shard that will not open is face data that never
/// merges, and a catalog snapshot that will not open is the other device's
/// collections.
///
/// So this fetches rather than giving up — and where it cannot, it says so
/// with the error rather than an empty result, because the callers below treat
/// "nothing there" as licence to write their own copy (ARCH §9.0a).
async fn read_derived(
backend: &dyn RemoteBackend,
path: &RemotePath,
) -> Result<Vec<u8>, RemoteError> {
let id = RemoteId::Path(path.clone());
match backend.get(&id, None).await {
Err(RemoteError::NotMaterialised(_)) => {
backend.materialise(&id).await?;
backend.get(&id, None).await
}
other => other,
}
}
fn shard_name(client: &str, id: u32) -> String {
format!("shard-{client}-{id:04}.sqlite")
}
@@ -688,3 +753,165 @@ mod tests {
.did_anything());
}
}
#[cfg(test)]
mod catalog_guard_tests {
//! What `sync_catalog` does when it cannot read the server's copy.
//!
//! The bug these exist for was a control-flow one — `if let Ok(bytes)`
//! folding every failure into "there is none yet" and falling through to
//! the upload — so the thing to assert is not a value but *whether a write
//! happened at all*.
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
/// A backend whose read fails in a chosen way, counting writes.
struct Fussy {
fail_with: Option<RemoteError>,
puts: Arc<AtomicUsize>,
caps: dr_sync::Capabilities,
}
impl Fussy {
fn reading(fail_with: Option<RemoteError>) -> (Self, Arc<AtomicUsize>) {
let puts = Arc::new(AtomicUsize::new(0));
(
Self {
fail_with,
puts: puts.clone(),
caps: dr_sync::Capabilities::minimal(),
},
puts,
)
}
}
#[async_trait::async_trait]
impl RemoteBackend for Fussy {
fn capabilities(&self) -> &dr_sync::Capabilities {
&self.caps
}
fn name(&self) -> &str {
"fussy"
}
async fn list(
&self,
_dir: &RemotePath,
_since: Option<&dr_sync::Validator>,
) -> Result<Vec<dr_sync::RemoteEntry>, RemoteError> {
Ok(Vec::new())
}
async fn dir_validator(
&self,
_dir: &RemotePath,
) -> Result<dr_sync::Validator, RemoteError> {
Err(RemoteError::Unsupported("test"))
}
async fn delta(
&self,
_c: &dr_sync::Cursor,
) -> Result<(Vec<dr_sync::RemoteChange>, dr_sync::Cursor), RemoteError> {
Err(RemoteError::Unsupported("test"))
}
async fn get(
&self,
_id: &RemoteId,
_r: Option<std::ops::Range<u64>>,
) -> Result<Vec<u8>, RemoteError> {
match &self.fail_with {
Some(RemoteError::NotFound(s)) => Err(RemoteError::NotFound(s.clone())),
Some(RemoteError::NotMaterialised(s)) => {
Err(RemoteError::NotMaterialised(s.clone()))
}
Some(_) => Err(RemoteError::PermissionDenied),
None => Ok(Vec::new()),
}
}
async fn put(
&self,
_p: &RemotePath,
_b: Vec<u8>,
_pc: Option<dr_sync::Precondition>,
) -> Result<dr_sync::Validator, RemoteError> {
self.puts.fetch_add(1, Ordering::SeqCst);
Ok(dr_sync::Validator::new("v"))
}
async fn delete(
&self,
_id: &RemoteId,
_pc: Option<dr_sync::Precondition>,
) -> Result<(), RemoteError> {
Ok(())
}
async fn move_to(&self, _f: &RemoteId, _t: &RemotePath) -> Result<(), RemoteError> {
Ok(())
}
async fn create_dir(&self, _p: &RemotePath) -> Result<(), RemoteError> {
Ok(())
}
}
/// A real catalog and a scratch directory, since `sync_catalog` snapshots
/// one before uploading.
fn fixture(name: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("dr-catalog-guard-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("scratch")).unwrap();
let catalog_path = dir.join("catalog.sqlite");
dr_catalog::Catalog::open(&catalog_path).unwrap();
(catalog_path, dir.join("scratch"))
}
async fn run_with(fail_with: Option<RemoteError>, name: &str) -> (usize, SyncReport) {
let (catalog_path, scratch) = fixture(name);
let (backend, puts) = Fussy::reading(fail_with);
let mut report = SyncReport::default();
sync_catalog(
&backend,
&RemotePath::new(".darkroom-derived"),
&catalog_path,
&scratch,
&mut report,
)
.await
.unwrap();
let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap());
(puts.load(Ordering::SeqCst), report)
}
#[tokio::test]
async fn a_catalog_that_is_here_but_not_downloaded_is_never_written_over() {
// The bug. On a placeholder library the snapshot is dehydrated, the
// read fails, and the old code took that for "there is no remote
// catalog" and pushed ours — discarding the other device's
// collections and their members on every single sync.
let (puts, report) = run_with(
Some(RemoteError::NotMaterialised("catalog.sqlite".into())),
"notmaterialised",
)
.await;
assert_eq!(puts, 0, "must not upload over a catalog it could not read");
assert!(!report.catalog_uploaded);
assert!(!report.catalog_merged);
}
#[tokio::test]
async fn a_catalog_that_cannot_be_read_at_all_is_never_written_over() {
// Not only placeholders: a refused read, a dropped connection. Any
// failure that is not "there is none" leaves the server's copy alone.
let (puts, _) = run_with(Some(RemoteError::PermissionDenied), "denied").await;
assert_eq!(puts, 0);
}
#[tokio::test]
async fn the_first_sync_of_a_library_still_uploads() {
// The other half, and the reason `NotFound` had to stay distinct: with
// genuinely nothing on the server, ours *is* the whole truth and
// refusing to push it would mean the catalog never syncs at all.
let (puts, report) = run_with(Some(RemoteError::NotFound("nope".into())), "firstrun").await;
assert_eq!(puts, 1, "nothing to merge, so ours goes up");
assert!(report.catalog_uploaded);
}
}
+11 -13
View File
@@ -62,7 +62,7 @@ use std::time::Duration;
use dr_export::{Encoded, NameContext};
use dr_sync::RemotePath;
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection};
use dr_types::{ExportSettings, ExportTarget};
use crate::AppWindow;
@@ -72,8 +72,8 @@ use crate::AppWindow;
/// Beside the catalog, for the reason in the module docs. Per account,
/// because the destination folder is a path on one particular server and an
/// entry queued for one account is meaningless to another.
pub fn outbox_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
pub fn outbox_dir(account: &Account) -> PathBuf {
crate::library::catalog_path(account)
.parent()
.map(|p| p.join("outbox"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-outbox"))
@@ -146,7 +146,7 @@ impl Placed {
),
// Named as queued rather than exported: the file is real and
// finished, but it is not yet where the user asked for it, and
// saying "exported to Nextcloud" before it has uploaded would be
// saying "exported to the library" before it has uploaded would be
// a claim the app cannot keep if the disk is pulled.
Placed::Queued { remote_dir, .. } => {
let dir = if remote_dir.is_empty() {
@@ -318,8 +318,7 @@ pub enum UploadMessage {
/// network error would burn the whole queue against a server that is not
/// answering, and the next pass costs nothing.
pub fn spawn_upload(
creds: AppCredentials,
user_id: String,
conn: Connection,
root: String,
outbox: PathBuf,
) -> std::sync::mpsc::Receiver<UploadMessage> {
@@ -339,7 +338,7 @@ pub fn spawn_upload(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(UploadMessage::Finished {
@@ -486,7 +485,7 @@ pub struct BatchRequest {
/// Credentials for the account the library is open on. `None` where no
/// library is open, which is fine for a [`Source::Rendered`] and fatal for
/// anything that has to be fetched.
pub creds: Option<(AppCredentials, String)>,
pub conn: Option<Connection>,
pub settings: ExportSettings,
pub outbox: PathBuf,
pub sidecar_cache: PathBuf,
@@ -692,7 +691,7 @@ fn render_from_library(
cache: Option<crate::library::CacheContext>,
cancel: &Cancel,
) -> Option<Result<RenderedItem, ItemError>> {
let Some((creds, user_id)) = request.creds.clone() else {
let Some(conn) = request.conn.clone() else {
return Some(Err(ItemError::Fetch("no library is open".into())));
};
let Some(gpu) = request.gpu.as_ref() else {
@@ -706,13 +705,12 @@ fn render_from_library(
// RAW, so it costs nothing to have in hand by the time there is a session
// to apply it to.
let sidecar_rx = crate::library::spawn_sidecar_fetch(
creds.clone(),
user_id.clone(),
conn.clone(),
path.to_string(),
request.sidecar_cache.clone(),
request.offline,
);
let bytes_rx = crate::library::spawn_full_fetch(creds, user_id, path.to_string(), cache);
let bytes_rx = crate::library::spawn_full_fetch(conn, path.to_string(), cache);
let bytes = match wait_for(&bytes_rx, cancel) {
Waited::Got(Ok(bytes)) => bytes,
@@ -1332,7 +1330,7 @@ mod tests {
fn request(settings: ExportSettings, sources: Vec<Source>) -> BatchRequest {
BatchRequest {
sources,
creds: None,
conn: None,
settings,
outbox: std::env::temp_dir().join("dr-batch-test-outbox"),
sidecar_cache: std::env::temp_dir().join("dr-batch-test-sidecars"),
+78 -12
View File
@@ -506,21 +506,31 @@ pub fn recluster(
// Which faces the user has already ruled on, so they enter as anchors.
//
// Two kinds of ruling, and the second is easy to miss. A *confirmation* is
// the obvious one. But setting a person aside is a ruling too, and the
// faces it covers are only ever suggestions — so anchoring confirmations
// alone left every ignored group's faces loose, and the next Regroup
// scattered them into fresh unnamed groups that were not ignored. The
// strangers came straight back, which is the feature not working at all.
// Anything the user has ruled on anchors, and there are three ways of
// ruling — only the first of which is obvious.
//
// Anchoring them keeps them where the user put them, and does one better:
// a newly indexed face similar to a group that was set aside merges *into*
// it, so a stranger photographed again stays set aside instead of
// reappearing as somebody new.
// A **confirmation** is the plain case. **Setting a group aside** is one
// too, and the faces it covers are only ever suggestions, so anchoring
// confirmations alone let every ignored group scatter into fresh unnamed
// groups that were not ignored, and the strangers came straight back.
//
// And so is **giving a group a name**. That was the omission that did the
// most damage, because it is silent. Naming a cluster does not confirm its
// faces — they stay suggestions — so the next Regroup cut them loose,
// regrouped them into a brand new person, and left the named one holding
// nothing. `prune_empty_unnamed` will not remove it, because it has a name.
// Name the new group the same thing and it happens again. That is how one
// library came to hold sixteen people called Catherine, fourteen of them
// empty, with her faces split across the two that were not.
//
// A name is a judgement about *this group* (FR-CULL-12), exactly as an
// ignore is. Anchoring them all also does one better: a newly indexed face
// that matches a named person now merges *into* them rather than arriving
// as a stranger.
let mut confirmed = std::collections::HashMap::new();
for p in faces::people(conn)? {
// Suggestions included exactly when the group was set aside.
for f in faces::for_person(conn, p.id, p.ignored)? {
let ruled_on = p.ignored || !p.name.trim().is_empty();
for f in faces::for_person(conn, p.id, ruled_on)? {
confirmed.insert(f.id, p.id);
}
}
@@ -1178,4 +1188,60 @@ mod tests {
assert_eq!(after.len(), 1);
assert!(!after[0].ignored);
}
/// Naming a group does not confirm its faces, so before this they were
/// still only suggestions — and the next Regroup cut them loose, built a
/// new person out of them, and left the named one empty. Do that a few
/// times and the rail fills with same-named people holding nothing while
/// the faces sit under whichever one was made last.
#[test]
fn a_named_group_keeps_its_faces_through_the_next_regroup() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
let people = faces::people(catalog.connection()).unwrap();
assert_eq!(people.len(), 1);
let her = people[0].id;
assert_eq!(people[0].suggested_faces, 2);
// Named, and nothing else — no confirmations, which is what a user who
// types a name and moves on has done.
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(
after.len(),
1,
"regrouping left a second person behind: {after:?}"
);
assert_eq!(after[0].id, her);
assert_eq!(after[0].name, "Catherine");
assert_eq!(
after[0].suggested_faces, 2,
"the named group lost the faces it was named for"
);
}
/// And a face found later joins the person it matches rather than arriving
/// as somebody new — the same benefit anchoring gives an ignored group.
#[test]
fn a_new_face_joins_a_named_person_rather_than_starting_a_rival() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
let her = faces::people(catalog.connection()).unwrap()[0].id;
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
put_face(&catalog, 3, 0, 0.98);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}");
assert_eq!(after[0].suggested_faces, 3);
}
}
+5 -11
View File
@@ -355,15 +355,10 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
/// boundary would not be.
/// What the whole-library face pass needs to reach the server.
///
/// Credentials and not just paths, because the pass fetches its own pixels: an
/// A connection and not just paths, because the pass fetches its own pixels: an
/// image with no proxy is the ordinary case, not one to skip (see
/// `library::spawn_face_sweep`).
pub type SweepPaths = (
dr_sync_nextcloud::AppCredentials,
String,
std::path::PathBuf,
std::path::PathBuf,
);
pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf);
/// The detector and embedder files, when both are present.
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf);
@@ -689,7 +684,7 @@ pub fn wire<S, M, P>(
if ctl.regroup.borrow().is_some() {
return;
}
let Some((_, _, catalog_path, _)) = paths() else {
let Some((_, catalog_path, _)) = paths() else {
return;
};
@@ -781,7 +776,7 @@ pub fn wire<S, M, P>(
w.set_identity_model_missing(true);
return;
};
let Some((creds, user_id, catalog_path, store_dir)) = paths() else {
let Some((conn, catalog_path, store_dir)) = paths() else {
return;
};
@@ -790,8 +785,7 @@ pub fn wire<S, M, P>(
*ctl.activity.borrow_mut() =
Some(activity.begin(crate::activity::Kind::Index, "Indexing faces"));
*ctl.sweep.borrow_mut() = Some(crate::library::spawn_face_sweep(
creds,
user_id,
conn,
catalog_path,
store_dir,
detector,
+7 -8
View File
@@ -55,8 +55,8 @@ use std::sync::Arc;
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode};
use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage};
use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection, RemoteBackend, RemotePath};
use dr_types::{FormatFilter, RootId};
/// Which root the card is granted as, and which the library is.
@@ -103,8 +103,7 @@ pub struct Request {
/// an import, and the photographs exist on disk either way.
#[derive(Clone)]
pub struct Upload {
pub credentials: AppCredentials,
pub user_id: String,
pub conn: Connection,
/// The library folder on the server. The dated folders from the template
/// are created beneath it, the same ones the local copy went into.
pub library: String,
@@ -122,8 +121,8 @@ pub struct Upload {
/// TRACES: FR-NC-7b
/// Where an account's imports wait between disk and the server.
pub fn staging_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
pub fn staging_dir(account: &Account) -> PathBuf {
crate::library::catalog_path(account)
.parent()
.map(|p| p.join("staging"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-import-staging"))
@@ -133,7 +132,7 @@ impl std::fmt::Debug for Upload {
/// Hand-written so a credential cannot reach a log through a `{:?}`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Upload")
.field("user_id", &self.user_id)
.field("account", &self.conn.account.describe())
.field("library", &self.library)
.field("thumbs", &self.thumbs)
.field("staging", &self.staging)
@@ -385,7 +384,7 @@ fn upload_all(
};
rt.block_on(async {
let backend = match crate::remote::connect(&upload.credentials, &upload.user_id) {
let backend = match crate::remote::connect(&upload.conn) {
Ok(b) => b,
Err(e) => {
log::warn!("connecting to upload: {e}");
+80 -58
View File
@@ -3,8 +3,20 @@
//! The state machine lives here, separate from the Slint bindings, so it can
//! be tested without a display server. `dr-ui` owns presentation; what a
//! login *is* belongs to the connector.
//!
//! # Two shapes of sign-in, not two screens
//!
//! A Nextcloud account is established through a browser handshake the app
//! polls for; a folder library is established by naming a directory. The model
//! below does not know which is which — it asks the
//! [`BackendProvider`](dr_sync::BackendProvider) whether the account it is
//! being asked to make needs a credential
//! ([`SignIn`](dr_sync::SignIn)), and the screen draws the waiting state only
//! where there is something to wait for. Everything after that point — picking
//! a library root, ticking formats, opening the grid — is identical, because
//! it goes through `dr-sync` rather than through a connector.
use dr_sync_nextcloud::{Session, SessionStore};
use dr_sync::{Account, AccountStore};
use dr_types::{Format, FormatFilter};
/// What the launch screen is currently doing.
@@ -18,14 +30,14 @@ pub enum LaunchState {
/// handles the password itself (FR-NC-1).
AwaitingApproval { login_url: String },
/// An account is configured.
SignedIn { session: Session },
SignedIn { session: Account },
/// Working; the reason is shown so a pause is never unexplained.
///
/// Carries the session where there is one, so a failure mid-work returns
/// to the signed-in screen rather than signing the user out.
Busy {
message: String,
session: Option<Box<Session>>,
session: Option<Box<Account>>,
},
}
@@ -53,6 +65,12 @@ pub struct LaunchModel {
pub state: LaunchState,
/// Last-used server, prefilled so a returning user need not retype it.
pub server_url: String,
/// Last-used folder, prefilled for the same reason.
///
/// Separate from `server_url` rather than one "endpoint" field, because
/// the screen shows both at once: someone deciding between the two should
/// not have to clear one to try the other.
pub folder_path: String,
pub error: Option<String>,
pub status: Option<String>,
/// False where no secrets daemon exists (FR-NC-2). The screen must say so
@@ -132,6 +150,7 @@ impl Default for LaunchModel {
Self {
state: LaunchState::SignedOut,
server_url: String::new(),
folder_path: String::new(),
error: None,
status: None,
can_remember: true,
@@ -143,14 +162,16 @@ impl Default for LaunchModel {
impl LaunchModel {
/// Build from stored sessions, resuming the last account if there is one.
pub fn from_store(store: &SessionStore) -> Self {
pub fn from_store(store: &AccountStore) -> Self {
let can_remember = store.can_remember();
match store.current() {
Some(session) => {
let filter = session.format_filter();
let (server_url, folder_path) = prefill(&session);
Self {
server_url: session.server.clone(),
server_url,
folder_path,
formats: Format::ALL
.iter()
.map(|f| (*f, filter.allows(*f)))
@@ -175,7 +196,7 @@ impl LaunchModel {
matches!(self.state, LaunchState::Busy { .. })
}
pub fn session(&self) -> Option<&Session> {
pub fn session(&self) -> Option<&Account> {
match &self.state {
LaunchState::SignedIn { session } => Some(session),
// A session survives a busy period; a scan failure must not log
@@ -245,7 +266,7 @@ impl LaunchModel {
// --- transitions ---------------------------------------------------
pub fn begin_sign_in(&mut self, server: impl Into<String>) {
self.server_url = normalise_server(&server.into());
self.server_url = server.into();
self.error = None;
self.state = LaunchState::Busy {
message: "Contacting server…".into(),
@@ -263,7 +284,7 @@ impl LaunchModel {
/// Security. That makes it the workable option where no browser can
/// complete the handshake.
pub fn begin_direct_sign_in(&mut self, server: impl Into<String>) {
self.server_url = normalise_server(&server.into());
self.server_url = server.into();
self.error = None;
self.state = LaunchState::Busy {
message: "Checking the credentials…".into(),
@@ -278,10 +299,14 @@ impl LaunchModel {
};
}
pub fn signed_in(&mut self, session: Session) {
pub fn signed_in(&mut self, session: Account) {
self.error = None;
self.status = None;
self.server_url = session.server.clone();
let (server_url, folder_path) = prefill(&session);
self.server_url = server_url;
if !folder_path.is_empty() {
self.folder_path = folder_path;
}
let filter = session.format_filter();
// Adopt the session's stored selection, so a returning user sees the
// tick-boxes they left.
@@ -357,7 +382,7 @@ impl LaunchModel {
/// Adopt the picker's current path as the library root.
///
/// Returns the session to persist, or `None` when signed out.
pub fn choose_current_folder(&mut self) -> Option<Session> {
pub fn choose_current_folder(&mut self) -> Option<Account> {
let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone();
session.root = path;
@@ -378,25 +403,16 @@ impl LaunchModel {
}
}
/// Normalise a server address typed by hand.
/// Which of the two entry fields an account's endpoint belongs in.
///
/// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than
/// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS,
/// and an unencrypted default would be a security decision made on the user's
/// behalf without telling them.
pub fn normalise_server(input: &str) -> String {
let s = input.trim().trim_end_matches('/');
if s.is_empty() {
return String::new();
}
if s.starts_with("https://") {
s.to_string()
} else if let Some(rest) = s.strip_prefix("http://") {
// Upgrade rather than accept. If the server genuinely has no TLS the
// connection fails loudly, which is the correct outcome.
format!("https://{rest}")
/// A returning user should find what they typed last time where they typed
/// it. Keyed on whether the connector has a login rather than on its id, so a
/// third backend does not have to be named here to be prefilled correctly.
fn prefill(account: &Account) -> (String, String) {
if account.login.is_empty() {
(String::new(), account.endpoint.clone())
} else {
format!("https://{s}")
(account.endpoint.clone(), String::new())
}
}
@@ -404,18 +420,17 @@ pub fn normalise_server(input: &str) -> String {
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
use dr_sync_nextcloud::AppCredentials;
use dr_sync::Secret;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example".into(),
login_name: "duncan".into(),
app_password: "token".into(),
}
fn session_with_root(root: &str) -> Account {
let mut s =
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
s.root = root.into();
s
}
fn session_with_root(root: &str) -> Session {
let mut s = Session::new(&creds(), "duncan");
fn folder_with_root(root: &str) -> Account {
let mut s = Account::new("folder", "/mnt/photos");
s.root = root.into();
s
}
@@ -513,27 +528,34 @@ mod tests {
}
#[test]
fn server_addresses_are_normalised_to_https() {
assert_eq!(normalise_server("cloud.example"), "https://cloud.example");
assert_eq!(
normalise_server("https://cloud.example/"),
"https://cloud.example"
);
assert_eq!(
normalise_server(" cloud.example "),
"https://cloud.example"
);
assert_eq!(normalise_server(""), "");
fn a_returning_user_finds_their_endpoint_where_they_typed_it() {
// Two entry fields are shown at once. Prefilling the wrong one — a
// folder path into the server box — reads as a corrupted setting.
let mut m = LaunchModel::default();
m.signed_in(session_with_root("PhotosRaw"));
assert_eq!(m.server_url, "https://cloud.example");
assert_eq!(m.folder_path, "");
let mut m = LaunchModel::default();
m.signed_in(folder_with_root("2026"));
assert_eq!(m.folder_path, "/mnt/photos");
assert_eq!(m.server_url, "");
}
#[test]
fn plain_http_is_upgraded_rather_than_accepted() {
// NFR-SEC-3: TLS is required. Failing loudly beats silently sending a
// credential in the clear.
assert_eq!(
normalise_server("http://cloud.example"),
"https://cloud.example"
);
fn a_folder_library_reaches_the_grid_the_same_way_a_server_one_does() {
// Everything past sign-in is backend-neutral, and this is the check
// that keeps it so: no branch on the account's connector below here.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.is_signed_in());
assert!(!m.can_open_library(), "no root chosen yet");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
m.signed_in(folder_with_root("2026"));
assert!(m.can_open_library());
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
assert_eq!(m.account_label(), "/mnt/photos/2026");
}
#[test]
@@ -554,13 +576,13 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
let store = AccountStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
let mut s = session_with_root("PhotosRaw");
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2]));
store.save(&s, &creds()).unwrap();
store.save(&s, Some(&Secret::new("token"))).unwrap();
let m = LaunchModel::from_store(&store);
assert!(m.is_signed_in());
@@ -576,7 +598,7 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
let store = AccountStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
+171 -16
View File
@@ -8,8 +8,8 @@ use std::cell::RefCell;
use std::rc::Rc;
use dr_plat::PlatformSecretStore;
use dr_sync::RemotePath;
use dr_sync_nextcloud::{auth, Session, SessionStore};
use dr_sync::{Account, AccountStore, BackendProvider, RemotePath};
use dr_sync_nextcloud::{auth, NextcloudProvider};
use slint::ComponentHandle;
@@ -19,7 +19,7 @@ use crate::AppWindow;
/// Shared launch state for the running window.
pub struct LaunchController {
pub model: RefCell<LaunchModel>,
pub store: SessionStore,
pub store: AccountStore,
/// Holds any in-flight poll timer. A `Timer` stops when dropped, so it
/// must outlive its own callback — parking it here avoids an Rc cycle
/// between the timer and the closure it runs.
@@ -28,7 +28,7 @@ pub struct LaunchController {
impl LaunchController {
pub fn new() -> Rc<Self> {
let store = SessionStore::open(Box::new(PlatformSecretStore::new()));
let store = AccountStore::open(Box::new(PlatformSecretStore::new()));
let model = LaunchModel::from_store(&store);
Rc::new(Self {
model: RefCell::new(model),
@@ -46,6 +46,7 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
window.set_launch_account(m.account_label().into());
window.set_launch_root(m.library_root().into());
window.set_launch_server(m.server_url.clone().into());
window.set_launch_folder(m.folder_path.clone().into());
window.set_launch_busy(m.is_busy());
window.set_launch_login_url(m.login_url().into());
window.set_launch_can_remember(m.can_remember);
@@ -87,7 +88,7 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
/// the session so the caller can start a scan.
pub fn wire<F>(window: &AppWindow, controller: Rc<LaunchController>, on_open_library: F)
where
F: Fn(Session) + 'static,
F: Fn(Account) + 'static,
{
// --- sign in -------------------------------------------------------
{
@@ -96,7 +97,17 @@ where
window.on_launch_sign_in(move |server| {
log::info!("sign-in requested for {server:?}");
let Some(w) = weak.upgrade() else { return };
ctl.model.borrow_mut().begin_sign_in(server.to_string());
// The connector owns what a valid address is — assuming HTTPS
// here would put one backend's rule in the interface.
let server = match NextcloudProvider.normalise_endpoint(&server) {
Ok(s) => s,
Err(e) => {
ctl.model.borrow_mut().fail(e);
render(&w, &ctl);
return;
}
};
ctl.model.borrow_mut().begin_sign_in(server);
render(&w, &ctl);
let server = ctl.model.borrow().server_url.clone();
@@ -111,9 +122,15 @@ where
let ctl = controller.clone();
window.on_launch_sign_in_direct(move |server, login, password| {
let Some(w) = weak.upgrade() else { return };
ctl.model
.borrow_mut()
.begin_direct_sign_in(server.to_string());
let server = match NextcloudProvider.normalise_endpoint(&server) {
Ok(s) => s,
Err(e) => {
ctl.model.borrow_mut().fail(e);
render(&w, &ctl);
return;
}
};
ctl.model.borrow_mut().begin_direct_sign_in(server);
render(&w, &ctl);
let server = ctl.model.borrow().server_url.clone();
@@ -127,6 +144,28 @@ where
});
}
// --- use a folder ---------------------------------------------------
//
// No thread, no waiting state, no credential: the whole sign-in is a
// `stat`. That asymmetry with the browser flow above is not a special
// case in the screen — it is what [`SignIn::EndpointOnly`] means, and any
// future connector declaring it lands here rather than in new code.
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_use_folder(move |path| {
let Some(w) = weak.upgrade() else { return };
match open_folder_library(&ctl.store, &path) {
Ok(account) => {
log::info!("using folder library at {}", account.endpoint);
ctl.model.borrow_mut().signed_in(account);
}
Err(e) => ctl.model.borrow_mut().fail(e),
}
render(&w, &ctl);
});
}
// --- sign out ------------------------------------------------------
{
let weak = window.as_weak();
@@ -274,6 +313,40 @@ where
render(window, &controller);
}
/// TRACES: FR-NC-13
/// Establish a folder library, returning the account to sign in as.
///
/// The whole of a [`SignIn::EndpointOnly`](dr_sync::SignIn) sign-in: check the
/// endpoint, build the account, persist it. Split out of the callback rather
/// than written inline because a Slint callback cannot be tested without a
/// display server, and this is the path that decides whether a mistyped folder
/// becomes a stored account — the failure that would then skip the launch
/// screen on the next start and surface as a library that finds nothing.
///
/// The error is a string because it goes straight to the screen's error line;
/// the connector wrote it to say what to fix.
fn open_folder_library(store: &AccountStore, path: &str) -> Result<Account, String> {
let provider = crate::remote::registry()
.get(dr_sync_folder::BACKEND_ID)
.ok_or("this build has no folder support")?;
// The connector checks the directory before an account is written for it.
let endpoint = provider.normalise_endpoint(path)?;
let account = provider.account_for(&endpoint).map_err(|e| e.to_string())?;
// `None`: there is no credential, and asking the keyring for one would
// fail on a machine with no secrets daemon — where a folder library is
// exactly the thing that should still work.
//
// A failure to persist is reported, not fatal: the library opens for this
// session and the user is asked again next launch, which is a great deal
// better than refusing to open a folder that is plainly there.
if let Err(e) = store.save(&account, None) {
log::warn!("persisting account: {e}");
}
Ok(account)
}
/// Run Login Flow v2 without blocking the UI thread.
///
/// Slint's event loop is single-threaded, so the network work happens on a
@@ -547,9 +620,10 @@ fn poll_channel(
}
LoginMessage::Success(boxed) => {
let (creds, user_id) = *boxed;
let session = Session::new(&creds, user_id);
if let Err(e) = ctl.store.save(&session, &creds) {
log::warn!("persisting session: {e}");
let session = NextcloudProvider::account_from(&creds, user_id);
let secret = dr_sync::Secret::new(&creds.app_password);
if let Err(e) = ctl.store.save(&session, Some(&secret)) {
log::warn!("persisting account: {e}");
}
ctl.model.borrow_mut().signed_in(session);
done = true;
@@ -576,10 +650,13 @@ fn poll_channel(
/// List top-level folders so one can be chosen as the library root.
fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, path: String) {
let Some(session) = ctl.model.borrow().session().cloned() else {
let Some(account) = ctl.model.borrow().session().cloned() else {
return;
};
let creds = match ctl.store.credentials(&session) {
let conn = match ctl
.store
.connection(&account, crate::remote::needs_secret(&account))
{
Ok(c) => c,
Err(e) => {
ctl.model.borrow_mut().fail(format!("credentials: {e}"));
@@ -591,7 +668,6 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, pa
};
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
let user_id = session.user_id.clone();
std::thread::spawn(move || {
// Multi-thread for the same reason as the login worker: a
@@ -608,7 +684,7 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, pa
return;
};
rt.block_on(async {
match crate::remote::connect(&creds, &user_id) {
match crate::remote::connect(&conn) {
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
Ok(entries) => {
let mut dirs: Vec<String> = entries
@@ -825,3 +901,82 @@ fn android_open_url(url: &str) -> Result<(), String> {
})
.map_err(|e: jni::errors::Error| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
fn store_in(dir: &std::path::Path) -> AccountStore {
AccountStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
)
}
fn tmpdir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("dr-launch-folder-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn opening_a_folder_stores_an_account_with_no_credential() {
// The whole sign-in, end to end through the registry: no browser, no
// keyring, no waiting state.
let dir = tmpdir("ok");
let library = dir.join("Photos");
std::fs::create_dir_all(&library).unwrap();
let store = store_in(&dir);
let account = open_folder_library(&store, &library.to_string_lossy()).unwrap();
assert_eq!(account.backend, dr_sync_folder::BACKEND_ID);
assert!(account.login.is_empty(), "a folder has nobody to name");
// And it survives, so the next launch skips the screen.
let reloaded = store.current().expect("persisted");
assert_eq!(reloaded.endpoint, account.endpoint);
// With nothing in the keyring — the machine may have no secrets daemon
// at all, which is precisely when a folder library matters.
assert!(store.connection(&reloaded, false).unwrap().secret.is_none());
}
#[test]
fn a_mistyped_folder_is_refused_rather_than_stored() {
// The failure this guards: a stored account for a folder that is not
// there skips the launch screen next start and reads as a library
// that has lost its photographs.
let dir = tmpdir("typo");
let store = store_in(&dir);
let err = open_folder_library(&store, &dir.join("Pictrues").to_string_lossy()).unwrap_err();
assert!(err.contains("No folder"), "{err}");
assert!(store.current().is_none(), "nothing may be persisted");
}
#[test]
fn the_error_says_what_to_fix() {
// It goes straight to the screen's error line, so it has to read as
// instruction rather than as a type name.
let dir = tmpdir("messages");
let store = store_in(&dir);
for (input, want) in [("", "Choose"), ("Pictures", "full path")] {
let err = open_folder_library(&store, input).unwrap_err();
assert!(err.contains(want), "{input:?} gave {err:?}");
}
}
#[test]
fn a_file_is_not_a_library() {
let dir = tmpdir("file");
let f = dir.join("a.CR2");
std::fs::write(&f, b"raw").unwrap();
let store = store_in(&dir);
let err = open_folder_library(&store, &f.to_string_lossy()).unwrap_err();
assert!(err.contains("not a folder"), "{err}");
}
}
+37 -49
View File
@@ -406,10 +406,10 @@ fn batch_request(
export::BatchRequest {
sources,
creds: library.credentials(),
conn: library.credentials(),
settings: stored.export,
outbox: match library.session() {
Some((_, s)) => export::outbox_dir(&s.server, &s.user_id),
Some(c) => export::outbox_dir(&c.account),
// No account, so no outbox — a device export still works, and a
// remote one is refused by `place` rather than here, so the message
// names the setting rather than the plumbing.
@@ -434,15 +434,16 @@ fn drain_outbox(library: &Rc<library_ui::LibraryController>) {
if library.is_offline() {
return;
}
let Some((creds, session)) = library.session() else {
let Some(conn) = library.session() else {
return;
};
let outbox = export::outbox_dir(&session.server, &session.user_id);
let outbox = export::outbox_dir(&conn.account);
if export::pending_count(&outbox) == 0 {
return;
}
let rx = export::spawn_upload(creds, session.user_id.clone(), session.root.clone(), outbox);
let root = conn.account.root.clone();
let rx = export::spawn_upload(conn, root, outbox);
std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
@@ -472,7 +473,9 @@ fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsC
let remote = stored.export.target == dr_types::ExportTarget::Remote;
window.set_export_label(
if remote {
"Export to Nextcloud"
// Not the connector's name: this is a Nextcloud account for some
// libraries and a folder on a mount for others.
"Export to the library"
} else {
"Export"
}
@@ -1036,13 +1039,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
library.catalog(),
activity.clone(),
move || {
let (_, session) = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(
&session.server,
&session.user_id,
))
.ok()
.map(std::rc::Rc::new)
let conn = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account))
.ok()
.map(std::rc::Rc::new)
},
// The weights are not shipped and are not a build input
// (docs/faces.md §2): the user puts them beside the catalog,
@@ -1050,24 +1050,21 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
{
let lib = library.clone();
move || {
let (_, session) = lib.session()?;
library::face_models(&session.server, &session.user_id)
let conn = lib.session()?;
library::face_models(&conn.account)
}
},
// The sweep opens its own connection on its own thread, so it
// takes paths rather than the handles this screen holds — and
// credentials, because it fetches the pixels it indexes rather
// a connection, because it fetches the pixels it indexes rather
// than reading whatever the grid happened to leave behind.
{
let lib = library.clone();
move || {
let (creds, session) = lib.session()?;
Some((
creds,
session.user_id.clone(),
library::catalog_path(&session.server, &session.user_id),
library::thumbs_dir(&session.server, &session.user_id),
))
let conn = lib.session()?;
let catalog = library::catalog_path(&conn.account);
let thumbs = library::thumbs_dir(&conn.account);
Some((conn, catalog, thumbs))
}
},
);
@@ -1100,7 +1097,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Before anything opens a store: an upgrade must not
// abandon a catalog, its thumbnails, or the offline
// ratings and edits waiting beside them.
library::migrate_legacy_cache_data(&session.server, &session.user_id);
library::migrate_legacy_cache_data(&session);
library_ui::open(
&window,
library.clone(),
@@ -1147,23 +1144,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
&window,
import,
move || {
let (creds, session) = library_for_context.session()?;
let conn = library_for_context.session()?;
Some(import_ui::Context {
catalog: library::catalog_path(&session.server, &session.user_id),
library_label: session.root.clone(),
catalog: library::catalog_path(&conn.account),
library_label: conn.account.root.clone(),
// The same formats the scan looks for. An import that took
// types the library then ignores would copy files off the
// card that never appear in the grid.
filter: session.format_filter(),
filter: conn.account.format_filter(),
upload: Some(import::Upload {
credentials: creds,
user_id: session.user_id.clone(),
library: session.root.clone(),
library: conn.account.root.clone(),
// The same shard store the grid reads and the sync
// pushes, so a thumbnail made during an import is the
// one every other client gets.
thumbs: library::thumbs_dir(&session.server, &session.user_id),
staging: import::staging_dir(&session.server, &session.user_id),
thumbs: library::thumbs_dir(&conn.account),
staging: import::staging_dir(&conn.account),
conn,
}),
})
},
@@ -1218,14 +1214,8 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
library: &Rc<library_ui::LibraryController>,
path: String| {
match library.session() {
Some((creds, session)) => {
settings_ui::spawn_folder_list(
weak.clone(),
ctl.clone(),
creds,
session.user_id.clone(),
path,
);
Some(conn) => {
settings_ui::spawn_folder_list(weak.clone(), ctl.clone(), conn, path);
}
None => {
// No account, so nothing to browse. Said plainly rather
@@ -1364,14 +1354,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let lib = library.clone();
let catalog = library.catalog();
move |w: &AppWindow| {
let store = lib.session().and_then(|(_, s)| {
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&s.server, &s.user_id))
.ok()
let store = lib.session().and_then(|c| {
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok()
});
identity_ui::refresh_coverage(w, &catalog, store.as_ref());
w.set_identity_model_missing(
lib.session()
.and_then(|(_, s)| library::face_models(&s.server, &s.user_id))
.and_then(|c| library::face_models(&c.account))
.is_none(),
);
}
@@ -1780,7 +1769,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
w.set_index(0);
w.set_total(1);
let Some((creds, user_id)) = library.credentials() else {
let Some(conn) = library.credentials() else {
w.set_load_error("no library session".into());
return;
};
@@ -1809,8 +1798,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// photograph is, instead of the image appearing at its defaults
// and visibly changing a moment later.
let sidecar_rx = library::spawn_sidecar_fetch(
creds.clone(),
user_id.clone(),
conn.clone(),
path.clone(),
library.sidecar_cache_dir().unwrap_or_default(),
library.is_offline(),
@@ -1829,7 +1817,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
log::info!("fetching {path} for develop");
w.set_load_error("Downloading…".into());
let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache);
let rx = library::spawn_full_fetch(conn, path.clone(), cache);
// The one transfer the user is actively waiting on. It gets a row
// like any other, so a download that is still running after they
+307 -79
View File
@@ -25,8 +25,7 @@ use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use dr_catalog::{Catalog, JobKind, Priority};
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection, RemoteBackend, RemoteError, RemoteId, RemotePath};
use dr_thumbs::ThumbStore;
use crate::sidecar_cache::SidecarCache;
@@ -573,8 +572,7 @@ pub fn sidecar_path(image_path: &str) -> String {
/// about it. Interrupting a cull with an error dialog per frame would be far
/// worse than the risk. The counts are reported once, at the end.
pub fn spawn_sidecar_writes(
creds: AppCredentials,
user_id: String,
conn: Connection,
writes: Vec<SidecarWrite>,
cache_dir: PathBuf,
offline: bool,
@@ -626,7 +624,7 @@ pub fn spawn_sidecar_writes(
let report = match rt {
None => queue_all(),
Some(rt) => rt.block_on(async {
match crate::remote::connect(&creds, &user_id) {
match crate::remote::connect(&conn) {
Ok(b) => {
let mut report = SidecarReport::default();
for w in &writes {
@@ -860,11 +858,7 @@ async fn write_one_sidecar_online(
/// The marker is cleared only after the server has taken the bytes. A drain
/// interrupted halfway leaves the rest of the outbox exactly as it was, so
/// nothing depends on this running to completion.
pub fn spawn_outbox_drain(
creds: AppCredentials,
user_id: String,
cache_dir: PathBuf,
) -> Receiver<SidecarMessage> {
pub fn spawn_outbox_drain(conn: Connection, cache_dir: PathBuf) -> Receiver<SidecarMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
@@ -895,7 +889,7 @@ pub fn spawn_outbox_drain(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(SidecarMessage::Finished {
@@ -950,7 +944,35 @@ async fn drain_one(
let path = RemotePath::new(path_str.to_string());
let id = RemoteId::Path(path.clone());
let remote = backend.get(&id, None).await.ok();
// TRACES: FR-NC-6c
// A miss and a placeholder are not the same answer, and conflating them
// destroys work. This read decides whether the sidecar already on the
// remote is merged in; treating "the content is not on this device" as
// "there is no sidecar" writes a fresh document over an existing one and
// discards every edit another device put there — the exact loss the
// format's unknown-key preservation exists to prevent.
//
// A sidecar is a few kilobytes, so the right response to a placeholder is
// to fetch it, not to give up. Where that is impossible — no client
// running — the entry stays queued, which is what the outbox is for.
let remote = match backend.get(&id, None).await {
Ok(bytes) => Some(bytes),
Err(RemoteError::NotFound(_)) => None,
Err(RemoteError::NotMaterialised(_)) => {
backend
.materialise(&id)
.await
.map_err(|e| format!("sidecar is not on this device ({e})"))?;
match backend.get(&id, None).await {
Ok(bytes) => Some(bytes),
Err(e) => return Err(format!("sidecar could not be read ({e})")),
}
}
// Anything else — a refused read, a dead connection — leaves the entry
// queued rather than resolved by overwriting.
Err(e) => return Err(format!("sidecar could not be read ({e})")),
};
if let Some(bytes) = remote.as_deref() {
if !bytes.is_empty() {
@@ -1001,20 +1023,17 @@ fn merge_into(local: &mut dr_pipeline::Sidecar, remote: &dr_pipeline::Sidecar) {
/// Where the catalog for an account lives.
///
/// Keyed by server and user so two accounts do not share an index. Under the
/// XDG data directory, not cache: the catalog is rebuildable but rebuilding it
/// costs a full rescan, so it is not something to discard on a cache sweep.
pub fn catalog_path(server: &str, user_id: &str) -> PathBuf {
let slug: String = server
.trim_start_matches("https://")
.trim_start_matches("http://")
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
data_root()
.join(format!("{slug}-{user_id}"))
.join("catalog.sqlite")
/// Keyed by [`Account::namespace`] so two accounts do not share an index —
/// two servers, two logins on one server, or two folders on one disk. Under
/// the XDG data directory, not cache: the catalog is rebuildable but
/// rebuilding it costs a full rescan, so it is not something to discard on a
/// cache sweep.
///
/// The namespace is the account's to compute, not this function's, because it
/// is also frozen: it names the directory an existing install's catalog,
/// thumbnail shards and un-uploaded sidecars are already in.
pub fn catalog_path(account: &Account) -> PathBuf {
data_root().join(account.namespace()).join("catalog.sqlite")
}
/// The directory every account's data hangs off.
@@ -1032,7 +1051,7 @@ pub fn catalog_path(server: &str, user_id: &str) -> PathBuf {
/// reached the server, is the worst failure this application can have, and
/// it would be silent.
///
/// `SessionStore::data_dir()` is the persistent per-app directory the
/// `AccountStore::data_dir()` is the persistent per-app directory the
/// Android entry point establishes before anything opens a store. On a
/// desktop it is the XDG config directory, and the two lines below keep the
/// established XDG *data* location there rather than moving anyone's
@@ -1041,7 +1060,7 @@ fn data_root() -> PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
.unwrap_or_else(dr_sync_nextcloud::session::SessionStore::data_dir);
.unwrap_or_else(dr_sync::AccountStore::data_dir);
base.join("darkroom")
}
@@ -1064,15 +1083,12 @@ fn data_root() -> PathBuf {
/// one filesystem, so it is atomic and cannot half-finish. If the destination
/// already exists this does nothing — the migration has run, or this is a
/// fresh install, and in neither case may it overwrite live data.
pub fn migrate_legacy_cache_data(server: &str, user_id: &str) {
pub fn migrate_legacy_cache_data(account: &Account) {
// Only meaningful where the old fallback and the new one differ, which is
// exactly the platform that had the problem. On a desktop with XDG set,
// both resolve to the same place and this returns immediately.
let legacy_base = std::env::temp_dir();
let Some(current) = catalog_path(server, user_id)
.parent()
.map(|p| p.to_path_buf())
else {
let Some(current) = catalog_path(account).parent().map(|p| p.to_path_buf()) else {
return;
};
let Some(account) = current.file_name() else {
@@ -1117,8 +1133,7 @@ fn move_account_dir(legacy: &std::path::Path, current: &std::path::Path) {
/// Returns the receiver the UI drains. The worker owns its own tokio runtime
/// and backend; nothing here touches the Slint event loop.
pub fn spawn_scan(
creds: AppCredentials,
user_id: String,
conn: Connection,
root: String,
filter: FormatFilter,
catalog_path: PathBuf,
@@ -1127,7 +1142,7 @@ pub fn spawn_scan(
std::thread::spawn(move || {
let started = std::time::Instant::now();
if let Err(e) = run_scan(&tx, creds, user_id, root, filter, catalog_path, started) {
if let Err(e) = run_scan(&tx, conn, root, filter, catalog_path, started) {
let _ = tx.send(ScanMessage::Failed {
message: e.message,
offline: e.offline,
@@ -1169,8 +1184,7 @@ impl From<dr_sync::RemoteError> for ScanFailure {
fn run_scan(
tx: &Sender<ScanMessage>,
creds: AppCredentials,
user_id: String,
conn: Connection,
root: String,
filter: FormatFilter,
catalog_path: PathBuf,
@@ -1185,7 +1199,7 @@ fn run_scan(
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
rt.block_on(async {
let backend = crate::remote::connect(&creds, &user_id).map_err(ScanFailure::local)?;
let backend = crate::remote::connect(&conn).map_err(ScanFailure::local)?;
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
// first run this is empty and the walk is complete; on every run after
@@ -1469,8 +1483,7 @@ pub enum PinMessage {
/// memory — and it is the same contention that produced 423 Locked in the
/// sweep.
pub fn spawn_pin_fetch(
creds: AppCredentials,
user_id: String,
conn: Connection,
catalog_path: PathBuf,
cache_dir: PathBuf,
budget: dr_catalog::Budget,
@@ -1531,7 +1544,7 @@ pub fn spawn_pin_fetch(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(PinMessage::Failed {
@@ -1552,6 +1565,48 @@ pub fn spawn_pin_fetch(
};
let id = RemoteId::Path(RemotePath::new(&source_ref));
// TRACES: FR-NC-6c
// On a placeholder library "pin" means *keep it downloaded*,
// not "make a second copy". The original materialises in the
// library folder itself, so copying it under `originals/`
// would hold every pinned photograph twice — and the copy
// would be the half the budget could evict while the real disk
// cost stayed. Only the bookkeeping is recorded, with no path,
// so nothing here can ever delete a file inside a synced tree
// (see `Cache::record_in_place`).
if backend.capabilities().materialisation.can_materialise() {
match backend.materialise(&id).await {
Ok(_) => {
let bytes = size_of(&catalog, image).unwrap_or(0);
if let Err(e) = store.record_in_place(
catalog.connection(),
image,
bytes,
true,
now_secs(),
) {
log::warn!("recording pinned {source_ref}: {e}");
continue;
}
stored += 1;
bytes_total += bytes;
if tx.send(PinMessage::Stored { done: stored }).is_err() {
return;
}
}
Err(e) if e.indicates_offline() => {
let _ = tx.send(PinMessage::Failed {
message: e.to_string(),
offline: true,
});
return;
}
Err(e) => log::warn!("pinning {source_ref}: {e}"),
}
continue;
}
match backend.get(&id, None).await {
Ok(bytes) => {
// `pinned: true` — this is the population the budget
@@ -1601,6 +1656,82 @@ pub fn spawn_pin_fetch(
rx
}
/// TRACES: FR-NC-6c
/// Hand a set of photographs back to the sync client, freeing their disk.
///
/// The other half of pinning on a placeholder library. `Cache::release` drops
/// the bookkeeping and — correctly — deletes nothing, because the rows it
/// holds for a library like this name no file of ours (`record_in_place`).
/// The bytes are in the library folder, and only the client may take them
/// back.
///
/// **This is a dehydration, not a deletion, and the distinction is the whole
/// safety of the feature.** Removing a materialised file inside a synced tree
/// propagates to the server and deletes the photograph everywhere.
///
/// Best effort per image: a file the client refuses to release simply stays,
/// which costs disk and loses nothing.
pub fn spawn_dehydrate(
conn: Connection,
catalog_path: PathBuf,
images: Vec<dr_types::ImageId>,
) -> Receiver<usize> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let Ok(catalog) = Catalog::open(&catalog_path) else {
return;
};
let Ok(rt) = crate::net_runtime::build() else {
return;
};
rt.block_on(async {
let Ok(backend) = crate::remote::connect(&conn) else {
return;
};
// Nothing to do where content is not a thing that can be given
// back — a server library, or a plain folder.
if !backend.capabilities().materialisation.can_materialise() {
return;
}
let mut released = 0usize;
for image in images {
let Some(source_ref) = source_ref_of(&catalog, image) else {
continue;
};
let id = RemoteId::Path(RemotePath::new(&source_ref));
match backend.dematerialise(&id).await {
Ok(()) => released += 1,
Err(e) => log::debug!("releasing {source_ref}: {e}"),
}
}
log::info!("released {released} photograph(s) back to the sync client");
let _ = tx.send(released);
});
});
rx
}
/// What an image occupies, as the catalog recorded it.
///
/// Zero where the scan could not tell — a placeholder reports no size, because
/// a one-byte stub says nothing about what it stands for (ARCH §9.0a). A pin
/// that cannot state its cost is better than one that states a wrong one.
fn size_of(catalog: &Catalog, image: dr_types::ImageId) -> Option<u64> {
catalog
.connection()
.query_row(
"SELECT file_size FROM images WHERE id = ?1",
rusqlite::params![image.0 as i64],
|r| r.get::<_, Option<i64>>(0),
)
.ok()
.flatten()
.map(|v| v.max(0) as u64)
}
/// The remote path for a catalogued image.
fn source_ref_of(catalog: &Catalog, image: dr_types::ImageId) -> Option<String> {
catalog
@@ -1678,8 +1809,7 @@ pub struct CacheContext {
/// not read, so an edit this build failed to understand is never destroyed by
/// having been opened.
pub fn spawn_sidecar_fetch(
creds: AppCredentials,
user_id: String,
conn: Connection,
image_path: String,
cache_dir: PathBuf,
offline: bool,
@@ -1720,7 +1850,7 @@ pub fn spawn_sidecar_fetch(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
log::debug!("sidecar fetch backend: {e}");
@@ -1769,8 +1899,7 @@ pub fn spawn_sidecar_fetch(
}
pub fn spawn_full_fetch(
creds: AppCredentials,
user_id: String,
conn: Connection,
path: String,
cache: Option<CacheContext>,
) -> Receiver<Result<Vec<u8>, FetchFailure>> {
@@ -1808,7 +1937,7 @@ pub fn spawn_full_fetch(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(Err(FetchFailure::local(e)));
@@ -1851,8 +1980,7 @@ pub fn spawn_full_fetch(
/// or a second device that synced the shards — fills the grid with no transfer
/// at all. Only genuine misses reach the network.
pub fn spawn_thumbnails(
creds: AppCredentials,
user_id: String,
conn: Connection,
wanted: Vec<ThumbnailRequest>,
store_dir: PathBuf,
catalog_path: PathBuf,
@@ -1958,7 +2086,7 @@ pub fn spawn_thumbnails(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
for req in &to_fetch {
@@ -2487,11 +2615,7 @@ const SWEEP_LANES: usize = 6;
///
/// Runs at the back of the queue by design: it holds no lock the grid needs,
/// and its chunked commits keep write transactions short.
pub fn spawn_sweep(
creds: AppCredentials,
user_id: String,
catalog_path: PathBuf,
) -> Receiver<SweepMessage> {
pub fn spawn_sweep(conn: Connection, catalog_path: PathBuf) -> Receiver<SweepMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
@@ -2529,7 +2653,7 @@ pub fn spawn_sweep(
};
rt.block_on(async {
let Ok(backend) = crate::remote::connect(&creds, &user_id) else {
let Ok(backend) = crate::remote::connect(&conn) else {
return;
};
@@ -2862,8 +2986,7 @@ fn faces_without_proxy(
/// the images in flight and nothing else.
#[allow(clippy::too_many_arguments)]
pub fn spawn_face_sweep(
creds: AppCredentials,
user_id: String,
conn: Connection,
catalog_path: PathBuf,
store_dir: PathBuf,
detector_model: PathBuf,
@@ -2991,7 +3114,7 @@ pub fn spawn_face_sweep(
rt.block_on(async {
// Through `remote::connect`, which is the only place in the
// interface that knows whose backend this is.
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
log::warn!("face sweep: {e}");
@@ -3012,6 +3135,13 @@ pub fn spawn_face_sweep(
let (mut done, mut images, mut found, mut failed) = (0usize, 0usize, 0usize, 0usize);
let mut offline = false;
// TRACES: FR-NC-6c
// The same borrow the thumbnail sweep makes, and deliberately its
// own pool: the two passes run at different times, so sharing one
// would keep every file the earlier pass touched hydrated until
// the later one finished. Each gives its own back (ARCH §9.0a).
let pool = dr_sync_folder::BorrowPool::new();
for chunk in wanted.chunks(SWEEP_CHUNK) {
let lanes: Vec<Vec<&ThumbnailRequest>> = (0..SWEEP_LANES)
.map(|lane| chunk.iter().skip(lane).step_by(SWEEP_LANES).collect())
@@ -3021,6 +3151,7 @@ pub fn spawn_face_sweep(
let backend = &*backend;
let models = &models;
let options = &options;
let pool = &pool;
async move {
let mut indexed: Vec<IndexedImage> = Vec::new();
let mut discard = Vec::new();
@@ -3029,6 +3160,23 @@ pub fn spawn_face_sweep(
let mut offline = false;
for req in lane {
attempted += 1;
let _held =
match pool.borrow(backend, &RemotePath::new(&req.path)).await {
Ok(h) => h,
Err(e) if e.indicates_offline() => {
log::info!("face sweep: {e}");
attempted -= 1;
offline = true;
break;
}
Err(e) => {
log::debug!("face sweep: {}: {e}", req.path);
failed += 1;
continue;
}
};
match fetch_preview(backend, req, &mut discard).await {
PreviewOutcome::Ready(mut preview) => {
// No await inside this borrow — see the
@@ -3133,8 +3281,13 @@ pub fn spawn_face_sweep(
{
// Receiver dropped: the screen closed, or
// the user pressed Stop. Everything written
// so far stays written.
// so far stays written — and everything
// borrowed is given back. A cancelled pass
// that kept the library hydrated would be
// the worst of both: the disk spent and
// the work abandoned.
log::info!("face sweep: cancelled after {images} image(s)");
pool.release_all(&*backend).await;
return;
}
}
@@ -3152,6 +3305,14 @@ pub fn spawn_face_sweep(
}
}
let returned = pool.release_all(&*backend).await;
if returned.released > 0 {
log::info!(
"face sweep: released {} borrowed file(s)",
returned.released
);
}
log::info!(
"face sweep: {found} face(s) across {images} image(s), {failed} failed{}",
if offline { ", server went away" } else { "" }
@@ -3226,8 +3387,7 @@ pub enum ThumbSweepMessage {
/// the alternative is a second piece of state that has to be invalidated when
/// a file is replaced.
pub fn spawn_thumbnail_sweep(
creds: AppCredentials,
user_id: String,
conn: Connection,
catalog_path: PathBuf,
store_dir: PathBuf,
) -> Receiver<ThumbSweepMessage> {
@@ -3300,7 +3460,7 @@ pub fn spawn_thumbnail_sweep(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
log::warn!("thumbnail sweep: {e}");
@@ -3313,6 +3473,15 @@ pub fn spawn_thumbnail_sweep(
let mut offline = false;
let mut found = Vec::new();
// TRACES: FR-NC-6c
// On a placeholder library the bytes may not be here at all, and
// this is a pass the user asked for — so it may fetch them, which
// browsing may not (ARCH §9.0a). Every file is *borrowed*: what
// this pass downloads it gives back, and what the user already had
// it leaves alone. Against a server or a plain folder every borrow
// is a no-op, so there is one code path rather than two.
let pool = dr_sync_folder::BorrowPool::new();
for chunk in wanted.chunks(SWEEP_CHUNK) {
// Each lane owns a disjoint slice and its own output, so
// nothing is shared and no lock is needed. The store is not
@@ -3323,6 +3492,7 @@ pub fn spawn_thumbnail_sweep(
let results = futures_join_all(lanes.into_iter().map(|lane| {
let backend: &dyn RemoteBackend = &*backend;
let pool = &pool;
async move {
let mut made: Vec<(u64, dr_thumbs::Thumbnail)> = Vec::new();
let mut found = Vec::new();
@@ -3335,6 +3505,27 @@ pub fn spawn_thumbnail_sweep(
// store on and is not a candidate.
let Some(file_id) = req.file_id else { continue };
attempted += 1;
// Held for this image only. A failure to fetch is
// this image's verdict, not the batch's: a client
// that cannot reach the server reports it as
// offline through the usual path below.
let _held =
match pool.borrow(backend, &RemotePath::new(&req.path)).await {
Ok(h) => h,
Err(e) if e.indicates_offline() => {
log::info!("thumbnail sweep: {e}");
attempted -= 1;
offline = true;
break;
}
Err(e) => {
log::debug!("thumbnail sweep: {}: {e}", req.path);
failed += 1;
continue;
}
};
match fetch_preview(backend, req, &mut found).await {
PreviewOutcome::Ready(preview) => {
match encode_preview(file_id, &preview) {
@@ -3385,6 +3576,10 @@ pub fn spawn_thumbnail_sweep(
.send(ThumbSweepMessage::Progress { done, stored })
.is_err()
{
// Cancelled. Hand back what was borrowed before leaving,
// or a stopped pass costs the disk of everything it had
// reached and delivers nothing for it.
pool.release_all(&*backend).await;
return;
}
if offline {
@@ -3393,6 +3588,19 @@ pub fn spawn_thumbnail_sweep(
}
flush_sweep(&catalog, &mut found);
// Give back everything this pass fetched, before reporting done —
// a user watching the disk should see it return, and a pass that
// reported success while still holding the library would be
// lying about what it cost.
let returned = pool.release_all(&*backend).await;
if returned.released > 0 {
log::info!(
"thumbnail sweep: released {} borrowed file(s)",
returned.released
);
}
log::info!("thumbnail sweep: {stored} stored, {failed} without a usable preview");
let _ = tx.send(ThumbSweepMessage::Finished {
stored,
@@ -3457,8 +3665,8 @@ fn thumbnails_outstanding(
/// Beside the catalog rather than in the cache directory: these sync to the
/// server and are shared with other clients, so discarding them on a cache
/// sweep would cost a re-download for everyone.
pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf {
catalog_path(server, user_id)
pub fn thumbs_dir(account: &Account) -> PathBuf {
catalog_path(account)
.parent()
.map(|p| p.join("thumbs"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-thumbs"))
@@ -3471,8 +3679,8 @@ pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf {
/// project's licence, so the user obtains them and the app loads them from here
/// (docs/faces.md §2). An absent directory is the ordinary state of a fresh
/// install, not an error.
pub fn face_models_dir(server: &str, user_id: &str) -> PathBuf {
catalog_path(server, user_id)
pub fn face_models_dir(account: &Account) -> PathBuf {
catalog_path(account)
.parent()
.map(|p| p.join("models"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-models"))
@@ -3511,13 +3719,13 @@ pub fn shared_face_models_dir() -> PathBuf {
/// 3. **The system directories.** Where a package installs them — the Arch
/// package puts the pair in `/usr/share/darkroom/models`. Last, so anything
/// the user placed themselves outranks what the package shipped.
pub fn face_models(server: &str, user_id: &str) -> Option<(PathBuf, PathBuf)> {
pub fn face_models(account: &Account) -> Option<(PathBuf, PathBuf)> {
fn pair(dir: PathBuf) -> Option<(PathBuf, PathBuf)> {
let detector = dir.join("scrfd_500m_640.onnx");
let embedder = dir.join("arcface_mbf_b1.onnx");
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
}
pair(face_models_dir(server, user_id))
pair(face_models_dir(account))
.or_else(|| pair(shared_face_models_dir()))
.or_else(|| system_face_models_dirs().into_iter().find_map(pair))
}
@@ -4302,17 +4510,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// An account for the path tests, defaulting to the connector every
/// existing install uses.
fn account(endpoint: &str, user: &str) -> Account {
Account::new("nextcloud", endpoint).with_login(user, user)
}
#[test]
fn catalog_paths_separate_accounts() {
// Two accounts on one machine must not share an index, or one
// library's images appear in the other.
let a = catalog_path("https://cloud.example", "duncan");
let b = catalog_path("https://cloud.example", "someone");
let c = catalog_path("https://other.example", "duncan");
let a = catalog_path(&account("https://cloud.example", "duncan"));
let b = catalog_path(&account("https://cloud.example", "someone"));
let c = catalog_path(&account("https://other.example", "duncan"));
assert_ne!(a, b);
assert_ne!(a, c);
}
#[test]
fn a_folder_library_gets_its_own_catalog() {
// The same rule across backends: a folder library on this machine
// must not land in the directory a server account is already using.
let server = catalog_path(&account("https://cloud.example", "duncan"));
let folder = catalog_path(&Account::new("folder", "/mnt/photos"));
assert_ne!(server, folder);
assert_ne!(
folder,
catalog_path(&Account::new("folder", "/mnt/other-photos"))
);
}
#[test]
fn a_legacy_cache_directory_is_moved_rather_than_abandoned() {
// The upgrade hazard: `sidecars/` and `outbox/` hold work that exists
@@ -4372,7 +4599,7 @@ mod tests {
// and edit, and `outbox/`, holding exports the user was told had
// succeeded. Losing a day of culling to an OS housekeeping pass, with
// no error and no trace, is the worst outcome this application has.
let path = catalog_path("https://cloud.example", "duncan");
let path = catalog_path(&account("https://cloud.example", "duncan"));
let text = path.to_string_lossy().to_lowercase();
assert!(
!text.contains("/cache/") && !text.contains("/tmp/"),
@@ -4386,17 +4613,17 @@ mod tests {
// Stated as a test because three separate call sites derive their
// location by taking this path's parent, and a change here moves all
// of them at once — including the two holding unsynced user work.
let catalog = catalog_path("https://cloud.example", "duncan");
let catalog = catalog_path(&account("https://cloud.example", "duncan"));
let parent = catalog.parent().expect("a parent");
assert_eq!(
crate::export::outbox_dir("https://cloud.example", "duncan"),
crate::export::outbox_dir(&account("https://cloud.example", "duncan")),
parent.join("outbox")
);
}
#[test]
fn catalog_path_is_filesystem_safe() {
let p = catalog_path("https://cloud.example.com:8443/nc", "duncan");
let p = catalog_path(&account("https://cloud.example.com:8443/nc", "duncan"));
let s = p.to_string_lossy();
assert!(!s.contains("://"));
assert!(!s.contains(':') || cfg!(windows));
@@ -5029,8 +5256,8 @@ mod tests {
fn thumbs_live_beside_the_catalog_not_in_the_cache() {
// They sync to the server and are shared with other clients, so a
// cache sweep must not discard them.
let cat = catalog_path("https://cloud.example", "duncan");
let thumbs = thumbs_dir("https://cloud.example", "duncan");
let cat = catalog_path(&account("https://cloud.example", "duncan"));
let thumbs = thumbs_dir(&account("https://cloud.example", "duncan"));
assert_eq!(thumbs.parent(), cat.parent());
}
@@ -5445,6 +5672,7 @@ mod tests {
size,
modified: None,
has_preview: false,
materialised: true,
}
}
+88 -89
View File
@@ -17,7 +17,7 @@ use std::rc::Rc;
use std::sync::mpsc::Receiver;
use dr_catalog::Catalog;
use dr_sync_nextcloud::{AppCredentials, Session, SessionStore};
use dr_sync::{Account, AccountStore, Connection};
use dr_types::FormatFilter;
use slint::{ComponentHandle, Model as _};
@@ -177,7 +177,12 @@ pub struct LibraryController {
/// Pushing shards and the catalog to the server.
sync_timer: RefCell<Option<slint::Timer>>,
/// Kept so a rescan can run without going back through the launch screen.
session: RefCell<Option<(AppCredentials, Session, FormatFilter)>>,
///
/// A [`Connection`] rather than credentials beside an account: it is what
/// every worker needs, it is what `remote::connect` takes, and holding the
/// two halves separately is how they came to be threaded through fifteen
/// signatures in the wrong order.
session: RefCell<Option<(Connection, FormatFilter)>>,
/// Which collection narrows the grid, owned by [`crate::collections_ui`]
/// and read here. Shared rather than passed per call because a rescan, a
/// scrub and a drop all reload the window and must all honour it.
@@ -506,8 +511,8 @@ impl LibraryController {
/// (FR-NC-6a), and a queued edit must never be.
pub fn sidecar_cache_dir(&self) -> Option<PathBuf> {
let borrow = self.session.borrow();
let (_, session, _) = borrow.as_ref()?;
library::catalog_path(&session.server, &session.user_id)
let (conn, _) = borrow.as_ref()?;
library::catalog_path(&conn.account)
.parent()
.map(|p| p.join("sidecars"))
}
@@ -520,8 +525,8 @@ impl LibraryController {
/// catalog row is gone is unreachable anyway.
pub fn cache_dir(&self) -> Option<PathBuf> {
let borrow = self.session.borrow();
let (_, session, _) = borrow.as_ref()?;
library::catalog_path(&session.server, &session.user_id)
let (conn, _) = borrow.as_ref()?;
library::catalog_path(&conn.account)
.parent()
.map(|p| p.join("originals"))
}
@@ -565,8 +570,8 @@ impl LibraryController {
/// three hundred would re-download every one of them.
pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option<library::CacheContext> {
let borrow = self.session.borrow();
let (_, session, _) = borrow.as_ref()?;
let catalog_path = library::catalog_path(&session.server, &session.user_id);
let (conn, _) = borrow.as_ref()?;
let catalog_path = library::catalog_path(&conn.account);
let dir = catalog_path.parent()?.join("originals");
drop(borrow);
@@ -602,15 +607,12 @@ impl LibraryController {
self.catalog.clone()
}
/// Credentials and session for the open library.
/// The open library's connection.
///
/// Needed by the trash, whose `MOVE` and `DELETE` go to the same account the
/// Needed by the trash, whose move and delete go to the same account the
/// scan and thumbnail workers use. `None` before a library is opened.
pub fn session(&self) -> Option<(AppCredentials, Session)> {
self.session
.borrow()
.as_ref()
.map(|(c, s, _)| (c.clone(), s.clone()))
pub fn session(&self) -> Option<Connection> {
self.session.borrow().as_ref().map(|(c, _)| c.clone())
}
/// Catalog ids of the rows currently in the model, in model order.
@@ -730,16 +732,12 @@ impl LibraryController {
.collect()
}
/// Credentials and account for the open library, if one is open.
/// The open library's connection, for a full-file fetch.
///
/// What a full-file fetch needs: the grid's paths are remote, so opening
/// an image means downloading it, and that needs the same session the
/// thumbnail workers use.
pub fn credentials(&self) -> Option<(AppCredentials, String)> {
self.session
.borrow()
.as_ref()
.map(|(creds, session, _)| (creds.clone(), session.user_id.clone()))
/// The grid's paths are remote, so opening an image means fetching it, and
/// that goes through the same account the thumbnail workers use.
pub fn credentials(&self) -> Option<Connection> {
self.session.borrow().as_ref().map(|(c, _)| c.clone())
}
/// Narrow the grid to a collection, or to the whole library with `None`.
@@ -786,10 +784,13 @@ pub fn open(
window: &AppWindow,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
store: &SessionStore,
session: Session,
store: &AccountStore,
account: Account,
) {
let creds = match store.credentials(&session) {
// The credential is fetched only where the connector wants one; a folder
// library has none, and asking the keyring for it would fail the one
// backend that needs nothing.
let conn = match store.connection(&account, crate::remote::needs_secret(&account)) {
Ok(c) => c,
Err(e) => {
window.set_library_error(format!("credentials: {e}").into());
@@ -798,8 +799,8 @@ pub fn open(
}
};
let filter = session.format_filter();
*ctl.session.borrow_mut() = Some((creds.clone(), session.clone(), filter.clone()));
let filter = account.format_filter();
*ctl.session.borrow_mut() = Some((conn.clone(), filter.clone()));
window.set_show_library(true);
window.set_library_open(true);
@@ -809,10 +810,10 @@ pub fn open(
// Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label(
if session.root.is_empty() {
format!("{} · whole account", session.user_id)
if conn.account.root.is_empty() {
format!("{} · whole account", conn.account.user_id)
} else {
format!("{}/{}", session.user_id, session.root)
format!("{}/{}", conn.account.user_id, conn.account.root)
}
.into(),
);
@@ -825,13 +826,13 @@ pub fn open(
return;
}
let path = library::catalog_path(&session.server, &session.user_id);
let path = library::catalog_path(&conn.account);
log::info!(
"scanning {} for {} format(s) → {}",
if session.root.is_empty() {
if conn.account.root.is_empty() {
"<account root>"
} else {
&session.root
&conn.account.root
},
filter.iter().count(),
path.display()
@@ -842,9 +843,8 @@ pub fn open(
show_catalog_now(window, &ctl, &path, &coll_ctl);
let rx = library::spawn_scan(
creds,
session.user_id.clone(),
session.root.clone(),
conn.clone(),
conn.account.root.clone(),
filter,
path.clone(),
);
@@ -870,8 +870,8 @@ fn drain_scan(
// Named after the folder, because two accounts or two roots produce rows
// that are otherwise identical.
let title = match ctl.session.borrow().as_ref() {
Some((_, session, _)) if !session.root.is_empty() => {
format!("Scanning {}", session.root)
Some((c, _)) if !c.account.root.is_empty() => {
format!("Scanning {}", c.account.root)
}
_ => "Scanning the library".to_string(),
};
@@ -1044,7 +1044,7 @@ fn start_rescan(
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some((creds, session, filter)) = ctl.session.borrow().clone() else {
let Some((conn, filter)) = ctl.session.borrow().clone() else {
return;
};
@@ -1052,11 +1052,10 @@ fn start_rescan(
window.set_library_error(slint::SharedString::new());
window.set_library_status("Rescanning…".into());
let path = library::catalog_path(&session.server, &session.user_id);
let path = library::catalog_path(&conn.account);
let rx = library::spawn_scan(
creds,
session.user_id.clone(),
session.root.clone(),
conn.clone(),
conn.account.root.clone(),
filter,
path.clone(),
);
@@ -1327,13 +1326,30 @@ fn release_collection_offline(
}
};
let images = collection_images(catalog, &ids);
match cache.release(catalog.connection(), &images) {
let outcome = match cache.release(catalog.connection(), &images) {
Ok(r) => r,
Err(e) => {
window.set_library_error(format!("removing local copies: {e}").into());
return;
}
};
// TRACES: FR-NC-6c
// On a placeholder library the bookkeeping above owns no files, so it
// freed nothing — the originals are materialised in the library folder
// and only the sync client may take them back. Asking it to is what
// makes unpinning actually return the disk, and it must be a
// dehydration rather than a delete: removing a file inside a synced
// tree propagates to the server (ARCH §9.0a).
//
// Fire and forget: it is per-file work over a socket, the user has
// already been told the pin is withdrawn, and a client that refuses
// leaves the content where it is at no cost but disk.
if let Some(conn) = ctl.session() {
let path = library::catalog_path(&conn.account);
std::mem::drop(library::spawn_dehydrate(conn, path, images));
}
outcome
};
let (count, freed) = released;
@@ -1413,7 +1429,7 @@ fn collection_images(catalog: &Catalog, ids: &[dr_types::CollectionId]) -> Vec<d
/// TRACES: FR-NC-6a
/// Download whatever the pins still want, reporting progress.
fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let Some(cache_dir) = ctl.cache_dir() else {
@@ -1428,9 +1444,8 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
let rx = library::spawn_pin_fetch(
creds,
session.user_id.clone(),
library::catalog_path(&session.server, &session.user_id),
conn.clone(),
library::catalog_path(&conn.account),
cache_dir,
// Pinned originals are exempt from the budget, but a pin fetch also
// stores passively when it finds an image already cached, so the worker
@@ -1629,11 +1644,11 @@ fn start_outbox_drain(window: &AppWindow, ctl: &Rc<LibraryController>) {
ctl.outbox_maybe_dirty.set(false);
return;
}
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let rx = library::spawn_outbox_drain(creds, session.user_id.clone(), cache_dir);
let rx = library::spawn_outbox_drain(conn.clone(), cache_dir);
let job = ctl
.activity
.begin(crate::activity::Kind::Upload, "Uploading queued edits");
@@ -2764,7 +2779,7 @@ pub(crate) fn start_sidecar_writes(
// write being conditional on it.
let offline = ctl.is_offline();
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
@@ -2772,8 +2787,7 @@ pub(crate) fn start_sidecar_writes(
};
let count = writes.len();
let rx =
library::spawn_sidecar_writes(creds, session.user_id.clone(), writes, cache_dir, offline);
let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline);
let timer = slint::Timer::default();
let weak = window.as_weak();
@@ -2907,7 +2921,7 @@ fn fetch_rank(row: usize, first_on_screen: usize, on_screen: usize) -> (u8, usiz
/// Fetch thumbnails for rows in the model that do not have one yet.
fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
@@ -2967,11 +2981,10 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
let requested = wanted.len();
let rx = library::spawn_thumbnails(
creds,
session.user_id.clone(),
conn.clone(),
wanted,
library::thumbs_dir(&session.server, &session.user_id),
library::catalog_path(&session.server, &session.user_id),
library::thumbs_dir(&conn.account),
library::catalog_path(&conn.account),
);
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class);
}
@@ -3022,14 +3035,11 @@ pub fn refresh_thumbnail(
// nothing here to correct.
return;
};
let Some((_, session, _)) = ctl.session.borrow().clone() else {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(
&session.server,
&session.user_id,
)) {
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) {
Ok(s) => s,
Err(e) => {
log::warn!("re-thumbnailing {remote_path}: opening the store: {e}");
@@ -3336,7 +3346,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
return;
}
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
// Already running: a second pass would race the first over the same
@@ -3355,7 +3365,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
return;
}
let catalog_path = library::catalog_path(&session.server, &session.user_id);
let catalog_path = library::catalog_path(&conn.account);
let scratch = catalog_path
.parent()
.map(|p| p.join("scratch"))
@@ -3373,14 +3383,9 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
// for next time and nothing is lost by not watching it. It reports
// through the log until an export has a place in the activity list.
{
let outbox = crate::export::outbox_dir(&session.server, &session.user_id);
let outbox = crate::export::outbox_dir(&conn.account);
if crate::export::pending_count(&outbox) > 0 {
let rx = crate::export::spawn_upload(
creds.clone(),
session.user_id.clone(),
session.root.clone(),
outbox,
);
let rx = crate::export::spawn_upload(conn.clone(), conn.account.root.clone(), outbox);
std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
@@ -3403,10 +3408,9 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
window.set_library_syncing(true);
let rx = crate::derived_sync::spawn_sync(
creds,
session.user_id.clone(),
session.root.clone(),
library::thumbs_dir(&session.server, &session.user_id),
conn.clone(),
conn.account.root.clone(),
library::thumbs_dir(&conn.account),
catalog_path,
scratch,
);
@@ -3529,7 +3533,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
/// describes the fraction of the library that happened to be scrolled past.
/// This covers the rest.
fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
@@ -3544,11 +3548,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
return;
}
let rx = library::spawn_sweep(
creds,
session.user_id.clone(),
library::catalog_path(&session.server, &session.user_id),
);
let rx = library::spawn_sweep(conn.clone(), library::catalog_path(&conn.account));
let timer = slint::Timer::default();
let weak = window.as_weak();
@@ -3640,7 +3640,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
/// The sync at the end is not a separate courtesy: a filled store that never
/// leaves this device is most of the cost for none of the point.
fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
@@ -3664,10 +3664,9 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
window.set_library_thumbnailing(true);
let rx = library::spawn_thumbnail_sweep(
creds,
session.user_id.clone(),
library::catalog_path(&session.server, &session.user_id),
library::thumbs_dir(&session.server, &session.user_id),
conn.clone(),
library::catalog_path(&conn.account),
library::thumbs_dir(&conn.account),
);
let timer = slint::Timer::default();
+90 -22
View File
@@ -1,4 +1,4 @@
// TRACES: FR-NC-12
// TRACES: FR-NC-12 | FR-NC-13
//! The one place the interface names a backend.
//!
//! `dr-sync` defines [`RemoteBackend`] and a capability model the engine adapts
@@ -9,32 +9,100 @@
//! bought nothing it was designed for and a WebDAV or local-folder backend
//! would have had nowhere to go.
//!
//! Everything above this module now works through `&dyn RemoteBackend`. Adding
//! a backend is implementing the trait and changing [`connect`] — not editing
//! seven files.
//! Everything above this module works through `&dyn RemoteBackend`, and every
//! account it opens is a [`dr_sync::Account`] — configuration with no server
//! in it. Adding a backend is implementing two traits and adding a line to
//! [`registry`]; nothing else in `dr-ui` changes. See `docs/storage.md`.
//!
//! ## What is deliberately still Nextcloud-shaped
//! # Why the registry is built here and not in `dr-sync`
//!
//! Credentials. [`AppCredentials`] is an app password obtained through Login
//! Flow v2, which is a Nextcloud protocol rather than a general notion of
//! "how one authenticates to a remote". Abstracting it needs a decision about
//! what an account *is* across backends — an OAuth token, a bucket key pair
//! and an app password have no useful common shape — and inventing one before
//! a second backend exists would produce a wrong answer confidently. That is
//! the remaining half of this seam, and it is a design problem rather than a
//! mechanical one.
//! `dr-sync` must not depend on any connector, or the engine would drag a TLS
//! stack into a build that only wanted a folder. So the crate that already
//! depends on all of them — the interface — is where the list lives. It is
//! the only file in the application that names one.
use dr_sync::{RemoteBackend, RemoteError};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
use std::sync::{Arc, OnceLock};
/// Open a connection to the configured remote.
use dr_sync::{Account, BackendProvider, BackendRegistry, Connection, RemoteBackend, RemoteError};
use dr_sync_folder::FolderProvider;
use dr_sync_nextcloud::{NextcloudProvider, NextcloudVfs};
/// Every storage backend this build has, in the order the launch screen
/// offers them.
///
/// Built once. A provider is stateless — it holds no connection and no
/// credential — so one instance serves every thread that asks.
pub fn registry() -> &'static BackendRegistry {
static REGISTRY: OnceLock<BackendRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut r = BackendRegistry::new();
r.register(Arc::new(NextcloudProvider));
// TRACES: FR-NC-6c
// The folder connector does the filesystem work and knows nothing
// about sync clients; the placeholder convention is supplied here,
// which is the one place that may name one. Detection is per folder
// and per connection — the same directory offers hydration while the
// client is running and not while it is down (ARCH §9.0).
r.register(Arc::new(FolderProvider::with_vfs_detector(|root| {
NextcloudVfs::looks_synced(root)
.then(|| Arc::new(NextcloudVfs::detect()) as Arc<dyn dr_sync_folder::Vfs>)
})));
r
})
}
/// The connector serving an account.
///
/// Errors when this build has none — a configuration file outlives the binary
/// that wrote it, and saying *which* backend is missing beats "could not open
/// library".
pub fn provider_for(account: &Account) -> Result<&'static Arc<dyn BackendProvider>, RemoteError> {
registry().for_account(account)
}
/// Whether an account's credential has to be fetched from secure storage.
///
/// Asked of the connector rather than inferred from the account, because an
/// empty login might be a folder library or might be a damaged record, and
/// guessing turns the second into a silent unauthenticated connection.
pub fn needs_secret(account: &Account) -> bool {
provider_for(account).is_ok_and(|p| p.sign_in().needs_secret())
}
/// Open a connection to a configured remote.
///
/// Returns the trait object every caller should hold. The error type is
/// `dr-sync`'s rather than the connector's, so a caller handles a failure
/// `dr-sync`'s rather than a connector's, so a caller handles a failure
/// without learning which backend produced it.
pub(crate) fn connect(
creds: &AppCredentials,
user_id: &str,
) -> Result<Box<dyn RemoteBackend>, RemoteError> {
Ok(Box::new(NextcloudBackend::new(creds, user_id)?))
pub(crate) fn connect(conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
registry().connect(conn)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_backends_are_registered() {
// The list the launch screen offers. A backend missing from here is a
// backend the user cannot choose, however complete its connector is.
let ids: Vec<&str> = registry().providers().iter().map(|p| p.id()).collect();
assert!(ids.contains(&"nextcloud"), "{ids:?}");
assert!(ids.contains(&"folder"), "{ids:?}");
}
#[test]
fn only_the_backend_with_a_login_wants_a_credential() {
assert!(needs_secret(&Account::new("nextcloud", "https://x")));
assert!(!needs_secret(&Account::new("folder", "/mnt/photos")));
}
#[test]
fn an_account_for_an_unknown_backend_names_it() {
let e = provider_for(&Account::new("s3", "bucket"))
.err()
.expect("no such connector")
.to_string();
assert!(e.contains("s3"), "{e}");
}
}
+8 -7
View File
@@ -33,6 +33,7 @@ use slint::ComponentHandle;
use crate::settings_store::SettingsStore;
use crate::AppWindow;
use dr_sync::Connection;
/// Shared settings state for the running window.
pub struct SettingsController {
@@ -53,7 +54,8 @@ pub struct SettingsController {
/// it browses a remote tree and nothing about it is specific to what the
/// chosen folder is *for*. `None` means the picker is closed, which is
/// also the only state a device destination ever has — a path on this
/// machine is typed or chosen by the platform, not walked over WebDAV.
/// machine is typed or chosen by the platform, not walked through a
/// backend.
pub browser: RefCell<Option<crate::launch::FolderBrowser>>,
/// Polls the folder listing while one is in flight.
///
@@ -603,9 +605,9 @@ pub fn wire<F, G>(
/// List the folders under `path`, for the export destination picker.
///
/// A near-twin of `launch_ui::spawn_folder_list` and deliberately not shared
/// with it. That one reaches into the `LaunchController` for its session and
/// reports failures onto the launch screen's error line; this one is handed
/// credentials and writes to the settings page. Factoring them together would
/// with it. That one reaches into the `LaunchController` for its account and
/// reports failures onto the launch screen's error line; this one is handed a
/// connection and writes to the settings page. Factoring them together would
/// mean a function taking both controllers, or a trait implemented twice to
/// abstract two call sites — more machinery than the twenty lines it saves.
///
@@ -615,8 +617,7 @@ pub fn wire<F, G>(
pub fn spawn_folder_list(
weak: slint::Weak<AppWindow>,
ctl: Rc<SettingsController>,
creds: dr_sync_nextcloud::AppCredentials,
user_id: String,
conn: Connection,
path: String,
) {
use dr_sync::RemotePath;
@@ -637,7 +638,7 @@ pub fn spawn_folder_list(
return;
};
rt.block_on(async {
match crate::remote::connect(&creds, &user_id) {
match crate::remote::connect(&conn) {
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
Ok(entries) => {
let mut dirs: Vec<String> = entries
+6 -8
View File
@@ -31,8 +31,8 @@ use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use dr_catalog::{trash, Catalog};
use dr_sync::{RemoteError, RemoteId, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Connection, RemoteError, RemoteId, RemotePath};
use dr_types::ImageId;
/// What a trash operation reports back to the UI.
@@ -162,8 +162,7 @@ pub fn plan_restore(
/// interrupted batch leaves the rows it completed correct rather than losing all
/// of them.
pub fn spawn_move(
creds: AppCredentials,
user_id: String,
conn: Connection,
moves: Vec<Move>,
direction: Direction,
catalog_path: PathBuf,
@@ -184,7 +183,7 @@ pub fn spawn_move(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
@@ -274,8 +273,7 @@ pub fn spawn_move(
/// worker can drop the previews — the shards sync, so a stale entry would keep
/// serving a preview of a deleted photograph on every device.
pub fn spawn_purge(
creds: AppCredentials,
user_id: String,
conn: Connection,
images: Vec<ImageId>,
paths: Vec<(ImageId, Option<u64>, String)>,
catalog_path: PathBuf,
@@ -297,7 +295,7 @@ pub fn spawn_purge(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
+5
View File
@@ -175,6 +175,7 @@ export component AppWindow inherits Window {
in property <string> launch-account: "";
in property <string> launch-root: "";
in property <string> launch-server: "";
in property <string> launch-folder: "";
in property <bool> launch-busy: false;
in property <string> launch-status: "";
in property <string> launch-error: "";
@@ -184,6 +185,8 @@ export component AppWindow inherits Window {
in-out property <[bool]> launch-format-checked;
callback launch-sign-in(string);
/// The path of a folder library — no account, no credential.
callback launch-use-folder(string);
/// server, username, app password
callback launch-sign-in-direct(string, string, string);
callback launch-sign-out();
@@ -1228,6 +1231,7 @@ in property <bool> panel-visible: true;
account: root.launch-account;
library-root: root.launch-root;
server-url: root.launch-server;
folder-path: root.launch-folder;
busy: root.launch-busy;
status: root.launch-status;
error: root.launch-error;
@@ -1237,6 +1241,7 @@ in property <bool> panel-visible: true;
format-checked: root.launch-format-checked;
sign-in(server) => { root.launch-sign-in(server); }
use-folder(path) => { root.launch-use-folder(path); }
sign-in-direct(server, user, pw) => {
root.launch-sign-in-direct(server, user, pw);
}
+74 -5
View File
@@ -44,6 +44,9 @@ export component LaunchScreen inherits Rectangle {
in property <string> account: "";
in property <string> library-root: "";
in property <string> server-url: "";
// Two endpoints, shown together. Someone deciding between a server and a
// folder should not have to clear one field to try the other.
in property <string> folder-path: "";
in property <bool> busy: false;
in property <string> status: "";
in property <string> error: "";
@@ -58,6 +61,9 @@ export component LaunchScreen inherits Rectangle {
// --- events out ---
callback sign-in(string);
// A folder library: no browser, no credential, no waiting state — the
// whole sign-in is checking the directory is there.
callback use-folder(string);
/// server, username, app password
callback sign-in-direct(string, string, string);
callback sign-out();
@@ -87,15 +93,37 @@ export component LaunchScreen inherits Rectangle {
init => { self.focus(); }
}
VerticalLayout {
alignment: center;
padding: Theme.gap-lg;
// Scrollable, and it has to be. The signed-out screen offers three routes
// — browser sign-in, an app password, a folder — and the column is taller
// than a laptop window in a side-by-side split, let alone a phone. A
// centred `VerticalLayout` that overflows clips at *both* ends, so the
// masthead and the last route disappear together and there is no
// indication either existed.
//
// `viewport-height` follows the content rather than being fixed: the
// screen's height changes by hundreds of pixels as it moves between
// signed-out, awaiting approval, and signed-in, and a constant would
// either strand a scrollbar on the short states or clip the tall one.
Flickable {
viewport-height: max(self.height, column.preferred-height);
column := VerticalLayout {
width: 100%;
alignment: start;
padding: Theme.gap-lg;
// Centres the column when it fits and lets it start at the top
// when it does not — the two cases the fixed `alignment: center`
// could not both serve.
padding-top: max(
Theme.gap-lg,
(root.height - card.preferred-height - 2 * Theme.gap-lg) / 2
);
Rectangle {
max-width: 460px;
horizontal-stretch: 0;
VerticalLayout {
card := VerticalLayout {
spacing: Theme.gap-lg;
// --- masthead ---
@@ -114,7 +142,7 @@ export component LaunchScreen inherits Rectangle {
Label {
text: root.signed-in
? "Connected"
: "Connect a Nextcloud account to begin";
: "Connect a Nextcloud account, or open a folder";
body: true;
}
}
@@ -208,6 +236,46 @@ export component LaunchScreen inherits Rectangle {
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.";
wrap: word-wrap;
}
// --- or: a folder on this machine ---
//
// A local disk, a mounted share, or the folder the
// Nextcloud desktop client already syncs. No account and
// no credential, so this is the route that works on a
// machine with no keyring at all.
HorizontalLayout {
spacing: Theme.gap;
alignment: center;
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
Caption { text: "or"; }
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
}
PanelHeading { text: "FOLDER"; }
folder-input := Field {
text: root.folder-path;
placeholder: "/home/you/Pictures";
accepted(path) => { root.use-folder(path); }
}
FormButton {
text: "Open folder";
enabled: !root.busy && folder-input.text != "";
clicked => { root.use-folder(folder-input.text); }
}
Caption {
text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.";
wrap: word-wrap;
}
}
// --- login pending: the browser step ---
@@ -377,5 +445,6 @@ export component LaunchScreen inherits Rectangle {
}
}
}
}
}
}
+1 -1
View File
@@ -949,7 +949,7 @@ component HeaderActions inherits HorizontalLayout {
text: root.exporting
? "Cancel export"
: (root.export-to-server
? "Export " + root.selected-count + " to Nextcloud"
? "Export " + root.selected-count + " to the library"
: "Export " + root.selected-count);
active: root.exporting;
y: root.centred ? (root.row-height - self.height) / 2 : 0;