diff --git a/core/dr-catalog/src/albums.rs b/core/dr-catalog/src/albums.rs new file mode 100644 index 0000000..f862f63 --- /dev/null +++ b/core/dr-catalog/src/albums.rs @@ -0,0 +1,499 @@ +//! TRACES: FR-EXP-10 | FR-EXP-6 | FR-CAT-7 +//! Albums: named export folders, and which photographs went into each. +//! +//! An album is where finished pictures go — a folder of JPEGs somebody else +//! looks at — as opposed to a collection, which is a set of originals the +//! photographer works on. The folder holds only the exported files. What the +//! catalog adds is the link back: each export is recorded against the image +//! it was rendered from, so opening an album in the library shows the RAWs +//! behind its JPEGs, and re-exporting after an edit is one selection away. +//! +//! # Where the folder is, and why that is two tables +//! +//! An album's folder is either on the library's server or on this device. +//! +//! A **server folder** is one path on the account, the same from every device +//! signed in to it, so it lives on the album row and syncs with it. +//! +//! A **local folder** — a filesystem path on a desktop, a Storage Access +//! Framework tree on Android — means nothing on any other device. It lives in +//! `album_folders`, which the merge never reads and the upload snapshot drops +//! ([`crate::sync::snapshot_for_upload`]). An album made on the desktop with a +//! local folder therefore reaches the tablet as an album with no folder there +//! yet, which is true, and which the tablet can fix by choosing one. +//! +//! # Created on first use, not by a migration +//! +//! A new schema version makes every older build refuse this catalog's +//! snapshot at sync (`crate::sync::remote_is_mergeable`), so the tablet would +//! stop merging collections, keywords and people until it was updated — for +//! a feature it does not have. The tables are created by [`ensure_tables`] +//! instead, the way `dedup_probes` is; an older build that meets them ignores +//! them, and its merge keeps working. +//! +//! # Sync +//! +//! Albums merge by uuid and revision with tombstones, and their exports as a +//! set union keyed on the image's server file id — the rules +//! [`crate::merge`] applies to collections, for the same reasons. + +use rusqlite::{Connection, OptionalExtension}; + +use dr_types::ImageId; + +use crate::error::CatalogError; + +/// Identifies an album within one catalog. Local, like every integer id here; +/// the uuid is what crosses devices. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AlbumId(pub u64); + +/// Where an album's files go. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Place { + /// A folder on the library's server, relative to the account root, with no + /// leading slash. The same on every device. + Server(String), + /// A folder on this device: a filesystem path, or on Android a SAF tree + /// URI. Never synced. + Local(String), +} + +/// One album, as the sidebar and the export sheet show it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Album { + pub id: AlbumId, + pub uuid: String, + pub name: String, + /// Where exports go from this device, or `None` for an album whose folder + /// is local to another device and has not been chosen here. + pub place: Option, + /// Distinct photographs exported into it — what the grid shows when the + /// album is opened. + pub sources: usize, +} + +/// Create the album tables if this catalog does not have them yet. +/// +/// Cheap when they exist: `IF NOT EXISTS` is answered from the schema, and +/// every function below calls this first so no caller has to remember to. +pub fn ensure_tables(conn: &Connection) -> Result<(), CatalogError> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS albums ( + id INTEGER PRIMARY KEY, + -- The merge identity; the integer id is local. + uuid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + -- A folder on the server, relative to the account root. NULL for + -- an album whose folder is local to some device. + server_path TEXT, + created INTEGER NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + modified INTEGER NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0 + ); + -- One row per file written into an album. Keyed on the file, not the + -- image: a photograph exported twice — two crops, or once before an + -- edit and once after — is two files in the folder and two rows here. + CREATE TABLE IF NOT EXISTS album_exports ( + album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE, + exported_at INTEGER NOT NULL, + PRIMARY KEY (album_id, file_name) + ); + CREATE INDEX IF NOT EXISTS album_exports_image ON album_exports(image_id); + -- This device's folder for an album. Never merged, never uploaded. + CREATE TABLE IF NOT EXISTS album_folders ( + album_id INTEGER PRIMARY KEY REFERENCES albums(id) ON DELETE CASCADE, + folder TEXT NOT NULL + );", + )?; + Ok(()) +} + +/// Make an album. +/// +/// The name is trimmed and must not be empty; two albums may share one, as +/// two collections may, because the uuid is the identity and refusing a +/// duplicate name here would refuse it on one device and not another. +pub fn create(conn: &Connection, name: &str, place: &Place) -> Result { + ensure_tables(conn)?; + let name = name.trim(); + if name.is_empty() { + return Err(CatalogError::EmptyName); + } + let now = now_secs(); + let tx = conn.unchecked_transaction()?; + tx.execute( + "INSERT INTO albums(uuid, name, server_path, created, revision, modified) + VALUES (?1, ?2, ?3, ?4, 1, ?4)", + rusqlite::params![crate::collections::new_uuid(), name, server_path(place), now], + )?; + let id = AlbumId(tx.last_insert_rowid() as u64); + if let Place::Local(folder) = place { + tx.execute( + "INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2)", + rusqlite::params![id.0 as i64, folder], + )?; + } + tx.commit()?; + Ok(id) +} + +/// Rename an album. The folder keeps its name: the album is what the +/// photographer calls it, the folder is what is already out there. +pub fn rename(conn: &Connection, id: AlbumId, name: &str) -> Result<(), CatalogError> { + ensure_tables(conn)?; + let name = name.trim(); + if name.is_empty() { + return Err(CatalogError::EmptyName); + } + let n = conn.execute( + "UPDATE albums SET name = ?2, revision = revision + 1, modified = ?3 + WHERE id = ?1 AND deleted = 0", + rusqlite::params![id.0 as i64, name, now_secs()], + )?; + if n == 0 { + return Err(CatalogError::NoSuchAlbum(id.0)); + } + Ok(()) +} + +/// Point an album at a different folder, from this device. +/// +/// A server folder replaces the synced path, and bumps the revision so the +/// move reaches every device. A local folder is recorded for this device +/// only; it also clears a server path, because an album goes to one place and +/// the photographer has just said which. +pub fn set_place(conn: &Connection, id: AlbumId, place: &Place) -> Result<(), CatalogError> { + ensure_tables(conn)?; + let tx = conn.unchecked_transaction()?; + let n = tx.execute( + "UPDATE albums SET server_path = ?2, revision = revision + 1, modified = ?3 + WHERE id = ?1 AND deleted = 0", + rusqlite::params![id.0 as i64, server_path(place), now_secs()], + )?; + if n == 0 { + return Err(CatalogError::NoSuchAlbum(id.0)); + } + match place { + Place::Local(folder) => tx.execute( + "INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2) + ON CONFLICT(album_id) DO UPDATE SET folder = excluded.folder", + rusqlite::params![id.0 as i64, folder], + )?, + Place::Server(_) => tx.execute( + "DELETE FROM album_folders WHERE album_id = ?1", + [id.0 as i64], + )?, + }; + tx.commit()?; + Ok(()) +} + +/// Delete an album, leaving a tombstone. The files in its folder are not +/// touched: they are finished work somebody may already have been sent a +/// link to, and the album was only ever this catalog's note of them. +pub fn delete(conn: &Connection, id: AlbumId) -> Result<(), CatalogError> { + ensure_tables(conn)?; + let tx = conn.unchecked_transaction()?; + let n = tx.execute( + "UPDATE albums SET deleted = 1, revision = revision + 1, modified = ?2 + WHERE id = ?1 AND deleted = 0", + rusqlite::params![id.0 as i64, now_secs()], + )?; + if n == 0 { + return Err(CatalogError::NoSuchAlbum(id.0)); + } + tx.execute("DELETE FROM album_exports WHERE album_id = ?1", [id.0 as i64])?; + tx.execute("DELETE FROM album_folders WHERE album_id = ?1", [id.0 as i64])?; + tx.commit()?; + Ok(()) +} + +/// Every live album, by name, with how many photographs each holds. +/// +/// One statement: the counts are aggregated from `album_exports` first and +/// joined to the (few) albums, not counted per row. +pub fn list(conn: &Connection) -> Result, CatalogError> { + ensure_tables(conn)?; + let mut stmt = conn.prepare( + "SELECT a.id, a.uuid, a.name, a.server_path, f.folder, coalesce(e.n, 0) + FROM albums a + LEFT JOIN album_folders f ON f.album_id = a.id + LEFT JOIN (SELECT album_id, count(DISTINCT image_id) AS n + FROM album_exports GROUP BY album_id) e + ON e.album_id = a.id + WHERE a.deleted = 0 + ORDER BY a.name COLLATE NOCASE, a.id", + )?; + let rows = stmt + .query_map([], album_from_row)? + .collect::, _>>()?; + Ok(rows) +} + +/// One album, or `None` if it is gone. +pub fn get(conn: &Connection, id: AlbumId) -> Result, CatalogError> { + ensure_tables(conn)?; + Ok(conn + .query_row( + "SELECT a.id, a.uuid, a.name, a.server_path, f.folder, + (SELECT count(DISTINCT image_id) FROM album_exports WHERE album_id = a.id) + FROM albums a + LEFT JOIN album_folders f ON f.album_id = a.id + WHERE a.id = ?1 AND a.deleted = 0", + [id.0 as i64], + album_from_row, + ) + .optional()?) +} + +/// The album with this uuid, if this catalog holds it live. +pub fn id_for_uuid(conn: &Connection, uuid: &str) -> Result, CatalogError> { + ensure_tables(conn)?; + Ok(conn + .query_row( + "SELECT id FROM albums WHERE uuid = ?1 AND deleted = 0", + [uuid], + |r| r.get::<_, i64>(0), + ) + .optional()? + .map(|id| AlbumId(id as u64))) +} + +/// Record the files one export wrote into an album, and which image each +/// came from. One transaction for the batch, however many files it placed. +/// +/// A file name already recorded is re-pointed at the image that wrote it +/// last: an export that overwrote `IMG_0001.jpg` replaced the picture in the +/// folder, and the link must say what is there now. +pub fn record_exports( + conn: &Connection, + id: AlbumId, + files: &[(ImageId, String)], +) -> Result<(), CatalogError> { + ensure_tables(conn)?; + if files.is_empty() { + return Ok(()); + } + let tx = conn.unchecked_transaction()?; + let now = now_secs(); + { + let mut insert = tx.prepare( + "INSERT INTO album_exports(album_id, file_name, image_id, exported_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(album_id, file_name) DO UPDATE SET + image_id = excluded.image_id, exported_at = excluded.exported_at", + )?; + for (image, name) in files { + insert.execute(rusqlite::params![id.0 as i64, name, image.0 as i64, now])?; + } + } + tx.commit()?; + Ok(()) +} + +/// The photographs behind an album's files, most recently exported first — +/// what the grid shows when the album is opened. +pub fn sources(conn: &Connection, id: AlbumId) -> Result, CatalogError> { + ensure_tables(conn)?; + let mut stmt = conn.prepare( + "SELECT image_id FROM album_exports + WHERE album_id = ?1 + GROUP BY image_id + ORDER BY max(exported_at) DESC, image_id", + )?; + let rows = stmt + .query_map([id.0 as i64], |r| r.get::<_, i64>(0))? + .map(|r| r.map(|i| ImageId(i as u64))) + .collect::, _>>()?; + Ok(rows) +} + +/// The names of the files an image left in an album — the "which JPEG is +/// this" half of the link. +pub fn files_of( + conn: &Connection, + id: AlbumId, + image: ImageId, +) -> Result, CatalogError> { + ensure_tables(conn)?; + let mut stmt = conn.prepare( + "SELECT file_name FROM album_exports + WHERE album_id = ?1 AND image_id = ?2 + ORDER BY exported_at DESC, file_name", + )?; + let rows = stmt + .query_map(rusqlite::params![id.0 as i64, image.0 as i64], |r| r.get(0))? + .collect::, _>>()?; + Ok(rows) +} + +fn album_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let server: Option = r.get(3)?; + let local: Option = r.get(4)?; + Ok(Album { + id: AlbumId(r.get::<_, i64>(0)? as u64), + uuid: r.get(1)?, + name: r.get(2)?, + // A server path wins: `set_place` clears the local folder when it + // sets one, so both being present means a merge brought a server + // path in over a local choice — and the newer revision decided that. + place: server.map(Place::Server).or(local.map(Place::Local)), + sources: r.get::<_, i64>(5)? as usize, + }) +} + +fn server_path(place: &Place) -> Option<&str> { + match place { + Place::Server(p) => Some(p.trim_matches('/')), + Place::Local(_) => None, + } +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A catalog, and the connection to it. The `Catalog` has to outlive the + /// connection it hands out, so tests hold both. + fn catalog() -> crate::Catalog { + let cat = crate::Catalog::in_memory().unwrap(); + cat.connection() + .execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + cat + } + + fn image(conn: &Connection, path: &str) -> ImageId { + conn.execute( + "INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)", + [path], + ) + .unwrap(); + ImageId(conn.last_insert_rowid() as u64) + } + + #[test] + fn an_album_lists_with_its_place_and_no_photographs() { + let cat = catalog(); + let conn = cat.connection(); + let web = create(conn, " Web ", &Place::Server("Shared/Web/".into())).unwrap(); + let print = create(conn, "Print", &Place::Local("/mnt/print".into())).unwrap(); + + let all = list(conn).unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].id, print, "sorted by name"); + assert_eq!(all[0].place, Some(Place::Local("/mnt/print".into()))); + assert_eq!(all[1].id, web); + assert_eq!(all[1].name, "Web", "trimmed"); + assert_eq!(all[1].place, Some(Place::Server("Shared/Web".into()))); + assert_eq!(all[1].sources, 0); + } + + #[test] + fn an_empty_name_is_refused() { + let cat = catalog(); + let conn = cat.connection(); + assert!(matches!( + create(conn, " ", &Place::Local("/x".into())), + Err(CatalogError::EmptyName) + )); + } + + #[test] + fn exports_link_files_back_to_their_images() { + let cat = catalog(); + let conn = cat.connection(); + let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); + let a = image(conn, "a.cr3"); + let b = image(conn, "b.cr3"); + + record_exports( + conn, + album, + &[(a, "a.jpg".into()), (a, "a (1).jpg".into()), (b, "b.jpg".into())], + ) + .unwrap(); + + let got = get(conn, album).unwrap().unwrap(); + assert_eq!(got.sources, 2, "two photographs, three files"); + let mut s = sources(conn, album).unwrap(); + s.sort(); + assert_eq!(s, vec![a, b]); + assert_eq!(files_of(conn, album, a).unwrap().len(), 2); + } + + #[test] + fn an_overwritten_file_points_at_what_wrote_it_last() { + let cat = catalog(); + let conn = cat.connection(); + let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); + let a = image(conn, "a.cr3"); + let b = image(conn, "b.cr3"); + record_exports(conn, album, &[(a, "x.jpg".into())]).unwrap(); + record_exports(conn, album, &[(b, "x.jpg".into())]).unwrap(); + assert_eq!(sources(conn, album).unwrap(), vec![b]); + } + + #[test] + fn moving_to_the_server_forgets_the_local_folder() { + let cat = catalog(); + let conn = cat.connection(); + let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); + set_place(conn, album, &Place::Server("Web".into())).unwrap(); + assert_eq!( + get(conn, album).unwrap().unwrap().place, + Some(Place::Server("Web".into())) + ); + set_place(conn, album, &Place::Local("/again".into())).unwrap(); + assert_eq!( + get(conn, album).unwrap().unwrap().place, + Some(Place::Local("/again".into())) + ); + } + + #[test] + fn a_deleted_album_is_gone_and_its_uuid_no_longer_resolves() { + let cat = catalog(); + let conn = cat.connection(); + let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); + let uuid = get(conn, album).unwrap().unwrap().uuid; + let a = image(conn, "a.cr3"); + record_exports(conn, album, &[(a, "a.jpg".into())]).unwrap(); + + delete(conn, album).unwrap(); + assert!(list(conn).unwrap().is_empty()); + assert_eq!(id_for_uuid(conn, &uuid).unwrap(), None); + assert!(matches!( + rename(conn, album, "Again"), + Err(CatalogError::NoSuchAlbum(_)) + )); + } + + #[test] + fn a_rename_bumps_the_revision_the_merge_compares() { + let cat = catalog(); + let conn = cat.connection(); + let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); + rename(conn, album, "Website").unwrap(); + let rev: i64 = conn + .query_row("SELECT revision FROM albums WHERE id = ?1", [album.0 as i64], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(rev, 2); + } +} diff --git a/core/dr-catalog/src/error.rs b/core/dr-catalog/src/error.rs index 4ea09f4..bbe24c5 100644 --- a/core/dr-catalog/src/error.rs +++ b/core/dr-catalog/src/error.rs @@ -65,6 +65,16 @@ pub enum CatalogError { #[error("no such collection: {0}")] NoSuchCollection(u64), + /// An album the caller named is gone — deleted here, or by a merge while + /// its id sat in a UI model. + #[error("no such album: {0}")] + NoSuchAlbum(u64), + + /// A name that is empty once trimmed. Refused rather than stored, because + /// a row with no name is one the sidebar cannot draw and nobody can pick. + #[error("a name is required")] + EmptyName, + /// A keyword the caller named is gone — deleted, or fused into another by a /// merge while its id sat in a UI model. /// diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index cd939a0..4f94e5c 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -36,6 +36,7 @@ use std::path::Path; use dr_types::{Availability, ImageId}; use rusqlite::Connection; +pub mod albums; pub mod bursts; pub mod cache; pub mod collections; @@ -57,6 +58,7 @@ pub mod sync; pub mod trash; pub mod walk; +pub use albums::{Album, AlbumId, Place}; pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES}; pub use collections::{Collection, CollectionKind, TreeRow}; pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash}; diff --git a/core/dr-catalog/src/merge.rs b/core/dr-catalog/src/merge.rs index 95cf04b..a2056ed 100644 --- a/core/dr-catalog/src/merge.rs +++ b/core/dr-catalog/src/merge.rs @@ -121,6 +121,15 @@ pub struct MergeReport { pub keywords_assigned: usize, /// Images whose capture metadata was taken from the remote. pub metadata_adopted: usize, + + /// Albums the remote had and this device did not, or had renamed, moved + /// or deleted with the higher revision. + pub albums_taken: usize, + /// Albums where this device's revision was at least as high. + pub albums_kept_local: usize, + /// Exported files the remote had recorded into an album and this device + /// had not. + pub album_exports_added: usize, } impl MergeReport { @@ -136,12 +145,17 @@ impl MergeReport { || self.keywords_fused > 0 || self.keywords_assigned > 0 || self.metadata_adopted > 0 + || self.albums_taken > 0 + || self.album_exports_added > 0 } /// Whether the local catalog holds anything the remote did not, and so /// must be uploaded even if nothing was taken from the remote. pub fn should_upload(&self) -> bool { - self.kept_local > 0 || self.keywords_kept_local > 0 || self.local_changed() + self.kept_local > 0 + || self.keywords_kept_local > 0 + || self.albums_kept_local > 0 + || self.local_changed() } } @@ -197,10 +211,134 @@ pub fn merge_all(conn: &Connection) -> Result { merge_keywords_within(&tx, &mut report)?; merge_people_within(&tx, &mut report)?; merge_metadata_within(&tx, &mut report)?; + merge_albums_within(&tx, &mut report)?; tx.commit()?; Ok(report) } +/// Merge albums and what was exported into them, from an attached catalog. +pub fn merge_albums(conn: &Connection) -> Result { + let tx = conn.unchecked_transaction()?; + let mut report = MergeReport::default(); + merge_albums_within(&tx, &mut report)?; + tx.commit()?; + Ok(report) +} + +/// The album half: rows by [`verdict`], exports as a set union. +/// +/// `album_folders` is not read. It is this device's choice of a local folder +/// and means nothing on the device that sent the snapshot — which has in any +/// case dropped it before uploading (see [`crate::albums`]). +fn merge_albums_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { + // A snapshot from a build before albums, or from a device that never + // made one, has no tables to read. Ours are made on demand so the + // statements below have somewhere to write. + if !remote_has(tx, "albums")? { + return Ok(()); + } + crate::albums::ensure_tables(tx)?; + + let rows: Vec<(String, String, Option, i64, i64, i64, bool, MergeVerdict)> = { + let mut stmt = tx.prepare( + "SELECT r.uuid, r.name, r.server_path, r.created, r.revision, r.modified, + r.deleted, l.revision, l.modified + FROM remote_cat.albums r + LEFT JOIN main.albums l ON l.uuid = r.uuid", + )?; + let rows = stmt + .query_map([], |r| { + let revision: i64 = r.get(4)?; + let modified: i64 = r.get(5)?; + let deleted: bool = r.get::<_, i64>(6)? != 0; + let local_rev: Option = r.get(7)?; + let local_mod: Option = r.get(8)?; + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + revision, + modified, + deleted, + verdict(local_rev.zip(local_mod), (revision, modified), deleted), + )) + })? + .collect::, _>>()?; + rows + }; + + { + // One upsert covers insert, update and tombstone: the verdict has + // already decided the remote row wins, so its fields are the answer + // whichever of the three it is. + let mut take = tx.prepare( + "INSERT INTO main.albums + (uuid, name, server_path, created, revision, modified, deleted) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(uuid) DO UPDATE SET + name = excluded.name, server_path = excluded.server_path, + revision = excluded.revision, modified = excluded.modified, + deleted = excluded.deleted", + )?; + let mut forget = tx.prepare( + "DELETE FROM main.album_exports + WHERE album_id = (SELECT id FROM main.albums WHERE uuid = ?1)", + )?; + let mut unfold = tx.prepare( + "DELETE FROM main.album_folders + WHERE album_id = (SELECT id FROM main.albums WHERE uuid = ?1)", + )?; + for (uuid, name, server_path, created, revision, modified, deleted, v) in rows { + if v == MergeVerdict::KeptLocal { + report.albums_kept_local += 1; + continue; + } + take.execute(rusqlite::params![ + uuid, name, server_path, created, revision, modified, deleted as i64 + ])?; + if deleted { + forget.execute([&uuid])?; + unfold.execute([&uuid])?; + } else if server_path.is_some() { + // Another device moved the album to the server with the newer + // revision; a local folder chosen here is no longer where it + // goes. + unfold.execute([&uuid])?; + } + report.albums_taken += 1; + } + } + + report.album_exports_added = + tx.execute(ALBUM_EXPORTS_BY_FILE_ID, [])? + tx.execute(ALBUM_EXPORTS_BY_CONTENT_HASH, [])?; + Ok(()) +} + +/// Exported files, matched to local images by the server's file id — the +/// identity [`MEMBERS_BY_FILE_ID`] explains. Tombstoned albums are excluded, +/// or a merge would refill an album it had just deleted. +const ALBUM_EXPORTS_BY_FILE_ID: &str = " + INSERT OR IGNORE INTO main.album_exports(album_id, file_name, image_id, exported_at) + SELECT la.id, re.file_name, li.id, re.exported_at + FROM remote_cat.album_exports re + JOIN remote_cat.albums ra ON ra.id = re.album_id + JOIN main.albums la ON la.uuid = ra.uuid AND la.deleted = 0 + JOIN remote_cat.remote rr ON rr.image_id = re.image_id + JOIN main.remote lr ON lr.file_id = rr.file_id + JOIN main.images li ON li.id = lr.image_id"; + +/// The same union by content hash, for a library with no server behind it. +const ALBUM_EXPORTS_BY_CONTENT_HASH: &str = " + INSERT OR IGNORE INTO main.album_exports(album_id, file_name, image_id, exported_at) + SELECT la.id, re.file_name, li.id, re.exported_at + FROM remote_cat.album_exports re + JOIN remote_cat.albums ra ON ra.id = re.album_id + JOIN main.albums la ON la.uuid = ra.uuid AND la.deleted = 0 + JOIN remote_cat.images ri ON ri.id = re.image_id + JOIN main.images li ON li.content_hash = ri.content_hash + WHERE ri.content_hash IS NOT NULL"; + /// Adopt capture metadata from an attached catalog, on its own. pub fn merge_metadata(conn: &Connection) -> Result { let tx = conn.unchecked_transaction()?; diff --git a/core/dr-catalog/src/sync.rs b/core/dr-catalog/src/sync.rs index 010538d..cb608ad 100644 --- a/core/dr-catalog/src/sync.rs +++ b/core/dr-catalog/src/sync.rs @@ -103,6 +103,10 @@ pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), Catalog let out = Connection::open(dest)?; build_snapshot(conn, &source, &out)?; + // This device's local album folders are paths and SAF grants nobody + // else can use; the merge never reads them, and the snapshot is what + // a fresh device would otherwise adopt whole. + out.execute_batch("DROP TABLE IF EXISTS album_folders")?; verify_snapshot(&out)?; Ok(()) } @@ -443,6 +447,92 @@ mod tests { assert_eq!(n, 2); } + /// One image, known to the server by `file_id`, in a catalog. + fn with_image(c: &Connection, file_id: i64) -> dr_types::ImageId { + c.execute( + "INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)", + [format!("IMG_{file_id}.CR3")], + ) + .unwrap(); + let id = c.last_insert_rowid(); + c.execute( + "INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)", + [id, file_id], + ) + .unwrap(); + dr_types::ImageId(id as u64) + } + + #[test] + fn an_album_and_its_exports_reach_another_device_but_its_folder_does_not() { + use crate::albums::{self, Place}; + let dir = tempdir(); + let remote_path = dir.join("remote.sqlite"); + let snap = dir.join("snap.sqlite"); + { + // The desktop: two albums, one on the server and one on its own + // disk, each with an export of the same photograph. + let r = seeded(&dir.join("desktop.sqlite")); + let img = with_image(&r, 4242); + let web = albums::create(&r, "Web", &Place::Server("Shared/Web".into())).unwrap(); + let print = albums::create(&r, "Print", &Place::Local("/mnt/print".into())).unwrap(); + albums::record_exports(&r, web, &[(img, "IMG_4242.jpg".into())]).unwrap(); + albums::record_exports(&r, print, &[(img, "IMG_4242.tif".into())]).unwrap(); + snapshot_for_upload(&r, &snap).unwrap(); + std::fs::rename(&snap, &remote_path).unwrap(); + } + + // The tablet knows the same file under its own image id. + let local = seeded(&dir.join("tablet.sqlite")); + with_image(&local, 1); + let img = with_image(&local, 4242); + + let report = merge_remote(&local, &remote_path).unwrap(); + assert_eq!(report.albums_taken, 2); + assert_eq!(report.album_exports_added, 2); + assert!(report.local_changed()); + + let all = albums::list(&local).unwrap(); + let print = all.iter().find(|a| a.name == "Print").unwrap(); + let web = all.iter().find(|a| a.name == "Web").unwrap(); + assert_eq!(print.place, None, "the desktop's disk is not the tablet's"); + assert_eq!(web.place, Some(Place::Server("Shared/Web".into()))); + assert_eq!(albums::sources(&local, web.id).unwrap(), vec![img]); + + // Nothing changed on either side, so a second pass takes nothing. + let again = merge_remote(&local, &remote_path).unwrap(); + assert_eq!(again.albums_taken, 0); + assert_eq!(again.album_exports_added, 0); + } + + #[test] + fn a_device_that_never_made_an_album_still_uploads_this_ones() { + use crate::albums::{self, Place}; + let dir = tempdir(); + let remote_path = dir.join("remote.sqlite"); + { + // A snapshot from a build that predates albums altogether. + let r = seeded(&remote_path); + checkpoint(&r).unwrap(); + } + let local = seeded(&dir.join("local.sqlite")); + albums::create(&local, "Web", &Place::Server("Web".into())).unwrap(); + + let report = merge_remote(&local, &remote_path).unwrap(); + assert_eq!(report.albums_taken, 0); + // Its albums table is absent, so nothing was compared — and the local + // album has still to reach the server. + assert!( + albums::list(&local).unwrap().len() == 1, + "the local album survives a merge with a catalog that has none" + ); + } + #[test] fn the_remote_can_be_merged_twice_without_attach_conflict() { // Detach must happen even on the failure path, or the second attempt diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index 9eb28b4..d6a7bc5 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 454 | -| TRACES tags found | 1852 | +| Source files scanned | 455 | +| TRACES tags found | 1853 | | Requirements defined | 191 | | Requirements deferred (post-v1) | 24 | | Requirements covered | 160 | @@ -28,7 +28,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n A tag naming an ID `requirements.md` does not define — what renumbering produces, and what a typo produces. -_None._ +- `FR-EXP-10` ## Tagged requirements @@ -37,7 +37,7 @@ _None._ | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:108`](../../core/dr-catalog/src/walk.rs#L108), [`core/dr-catalog/src/walk.rs:161`](../../core/dr-catalog/src/walk.rs#L161), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:129`](../../core/dr-sync/src/scan.rs#L129), [`core/dr-types/src/lib.rs:203`](../../core/dr-types/src/lib.rs#L203), [`core/dr-types/src/lib.rs:272`](../../core/dr-types/src/lib.rs#L272), [`core/dr-types/src/lib.rs:305`](../../core/dr-types/src/lib.rs#L305), [`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), [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.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:377`](../../core/dr-types/src/settings.rs#L377), [`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:346`](../../ui/dr-ui/src/import.rs#L346), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1796`](../../ui/dr-ui/src/lib.rs#L1796), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1343`](../../ui/dr-ui/ui/library.slint#L1343), [`ui/dr-ui/ui/library.slint:1397`](../../ui/dr-ui/ui/library.slint#L1397), [`ui/dr-ui/ui/library.slint:1655`](../../ui/dr-ui/ui/library.slint#L1655) | | 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:1796`](../../ui/dr-ui/src/lib.rs#L1796), [`ui/dr-ui/src/library/filters.rs:12`](../../ui/dr-ui/src/library/filters.rs#L12), [`ui/dr-ui/src/library/thumbnails_gen.rs:524`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L524), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5) | -| FR-CAT-11a | [`core/dr-catalog/src/duplicates.rs:1`](../../core/dr-catalog/src/duplicates.rs#L1), [`core/dr-catalog/src/error.rs:100`](../../core/dr-catalog/src/error.rs#L100), [`core/dr-catalog/src/faces.rs:1602`](../../core/dr-catalog/src/faces.rs#L1602), [`core/dr-catalog/src/faces.rs:1620`](../../core/dr-catalog/src/faces.rs#L1620), [`ui/dr-ui/src/duplicates.rs:1`](../../ui/dr-ui/src/duplicates.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:1`](../../ui/dr-ui/src/duplicates_ui.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:79`](../../ui/dr-ui/src/duplicates_ui.rs#L79), [`ui/dr-ui/src/lib.rs:1826`](../../ui/dr-ui/src/lib.rs#L1826), [`ui/dr-ui/src/lib.rs:3518`](../../ui/dr-ui/src/lib.rs#L3518), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:664`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L664), [`ui/dr-ui/ui/app.slint:80`](../../ui/dr-ui/ui/app.slint#L80), [`ui/dr-ui/ui/collections.slint:883`](../../ui/dr-ui/ui/collections.slint#L883), [`ui/dr-ui/ui/duplicates.slint:1`](../../ui/dr-ui/ui/duplicates.slint#L1), [`ui/dr-ui/ui/settings.slint:108`](../../ui/dr-ui/ui/settings.slint#L108), [`ui/dr-ui/ui/settings.slint:512`](../../ui/dr-ui/ui/settings.slint#L512) | +| FR-CAT-11a | [`core/dr-catalog/src/duplicates.rs:1`](../../core/dr-catalog/src/duplicates.rs#L1), [`core/dr-catalog/src/error.rs:110`](../../core/dr-catalog/src/error.rs#L110), [`core/dr-catalog/src/faces.rs:1602`](../../core/dr-catalog/src/faces.rs#L1602), [`core/dr-catalog/src/faces.rs:1620`](../../core/dr-catalog/src/faces.rs#L1620), [`ui/dr-ui/src/duplicates.rs:1`](../../ui/dr-ui/src/duplicates.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:1`](../../ui/dr-ui/src/duplicates_ui.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:79`](../../ui/dr-ui/src/duplicates_ui.rs#L79), [`ui/dr-ui/src/lib.rs:1826`](../../ui/dr-ui/src/lib.rs#L1826), [`ui/dr-ui/src/lib.rs:3518`](../../ui/dr-ui/src/lib.rs#L3518), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:664`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L664), [`ui/dr-ui/ui/app.slint:80`](../../ui/dr-ui/ui/app.slint#L80), [`ui/dr-ui/ui/collections.slint:883`](../../ui/dr-ui/ui/collections.slint#L883), [`ui/dr-ui/ui/duplicates.slint:1`](../../ui/dr-ui/ui/duplicates.slint#L1), [`ui/dr-ui/ui/settings.slint:108`](../../ui/dr-ui/ui/settings.slint#L108), [`ui/dr-ui/ui/settings.slint:512`](../../ui/dr-ui/ui/settings.slint#L512) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:140`](../../core/dr-pipeline/src/sidecar.rs#L140) | | FR-CAT-13 | [`core/dr-catalog/src/rating.rs:276`](../../core/dr-catalog/src/rating.rs#L276), [`core/dr-catalog/src/schema.rs:863`](../../core/dr-catalog/src/schema.rs#L863), [`core/dr-sync/src/scan.rs:40`](../../core/dr-sync/src/scan.rs#L40), [`core/dr-sync/src/scan.rs:944`](../../core/dr-sync/src/scan.rs#L944), [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`core/dr-xmp/src/lib.rs:1`](../../core/dr-xmp/src/lib.rs#L1), [`core/dr-xmp/src/lib.rs:331`](../../core/dr-xmp/src/lib.rs#L331), [`core/dr-xmp/src/lib.rs:442`](../../core/dr-xmp/src/lib.rs#L442), [`core/dr-xmp/src/lib.rs:535`](../../core/dr-xmp/src/lib.rs#L535), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`core/dr-xmp/src/read.rs:1`](../../core/dr-xmp/src/read.rs#L1), [`core/dr-xmp/src/write.rs:1`](../../core/dr-xmp/src/write.rs#L1), [`core/dr-xmp/tests/round_trip.rs:1`](../../core/dr-xmp/tests/round_trip.rs#L1), [`ui/dr-ui/src/library/scan.rs:335`](../../ui/dr-ui/src/library/scan.rs#L335), [`ui/dr-ui/src/library/xmp.rs:132`](../../ui/dr-ui/src/library/xmp.rs#L132), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui/controller.rs:312`](../../ui/dr-ui/src/library_ui/controller.rs#L312), [`ui/dr-ui/src/library_ui/controller.rs:403`](../../ui/dr-ui/src/library_ui/controller.rs#L403), [`ui/dr-ui/src/library_ui/controller.rs:550`](../../ui/dr-ui/src/library_ui/controller.rs#L550), [`ui/dr-ui/src/library_ui/grid.rs:206`](../../ui/dr-ui/src/library_ui/grid.rs#L206), [`ui/dr-ui/src/library_ui/open.rs:438`](../../ui/dr-ui/src/library_ui/open.rs#L438), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:118`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L118), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:664`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L664), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:693`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L693), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:749`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L749), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:848`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L848), [`ui/dr-ui/src/settings_ui.rs:632`](../../ui/dr-ui/src/settings_ui.rs#L632), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:250`](../../ui/dr-ui/src/xmp_sync.rs#L250), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287), [`ui/dr-ui/src/xmp_sync.rs:324`](../../ui/dr-ui/src/xmp_sync.rs#L324), [`ui/dr-ui/ui/app.slint:651`](../../ui/dr-ui/ui/app.slint#L651), [`ui/dr-ui/ui/settings.slint:210`](../../ui/dr-ui/ui/settings.slint#L210), [`ui/dr-ui/ui/settings.slint:989`](../../ui/dr-ui/ui/settings.slint#L989) | | FR-CAT-15 | [`core/dr-catalog/src/schema.rs:1237`](../../core/dr-catalog/src/schema.rs#L1237), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:549`](../../core/dr-sync-nextcloud/src/lib.rs#L549), [`core/dr-sync/src/lib.rs:155`](../../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:618`](../../core/dr-sync/src/scan.rs#L618), [`core/dr-sync/src/scan.rs:93`](../../core/dr-sync/src/scan.rs#L93), [`core/dr-thumbs/src/lib.rs:393`](../../core/dr-thumbs/src/lib.rs#L393), [`core/dr-types/src/place.rs:91`](../../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui/trash.rs:84`](../../ui/dr-ui/src/collections_ui/trash.rs#L84), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:819`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L819), [`ui/dr-ui/src/duplicates.rs:1`](../../ui/dr-ui/src/duplicates.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:1`](../../ui/dr-ui/src/duplicates_ui.rs#L1), [`ui/dr-ui/src/library/cells.rs:158`](../../ui/dr-ui/src/library/cells.rs#L158), [`ui/dr-ui/src/library/cells.rs:192`](../../ui/dr-ui/src/library/cells.rs#L192), [`ui/dr-ui/src/library/filters.rs:12`](../../ui/dr-ui/src/library/filters.rs#L12), [`ui/dr-ui/src/library/filters.rs:29`](../../ui/dr-ui/src/library/filters.rs#L29), [`ui/dr-ui/src/library/filters.rs:59`](../../ui/dr-ui/src/library/filters.rs#L59), [`ui/dr-ui/src/library_ui/controller.rs:248`](../../ui/dr-ui/src/library_ui/controller.rs#L248), [`ui/dr-ui/src/library_ui/controller.rs:955`](../../ui/dr-ui/src/library_ui/controller.rs#L955), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:1269`](../../ui/dr-ui/ui/collections.slint#L1269), [`ui/dr-ui/ui/collections.slint:788`](../../ui/dr-ui/ui/collections.slint#L788), [`ui/dr-ui/ui/duplicates.slint:1`](../../ui/dr-ui/ui/duplicates.slint#L1) | @@ -47,7 +47,7 @@ _None._ | 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:591`](../../core/dr-catalog/src/schema.rs#L591), [`ui/dr-ui/src/library/filters.rs:39`](../../ui/dr-ui/src/library/filters.rs#L39), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library_ui/mod.rs:1`](../../ui/dr-ui/src/library_ui/mod.rs#L1), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488) | | 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/rating.rs:396`](../../core/dr-catalog/src/rating.rs#L396), [`core/dr-catalog/src/rating.rs:411`](../../core/dr-catalog/src/rating.rs#L411), [`core/dr-catalog/src/rating.rs:422`](../../core/dr-catalog/src/rating.rs#L422), [`core/dr-catalog/src/rating.rs:449`](../../core/dr-catalog/src/rating.rs#L449), [`core/dr-catalog/src/rating.rs:52`](../../core/dr-catalog/src/rating.rs#L52), [`core/dr-catalog/src/rating.rs:827`](../../core/dr-catalog/src/rating.rs#L827), [`core/dr-catalog/src/rating.rs:851`](../../core/dr-catalog/src/rating.rs#L851), [`core/dr-catalog/src/rating.rs:870`](../../core/dr-catalog/src/rating.rs#L870), [`core/dr-catalog/src/schema.rs:1152`](../../core/dr-catalog/src/schema.rs#L1152), [`core/dr-catalog/src/schema.rs:1739`](../../core/dr-catalog/src/schema.rs#L1739), [`core/dr-decode/src/lib.rs:307`](../../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/lib.rs:313`](../../core/dr-decode/src/lib.rs#L313), [`core/dr-decode/src/lib.rs:323`](../../core/dr-decode/src/lib.rs#L323), [`core/dr-decode/src/lib.rs:450`](../../core/dr-decode/src/lib.rs#L450), [`core/dr-export/src/dng.rs:308`](../../core/dr-export/src/dng.rs#L308), [`core/dr-pipeline/src/sidecar.rs:1093`](../../core/dr-pipeline/src/sidecar.rs#L1093), [`core/dr-pipeline/src/sidecar.rs:174`](../../core/dr-pipeline/src/sidecar.rs#L174), [`core/dr-pipeline/src/sidecar.rs:193`](../../core/dr-pipeline/src/sidecar.rs#L193), [`core/dr-pipeline/src/sidecar.rs:2874`](../../core/dr-pipeline/src/sidecar.rs#L2874), [`core/dr-pipeline/src/sidecar.rs:2892`](../../core/dr-pipeline/src/sidecar.rs#L2892), [`core/dr-pipeline/src/sidecar.rs:590`](../../core/dr-pipeline/src/sidecar.rs#L590), [`ui/dr-ui/src/collections_ui/controller.rs:16`](../../ui/dr-ui/src/collections_ui/controller.rs#L16), [`ui/dr-ui/src/collections_ui/controller.rs:204`](../../ui/dr-ui/src/collections_ui/controller.rs#L204), [`ui/dr-ui/src/collections_ui/controller.rs:413`](../../ui/dr-ui/src/collections_ui/controller.rs#L413), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:150`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L150), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:264`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L264), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:323`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L323), [`ui/dr-ui/src/decoder_seam.rs:184`](../../ui/dr-ui/src/decoder_seam.rs#L184), [`ui/dr-ui/src/library/cells.rs:206`](../../ui/dr-ui/src/library/cells.rs#L206), [`ui/dr-ui/src/library/scan.rs:526`](../../ui/dr-ui/src/library/scan.rs#L526), [`ui/dr-ui/src/library/scan.rs:916`](../../ui/dr-ui/src/library/scan.rs#L916), [`ui/dr-ui/src/library_ui/controller.rs:830`](../../ui/dr-ui/src/library_ui/controller.rs#L830), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1144`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1144), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1168`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1168), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1185`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1185), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:118`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L118), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1391`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1391), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1402`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1402), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1415`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1415), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1430`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1430), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1439`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1439), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:97`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L97), [`ui/dr-ui/tests/status_is_not_colour_alone.rs:177`](../../ui/dr-ui/tests/status_is_not_colour_alone.rs#L177), [`ui/dr-ui/tests/status_is_not_colour_alone.rs:215`](../../ui/dr-ui/tests/status_is_not_colour_alone.rs#L215), [`ui/dr-ui/ui/app.slint:1653`](../../ui/dr-ui/ui/app.slint#L1653), [`ui/dr-ui/ui/app.slint:2797`](../../ui/dr-ui/ui/app.slint#L2797), [`ui/dr-ui/ui/develop.slint:142`](../../ui/dr-ui/ui/develop.slint#L142), [`ui/dr-ui/ui/develop.slint:48`](../../ui/dr-ui/ui/develop.slint#L48), [`ui/dr-ui/ui/labels.slint:19`](../../ui/dr-ui/ui/labels.slint#L19), [`ui/dr-ui/ui/labels.slint:1`](../../ui/dr-ui/ui/labels.slint#L1), [`ui/dr-ui/ui/labels.slint:40`](../../ui/dr-ui/ui/labels.slint#L40), [`ui/dr-ui/ui/labels.slint:74`](../../ui/dr-ui/ui/labels.slint#L74), [`ui/dr-ui/ui/library.slint:1609`](../../ui/dr-ui/ui/library.slint#L1609), [`ui/dr-ui/ui/library.slint:1612`](../../ui/dr-ui/ui/library.slint#L1612), [`ui/dr-ui/ui/library.slint:1763`](../../ui/dr-ui/ui/library.slint#L1763), [`ui/dr-ui/ui/library.slint:1787`](../../ui/dr-ui/ui/library.slint#L1787), [`ui/dr-ui/ui/library.slint:1844`](../../ui/dr-ui/ui/library.slint#L1844), [`ui/dr-ui/ui/library.slint:21`](../../ui/dr-ui/ui/library.slint#L21), [`ui/dr-ui/ui/library.slint:3196`](../../ui/dr-ui/ui/library.slint#L3196), [`ui/dr-ui/ui/library.slint:3203`](../../ui/dr-ui/ui/library.slint#L3203), [`ui/dr-ui/ui/library.slint:3223`](../../ui/dr-ui/ui/library.slint#L3223), [`ui/dr-ui/ui/library.slint:3312`](../../ui/dr-ui/ui/library.slint#L3312), [`ui/dr-ui/ui/library.slint:3928`](../../ui/dr-ui/ui/library.slint#L3928), [`ui/dr-ui/ui/library.slint:467`](../../ui/dr-ui/ui/library.slint#L467), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488), [`ui/dr-ui/ui/library.slint:5054`](../../ui/dr-ui/ui/library.slint#L5054), [`ui/dr-ui/ui/library.slint:5070`](../../ui/dr-ui/ui/library.slint#L5070), [`ui/dr-ui/ui/library.slint:510`](../../ui/dr-ui/ui/library.slint#L510), [`ui/dr-ui/ui/library.slint:616`](../../ui/dr-ui/ui/library.slint#L616), [`ui/dr-ui/ui/library.slint:660`](../../ui/dr-ui/ui/library.slint#L660), [`ui/dr-ui/ui/library.slint:666`](../../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:670`](../../ui/dr-ui/ui/library.slint#L670) | | 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/rating.rs:449`](../../core/dr-catalog/src/rating.rs#L449), [`core/dr-catalog/src/rating.rs:880`](../../core/dr-catalog/src/rating.rs#L880), [`core/dr-catalog/src/schema.rs:1152`](../../core/dr-catalog/src/schema.rs#L1152), [`core/dr-types/src/place.rs:138`](../../core/dr-types/src/place.rs#L138), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../../core/dr-types/src/settings.rs#L64), [`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/filters.rs:156`](../../ui/dr-ui/src/library/filters.rs#L156), [`ui/dr-ui/src/library/filters.rs:357`](../../ui/dr-ui/src/library/filters.rs#L357), [`ui/dr-ui/src/library/filters.rs:442`](../../ui/dr-ui/src/library/filters.rs#L442), [`ui/dr-ui/src/library/filters.rs:64`](../../ui/dr-ui/src/library/filters.rs#L64), [`ui/dr-ui/src/library/spans.rs:253`](../../ui/dr-ui/src/library/spans.rs#L253), [`ui/dr-ui/src/library_ui/controller.rs:407`](../../ui/dr-ui/src/library_ui/controller.rs#L407), [`ui/dr-ui/src/library_ui/controller.rs:518`](../../ui/dr-ui/src/library_ui/controller.rs#L518), [`ui/dr-ui/src/library_ui/filter_bar.rs:256`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L256), [`ui/dr-ui/src/library_ui/filter_bar.rs:351`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L351), [`ui/dr-ui/src/library_ui/filter_bar.rs:406`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L406), [`ui/dr-ui/src/library_ui/filter_bar.rs:840`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L840), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:85`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L85), [`ui/dr-ui/ui/app.slint:647`](../../ui/dr-ui/ui/app.slint#L647), [`ui/dr-ui/ui/library.slint:113`](../../ui/dr-ui/ui/library.slint#L113), [`ui/dr-ui/ui/library.slint:1931`](../../ui/dr-ui/ui/library.slint#L1931), [`ui/dr-ui/ui/library.slint:1968`](../../ui/dr-ui/ui/library.slint#L1968), [`ui/dr-ui/ui/library.slint:2697`](../../ui/dr-ui/ui/library.slint#L2697), [`ui/dr-ui/ui/library.slint:3223`](../../ui/dr-ui/ui/library.slint#L3223), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488), [`ui/dr-ui/ui/library.slint:5054`](../../ui/dr-ui/ui/library.slint#L5054), [`ui/dr-ui/ui/library.slint:616`](../../ui/dr-ui/ui/library.slint#L616), [`ui/dr-ui/ui/library.slint:674`](../../ui/dr-ui/ui/library.slint#L674), [`ui/dr-ui/ui/library.slint:696`](../../ui/dr-ui/ui/library.slint#L696), [`ui/dr-ui/ui/settings.slint:202`](../../ui/dr-ui/ui/settings.slint#L202), [`ui/dr-ui/ui/widgets.slint:271`](../../ui/dr-ui/ui/widgets.slint#L271) | -| 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/controller.rs:258`](../../ui/dr-ui/src/collections_ui/controller.rs#L258), [`ui/dr-ui/src/collections_ui/controller.rs:268`](../../ui/dr-ui/src/collections_ui/controller.rs#L268), [`ui/dr-ui/src/collections_ui/controller.rs:291`](../../ui/dr-ui/src/collections_ui/controller.rs#L291), [`ui/dr-ui/src/collections_ui/controller.rs:312`](../../ui/dr-ui/src/collections_ui/controller.rs#L312), [`ui/dr-ui/src/collections_ui/controller.rs:46`](../../ui/dr-ui/src/collections_ui/controller.rs#L46), [`ui/dr-ui/src/collections_ui/drag.rs:274`](../../ui/dr-ui/src/collections_ui/drag.rs#L274), [`ui/dr-ui/src/collections_ui/mod.rs:1`](../../ui/dr-ui/src/collections_ui/mod.rs#L1), [`ui/dr-ui/src/collections_ui/press.rs:258`](../../ui/dr-ui/src/collections_ui/press.rs#L258), [`ui/dr-ui/src/collections_ui/press.rs:490`](../../ui/dr-ui/src/collections_ui/press.rs#L490), [`ui/dr-ui/src/collections_ui/press.rs:54`](../../ui/dr-ui/src/collections_ui/press.rs#L54), [`ui/dr-ui/src/collections_ui/rename_menu.rs:196`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L196), [`ui/dr-ui/src/collections_ui/rename_menu.rs:21`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L21), [`ui/dr-ui/src/collections_ui/tree_sync.rs:246`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L246), [`ui/dr-ui/src/collections_ui/tree_sync.rs:317`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L317), [`ui/dr-ui/src/collections_ui/tree_sync.rs:339`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L339), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:121`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L121), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:171`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L171), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:444`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L444), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:522`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L522), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:586`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L586), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:287`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L287), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:344`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L344), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:394`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L394), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:517`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L517), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:581`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L581), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/spans.rs:139`](../../ui/dr-ui/src/library/spans.rs#L139), [`ui/dr-ui/src/library/spans.rs:58`](../../ui/dr-ui/src/library/spans.rs#L58), [`ui/dr-ui/src/library/spans.rs:84`](../../ui/dr-ui/src/library/spans.rs#L84), [`ui/dr-ui/src/library_ui/sync.rs:235`](../../ui/dr-ui/src/library_ui/sync.rs#L235), [`ui/dr-ui/src/library_ui/timeline.rs:513`](../../ui/dr-ui/src/library_ui/timeline.rs#L513), [`ui/dr-ui/src/library_ui/window.rs:376`](../../ui/dr-ui/src/library_ui/window.rs#L376), [`ui/dr-ui/ui/app.slint:3495`](../../ui/dr-ui/ui/app.slint#L3495), [`ui/dr-ui/ui/collections.slint:1269`](../../ui/dr-ui/ui/collections.slint#L1269), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:615`](../../ui/dr-ui/ui/collections.slint#L615), [`ui/dr-ui/ui/library.slint:1362`](../../ui/dr-ui/ui/library.slint#L1362), [`ui/dr-ui/ui/library.slint:1729`](../../ui/dr-ui/ui/library.slint#L1729), [`ui/dr-ui/ui/library.slint:3679`](../../ui/dr-ui/ui/library.slint#L3679), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488), [`ui/dr-ui/ui/library.slint:5032`](../../ui/dr-ui/ui/library.slint#L5032), [`ui/dr-ui/ui/library.slint:513`](../../ui/dr-ui/ui/library.slint#L513), [`ui/dr-ui/ui/library.slint:5174`](../../ui/dr-ui/ui/library.slint#L5174), [`ui/dr-ui/ui/library.slint:562`](../../ui/dr-ui/ui/library.slint#L562), [`ui/dr-ui/ui/library.slint:572`](../../ui/dr-ui/ui/library.slint#L572) | +| FR-CAT-7 | [`core/dr-catalog/src/albums.rs:1`](../../core/dr-catalog/src/albums.rs#L1), [`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/controller.rs:258`](../../ui/dr-ui/src/collections_ui/controller.rs#L258), [`ui/dr-ui/src/collections_ui/controller.rs:268`](../../ui/dr-ui/src/collections_ui/controller.rs#L268), [`ui/dr-ui/src/collections_ui/controller.rs:291`](../../ui/dr-ui/src/collections_ui/controller.rs#L291), [`ui/dr-ui/src/collections_ui/controller.rs:312`](../../ui/dr-ui/src/collections_ui/controller.rs#L312), [`ui/dr-ui/src/collections_ui/controller.rs:46`](../../ui/dr-ui/src/collections_ui/controller.rs#L46), [`ui/dr-ui/src/collections_ui/drag.rs:274`](../../ui/dr-ui/src/collections_ui/drag.rs#L274), [`ui/dr-ui/src/collections_ui/mod.rs:1`](../../ui/dr-ui/src/collections_ui/mod.rs#L1), [`ui/dr-ui/src/collections_ui/press.rs:258`](../../ui/dr-ui/src/collections_ui/press.rs#L258), [`ui/dr-ui/src/collections_ui/press.rs:490`](../../ui/dr-ui/src/collections_ui/press.rs#L490), [`ui/dr-ui/src/collections_ui/press.rs:54`](../../ui/dr-ui/src/collections_ui/press.rs#L54), [`ui/dr-ui/src/collections_ui/rename_menu.rs:196`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L196), [`ui/dr-ui/src/collections_ui/rename_menu.rs:21`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L21), [`ui/dr-ui/src/collections_ui/tree_sync.rs:246`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L246), [`ui/dr-ui/src/collections_ui/tree_sync.rs:317`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L317), [`ui/dr-ui/src/collections_ui/tree_sync.rs:339`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L339), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:121`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L121), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:171`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L171), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:444`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L444), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:522`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L522), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:586`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L586), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:287`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L287), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:344`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L344), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:394`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L394), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:517`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L517), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:581`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L581), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/spans.rs:139`](../../ui/dr-ui/src/library/spans.rs#L139), [`ui/dr-ui/src/library/spans.rs:58`](../../ui/dr-ui/src/library/spans.rs#L58), [`ui/dr-ui/src/library/spans.rs:84`](../../ui/dr-ui/src/library/spans.rs#L84), [`ui/dr-ui/src/library_ui/sync.rs:235`](../../ui/dr-ui/src/library_ui/sync.rs#L235), [`ui/dr-ui/src/library_ui/timeline.rs:513`](../../ui/dr-ui/src/library_ui/timeline.rs#L513), [`ui/dr-ui/src/library_ui/window.rs:376`](../../ui/dr-ui/src/library_ui/window.rs#L376), [`ui/dr-ui/ui/app.slint:3495`](../../ui/dr-ui/ui/app.slint#L3495), [`ui/dr-ui/ui/collections.slint:1269`](../../ui/dr-ui/ui/collections.slint#L1269), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:615`](../../ui/dr-ui/ui/collections.slint#L615), [`ui/dr-ui/ui/library.slint:1362`](../../ui/dr-ui/ui/library.slint#L1362), [`ui/dr-ui/ui/library.slint:1729`](../../ui/dr-ui/ui/library.slint#L1729), [`ui/dr-ui/ui/library.slint:3679`](../../ui/dr-ui/ui/library.slint#L3679), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488), [`ui/dr-ui/ui/library.slint:5032`](../../ui/dr-ui/ui/library.slint#L5032), [`ui/dr-ui/ui/library.slint:513`](../../ui/dr-ui/ui/library.slint#L513), [`ui/dr-ui/ui/library.slint:5174`](../../ui/dr-ui/ui/library.slint#L5174), [`ui/dr-ui/ui/library.slint:562`](../../ui/dr-ui/ui/library.slint#L562), [`ui/dr-ui/ui/library.slint:572`](../../ui/dr-ui/ui/library.slint#L572) | | FR-CAT-8 | [`core/dr-catalog/src/schema.rs:767`](../../core/dr-catalog/src/schema.rs#L767), [`core/dr-pipeline/src/coverage.rs:1`](../../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/graph.rs:587`](../../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:642`](../../core/dr-pipeline/src/graph.rs#L642), [`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:1433`](../../core/dr-pipeline/src/sidecar.rs#L1433), [`core/dr-pipeline/src/sidecar.rs:2273`](../../core/dr-pipeline/src/sidecar.rs#L2273), [`core/dr-pipeline/src/sidecar.rs:99`](../../core/dr-pipeline/src/sidecar.rs#L99), [`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), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`ui/dr-ui/src/develop/mask_ops.rs:1076`](../../ui/dr-ui/src/develop/mask_ops.rs#L1076), [`ui/dr-ui/src/develop/mask_ops.rs:1130`](../../ui/dr-ui/src/develop/mask_ops.rs#L1130), [`ui/dr-ui/src/develop/mask_ops.rs:178`](../../ui/dr-ui/src/develop/mask_ops.rs#L178), [`ui/dr-ui/src/develop/masks.rs:743`](../../ui/dr-ui/src/develop/masks.rs#L743), [`ui/dr-ui/src/develop/session.rs:566`](../../ui/dr-ui/src/develop/session.rs#L566), [`ui/dr-ui/src/develop/session.rs:600`](../../ui/dr-ui/src/develop/session.rs#L600), [`ui/dr-ui/src/export.rs:831`](../../ui/dr-ui/src/export.rs#L831), [`ui/dr-ui/src/lib.rs:1186`](../../ui/dr-ui/src/lib.rs#L1186), [`ui/dr-ui/src/lib.rs:1246`](../../ui/dr-ui/src/lib.rs#L1246), [`ui/dr-ui/src/lib.rs:2736`](../../ui/dr-ui/src/lib.rs#L2736), [`ui/dr-ui/src/lib.rs:2929`](../../ui/dr-ui/src/lib.rs#L2929), [`ui/dr-ui/src/lib.rs:797`](../../ui/dr-ui/src/lib.rs#L797), [`ui/dr-ui/src/library/scan.rs:226`](../../ui/dr-ui/src/library/scan.rs#L226), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:34`](../../ui/dr-ui/src/library/scan.rs#L34), [`ui/dr-ui/src/library/sidecar.rs:102`](../../ui/dr-ui/src/library/sidecar.rs#L102), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:380`](../../ui/dr-ui/src/library/sidecar.rs#L380), [`ui/dr-ui/src/library/sidecar.rs:65`](../../ui/dr-ui/src/library/sidecar.rs#L65), [`ui/dr-ui/src/library/thumbnails_fetch.rs:413`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L413), [`ui/dr-ui/src/library_ui/grid.rs:778`](../../ui/dr-ui/src/library_ui/grid.rs#L778), [`ui/dr-ui/src/library_ui/open.rs:455`](../../ui/dr-ui/src/library_ui/open.rs#L455), [`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:1209`](../../core/dr-catalog/src/schema.rs#L1209), [`core/dr-catalog/src/walk.rs:161`](../../core/dr-catalog/src/walk.rs#L161), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:432`](../../core/dr-catalog/src/walk.rs#L432), [`core/dr-catalog/src/walk.rs:695`](../../core/dr-catalog/src/walk.rs#L695), [`core/dr-catalog/src/walk.rs:989`](../../core/dr-catalog/src/walk.rs#L989), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`ui/dr-ui/src/develop/render.rs:626`](../../ui/dr-ui/src/develop/render.rs#L626), [`ui/dr-ui/src/develop/render.rs:669`](../../ui/dr-ui/src/develop/render.rs#L669), [`ui/dr-ui/src/library/filters.rs:89`](../../ui/dr-ui/src/library/filters.rs#L89), [`ui/dr-ui/src/library/scan.rs:126`](../../ui/dr-ui/src/library/scan.rs#L126), [`ui/dr-ui/src/library/scan.rs:179`](../../ui/dr-ui/src/library/scan.rs#L179), [`ui/dr-ui/src/library/scan.rs:210`](../../ui/dr-ui/src/library/scan.rs#L210), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:635`](../../ui/dr-ui/src/library/scan.rs#L635), [`ui/dr-ui/src/library/sidecar.rs:102`](../../ui/dr-ui/src/library/sidecar.rs#L102), [`ui/dr-ui/src/library/sidecar.rs:364`](../../ui/dr-ui/src/library/sidecar.rs#L364), [`ui/dr-ui/src/library/sidecar.rs:380`](../../ui/dr-ui/src/library/sidecar.rs#L380), [`ui/dr-ui/src/library/sidecar.rs:434`](../../ui/dr-ui/src/library/sidecar.rs#L434), [`ui/dr-ui/src/library/spans.rs:369`](../../ui/dr-ui/src/library/spans.rs#L369), [`ui/dr-ui/src/library/thumbnails_fetch.rs:393`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L393), [`ui/dr-ui/src/library/thumbnails_fetch.rs:449`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L449), [`ui/dr-ui/src/library/thumbnails_gen.rs:74`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L74), [`ui/dr-ui/src/library_ui/controller.rs:295`](../../ui/dr-ui/src/library_ui/controller.rs#L295), [`ui/dr-ui/src/library_ui/controller.rs:331`](../../ui/dr-ui/src/library_ui/controller.rs#L331), [`ui/dr-ui/src/library_ui/controller.rs:339`](../../ui/dr-ui/src/library_ui/controller.rs#L339), [`ui/dr-ui/src/library_ui/controller.rs:586`](../../ui/dr-ui/src/library_ui/controller.rs#L586), [`ui/dr-ui/src/library_ui/controller.rs:703`](../../ui/dr-ui/src/library_ui/controller.rs#L703), [`ui/dr-ui/src/library_ui/filter_bar.rs:461`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L461), [`ui/dr-ui/src/library_ui/filter_bar.rs:547`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L547), [`ui/dr-ui/src/library_ui/offline.rs:561`](../../ui/dr-ui/src/library_ui/offline.rs#L561), [`ui/dr-ui/src/library_ui/offline.rs:607`](../../ui/dr-ui/src/library_ui/offline.rs#L607), [`ui/dr-ui/src/library_ui/offline.rs:623`](../../ui/dr-ui/src/library_ui/offline.rs#L623), [`ui/dr-ui/src/library_ui/open.rs:535`](../../ui/dr-ui/src/library_ui/open.rs#L535), [`ui/dr-ui/src/library_ui/open.rs:615`](../../ui/dr-ui/src/library_ui/open.rs#L615), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:75`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L75), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:883`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L883), [`ui/dr-ui/src/library_ui/sync.rs:287`](../../ui/dr-ui/src/library_ui/sync.rs#L287), [`ui/dr-ui/src/library_ui/sync.rs:421`](../../ui/dr-ui/src/library_ui/sync.rs#L421), [`ui/dr-ui/src/library_ui/sync.rs:83`](../../ui/dr-ui/src/library_ui/sync.rs#L83), [`ui/dr-ui/src/library_ui/window.rs:659`](../../ui/dr-ui/src/library_ui/window.rs#L659), [`ui/dr-ui/src/library_ui/window.rs:939`](../../ui/dr-ui/src/library_ui/window.rs#L939), [`ui/dr-ui/src/presets.rs:486`](../../ui/dr-ui/src/presets.rs#L486), [`ui/dr-ui/src/presets.rs:498`](../../ui/dr-ui/src/presets.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../../core/dr-decode/src/preview.rs#L121) | @@ -100,7 +100,7 @@ _None._ | FR-EXP-3 | [`core/dr-export/src/lib.rs:186`](../../core/dr-export/src/lib.rs#L186), [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../../core/dr-export/src/size.rs#L136), [`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-export/src/size.rs:84`](../../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1008`](../../core/dr-types/src/settings.rs#L1008), [`core/dr-types/src/settings.rs:1016`](../../core/dr-types/src/settings.rs#L1016), [`core/dr-types/src/settings.rs:1032`](../../core/dr-types/src/settings.rs#L1032), [`core/dr-types/src/settings.rs:1101`](../../core/dr-types/src/settings.rs#L1101), [`core/dr-types/src/settings.rs:1129`](../../core/dr-types/src/settings.rs#L1129), [`core/dr-types/src/settings.rs:1140`](../../core/dr-types/src/settings.rs#L1140), [`core/dr-types/src/settings.rs:1146`](../../core/dr-types/src/settings.rs#L1146), [`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), [`ui/dr-ui/src/settings_ui.rs:292`](../../ui/dr-ui/src/settings_ui.rs#L292), [`ui/dr-ui/src/settings_ui.rs:305`](../../ui/dr-ui/src/settings_ui.rs#L305), [`ui/dr-ui/src/settings_ui.rs:324`](../../ui/dr-ui/src/settings_ui.rs#L324), [`ui/dr-ui/src/settings_ui.rs:809`](../../ui/dr-ui/src/settings_ui.rs#L809), [`ui/dr-ui/ui/export.slint:159`](../../ui/dr-ui/ui/export.slint#L159), [`ui/dr-ui/ui/export.slint:169`](../../ui/dr-ui/ui/export.slint#L169) | | 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:646`](../../ui/dr-ui/src/lib.rs#L646), [`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:941`](../../ui/dr-ui/src/settings_ui.rs#L941) | +| FR-EXP-6 | [`core/dr-catalog/src/albums.rs:1`](../../core/dr-catalog/src/albums.rs#L1), [`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:646`](../../ui/dr-ui/src/lib.rs#L646), [`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:941`](../../ui/dr-ui/src/settings_ui.rs#L941) | | FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/develop_ui.rs:309`](../../ui/dr-ui/src/develop_ui.rs#L309), [`ui/dr-ui/src/export.rs:1024`](../../ui/dr-ui/src/export.rs#L1024), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:287`](../../ui/dr-ui/src/lib.rs#L287), [`ui/dr-ui/src/lib.rs:646`](../../ui/dr-ui/src/lib.rs#L646), [`ui/dr-ui/src/lib.rs:692`](../../ui/dr-ui/src/lib.rs#L692), [`ui/dr-ui/src/lib.rs:720`](../../ui/dr-ui/src/lib.rs#L720), [`ui/dr-ui/src/library_ui/controller.rs:1050`](../../ui/dr-ui/src/library_ui/controller.rs#L1050), [`ui/dr-ui/src/library_ui/controller.rs:1062`](../../ui/dr-ui/src/library_ui/controller.rs#L1062), [`ui/dr-ui/src/library_ui/controller.rs:1074`](../../ui/dr-ui/src/library_ui/controller.rs#L1074), [`ui/dr-ui/src/library_ui/controller.rs:762`](../../ui/dr-ui/src/library_ui/controller.rs#L762), [`ui/dr-ui/src/library_ui/controller.rs:862`](../../ui/dr-ui/src/library_ui/controller.rs#L862), [`ui/dr-ui/src/library_ui/controller.rs:919`](../../ui/dr-ui/src/library_ui/controller.rs#L919), [`ui/dr-ui/src/library_ui/sync.rs:100`](../../ui/dr-ui/src/library_ui/sync.rs#L100), [`ui/dr-ui/src/settings_ui.rs:502`](../../ui/dr-ui/src/settings_ui.rs#L502), [`ui/dr-ui/ui/app.slint:1081`](../../ui/dr-ui/ui/app.slint#L1081), [`ui/dr-ui/ui/app.slint:1507`](../../ui/dr-ui/ui/app.slint#L1507), [`ui/dr-ui/ui/app.slint:2526`](../../ui/dr-ui/ui/app.slint#L2526), [`ui/dr-ui/ui/app.slint:3433`](../../ui/dr-ui/ui/app.slint#L3433), [`ui/dr-ui/ui/app.slint:678`](../../ui/dr-ui/ui/app.slint#L678), [`ui/dr-ui/ui/app.slint:695`](../../ui/dr-ui/ui/app.slint#L695), [`ui/dr-ui/ui/app.slint:931`](../../ui/dr-ui/ui/app.slint#L931), [`ui/dr-ui/ui/export.slint:1`](../../ui/dr-ui/ui/export.slint#L1), [`ui/dr-ui/ui/export.slint:384`](../../ui/dr-ui/ui/export.slint#L384), [`ui/dr-ui/ui/library.slint:2004`](../../ui/dr-ui/ui/library.slint#L2004), [`ui/dr-ui/ui/library.slint:2012`](../../ui/dr-ui/ui/library.slint#L2012), [`ui/dr-ui/ui/library.slint:3171`](../../ui/dr-ui/ui/library.slint#L3171), [`ui/dr-ui/ui/library.slint:5199`](../../ui/dr-ui/ui/library.slint#L5199) | | FR-EXP-8 | [`core/dr-decode/src/lib.rs:372`](../../core/dr-decode/src/lib.rs#L372), [`core/dr-decode/src/lib.rs:396`](../../core/dr-decode/src/lib.rs#L396), [`core/dr-decode/src/lib.rs:410`](../../core/dr-decode/src/lib.rs#L410), [`core/dr-decode/src/lib.rs:77`](../../core/dr-decode/src/lib.rs#L77), [`core/dr-decode/src/lib.rs:85`](../../core/dr-decode/src/lib.rs#L85), [`core/dr-decode/src/lib.rs:88`](../../core/dr-decode/src/lib.rs#L88), [`core/dr-decode/src/locate.rs:1233`](../../core/dr-decode/src/locate.rs#L1233), [`core/dr-decode/src/locate.rs:1292`](../../core/dr-decode/src/locate.rs#L1292), [`core/dr-decode/src/locate.rs:359`](../../core/dr-decode/src/locate.rs#L359), [`core/dr-decode/src/locate.rs:556`](../../core/dr-decode/src/locate.rs#L556), [`core/dr-decode/src/locate.rs:640`](../../core/dr-decode/src/locate.rs#L640), [`core/dr-decode/src/locate.rs:653`](../../core/dr-decode/src/locate.rs#L653), [`core/dr-decode/src/locate.rs:736`](../../core/dr-decode/src/locate.rs#L736), [`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:140`](../../core/dr-export/src/lib.rs#L140), [`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:655`](../../core/dr-types/src/lib.rs#L655), [`core/dr-types/src/settings.rs:671`](../../core/dr-types/src/settings.rs#L671), [`ui/dr-ui/src/decoder_seam.rs:256`](../../ui/dr-ui/src/decoder_seam.rs#L256), [`ui/dr-ui/src/develop/session.rs:37`](../../ui/dr-ui/src/develop/session.rs#L37), [`ui/dr-ui/src/develop/session.rs:417`](../../ui/dr-ui/src/develop/session.rs#L417), [`ui/dr-ui/src/develop/session.rs:529`](../../ui/dr-ui/src/develop/session.rs#L529), [`ui/dr-ui/src/export.rs:475`](../../ui/dr-ui/src/export.rs#L475), [`ui/dr-ui/src/export.rs:646`](../../ui/dr-ui/src/export.rs#L646), [`ui/dr-ui/src/export.rs:692`](../../ui/dr-ui/src/export.rs#L692), [`ui/dr-ui/src/export.rs:720`](../../ui/dr-ui/src/export.rs#L720), [`ui/dr-ui/src/export.rs:757`](../../ui/dr-ui/src/export.rs#L757), [`ui/dr-ui/src/export.rs:858`](../../ui/dr-ui/src/export.rs#L858), [`ui/dr-ui/src/export.rs:876`](../../ui/dr-ui/src/export.rs#L876), [`ui/dr-ui/src/lib.rs:287`](../../ui/dr-ui/src/lib.rs#L287), [`ui/dr-ui/src/lib.rs:311`](../../ui/dr-ui/src/lib.rs#L311), [`ui/dr-ui/src/lib.rs:678`](../../ui/dr-ui/src/lib.rs#L678), [`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:552`](../../core/dr-decode/src/lib.rs#L552), [`core/dr-export/src/lib.rs:132`](../../core/dr-export/src/lib.rs#L132), [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1400`](../../core/dr-gpu/src/adjust.rs#L1400), [`ui/dr-ui/examples/face_native.rs:1`](../../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/decoder_seam.rs:256`](../../ui/dr-ui/src/decoder_seam.rs#L256), [`ui/dr-ui/src/develop/mask_ops.rs:1130`](../../ui/dr-ui/src/develop/mask_ops.rs#L1130), [`ui/dr-ui/src/develop/render.rs:588`](../../ui/dr-ui/src/develop/render.rs#L588), [`ui/dr-ui/src/develop/render.rs:626`](../../ui/dr-ui/src/develop/render.rs#L626), [`ui/dr-ui/src/lib.rs:646`](../../ui/dr-ui/src/lib.rs#L646), [`ui/dr-ui/src/library/sweep.rs:510`](../../ui/dr-ui/src/library/sweep.rs#L510), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | @@ -175,11 +175,11 @@ _None._ | NFR-PORT-2 | [`core/dr-gpu/src/lib.rs:1`](../../core/dr-gpu/src/lib.rs#L1) | | NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1) | | NFR-R1 | [`core/dr-catalog/src/schema.rs:361`](../../core/dr-catalog/src/schema.rs#L361), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:194`](../../core/dr-gpu/src/lib.rs#L194), [`core/dr-sync/src/account.rs:240`](../../core/dr-sync/src/account.rs#L240), [`ui/dr-ui/src/library/paths.rs:69`](../../ui/dr-ui/src/library/paths.rs#L69) | -| NFR-R2 | [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1), [`core/dr-catalog/src/recovery.rs:222`](../../core/dr-catalog/src/recovery.rs#L222), [`core/dr-catalog/src/sync.rs:249`](../../core/dr-catalog/src/sync.rs#L249), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`ui/dr-ui/src/derived_sync.rs:623`](../../ui/dr-ui/src/derived_sync.rs#L623), [`ui/dr-ui/src/derived_sync.rs:746`](../../ui/dr-ui/src/derived_sync.rs#L746), [`ui/dr-ui/src/library_ui/sync.rs:22`](../../ui/dr-ui/src/library_ui/sync.rs#L22), [`ui/dr-ui/src/library_ui/sync.rs:369`](../../ui/dr-ui/src/library_ui/sync.rs#L369) | +| NFR-R2 | [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1), [`core/dr-catalog/src/recovery.rs:222`](../../core/dr-catalog/src/recovery.rs#L222), [`core/dr-catalog/src/sync.rs:253`](../../core/dr-catalog/src/sync.rs#L253), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`ui/dr-ui/src/derived_sync.rs:623`](../../ui/dr-ui/src/derived_sync.rs#L623), [`ui/dr-ui/src/derived_sync.rs:746`](../../ui/dr-ui/src/derived_sync.rs#L746), [`ui/dr-ui/src/library_ui/sync.rs:22`](../../ui/dr-ui/src/library_ui/sync.rs#L22), [`ui/dr-ui/src/library_ui/sync.rs:369`](../../ui/dr-ui/src/library_ui/sync.rs#L369) | | NFR-R3 | [`core/dr-decode/src/error.rs:30`](../../core/dr-decode/src/error.rs#L30) | | NFR-R4 | [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui/controller.rs:403`](../../ui/dr-ui/src/library_ui/controller.rs#L403), [`ui/dr-ui/src/library_ui/controller.rs:550`](../../ui/dr-ui/src/library_ui/controller.rs#L550), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:693`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L693), [`ui/dr-ui/src/settings_ui.rs:632`](../../ui/dr-ui/src/settings_ui.rs#L632), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/ui/settings.slint:210`](../../ui/dr-ui/ui/settings.slint#L210), [`ui/dr-ui/ui/settings.slint:989`](../../ui/dr-ui/ui/settings.slint#L989) | | 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-R6 | [`core/dr-catalog/src/error.rs:1`](../../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/lib.rs:256`](../../core/dr-catalog/src/lib.rs#L256), [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1) | +| NFR-R6 | [`core/dr-catalog/src/error.rs:1`](../../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/lib.rs:258`](../../core/dr-catalog/src/lib.rs#L258), [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.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-gpu/src/adjust.rs:1322`](../../core/dr-gpu/src/adjust.rs#L1322), [`core/dr-pipeline/src/history.rs:86`](../../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop/render.rs:561`](../../ui/dr-ui/src/develop/render.rs#L561), [`ui/dr-ui/src/identity_ui.rs:125`](../../ui/dr-ui/src/identity_ui.rs#L125), [`ui/dr-ui/src/lib.rs:110`](../../ui/dr-ui/src/lib.rs#L110), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) |