Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
This commit is contained in:
Generated
+17
-1
@@ -1224,7 +1224,7 @@ name = "darkroom-android"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-sync-nextcloud",
|
||||
"dr-sync",
|
||||
"dr-ui",
|
||||
"log",
|
||||
"slint",
|
||||
@@ -1551,6 +1551,21 @@ name = "dr-sync"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
"dr-types",
|
||||
"log",
|
||||
"thiserror 2.0.20",
|
||||
@@ -1615,6 +1630,7 @@ dependencies = [
|
||||
"dr-plat",
|
||||
"dr-segment",
|
||||
"dr-sync",
|
||||
"dr-sync-folder",
|
||||
"dr-sync-nextcloud",
|
||||
"dr-thumbs",
|
||||
"dr-types",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,612 @@
|
||||
// TRACES: FR-NC-13 | FR-NC-12
|
||||
//! A library that is just a directory.
|
||||
//!
|
||||
//! The second [`RemoteBackend`], and the one that exists to prove the first
|
||||
//! was an abstraction rather than a description. It serves a plain folder: a
|
||||
//! local disk, an NFS or SMB mount, a Nextcloud desktop client's synced copy,
|
||||
//! an external drive. No server, no account, no credential.
|
||||
//!
|
||||
//! # What it is honestly worse at, and why that is fine
|
||||
//!
|
||||
//! Nextcloud's fast path rests on directory ETags propagating up the tree, so
|
||||
//! one request against the root proves a 50k-image library unchanged. A POSIX
|
||||
//! directory's mtime says only that its own entry list changed — not that a
|
||||
//! grandchild's *contents* did — so there is nothing here to propagate and
|
||||
//! [`ChangeDetection::LocalEtags`] is the truthful answer. The engine reads
|
||||
//! that and walks the tree every scan instead of pruning it.
|
||||
//!
|
||||
//! Which costs almost nothing, because the walk that was expensive was
|
||||
//! expensive for a reason this backend does not have. Fifty thousand
|
||||
//! `stat` calls against a local filesystem take well under a second; fifty
|
||||
//! thousand `PROPFIND`s do not. The capability model is what lets both be
|
||||
//! driven by the same engine at the speed each one actually runs at.
|
||||
//!
|
||||
//! # Identity
|
||||
//!
|
||||
//! [`RemoteId::Stable`] here is a hash of the path relative to the library
|
||||
//! root. That gives the catalog what it needs — a `u64` that names a
|
||||
//! photograph, is the same on every device looking at the same folder, and
|
||||
//! does not change when the file is edited — which is what keys the thumbnail
|
||||
//! shards and the face index (`catalog.md` §10.1).
|
||||
//!
|
||||
//! It does **not** survive a rename, and [`Capabilities::stable_ids`] says so.
|
||||
//! A moved photograph is seen as a delete and an add, and its thumbnail is
|
||||
//! derived again. That is the documented degradation for a backend without
|
||||
//! server-assigned ids, and it is the right trade here: the alternative,
|
||||
//! keying on the inode, is stable across a rename but *differs between
|
||||
//! devices* and is reused by the filesystem after a delete — so two machines
|
||||
//! would disagree about which photograph a thumbnail belonged to, and a
|
||||
//! recycled inode would silently attach an old thumbnail to a new image.
|
||||
//! Re-deriving a thumbnail is a cost; showing the wrong one is a bug.
|
||||
//!
|
||||
//! # Blocking
|
||||
//!
|
||||
//! Every filesystem call goes through the blocking pool. On a local disk that
|
||||
//! is overkill; on the NFS mount this backend is most useful over, a stalled
|
||||
//! server would otherwise wedge the async worker that made the call and every
|
||||
//! other request sharing it.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::ops::Range;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use dr_sync::{
|
||||
Account, BackendProvider, Capabilities, ChangeDetection, Connection, Cursor, EntryKind,
|
||||
Precondition, RemoteBackend, RemoteChange, RemoteEntry, RemoteError, RemoteId, RemotePath,
|
||||
ServerPreviews, SignIn, Validator,
|
||||
};
|
||||
|
||||
/// The id written to [`Account::backend`] for a folder library.
|
||||
///
|
||||
/// On-disk configuration: changing it orphans every folder account.
|
||||
pub const BACKEND_ID: &str = "folder";
|
||||
|
||||
/// TRACES: FR-NC-13
|
||||
/// Registers the folder connector.
|
||||
///
|
||||
/// See [`dr_sync::provider`] for what each method is for.
|
||||
pub struct FolderProvider;
|
||||
|
||||
impl BackendProvider for FolderProvider {
|
||||
fn id(&self) -> &'static str {
|
||||
BACKEND_ID
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Folder"
|
||||
}
|
||||
|
||||
fn endpoint_label(&self) -> &'static str {
|
||||
"Folder"
|
||||
}
|
||||
|
||||
fn endpoint_placeholder(&self) -> &'static str {
|
||||
"/home/you/Pictures"
|
||||
}
|
||||
|
||||
fn sign_in(&self) -> SignIn {
|
||||
SignIn::EndpointOnly
|
||||
}
|
||||
|
||||
/// Check the directory before an account is written for it.
|
||||
///
|
||||
/// A typo here would otherwise be stored, skip the launch screen on the
|
||||
/// next start, and surface as a scan that finds nothing — which reads as
|
||||
/// a broken library rather than a wrong path. The messages say what to fix.
|
||||
fn normalise_endpoint(&self, input: &str) -> Result<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> {
|
||||
Ok(Box::new(FolderBackend::new(&conn.account.endpoint)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-13 | FR-NC-4
|
||||
/// A library rooted at a directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FolderBackend {
|
||||
root: PathBuf,
|
||||
caps: Capabilities,
|
||||
}
|
||||
|
||||
impl FolderBackend {
|
||||
/// Open the folder at `root`.
|
||||
///
|
||||
/// The directory must exist now. It may stop existing later — a drive
|
||||
/// unplugged, a mount dropped — and that surfaces per-operation as
|
||||
/// [`RemoteError::Network`], which is what puts the app into offline mode
|
||||
/// and leaves the catalog readable, exactly as a dead server does.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Result<Self, RemoteError> {
|
||||
let root = root.into();
|
||||
if !root.is_dir() {
|
||||
return Err(RemoteError::Configuration(format!(
|
||||
"{} is not a folder",
|
||||
root.display()
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
root,
|
||||
caps: Capabilities {
|
||||
// A directory's mtime describes its own entry list and nothing
|
||||
// below it, so there is no propagation to exploit; the engine
|
||||
// walks and compares per entry.
|
||||
change_detection: ChangeDetection::LocalEtags,
|
||||
// A path hash does not survive a rename. See the module docs
|
||||
// for why the inode is not used instead.
|
||||
stable_ids: false,
|
||||
range_reads: true,
|
||||
// Not a protocol with a message size limit; a write is a write.
|
||||
chunked_upload: None,
|
||||
bulk_upload: false,
|
||||
conditional_write: true,
|
||||
server_previews: ServerPreviews::None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// The local path for a remote path, refusing anything that escapes.
|
||||
///
|
||||
/// The guard is not theoretical. A `RemotePath` is built from strings that
|
||||
/// reach us from a catalog written by another device and from filenames on
|
||||
/// the remote itself, and this backend resolves them against a real
|
||||
/// filesystem with the user's own permissions. `../../.ssh/id_ed25519` is
|
||||
/// a legal path segment; without this it would be a legal *read*.
|
||||
fn resolve(&self, path: &RemotePath) -> Result<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))
|
||||
}
|
||||
|
||||
/// 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",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
blocking(move || {
|
||||
let read =
|
||||
std::fs::read_dir(&local).map_err(|e| map_io(e, &local.display().to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in read {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
// One unreadable entry must not fail the listing: a
|
||||
// scan of a real library meets a broken symlink or a
|
||||
// file being written, and abandoning the whole
|
||||
// directory over it loses every photograph beside it.
|
||||
Err(e) => {
|
||||
log::debug!("skipping an entry in {}: {e}", local.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else {
|
||||
// A name that is not UTF-8 cannot round-trip through a
|
||||
// `RemotePath`, and quietly mangling it would produce a
|
||||
// path that addresses a different file — or none.
|
||||
log::warn!("skipping a non-UTF-8 name in {}", local.display());
|
||||
continue;
|
||||
};
|
||||
|
||||
// `metadata`, not `symlink_metadata`: a symlinked shoot
|
||||
// folder is a normal way to assemble a library, and the
|
||||
// scan's depth limit is what stops a loop.
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::debug!("skipping {name}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let path = dir.join(name);
|
||||
out.push(RemoteEntry {
|
||||
id: RemoteId::Stable(identity(&path)),
|
||||
kind: if meta.is_dir() {
|
||||
EntryKind::Directory
|
||||
} else {
|
||||
EntryKind::File
|
||||
},
|
||||
validator: validator_of(&meta),
|
||||
size: meta.len(),
|
||||
modified: modified_secs(&meta),
|
||||
// No renderer behind a folder; previews are extracted
|
||||
// locally from the file itself.
|
||||
has_preview: false,
|
||||
path,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Not offered.
|
||||
///
|
||||
/// A directory's mtime changes when its own entries are added or removed
|
||||
/// and at no other time, so it cannot answer the question this method
|
||||
/// exists for — "did anything below here change?". Returning it anyway
|
||||
/// would let a future caller prune a subtree whose contents had been
|
||||
/// edited, and hide those edits for as long as the folder list held still.
|
||||
async fn dir_validator(&self, _dir: &RemotePath) -> Result<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 = self.resolve_id(id)?;
|
||||
blocking(move || {
|
||||
let what = local.display().to_string();
|
||||
let mut file = std::fs::File::open(&local).map_err(|e| map_io(e, &what))?;
|
||||
|
||||
let Some(r) = range else {
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).map_err(|e| map_io(e, &what))?;
|
||||
return Ok(buf);
|
||||
};
|
||||
|
||||
// A short read at the end of the file is not an error: the header
|
||||
// extractor asks for a fixed window and the file may be smaller
|
||||
// than it, which is the ordinary case for a small JPEG.
|
||||
file.seek(SeekFrom::Start(r.start))
|
||||
.map_err(|e| map_io(e, &what))?;
|
||||
let want = r.end.saturating_sub(r.start);
|
||||
let mut buf = Vec::new();
|
||||
file.take(want)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| map_io(e, &what))?;
|
||||
Ok(buf)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put(
|
||||
&self,
|
||||
path: &RemotePath,
|
||||
body: Vec<u8>,
|
||||
precond: Option<Precondition>,
|
||||
) -> Result<Validator, RemoteError> {
|
||||
let local = self.resolve(path)?;
|
||||
blocking(move || {
|
||||
let what = local.display().to_string();
|
||||
if let Some(parent) = local.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| map_io(e, &parent.display().to_string()))?;
|
||||
}
|
||||
|
||||
match &precond {
|
||||
// Genuinely atomic: `O_CREAT | O_EXCL` is one syscall, so two
|
||||
// devices racing to create a sidecar cannot both win.
|
||||
Some(Precondition::IfAbsent) => {
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&local)
|
||||
.map_err(|e| map_io(e, &what))?;
|
||||
f.write_all(&body).map_err(|e| map_io(e, &what))?;
|
||||
f.sync_all().map_err(|e| map_io(e, &what))?;
|
||||
let meta = f.metadata().map_err(|e| map_io(e, &what))?;
|
||||
return Ok(validator_of(&meta));
|
||||
}
|
||||
// Compare, then swap. A POSIX filesystem has no compare-and-
|
||||
// swap, so this narrows the window to the microseconds between
|
||||
// the `stat` and the `rename` rather than closing it. That is
|
||||
// still far tighter than the fallback the engine uses when a
|
||||
// backend declares no conditional write at all — comparing
|
||||
// revision counters *inside* the sidecar, which spans a whole
|
||||
// read-modify-write — which is why the capability is declared
|
||||
// rather than refused.
|
||||
Some(Precondition::IfMatch(expected)) => {
|
||||
let meta = std::fs::metadata(&local).map_err(|e| map_io(e, &what))?;
|
||||
if &validator_of(&meta) != expected {
|
||||
return Err(RemoteError::PreconditionFailed);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Write beside the destination and rename over it, so a reader
|
||||
// never sees a half-written sidecar and an interrupted write
|
||||
// cannot destroy the file it was replacing. Beside, not in
|
||||
// `/tmp`: a rename across filesystems is not atomic, and on
|
||||
// Android `/tmp` is a different one.
|
||||
let tmp = local.with_extension(format!(
|
||||
"{}.darkroom-tmp",
|
||||
local.extension().and_then(|e| e.to_str()).unwrap_or("")
|
||||
));
|
||||
let write = (|| -> Result<(), RemoteError> {
|
||||
let mut f = std::fs::File::create(&tmp).map_err(|e| map_io(e, &what))?;
|
||||
f.write_all(&body).map_err(|e| map_io(e, &what))?;
|
||||
f.sync_all().map_err(|e| map_io(e, &what))
|
||||
})();
|
||||
if let Err(e) = write {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, &local) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(map_io(e, &what));
|
||||
}
|
||||
|
||||
let meta = std::fs::metadata(&local).map_err(|e| map_io(e, &what))?;
|
||||
Ok(validator_of(&meta))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a file, or an empty directory.
|
||||
///
|
||||
/// **Not recursive, unlike WebDAV's `DELETE` on a collection.** The
|
||||
/// divergence is deliberate: a folder library is the user's own
|
||||
/// photographs on their own disk, with no server-side trash behind it, so
|
||||
/// a caller that passed the wrong path would have no way back. Nothing in
|
||||
/// the engine deletes a directory — the soft delete is a
|
||||
/// [`move_to`](RemoteBackend::move_to) into the trash folder — so refusing
|
||||
/// costs nothing and the guard is free.
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &RemoteId,
|
||||
precond: Option<Precondition>,
|
||||
) -> Result<(), RemoteError> {
|
||||
let local = self.resolve_id(id)?;
|
||||
blocking(move || {
|
||||
let what = local.display().to_string();
|
||||
let meta = std::fs::symlink_metadata(&local).map_err(|e| map_io(e, &what))?;
|
||||
|
||||
match &precond {
|
||||
Some(Precondition::IfMatch(expected)) => {
|
||||
if &validator_of(&meta) != expected {
|
||||
return Err(RemoteError::PreconditionFailed);
|
||||
}
|
||||
}
|
||||
// "Delete only if nothing is there" is not a thing to ask of a
|
||||
// delete; something is there or the `stat` above already
|
||||
// failed.
|
||||
Some(Precondition::IfAbsent) => {
|
||||
return Err(RemoteError::Unsupported(
|
||||
"IfAbsent is not meaningful on a delete",
|
||||
))
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
if meta.is_dir() {
|
||||
std::fs::remove_dir(&local).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::DirectoryNotEmpty {
|
||||
RemoteError::Configuration(format!(
|
||||
"{what} is not empty; a folder library will not delete a tree"
|
||||
))
|
||||
} else {
|
||||
map_io(e, &what)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
std::fs::remove_file(&local).map_err(|e| map_io(e, &what))
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn move_to(&self, from: &RemoteId, to: &RemotePath) -> Result<(), RemoteError> {
|
||||
let src = self.resolve_id(from)?;
|
||||
let dst = self.resolve(to)?;
|
||||
blocking(move || {
|
||||
let what = dst.display().to_string();
|
||||
// Parents first: the trash folder does not exist until the first
|
||||
// photograph is trashed, and the trait promises this creates it.
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| map_io(e, &parent.display().to_string()))?;
|
||||
}
|
||||
|
||||
match std::fs::rename(&src, &dst) {
|
||||
Ok(()) => Ok(()),
|
||||
// EXDEV. Both paths are inside one library root, so this
|
||||
// needs a root that spans a mount point — a shoot folder
|
||||
// that is its own mount, which is an ordinary way to attach
|
||||
// an archive drive. Copy and unlink rather than refusing:
|
||||
// the identity a rename would have preserved is a path hash
|
||||
// here, and it changes either way.
|
||||
Err(e) if e.raw_os_error() == Some(18) => {
|
||||
std::fs::copy(&src, &dst).map_err(|e| map_io(e, &what))?;
|
||||
std::fs::remove_file(&src).map_err(|e| {
|
||||
// The copy landed. Leaving the original is a
|
||||
// duplicate, which the next scan will show; losing
|
||||
// the copy would be worse.
|
||||
let _ = std::fs::remove_file(&dst);
|
||||
map_io(e, &src.display().to_string())
|
||||
})
|
||||
}
|
||||
Err(e) => Err(map_io(e, &what)),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_dir(&self, path: &RemotePath) -> Result<(), RemoteError> {
|
||||
let local = self.resolve(path)?;
|
||||
blocking(move || {
|
||||
// `create_dir_all` makes parents and succeeds on one that already
|
||||
// exists, which is exactly the contract.
|
||||
std::fs::create_dir_all(&local).map_err(|e| map_io(e, &local.display().to_string()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,540 @@
|
||||
//! Behaviour of the folder connector, against real directories.
|
||||
//!
|
||||
//! No mocks: the whole point of this backend is what a filesystem actually
|
||||
//! does, and a double would only assert what this file assumes.
|
||||
|
||||
use super::*;
|
||||
use dr_sync::{scan, RemoteBackend};
|
||||
use dr_types::FormatFilter;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A throwaway library root.
|
||||
///
|
||||
/// Under the system temp directory, named for the test, and cleared first so a
|
||||
/// crashed run cannot leave state that makes the next one pass.
|
||||
struct Tmp(PathBuf);
|
||||
|
||||
impl Tmp {
|
||||
fn new(name: &str) -> Self {
|
||||
let d = std::env::temp_dir().join(format!("dr-folder-test-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
Tmp(d)
|
||||
}
|
||||
|
||||
fn file(&self, rel: &str, body: &[u8]) -> &Self {
|
||||
let p = self.0.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, body).unwrap();
|
||||
self
|
||||
}
|
||||
|
||||
fn backend(&self) -> FolderBackend {
|
||||
FolderBackend::new(&self.0).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tmp {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn names(entries: &[RemoteEntry]) -> Vec<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;
|
||||
|
||||
assert!(p.normalise_endpoint(" ").is_err(), "empty");
|
||||
assert!(p.normalise_endpoint("Pictures").is_err(), "relative");
|
||||
assert!(p.normalise_endpoint("/no/such/place").is_err(), "missing");
|
||||
|
||||
t.file("a.CR2", b"x");
|
||||
assert!(
|
||||
p.normalise_endpoint(&t.0.join("a.CR2").to_string_lossy())
|
||||
.is_err(),
|
||||
"a file is not a library"
|
||||
);
|
||||
|
||||
let ok = p.normalise_endpoint(&t.0.to_string_lossy()).unwrap();
|
||||
assert_eq!(PathBuf::from(&ok), t.0.canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_spellings_of_one_folder_become_one_account() {
|
||||
// Otherwise the same photographs are indexed twice, into two catalogs.
|
||||
let t = Tmp::new("canonical");
|
||||
t.file("sub/a.CR2", b"x");
|
||||
let p = FolderProvider;
|
||||
let direct = p
|
||||
.normalise_endpoint(&t.0.join("sub").to_string_lossy())
|
||||
.unwrap();
|
||||
let roundabout = p
|
||||
.normalise_endpoint(&t.0.join("sub/../sub").to_string_lossy())
|
||||
.unwrap();
|
||||
assert_eq!(direct, roundabout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_account_needs_no_credential() {
|
||||
let p = FolderProvider;
|
||||
assert_eq!(p.sign_in(), SignIn::EndpointOnly);
|
||||
assert!(!p.sign_in().needs_secret());
|
||||
|
||||
let account = p.account_for("/mnt/photos").unwrap();
|
||||
assert_eq!(account.backend, BACKEND_ID);
|
||||
assert_eq!(account.endpoint, "/mnt/photos");
|
||||
assert!(account.login.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_registry_opens_a_folder_account() {
|
||||
// End to end through the abstraction: an account, a registry, a backend —
|
||||
// with nothing in between naming this crate.
|
||||
let t = Tmp::new("registry");
|
||||
let mut registry = dr_sync::BackendRegistry::new();
|
||||
registry.register(std::sync::Arc::new(FolderProvider));
|
||||
|
||||
let account = Account::new(BACKEND_ID, t.0.to_string_lossy());
|
||||
let backend = registry.connect(&Connection::new(account, None)).unwrap();
|
||||
assert_eq!(backend.name(), "Folder");
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
//! Nextcloud connector — the only [`RemoteBackend`] implementation.
|
||||
//! Nextcloud connector.
|
||||
//!
|
||||
//! One of two [`RemoteBackend`] implementations, registered through
|
||||
//! [`NextcloudProvider`]. What an *account* is no longer lives here — that is
|
||||
//! [`dr_sync::Account`], which has no server in it — so this crate is the
|
||||
//! protocol and nothing else.
|
||||
//!
|
||||
//! Hand-rolled over `reqwest` rather than built on a WebDAV crate (D7). No
|
||||
//! mature Nextcloud crate exists, and the operations that matter here are
|
||||
@@ -16,11 +21,11 @@ use dr_sync::{
|
||||
pub mod auth;
|
||||
pub mod desktop_client;
|
||||
mod propfind;
|
||||
pub mod session;
|
||||
pub mod provider;
|
||||
|
||||
pub use auth::{AppCredentials, LoginFlow};
|
||||
pub use desktop_client::DesktopClient;
|
||||
pub use session::{Session, SessionError, SessionStore};
|
||||
pub use provider::NextcloudProvider;
|
||||
|
||||
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
|
||||
const CHUNKS: ChunkConstraints = ChunkConstraints {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,21 @@ pub enum RemoteError {
|
||||
#[error("operation unsupported by this backend: {0}")]
|
||||
Unsupported(&'static str),
|
||||
|
||||
/// The account is configured wrongly, or for a backend this build has no
|
||||
/// connector for.
|
||||
///
|
||||
/// **Not a network failure and not an auth failure**, which is why it is
|
||||
/// its own variant. A folder library whose directory has been unmounted,
|
||||
/// or an account naming a backend a cut-down build was not compiled with,
|
||||
/// produces a request that never leaves the process — reporting either as
|
||||
/// `Network` would put the app into offline mode and tell the user their
|
||||
/// connection is down, and reporting them as `AuthFailed` would send them
|
||||
/// to re-enter a credential that is fine. The message names what is wrong
|
||||
/// with the configuration, because that is the only thing that will fix
|
||||
/// it.
|
||||
#[error("account misconfigured: {0}")]
|
||||
Configuration(String),
|
||||
|
||||
/// A conditional write failed: the remote changed underneath us. Triggers
|
||||
/// the sidecar merge path (ARCH §8.5).
|
||||
#[error("precondition failed — remote was modified")]
|
||||
|
||||
+12
-3
@@ -1,9 +1,14 @@
|
||||
//! Pluggable remote storage for DarkRoom.
|
||||
//!
|
||||
//! Defines the [`RemoteBackend`] trait and the capability model the sync
|
||||
//! engine adapts to. Only the Nextcloud connector is implemented
|
||||
//! (`dr-sync-nextcloud`), but the boundary is designed so other backends can
|
||||
//! be added without touching the engine.
|
||||
//! engine adapts to, plus the pieces that let the application hold a backend
|
||||
//! without naming one: an [`Account`] that is configuration rather than a
|
||||
//! server, and a [`BackendProvider`] registry that turns one into a live
|
||||
//! connection.
|
||||
//!
|
||||
//! Two connectors ship: `dr-sync-nextcloud` and `dr-sync-folder`. Adding a
|
||||
//! third is implementing those two traits and registering the result — see
|
||||
//! [`provider`] for the whole contract.
|
||||
//!
|
||||
//! # Why capabilities rather than a common denominator
|
||||
//!
|
||||
@@ -20,15 +25,19 @@ use std::ops::Range;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub mod account;
|
||||
pub mod capability;
|
||||
pub mod error;
|
||||
pub mod provider;
|
||||
pub mod reachability;
|
||||
pub mod scan;
|
||||
pub mod types;
|
||||
pub mod upload;
|
||||
|
||||
pub use account::{Account, AccountError, AccountStore, Connection, Secret, LEGACY_BACKEND};
|
||||
pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, ServerPreviews};
|
||||
pub use error::RemoteError;
|
||||
pub use provider::{BackendProvider, BackendRegistry, SignIn};
|
||||
pub use reachability::{Connectivity, Reachability};
|
||||
pub use scan::{scan, ScanProgress, ScanResult};
|
||||
pub use types::{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+74
-6
@@ -56,8 +56,9 @@ darkroom/
|
||||
│ ├── dr-gpu wgpu device, tile scheduler, WGSL shaders, mask rasteriser
|
||||
│ ├── dr-colour lcms2 bindings, camera profiles, working-space transforms
|
||||
│ ├── dr-export encoders, resampling, output sizing
|
||||
│ ├── dr-sync RemoteBackend trait, sync engine, cache rules, merge
|
||||
│ └── dr-sync-nextcloud the only backend implementation (§8.4)
|
||||
│ ├── dr-sync RemoteBackend + BackendProvider, Account, sync engine, merge
|
||||
│ ├── dr-sync-nextcloud WebDAV, oc:fileid, chunked v2, Login Flow v2 (§8.4)
|
||||
│ └── dr-sync-folder a plain directory: disk, mount, synced folder (§8.4a)
|
||||
├── ui/
|
||||
│ ├── dr-ui Slint components, adaptive layout, descriptor→control mapping
|
||||
│ └── dr-widgets custom controls per WidgetKind (curve, wheel, crop, brush)
|
||||
@@ -572,9 +573,34 @@ recovery. Panics in decode are caught at the boundary, since RAW parsing handles
|
||||
|
||||
## 8. Sync architecture
|
||||
|
||||
Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait; **only the Nextcloud connector is
|
||||
implemented**, but the boundary is designed so S3, generic WebDAV, or a self-hosted photo server can
|
||||
be added without touching the sync engine.
|
||||
Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait and the capability model the engine
|
||||
adapts to; two connectors implement it — `dr-sync-nextcloud` and `dr-sync-folder` — and S3, generic
|
||||
WebDAV, or a self-hosted photo server can be added without touching the engine.
|
||||
|
||||
**`docs/storage.md` is the contract**: the four traits a connector meets, the four steps to add one,
|
||||
and what each shipped connector actually declares. This section says *why* the seam is shaped the
|
||||
way it is; that document says how to use it.
|
||||
|
||||
### 8.0 A trait is not a seam
|
||||
|
||||
Worth stating because this was got wrong for a release. `RemoteBackend` existed from the start and
|
||||
the code above it still knew it was talking to Nextcloud: seven files in `dr-ui` constructed a
|
||||
`NextcloudBackend` directly, ten functions took one by concrete type, an account *was* a server URL
|
||||
beside a DAV user id, and the local cache directory was named after a hostname. The abstraction was
|
||||
real and bought nothing.
|
||||
|
||||
Pluggable storage needs four things, and only the first is a trait over operations:
|
||||
|
||||
| | What | Where |
|
||||
|---|---|---|
|
||||
| 1 | Operations | `RemoteBackend` (§8.3) |
|
||||
| 2 | Capabilities — what is *cheap*, so the engine adapts rather than assumes | `Capabilities` (§8.1) |
|
||||
| 3 | Configuration — what an account is, with no server in it | `Account`, `Connection` |
|
||||
| 4 | Registration — how a connector is discovered without being named | `BackendProvider`, `BackendRegistry` |
|
||||
|
||||
`ui/dr-ui/src/remote.rs` is the only file above `dr-sync` that names a connector. `dr-sync` itself
|
||||
depends on none of them, so a build that only wants a folder library does not compile a TLS stack to
|
||||
get one.
|
||||
|
||||
### 8.1 Why capability negotiation, not a common denominator
|
||||
|
||||
@@ -702,7 +728,8 @@ Design notes worth keeping:
|
||||
|
||||
### 8.4 The Nextcloud connector
|
||||
|
||||
The only implementation. Mapping to the trait:
|
||||
The reference implementation, and the one whose peculiarities the capability model exists to keep.
|
||||
Mapping to the trait:
|
||||
|
||||
| Trait method | Nextcloud |
|
||||
|---|---|
|
||||
@@ -739,6 +766,47 @@ never `Depth: infinity`. Two implementation details worth keeping:
|
||||
and proves nothing about children, so it is pure overhead; a test asserts zero probes in that
|
||||
case. Only `PropagatingEtags` makes an unchanged parent prove an unchanged subtree.
|
||||
|
||||
### 8.4a The folder connector
|
||||
|
||||
A plain directory: a local disk, a network mount, an external drive, or the folder a Nextcloud
|
||||
desktop client already syncs. No server, no account, no credential — which makes it the route that
|
||||
works on a machine with no secrets daemon.
|
||||
|
||||
It exists for two reasons. It is genuinely useful, and a second implementation is the only way to
|
||||
find out whether the first was an abstraction or a description. Adding it is what turned §8.0's four
|
||||
items from a claim into a fact.
|
||||
|
||||
| Trait method | Folder |
|
||||
|---|---|
|
||||
| `capabilities` | `LocalEtags`, **no** stable ids, ranges, no chunking, conditional |
|
||||
| `list` | `read_dir` + `metadata`; validator is `size`-`mtime` |
|
||||
| `dir_validator` | `Unsupported` — a directory's mtime does not propagate |
|
||||
| `delta` | `Unsupported` — a folder keeps no change feed |
|
||||
| `get` + range | `seek` + `take`; a short read past the end is not an error |
|
||||
| `put` | write to a temporary beside the destination, `rename` over it |
|
||||
| `put` `IfAbsent` | `O_CREAT | O_EXCL` — genuinely atomic |
|
||||
| `put` `IfMatch` | `stat`, compare, then the same rename — narrows the race, does not close it |
|
||||
| `delete` | files, and *empty* directories only — see below |
|
||||
| `move_to` | `rename`, falling back to copy + unlink across a mount boundary |
|
||||
| auth | none |
|
||||
|
||||
Three things worth carrying forward:
|
||||
|
||||
- **`LocalEtags` is the honest answer, and it costs nothing.** A POSIX directory's mtime describes
|
||||
its own entry list and nothing below it, so there is no propagation to exploit and the engine
|
||||
walks the tree every scan. The walk that was expensive was expensive because it was 50k
|
||||
`PROPFIND`s; 50k `stat` calls take well under a second. This is the capability model paying for
|
||||
itself — one engine, two backends, each running at the speed it actually runs at.
|
||||
- **Identity is a path hash, not an inode.** An inode is stable across a rename but differs between
|
||||
devices and is reused after a delete, so two machines would disagree about which photograph a
|
||||
thumbnail belonged to and a recycled inode would attach an old thumbnail to a new image.
|
||||
Re-deriving a thumbnail is a cost; showing the wrong one is a bug. `stable_ids: false` reports the
|
||||
consequence.
|
||||
- **`delete` is deliberately not recursive**, unlike WebDAV's `DELETE` on a collection. There is no
|
||||
server-side trash behind a local folder, so a caller with a wrong path would have no way back.
|
||||
Nothing in the engine deletes a directory — the soft delete is a `move_to` (§FR-CAT-15) — so the
|
||||
guard is free.
|
||||
|
||||
### 8.5 Sidecar conflict resolution
|
||||
|
||||
`put` with `Precondition::IfMatch(validator)`. On precondition failure:
|
||||
|
||||
+25
-3
@@ -731,9 +731,16 @@ returns.
|
||||
WebDAV `SEARCH` (RFC 5323) against `/remote.php/dav/` filtered by mimetype and paginated via
|
||||
`d:limit`/`d:nresults`, in preference to walking thousands of folders with PROPFIND.
|
||||
|
||||
**FR-NC-12 — Backend independence.** Sync shall be implemented against a backend interface, with
|
||||
Nextcloud as the only implementation in v1. No protocol detail specific to Nextcloud may appear
|
||||
outside its connector.
|
||||
**FR-NC-12 — Backend independence.** Sync shall be implemented against a backend interface. No
|
||||
protocol detail specific to any one backend may appear outside its connector, and no layer above
|
||||
the interface may name a connector — with the single exception of the registry that constructs them
|
||||
(`dr_ui::remote`).
|
||||
|
||||
A trait over operations is not sufficient on its own, and the first release proved it: `dr-ui`
|
||||
constructed the Nextcloud backend directly in seven files, an account *was* a server URL beside a
|
||||
DAV user id, and the local cache directory was named after a hostname. Independence requires four
|
||||
things — operations, declared capabilities, an account model with no server in it, and a
|
||||
registration mechanism (ARCH §8.0, `docs/storage.md`).
|
||||
|
||||
Backends **declare capabilities** rather than conforming to a lowest common denominator, because
|
||||
the property that makes Nextcloud sync fast — directory ETags propagating up the tree, so an
|
||||
@@ -748,6 +755,21 @@ Where a capability is absent the app shall **degrade visibly, not silently**:
|
||||
- Without conditional writes, sidecar conflict detection falls back to revision comparison, which
|
||||
narrows but does not close the race; this is surfaced as a reduced-safety mode
|
||||
|
||||
**FR-NC-13 — Folder libraries.** A library shall be openable as a **plain directory** — a local
|
||||
disk, a network mount, an external drive, or a folder another client already syncs — with no
|
||||
account, no server and no credential.
|
||||
|
||||
This is a requirement rather than a convenience for three reasons. It is what a photographer with
|
||||
an archive drive and no server actually has. It is the only route that works where no secrets
|
||||
daemon exists, which FR-NC-2 otherwise treats as a degraded mode. And a second connector is the
|
||||
only way to keep FR-NC-12 honest: an interface with one implementation cannot be shown to be an
|
||||
interface.
|
||||
|
||||
The folder connector shall declare its capabilities truthfully rather than flatteringly — in
|
||||
particular it shall **not** claim propagating directory ETags, because a POSIX directory's mtime
|
||||
describes its own entry list and nothing beneath it, and a backend that claimed otherwise would
|
||||
hide edits rather than merely run slowly (ARCH §8.4a).
|
||||
|
||||
### 3.8 Platform integration
|
||||
|
||||
#### Android
|
||||
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
# Storage backends
|
||||
|
||||
How DarkRoom talks to wherever a library lives, and what it takes to add
|
||||
somewhere new.
|
||||
|
||||
This document is the contract. `docs/architecture.md` §8 says why sync is built
|
||||
on capability negotiation rather than a common denominator; this says what the
|
||||
seam actually is, where each piece lives, and what a third connector has to do.
|
||||
|
||||
---
|
||||
|
||||
## 1. What "pluggable" has to mean
|
||||
|
||||
A trait alone does not make storage pluggable. `RemoteBackend` existed from the
|
||||
first release and every layer above it still knew it was talking to Nextcloud:
|
||||
seven files in `dr-ui` constructed a `NextcloudBackend` directly, ten functions
|
||||
took one by concrete type, the account model was a server URL beside a DAV user
|
||||
id, and the local cache directory was named after a hostname. The abstraction
|
||||
was real and bought nothing, because everything that *reached* a backend was
|
||||
still shaped like one product.
|
||||
|
||||
Pluggable means all four of these, not just the first:
|
||||
|
||||
1. **Operations** — what a backend can do. `RemoteBackend`.
|
||||
2. **Capabilities** — what it can do *cheaply*, so the engine adapts instead of
|
||||
assuming. `Capabilities`.
|
||||
3. **Configuration** — what an account is, with no server in it. `Account`.
|
||||
4. **Registration** — how the application discovers a connector at all, without
|
||||
naming it. `BackendProvider` + `BackendRegistry`.
|
||||
|
||||
Two connectors ship. Nextcloud is unchanged and keeps every one of its
|
||||
peculiarities — those are the point of the capability model, not an
|
||||
embarrassment it has to hide. The folder connector serves a plain directory and
|
||||
exists partly because it is genuinely useful and partly because a second
|
||||
implementation is the only way to find out whether the first was an
|
||||
abstraction.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where each piece lives
|
||||
|
||||
```
|
||||
core/dr-sync/ the contract, and nothing that speaks a protocol
|
||||
├─ types.rs RemotePath, RemoteId, RemoteEntry, Validator, …
|
||||
├─ capability.rs Capabilities, ChangeDetection, ServerPreviews
|
||||
├─ error.rs RemoteError — the one error every caller handles
|
||||
├─ account.rs Account, AccountStore, Secret, Connection
|
||||
├─ provider.rs BackendProvider, BackendRegistry, SignIn
|
||||
├─ lib.rs RemoteBackend, SyncStrategy
|
||||
├─ scan.rs the walk, driven by capabilities
|
||||
├─ upload.rs where an original is placed
|
||||
└─ reachability.rs online/offline, inferred from observed results
|
||||
|
||||
core/dr-sync-nextcloud/ WebDAV, oc:fileid, chunked upload v2, Login Flow v2
|
||||
core/dr-sync-folder/ a directory on a filesystem
|
||||
|
||||
ui/dr-ui/src/remote.rs the registry — the ONLY file above dr-sync that
|
||||
names a connector
|
||||
```
|
||||
|
||||
`dr-sync` depends on no connector. That is deliberate and load-bearing: a build
|
||||
that only wants a folder library must not compile a TLS stack to get one, and
|
||||
the registry therefore lives in the crate that already depends on everything —
|
||||
the interface.
|
||||
|
||||
---
|
||||
|
||||
## 3. The four traits and types a connector meets
|
||||
|
||||
### 3.1 `RemoteBackend` — operations
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait RemoteBackend: Send + Sync {
|
||||
fn capabilities(&self) -> &Capabilities;
|
||||
fn name(&self) -> &str;
|
||||
|
||||
// discovery
|
||||
async fn list(&self, dir: &RemotePath, since: Option<&Validator>)
|
||||
-> Result<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: fifty thousand `stat`
|
||||
calls against a filesystem take well under a second, and fifty thousand
|
||||
`PROPFIND`s do not. The capability model is what lets both be driven by the same
|
||||
engine at the speed each actually runs at.
|
||||
|
||||
**Identity is a hash of the path relative to the library root**, FNV-1a 64
|
||||
(written out, because `DefaultHasher` is explicitly unstable between Rust
|
||||
releases and this value is written into the catalog). It gives the catalog a
|
||||
`u64` that names a photograph, is the same on every device looking at the same
|
||||
folder, and does not change when the file is edited. It does not survive a
|
||||
rename, and `stable_ids: false` says so: a moved photograph is seen as a delete
|
||||
and an add, and its thumbnail is derived again.
|
||||
|
||||
The alternative — keying on the inode — is stable across a rename but *differs
|
||||
between devices* and is reused by the filesystem after a delete. Two machines
|
||||
would disagree about which photograph a thumbnail belonged to, and a recycled
|
||||
inode would silently attach an old thumbnail to a new image. Re-deriving a
|
||||
thumbnail is a cost; showing the wrong one is a bug.
|
||||
|
||||
**Conditional writes.** `IfAbsent` is genuinely atomic (`O_CREAT | O_EXCL`).
|
||||
`IfMatch` is compare-then-swap: a `stat`, then a write to a temporary beside the
|
||||
destination and a `rename` over it. A POSIX filesystem has no compare-and-swap,
|
||||
so the race is narrowed to the microseconds between the two syscalls rather than
|
||||
closed — still far tighter than the fallback the engine uses for a backend that
|
||||
declares no conditional write at all, which spans a whole read-modify-write.
|
||||
The capability is declared, and the residual race is documented at the call
|
||||
site.
|
||||
|
||||
**Two deliberate divergences from WebDAV semantics:**
|
||||
|
||||
- **`delete` is not recursive.** A folder library is the user's own photographs
|
||||
on their own disk with no server-side trash behind it, so a caller that passed
|
||||
the wrong path would have no way back. Deleting a non-empty directory returns
|
||||
`RemoteError::Configuration`. Nothing in the engine deletes a directory — the
|
||||
soft delete is a `move_to` into the trash folder — so the guard is free.
|
||||
- **Every filesystem call runs on the blocking pool.** On a local disk that is
|
||||
overkill; on the NFS mount this backend is most useful over, a stalled server
|
||||
would otherwise wedge the async worker that made the call and every other
|
||||
request sharing it.
|
||||
|
||||
**Failure classification** matters as much as the operations. A vanished mount
|
||||
(`ESTALE`, `ENOTCONN`, `EIO`) maps to `RemoteError::Network`, which is what puts
|
||||
the app into offline mode and leaves the catalog readable — exactly as a dead
|
||||
server does. A permissions problem maps to `PermissionDenied` and does *not*,
|
||||
because going offline over one forbidden file would hide a fixable problem
|
||||
behind a network banner. An endpoint that is not a directory at all maps to
|
||||
`RemoteError::Configuration`: nothing was unreachable and no credential was
|
||||
wrong, so neither of the other two would send the user anywhere useful.
|
||||
|
||||
---
|
||||
|
||||
## 6. What the abstraction does not yet cover
|
||||
|
||||
Stated so the next person does not have to rediscover it.
|
||||
|
||||
- **Multiple accounts at once.** `AccountStore` holds a list and the launch
|
||||
screen uses the most recent. Nothing in the model prevents two open libraries;
|
||||
the interface has no place to show them.
|
||||
- **Per-backend settings.** A connector has no way to contribute a settings
|
||||
page. Anything configurable is on the `Account` or is not configurable.
|
||||
- **Capability probing at runtime.** `Capabilities` is fixed at construction.
|
||||
Nextcloud's `server_previews` should really be probed per account — a server
|
||||
with `camerarawpreviews` installed can render RAW — and today it is assumed to
|
||||
be `CommonFormatsOnly`.
|
||||
- **A general notion of an account.** Credentials stay connector-specific on
|
||||
purpose (§3.5). A third connector with an OAuth flow will need a third `SignIn`
|
||||
variant, and that is the right place for it to appear.
|
||||
+67
-66
@@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Source files scanned | 278 |
|
||||
| TRACES tags found | 811 |
|
||||
| Requirements defined | 177 |
|
||||
| Requirements covered | 106 |
|
||||
| **Coverage** | **59.9%** (106/177) |
|
||||
| Source files scanned | 282 |
|
||||
| TRACES tags found | 822 |
|
||||
| Requirements defined | 178 |
|
||||
| Requirements covered | 107 |
|
||||
| **Coverage** | **60.1%** (107/178) |
|
||||
|
||||
### By type
|
||||
|
||||
| Type | Covered | Defined |
|
||||
|---|---|---|
|
||||
| FR | 84 | 122 |
|
||||
| FR | 85 | 123 |
|
||||
| NFR | 20 | 49 |
|
||||
| R | 2 | 6 |
|
||||
|
||||
@@ -34,76 +34,77 @@ _None._
|
||||
| ID | Tagged in |
|
||||
|---|---|
|
||||
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
||||
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1014`](../ui/dr-ui/ui/library.slint#L1014), [`ui/dr-ui/ui/library.slint:1176`](../ui/dr-ui/ui/library.slint#L1176), [`ui/dr-ui/ui/library.slint:863`](../ui/dr-ui/ui/library.slint#L863) |
|
||||
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:2247`](../ui/dr-ui/src/library.rs#L2247), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1014`](../ui/dr-ui/ui/library.slint#L1014), [`ui/dr-ui/ui/library.slint:1176`](../ui/dr-ui/ui/library.slint#L1176), [`ui/dr-ui/ui/library.slint:863`](../ui/dr-ui/ui/library.slint#L863) |
|
||||
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:2229`](../ui/dr-ui/src/library.rs#L2229), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) |
|
||||
| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) |
|
||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:180`](../ui/dr-ui/src/library.rs#L180), [`ui/dr-ui/src/library.rs:209`](../ui/dr-ui/src/library.rs#L209), [`ui/dr-ui/src/library.rs:3661`](../ui/dr-ui/src/library.rs#L3661), [`ui/dr-ui/src/library.rs:3695`](../ui/dr-ui/src/library.rs#L3695), [`ui/dr-ui/src/library_ui.rs:185`](../ui/dr-ui/src/library_ui.rs#L185), [`ui/dr-ui/src/library_ui.rs:758`](../ui/dr-ui/src/library_ui.rs#L758), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
|
||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:452`](../core/dr-sync-nextcloud/src/lib.rs#L452), [`core/dr-sync/src/lib.rs:133`](../core/dr-sync/src/lib.rs#L133), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1219`](../ui/dr-ui/src/collections_ui.rs#L1219), [`ui/dr-ui/src/collections_ui.rs:1975`](../ui/dr-ui/src/collections_ui.rs#L1975), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:208`](../ui/dr-ui/src/library.rs#L208), [`ui/dr-ui/src/library.rs:3637`](../ui/dr-ui/src/library.rs#L3637), [`ui/dr-ui/src/library.rs:3671`](../ui/dr-ui/src/library.rs#L3671), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:756`](../ui/dr-ui/src/library_ui.rs#L756), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
|
||||
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) |
|
||||
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4593`](../ui/dr-ui/src/library_ui.rs#L4593), [`ui/dr-ui/ui/app.slint:316`](../ui/dr-ui/ui/app.slint#L316), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:190`](../ui/dr-ui/src/library.rs#L190), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
||||
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3709`](../ui/dr-ui/src/library.rs#L3709), [`ui/dr-ui/src/library_ui.rs:629`](../ui/dr-ui/src/library_ui.rs#L629), [`ui/dr-ui/src/library_ui.rs:6390`](../ui/dr-ui/src/library_ui.rs#L6390), [`ui/dr-ui/src/library_ui.rs:6401`](../ui/dr-ui/src/library_ui.rs#L6401), [`ui/dr-ui/src/library_ui.rs:6414`](../ui/dr-ui/src/library_ui.rs#L6414), [`ui/dr-ui/src/library_ui.rs:6429`](../ui/dr-ui/src/library_ui.rs#L6429), [`ui/dr-ui/src/library_ui.rs:6438`](../ui/dr-ui/src/library_ui.rs#L6438), [`ui/dr-ui/ui/app.slint:261`](../ui/dr-ui/ui/app.slint#L261), [`ui/dr-ui/ui/app.slint:442`](../ui/dr-ui/ui/app.slint#L442), [`ui/dr-ui/ui/library.slint:1225`](../ui/dr-ui/ui/library.slint#L1225), [`ui/dr-ui/ui/library.slint:1228`](../ui/dr-ui/ui/library.slint#L1228), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908) |
|
||||
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:3955`](../ui/dr-ui/src/library.rs#L3955), [`ui/dr-ui/src/library_ui.rs:316`](../ui/dr-ui/src/library_ui.rs#L316), [`ui/dr-ui/src/library_ui.rs:388`](../ui/dr-ui/src/library_ui.rs#L388), [`ui/dr-ui/src/library_ui.rs:5204`](../ui/dr-ui/src/library_ui.rs#L5204), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:6530`](../ui/dr-ui/src/library_ui.rs#L6530), [`ui/dr-ui/ui/app.slint:264`](../ui/dr-ui/ui/app.slint#L264), [`ui/dr-ui/ui/app.slint:442`](../ui/dr-ui/ui/app.slint#L442), [`ui/dr-ui/ui/app.slint:687`](../ui/dr-ui/ui/app.slint#L687), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1301`](../ui/dr-ui/ui/library.slint#L1301), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) |
|
||||
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3485`](../ui/dr-ui/src/library_ui.rs#L3485), [`ui/dr-ui/src/library_ui.rs:4183`](../ui/dr-ui/src/library_ui.rs#L4183), [`ui/dr-ui/ui/app.slint:437`](../ui/dr-ui/ui/app.slint#L437), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2564`](../ui/dr-ui/ui/library.slint#L2564), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
|
||||
| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3374`](../ui/dr-ui/src/develop.rs#L3374), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/lib.rs:1788`](../ui/dr-ui/src/lib.rs#L1788), [`ui/dr-ui/src/lib.rs:1924`](../ui/dr-ui/src/lib.rs#L1924), [`ui/dr-ui/src/lib.rs:498`](../ui/dr-ui/src/lib.rs#L498), [`ui/dr-ui/src/lib.rs:923`](../ui/dr-ui/src/lib.rs#L923), [`ui/dr-ui/src/library.rs:1656`](../ui/dr-ui/src/library.rs#L1656), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library.rs:507`](../ui/dr-ui/src/library.rs#L507), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:792`](../ui/dr-ui/src/library.rs#L792), [`ui/dr-ui/src/library_ui.rs:4885`](../ui/dr-ui/src/library_ui.rs#L4885), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2870`](../ui/dr-ui/src/develop.rs#L2870), [`ui/dr-ui/src/library.rs:149`](../ui/dr-ui/src/library.rs#L149), [`ui/dr-ui/src/library.rs:1616`](../ui/dr-ui/src/library.rs#L1616), [`ui/dr-ui/src/library.rs:1693`](../ui/dr-ui/src/library.rs#L1693), [`ui/dr-ui/src/library.rs:234`](../ui/dr-ui/src/library.rs#L234), [`ui/dr-ui/src/library.rs:4062`](../ui/dr-ui/src/library.rs#L4062), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:776`](../ui/dr-ui/src/library.rs#L776), [`ui/dr-ui/src/library.rs:792`](../ui/dr-ui/src/library.rs#L792), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library_ui.rs:1565`](../ui/dr-ui/src/library_ui.rs#L1565), [`ui/dr-ui/src/library_ui.rs:1591`](../ui/dr-ui/src/library_ui.rs#L1591), [`ui/dr-ui/src/library_ui.rs:1607`](../ui/dr-ui/src/library_ui.rs#L1607), [`ui/dr-ui/src/library_ui.rs:1701`](../ui/dr-ui/src/library_ui.rs#L1701), [`ui/dr-ui/src/library_ui.rs:227`](../ui/dr-ui/src/library_ui.rs#L227), [`ui/dr-ui/src/library_ui.rs:2317`](../ui/dr-ui/src/library_ui.rs#L2317), [`ui/dr-ui/src/library_ui.rs:260`](../ui/dr-ui/src/library_ui.rs#L260), [`ui/dr-ui/src/library_ui.rs:2755`](../ui/dr-ui/src/library_ui.rs#L2755), [`ui/dr-ui/src/library_ui.rs:2979`](../ui/dr-ui/src/library_ui.rs#L2979), [`ui/dr-ui/src/library_ui.rs:3260`](../ui/dr-ui/src/library_ui.rs#L3260), [`ui/dr-ui/src/library_ui.rs:3348`](../ui/dr-ui/src/library_ui.rs#L3348), [`ui/dr-ui/src/library_ui.rs:3536`](../ui/dr-ui/src/library_ui.rs#L3536), [`ui/dr-ui/src/library_ui.rs:3654`](../ui/dr-ui/src/library_ui.rs#L3654), [`ui/dr-ui/src/library_ui.rs:441`](../ui/dr-ui/src/library_ui.rs#L441), [`ui/dr-ui/src/library_ui.rs:499`](../ui/dr-ui/src/library_ui.rs#L499), [`ui/dr-ui/src/library_ui.rs:5302`](../ui/dr-ui/src/library_ui.rs#L5302), [`ui/dr-ui/src/library_ui.rs:5417`](../ui/dr-ui/src/library_ui.rs#L5417), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2702`](../ui/dr-ui/src/library.rs#L2702), [`ui/dr-ui/src/library.rs:3172`](../ui/dr-ui/src/library.rs#L3172), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3610`](../ui/dr-ui/src/library_ui.rs#L3610), [`ui/dr-ui/src/library_ui.rs:4575`](../ui/dr-ui/src/library_ui.rs#L4575), [`ui/dr-ui/ui/app.slint:319`](../ui/dr-ui/ui/app.slint#L319), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:189`](../ui/dr-ui/src/library.rs#L189), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
||||
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1558`](../ui/dr-ui/src/collections_ui.rs#L1558), [`ui/dr-ui/src/collections_ui.rs:1577`](../ui/dr-ui/src/collections_ui.rs#L1577), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3685`](../ui/dr-ui/src/library.rs#L3685), [`ui/dr-ui/src/library_ui.rs:631`](../ui/dr-ui/src/library_ui.rs#L631), [`ui/dr-ui/src/library_ui.rs:6372`](../ui/dr-ui/src/library_ui.rs#L6372), [`ui/dr-ui/src/library_ui.rs:6383`](../ui/dr-ui/src/library_ui.rs#L6383), [`ui/dr-ui/src/library_ui.rs:6396`](../ui/dr-ui/src/library_ui.rs#L6396), [`ui/dr-ui/src/library_ui.rs:6411`](../ui/dr-ui/src/library_ui.rs#L6411), [`ui/dr-ui/src/library_ui.rs:6420`](../ui/dr-ui/src/library_ui.rs#L6420), [`ui/dr-ui/ui/app.slint:264`](../ui/dr-ui/ui/app.slint#L264), [`ui/dr-ui/ui/app.slint:445`](../ui/dr-ui/ui/app.slint#L445), [`ui/dr-ui/ui/library.slint:1225`](../ui/dr-ui/ui/library.slint#L1225), [`ui/dr-ui/ui/library.slint:1228`](../ui/dr-ui/ui/library.slint#L1228), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908) |
|
||||
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:3931`](../ui/dr-ui/src/library.rs#L3931), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:393`](../ui/dr-ui/src/library_ui.rs#L393), [`ui/dr-ui/src/library_ui.rs:5186`](../ui/dr-ui/src/library_ui.rs#L5186), [`ui/dr-ui/src/library_ui.rs:5239`](../ui/dr-ui/src/library_ui.rs#L5239), [`ui/dr-ui/src/library_ui.rs:6512`](../ui/dr-ui/src/library_ui.rs#L6512), [`ui/dr-ui/ui/app.slint:267`](../ui/dr-ui/ui/app.slint#L267), [`ui/dr-ui/ui/app.slint:445`](../ui/dr-ui/ui/app.slint#L445), [`ui/dr-ui/ui/app.slint:690`](../ui/dr-ui/ui/app.slint#L690), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1301`](../ui/dr-ui/ui/library.slint#L1301), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) |
|
||||
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3472`](../ui/dr-ui/src/library_ui.rs#L3472), [`ui/dr-ui/src/library_ui.rs:4165`](../ui/dr-ui/src/library_ui.rs#L4165), [`ui/dr-ui/ui/app.slint:440`](../ui/dr-ui/ui/app.slint#L440), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2564`](../ui/dr-ui/ui/library.slint#L2564), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
|
||||
| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3374`](../ui/dr-ui/src/develop.rs#L3374), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1376`](../ui/dr-ui/src/lib.rs#L1376), [`ui/dr-ui/src/lib.rs:1777`](../ui/dr-ui/src/lib.rs#L1777), [`ui/dr-ui/src/lib.rs:1912`](../ui/dr-ui/src/lib.rs#L1912), [`ui/dr-ui/src/lib.rs:501`](../ui/dr-ui/src/lib.rs#L501), [`ui/dr-ui/src/lib.rs:926`](../ui/dr-ui/src/lib.rs#L926), [`ui/dr-ui/src/library.rs:1641`](../ui/dr-ui/src/library.rs#L1641), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library_ui.rs:4867`](../ui/dr-ui/src/library_ui.rs#L4867), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2870`](../ui/dr-ui/src/develop.rs#L2870), [`ui/dr-ui/src/library.rs:148`](../ui/dr-ui/src/library.rs#L148), [`ui/dr-ui/src/library.rs:1601`](../ui/dr-ui/src/library.rs#L1601), [`ui/dr-ui/src/library.rs:1677`](../ui/dr-ui/src/library.rs#L1677), [`ui/dr-ui/src/library.rs:233`](../ui/dr-ui/src/library.rs#L233), [`ui/dr-ui/src/library.rs:4038`](../ui/dr-ui/src/library.rs#L4038), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:774`](../ui/dr-ui/src/library.rs#L774), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1563`](../ui/dr-ui/src/library_ui.rs#L1563), [`ui/dr-ui/src/library_ui.rs:1589`](../ui/dr-ui/src/library_ui.rs#L1589), [`ui/dr-ui/src/library_ui.rs:1605`](../ui/dr-ui/src/library_ui.rs#L1605), [`ui/dr-ui/src/library_ui.rs:1699`](../ui/dr-ui/src/library_ui.rs#L1699), [`ui/dr-ui/src/library_ui.rs:2315`](../ui/dr-ui/src/library_ui.rs#L2315), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:2753`](../ui/dr-ui/src/library_ui.rs#L2753), [`ui/dr-ui/src/library_ui.rs:2975`](../ui/dr-ui/src/library_ui.rs#L2975), [`ui/dr-ui/src/library_ui.rs:3253`](../ui/dr-ui/src/library_ui.rs#L3253), [`ui/dr-ui/src/library_ui.rs:3341`](../ui/dr-ui/src/library_ui.rs#L3341), [`ui/dr-ui/src/library_ui.rs:3523`](../ui/dr-ui/src/library_ui.rs#L3523), [`ui/dr-ui/src/library_ui.rs:3637`](../ui/dr-ui/src/library_ui.rs#L3637), [`ui/dr-ui/src/library_ui.rs:446`](../ui/dr-ui/src/library_ui.rs#L446), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/library_ui.rs:5284`](../ui/dr-ui/src/library_ui.rs#L5284), [`ui/dr-ui/src/library_ui.rs:5399`](../ui/dr-ui/src/library_ui.rs#L5399), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
|
||||
| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1896`](../ui/dr-ui/src/lib.rs#L1896), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:255`](../ui/dr-ui/src/library.rs#L255), [`ui/dr-ui/src/library.rs:285`](../ui/dr-ui/src/library.rs#L285), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1884`](../ui/dr-ui/src/lib.rs#L1884), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:284`](../ui/dr-ui/src/library.rs#L284), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) |
|
||||
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460) |
|
||||
| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2725`](../ui/dr-ui/src/library.rs#L2725), [`ui/dr-ui/src/library.rs:2829`](../ui/dr-ui/src/library.rs#L2829), [`ui/dr-ui/ui/settings.slint:404`](../ui/dr-ui/ui/settings.slint#L404), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) |
|
||||
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463) |
|
||||
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459) |
|
||||
| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2703`](../ui/dr-ui/src/library.rs#L2703), [`ui/dr-ui/src/library.rs:2807`](../ui/dr-ui/src/library.rs#L2807), [`ui/dr-ui/ui/settings.slint:404`](../ui/dr-ui/ui/settings.slint#L404), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) |
|
||||
| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) |
|
||||
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) |
|
||||
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1472`](../ui/dr-ui/src/lib.rs#L1472), [`ui/dr-ui/src/lib.rs:2174`](../ui/dr-ui/src/lib.rs#L2174), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/library.rs:507`](../ui/dr-ui/src/library.rs#L507), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:1761`](../ui/dr-ui/ui/app.slint#L1761), [`ui/dr-ui/ui/app.slint:790`](../ui/dr-ui/ui/app.slint#L790), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
|
||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:613`](../ui/dr-ui/src/lib.rs#L613), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) |
|
||||
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1461`](../ui/dr-ui/src/lib.rs#L1461), [`ui/dr-ui/src/lib.rs:2162`](../ui/dr-ui/src/lib.rs#L2162), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:1766`](../ui/dr-ui/ui/app.slint#L1766), [`ui/dr-ui/ui/app.slint:793`](../ui/dr-ui/ui/app.slint#L793), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
|
||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:616`](../ui/dr-ui/src/lib.rs#L616), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) |
|
||||
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) |
|
||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4636`](../ui/dr-ui/src/develop.rs#L4636) |
|
||||
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) |
|
||||
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) |
|
||||
| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2148`](../ui/dr-ui/src/lib.rs#L2148), [`ui/dr-ui/src/lib.rs:546`](../ui/dr-ui/src/lib.rs#L546), [`ui/dr-ui/src/lib.rs:604`](../ui/dr-ui/src/lib.rs#L604), [`ui/dr-ui/src/library.rs:498`](../ui/dr-ui/src/library.rs#L498), [`ui/dr-ui/src/library.rs:747`](../ui/dr-ui/src/library.rs#L747), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:1006`](../ui/dr-ui/ui/adjust.slint#L1006), [`ui/dr-ui/ui/adjust.slint:924`](../ui/dr-ui/ui/adjust.slint#L924), [`ui/dr-ui/ui/app.slint:2251`](../ui/dr-ui/ui/app.slint#L2251), [`ui/dr-ui/ui/app.slint:568`](../ui/dr-ui/ui/app.slint#L568) |
|
||||
| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2136`](../ui/dr-ui/src/lib.rs#L2136), [`ui/dr-ui/src/lib.rs:549`](../ui/dr-ui/src/lib.rs#L549), [`ui/dr-ui/src/lib.rs:607`](../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/library.rs:497`](../ui/dr-ui/src/library.rs#L497), [`ui/dr-ui/src/library.rs:745`](../ui/dr-ui/src/library.rs#L745), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:1006`](../ui/dr-ui/ui/adjust.slint#L1006), [`ui/dr-ui/ui/adjust.slint:924`](../ui/dr-ui/ui/adjust.slint#L924), [`ui/dr-ui/ui/app.slint:2256`](../ui/dr-ui/ui/app.slint#L2256), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571) |
|
||||
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:205`](../core/dr-pipeline/src/framing.rs#L205), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:927`](../core/dr-pipeline/src/framing.rs#L927), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1992`](../ui/dr-ui/src/develop.rs#L1992), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) |
|
||||
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) |
|
||||
| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1420`](../ui/dr-ui/src/lib.rs#L1420), [`ui/dr-ui/src/lib.rs:1450`](../ui/dr-ui/src/lib.rs#L1450), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
||||
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/library.rs:1656`](../ui/dr-ui/src/library.rs#L1656), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library.rs:488`](../ui/dr-ui/src/library.rs#L488), [`ui/dr-ui/src/library_ui.rs:2577`](../ui/dr-ui/src/library_ui.rs#L2577), [`ui/dr-ui/src/library_ui.rs:2979`](../ui/dr-ui/src/library_ui.rs#L2979), [`ui/dr-ui/src/library_ui.rs:467`](../ui/dr-ui/src/library_ui.rs#L467), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:547`](../ui/dr-ui/src/settings_ui.rs#L547), [`ui/dr-ui/ui/adjust.slint:613`](../ui/dr-ui/ui/adjust.slint#L613), [`ui/dr-ui/ui/library.slint:1328`](../ui/dr-ui/ui/library.slint#L1328), [`ui/dr-ui/ui/library.slint:837`](../ui/dr-ui/ui/library.slint#L837), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) |
|
||||
| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
||||
| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1479`](../ui/dr-ui/src/lib.rs#L1479), [`ui/dr-ui/src/lib.rs:2441`](../ui/dr-ui/src/lib.rs#L2441), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:700`](../ui/dr-ui/ui/adjust.slint#L700), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1666`](../ui/dr-ui/ui/app.slint#L1666), [`ui/dr-ui/ui/app.slint:1839`](../ui/dr-ui/ui/app.slint#L1839), [`ui/dr-ui/ui/app.slint:2157`](../ui/dr-ui/ui/app.slint#L2157), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) |
|
||||
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:754`](../ui/dr-ui/src/lib.rs#L754), [`ui/dr-ui/src/lib.rs:813`](../ui/dr-ui/src/lib.rs#L813) |
|
||||
| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:1439`](../ui/dr-ui/src/lib.rs#L1439), [`ui/dr-ui/src/lib.rs:1447`](../ui/dr-ui/src/lib.rs#L1447), [`ui/dr-ui/src/lib.rs:2332`](../ui/dr-ui/src/lib.rs#L2332), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
||||
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1376`](../ui/dr-ui/src/lib.rs#L1376), [`ui/dr-ui/src/library.rs:1641`](../ui/dr-ui/src/library.rs#L1641), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:487`](../ui/dr-ui/src/library.rs#L487), [`ui/dr-ui/src/library_ui.rs:2575`](../ui/dr-ui/src/library_ui.rs#L2575), [`ui/dr-ui/src/library_ui.rs:2975`](../ui/dr-ui/src/library_ui.rs#L2975), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:549`](../ui/dr-ui/src/settings_ui.rs#L549), [`ui/dr-ui/ui/adjust.slint:613`](../ui/dr-ui/ui/adjust.slint#L613), [`ui/dr-ui/ui/library.slint:1328`](../ui/dr-ui/ui/library.slint#L1328), [`ui/dr-ui/ui/library.slint:837`](../ui/dr-ui/ui/library.slint#L837), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) |
|
||||
| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1447`](../ui/dr-ui/src/lib.rs#L1447), [`ui/dr-ui/src/lib.rs:2332`](../ui/dr-ui/src/lib.rs#L2332), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
||||
| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1468`](../ui/dr-ui/src/lib.rs#L1468), [`ui/dr-ui/src/lib.rs:2429`](../ui/dr-ui/src/lib.rs#L2429), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:700`](../ui/dr-ui/ui/adjust.slint#L700), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1671`](../ui/dr-ui/ui/app.slint#L1671), [`ui/dr-ui/ui/app.slint:1844`](../ui/dr-ui/ui/app.slint#L1844), [`ui/dr-ui/ui/app.slint:2162`](../ui/dr-ui/ui/app.slint#L2162), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) |
|
||||
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:757`](../ui/dr-ui/src/lib.rs#L757), [`ui/dr-ui/src/lib.rs:816`](../ui/dr-ui/src/lib.rs#L816) |
|
||||
| FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101) |
|
||||
| FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) |
|
||||
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1435`](../ui/dr-ui/src/lib.rs#L1435), [`ui/dr-ui/src/lib.rs:2647`](../ui/dr-ui/src/lib.rs#L2647) |
|
||||
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1535`](../ui/dr-ui/src/lib.rs#L1535), [`ui/dr-ui/src/lib.rs:314`](../ui/dr-ui/src/lib.rs#L314), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
|
||||
| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/develop.rs:5519`](../ui/dr-ui/src/develop.rs#L5519), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:1435`](../ui/dr-ui/src/lib.rs#L1435), [`ui/dr-ui/src/lib.rs:2624`](../ui/dr-ui/src/lib.rs#L2624), [`ui/dr-ui/src/lib.rs:2647`](../ui/dr-ui/src/lib.rs#L2647), [`ui/dr-ui/ui/app.slint:1526`](../ui/dr-ui/ui/app.slint#L1526), [`ui/dr-ui/ui/app.slint:47`](../ui/dr-ui/ui/app.slint#L47), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:731`](../ui/dr-ui/ui/settings.slint#L731) |
|
||||
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1424`](../ui/dr-ui/src/lib.rs#L1424), [`ui/dr-ui/src/lib.rs:2635`](../ui/dr-ui/src/lib.rs#L2635) |
|
||||
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1524`](../ui/dr-ui/src/lib.rs#L1524), [`ui/dr-ui/src/lib.rs:314`](../ui/dr-ui/src/lib.rs#L314), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
|
||||
| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/develop.rs:5519`](../ui/dr-ui/src/develop.rs#L5519), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1398`](../ui/dr-ui/src/lib.rs#L1398), [`ui/dr-ui/src/lib.rs:1424`](../ui/dr-ui/src/lib.rs#L1424), [`ui/dr-ui/src/lib.rs:2612`](../ui/dr-ui/src/lib.rs#L2612), [`ui/dr-ui/src/lib.rs:2635`](../ui/dr-ui/src/lib.rs#L2635), [`ui/dr-ui/ui/app.slint:1531`](../ui/dr-ui/ui/app.slint#L1531), [`ui/dr-ui/ui/app.slint:47`](../ui/dr-ui/ui/app.slint#L47), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:731`](../ui/dr-ui/ui/settings.slint#L731) |
|
||||
| FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:600`](../core/dr-types/src/settings.rs#L600), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) |
|
||||
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204), [`ui/dr-ui/src/lib.rs:2090`](../ui/dr-ui/src/lib.rs#L2090), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/lib.rs:397`](../ui/dr-ui/src/lib.rs#L397), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library_ui.rs:3365`](../ui/dr-ui/src/library_ui.rs#L3365), [`ui/dr-ui/src/library_ui.rs:558`](../ui/dr-ui/src/library_ui.rs#L558), [`ui/dr-ui/src/library_ui.rs:6372`](../ui/dr-ui/src/library_ui.rs#L6372), [`ui/dr-ui/src/library_ui.rs:6449`](../ui/dr-ui/src/library_ui.rs#L6449), [`ui/dr-ui/src/library_ui.rs:6461`](../ui/dr-ui/src/library_ui.rs#L6461), [`ui/dr-ui/src/library_ui.rs:661`](../ui/dr-ui/src/library_ui.rs#L661), [`ui/dr-ui/src/library_ui.rs:718`](../ui/dr-ui/src/library_ui.rs#L718), [`ui/dr-ui/ui/app.slint:1367`](../ui/dr-ui/ui/app.slint#L1367), [`ui/dr-ui/ui/app.slint:926`](../ui/dr-ui/ui/app.slint#L926), [`ui/dr-ui/ui/library.slint:1336`](../ui/dr-ui/ui/library.slint#L1336), [`ui/dr-ui/ui/library.slint:841`](../ui/dr-ui/ui/library.slint#L841), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
|
||||
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:604`](../ui/dr-ui/src/settings_ui.rs#L604) |
|
||||
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204), [`ui/dr-ui/src/lib.rs:2078`](../ui/dr-ui/src/lib.rs#L2078), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/lib.rs:397`](../ui/dr-ui/src/lib.rs#L397), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library_ui.rs:3358`](../ui/dr-ui/src/library_ui.rs#L3358), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`ui/dr-ui/src/library_ui.rs:6354`](../ui/dr-ui/src/library_ui.rs#L6354), [`ui/dr-ui/src/library_ui.rs:6431`](../ui/dr-ui/src/library_ui.rs#L6431), [`ui/dr-ui/src/library_ui.rs:6443`](../ui/dr-ui/src/library_ui.rs#L6443), [`ui/dr-ui/src/library_ui.rs:663`](../ui/dr-ui/src/library_ui.rs#L663), [`ui/dr-ui/src/library_ui.rs:720`](../ui/dr-ui/src/library_ui.rs#L720), [`ui/dr-ui/ui/app.slint:1372`](../ui/dr-ui/ui/app.slint#L1372), [`ui/dr-ui/ui/app.slint:929`](../ui/dr-ui/ui/app.slint#L929), [`ui/dr-ui/ui/library.slint:1336`](../ui/dr-ui/ui/library.slint#L1336), [`ui/dr-ui/ui/library.slint:841`](../ui/dr-ui/ui/library.slint#L841), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
|
||||
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:619`](../ui/dr-ui/src/export.rs#L619), [`ui/dr-ui/src/export.rs:647`](../ui/dr-ui/src/export.rs#L647), [`ui/dr-ui/src/export.rs:777`](../ui/dr-ui/src/export.rs#L777), [`ui/dr-ui/src/export.rs:794`](../ui/dr-ui/src/export.rs#L794), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2829`](../ui/dr-ui/src/develop.rs#L2829), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362) |
|
||||
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
|
||||
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library.rs:1049`](../ui/dr-ui/src/library.rs#L1049), [`ui/dr-ui/src/library.rs:1693`](../ui/dr-ui/src/library.rs#L1693), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library_ui.rs:1607`](../ui/dr-ui/src/library_ui.rs#L1607), [`ui/dr-ui/src/library_ui.rs:3365`](../ui/dr-ui/src/library_ui.rs#L3365), [`ui/dr-ui/src/library_ui.rs:499`](../ui/dr-ui/src/library_ui.rs#L499), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:904`](../core/dr-sync-nextcloud/src/lib.rs#L904), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) |
|
||||
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
|
||||
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4593`](../ui/dr-ui/src/library_ui.rs#L4593), [`ui/dr-ui/ui/app.slint:316`](../ui/dr-ui/ui/app.slint#L316), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
|
||||
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) |
|
||||
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`ui/dr-ui/src/launch.rs:277`](../ui/dr-ui/src/launch.rs#L277), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:417`](../ui/dr-ui/src/launch_ui.rs#L417) |
|
||||
| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library.rs:1040`](../ui/dr-ui/src/library.rs#L1040), [`ui/dr-ui/src/library.rs:1677`](../ui/dr-ui/src/library.rs#L1677), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1605`](../ui/dr-ui/src/library_ui.rs#L1605), [`ui/dr-ui/src/library_ui.rs:3358`](../ui/dr-ui/src/library_ui.rs#L3358), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:39`](../core/dr-sync-nextcloud/src/lib.rs#L39), [`core/dr-sync-nextcloud/src/lib.rs:909`](../core/dr-sync-nextcloud/src/lib.rs#L909), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:166`](../core/dr-sync/src/lib.rs#L166), [`core/dr-sync/src/lib.rs:49`](../core/dr-sync/src/lib.rs#L49), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) |
|
||||
| FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:143`](../core/dr-sync-folder/src/lib.rs#L143), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:65`](../core/dr-sync-folder/src/lib.rs#L65), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) |
|
||||
| FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`core/dr-sync/src/account.rs:59`](../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
|
||||
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2702`](../ui/dr-ui/src/library.rs#L2702), [`ui/dr-ui/src/library.rs:3172`](../ui/dr-ui/src/library.rs#L3172), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3610`](../ui/dr-ui/src/library_ui.rs#L3610), [`ui/dr-ui/src/library_ui.rs:4575`](../ui/dr-ui/src/library_ui.rs#L4575), [`ui/dr-ui/ui/app.slint:319`](../ui/dr-ui/ui/app.slint#L319), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||
| FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:143`](../core/dr-sync-folder/src/lib.rs#L143), [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:166`](../core/dr-sync/src/lib.rs#L166), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61) |
|
||||
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488) |
|
||||
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
||||
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:1014`](../core/dr-catalog/src/schema.rs#L1014), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1819`](../ui/dr-ui/src/lib.rs#L1819), [`ui/dr-ui/src/lib.rs:2710`](../ui/dr-ui/src/lib.rs#L2710), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1458`](../ui/dr-ui/src/library.rs#L1458), [`ui/dr-ui/src/library.rs:1616`](../ui/dr-ui/src/library.rs#L1616), [`ui/dr-ui/src/library_ui.rs:1066`](../ui/dr-ui/src/library_ui.rs#L1066), [`ui/dr-ui/src/library_ui.rs:1139`](../ui/dr-ui/src/library_ui.rs#L1139), [`ui/dr-ui/src/library_ui.rs:1240`](../ui/dr-ui/src/library_ui.rs#L1240), [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295), [`ui/dr-ui/src/library_ui.rs:1413`](../ui/dr-ui/src/library_ui.rs#L1413), [`ui/dr-ui/src/library_ui.rs:1532`](../ui/dr-ui/src/library_ui.rs#L1532), [`ui/dr-ui/src/library_ui.rs:2059`](../ui/dr-ui/src/library_ui.rs#L2059), [`ui/dr-ui/src/library_ui.rs:268`](../ui/dr-ui/src/library_ui.rs#L268), [`ui/dr-ui/src/library_ui.rs:279`](../ui/dr-ui/src/library_ui.rs#L279), [`ui/dr-ui/src/library_ui.rs:287`](../ui/dr-ui/src/library_ui.rs#L287), [`ui/dr-ui/src/library_ui.rs:299`](../ui/dr-ui/src/library_ui.rs#L299), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:400`](../ui/dr-ui/src/library_ui.rs#L400), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:452`](../ui/dr-ui/src/library_ui.rs#L452), [`ui/dr-ui/src/library_ui.rs:515`](../ui/dr-ui/src/library_ui.rs#L515), [`ui/dr-ui/src/library_ui.rs:5319`](../ui/dr-ui/src/library_ui.rs#L5319), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:558`](../ui/dr-ui/src/library_ui.rs#L558), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2330`](../ui/dr-ui/ui/app.slint#L2330), [`ui/dr-ui/ui/app.slint:431`](../ui/dr-ui/ui/app.slint#L431), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:980`](../ui/dr-ui/ui/library.slint#L980) |
|
||||
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295) |
|
||||
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1066`](../ui/dr-ui/src/library_ui.rs#L1066), [`ui/dr-ui/src/library_ui.rs:1139`](../ui/dr-ui/src/library_ui.rs#L1139), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
|
||||
| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372) |
|
||||
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126) |
|
||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1788`](../ui/dr-ui/src/lib.rs#L1788), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library_ui.rs:467`](../ui/dr-ui/src/library_ui.rs#L467) |
|
||||
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library.rs:976`](../ui/dr-ui/src/library.rs#L976) |
|
||||
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:1014`](../core/dr-catalog/src/schema.rs#L1014), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1807`](../ui/dr-ui/src/lib.rs#L1807), [`ui/dr-ui/src/lib.rs:2698`](../ui/dr-ui/src/lib.rs#L2698), [`ui/dr-ui/src/library.rs:1421`](../ui/dr-ui/src/library.rs#L1421), [`ui/dr-ui/src/library.rs:1444`](../ui/dr-ui/src/library.rs#L1444), [`ui/dr-ui/src/library.rs:1601`](../ui/dr-ui/src/library.rs#L1601), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1530`](../ui/dr-ui/src/library_ui.rs#L1530), [`ui/dr-ui/src/library_ui.rs:2057`](../ui/dr-ui/src/library_ui.rs#L2057), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:284`](../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:304`](../ui/dr-ui/src/library_ui.rs#L304), [`ui/dr-ui/src/library_ui.rs:313`](../ui/dr-ui/src/library_ui.rs#L313), [`ui/dr-ui/src/library_ui.rs:405`](../ui/dr-ui/src/library_ui.rs#L405), [`ui/dr-ui/src/library_ui.rs:415`](../ui/dr-ui/src/library_ui.rs#L415), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:520`](../ui/dr-ui/src/library_ui.rs#L520), [`ui/dr-ui/src/library_ui.rs:5301`](../ui/dr-ui/src/library_ui.rs#L5301), [`ui/dr-ui/src/library_ui.rs:5319`](../ui/dr-ui/src/library_ui.rs#L5319), [`ui/dr-ui/src/library_ui.rs:5331`](../ui/dr-ui/src/library_ui.rs#L5331), [`ui/dr-ui/src/library_ui.rs:551`](../ui/dr-ui/src/library_ui.rs#L551), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2335`](../ui/dr-ui/ui/app.slint#L2335), [`ui/dr-ui/ui/app.slint:434`](../ui/dr-ui/ui/app.slint#L434), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:980`](../ui/dr-ui/ui/library.slint#L980) |
|
||||
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) |
|
||||
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
|
||||
| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:100`](../core/dr-sync-nextcloud/src/lib.rs#L100), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3172`](../ui/dr-ui/src/library.rs#L3172), [`ui/dr-ui/src/library_ui.rs:3610`](../ui/dr-ui/src/library_ui.rs#L3610), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372) |
|
||||
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1123`](../ui/dr-ui/src/lib.rs#L1123) |
|
||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1777`](../ui/dr-ui/src/lib.rs#L1777), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472) |
|
||||
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library.rs:970`](../ui/dr-ui/src/library.rs#L970) |
|
||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) |
|
||||
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:863`](../ui/dr-ui/src/lib.rs#L863), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:866`](../ui/dr-ui/src/lib.rs#L866), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||
| FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) |
|
||||
| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) |
|
||||
| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) |
|
||||
@@ -111,38 +112,38 @@ _None._
|
||||
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) |
|
||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204) |
|
||||
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) |
|
||||
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2792`](../ui/dr-ui/src/lib.rs#L2792), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:165`](../ui/dr-ui/ui/identity.slint#L165), [`ui/dr-ui/ui/library.slint:1046`](../ui/dr-ui/ui/library.slint#L1046) |
|
||||
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/lib.rs:87`](../ui/dr-ui/src/lib.rs#L87), [`ui/dr-ui/src/library_ui.rs:287`](../ui/dr-ui/src/library_ui.rs#L287), [`ui/dr-ui/src/library_ui.rs:5204`](../ui/dr-ui/src/library_ui.rs#L5204), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/src/library_ui.rs:6480`](../ui/dr-ui/src/library_ui.rs#L6480), [`ui/dr-ui/ui/adjust.slint:506`](../ui/dr-ui/ui/adjust.slint#L506), [`ui/dr-ui/ui/adjust.slint:641`](../ui/dr-ui/ui/adjust.slint#L641), [`ui/dr-ui/ui/adjust.slint:962`](../ui/dr-ui/ui/adjust.slint#L962), [`ui/dr-ui/ui/app.slint:1945`](../ui/dr-ui/ui/app.slint#L1945), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:468`](../ui/dr-ui/ui/app.slint#L468), [`ui/dr-ui/ui/app.slint:54`](../ui/dr-ui/ui/app.slint#L54), [`ui/dr-ui/ui/app.slint:761`](../ui/dr-ui/ui/app.slint#L761), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/masks.slint:304`](../ui/dr-ui/ui/masks.slint#L304), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) |
|
||||
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/library_ui.rs:4388`](../ui/dr-ui/src/library_ui.rs#L4388), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1761`](../ui/dr-ui/ui/app.slint#L1761), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
|
||||
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4388`](../ui/dr-ui/src/library_ui.rs#L4388), [`ui/dr-ui/src/library_ui.rs:4433`](../ui/dr-ui/src/library_ui.rs#L4433), [`ui/dr-ui/src/library_ui.rs:4536`](../ui/dr-ui/src/library_ui.rs#L4536), [`ui/dr-ui/src/library_ui.rs:4564`](../ui/dr-ui/src/library_ui.rs#L4564), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/ui/app.slint:1603`](../ui/dr-ui/ui/app.slint#L1603), [`ui/dr-ui/ui/app.slint:437`](../ui/dr-ui/ui/app.slint#L437), [`ui/dr-ui/ui/app.slint:468`](../ui/dr-ui/ui/app.slint#L468), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
|
||||
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2406`](../ui/dr-ui/src/lib.rs#L2406), [`ui/dr-ui/src/lib.rs:2831`](../ui/dr-ui/src/lib.rs#L2831), [`ui/dr-ui/src/lib.rs:3016`](../ui/dr-ui/src/lib.rs#L3016), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:89`](../ui/dr-ui/ui/app.slint#L89), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2780`](../ui/dr-ui/src/lib.rs#L2780), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:165`](../ui/dr-ui/ui/identity.slint#L165), [`ui/dr-ui/ui/library.slint:1046`](../ui/dr-ui/ui/library.slint#L1046) |
|
||||
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1488`](../ui/dr-ui/src/collections_ui.rs#L1488), [`ui/dr-ui/src/collections_ui.rs:1502`](../ui/dr-ui/src/collections_ui.rs#L1502), [`ui/dr-ui/src/collections_ui.rs:1548`](../ui/dr-ui/src/collections_ui.rs#L1548), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1646`](../ui/dr-ui/src/collections_ui.rs#L1646), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/lib.rs:87`](../ui/dr-ui/src/lib.rs#L87), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:5186`](../ui/dr-ui/src/library_ui.rs#L5186), [`ui/dr-ui/src/library_ui.rs:5331`](../ui/dr-ui/src/library_ui.rs#L5331), [`ui/dr-ui/src/library_ui.rs:6462`](../ui/dr-ui/src/library_ui.rs#L6462), [`ui/dr-ui/ui/adjust.slint:506`](../ui/dr-ui/ui/adjust.slint#L506), [`ui/dr-ui/ui/adjust.slint:641`](../ui/dr-ui/ui/adjust.slint#L641), [`ui/dr-ui/ui/adjust.slint:962`](../ui/dr-ui/ui/adjust.slint#L962), [`ui/dr-ui/ui/app.slint:1950`](../ui/dr-ui/ui/app.slint#L1950), [`ui/dr-ui/ui/app.slint:464`](../ui/dr-ui/ui/app.slint#L464), [`ui/dr-ui/ui/app.slint:471`](../ui/dr-ui/ui/app.slint#L471), [`ui/dr-ui/ui/app.slint:54`](../ui/dr-ui/ui/app.slint#L54), [`ui/dr-ui/ui/app.slint:764`](../ui/dr-ui/ui/app.slint#L764), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/masks.slint:304`](../ui/dr-ui/ui/masks.slint#L304), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) |
|
||||
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/library_ui.rs:4370`](../ui/dr-ui/src/library_ui.rs#L4370), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1766`](../ui/dr-ui/ui/app.slint#L1766), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
|
||||
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1488`](../ui/dr-ui/src/collections_ui.rs#L1488), [`ui/dr-ui/src/collections_ui.rs:1502`](../ui/dr-ui/src/collections_ui.rs#L1502), [`ui/dr-ui/src/collections_ui.rs:1548`](../ui/dr-ui/src/collections_ui.rs#L1548), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1646`](../ui/dr-ui/src/collections_ui.rs#L1646), [`ui/dr-ui/src/collections_ui.rs:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4370`](../ui/dr-ui/src/library_ui.rs#L4370), [`ui/dr-ui/src/library_ui.rs:4415`](../ui/dr-ui/src/library_ui.rs#L4415), [`ui/dr-ui/src/library_ui.rs:4518`](../ui/dr-ui/src/library_ui.rs#L4518), [`ui/dr-ui/src/library_ui.rs:4546`](../ui/dr-ui/src/library_ui.rs#L4546), [`ui/dr-ui/src/library_ui.rs:5319`](../ui/dr-ui/src/library_ui.rs#L5319), [`ui/dr-ui/src/library_ui.rs:5331`](../ui/dr-ui/src/library_ui.rs#L5331), [`ui/dr-ui/ui/app.slint:1608`](../ui/dr-ui/ui/app.slint#L1608), [`ui/dr-ui/ui/app.slint:440`](../ui/dr-ui/ui/app.slint#L440), [`ui/dr-ui/ui/app.slint:471`](../ui/dr-ui/ui/app.slint#L471), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
|
||||
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2394`](../ui/dr-ui/src/lib.rs#L2394), [`ui/dr-ui/src/lib.rs:2819`](../ui/dr-ui/src/lib.rs#L2819), [`ui/dr-ui/src/lib.rs:3004`](../ui/dr-ui/src/lib.rs#L3004), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:89`](../ui/dr-ui/ui/app.slint#L89), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262) |
|
||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2829`](../ui/dr-ui/src/library.rs#L2829) |
|
||||
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:2124`](../ui/dr-ui/src/lib.rs#L2124), [`ui/dr-ui/ui/app.slint:937`](../ui/dr-ui/ui/app.slint#L937), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
|
||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
|
||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2807`](../ui/dr-ui/src/library.rs#L2807) |
|
||||
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2112`](../ui/dr-ui/src/lib.rs#L2112), [`ui/dr-ui/ui/app.slint:940`](../ui/dr-ui/ui/app.slint#L940), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
|
||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:499`](../ui/dr-ui/src/export.rs#L499) |
|
||||
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
|
||||
| NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||
| NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
|
||||
| NFR-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:3795`](../ui/dr-ui/src/library.rs#L3795), [`ui/dr-ui/src/library.rs:4568`](../ui/dr-ui/src/library.rs#L4568), [`ui/dr-ui/src/library_ui.rs:4073`](../ui/dr-ui/src/library_ui.rs#L4073), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) |
|
||||
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
|
||||
| NFR-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:3771`](../ui/dr-ui/src/library.rs#L3771), [`ui/dr-ui/src/library.rs:4563`](../ui/dr-ui/src/library.rs#L4563), [`ui/dr-ui/src/library_ui.rs:4055`](../ui/dr-ui/src/library_ui.rs#L4055), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) |
|
||||
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
|
||||
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) |
|
||||
| NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) |
|
||||
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:1049`](../ui/dr-ui/src/library.rs#L1049) |
|
||||
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/library.rs:1040`](../ui/dr-ui/src/library.rs#L1040) |
|
||||
| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
|
||||
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
|
||||
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72) |
|
||||
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724) |
|
||||
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2702`](../ui/dr-ui/src/library.rs#L2702) |
|
||||
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
||||
| NFR-SEC-2 | [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
|
||||
| NFR-SEC-2 | [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
|
||||
| NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
|
||||
| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
||||
|
||||
## Not yet tagged
|
||||
|
||||
71 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
||||
71 of 178 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
||||
|
||||
<details><summary>Show untagged requirements</summary>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TRACES: FR-CAT-3 | FR-CAT-7 | FR-NC-7
|
||||
//! Pushing derived state to Nextcloud: thumbnail shards and the catalog.
|
||||
//! Pushing derived state to the library: thumbnail shards and the catalog.
|
||||
//!
|
||||
//! # What travels, and why only this
|
||||
//!
|
||||
@@ -34,8 +34,8 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::AppCredentials;
|
||||
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
||||
|
||||
use dr_thumbs::ThumbStore;
|
||||
|
||||
/// Folder under the library root holding derived state.
|
||||
@@ -96,8 +96,7 @@ pub enum SyncMessage {
|
||||
/// Runs on its own thread with its own runtime, like every other network path
|
||||
/// here — the Slint loop must never block (NFR-P9).
|
||||
pub fn spawn_sync(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
conn: Connection,
|
||||
root: String,
|
||||
thumbs_dir: PathBuf,
|
||||
catalog_path: PathBuf,
|
||||
@@ -117,7 +116,7 @@ pub fn spawn_sync(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&conn) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(SyncMessage::Failed(e.to_string()));
|
||||
|
||||
+11
-13
@@ -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"),
|
||||
|
||||
@@ -356,15 +356,10 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
||||
/// boundary would not be.
|
||||
/// What the whole-library face pass needs to reach the server.
|
||||
///
|
||||
/// Credentials and not just paths, because the pass fetches its own pixels: an
|
||||
/// A connection and not just paths, because the pass fetches its own pixels: an
|
||||
/// image with no proxy is the ordinary case, not one to skip (see
|
||||
/// `library::spawn_face_sweep`).
|
||||
pub type SweepPaths = (
|
||||
dr_sync_nextcloud::AppCredentials,
|
||||
String,
|
||||
std::path::PathBuf,
|
||||
std::path::PathBuf,
|
||||
);
|
||||
pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf);
|
||||
|
||||
/// The detector and embedder files, when both are present.
|
||||
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf);
|
||||
@@ -690,7 +685,7 @@ pub fn wire<S, M, P>(
|
||||
if ctl.regroup.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
let Some((_, _, catalog_path, _)) = paths() else {
|
||||
let Some((_, catalog_path, _)) = paths() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -782,7 +777,7 @@ pub fn wire<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;
|
||||
};
|
||||
|
||||
@@ -791,8 +786,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,
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
);
|
||||
|
||||
+92
-16
@@ -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,62 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
// --- use a folder ---------------------------------------------------
|
||||
//
|
||||
// No thread, no waiting state, no credential: the whole sign-in is a
|
||||
// `stat`. That asymmetry with the browser flow above is not a special
|
||||
// case in the screen — it is what [`SignIn::EndpointOnly`] means, and any
|
||||
// future connector declaring it lands here rather than in new code.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = controller.clone();
|
||||
window.on_launch_use_folder(move |path| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
let provider = match crate::remote::registry().get(dr_sync_folder::BACKEND_ID) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
ctl.model
|
||||
.borrow_mut()
|
||||
.fail("this build has no folder support");
|
||||
render(&w, &ctl);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// The connector checks the directory before an account is written
|
||||
// for it. A typo stored here would skip the launch screen next
|
||||
// start and surface as a library that finds nothing.
|
||||
let endpoint = match provider.normalise_endpoint(&path) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
ctl.model.borrow_mut().fail(e);
|
||||
render(&w, &ctl);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let account = match provider.account_for(&endpoint) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
ctl.model.borrow_mut().fail(e.to_string());
|
||||
render(&w, &ctl);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// `None`: there is no credential, and asking the keyring for one
|
||||
// would fail on a machine with no secrets daemon — where a folder
|
||||
// library is exactly the thing that should still work.
|
||||
if let Err(e) = ctl.store.save(&account, None) {
|
||||
log::warn!("persisting account: {e}");
|
||||
}
|
||||
log::info!("using folder library at {}", account.endpoint);
|
||||
ctl.model.borrow_mut().signed_in(account);
|
||||
render(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// --- sign out ------------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
@@ -547,9 +620,10 @@ fn poll_channel(
|
||||
}
|
||||
LoginMessage::Success(boxed) => {
|
||||
let (creds, user_id) = *boxed;
|
||||
let session = Session::new(&creds, user_id);
|
||||
if let Err(e) = ctl.store.save(&session, &creds) {
|
||||
log::warn!("persisting session: {e}");
|
||||
let session = NextcloudProvider::account_from(&creds, user_id);
|
||||
let secret = dr_sync::Secret::new(&creds.app_password);
|
||||
if let Err(e) = ctl.store.save(&session, Some(&secret)) {
|
||||
log::warn!("persisting account: {e}");
|
||||
}
|
||||
ctl.model.borrow_mut().signed_in(session);
|
||||
done = true;
|
||||
@@ -576,10 +650,13 @@ fn poll_channel(
|
||||
|
||||
/// List top-level folders so one can be chosen as the library root.
|
||||
fn spawn_folder_list(weak: slint::Weak<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
|
||||
|
||||
+37
-49
@@ -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
|
||||
|
||||
+72
-77
@@ -25,8 +25,7 @@ use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use dr_catalog::{Catalog, JobKind, Priority};
|
||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::AppCredentials;
|
||||
use dr_sync::{Account, Connection, RemoteBackend, RemoteId, RemotePath};
|
||||
use dr_thumbs::ThumbStore;
|
||||
|
||||
use crate::sidecar_cache::SidecarCache;
|
||||
@@ -573,8 +572,7 @@ pub fn sidecar_path(image_path: &str) -> String {
|
||||
/// about it. Interrupting a cull with an error dialog per frame would be far
|
||||
/// worse than the risk. The counts are reported once, at the end.
|
||||
pub fn spawn_sidecar_writes(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
conn: Connection,
|
||||
writes: Vec<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 {
|
||||
@@ -1001,20 +995,17 @@ fn merge_into(local: &mut dr_pipeline::Sidecar, remote: &dr_pipeline::Sidecar) {
|
||||
|
||||
/// Where the catalog for an account lives.
|
||||
///
|
||||
/// Keyed by server and user so two accounts do not share an index. Under the
|
||||
/// XDG data directory, not cache: the catalog is rebuildable but rebuilding it
|
||||
/// costs a full rescan, so it is not something to discard on a cache sweep.
|
||||
pub fn catalog_path(server: &str, user_id: &str) -> PathBuf {
|
||||
let slug: String = server
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
|
||||
data_root()
|
||||
.join(format!("{slug}-{user_id}"))
|
||||
.join("catalog.sqlite")
|
||||
/// Keyed by [`Account::namespace`] so two accounts do not share an index —
|
||||
/// two servers, two logins on one server, or two folders on one disk. Under
|
||||
/// the XDG data directory, not cache: the catalog is rebuildable but
|
||||
/// rebuilding it costs a full rescan, so it is not something to discard on a
|
||||
/// cache sweep.
|
||||
///
|
||||
/// The namespace is the account's to compute, not this function's, because it
|
||||
/// is also frozen: it names the directory an existing install's catalog,
|
||||
/// thumbnail shards and un-uploaded sidecars are already in.
|
||||
pub fn catalog_path(account: &Account) -> PathBuf {
|
||||
data_root().join(account.namespace()).join("catalog.sqlite")
|
||||
}
|
||||
|
||||
/// The directory every account's data hangs off.
|
||||
@@ -1032,7 +1023,7 @@ pub fn catalog_path(server: &str, user_id: &str) -> PathBuf {
|
||||
/// reached the server, is the worst failure this application can have, and
|
||||
/// it would be silent.
|
||||
///
|
||||
/// `SessionStore::data_dir()` is the persistent per-app directory the
|
||||
/// `AccountStore::data_dir()` is the persistent per-app directory the
|
||||
/// Android entry point establishes before anything opens a store. On a
|
||||
/// desktop it is the XDG config directory, and the two lines below keep the
|
||||
/// established XDG *data* location there rather than moving anyone's
|
||||
@@ -1041,7 +1032,7 @@ fn data_root() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
|
||||
.unwrap_or_else(dr_sync_nextcloud::session::SessionStore::data_dir);
|
||||
.unwrap_or_else(dr_sync::AccountStore::data_dir);
|
||||
|
||||
base.join("darkroom")
|
||||
}
|
||||
@@ -1064,15 +1055,12 @@ fn data_root() -> PathBuf {
|
||||
/// one filesystem, so it is atomic and cannot half-finish. If the destination
|
||||
/// already exists this does nothing — the migration has run, or this is a
|
||||
/// fresh install, and in neither case may it overwrite live data.
|
||||
pub fn migrate_legacy_cache_data(server: &str, user_id: &str) {
|
||||
pub fn migrate_legacy_cache_data(account: &Account) {
|
||||
// Only meaningful where the old fallback and the new one differ, which is
|
||||
// exactly the platform that had the problem. On a desktop with XDG set,
|
||||
// both resolve to the same place and this returns immediately.
|
||||
let legacy_base = std::env::temp_dir();
|
||||
let Some(current) = catalog_path(server, user_id)
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
else {
|
||||
let Some(current) = catalog_path(account).parent().map(|p| p.to_path_buf()) else {
|
||||
return;
|
||||
};
|
||||
let Some(account) = current.file_name() else {
|
||||
@@ -1117,8 +1105,7 @@ fn move_account_dir(legacy: &std::path::Path, current: &std::path::Path) {
|
||||
/// Returns the receiver the UI drains. The worker owns its own tokio runtime
|
||||
/// and backend; nothing here touches the Slint event loop.
|
||||
pub fn spawn_scan(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
conn: Connection,
|
||||
root: String,
|
||||
filter: FormatFilter,
|
||||
catalog_path: PathBuf,
|
||||
@@ -1127,7 +1114,7 @@ pub fn spawn_scan(
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let started = std::time::Instant::now();
|
||||
if let Err(e) = run_scan(&tx, creds, user_id, root, filter, catalog_path, started) {
|
||||
if let Err(e) = run_scan(&tx, conn, root, filter, catalog_path, started) {
|
||||
let _ = tx.send(ScanMessage::Failed {
|
||||
message: e.message,
|
||||
offline: e.offline,
|
||||
@@ -1169,8 +1156,7 @@ impl From<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 +1171,7 @@ fn run_scan(
|
||||
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = crate::remote::connect(&creds, &user_id).map_err(ScanFailure::local)?;
|
||||
let backend = crate::remote::connect(&conn).map_err(ScanFailure::local)?;
|
||||
|
||||
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
|
||||
// first run this is empty and the walk is complete; on every run after
|
||||
@@ -1469,8 +1455,7 @@ pub enum PinMessage {
|
||||
/// memory — and it is the same contention that produced 423 Locked in the
|
||||
/// sweep.
|
||||
pub fn spawn_pin_fetch(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
conn: Connection,
|
||||
catalog_path: PathBuf,
|
||||
cache_dir: PathBuf,
|
||||
budget: dr_catalog::Budget,
|
||||
@@ -1531,7 +1516,7 @@ pub fn spawn_pin_fetch(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&conn) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(PinMessage::Failed {
|
||||
@@ -1678,8 +1663,7 @@ pub struct CacheContext {
|
||||
/// not read, so an edit this build failed to understand is never destroyed by
|
||||
/// having been opened.
|
||||
pub fn spawn_sidecar_fetch(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
conn: Connection,
|
||||
image_path: String,
|
||||
cache_dir: PathBuf,
|
||||
offline: bool,
|
||||
@@ -1720,7 +1704,7 @@ pub fn spawn_sidecar_fetch(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&conn) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::debug!("sidecar fetch backend: {e}");
|
||||
@@ -1769,8 +1753,7 @@ pub fn spawn_sidecar_fetch(
|
||||
}
|
||||
|
||||
pub fn spawn_full_fetch(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
conn: Connection,
|
||||
path: String,
|
||||
cache: Option<CacheContext>,
|
||||
) -> Receiver<Result<Vec<u8>, FetchFailure>> {
|
||||
@@ -1808,7 +1791,7 @@ pub fn spawn_full_fetch(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&conn) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(FetchFailure::local(e)));
|
||||
@@ -1851,8 +1834,7 @@ pub fn spawn_full_fetch(
|
||||
/// or a second device that synced the shards — fills the grid with no transfer
|
||||
/// at all. Only genuine misses reach the network.
|
||||
pub fn spawn_thumbnails(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
conn: Connection,
|
||||
wanted: Vec<ThumbnailRequest>,
|
||||
store_dir: PathBuf,
|
||||
catalog_path: PathBuf,
|
||||
@@ -1958,7 +1940,7 @@ pub fn spawn_thumbnails(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&conn) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
for req in &to_fetch {
|
||||
@@ -2487,11 +2469,7 @@ const SWEEP_LANES: usize = 6;
|
||||
///
|
||||
/// Runs at the back of the queue by design: it holds no lock the grid needs,
|
||||
/// and its chunked commits keep write transactions short.
|
||||
pub fn spawn_sweep(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
catalog_path: PathBuf,
|
||||
) -> Receiver<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 +2507,7 @@ pub fn spawn_sweep(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let Ok(backend) = crate::remote::connect(&creds, &user_id) else {
|
||||
let Ok(backend) = crate::remote::connect(&conn) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -2862,8 +2840,7 @@ fn faces_without_proxy(
|
||||
/// the images in flight and nothing else.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_face_sweep(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
conn: Connection,
|
||||
catalog_path: PathBuf,
|
||||
store_dir: PathBuf,
|
||||
detector_model: PathBuf,
|
||||
@@ -2991,7 +2968,7 @@ pub fn spawn_face_sweep(
|
||||
rt.block_on(async {
|
||||
// Through `remote::connect`, which is the only place in the
|
||||
// interface that knows whose backend this is.
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&conn) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("face sweep: {e}");
|
||||
@@ -3226,8 +3203,7 @@ pub enum ThumbSweepMessage {
|
||||
/// the alternative is a second piece of state that has to be invalidated when
|
||||
/// a file is replaced.
|
||||
pub fn spawn_thumbnail_sweep(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
conn: Connection,
|
||||
catalog_path: PathBuf,
|
||||
store_dir: PathBuf,
|
||||
) -> Receiver<ThumbSweepMessage> {
|
||||
@@ -3300,7 +3276,7 @@ pub fn spawn_thumbnail_sweep(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&conn) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("thumbnail sweep: {e}");
|
||||
@@ -3457,8 +3433,8 @@ fn thumbnails_outstanding(
|
||||
/// Beside the catalog rather than in the cache directory: these sync to the
|
||||
/// server and are shared with other clients, so discarding them on a cache
|
||||
/// sweep would cost a re-download for everyone.
|
||||
pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf {
|
||||
catalog_path(server, user_id)
|
||||
pub fn thumbs_dir(account: &Account) -> PathBuf {
|
||||
catalog_path(account)
|
||||
.parent()
|
||||
.map(|p| p.join("thumbs"))
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-thumbs"))
|
||||
@@ -3471,8 +3447,8 @@ pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf {
|
||||
/// project's licence, so the user obtains them and the app loads them from here
|
||||
/// (docs/faces.md §2). An absent directory is the ordinary state of a fresh
|
||||
/// install, not an error.
|
||||
pub fn face_models_dir(server: &str, user_id: &str) -> PathBuf {
|
||||
catalog_path(server, user_id)
|
||||
pub fn face_models_dir(account: &Account) -> PathBuf {
|
||||
catalog_path(account)
|
||||
.parent()
|
||||
.map(|p| p.join("models"))
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-models"))
|
||||
@@ -3511,13 +3487,13 @@ pub fn shared_face_models_dir() -> PathBuf {
|
||||
/// 3. **The system directories.** Where a package installs them — the Arch
|
||||
/// package puts the pair in `/usr/share/darkroom/models`. Last, so anything
|
||||
/// the user placed themselves outranks what the package shipped.
|
||||
pub fn face_models(server: &str, user_id: &str) -> Option<(PathBuf, PathBuf)> {
|
||||
pub fn face_models(account: &Account) -> Option<(PathBuf, PathBuf)> {
|
||||
fn pair(dir: PathBuf) -> Option<(PathBuf, PathBuf)> {
|
||||
let detector = dir.join("scrfd_500m_640.onnx");
|
||||
let embedder = dir.join("arcface_mbf_b1.onnx");
|
||||
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
|
||||
}
|
||||
pair(face_models_dir(server, user_id))
|
||||
pair(face_models_dir(account))
|
||||
.or_else(|| pair(shared_face_models_dir()))
|
||||
.or_else(|| system_face_models_dirs().into_iter().find_map(pair))
|
||||
}
|
||||
@@ -4302,17 +4278,36 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// An account for the path tests, defaulting to the connector every
|
||||
/// existing install uses.
|
||||
fn account(endpoint: &str, user: &str) -> Account {
|
||||
Account::new("nextcloud", endpoint).with_login(user, user)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_paths_separate_accounts() {
|
||||
// Two accounts on one machine must not share an index, or one
|
||||
// library's images appear in the other.
|
||||
let a = catalog_path("https://cloud.example", "duncan");
|
||||
let b = catalog_path("https://cloud.example", "someone");
|
||||
let c = catalog_path("https://other.example", "duncan");
|
||||
let a = catalog_path(&account("https://cloud.example", "duncan"));
|
||||
let b = catalog_path(&account("https://cloud.example", "someone"));
|
||||
let c = catalog_path(&account("https://other.example", "duncan"));
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_library_gets_its_own_catalog() {
|
||||
// The same rule across backends: a folder library on this machine
|
||||
// must not land in the directory a server account is already using.
|
||||
let server = catalog_path(&account("https://cloud.example", "duncan"));
|
||||
let folder = catalog_path(&Account::new("folder", "/mnt/photos"));
|
||||
assert_ne!(server, folder);
|
||||
assert_ne!(
|
||||
folder,
|
||||
catalog_path(&Account::new("folder", "/mnt/other-photos"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_cache_directory_is_moved_rather_than_abandoned() {
|
||||
// The upgrade hazard: `sidecars/` and `outbox/` hold work that exists
|
||||
@@ -4372,7 +4367,7 @@ mod tests {
|
||||
// and edit, and `outbox/`, holding exports the user was told had
|
||||
// succeeded. Losing a day of culling to an OS housekeeping pass, with
|
||||
// no error and no trace, is the worst outcome this application has.
|
||||
let path = catalog_path("https://cloud.example", "duncan");
|
||||
let path = catalog_path(&account("https://cloud.example", "duncan"));
|
||||
let text = path.to_string_lossy().to_lowercase();
|
||||
assert!(
|
||||
!text.contains("/cache/") && !text.contains("/tmp/"),
|
||||
@@ -4386,17 +4381,17 @@ mod tests {
|
||||
// Stated as a test because three separate call sites derive their
|
||||
// location by taking this path's parent, and a change here moves all
|
||||
// of them at once — including the two holding unsynced user work.
|
||||
let catalog = catalog_path("https://cloud.example", "duncan");
|
||||
let catalog = catalog_path(&account("https://cloud.example", "duncan"));
|
||||
let parent = catalog.parent().expect("a parent");
|
||||
assert_eq!(
|
||||
crate::export::outbox_dir("https://cloud.example", "duncan"),
|
||||
crate::export::outbox_dir(&account("https://cloud.example", "duncan")),
|
||||
parent.join("outbox")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_path_is_filesystem_safe() {
|
||||
let p = catalog_path("https://cloud.example.com:8443/nc", "duncan");
|
||||
let p = catalog_path(&account("https://cloud.example.com:8443/nc", "duncan"));
|
||||
let s = p.to_string_lossy();
|
||||
assert!(!s.contains("://"));
|
||||
assert!(!s.contains(':') || cfg!(windows));
|
||||
@@ -5029,8 +5024,8 @@ mod tests {
|
||||
fn thumbs_live_beside_the_catalog_not_in_the_cache() {
|
||||
// They sync to the server and are shared with other clients, so a
|
||||
// cache sweep must not discard them.
|
||||
let cat = catalog_path("https://cloud.example", "duncan");
|
||||
let thumbs = thumbs_dir("https://cloud.example", "duncan");
|
||||
let cat = catalog_path(&account("https://cloud.example", "duncan"));
|
||||
let thumbs = thumbs_dir(&account("https://cloud.example", "duncan"));
|
||||
assert_eq!(thumbs.parent(), cat.parent());
|
||||
}
|
||||
|
||||
|
||||
+70
-88
@@ -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(),
|
||||
);
|
||||
@@ -1413,7 +1412,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 +1427,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 +1627,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 +2762,7 @@ pub(crate) fn start_sidecar_writes(
|
||||
// write being conditional on it.
|
||||
let offline = ctl.is_offline();
|
||||
|
||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
|
||||
@@ -2772,8 +2770,7 @@ pub(crate) fn start_sidecar_writes(
|
||||
};
|
||||
|
||||
let count = writes.len();
|
||||
let rx =
|
||||
library::spawn_sidecar_writes(creds, session.user_id.clone(), writes, cache_dir, offline);
|
||||
let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
@@ -2907,7 +2904,7 @@ fn fetch_rank(row: usize, first_on_screen: usize, on_screen: usize) -> (u8, usiz
|
||||
|
||||
/// Fetch thumbnails for rows in the model that do not have one yet.
|
||||
fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -2967,11 +2964,10 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<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 +3018,11 @@ pub fn refresh_thumbnail(
|
||||
// nothing here to correct.
|
||||
return;
|
||||
};
|
||||
let Some((_, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(
|
||||
&session.server,
|
||||
&session.user_id,
|
||||
)) {
|
||||
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("re-thumbnailing {remote_path}: opening the store: {e}");
|
||||
@@ -3336,7 +3329,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<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 +3348,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 +3366,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 +3391,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 +3516,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 +3531,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 +3623,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 +3647,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();
|
||||
|
||||
+81
-22
@@ -1,4 +1,4 @@
|
||||
// TRACES: FR-NC-12
|
||||
// TRACES: FR-NC-12 | FR-NC-13
|
||||
//! The one place the interface names a backend.
|
||||
//!
|
||||
//! `dr-sync` defines [`RemoteBackend`] and a capability model the engine adapts
|
||||
@@ -9,32 +9,91 @@
|
||||
//! bought nothing it was designed for and a WebDAV or local-folder backend
|
||||
//! would have had nowhere to go.
|
||||
//!
|
||||
//! Everything above this module now works through `&dyn RemoteBackend`. Adding
|
||||
//! a backend is implementing the trait and changing [`connect`] — not editing
|
||||
//! seven files.
|
||||
//! Everything above this module works through `&dyn RemoteBackend`, and every
|
||||
//! account it opens is a [`dr_sync::Account`] — configuration with no server
|
||||
//! in it. Adding a backend is implementing two traits and adding a line to
|
||||
//! [`registry`]; nothing else in `dr-ui` changes. See `docs/storage.md`.
|
||||
//!
|
||||
//! ## What is deliberately still Nextcloud-shaped
|
||||
//! # Why the registry is built here and not in `dr-sync`
|
||||
//!
|
||||
//! Credentials. [`AppCredentials`] is an app password obtained through Login
|
||||
//! Flow v2, which is a Nextcloud protocol rather than a general notion of
|
||||
//! "how one authenticates to a remote". Abstracting it needs a decision about
|
||||
//! what an account *is* across backends — an OAuth token, a bucket key pair
|
||||
//! and an app password have no useful common shape — and inventing one before
|
||||
//! a second backend exists would produce a wrong answer confidently. That is
|
||||
//! the remaining half of this seam, and it is a design problem rather than a
|
||||
//! mechanical one.
|
||||
//! `dr-sync` must not depend on any connector, or the engine would drag a TLS
|
||||
//! stack into a build that only wanted a folder. So the crate that already
|
||||
//! depends on all of them — the interface — is where the list lives. It is
|
||||
//! the only file in the application that names one.
|
||||
|
||||
use dr_sync::{RemoteBackend, RemoteError};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Open a connection to the configured remote.
|
||||
use dr_sync::{Account, BackendProvider, BackendRegistry, Connection, RemoteBackend, RemoteError};
|
||||
use dr_sync_folder::FolderProvider;
|
||||
use dr_sync_nextcloud::NextcloudProvider;
|
||||
|
||||
/// Every storage backend this build has, in the order the launch screen
|
||||
/// offers them.
|
||||
///
|
||||
/// Built once. A provider is stateless — it holds no connection and no
|
||||
/// credential — so one instance serves every thread that asks.
|
||||
pub fn registry() -> &'static BackendRegistry {
|
||||
static REGISTRY: OnceLock<BackendRegistry> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
let mut r = BackendRegistry::new();
|
||||
r.register(Arc::new(NextcloudProvider));
|
||||
r.register(Arc::new(FolderProvider));
|
||||
r
|
||||
})
|
||||
}
|
||||
|
||||
/// The connector serving an account.
|
||||
///
|
||||
/// Errors when this build has none — a configuration file outlives the binary
|
||||
/// that wrote it, and saying *which* backend is missing beats "could not open
|
||||
/// library".
|
||||
pub fn provider_for(account: &Account) -> Result<&'static Arc<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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -114,7 +120,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
Label {
|
||||
text: root.signed-in
|
||||
? "Connected"
|
||||
: "Connect a Nextcloud account to begin";
|
||||
: "Connect a Nextcloud account, or open a folder";
|
||||
body: true;
|
||||
}
|
||||
}
|
||||
@@ -208,6 +214,46 @@ export component LaunchScreen inherits Rectangle {
|
||||
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// --- or: a folder on this machine ---
|
||||
//
|
||||
// A local disk, a mounted share, or the folder the
|
||||
// Nextcloud desktop client already syncs. No account and
|
||||
// no credential, so this is the route that works on a
|
||||
// machine with no keyring at all.
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Caption { text: "or"; }
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
PanelHeading { text: "FOLDER"; }
|
||||
folder-input := Field {
|
||||
text: root.folder-path;
|
||||
placeholder: "/home/you/Pictures";
|
||||
accepted(path) => { root.use-folder(path); }
|
||||
}
|
||||
|
||||
FormButton {
|
||||
text: "Open folder";
|
||||
enabled: !root.busy && folder-input.text != "";
|
||||
clicked => { root.use-folder(folder-input.text); }
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// --- login pending: the browser step ---
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user