Sync with integration
This commit is contained in:
@@ -682,7 +682,7 @@ fn require_exists(conn: &Connection, id: CollectionId) -> Result<(), CatalogErro
|
|||||||
/// same reasoning as the connector's date parsing. Version 4 layout, seeded
|
/// same reasoning as the connector's date parsing. Version 4 layout, seeded
|
||||||
/// from the OS via `getrandom` through `rusqlite`'s existing dependency-free
|
/// from the OS via `getrandom` through `rusqlite`'s existing dependency-free
|
||||||
/// path — see below.
|
/// path — see below.
|
||||||
fn new_uuid() -> String {
|
pub(crate) fn new_uuid() -> String {
|
||||||
let b = random_bytes();
|
let b = random_bytes();
|
||||||
// Version 4, variant 1, per RFC 4122 §4.4.
|
// Version 4, variant 1, per RFC 4122 §4.4.
|
||||||
let v6 = (b[6] & 0x0F) | 0x40;
|
let v6 = (b[6] & 0x0F) | 0x40;
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ pub enum CatalogError {
|
|||||||
#[error("no such collection: {0}")]
|
#[error("no such collection: {0}")]
|
||||||
NoSuchCollection(u64),
|
NoSuchCollection(u64),
|
||||||
|
|
||||||
|
/// A keyword the caller named is gone — deleted, or fused into another by a
|
||||||
|
/// merge while its id sat in a UI model.
|
||||||
|
///
|
||||||
|
/// Its own variant rather than a silent no-op because the two are different
|
||||||
|
/// answers to the user: a rename that quietly did nothing looks exactly like
|
||||||
|
/// a rename that did not take.
|
||||||
|
#[error("no such keyword: {0}")]
|
||||||
|
NoSuchKeyword(u64),
|
||||||
|
|
||||||
/// Images were dropped onto a smart collection.
|
/// Images were dropped onto a smart collection.
|
||||||
///
|
///
|
||||||
/// A smart collection's membership *is* its selector, so member rows would
|
/// A smart collection's membership *is* its selector, so member rows would
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,10 @@
|
|||||||
//! - [`walk`] — those decisions driven against real storage, local or SAF
|
//! - [`walk`] — those decisions driven against real storage, local or SAF
|
||||||
//! - [`query`] — selectors compiled to indexed SQL, windowed for the grid
|
//! - [`query`] — selectors compiled to indexed SQL, windowed for the grid
|
||||||
//! - [`collections`] — the collection tree and membership the UI edits
|
//! - [`collections`] — the collection tree and membership the UI edits
|
||||||
|
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
|
||||||
//! - [`jobs`] — the durable background work queue
|
//! - [`jobs`] — the durable background work queue
|
||||||
//! - [`trash`] — soft delete to a folder, then permanent delete
|
//! - [`trash`] — soft delete to a folder, then permanent delete
|
||||||
//! - [`merge`] / [`sync`] — cross-device collection merging
|
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
||||||
//!
|
//!
|
||||||
//! # The one thing everything is designed around
|
//! # The one thing everything is designed around
|
||||||
//!
|
//!
|
||||||
@@ -35,6 +36,7 @@ pub mod cache;
|
|||||||
pub mod collections;
|
pub mod collections;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
|
pub mod keywords;
|
||||||
pub mod merge;
|
pub mod merge;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod rating;
|
pub mod rating;
|
||||||
@@ -48,6 +50,7 @@ pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
|
|||||||
pub use collections::{Collection, CollectionKind, TreeRow};
|
pub use collections::{Collection, CollectionKind, TreeRow};
|
||||||
pub use error::CatalogError;
|
pub use error::CatalogError;
|
||||||
pub use jobs::{Job, JobKind, Priority};
|
pub use jobs::{Job, JobKind, Priority};
|
||||||
|
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
|
||||||
pub use merge::MergeReport;
|
pub use merge::MergeReport;
|
||||||
pub use query::{Query, Sort};
|
pub use query::{Query, Sort};
|
||||||
pub use rating::{Judgement, MAX_RATING};
|
pub use rating::{Judgement, MAX_RATING};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! TRACES: FR-CAT-7 | FR-NC-9
|
//! TRACES: FR-CAT-7 | FR-CAT-5 | FR-NC-9
|
||||||
//! Merging a remote catalog's collections into the local one.
|
//! Merging a remote catalog's collections and keywords into the local one.
|
||||||
//!
|
//!
|
||||||
//! # Why this is a merge and not a copy
|
//! # Why this is a merge and not a copy
|
||||||
//!
|
//!
|
||||||
@@ -33,6 +33,35 @@
|
|||||||
//! against a device that still holds it would otherwise resurrect it. The
|
//! against a device that still holds it would otherwise resurrect it. The
|
||||||
//! tombstone carries a revision like any other edit, so deletion competes on
|
//! tombstone carries a revision like any other edit, so deletion competes on
|
||||||
//! the same footing as a rename.
|
//! the same footing as a rename.
|
||||||
|
//!
|
||||||
|
//! # Keywords merge on the same three rules
|
||||||
|
//!
|
||||||
|
//! [`merge_keywords`] reuses all of the above rather than inventing a second
|
||||||
|
//! set of rules, because a keyword is the same shape of problem as a
|
||||||
|
//! collection: a named thing with a device-independent identity, and a
|
||||||
|
//! many-to-many join to images.
|
||||||
|
//!
|
||||||
|
//! - The **vocabulary** (`keyword_terms`) is decided per row by [`verdict`],
|
||||||
|
//! exactly as collections are.
|
||||||
|
//! - The **assignments** (`keywords`) are a set union, exactly as membership
|
||||||
|
//! is: two devices each keywording different photographs "puffin" keep both
|
||||||
|
//! sets, and two devices each keywording the *same* photograph converge on
|
||||||
|
//! one row rather than one of them winning.
|
||||||
|
//! - **Deletion** tombstones, and takes the assignments with it.
|
||||||
|
//!
|
||||||
|
//! Two things are genuinely different, and both are consequences of assignments
|
||||||
|
//! storing the *word* rather than a row id:
|
||||||
|
//!
|
||||||
|
//! 1. A tombstone deletes assignments **by name**, so a deletion still lands on
|
||||||
|
//! a device that had minted its own identity for the same word. The union
|
||||||
|
//! then refuses to readmit a word a winning tombstone has just removed —
|
||||||
|
//! without that filter, the other device's live assignments would resurrect
|
||||||
|
//! it on the very same pass.
|
||||||
|
//! 2. Two devices that independently typed the same word arrive with two uuids
|
||||||
|
//! for one keyword. [`crate::keywords::fuse_duplicates`] collapses them onto
|
||||||
|
//! the lexicographically smaller one, which both devices compute identically.
|
||||||
|
//! A unique index on the name would instead abort the merge transaction at
|
||||||
|
//! that moment, which is the ordinary case rather than a corner one.
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
@@ -59,18 +88,44 @@ pub struct MergeReport {
|
|||||||
pub kept_local: usize,
|
pub kept_local: usize,
|
||||||
pub deleted: usize,
|
pub deleted: usize,
|
||||||
pub members_added: usize,
|
pub members_added: usize,
|
||||||
|
|
||||||
|
// Keywords are counted separately from collections rather than summed into
|
||||||
|
// the same fields. The report is shown to the user — "3 collections, 11
|
||||||
|
// keywords" is a sentence; "14 things" is not — and a merge that went wrong
|
||||||
|
// is far easier to place when the counts say which half it went wrong in.
|
||||||
|
/// Keywords the remote had and this device did not.
|
||||||
|
pub keywords_inserted: usize,
|
||||||
|
/// Keywords the remote had renamed, or brought back from a tombstone.
|
||||||
|
pub keywords_updated: usize,
|
||||||
|
/// Keywords the remote deleted, and this device has now deleted too.
|
||||||
|
pub keywords_deleted: usize,
|
||||||
|
/// Keywords where this device's revision was at least as high.
|
||||||
|
pub keywords_kept_local: usize,
|
||||||
|
/// Redundant identities for one word, retired by
|
||||||
|
/// [`crate::keywords::fuse_duplicates`].
|
||||||
|
pub keywords_fused: usize,
|
||||||
|
/// Keyword assignments taken from the remote.
|
||||||
|
pub keywords_assigned: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MergeReport {
|
impl MergeReport {
|
||||||
/// Whether the local catalog changed, and so needs re-uploading.
|
/// Whether the local catalog changed, and so needs re-uploading.
|
||||||
pub fn local_changed(&self) -> bool {
|
pub fn local_changed(&self) -> bool {
|
||||||
self.inserted > 0 || self.updated > 0 || self.deleted > 0 || self.members_added > 0
|
self.inserted > 0
|
||||||
|
|| self.updated > 0
|
||||||
|
|| self.deleted > 0
|
||||||
|
|| self.members_added > 0
|
||||||
|
|| self.keywords_inserted > 0
|
||||||
|
|| self.keywords_updated > 0
|
||||||
|
|| self.keywords_deleted > 0
|
||||||
|
|| self.keywords_fused > 0
|
||||||
|
|| self.keywords_assigned > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the local catalog holds anything the remote did not, and so
|
/// Whether the local catalog holds anything the remote did not, and so
|
||||||
/// must be uploaded even if nothing was taken from the remote.
|
/// must be uploaded even if nothing was taken from the remote.
|
||||||
pub fn should_upload(&self) -> bool {
|
pub fn should_upload(&self) -> bool {
|
||||||
self.kept_local > 0 || self.local_changed()
|
self.kept_local > 0 || self.keywords_kept_local > 0 || self.local_changed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,16 +163,55 @@ pub fn verdict(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge collections and membership from an attached catalog.
|
/// Merge everything that syncs, from an attached catalog.
|
||||||
///
|
///
|
||||||
/// The remote catalog must already be attached under the schema name
|
/// The remote catalog must already be attached under the schema name
|
||||||
/// `remote_cat`; [`crate::Catalog::merge_attached_collections`] handles that.
|
/// `remote_cat`; [`crate::sync::merge_remote`] handles that.
|
||||||
|
///
|
||||||
|
/// **One transaction over both halves.** Keywords and collections are
|
||||||
|
/// independent as data, but a merge that landed the collections and then failed
|
||||||
|
/// on the keywords would leave a catalog that has already taken the remote's
|
||||||
|
/// revisions for half of itself — and the next attempt, seeing those revisions,
|
||||||
|
/// would decline to take them again. Half a merge is not a state that can be
|
||||||
|
/// resumed, so it is not a state that can be reached.
|
||||||
|
pub fn merge_all(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
let mut report = MergeReport::default();
|
||||||
|
merge_collections_within(&tx, &mut report)?;
|
||||||
|
merge_keywords_within(&tx, &mut report)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge collections and membership from an attached catalog.
|
||||||
|
///
|
||||||
|
/// The collections half of [`merge_all`], on its own. Kept as a public entry
|
||||||
|
/// point because the two halves are genuinely independent, and because the
|
||||||
|
/// rules for this one are worth being able to exercise without a keyword in
|
||||||
|
/// sight.
|
||||||
///
|
///
|
||||||
/// Runs in one transaction: a merge either lands whole or not at all.
|
/// Runs in one transaction: a merge either lands whole or not at all.
|
||||||
pub fn merge_collections(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
pub fn merge_collections(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
let mut report = MergeReport::default();
|
let mut report = MergeReport::default();
|
||||||
|
merge_collections_within(&tx, &mut report)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge the keyword vocabulary and its assignments from an attached catalog.
|
||||||
|
///
|
||||||
|
/// The keywords half of [`merge_all`], on its own. See the module header for
|
||||||
|
/// the three rules and the two places keywords differ from collections.
|
||||||
|
pub fn merge_keywords(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
let mut report = MergeReport::default();
|
||||||
|
merge_keywords_within(&tx, &mut report)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_collections_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> {
|
||||||
// ---- collections ------------------------------------------------------
|
// ---- collections ------------------------------------------------------
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
@@ -310,8 +404,245 @@ pub fn merge_collections(conn: &Connection) -> Result<MergeReport, CatalogError>
|
|||||||
)?;
|
)?;
|
||||||
report.members_added = added;
|
report.members_added = added;
|
||||||
|
|
||||||
tx.commit()?;
|
Ok(())
|
||||||
Ok(report)
|
}
|
||||||
|
|
||||||
|
/// Schema name the downloaded remote catalog is attached under.
|
||||||
|
///
|
||||||
|
/// Repeated from [`crate::sync`] rather than shared, because the SQL below
|
||||||
|
/// spells it inline and a constant that only half the file used would be worse
|
||||||
|
/// than no constant at all.
|
||||||
|
const REMOTE: &str = "remote_cat";
|
||||||
|
|
||||||
|
/// The keyword half. See the module header.
|
||||||
|
fn merge_keywords_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> {
|
||||||
|
// ---- the vocabulary ---------------------------------------------------
|
||||||
|
//
|
||||||
|
// A remote written before schema v6 has no `keyword_terms` at all, and
|
||||||
|
// `remote_is_mergeable` deliberately admits it: the check is that the
|
||||||
|
// remote is not *newer* than us. So the table's absence is a normal state
|
||||||
|
// and not an error. Its assignments still merge below — those have been in
|
||||||
|
// the schema since v1 — and its words gain identities on that device the
|
||||||
|
// next time it opens the catalog and backfills.
|
||||||
|
if attached_has_table(tx, REMOTE, "keyword_terms")? {
|
||||||
|
struct Incoming {
|
||||||
|
uuid: String,
|
||||||
|
name: String,
|
||||||
|
/// What this device currently calls the same identity, if it has
|
||||||
|
/// it. A rename is applied to the assignment rows by rewriting this
|
||||||
|
/// text, so it has to be read before the term row is overwritten.
|
||||||
|
local_name: Option<String>,
|
||||||
|
created: i64,
|
||||||
|
revision: i64,
|
||||||
|
modified: i64,
|
||||||
|
verdict: MergeVerdict,
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<Incoming> = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"SELECT r.uuid, r.name, r.created, r.revision, r.modified, r.deleted,
|
||||||
|
l.name, l.revision, l.modified
|
||||||
|
FROM remote_cat.keyword_terms r
|
||||||
|
LEFT JOIN main.keyword_terms l ON l.uuid = r.uuid",
|
||||||
|
)?;
|
||||||
|
let found = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
let deleted: i64 = r.get(5)?;
|
||||||
|
let local_rev: Option<i64> = r.get(7)?;
|
||||||
|
let local_mod: Option<i64> = r.get(8)?;
|
||||||
|
let revision: i64 = r.get(3)?;
|
||||||
|
let modified: i64 = r.get(4)?;
|
||||||
|
Ok(Incoming {
|
||||||
|
uuid: r.get(0)?,
|
||||||
|
name: r.get(1)?,
|
||||||
|
local_name: r.get(6)?,
|
||||||
|
created: r.get(2)?,
|
||||||
|
revision,
|
||||||
|
modified,
|
||||||
|
verdict: verdict(
|
||||||
|
local_rev.zip(local_mod),
|
||||||
|
(revision, modified),
|
||||||
|
deleted != 0,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
found
|
||||||
|
};
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
match row.verdict {
|
||||||
|
MergeVerdict::KeptLocal => {
|
||||||
|
report.keywords_kept_local += 1;
|
||||||
|
}
|
||||||
|
MergeVerdict::InsertedFromRemote => {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO main.keyword_terms
|
||||||
|
(uuid, name, created, revision, modified, deleted)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, 0)",
|
||||||
|
rusqlite::params![
|
||||||
|
row.uuid,
|
||||||
|
row.name,
|
||||||
|
row.created,
|
||||||
|
row.revision,
|
||||||
|
row.modified,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
report.keywords_inserted += 1;
|
||||||
|
}
|
||||||
|
MergeVerdict::UpdatedFromRemote => {
|
||||||
|
// The assignments carry the *word*, so taking a new name
|
||||||
|
// for an identity we already hold means rewriting every row
|
||||||
|
// spelt the old way. Without this the vocabulary would show
|
||||||
|
// the new spelling and the search would only find the old.
|
||||||
|
if let Some(old) = row.local_name.filter(|n| *n != row.name) {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR IGNORE INTO main.keywords(version_id, keyword)
|
||||||
|
SELECT version_id, ?2 FROM main.keywords WHERE keyword = ?1",
|
||||||
|
rusqlite::params![old, row.name],
|
||||||
|
)?;
|
||||||
|
tx.execute("DELETE FROM main.keywords WHERE keyword = ?1", [&old])?;
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE main.keyword_terms
|
||||||
|
SET name = ?2, revision = ?3, modified = ?4, deleted = 0
|
||||||
|
WHERE uuid = ?1",
|
||||||
|
rusqlite::params![row.uuid, row.name, row.revision, row.modified],
|
||||||
|
)?;
|
||||||
|
report.keywords_updated += 1;
|
||||||
|
}
|
||||||
|
MergeVerdict::DeletedByRemote => {
|
||||||
|
// Tombstone rather than DELETE, or a third device
|
||||||
|
// reintroduces the keyword through us.
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO main.keyword_terms
|
||||||
|
(uuid, name, created, revision, modified, deleted)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, 1)
|
||||||
|
ON CONFLICT(uuid) DO UPDATE SET
|
||||||
|
deleted = 1, revision = ?4, modified = ?5",
|
||||||
|
rusqlite::params![
|
||||||
|
row.uuid,
|
||||||
|
row.name,
|
||||||
|
row.created,
|
||||||
|
row.revision,
|
||||||
|
row.modified,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
// **By name, not by identity.** This device may well have
|
||||||
|
// minted its own uuid for the same word before the two ever
|
||||||
|
// synced, in which case deleting by uuid would tombstone a
|
||||||
|
// row that nothing is assigned to and leave every
|
||||||
|
// photograph still carrying the word.
|
||||||
|
tx.execute("DELETE FROM main.keywords WHERE keyword = ?1", [&row.name])?;
|
||||||
|
report.keywords_deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two devices that each typed "Iceland" now hold two identities for one
|
||||||
|
// word. Collapse them before the assignments arrive, so the vocabulary
|
||||||
|
// the user sees after a sync has one row per word.
|
||||||
|
report.keywords_fused = crate::keywords::fuse_duplicates(tx)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- assignments ------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Set union, and the union is the whole point: FR-NC-9's principle applied
|
||||||
|
// to metadata rather than to edit nodes. Two devices that keyworded
|
||||||
|
// different frames "puffin" both keep their work, and neither loses it to
|
||||||
|
// whichever synced second.
|
||||||
|
//
|
||||||
|
// A removal therefore does not propagate — the remote's assignment simply
|
||||||
|
// reappears. That is the same trade-off collection membership makes above,
|
||||||
|
// and for the same reason: an unwanted keyword is removed again in a
|
||||||
|
// second, and a silently lost afternoon of keywording is not recoverable at
|
||||||
|
// all. Making removal propagate needs a tombstone per assignment, which is
|
||||||
|
// a schema change and a merge rule of its own.
|
||||||
|
//
|
||||||
|
// An incoming keyword lands on the local default version, so an image that
|
||||||
|
// has not got one yet would silently drop it. That is not a rare state: the
|
||||||
|
// invariant is maintained by a backfill on open, and a scan that ran since
|
||||||
|
// has added rows it has not covered. Losing a word the user typed on
|
||||||
|
// another device, for a bookkeeping reason, would be the wrong answer —
|
||||||
|
// this is idempotent and writes nothing once the invariant holds.
|
||||||
|
crate::rating::ensure_default_versions_within(tx)?;
|
||||||
|
|
||||||
|
// Two passes rather than one statement with an `OR`, because they resolve
|
||||||
|
// *different identities* for the same photograph and each wants its own
|
||||||
|
// index. See [`ASSIGN_BY_FILE_ID`] for why there are two at all.
|
||||||
|
for sql in [ASSIGN_BY_FILE_ID, ASSIGN_BY_CONTENT_HASH] {
|
||||||
|
report.keywords_assigned += tx.execute(sql, [])?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take assignments for images both devices know by the server's file id.
|
||||||
|
///
|
||||||
|
/// **Preferred over the content hash**, and the reason is that `content_hash`
|
||||||
|
/// is expensive — the schema says so, and it is computed only when import
|
||||||
|
/// dedup or a reconnect asks for it, which for most libraries is never. Keying
|
||||||
|
/// keywords on it alone would mean the union quietly did nothing for the
|
||||||
|
/// ordinary image, which is the exact failure this merge exists to prevent.
|
||||||
|
///
|
||||||
|
/// `oc:fileid` is the opposite: it is recorded for every image the moment a
|
||||||
|
/// remote scan sees it, it is stable across server-side renames and moves, and
|
||||||
|
/// it is the same integer on every device pointed at the same Nextcloud — which
|
||||||
|
/// is precisely the situation where two devices are keywording one library.
|
||||||
|
///
|
||||||
|
/// The keyword lands on the local image's **default version**, not on the
|
||||||
|
/// version it came from. Version uuids do not reconcile across devices in the
|
||||||
|
/// catalog: [`crate::rating::ensure_default_versions`] mints a fresh one per
|
||||||
|
/// device, so the same photograph's default versions have different uuids on
|
||||||
|
/// two machines and a uuid-keyed join would union nothing at all. Version
|
||||||
|
/// identity is reconciled in the *sidecar* (FR-NC-8), and until a merged
|
||||||
|
/// version arrives through there, the default version is both where
|
||||||
|
/// [`crate::keywords::assign`] writes and where the panel reads — so it is the
|
||||||
|
/// one place the word can land and be seen.
|
||||||
|
const ASSIGN_BY_FILE_ID: &str = "
|
||||||
|
INSERT OR IGNORE INTO main.keywords(version_id, keyword)
|
||||||
|
SELECT lv.id, rk.keyword
|
||||||
|
FROM remote_cat.keywords rk
|
||||||
|
JOIN remote_cat.versions rv ON rv.id = rk.version_id
|
||||||
|
JOIN remote_cat.remote rr ON rr.image_id = rv.image_id
|
||||||
|
JOIN main.remote lr ON lr.file_id = rr.file_id
|
||||||
|
JOIN main.versions lv ON lv.image_id = lr.image_id AND lv.is_default = 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM main.keyword_terms t
|
||||||
|
WHERE t.name = rk.keyword AND t.deleted = 1)
|
||||||
|
OR EXISTS (SELECT 1 FROM main.keyword_terms t
|
||||||
|
WHERE t.name = rk.keyword AND t.deleted = 0)";
|
||||||
|
|
||||||
|
/// The same union for a library with no server behind it.
|
||||||
|
///
|
||||||
|
/// A local-only library has no `remote` rows at all, so [`ASSIGN_BY_FILE_ID`]
|
||||||
|
/// matches nothing and this is the only identity available — and it is the one
|
||||||
|
/// collection membership already uses, so a library where membership merges
|
||||||
|
/// has keywords that merge too.
|
||||||
|
const ASSIGN_BY_CONTENT_HASH: &str = "
|
||||||
|
INSERT OR IGNORE INTO main.keywords(version_id, keyword)
|
||||||
|
SELECT lv.id, rk.keyword
|
||||||
|
FROM remote_cat.keywords rk
|
||||||
|
JOIN remote_cat.versions rv ON rv.id = rk.version_id
|
||||||
|
JOIN remote_cat.images ri ON ri.id = rv.image_id
|
||||||
|
JOIN main.images li ON li.content_hash = ri.content_hash
|
||||||
|
JOIN main.versions lv ON lv.image_id = li.id AND lv.is_default = 1
|
||||||
|
WHERE ri.content_hash IS NOT NULL
|
||||||
|
AND (NOT EXISTS (SELECT 1 FROM main.keyword_terms t
|
||||||
|
WHERE t.name = rk.keyword AND t.deleted = 1)
|
||||||
|
OR EXISTS (SELECT 1 FROM main.keyword_terms t
|
||||||
|
WHERE t.name = rk.keyword AND t.deleted = 0))";
|
||||||
|
|
||||||
|
/// Whether an attached database holds a table of this name.
|
||||||
|
///
|
||||||
|
/// The schema name and the table name are both literals from this file, never
|
||||||
|
/// user text — but they are still bound rather than formatted where SQLite
|
||||||
|
/// allows it, because the habit is what keeps the one that eventually is user
|
||||||
|
/// text from being formatted by accident.
|
||||||
|
fn attached_has_table(conn: &Connection, schema: &str, table: &str) -> Result<bool, CatalogError> {
|
||||||
|
let sql =
|
||||||
|
format!("SELECT count(*) FROM {schema}.sqlite_master WHERE type = 'table' AND name = ?1");
|
||||||
|
let n: i64 = conn.query_row(&sql, [table], |r| r.get(0))?;
|
||||||
|
Ok(n > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -391,12 +722,21 @@ mod tests {
|
|||||||
// ---- integration over two real catalogs ------------------------------
|
// ---- integration over two real catalogs ------------------------------
|
||||||
|
|
||||||
fn two_catalogs() -> Connection {
|
fn two_catalogs() -> Connection {
|
||||||
|
attached_remote(schema::for_attached("remote_cat"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same pair, but with the remote stopped at v1 — a device running a
|
||||||
|
/// build from before keywords had identities.
|
||||||
|
fn two_catalogs_with_a_v1_remote() -> Connection {
|
||||||
|
attached_remote(schema::v1_for_attached("remote_cat"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attached_remote(remote_schema: String) -> Connection {
|
||||||
let c = Connection::open_in_memory().unwrap();
|
let c = Connection::open_in_memory().unwrap();
|
||||||
schema::configure(&c).unwrap();
|
schema::configure(&c).unwrap();
|
||||||
schema::migrate(&c).unwrap();
|
schema::migrate(&c).unwrap();
|
||||||
// A second in-memory database standing in for the downloaded remote.
|
// A second in-memory database standing in for the downloaded remote.
|
||||||
c.execute_batch("ATTACH ':memory:' AS remote_cat").unwrap();
|
c.execute_batch("ATTACH ':memory:' AS remote_cat").unwrap();
|
||||||
let remote_schema = super::super::schema::v1_for_attached("remote_cat");
|
|
||||||
c.execute_batch(&remote_schema).unwrap();
|
c.execute_batch(&remote_schema).unwrap();
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
@@ -656,6 +996,330 @@ mod tests {
|
|||||||
assert!(!second.local_changed(), "merge must be idempotent");
|
assert!(!second.local_changed(), "merge must be idempotent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- keywords --------------------------------------------------------
|
||||||
|
|
||||||
|
/// Give an image a default version, as every write path assumes it has.
|
||||||
|
fn add_version(c: &Connection, db: &str, image: i64, uuid: &str) -> i64 {
|
||||||
|
c.execute(
|
||||||
|
&format!(
|
||||||
|
"INSERT INTO {db}.versions(image_id, uuid, name, is_default)
|
||||||
|
VALUES (?1, ?2, 'Default', 1)"
|
||||||
|
),
|
||||||
|
rusqlite::params![image, uuid],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.last_insert_rowid()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a word on an image's default version, creating the version.
|
||||||
|
///
|
||||||
|
/// The version uuid is derived from the database *and* the image, so the
|
||||||
|
/// two catalogs never accidentally agree on one — which is the real
|
||||||
|
/// situation, and the reason the assignment union cannot key on it.
|
||||||
|
fn keyword(c: &Connection, db: &str, image: i64, word: &str) {
|
||||||
|
let existing: Option<i64> = c
|
||||||
|
.query_row(
|
||||||
|
&format!("SELECT id FROM {db}.versions WHERE image_id = ?1 AND is_default = 1"),
|
||||||
|
[image],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
let version =
|
||||||
|
existing.unwrap_or_else(|| add_version(c, db, image, &format!("v-{db}-{image}")));
|
||||||
|
c.execute(
|
||||||
|
&format!("INSERT OR IGNORE INTO {db}.keywords(version_id, keyword) VALUES (?1, ?2)"),
|
||||||
|
rusqlite::params![version, word],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_term(c: &Connection, db: &str, uuid: &str, name: &str, rev: i64, deleted: i64) {
|
||||||
|
c.execute(
|
||||||
|
&format!(
|
||||||
|
"INSERT INTO {db}.keyword_terms(uuid, name, created, revision, modified, deleted)
|
||||||
|
VALUES (?1, ?2, 0, ?3, ?3, ?4)"
|
||||||
|
),
|
||||||
|
rusqlite::params![uuid, name, rev, deleted],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an image to a server file id, as a remote scan does.
|
||||||
|
fn add_file_id(c: &Connection, db: &str, image: i64, file_id: i64) {
|
||||||
|
c.execute(
|
||||||
|
&format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"),
|
||||||
|
rusqlite::params![image, file_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every word on an image locally, sorted.
|
||||||
|
fn words_on(c: &Connection, image: i64) -> Vec<String> {
|
||||||
|
let mut stmt = c
|
||||||
|
.prepare(
|
||||||
|
"SELECT DISTINCT k.keyword FROM main.keywords k
|
||||||
|
JOIN main.versions v ON v.id = k.version_id
|
||||||
|
WHERE v.image_id = ?1 ORDER BY k.keyword",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt.query_map([image], |r| r.get(0)).unwrap();
|
||||||
|
rows.collect::<Result<Vec<_>, _>>().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn live_terms(c: &Connection) -> Vec<String> {
|
||||||
|
let mut stmt = c
|
||||||
|
.prepare("SELECT name FROM main.keyword_terms WHERE deleted = 0 ORDER BY name")
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt.query_map([], |r| r.get(0)).unwrap();
|
||||||
|
rows.collect::<Result<Vec<_>, _>>().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_devices_keywording_different_photographs_both_survive() {
|
||||||
|
// FR-NC-9's principle applied to metadata: disjoint work merges to the
|
||||||
|
// union, and neither device loses an afternoon to whoever synced last.
|
||||||
|
let c = two_catalogs();
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_image(&c, db, 1, "hash-a");
|
||||||
|
add_image(&c, db, 2, "hash-b");
|
||||||
|
}
|
||||||
|
keyword(&c, "main", 1, "puffin");
|
||||||
|
keyword(&c, "remote_cat", 2, "gannet");
|
||||||
|
|
||||||
|
merge_keywords(&c).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(words_on(&c, 1), ["puffin"]);
|
||||||
|
assert_eq!(words_on(&c, 2), ["gannet"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_devices_keywording_one_photograph_keep_both_words() {
|
||||||
|
// The case the union is really for: the same frame, two different
|
||||||
|
// words, and last-writer-wins would silently drop one of them.
|
||||||
|
let c = two_catalogs();
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_image(&c, db, 1, "hash-a");
|
||||||
|
}
|
||||||
|
keyword(&c, "main", 1, "puffin");
|
||||||
|
keyword(&c, "remote_cat", 1, "Iceland");
|
||||||
|
|
||||||
|
let report = merge_keywords(&c).unwrap();
|
||||||
|
assert_eq!(report.keywords_assigned, 1);
|
||||||
|
assert_eq!(words_on(&c, 1), ["Iceland", "puffin"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_word_both_devices_already_had_is_not_duplicated() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_image(&c, db, 1, "hash-a");
|
||||||
|
keyword(&c, db, 1, "puffin");
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = merge_keywords(&c).unwrap();
|
||||||
|
assert_eq!(report.keywords_assigned, 0);
|
||||||
|
assert_eq!(words_on(&c, 1), ["puffin"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_reach_an_image_the_server_names_but_no_one_has_hashed() {
|
||||||
|
// `content_hash` is computed only when import dedup or a reconnect asks
|
||||||
|
// for it, so for most images it is NULL — and a union keyed on it alone
|
||||||
|
// would quietly do nothing for the ordinary photograph. The file id is
|
||||||
|
// recorded by every remote scan, which is exactly the situation where
|
||||||
|
// two devices are keywording one library.
|
||||||
|
let c = two_catalogs();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO main.roots(id, kind, label) VALUES (1, 'remote', 'r')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO remote_cat.roots(id, kind, label) VALUES (1, 'remote', 'r')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Different row ids for one photograph, and no hash on either side.
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO main.images(id, root_id, source_ref, added_at)
|
||||||
|
VALUES (77, 1, 'IMG_1.CR3', 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO remote_cat.images(id, root_id, source_ref, added_at)
|
||||||
|
VALUES (3, 1, 'IMG_1.CR3', 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
add_file_id(&c, "main", 77, 9001);
|
||||||
|
add_file_id(&c, "remote_cat", 3, 9001);
|
||||||
|
add_version(&c, "main", 77, "v-main");
|
||||||
|
keyword(&c, "remote_cat", 3, "puffin");
|
||||||
|
|
||||||
|
merge_keywords(&c).unwrap();
|
||||||
|
assert_eq!(words_on(&c, 77), ["puffin"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_map_across_devices_by_content_hash_where_there_is_no_server() {
|
||||||
|
// A local-only library has no `remote` rows at all, so the hash is the
|
||||||
|
// only identity available — and it is the one membership already uses.
|
||||||
|
let c = two_catalogs();
|
||||||
|
add_image(&c, "main", 77, "same-photo");
|
||||||
|
add_image(&c, "remote_cat", 3, "same-photo");
|
||||||
|
add_version(&c, "main", 77, "v-main");
|
||||||
|
keyword(&c, "remote_cat", 3, "puffin");
|
||||||
|
|
||||||
|
merge_keywords(&c).unwrap();
|
||||||
|
assert_eq!(words_on(&c, 77), ["puffin"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_vocabulary_merges_by_uuid_and_a_skewed_clock_cannot_win() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
add_term(&c, "main", "u-1", "Iceland", 9, 0);
|
||||||
|
add_term(&c, "remote_cat", "u-1", "iceland", 2, 0);
|
||||||
|
add_term(&c, "remote_cat", "u-2", "puffin", 1, 0);
|
||||||
|
|
||||||
|
let report = merge_keywords(&c).unwrap();
|
||||||
|
assert_eq!(report.keywords_kept_local, 1);
|
||||||
|
assert_eq!(report.keywords_inserted, 1);
|
||||||
|
assert_eq!(live_terms(&c), ["Iceland", "puffin"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_remote_rename_moves_this_device_s_assignments_too() {
|
||||||
|
// The failure this exists to stop: the vocabulary shows the corrected
|
||||||
|
// spelling and the search still only finds the old one.
|
||||||
|
let c = two_catalogs();
|
||||||
|
add_image(&c, "main", 1, "hash-a");
|
||||||
|
add_term(&c, "main", "u-1", "Icland", 1, 0);
|
||||||
|
keyword(&c, "main", 1, "Icland");
|
||||||
|
add_term(&c, "remote_cat", "u-1", "Iceland", 4, 0);
|
||||||
|
|
||||||
|
let report = merge_keywords(&c).unwrap();
|
||||||
|
assert_eq!(report.keywords_updated, 1);
|
||||||
|
assert_eq!(live_terms(&c), ["Iceland"]);
|
||||||
|
assert_eq!(words_on(&c, 1), ["Iceland"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_remote_deletion_takes_the_word_off_every_photograph() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_image(&c, db, 1, "hash-a");
|
||||||
|
}
|
||||||
|
add_term(&c, "main", "u-1", "blurry", 1, 0);
|
||||||
|
keyword(&c, "main", 1, "blurry");
|
||||||
|
add_term(&c, "remote_cat", "u-1", "blurry", 5, 1);
|
||||||
|
|
||||||
|
let report = merge_keywords(&c).unwrap();
|
||||||
|
assert_eq!(report.keywords_deleted, 1);
|
||||||
|
assert!(live_terms(&c).is_empty());
|
||||||
|
assert!(words_on(&c, 1).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_deletion_is_not_undone_by_the_union_on_the_same_pass() {
|
||||||
|
// The remote deleted the word *and* still carries assignments for it —
|
||||||
|
// it has not yet had the chance to sweep them, or a third device put
|
||||||
|
// them there. Without the tombstone filter the union would put the word
|
||||||
|
// straight back on the photograph the deletion had just cleared.
|
||||||
|
let c = two_catalogs();
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_image(&c, db, 1, "hash-a");
|
||||||
|
}
|
||||||
|
add_term(&c, "main", "u-1", "blurry", 1, 0);
|
||||||
|
keyword(&c, "main", 1, "blurry");
|
||||||
|
add_term(&c, "remote_cat", "u-1", "blurry", 5, 1);
|
||||||
|
keyword(&c, "remote_cat", 1, "blurry");
|
||||||
|
|
||||||
|
merge_keywords(&c).unwrap();
|
||||||
|
assert!(words_on(&c, 1).is_empty(), "a deleted keyword came back");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_deletion_lands_even_when_the_two_devices_minted_different_uuids() {
|
||||||
|
// Both typed "blurry" before they ever synced, so this device's row has
|
||||||
|
// a uuid the remote has never heard of. Deleting by identity would
|
||||||
|
// tombstone nothing and leave every photograph still carrying the word.
|
||||||
|
let c = two_catalogs();
|
||||||
|
add_image(&c, "main", 1, "hash-a");
|
||||||
|
add_term(&c, "main", "mine", "blurry", 1, 0);
|
||||||
|
keyword(&c, "main", 1, "blurry");
|
||||||
|
add_term(&c, "remote_cat", "theirs", "blurry", 5, 1);
|
||||||
|
|
||||||
|
merge_keywords(&c).unwrap();
|
||||||
|
assert!(words_on(&c, 1).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_devices_that_typed_one_word_end_up_with_one_keyword() {
|
||||||
|
// Neither is wrong until they meet, which is why the name carries no
|
||||||
|
// unique index — a constraint would abort the merge at this moment.
|
||||||
|
let c = two_catalogs();
|
||||||
|
add_term(&c, "main", "zzzz", "Iceland", 3, 0);
|
||||||
|
add_term(&c, "remote_cat", "aaaa", "Iceland", 1, 0);
|
||||||
|
|
||||||
|
let report = merge_keywords(&c).unwrap();
|
||||||
|
assert_eq!(report.keywords_fused, 1);
|
||||||
|
assert_eq!(live_terms(&c), ["Iceland"]);
|
||||||
|
|
||||||
|
let survivor: String = c
|
||||||
|
.query_row("SELECT uuid FROM main.keyword_terms", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
survivor, "aaaa",
|
||||||
|
"both devices must pick the same survivor without asking each other"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merging_keywords_twice_changes_nothing_the_second_time() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_image(&c, db, 1, "hash-a");
|
||||||
|
}
|
||||||
|
add_term(&c, "remote_cat", "u-1", "puffin", 1, 0);
|
||||||
|
keyword(&c, "remote_cat", 1, "puffin");
|
||||||
|
|
||||||
|
let first = merge_keywords(&c).unwrap();
|
||||||
|
assert!(first.local_changed());
|
||||||
|
let second = merge_keywords(&c).unwrap();
|
||||||
|
assert!(!second.local_changed(), "merge must be idempotent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_remote_from_before_keyword_identities_still_contributes_its_words() {
|
||||||
|
// `remote_is_mergeable` admits an older remote on purpose — the check
|
||||||
|
// is that it is not *newer* than us. A missing table is therefore a
|
||||||
|
// normal state and must not fail the merge.
|
||||||
|
let c = two_catalogs_with_a_v1_remote();
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_image(&c, db, 1, "hash-a");
|
||||||
|
}
|
||||||
|
keyword(&c, "remote_cat", 1, "puffin");
|
||||||
|
|
||||||
|
let report = merge_keywords(&c).unwrap();
|
||||||
|
assert_eq!(report.keywords_assigned, 1);
|
||||||
|
assert_eq!(words_on(&c, 1), ["puffin"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_all_lands_both_halves() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
add_collection(&c, "remote_cat", 1, "u-coll", "Portugal", 1);
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_image(&c, db, 1, "hash-a");
|
||||||
|
}
|
||||||
|
keyword(&c, "remote_cat", 1, "puffin");
|
||||||
|
|
||||||
|
let report = merge_all(&c).unwrap();
|
||||||
|
assert_eq!(report.inserted, 1);
|
||||||
|
assert_eq!(report.keywords_assigned, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keeping_local_still_marks_the_catalog_for_upload() {
|
fn keeping_local_still_marks_the_catalog_for_upload() {
|
||||||
// We hold something the remote does not, so the remote is stale even
|
// We hold something the remote does not, so the remote is stale even
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ impl Judgement {
|
|||||||
/// The UUID is per row and generated here — it is the merge identity across
|
/// The UUID is per row and generated here — it is the merge identity across
|
||||||
/// devices (FR-NC-8), so two images must never share one.
|
/// devices (FR-NC-8), so two images must never share one.
|
||||||
pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError> {
|
pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError> {
|
||||||
|
// One transaction for the batch. A backfill over a 24k-image library is
|
||||||
|
// 24k inserts, and per-statement commits would make it minutes rather
|
||||||
|
// than seconds.
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
let n = ensure_default_versions_within(&tx)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`ensure_default_versions`] without opening a transaction.
|
||||||
|
///
|
||||||
|
/// Separate because SQLite has no nested `BEGIN`: [`crate::merge`] needs the
|
||||||
|
/// invariant restored *inside* the merge transaction — an incoming keyword
|
||||||
|
/// lands on a default version, so an image without one would silently drop it —
|
||||||
|
/// and calling the public form there fails at runtime with "cannot start a
|
||||||
|
/// transaction within a transaction". The same split, for the same reason, as
|
||||||
|
/// `collections::add_within`.
|
||||||
|
pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, CatalogError> {
|
||||||
let ids: Vec<i64> = {
|
let ids: Vec<i64> = {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT i.id FROM images i
|
"SELECT i.id FROM images i
|
||||||
@@ -89,12 +107,8 @@ pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError>
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One transaction for the batch. A backfill over a 24k-image library is
|
|
||||||
// 24k inserts, and per-statement commits would make it minutes rather
|
|
||||||
// than seconds.
|
|
||||||
let tx = conn.unchecked_transaction()?;
|
|
||||||
{
|
{
|
||||||
let mut insert = tx.prepare(
|
let mut insert = conn.prepare(
|
||||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||||
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
||||||
)?;
|
)?;
|
||||||
@@ -102,7 +116,6 @@ pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError>
|
|||||||
insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?;
|
insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
|
||||||
|
|
||||||
Ok(ids.len())
|
Ok(ids.len())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
|||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
/// Schema version this build writes and understands.
|
/// Schema version this build writes and understands.
|
||||||
pub const SCHEMA_VERSION: i64 = 5;
|
pub const SCHEMA_VERSION: i64 = 6;
|
||||||
|
|
||||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||||
///
|
///
|
||||||
@@ -66,6 +66,12 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
|||||||
tx.pragma_update(None, "user_version", 5)?;
|
tx.pragma_update(None, "user_version", 5)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
if from < 6 {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute_batch(V6)?;
|
||||||
|
tx.pragma_update(None, "user_version", 6)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(from)
|
Ok(from)
|
||||||
}
|
}
|
||||||
@@ -103,6 +109,20 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
|
|||||||
out.push(("default_versions", n));
|
out.push(("default_versions", n));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v6: a vocabulary row for every word some image already carries.
|
||||||
|
//
|
||||||
|
// Three ways a catalog arrives holding assignments with no term behind
|
||||||
|
// them, and all three are normal rather than exceptional: a library
|
||||||
|
// keyworded by a build that predates this table, a catalog rebuilt from
|
||||||
|
// sidecars (which carry the word and not the identity), and an import from
|
||||||
|
// Lightroom or darktable (FR-CAT-14). Without this the words are
|
||||||
|
// searchable but absent from the vocabulary list, which reads as the
|
||||||
|
// keywords having been lost.
|
||||||
|
let n = crate::keywords::adopt_orphan_terms(conn)?;
|
||||||
|
if n > 0 {
|
||||||
|
out.push(("keyword_terms", n));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,15 +154,45 @@ pub fn configure(conn: &Connection) -> Result<(), CatalogError> {
|
|||||||
/// in [`V1`]: every `CREATE TABLE`/`CREATE INDEX` must name its object
|
/// in [`V1`]: every `CREATE TABLE`/`CREATE INDEX` must name its object
|
||||||
/// unqualified, which they do.
|
/// unqualified, which they do.
|
||||||
pub fn v1_for_attached(schema_name: &str) -> String {
|
pub fn v1_for_attached(schema_name: &str) -> String {
|
||||||
V1.replace("CREATE TABLE ", &format!("CREATE TABLE {schema_name}."))
|
rewrite_for_attached(V1, schema_name)
|
||||||
|
// REFERENCES within an attached schema resolve to that schema already,
|
||||||
|
// so foreign keys need no rewriting — but the ON clause of an index
|
||||||
|
// does, and `CREATE INDEX x.name ON table` is the correct form.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every table this build knows about, rewritten to target an attached
|
||||||
|
/// database.
|
||||||
|
///
|
||||||
|
/// [`v1_for_attached`] is kept alongside this rather than replaced by it: a
|
||||||
|
/// remote catalog written by an older build genuinely has only the v1 tables,
|
||||||
|
/// and the merge has to keep working against one (see
|
||||||
|
/// [`crate::merge::merge_keywords`]). Building that case in a test needs a way
|
||||||
|
/// to say "v1 and no more".
|
||||||
|
///
|
||||||
|
/// Only the migrations that *create* objects appear here. V2 through V5 are
|
||||||
|
/// `ALTER TABLE ... ADD COLUMN`, and the columns they add are local index
|
||||||
|
/// state — shadowing, trashing, cache pinning — that a merge never reads
|
||||||
|
/// across the attachment.
|
||||||
|
pub fn for_attached(schema_name: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}\n{}",
|
||||||
|
rewrite_for_attached(V1, schema_name),
|
||||||
|
rewrite_for_attached(V6, schema_name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Qualify every object a `CREATE` statement names with `schema_name`.
|
||||||
|
///
|
||||||
|
/// The rewrite is textual and therefore only as good as the naming discipline
|
||||||
|
/// in the batches it is given: every `CREATE TABLE`/`CREATE INDEX` must name
|
||||||
|
/// its object unqualified, which they do.
|
||||||
|
fn rewrite_for_attached(sql: &str, schema_name: &str) -> String {
|
||||||
|
sql.replace("CREATE TABLE ", &format!("CREATE TABLE {schema_name}."))
|
||||||
.replace("CREATE INDEX ", &format!("CREATE INDEX {schema_name}."))
|
.replace("CREATE INDEX ", &format!("CREATE INDEX {schema_name}."))
|
||||||
.replace(
|
.replace(
|
||||||
"CREATE UNIQUE INDEX ",
|
"CREATE UNIQUE INDEX ",
|
||||||
&format!("CREATE UNIQUE INDEX {schema_name}."),
|
&format!("CREATE UNIQUE INDEX {schema_name}."),
|
||||||
)
|
)
|
||||||
// REFERENCES within an attached schema resolve to that schema already,
|
|
||||||
// so foreign keys need no rewriting — but the ON clause of an index
|
|
||||||
// does, and `CREATE INDEX x.name ON table` is the correct form.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark each JPEG that sits beside a RAW of the same name.
|
/// Mark each JPEG that sits beside a RAW of the same name.
|
||||||
@@ -226,6 +276,63 @@ fn stem_of(path: &str) -> &str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const V6: &str = r#"
|
||||||
|
-- TRACES: FR-CAT-5 | FR-CAT-6 | FR-NC-9
|
||||||
|
-- Keywords gain an identity, so that renaming and deleting one can cross
|
||||||
|
-- between devices.
|
||||||
|
--
|
||||||
|
-- The v1 `keywords` table is the *assignment*: one row per (version, word),
|
||||||
|
-- and the word is stored as text. That stays exactly as it is, and this
|
||||||
|
-- migration adds nothing to it, for a reason that is easy to get backwards.
|
||||||
|
--
|
||||||
|
-- # Why assignments keep the text rather than pointing at a row here
|
||||||
|
--
|
||||||
|
-- The catalog is a rebuildable index (ARCH §6.12). What an image is keyworded
|
||||||
|
-- with is authoritative in the sidecar and in XMP `dc:subject` (FR-CAT-13),
|
||||||
|
-- and both of those carry a *string*. Rewriting the join to reference
|
||||||
|
-- `keyword_terms(id)` would mean a catalog rebuilt from sidecars had to invent
|
||||||
|
-- term rows before it could record a single assignment, and an integer that
|
||||||
|
-- means nothing on the other device would sit where the durable fact belongs.
|
||||||
|
-- It would also break `crate::query`, which matches `kw.keyword` directly and
|
||||||
|
-- must keep hitting `keywords_term` on a 50k library (FR-CAT-6).
|
||||||
|
--
|
||||||
|
-- So the text is the fact and this table is the *identity*: it exists to give
|
||||||
|
-- a rename and a deletion something a merge can key on, and to let a keyword
|
||||||
|
-- exist in the vocabulary before any photograph carries it.
|
||||||
|
CREATE TABLE keyword_terms (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
-- Device-independent identity, as `collections.uuid` is. The integer id is
|
||||||
|
-- local and collides across devices.
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
-- The word itself, and the value written into every assignment row.
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created INTEGER NOT NULL,
|
||||||
|
-- Monotonic, bumped on every local edit. `crate::merge` compares these
|
||||||
|
-- rather than timestamps, so a clock-skewed device cannot silently win.
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
modified INTEGER NOT NULL,
|
||||||
|
-- Tombstone, so a merge against a device that still holds the keyword does
|
||||||
|
-- not resurrect it.
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Deliberately **not** UNIQUE.
|
||||||
|
--
|
||||||
|
-- Two devices that each type "Iceland" create two rows with two uuids, and
|
||||||
|
-- both are correct until they meet. A unique constraint would abort the merge
|
||||||
|
-- transaction at exactly that moment — the ordinary case, not a corner one.
|
||||||
|
-- Uniqueness is instead reached by convergence: `crate::keywords::create`
|
||||||
|
-- resolves an existing name locally, and `crate::keywords::fuse_duplicates`
|
||||||
|
-- collapses a cross-device pair onto the lexicographically smaller uuid, which
|
||||||
|
-- both devices compute identically without talking to each other.
|
||||||
|
--
|
||||||
|
-- Partial on `deleted = 0` because every lookup here is a live one: the
|
||||||
|
-- vocabulary list, the resolve-by-name in `create`, and the fuse pass all
|
||||||
|
-- exclude tombstones, and including them would grow the index with every
|
||||||
|
-- keyword the library has ever had rather than with the ones it has.
|
||||||
|
CREATE INDEX keyword_terms_name ON keyword_terms(name) WHERE deleted = 0;
|
||||||
|
"#;
|
||||||
|
|
||||||
const V5: &str = r#"
|
const V5: &str = r#"
|
||||||
-- TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4
|
-- TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4
|
||||||
-- Offline availability: what is kept, why it is kept, and where it lives.
|
-- Offline availability: what is kept, why it is kept, and where it lives.
|
||||||
@@ -671,6 +778,77 @@ mod tests {
|
|||||||
assert_eq!(bytes, 100, "the existing row is untouched");
|
assert_eq!(bytes, 100, "the existing row is untouched");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_v5_catalog_keeps_its_keywords_and_gains_their_identities() {
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
// The migration case that matters here: a library keyworded by an
|
||||||
|
// import or an older build already has assignment rows, and they must
|
||||||
|
// survive into the vocabulary rather than being left searchable but
|
||||||
|
// invisible.
|
||||||
|
let c = mem();
|
||||||
|
for step in [V1, V2, V3, V4, V5] {
|
||||||
|
c.execute_batch(step).unwrap();
|
||||||
|
}
|
||||||
|
c.pragma_update(None, "user_version", 5).unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (7, 1, 'IMG_7.CR3', 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO versions(id, image_id, uuid, name, is_default)
|
||||||
|
VALUES (1, 7, 'v-7', 'Default', 1)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO keywords(version_id, keyword) VALUES (1, 'puffin')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(migrate(&c).unwrap(), 5, "migrated from v5");
|
||||||
|
assert_eq!(backfilled(&c, "keyword_terms"), 1);
|
||||||
|
|
||||||
|
let name: String = c
|
||||||
|
.query_row("SELECT name FROM keyword_terms", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(name, "puffin");
|
||||||
|
// The assignment is untouched — it is the durable fact, and the term
|
||||||
|
// row is only its identity.
|
||||||
|
let n: i64 = c
|
||||||
|
.query_row("SELECT count(*) FROM keywords", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
|
||||||
|
// It runs on every open, so a second pass must find nothing to do.
|
||||||
|
assert_eq!(backfilled(&c, "keyword_terms"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_devices_may_both_hold_a_term_of_the_same_name() {
|
||||||
|
// Deliberately not a unique index. Two devices each typing "Iceland"
|
||||||
|
// is the ordinary case, and a constraint would abort the merge
|
||||||
|
// transaction at exactly the moment they first sync.
|
||||||
|
let c = mem();
|
||||||
|
migrate(&c).unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO keyword_terms(uuid, name, created, revision, modified)
|
||||||
|
VALUES ('a', 'Iceland', 0, 1, 1), ('b', 'Iceland', 0, 1, 1)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let n: i64 = c
|
||||||
|
.query_row("SELECT count(*) FROM keyword_terms", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stems_ignore_directories_containing_dots() {
|
fn stems_ignore_directories_containing_dots() {
|
||||||
assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1");
|
assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1");
|
||||||
|
|||||||
@@ -16,10 +16,11 @@
|
|||||||
//!
|
//!
|
||||||
//! # What is actually synced
|
//! # What is actually synced
|
||||||
//!
|
//!
|
||||||
//! Only collections merge (see [`crate::merge`]). The rest of the catalog is a
|
//! Only the *user's judgements about their library* merge: collections, and the
|
||||||
//! *local index* of *local* storage — folder mtimes, cache paths, job rows —
|
//! keyword vocabulary with its assignments (see [`crate::merge`]). The rest of
|
||||||
//! and copying another device's version of those in would be actively wrong.
|
//! the catalog is a *local index* of *local* storage — folder mtimes, cache
|
||||||
//! The remote file is read for its collections and then discarded.
|
//! paths, job rows — and copying another device's version of those in would be
|
||||||
|
//! actively wrong. The remote file is read for those two and then discarded.
|
||||||
//!
|
//!
|
||||||
//! This is why the catalog remains disposable in the ARCH §6.12 sense: nothing
|
//! This is why the catalog remains disposable in the ARCH §6.12 sense: nothing
|
||||||
//! here makes the local database authoritative for anything a rebuild could
|
//! here makes the local database authoritative for anything a rebuild could
|
||||||
@@ -96,7 +97,7 @@ pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, Cat
|
|||||||
[remote.to_string_lossy().as_ref()],
|
[remote.to_string_lossy().as_ref()],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let result = merge::merge_collections(conn);
|
let result = merge::merge_all(conn);
|
||||||
|
|
||||||
// Detach even if the merge failed, or the next attempt errors with
|
// Detach even if the merge failed, or the next attempt errors with
|
||||||
// "database remote_cat is already in use".
|
// "database remote_cat is already in use".
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! TRACES: FR-DEV-3
|
||||||
|
//! The tone curve's four curves, on a device.
|
||||||
|
//!
|
||||||
|
//! `dr-pipeline` asserts that the right WGSL is generated and `dr-gpu`'s other
|
||||||
|
//! tests assert that a shader runs; neither notices a fragment that says
|
||||||
|
//! exactly what it should and does not compile, or one that compiles and puts
|
||||||
|
//! the red curve's uniforms into the blue slot. So this renders flat grey
|
||||||
|
//! through each curve and looks at what came out.
|
||||||
|
//!
|
||||||
|
//! Flat grey because it makes every assertion a comparison between the three
|
||||||
|
//! components of one pixel: a curve that is meant to be chromatic must move
|
||||||
|
//! them apart, and one that is meant to be tonal must not.
|
||||||
|
|
||||||
|
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||||
|
use dr_pipeline::ops::curve::{self, Axis, Channel};
|
||||||
|
use dr_pipeline::EditGraph;
|
||||||
|
|
||||||
|
const SIZE: u32 = 8;
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
pollster::block_on(GpuContext::new_headless()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The centre pixel's red, green and blue, after `graph` has run over flat
|
||||||
|
/// mid-grey.
|
||||||
|
fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) {
|
||||||
|
let data: Vec<u8> = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||||
|
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
|
||||||
|
|
||||||
|
// Composed the way the display path composes it. A curve that generates
|
||||||
|
// invalid WGSL fails at `render` below, which is the point of running this
|
||||||
|
// on a device at all.
|
||||||
|
let shader = graph.compose();
|
||||||
|
|
||||||
|
let mut adjust = AdjustPass::new(ctx);
|
||||||
|
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||||
|
let pixels = adjust.export_pixels().expect("readback").0;
|
||||||
|
|
||||||
|
let at = ((SIZE / 2 * SIZE + SIZE / 2) * 4) as usize;
|
||||||
|
(pixels[at], pixels[at + 1], pixels[at + 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A curve with its mid-point lifted — the simplest edit that is unmistakably
|
||||||
|
/// an edit.
|
||||||
|
fn lifted(channel: Channel) -> EditGraph {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_param(
|
||||||
|
curve::ID,
|
||||||
|
curve::coordinate(channel, 2, Axis::Y),
|
||||||
|
0.75,
|
||||||
|
);
|
||||||
|
graph
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_master_curve_lifts_every_component_together() {
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("no adapter; skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain());
|
||||||
|
let (r, g, b) = rendered(&ctx, &lifted(Channel::Master));
|
||||||
|
|
||||||
|
assert!(r > r0, "the master curve did not lift the image: {r} vs {r0}");
|
||||||
|
// Grey in, grey out: the master curve is applied as a ratio over
|
||||||
|
// luminance, so it changes tone and not hue. A tolerance of one code
|
||||||
|
// value, because the components travel through the ratio separately and
|
||||||
|
// the result is quantised to eight bits.
|
||||||
|
assert!(
|
||||||
|
r.abs_diff(g) <= 1 && g.abs_diff(b) <= 1,
|
||||||
|
"the master curve tinted a neutral pixel: {r},{g},{b}"
|
||||||
|
);
|
||||||
|
assert_eq!((g0, b0), (r0, r0), "the unedited image is neutral");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_channel_curve_lifts_only_its_own_component() {
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("no adapter; skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain());
|
||||||
|
for (channel, name) in [
|
||||||
|
(Channel::Red, "red"),
|
||||||
|
(Channel::Green, "green"),
|
||||||
|
(Channel::Blue, "blue"),
|
||||||
|
] {
|
||||||
|
let (r, g, b) = rendered(&ctx, &lifted(channel));
|
||||||
|
// The component the curve names moves; the other two stay exactly
|
||||||
|
// where they were. This is what catches a fragment whose uniforms are
|
||||||
|
// wired to the wrong curve — it would still lift *something*.
|
||||||
|
let (moved, still) = match channel {
|
||||||
|
Channel::Red => (r > r0, g == g0 && b == b0),
|
||||||
|
Channel::Green => (g > g0, r == r0 && b == b0),
|
||||||
|
_ => (b > b0, r == r0 && g == g0),
|
||||||
|
};
|
||||||
|
assert!(moved, "the {name} curve changed nothing: {r},{g},{b}");
|
||||||
|
assert!(
|
||||||
|
still,
|
||||||
|
"the {name} curve moved a component that was not its own: \
|
||||||
|
{r},{g},{b} from {r0},{g0},{b0}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_master_and_the_channels_compose_in_one_pass() {
|
||||||
|
// All four curves at once: the case where the generated fragment is
|
||||||
|
// longest, every helper is present, and forty uniforms are in the block.
|
||||||
|
// Mostly a compile check, which is why the assertion is only that the
|
||||||
|
// result is a colour and not the one we started with.
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("no adapter; skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_param(curve::ID, curve::P1_Y, 0.15);
|
||||||
|
graph.set_param(curve::ID, curve::P3_Y, 0.85);
|
||||||
|
for (channel, y) in [
|
||||||
|
(Channel::Red, 0.55),
|
||||||
|
(Channel::Green, 0.5),
|
||||||
|
(Channel::Blue, 0.62),
|
||||||
|
] {
|
||||||
|
graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), y);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (r0, _, _) = rendered(&ctx, &EditGraph::default_chain());
|
||||||
|
let (r, g, b) = rendered(&ctx, &graph);
|
||||||
|
assert!(
|
||||||
|
(r, g, b) != (r0, r0, r0),
|
||||||
|
"four active curves left the image untouched"
|
||||||
|
);
|
||||||
|
// Red and blue were pushed apart from green, which is the chromatic half
|
||||||
|
// doing its work on top of the tonal one.
|
||||||
|
assert!(b > g, "blue was lifted above green: {r},{g},{b}");
|
||||||
|
}
|
||||||
@@ -209,10 +209,11 @@ They still belong in this directory, because the pipeline's **order** is the
|
|||||||
one thing a reader comes here to learn, and an order written half in YAML and
|
one thing a reader comes here to learn, and an order written half in YAML and
|
||||||
half in Rust would be worse than either alone.
|
half in Rust would be worse than either alone.
|
||||||
|
|
||||||
Currently hand-written: `tone_curve` (a curve widget over five interpolated
|
Currently hand-written: `tone_curve` (one widget over four curves of five
|
||||||
points), `colour_mixer` (thirty-six faceted parameters from twelve computed hue
|
interpolated points — master, red, green, blue — each reaching the shader only
|
||||||
bands), and `noise_reduction` (a kernel, and one that decides how many
|
when it has been moved), `colour_mixer` (thirty-six faceted parameters from
|
||||||
dispatches to emit at each resolution — see the next section).
|
twelve computed hue bands), and `noise_reduction` (a kernel, and one that
|
||||||
|
decides how many dispatches to emit at each resolution — see the next section).
|
||||||
`vignetting` is hand-written too but is not in the develop chain — it
|
`vignetting` is hand-written too but is not in the develop chain — it
|
||||||
carries lens-profile coefficients that are not parameters. `distortion` and
|
carries lens-profile coefficients that are not parameters. `distortion` and
|
||||||
`aberration` are `Warp`s rather than operations: they rewrite coordinates
|
`aberration` are `Warp`s rather than operations: they rewrite coordinates
|
||||||
|
|||||||
@@ -16,13 +16,26 @@ attributes: [tone, colour]
|
|||||||
rust: ToneCurve
|
rust: ToneCurve
|
||||||
|
|
||||||
why_rust: |
|
why_rust: |
|
||||||
Five control points presented as one curve widget, with an interpolator and
|
Four curves — master, red, green, blue — of five control points each,
|
||||||
a monotonicity guarantee behind it. Its neutral is a *relationship* between
|
presented as one widget, with an interpolator and a monotonicity guarantee
|
||||||
parameters rather than a set of values — the identity diagonal — which is
|
behind them. Its neutral is a *relationship* between parameters rather than
|
||||||
not something the declarative `active:` rule can express, and its
|
a set of values — the identity diagonal, on every channel — which is not
|
||||||
`presentation()` spans parameters rather than describing one.
|
something the declarative `active:` rule can express; its `presentation()`
|
||||||
|
spans forty parameters rather than describing one; and its fragment is
|
||||||
|
*assembled* rather than written, because each curve reaches the shader only
|
||||||
|
when it has been moved off the diagonal. A declared node's `wgsl:` is one
|
||||||
|
fixed block of text, which is the right shape for nearly everything here and
|
||||||
|
the wrong one for a node whose cost has to follow what the photographer
|
||||||
|
actually touched.
|
||||||
|
|
||||||
placement: |
|
placement: |
|
||||||
After the fixed-weight region controls, so the curve is the final word on
|
After the fixed-weight region controls, so the curve is the final word on
|
||||||
tone: a photographer reaches for it to fix what those controls could not
|
tone: a photographer reaches for it to fix what those controls could not
|
||||||
place exactly.
|
place exactly.
|
||||||
|
|
||||||
|
Within the node, the master curve runs before the per-channel ones. Both
|
||||||
|
orders are visibly different images and the reasons for this one are written
|
||||||
|
out in `src/ops/curve.rs`: the master is tonal and hue-preserving, the
|
||||||
|
per-channel curves are the chromatic grade over the tones it produced, and a
|
||||||
|
point placed on a channel curve should act on the tone the photographer can
|
||||||
|
see rather than on the one the master is about to move.
|
||||||
|
|||||||
+891
-165
File diff suppressed because it is too large
Load Diff
@@ -1088,7 +1088,7 @@ impl std::error::Error for ParseError {}
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::framing;
|
use crate::framing;
|
||||||
use crate::ops::{exposure, saturation, white_balance};
|
use crate::ops::{curve, exposure, saturation, white_balance};
|
||||||
|
|
||||||
fn edited() -> EditGraph {
|
fn edited() -> EditGraph {
|
||||||
let mut g = EditGraph::default_chain();
|
let mut g = EditGraph::default_chain();
|
||||||
@@ -1273,6 +1273,90 @@ mod tests {
|
|||||||
assert_eq!(once, twice);
|
assert_eq!(once, twice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||||||
|
/// A file written before the tone curve had per-channel curves.
|
||||||
|
///
|
||||||
|
/// Spelled out as literal text rather than produced by `to_text`, because
|
||||||
|
/// the claim is about *those bytes*: a sidecar generated by this build
|
||||||
|
/// would agree with this build by construction, and would go on agreeing
|
||||||
|
/// with it through a rename that broke every file on disk.
|
||||||
|
#[test]
|
||||||
|
fn a_sidecar_from_before_the_channel_curves_still_names_the_master() {
|
||||||
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 4\nmodified = 9\n\
|
||||||
|
tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n";
|
||||||
|
let parsed = Sidecar::parse(text).expect("valid");
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
parsed.default_version().expect("a version").apply(&mut g);
|
||||||
|
|
||||||
|
// The S-curve the file describes, on the master curve and nowhere
|
||||||
|
// else.
|
||||||
|
assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15));
|
||||||
|
assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85));
|
||||||
|
for channel in [curve::Channel::Red, curve::Channel::Green, curve::Channel::Blue] {
|
||||||
|
for point in 0..curve::POINTS {
|
||||||
|
for axis in [curve::Axis::X, curve::Axis::Y] {
|
||||||
|
let id = curve::coordinate(channel, point, axis);
|
||||||
|
let expected = g
|
||||||
|
.capabilities()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == curve::ID)
|
||||||
|
.and_then(|c| c.params.iter().find(|p| p.id == id))
|
||||||
|
.map(|p| p.default);
|
||||||
|
assert_eq!(
|
||||||
|
g.param(curve::ID, id),
|
||||||
|
expected,
|
||||||
|
"{id} moved, and no line in the file mentions it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And writing it back produces the same two lines: the curves the file
|
||||||
|
// never mentioned are still at their defaults, so they are still
|
||||||
|
// absent (`only_non_default_values_are_written`).
|
||||||
|
let written = Sidecar::parse(&Sidecar::parse(text).expect("valid").to_text())
|
||||||
|
.expect("valid")
|
||||||
|
.to_text();
|
||||||
|
assert!(written.contains("tone_curve.p1_y = 0.15"), "{written}");
|
||||||
|
assert!(written.contains("tone_curve.p3_y = 0.85"), "{written}");
|
||||||
|
assert!(
|
||||||
|
!written.contains("tone_curve.r_"),
|
||||||
|
"an untouched channel curve was written out:\n{written}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// The other direction: the new curves persist like any other parameter.
|
||||||
|
#[test]
|
||||||
|
fn a_per_channel_curve_survives_the_round_trip() {
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
// A faded shadow: blue lifted at the black point, red pulled down.
|
||||||
|
let blue = curve::coordinate(curve::Channel::Blue, 0, curve::Axis::Y);
|
||||||
|
let red = curve::coordinate(curve::Channel::Red, 4, curve::Axis::Y);
|
||||||
|
g.set_param(curve::ID, blue, 0.08);
|
||||||
|
g.set_param(curve::ID, red, 0.92);
|
||||||
|
g.set_param(curve::ID, curve::P2_Y, 0.55);
|
||||||
|
|
||||||
|
let mut sidecar = Sidecar::new();
|
||||||
|
sidecar.put(version_of(&g));
|
||||||
|
let text = sidecar.to_text();
|
||||||
|
// Keyed by the channel-prefixed id, which is what makes the master's
|
||||||
|
// unprefixed ones safe to leave alone.
|
||||||
|
assert!(text.contains("tone_curve.b_p0_y = 0.08"), "{text}");
|
||||||
|
|
||||||
|
let parsed = Sidecar::parse(&text).expect("valid");
|
||||||
|
let mut restored = EditGraph::default_chain();
|
||||||
|
parsed
|
||||||
|
.default_version()
|
||||||
|
.expect("a version")
|
||||||
|
.apply(&mut restored);
|
||||||
|
|
||||||
|
assert_eq!(restored.param(curve::ID, blue), Some(0.08));
|
||||||
|
assert_eq!(restored.param(curve::ID, red), Some(0.92));
|
||||||
|
assert_eq!(restored.param(curve::ID, curve::P2_Y), Some(0.55));
|
||||||
|
assert!(!restored.is_neutral());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_operation_survives_a_round_trip() {
|
fn an_unknown_operation_survives_a_round_trip() {
|
||||||
// The data-loss case that matters: a device running an older build
|
// The data-loss case that matters: a device running an older build
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! TRACES: FR-DEV-3
|
||||||
|
//! The tone curve's four curves, seen from outside the crate.
|
||||||
|
//!
|
||||||
|
//! The unit tests beside the operation assert what one `ToneCurve` does. These
|
||||||
|
//! assert the two properties that only show up once it is a node in a chain
|
||||||
|
//! with a sidecar under it: that a file written before the per-channel curves
|
||||||
|
//! existed still describes the edit it described, and that the three new
|
||||||
|
//! curves cost a photograph that does not use them precisely nothing.
|
||||||
|
|
||||||
|
use dr_pipeline::ops::curve::{self, Axis, Channel};
|
||||||
|
use dr_pipeline::{EditGraph, Sidecar};
|
||||||
|
|
||||||
|
/// A version block carrying `params`, in the on-disk spelling.
|
||||||
|
fn sidecar_with(params: &[(&str, f32)]) -> String {
|
||||||
|
let mut text = String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n");
|
||||||
|
for (key, value) in params {
|
||||||
|
text.push_str(&format!("{key} = {value}\n"));
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(text: &str) -> EditGraph {
|
||||||
|
let sidecar = Sidecar::parse(text).expect("a valid sidecar");
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
sidecar
|
||||||
|
.default_version()
|
||||||
|
.expect("a default version")
|
||||||
|
.apply(&mut graph);
|
||||||
|
graph
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-8
|
||||||
|
/// **The compatibility guarantee, end to end.**
|
||||||
|
///
|
||||||
|
/// An edit made before this build existed has to produce the same image now.
|
||||||
|
/// Asserted on the generated shader rather than on the parameter values,
|
||||||
|
/// because that is what the photograph is actually made of: same source, same
|
||||||
|
/// uniforms, same picture.
|
||||||
|
#[test]
|
||||||
|
fn an_edit_written_before_the_channel_curves_renders_as_it_did() {
|
||||||
|
let from_file = apply(&sidecar_with(&[
|
||||||
|
("tone_curve.p1_y", 0.15),
|
||||||
|
("tone_curve.p3_y", 0.85),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let mut by_hand = EditGraph::default_chain();
|
||||||
|
by_hand.set_param(curve::ID, curve::P1_Y, 0.15);
|
||||||
|
by_hand.set_param(curve::ID, curve::P3_Y, 0.85);
|
||||||
|
|
||||||
|
let restored = from_file.compose();
|
||||||
|
let expected = by_hand.compose();
|
||||||
|
assert_eq!(restored.source, expected.source);
|
||||||
|
assert_eq!(restored.uniforms, expected.uniforms);
|
||||||
|
assert_eq!(restored.structure_hash, expected.structure_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Neutral means absent, and stays absent with four times as much to be
|
||||||
|
/// neutral about.**
|
||||||
|
///
|
||||||
|
/// The curve carries forty parameters now. An image edited with an S-curve and
|
||||||
|
/// nothing else must generate the shader it generated when it carried ten:
|
||||||
|
/// three untouched curves are not three identity evaluations, they are nothing
|
||||||
|
/// at all.
|
||||||
|
#[test]
|
||||||
|
fn three_untouched_curves_cost_nothing() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_param(curve::ID, curve::P2_Y, 0.62);
|
||||||
|
let shader = graph.compose();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
shader.source.contains("---- tone_curve ----"),
|
||||||
|
"the master curve must reach the shader"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!shader.source.contains("channel_curve"),
|
||||||
|
"an untouched channel curve reached the shader:\n{}",
|
||||||
|
shader.source
|
||||||
|
);
|
||||||
|
for prefix in ["r_", "g_", "b_"] {
|
||||||
|
assert!(
|
||||||
|
!shader.source.contains(&format!("tone_curve_{prefix}")),
|
||||||
|
"an untouched channel curve declared uniforms:\n{}",
|
||||||
|
shader.source
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And with none of them touched, the operation is not there at all — the
|
||||||
|
/// property `a_fresh_graph_is_neutral` asserts for the chain, restated for the
|
||||||
|
/// node that just quadrupled in size.
|
||||||
|
#[test]
|
||||||
|
fn a_curve_at_its_defaults_is_absent_from_the_shader() {
|
||||||
|
let graph = EditGraph::default_chain();
|
||||||
|
assert!(graph.is_neutral());
|
||||||
|
assert!(!graph.compose().source.contains("tone_curve"));
|
||||||
|
|
||||||
|
// Including when every one of the forty parameters has been explicitly
|
||||||
|
// written to its own default, which is what a sidecar round trip through
|
||||||
|
// a build with a different idea of "default" would produce.
|
||||||
|
let mut written = EditGraph::default_chain();
|
||||||
|
for cap in written.capabilities() {
|
||||||
|
if cap.id != curve::ID {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for p in &cap.params {
|
||||||
|
written.set_param(curve::ID, p.id, p.default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(written.is_neutral());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A grade with no tonal work is a real edit, and it must not drag the
|
||||||
|
/// luminance path in behind it.
|
||||||
|
#[test]
|
||||||
|
fn a_channel_curve_reaches_the_shader_on_its_own() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_param(
|
||||||
|
curve::ID,
|
||||||
|
curve::coordinate(Channel::Blue, 0, Axis::Y),
|
||||||
|
0.08,
|
||||||
|
);
|
||||||
|
|
||||||
|
let shader = graph.compose();
|
||||||
|
assert!(shader.source.contains("c.b = channel_curve(c.b,"));
|
||||||
|
assert!(
|
||||||
|
!shader.source.contains("apply_tone_gain"),
|
||||||
|
"the identity master curve reached the shader:\n{}",
|
||||||
|
shader.source
|
||||||
|
);
|
||||||
|
// Blue's ten points, and nothing else: the other two curves are at the
|
||||||
|
// identity and contribute no slot to the uniform block.
|
||||||
|
for prefix in ["r_", "g_"] {
|
||||||
|
assert!(
|
||||||
|
!shader.source.contains(&format!("tone_curve_{prefix}")),
|
||||||
|
"an untouched channel curve declared uniforms:\n{}",
|
||||||
|
shader.source
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for point in 0..curve::POINTS {
|
||||||
|
assert!(
|
||||||
|
shader.source.contains(&format!("tone_curve_b_x{point}")),
|
||||||
|
"blue's point {point} is missing from the uniform block"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each curve is its own shader, so the pipeline cache cannot hand the red
|
||||||
|
/// curve's compiled program to an edit that moved the green one.
|
||||||
|
#[test]
|
||||||
|
fn every_curve_generates_a_distinct_shader() {
|
||||||
|
let mut hashes: Vec<u64> = Vec::new();
|
||||||
|
for channel in Channel::ALL {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.62);
|
||||||
|
hashes.push(graph.compose().structure_hash);
|
||||||
|
}
|
||||||
|
let before = hashes.len();
|
||||||
|
hashes.sort_unstable();
|
||||||
|
hashes.dedup();
|
||||||
|
assert_eq!(before, hashes.len(), "two curves share a compiled shader");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The forty parameters all persist, and the file names each one once.
|
||||||
|
#[test]
|
||||||
|
fn every_point_of_every_curve_round_trips_through_a_sidecar() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
// A different y per coordinate, so a point wired to the wrong channel
|
||||||
|
// cannot pass by holding the value it was supposed to hold anyway.
|
||||||
|
// Thirty-secondths because they survive both the decimal the file is
|
||||||
|
// written in and the binary32 it is read back into exactly — this test is
|
||||||
|
// about which parameter a value lands in, and a rounding difference here
|
||||||
|
// would fail it for an unrelated reason.
|
||||||
|
let mut step = 1;
|
||||||
|
for channel in Channel::ALL {
|
||||||
|
for point in 0..curve::POINTS {
|
||||||
|
graph.set_param(
|
||||||
|
curve::ID,
|
||||||
|
curve::coordinate(channel, point, Axis::Y),
|
||||||
|
step as f32 / 32.0,
|
||||||
|
);
|
||||||
|
step += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sidecar = Sidecar::new();
|
||||||
|
sidecar.put(dr_pipeline::sidecar::Version::from_graph(
|
||||||
|
"u1", "Default", &graph,
|
||||||
|
));
|
||||||
|
let restored = apply(&sidecar.to_text());
|
||||||
|
|
||||||
|
for channel in Channel::ALL {
|
||||||
|
for point in 0..curve::POINTS {
|
||||||
|
let id = curve::coordinate(channel, point, Axis::Y);
|
||||||
|
assert_eq!(
|
||||||
|
restored.param(curve::ID, id),
|
||||||
|
graph.param(curve::ID, id),
|
||||||
|
"{id} did not survive the sidecar"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+53
-53
@@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source files scanned | 148 |
|
| Source files scanned | 160 |
|
||||||
| TRACES tags found | 344 |
|
| TRACES tags found | 436 |
|
||||||
| Requirements defined | 175 |
|
| Requirements defined | 175 |
|
||||||
| Requirements covered | 83 |
|
| Requirements covered | 86 |
|
||||||
| **Coverage** | **47.4%** (83/175) |
|
| **Coverage** | **49.1%** (86/175) |
|
||||||
|
|
||||||
### By type
|
### By type
|
||||||
|
|
||||||
| Type | Covered | Defined |
|
| Type | Covered | Defined |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FR | 65 | 120 |
|
| FR | 68 | 120 |
|
||||||
| NFR | 16 | 49 |
|
| NFR | 16 | 49 |
|
||||||
| R | 2 | 6 |
|
| R | 2 | 6 |
|
||||||
|
|
||||||
@@ -35,71 +35,74 @@ _None._
|
|||||||
|---|---|
|
|---|---|
|
||||||
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298), [`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-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:196`](../core/dr-types/src/lib.rs#L196), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298), [`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-11 | [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152) |
|
| FR-CAT-11 | [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152) |
|
||||||
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109) |
|
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111) |
|
||||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`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:122`](../core/dr-sync/src/lib.rs#L122), [`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:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2576`](../ui/dr-ui/src/library.rs#L2576), [`ui/dr-ui/src/library.rs:2608`](../ui/dr-ui/src/library.rs#L2608), [`ui/dr-ui/src/library_ui.rs:123`](../ui/dr-ui/src/library_ui.rs#L123), [`ui/dr-ui/src/library_ui.rs:637`](../ui/dr-ui/src/library_ui.rs#L637), [`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-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) |
|
||||||
|
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:365`](../core/dr-catalog/src/schema.rs#L365), [`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:122`](../core/dr-sync/src/lib.rs#L122), [`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:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2576`](../ui/dr-ui/src/library.rs#L2576), [`ui/dr-ui/src/library.rs:2608`](../ui/dr-ui/src/library.rs#L2608), [`ui/dr-ui/src/library_ui.rs:123`](../ui/dr-ui/src/library_ui.rs#L123), [`ui/dr-ui/src/library_ui.rs:637`](../ui/dr-ui/src/library_ui.rs#L637), [`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:49`](../core/dr-types/src/lib.rs#L49) |
|
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) |
|
||||||
| 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-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) |
|
| 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) |
|
||||||
| 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), [`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-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), [`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/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-decode/src/lib.rs:310`](../core/dr-decode/src/lib.rs#L310), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126) |
|
| 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:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs:264`](../core/dr-decode/src/lib.rs#L264), [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library_ui.rs:5224`](../ui/dr-ui/src/library_ui.rs#L5224), [`ui/dr-ui/src/library_ui.rs:5235`](../ui/dr-ui/src/library_ui.rs#L5235), [`ui/dr-ui/src/library_ui.rs:5248`](../ui/dr-ui/src/library_ui.rs#L5248), [`ui/dr-ui/src/library_ui.rs:5263`](../ui/dr-ui/src/library_ui.rs#L5263), [`ui/dr-ui/src/library_ui.rs:5272`](../ui/dr-ui/src/library_ui.rs#L5272), [`ui/dr-ui/ui/app.slint:592`](../ui/dr-ui/ui/app.slint#L592), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
|
||||||
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.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-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179) |
|
| 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:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/ui/app.slint:592`](../ui/dr-ui/ui/app.slint#L592), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
|
||||||
| 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:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`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/ui/app.slint:570`](../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:687`](../ui/dr-ui/ui/library.slint#L687) |
|
| 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:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`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/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:716`](../ui/dr-ui/ui/library.slint#L716) |
|
||||||
| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/develop.rs:1770`](../ui/dr-ui/src/develop.rs#L1770), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1065`](../ui/dr-ui/src/lib.rs#L1065), [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/lib.rs:1510`](../ui/dr-ui/src/lib.rs#L1510), [`ui/dr-ui/src/lib.rs:460`](../ui/dr-ui/src/lib.rs#L460), [`ui/dr-ui/src/lib.rs:748`](../ui/dr-ui/src/lib.rs#L748), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4071`](../ui/dr-ui/src/library_ui.rs#L4071), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:141`](../core/dr-pipeline/src/ops/curve.rs#L141), [`core/dr-pipeline/src/ops/curve.rs:657`](../core/dr-pipeline/src/ops/curve.rs#L657), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/tests/tone_curve.rs:32`](../core/dr-pipeline/tests/tone_curve.rs#L32), [`ui/dr-ui/src/develop.rs:2194`](../ui/dr-ui/src/develop.rs#L2194), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/lib.rs:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/lib.rs:1528`](../ui/dr-ui/src/lib.rs#L1528), [`ui/dr-ui/src/lib.rs:462`](../ui/dr-ui/src/lib.rs#L462), [`ui/dr-ui/src/lib.rs:766`](../ui/dr-ui/src/lib.rs#L766), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4235`](../ui/dr-ui/src/library_ui.rs#L4235), [`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:230`](../core/dr-catalog/src/schema.rs#L230), [`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:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/develop.rs:1478`](../ui/dr-ui/src/develop.rs#L1478), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1443`](../ui/dr-ui/src/library_ui.rs#L1443), [`ui/dr-ui/src/library_ui.rs:1469`](../ui/dr-ui/src/library_ui.rs#L1469), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1579`](../ui/dr-ui/src/library_ui.rs#L1579), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2377`](../ui/dr-ui/src/library_ui.rs#L2377), [`ui/dr-ui/src/library_ui.rs:2559`](../ui/dr-ui/src/library_ui.rs#L2559), [`ui/dr-ui/src/library_ui.rs:2840`](../ui/dr-ui/src/library_ui.rs#L2840), [`ui/dr-ui/src/library_ui.rs:2912`](../ui/dr-ui/src/library_ui.rs#L2912), [`ui/dr-ui/src/library_ui.rs:3077`](../ui/dr-ui/src/library_ui.rs#L3077), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:4267`](../ui/dr-ui/src/library_ui.rs#L4267), [`ui/dr-ui/src/library_ui.rs:4382`](../ui/dr-ui/src/library_ui.rs#L4382), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`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:337`](../core/dr-catalog/src/schema.rs#L337), [`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:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/develop.rs:1902`](../ui/dr-ui/src/develop.rs#L1902), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1443`](../ui/dr-ui/src/library_ui.rs#L1443), [`ui/dr-ui/src/library_ui.rs:1469`](../ui/dr-ui/src/library_ui.rs#L1469), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1579`](../ui/dr-ui/src/library_ui.rs#L1579), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2541`](../ui/dr-ui/src/library_ui.rs#L2541), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:3004`](../ui/dr-ui/src/library_ui.rs#L3004), [`ui/dr-ui/src/library_ui.rs:3076`](../ui/dr-ui/src/library_ui.rs#L3076), [`ui/dr-ui/src/library_ui.rs:3241`](../ui/dr-ui/src/library_ui.rs#L3241), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:4465`](../ui/dr-ui/src/library_ui.rs#L4465), [`ui/dr-ui/src/library_ui.rs:4580`](../ui/dr-ui/src/library_ui.rs#L4580), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`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:134`](../core/dr-decode/src/preview.rs#L134) |
|
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
||||||
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161) |
|
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161) |
|
||||||
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) |
|
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) |
|
||||||
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1388`](../core/dr-gpu/src/adjust.rs#L1388), [`core/dr-gpu/src/adjust.rs:297`](../core/dr-gpu/src/adjust.rs#L297), [`core/dr-pipeline/src/framing.rs:186`](../core/dr-pipeline/src/framing.rs#L186), [`core/dr-pipeline/src/operation.rs:196`](../core/dr-pipeline/src/operation.rs#L196), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`ui/dr-ui/src/develop.rs:1016`](../ui/dr-ui/src/develop.rs#L1016), [`ui/dr-ui/src/develop.rs:62`](../ui/dr-ui/src/develop.rs#L62), [`ui/dr-ui/src/develop.rs:867`](../ui/dr-ui/src/develop.rs#L867), [`ui/dr-ui/src/lib.rs:1101`](../ui/dr-ui/src/lib.rs#L1101), [`ui/dr-ui/src/lib.rs:1753`](../ui/dr-ui/src/lib.rs#L1753), [`ui/dr-ui/src/lib.rs:286`](../ui/dr-ui/src/lib.rs#L286) |
|
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) |
|
||||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257), [`core/dr-pipeline/src/graph.rs:163`](../core/dr-pipeline/src/graph.rs#L163), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:490`](../core/dr-pipeline/src/mask.rs#L490), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107), [`ui/dr-ui/src/lib.rs:522`](../ui/dr-ui/src/lib.rs#L522) |
|
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1768`](../core/dr-gpu/src/adjust.rs#L1768), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:333`](../core/dr-pipeline/src/detail.rs#L333), [`core/dr-pipeline/src/detail.rs:408`](../core/dr-pipeline/src/detail.rs#L408), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`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:1255`](../ui/dr-ui/src/develop.rs#L1255), [`ui/dr-ui/src/develop.rs:1270`](../ui/dr-ui/src/develop.rs#L1270), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1375`](../ui/dr-ui/src/develop.rs#L1375), [`ui/dr-ui/src/develop.rs:1449`](../ui/dr-ui/src/develop.rs#L1449), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2552`](../ui/dr-ui/src/develop.rs#L2552), [`ui/dr-ui/src/develop.rs:2606`](../ui/dr-ui/src/develop.rs#L2606), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1119`](../ui/dr-ui/src/lib.rs#L1119), [`ui/dr-ui/src/lib.rs:1771`](../ui/dr-ui/src/lib.rs#L1771), [`ui/dr-ui/src/lib.rs:288`](../ui/dr-ui/src/lib.rs#L288), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772) |
|
||||||
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107) |
|
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:925`](../core/dr-pipeline/src/mask.rs#L925), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:323`](../core/dr-pipeline/src/ops/curve.rs#L323), [`ui/dr-ui/src/develop.rs:727`](../ui/dr-ui/src/develop.rs#L727), [`ui/dr-ui/src/lib.rs:524`](../ui/dr-ui/src/lib.rs#L524) |
|
||||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:163`](../core/dr-pipeline/src/graph.rs#L163), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:490`](../core/dr-pipeline/src/mask.rs#L490), [`ui/dr-ui/src/develop.rs:2579`](../ui/dr-ui/src/develop.rs#L2579) |
|
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) |
|
||||||
| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:186`](../core/dr-pipeline/src/framing.rs#L186) |
|
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:925`](../core/dr-pipeline/src/mask.rs#L925), [`ui/dr-ui/src/develop.rs:3185`](../ui/dr-ui/src/develop.rs#L3185) |
|
||||||
| FR-DEV-3e | [`core/dr-decode/src/lib.rs:504`](../core/dr-decode/src/lib.rs#L504), [`core/dr-decode/src/lib.rs:646`](../core/dr-decode/src/lib.rs#L646), [`core/dr-decode/src/lib.rs:686`](../core/dr-decode/src/lib.rs#L686) |
|
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/detail_stage.rs:240`](../core/dr-gpu/tests/detail_stage.rs#L240), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:420`](../core/dr-pipeline/src/graph.rs#L420), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) |
|
||||||
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:310`](../core/dr-decode/src/lib.rs#L310), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:200`](../core/dr-pipeline/src/framing.rs#L200), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) |
|
| 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:100`](../core/dr-decode/src/lib.rs#L100), [`core/dr-decode/src/lib.rs:632`](../core/dr-decode/src/lib.rs#L632), [`core/dr-decode/src/lib.rs:672`](../core/dr-decode/src/lib.rs#L672), [`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:690`](../core/dr-gpu/src/adjust.rs#L690), [`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:1344`](../core/dr-pipeline/src/operation.rs#L1344), [`core/dr-pipeline/src/operation.rs:1369`](../core/dr-pipeline/src/operation.rs#L1369), [`core/dr-pipeline/src/operation.rs:1384`](../core/dr-pipeline/src/operation.rs#L1384), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:513`](../core/dr-pipeline/src/operation.rs#L513) |
|
||||||
| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:211`](../core/dr-gpu/src/lib.rs#L211) |
|
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) |
|
||||||
| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:1786`](../ui/dr-ui/src/develop.rs#L1786), [`ui/dr-ui/src/develop.rs:1796`](../ui/dr-ui/src/develop.rs#L1796), [`ui/dr-ui/src/develop.rs:44`](../ui/dr-ui/src/develop.rs#L44), [`ui/dr-ui/src/lib.rs:1093`](../ui/dr-ui/src/lib.rs#L1093) |
|
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
||||||
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:1747`](../ui/dr-ui/src/develop.rs#L1747), [`ui/dr-ui/src/develop.rs:1757`](../ui/dr-ui/src/develop.rs#L1757), [`ui/dr-ui/src/lib.rs:1065`](../ui/dr-ui/src/lib.rs#L1065), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2208`](../ui/dr-ui/src/library_ui.rs#L2208), [`ui/dr-ui/src/library_ui.rs:2559`](../ui/dr-ui/src/library_ui.rs#L2559), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:1037`](../ui/dr-ui/ui/library.slint#L1037), [`ui/dr-ui/ui/library.slint:640`](../ui/dr-ui/ui/library.slint#L640), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) |
|
| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2210`](../ui/dr-ui/src/develop.rs#L2210), [`ui/dr-ui/src/develop.rs:2220`](../ui/dr-ui/src/develop.rs#L2220), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/lib.rs:1111`](../ui/dr-ui/src/lib.rs#L1111) |
|
||||||
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1311`](../core/dr-gpu/src/adjust.rs#L1311), [`core/dr-gpu/src/adjust.rs:1388`](../core/dr-gpu/src/adjust.rs#L1388), [`core/dr-gpu/src/adjust.rs:1473`](../core/dr-gpu/src/adjust.rs#L1473), [`core/dr-gpu/src/adjust.rs:42`](../core/dr-gpu/src/adjust.rs#L42), [`core/dr-gpu/src/lib.rs:48`](../core/dr-gpu/src/lib.rs#L48), [`core/dr-gpu/src/lib.rs:88`](../core/dr-gpu/src/lib.rs#L88), [`ui/dr-ui/src/develop.rs:1314`](../ui/dr-ui/src/develop.rs#L1314), [`ui/dr-ui/src/develop.rs:1934`](../ui/dr-ui/src/develop.rs#L1934), [`ui/dr-ui/src/develop.rs:2124`](../ui/dr-ui/src/develop.rs#L2124), [`ui/dr-ui/src/develop.rs:2158`](../ui/dr-ui/src/develop.rs#L2158), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56), [`ui/dr-ui/src/lib.rs:647`](../ui/dr-ui/src/lib.rs#L647) |
|
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:2171`](../ui/dr-ui/src/develop.rs#L2171), [`ui/dr-ui/src/develop.rs:2181`](../ui/dr-ui/src/develop.rs#L2181), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2372`](../ui/dr-ui/src/library_ui.rs#L2372), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1077`](../ui/dr-ui/ui/library.slint#L1077), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:737`](../ui/dr-ui/ui/library.slint#L737), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) |
|
||||||
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:169`](../core/dr-pipeline/src/operation.rs#L169), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) |
|
| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:333`](../core/dr-pipeline/src/detail.rs#L333), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) |
|
||||||
| 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:1359`](../ui/dr-ui/src/develop.rs#L1359), [`ui/dr-ui/src/develop.rs:3084`](../ui/dr-ui/src/develop.rs#L3084), [`ui/dr-ui/src/develop.rs:3116`](../ui/dr-ui/src/develop.rs#L3116), [`ui/dr-ui/src/develop.rs:55`](../ui/dr-ui/src/develop.rs#L55), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1152`](../ui/dr-ui/src/lib.rs#L1152), [`ui/dr-ui/src/lib.rs:281`](../ui/dr-ui/src/lib.rs#L281), [`ui/dr-ui/ui/app.slint:303`](../ui/dr-ui/ui/app.slint#L303), [`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-1 | [`core/dr-gpu/src/adjust.rs:1691`](../core/dr-gpu/src/adjust.rs#L1691), [`core/dr-gpu/src/adjust.rs:1768`](../core/dr-gpu/src/adjust.rs#L1768), [`core/dr-gpu/src/adjust.rs:1853`](../core/dr-gpu/src/adjust.rs#L1853), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`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/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:408`](../core/dr-pipeline/src/detail.rs#L408), [`core/dr-pipeline/src/graph.rs:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`ui/dr-ui/src/develop.rs:1738`](../ui/dr-ui/src/develop.rs#L1738), [`ui/dr-ui/src/develop.rs:2358`](../ui/dr-ui/src/develop.rs#L2358), [`ui/dr-ui/src/develop.rs:2730`](../ui/dr-ui/src/develop.rs#L2730), [`ui/dr-ui/src/develop.rs:2764`](../ui/dr-ui/src/develop.rs#L2764), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57), [`ui/dr-ui/src/lib.rs:665`](../ui/dr-ui/src/lib.rs#L665) |
|
||||||
|
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) |
|
||||||
|
| 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:1783`](../ui/dr-ui/src/develop.rs#L1783), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3832`](../ui/dr-ui/src/develop.rs#L3832), [`ui/dr-ui/src/develop.rs:3864`](../ui/dr-ui/src/develop.rs#L3864), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1170`](../ui/dr-ui/src/lib.rs#L1170), [`ui/dr-ui/src/lib.rs:283`](../ui/dr-ui/src/lib.rs#L283), [`ui/dr-ui/ui/app.slint:304`](../ui/dr-ui/ui/app.slint#L304), [`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-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-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:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:1621`](../core/dr-gpu/src/adjust.rs#L1621), [`core/dr-pipeline/src/graph.rs:336`](../core/dr-pipeline/src/graph.rs#L336), [`core/dr-pipeline/src/operation.rs:169`](../core/dr-pipeline/src/operation.rs#L169), [`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:454`](../core/dr-types/src/settings.rs#L454), [`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:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:2001`](../core/dr-gpu/src/adjust.rs#L2001), [`core/dr-pipeline/src/graph.rs:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`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:454`](../core/dr-types/src/settings.rs#L454), [`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-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-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-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:324`](../ui/dr-ui/src/lib.rs#L324), [`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:561`](../ui/dr-ui/src/settings_ui.rs#L561) |
|
| 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:326`](../ui/dr-ui/src/lib.rs#L326), [`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:561`](../ui/dr-ui/src/settings_ui.rs#L561) |
|
||||||
| 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:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1676`](../ui/dr-ui/src/lib.rs#L1676), [`ui/dr-ui/src/lib.rs:172`](../ui/dr-ui/src/lib.rs#L172), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/lib.rs:359`](../ui/dr-ui/src/lib.rs#L359), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library_ui.rs:2929`](../ui/dr-ui/src/library_ui.rs#L2929), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:5008`](../ui/dr-ui/src/library_ui.rs#L5008), [`ui/dr-ui/src/library_ui.rs:5020`](../ui/dr-ui/src/library_ui.rs#L5020), [`ui/dr-ui/src/library_ui.rs:5032`](../ui/dr-ui/src/library_ui.rs#L5032), [`ui/dr-ui/src/library_ui.rs:540`](../ui/dr-ui/src/library_ui.rs#L540), [`ui/dr-ui/src/library_ui.rs:597`](../ui/dr-ui/src/library_ui.rs#L597), [`ui/dr-ui/ui/app.slint:1180`](../ui/dr-ui/ui/app.slint#L1180), [`ui/dr-ui/ui/app.slint:869`](../ui/dr-ui/ui/app.slint#L869), [`ui/dr-ui/ui/library.slint:1045`](../ui/dr-ui/ui/library.slint#L1045), [`ui/dr-ui/ui/library.slint:644`](../ui/dr-ui/ui/library.slint#L644), [`ui/dr-ui/ui/library.slint:712`](../ui/dr-ui/ui/library.slint#L712) |
|
| 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:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1694`](../ui/dr-ui/src/lib.rs#L1694), [`ui/dr-ui/src/lib.rs:173`](../ui/dr-ui/src/lib.rs#L173), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326), [`ui/dr-ui/src/lib.rs:361`](../ui/dr-ui/src/lib.rs#L361), [`ui/dr-ui/src/lib.rs:388`](../ui/dr-ui/src/lib.rs#L388), [`ui/dr-ui/src/library_ui.rs:3093`](../ui/dr-ui/src/library_ui.rs#L3093), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:5206`](../ui/dr-ui/src/library_ui.rs#L5206), [`ui/dr-ui/src/library_ui.rs:5283`](../ui/dr-ui/src/library_ui.rs#L5283), [`ui/dr-ui/src/library_ui.rs:5295`](../ui/dr-ui/src/library_ui.rs#L5295), [`ui/dr-ui/src/library_ui.rs:540`](../ui/dr-ui/src/library_ui.rs#L540), [`ui/dr-ui/src/library_ui.rs:597`](../ui/dr-ui/src/library_ui.rs#L597), [`ui/dr-ui/ui/app.slint:1243`](../ui/dr-ui/ui/app.slint#L1243), [`ui/dr-ui/ui/app.slint:932`](../ui/dr-ui/ui/app.slint#L932), [`ui/dr-ui/ui/library.slint:1085`](../ui/dr-ui/ui/library.slint#L1085), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) |
|
||||||
| FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`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:412`](../core/dr-decode/src/lib.rs#L412), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:415`](../core/dr-gpu/src/adjust.rs#L415), [`ui/dr-ui/src/develop.rs:1437`](../ui/dr-ui/src/develop.rs#L1437), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324) |
|
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:1861`](../ui/dr-ui/src/develop.rs#L1861), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326) |
|
||||||
| 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-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:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:2929`](../ui/dr-ui/src/library_ui.rs#L2929), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:388`](../ui/dr-ui/src/lib.rs#L388), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:3093`](../ui/dr-ui/src/library_ui.rs#L3093), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`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:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.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:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.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) |
|
| 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) |
|
||||||
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`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_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`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_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
||||||
| 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:155`](../core/dr-sync/src/lib.rs#L155), [`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-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:155`](../core/dr-sync/src/lib.rs#L155), [`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) |
|
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) |
|
||||||
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
| 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:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`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:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1436`](../ui/dr-ui/src/lib.rs#L1436), [`ui/dr-ui/src/lib.rs:2131`](../ui/dr-ui/src/lib.rs#L2131), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:1118`](../ui/dr-ui/src/library_ui.rs#L1118), [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173), [`ui/dr-ui/src/library_ui.rs:1291`](../ui/dr-ui/src/library_ui.rs#L1291), [`ui/dr-ui/src/library_ui.rs:1410`](../ui/dr-ui/src/library_ui.rs#L1410), [`ui/dr-ui/src/library_ui.rs:1888`](../ui/dr-ui/src/library_ui.rs#L1888), [`ui/dr-ui/src/library_ui.rs:206`](../ui/dr-ui/src/library_ui.rs#L206), [`ui/dr-ui/src/library_ui.rs:217`](../ui/dr-ui/src/library_ui.rs#L217), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:237`](../ui/dr-ui/src/library_ui.rs#L237), [`ui/dr-ui/src/library_ui.rs:246`](../ui/dr-ui/src/library_ui.rs#L246), [`ui/dr-ui/src/library_ui.rs:311`](../ui/dr-ui/src/library_ui.rs#L311), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:363`](../ui/dr-ui/src/library_ui.rs#L363), [`ui/dr-ui/src/library_ui.rs:426`](../ui/dr-ui/src/library_ui.rs#L426), [`ui/dr-ui/src/library_ui.rs:4284`](../ui/dr-ui/src/library_ui.rs#L4284), [`ui/dr-ui/src/library_ui.rs:4302`](../ui/dr-ui/src/library_ui.rs#L4302), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`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:2026`](../ui/dr-ui/ui/app.slint#L2026), [`ui/dr-ui/ui/app.slint:564`](../ui/dr-ui/ui/app.slint#L564), [`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:758`](../ui/dr-ui/ui/library.slint#L758) |
|
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:738`](../core/dr-catalog/src/schema.rs#L738), [`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:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1454`](../ui/dr-ui/src/lib.rs#L1454), [`ui/dr-ui/src/lib.rs:2194`](../ui/dr-ui/src/lib.rs#L2194), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:1118`](../ui/dr-ui/src/library_ui.rs#L1118), [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173), [`ui/dr-ui/src/library_ui.rs:1291`](../ui/dr-ui/src/library_ui.rs#L1291), [`ui/dr-ui/src/library_ui.rs:1410`](../ui/dr-ui/src/library_ui.rs#L1410), [`ui/dr-ui/src/library_ui.rs:1888`](../ui/dr-ui/src/library_ui.rs#L1888), [`ui/dr-ui/src/library_ui.rs:206`](../ui/dr-ui/src/library_ui.rs#L206), [`ui/dr-ui/src/library_ui.rs:217`](../ui/dr-ui/src/library_ui.rs#L217), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:237`](../ui/dr-ui/src/library_ui.rs#L237), [`ui/dr-ui/src/library_ui.rs:246`](../ui/dr-ui/src/library_ui.rs#L246), [`ui/dr-ui/src/library_ui.rs:311`](../ui/dr-ui/src/library_ui.rs#L311), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:363`](../ui/dr-ui/src/library_ui.rs#L363), [`ui/dr-ui/src/library_ui.rs:426`](../ui/dr-ui/src/library_ui.rs#L426), [`ui/dr-ui/src/library_ui.rs:4482`](../ui/dr-ui/src/library_ui.rs#L4482), [`ui/dr-ui/src/library_ui.rs:4500`](../ui/dr-ui/src/library_ui.rs#L4500), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`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:2247`](../ui/dr-ui/ui/app.slint#L2247), [`ui/dr-ui/ui/app.slint:581`](../ui/dr-ui/ui/app.slint#L581), [`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:798`](../ui/dr-ui/ui/library.slint#L798) |
|
||||||
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173) |
|
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173) |
|
||||||
| 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:115`](../core/dr-types/src/lib.rs#L115), [`core/dr-types/src/lib.rs:197`](../core/dr-types/src/lib.rs#L197), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`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-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:115`](../core/dr-types/src/lib.rs#L115), [`core/dr-types/src/lib.rs:197`](../core/dr-types/src/lib.rs#L197), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`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-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) |
|
| FR-NC-7 | [`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) |
|
||||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378) |
|
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378) |
|
||||||
| FR-NC-9 | [`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-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/src/sidecar.rs:301`](../core/dr-pipeline/src/sidecar.rs#L301), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) |
|
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/src/sidecar.rs:303`](../core/dr-pipeline/src/sidecar.rs#L303), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) |
|
||||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) |
|
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) |
|
||||||
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
| 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), [`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), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||||
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:201`](../core/dr-decode/src/lib.rs#L201), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) |
|
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:222`](../core/dr-decode/src/lib.rs#L222), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) |
|
||||||
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:412`](../core/dr-decode/src/lib.rs#L412), [`core/dr-decode/src/lib.rs:97`](../core/dr-decode/src/lib.rs#L97), [`core/dr-decode/src/locate.rs:1045`](../core/dr-decode/src/locate.rs#L1045) |
|
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:118`](../core/dr-decode/src/lib.rs#L118), [`core/dr-decode/src/lib.rs:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-decode/src/locate.rs:1045`](../core/dr-decode/src/locate.rs#L1045) |
|
||||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:172`](../ui/dr-ui/src/lib.rs#L172) |
|
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:173`](../ui/dr-ui/src/lib.rs#L173) |
|
||||||
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:125`](../core/dr-decode/src/lib.rs#L125), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:573`](../core/dr-gpu/src/demosaic.rs#L573), [`core/dr-gpu/src/demosaic.rs:652`](../core/dr-gpu/src/demosaic.rs#L652), [`core/dr-gpu/src/demosaic.rs:776`](../core/dr-gpu/src/demosaic.rs#L776) |
|
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:146`](../core/dr-decode/src/lib.rs#L146), [`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:2213`](../ui/dr-ui/src/lib.rs#L2213), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/ui/library.slint:804`](../ui/dr-ui/ui/library.slint#L804) |
|
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2276`](../ui/dr-ui/src/lib.rs#L2276), [`ui/dr-ui/src/lib.rs:65`](../ui/dr-ui/src/lib.rs#L65), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/ui/library.slint:844`](../ui/dr-ui/ui/library.slint#L844) |
|
||||||
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/ui/app.slint:575`](../ui/dr-ui/ui/app.slint#L575), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/library.slint:1784`](../ui/dr-ui/ui/library.slint#L1784), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:947`](../ui/dr-ui/ui/library.slint#L947), [`ui/dr-ui/ui/library.slint:954`](../ui/dr-ui/ui/library.slint#L954), [`ui/dr-ui/ui/library.slint:960`](../ui/dr-ui/ui/library.slint#L960) |
|
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:65`](../ui/dr-ui/src/lib.rs#L65), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/ui/app.slint:611`](../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/app.slint:618`](../ui/dr-ui/ui/app.slint#L618), [`ui/dr-ui/ui/library.slint:1000`](../ui/dr-ui/ui/library.slint#L1000), [`ui/dr-ui/ui/library.slint:1868`](../ui/dr-ui/ui/library.slint#L1868), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:987`](../ui/dr-ui/ui/library.slint#L987), [`ui/dr-ui/ui/library.slint:994`](../ui/dr-ui/ui/library.slint#L994) |
|
||||||
| FR-UI-3 | [`ui/dr-ui/src/library_ui.rs:3590`](../ui/dr-ui/src/library_ui.rs#L3590), [`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) |
|
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1449`](../ui/dr-ui/src/develop.rs#L1449), [`ui/dr-ui/src/library_ui.rs:3754`](../ui/dr-ui/src/library_ui.rs#L3754), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:792`](../ui/dr-ui/src/masks_ui.rs#L792), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772), [`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) |
|
||||||
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3590`](../ui/dr-ui/src/library_ui.rs#L3590), [`ui/dr-ui/src/library_ui.rs:3635`](../ui/dr-ui/src/library_ui.rs#L3635), [`ui/dr-ui/src/library_ui.rs:3738`](../ui/dr-ui/src/library_ui.rs#L3738), [`ui/dr-ui/src/library_ui.rs:3766`](../ui/dr-ui/src/library_ui.rs#L3766), [`ui/dr-ui/src/library_ui.rs:4302`](../ui/dr-ui/src/library_ui.rs#L4302), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/ui/app.slint:1401`](../ui/dr-ui/ui/app.slint#L1401), [`ui/dr-ui/ui/app.slint:570`](../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/library.slint:1784`](../ui/dr-ui/ui/library.slint#L1784), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:687`](../ui/dr-ui/ui/library.slint#L687), [`ui/dr-ui/ui/library.slint:947`](../ui/dr-ui/ui/library.slint#L947), [`ui/dr-ui/ui/library.slint:954`](../ui/dr-ui/ui/library.slint#L954), [`ui/dr-ui/ui/library.slint:960`](../ui/dr-ui/ui/library.slint#L960) |
|
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3754`](../ui/dr-ui/src/library_ui.rs#L3754), [`ui/dr-ui/src/library_ui.rs:3799`](../ui/dr-ui/src/library_ui.rs#L3799), [`ui/dr-ui/src/library_ui.rs:3902`](../ui/dr-ui/src/library_ui.rs#L3902), [`ui/dr-ui/src/library_ui.rs:3930`](../ui/dr-ui/src/library_ui.rs#L3930), [`ui/dr-ui/src/library_ui.rs:4500`](../ui/dr-ui/src/library_ui.rs#L4500), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/ui/app.slint:1468`](../ui/dr-ui/ui/app.slint#L1468), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/app.slint:618`](../ui/dr-ui/ui/app.slint#L618), [`ui/dr-ui/ui/library.slint:1000`](../ui/dr-ui/ui/library.slint#L1000), [`ui/dr-ui/ui/library.slint:1868`](../ui/dr-ui/ui/library.slint#L1868), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:716`](../ui/dr-ui/ui/library.slint#L716), [`ui/dr-ui/ui/library.slint:987`](../ui/dr-ui/ui/library.slint#L987), [`ui/dr-ui/ui/library.slint:994`](../ui/dr-ui/ui/library.slint#L994) |
|
||||||
| 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:2247`](../ui/dr-ui/src/lib.rs#L2247), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| 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:1921`](../ui/dr-ui/src/lib.rs#L1921), [`ui/dr-ui/src/lib.rs:2310`](../ui/dr-ui/src/lib.rs#L2310), [`ui/dr-ui/src/lib.rs:2495`](../ui/dr-ui/src/lib.rs#L2495), [`ui/dr-ui/src/masks_ui.rs:747`](../ui/dr-ui/src/masks_ui.rs#L747), [`ui/dr-ui/ui/app.slint:325`](../ui/dr-ui/ui/app.slint#L325), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||||
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257) |
|
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) |
|
||||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||||
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1490`](../ui/dr-ui/src/export.rs#L1490), [`ui/dr-ui/src/export.rs:1516`](../ui/dr-ui/src/export.rs#L1516), [`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:1710`](../ui/dr-ui/src/lib.rs#L1710), [`ui/dr-ui/ui/app.slint:880`](../ui/dr-ui/ui/app.slint#L880), [`ui/dr-ui/ui/library.slint:712`](../ui/dr-ui/ui/library.slint#L712) |
|
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1490`](../ui/dr-ui/src/export.rs#L1490), [`ui/dr-ui/src/export.rs:1516`](../ui/dr-ui/src/export.rs#L1516), [`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:1728`](../ui/dr-ui/src/lib.rs#L1728), [`ui/dr-ui/ui/app.slint:943`](../ui/dr-ui/ui/app.slint#L943), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) |
|
||||||
| 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), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
|
| 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), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
|
||||||
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
|
| 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-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) |
|
||||||
@@ -108,23 +111,22 @@ _None._
|
|||||||
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298) |
|
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298) |
|
||||||
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) |
|
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) |
|
||||||
| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
|
| 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/schema.rs:1`](../core/dr-catalog/src/schema.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-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-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||||
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56) |
|
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57) |
|
||||||
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`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) |
|
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`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) |
|
||||||
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#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) |
|
| 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:211`](../core/dr-gpu/src/lib.rs#L211) |
|
| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
||||||
|
|
||||||
## Not yet tagged
|
## Not yet tagged
|
||||||
|
|
||||||
92 of 175 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
89 of 175 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>
|
<details><summary>Show untagged requirements</summary>
|
||||||
|
|
||||||
- FR-CAT-10
|
- FR-CAT-10
|
||||||
- FR-CAT-13
|
|
||||||
- FR-CAT-14
|
- FR-CAT-14
|
||||||
- FR-CULL-10
|
- FR-CULL-10
|
||||||
- FR-CULL-11
|
- FR-CULL-11
|
||||||
@@ -136,11 +138,9 @@ _None._
|
|||||||
- FR-CULL-8
|
- FR-CULL-8
|
||||||
- FR-CULL-9
|
- FR-CULL-9
|
||||||
- FR-DEV-1
|
- FR-DEV-1
|
||||||
- FR-DEV-2
|
|
||||||
- FR-DEV-3f
|
- FR-DEV-3f
|
||||||
- FR-DEV-3g
|
- FR-DEV-3g
|
||||||
- FR-DEV-7
|
- FR-DEV-7
|
||||||
- FR-DEV-8
|
|
||||||
- FR-DSP-2
|
- FR-DSP-2
|
||||||
- FR-DSP-3
|
- FR-DSP-3
|
||||||
- FR-DSP-4
|
- FR-DSP-4
|
||||||
|
|||||||
+352
-46
@@ -273,6 +273,20 @@ pub struct DevelopSession {
|
|||||||
/// attributes leaves it at — the tabs are the interface's idea, not the
|
/// attributes leaves it at — the tabs are the interface's idea, not the
|
||||||
/// core's, and nothing breaks without them (ARCH §4.3a).
|
/// core's, and nothing breaks without them (ARCH §4.3a).
|
||||||
active_tab: Option<dr_pipeline::Attribute>,
|
active_tab: Option<dr_pipeline::Attribute>,
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Which of the curve widget's subjects the panel is plotting.
|
||||||
|
///
|
||||||
|
/// The tone curve is four curves — one over tone and one per colour
|
||||||
|
/// channel — and one square plot draws one of them at a time. The index
|
||||||
|
/// is into the subjects the operation's parameters are faceted on, in the
|
||||||
|
/// order it declares them, so nothing here knows that "red" exists.
|
||||||
|
///
|
||||||
|
/// **Interface state, not part of the edit.** It changes no pixel, so it
|
||||||
|
/// is not a parameter, it is not in the graph, it is not in the sidecar
|
||||||
|
/// and it is not on the undo stack — the same standing as which tab is
|
||||||
|
/// open. One value rather than one per operation, for the same reason
|
||||||
|
/// `curve_samples` is one polyline: the panel draws one curve.
|
||||||
|
curve_channel: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DevelopSession {
|
impl DevelopSession {
|
||||||
@@ -339,6 +353,7 @@ impl DevelopSession {
|
|||||||
active_mask: None,
|
active_mask: None,
|
||||||
show_overlay: false,
|
show_overlay: false,
|
||||||
active_tab: None,
|
active_tab: None,
|
||||||
|
curve_channel: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,8 +364,12 @@ impl DevelopSession {
|
|||||||
pub fn rows(&self) -> Vec<ParamRow> {
|
pub fn rows(&self) -> Vec<ParamRow> {
|
||||||
let caps = self.scoped_capabilities();
|
let caps = self.scoped_capabilities();
|
||||||
match self.active_tab {
|
match self.active_tab {
|
||||||
Some(attribute) => rows_filtered(&caps, |op| op.attributes.contains(&attribute)),
|
Some(attribute) => rows_filtered(
|
||||||
None => rows_from(&caps),
|
&caps,
|
||||||
|
|op| op.attributes.contains(&attribute),
|
||||||
|
self.curve_channel,
|
||||||
|
),
|
||||||
|
None => rows_filtered(&caps, |_| true, self.curve_channel),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +403,10 @@ impl DevelopSession {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|a| *a != Attribute::Geometry)
|
.filter(|a| *a != Attribute::Geometry)
|
||||||
.filter(|a| {
|
.filter(|a| {
|
||||||
caps.iter()
|
caps.iter().any(|c| {
|
||||||
.any(|c| c.attributes.contains(a) && !rows_filtered(&caps, |o| o.attributes.contains(a)).is_empty())
|
c.attributes.contains(a)
|
||||||
|
&& !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.map(|a| (a, crate::labels::resolve(a.label().0)))
|
.map(|a| (a, crate::labels::resolve(a.label().0)))
|
||||||
.collect()
|
.collect()
|
||||||
@@ -494,8 +515,13 @@ pub(crate) fn supported(widget: WidgetKind) -> bool {
|
|||||||
/// FR-DEV-3c acceptance test asks for — an operation the frontend has never
|
/// FR-DEV-3c acceptance test asks for — an operation the frontend has never
|
||||||
/// heard of appearing in a generated panel — and it cannot be asserted at all
|
/// heard of appearing in a generated panel — and it cannot be asserted at all
|
||||||
/// if generating a row requires a device.
|
/// if generating a row requires a device.
|
||||||
|
///
|
||||||
|
/// `#[cfg(test)]` since the panel began passing the selected curve down: the
|
||||||
|
/// session always has one to pass, and a wrapper that quietly picked the first
|
||||||
|
/// would be a second answer to a question the session already answers.
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
||||||
rows_filtered(caps, |_| true)
|
rows_filtered(caps, |_| true, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The panel model for the capabilities `keep` accepts.
|
/// The panel model for the capabilities `keep` accepts.
|
||||||
@@ -508,9 +534,16 @@ pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
|||||||
///
|
///
|
||||||
/// Getting that backwards is how a slider ends up driving a different
|
/// Getting that backwards is how a slider ends up driving a different
|
||||||
/// operation, which is the kind of fault that looks like a rendering bug.
|
/// operation, which is the kind of fault that looks like a rendering bug.
|
||||||
|
///
|
||||||
|
/// `curve_channel` is which subject a multi-subject widget is showing — the
|
||||||
|
/// tone curve's four curves are one plot with a selector over it. It is passed
|
||||||
|
/// in rather than read from anywhere because this function is deliberately
|
||||||
|
/// free-standing: the descriptor-to-panel path has to be exercisable against a
|
||||||
|
/// hand-built capability list with no session behind it.
|
||||||
pub(crate) fn rows_filtered(
|
pub(crate) fn rows_filtered(
|
||||||
caps: &[OpCapability],
|
caps: &[OpCapability],
|
||||||
keep: impl Fn(&OpCapability) -> bool,
|
keep: impl Fn(&OpCapability) -> bool,
|
||||||
|
curve_channel: usize,
|
||||||
) -> Vec<ParamRow> {
|
) -> Vec<ParamRow> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for (op_index, op) in caps.iter().enumerate() {
|
for (op_index, op) in caps.iter().enumerate() {
|
||||||
@@ -558,7 +591,9 @@ pub(crate) fn rows_filtered(
|
|||||||
// to the core stops this compiling until someone has decided,
|
// to the core stops this compiling until someone has decided,
|
||||||
// here, whether the panel draws it.
|
// here, whether the panel draws it.
|
||||||
let row = match widget {
|
let row = match widget {
|
||||||
WidgetKind::ToneCurve => curve_row(op_index, group_head, op, presentation),
|
WidgetKind::ToneCurve => {
|
||||||
|
curve_row(op_index, group_head, op, presentation, curve_channel)
|
||||||
|
}
|
||||||
// Canvas-hosted kinds returned above; the rest are not
|
// Canvas-hosted kinds returned above; the rest are not
|
||||||
// implemented and reached sliders via `choose`.
|
// implemented and reached sliders via `choose`.
|
||||||
WidgetKind::ColourWheel
|
WidgetKind::ColourWheel
|
||||||
@@ -671,8 +706,76 @@ pub(crate) fn rows_filtered(
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One run of a curve widget's parameters: the points of a single curve.
|
||||||
|
///
|
||||||
|
/// A widget may span several curves — the tone curve is one plot over a master
|
||||||
|
/// curve and three colour channels — and it says so the way the colour mixer
|
||||||
|
/// says it has twelve bands: by faceting each parameter with the *subject* it
|
||||||
|
/// acts on. Consecutive parameters sharing a subject are one curve.
|
||||||
|
struct CurveRun {
|
||||||
|
/// The subject's localisation key, or `None` where the widget's parameters
|
||||||
|
/// carry no facet at all and are therefore a single unnamed curve.
|
||||||
|
subject: Option<&'static str>,
|
||||||
|
/// Where this run's points begin in the operation's parameter list. What
|
||||||
|
/// a drag routes back through, so it must be a position in `op.params`
|
||||||
|
/// and not in the presentation's list.
|
||||||
|
base: usize,
|
||||||
|
/// How many coordinates it holds.
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3a
|
||||||
|
/// The curves a curve widget spans, in the order the operation declares them.
|
||||||
|
///
|
||||||
|
/// **This is the whole of the panel's knowledge of colour channels: none.** It
|
||||||
|
/// groups by whatever subject the parameters carry, so an operation offering a
|
||||||
|
/// master curve and three channels gets a four-way selector, one offering a
|
||||||
|
/// single unfaceted curve gets no selector at all, and one that grows a fifth
|
||||||
|
/// curve tomorrow needs no change here.
|
||||||
|
///
|
||||||
|
/// Returns `None` where the parameters do not look like point coordinates —
|
||||||
|
/// an odd count, a run that is not contiguous in the capability list — in
|
||||||
|
/// which case the caller falls back to sliders rather than drawing a widget
|
||||||
|
/// over a layout it has guessed at.
|
||||||
|
fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option<Vec<CurveRun>> {
|
||||||
|
// Points are x/y pairs, so an odd count means the operation and this code
|
||||||
|
// disagree about the layout.
|
||||||
|
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
||||||
|
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut runs: Vec<CurveRun> = Vec::new();
|
||||||
|
for id in presentation.params {
|
||||||
|
// The widget addresses points by offset from the first of its run, so
|
||||||
|
// a run has to be contiguous in the capability list.
|
||||||
|
let at = op.params.iter().position(|p| p.id == *id)?;
|
||||||
|
let subject = op.params[at].facet.as_ref().map(|f| f.subject.0);
|
||||||
|
|
||||||
|
match runs.last_mut() {
|
||||||
|
Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1,
|
||||||
|
_ => runs.push(CurveRun {
|
||||||
|
subject,
|
||||||
|
base: at,
|
||||||
|
len: 1,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if runs.iter().any(|r| !r.len.is_multiple_of(2)) {
|
||||||
|
log::warn!("{}: a curve's points are not contiguous", op.id);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(runs)
|
||||||
|
}
|
||||||
|
|
||||||
/// One row standing for a whole curve.
|
/// One row standing for a whole curve.
|
||||||
///
|
///
|
||||||
|
/// `channel` picks which of the widget's curves is plotted; it is clamped
|
||||||
|
/// rather than validated, because the selection is interface state that
|
||||||
|
/// outlives a change of photograph and the new image's operation may have
|
||||||
|
/// fewer curves than the old one's.
|
||||||
|
///
|
||||||
/// Returns `None` if the operation's parameters do not look like point
|
/// Returns `None` if the operation's parameters do not look like point
|
||||||
/// coordinates, in which case the caller falls back to sliders rather than
|
/// coordinates, in which case the caller falls back to sliders rather than
|
||||||
/// rendering a broken widget.
|
/// rendering a broken widget.
|
||||||
@@ -681,38 +784,21 @@ fn curve_row(
|
|||||||
group_head: usize,
|
group_head: usize,
|
||||||
op: &OpCapability,
|
op: &OpCapability,
|
||||||
presentation: &Presentation,
|
presentation: &Presentation,
|
||||||
|
channel: usize,
|
||||||
) -> Option<ParamRow> {
|
) -> Option<ParamRow> {
|
||||||
// Points are x/y pairs, so an odd count means the operation and this
|
let runs = curve_runs(op, presentation)?;
|
||||||
// code disagree about the layout.
|
let run = runs.get(channel.min(runs.len().saturating_sub(1)))?;
|
||||||
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
|
||||||
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The widget addresses points by offset from the first, so they must
|
let points: Vec<f32> = op.params[run.base..run.base + run.len]
|
||||||
// be contiguous in the capability list.
|
|
||||||
let base = op
|
|
||||||
.params
|
|
||||||
.iter()
|
.iter()
|
||||||
.position(|p| p.id == presentation.params[0])?;
|
|
||||||
for (i, id) in presentation.params.iter().enumerate() {
|
|
||||||
if op.params.get(base + i).map(|p| p.id) != Some(*id) {
|
|
||||||
log::warn!("{}: curve parameters are not contiguous", op.id);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let points: Vec<f32> = presentation
|
|
||||||
.params
|
|
||||||
.iter()
|
|
||||||
.filter_map(|id| op.params.iter().find(|p| p.id == *id))
|
|
||||||
.map(|p| p.value)
|
.map(|p| p.value)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(ParamRow {
|
Some(ParamRow {
|
||||||
op_index: op_index as i32,
|
op_index: op_index as i32,
|
||||||
// The first point parameter; the widget offsets from here.
|
// The first point parameter *of the curve on show*; the widget offsets
|
||||||
param_index: base as i32,
|
// from here, so switching curve is what re-points the drag.
|
||||||
|
param_index: run.base as i32,
|
||||||
op_label: labels::resolve(op.label.0).into(),
|
op_label: labels::resolve(op.label.0).into(),
|
||||||
param_label: String::new().into(),
|
param_label: String::new().into(),
|
||||||
// A widget spanning a whole operation is not a row in anyone's
|
// A widget spanning a whole operation is not a row in anyone's
|
||||||
@@ -733,21 +819,97 @@ fn curve_row(
|
|||||||
precision: 4,
|
precision: 4,
|
||||||
unit: String::new().into(),
|
unit: String::new().into(),
|
||||||
points: slint::ModelRc::new(slint::VecModel::from(points)),
|
points: slint::ModelRc::new(slint::VecModel::from(points)),
|
||||||
// A curve is not a choice between named alternatives.
|
// A curve is not a choice between named alternatives. The curves it
|
||||||
|
// can switch between are named on the panel rather than on the row —
|
||||||
|
// see `DevelopSession::curve_channels` for why they cannot ride here.
|
||||||
choices: no_choices(),
|
choices: no_choices(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DevelopSession {
|
impl DevelopSession {
|
||||||
/// The curve's shape, sampled for drawing.
|
/// TRACES: FR-DEV-3
|
||||||
|
/// The names of the curves the widget can switch between.
|
||||||
|
///
|
||||||
|
/// Empty where there is only one, which is also the answer for a frontend
|
||||||
|
/// with no curve at all: a selector over a single choice is a row of
|
||||||
|
/// nothing.
|
||||||
|
///
|
||||||
|
/// **Derived from the facets, so nothing here names a colour channel.**
|
||||||
|
/// The operation says its forty points are one control applied to four
|
||||||
|
/// subjects and publishes a localisation key for each; this resolves the
|
||||||
|
/// keys and hands over four words. An operation that grew a fifth curve
|
||||||
|
/// would appear here on its own.
|
||||||
|
///
|
||||||
|
/// A panel property rather than a field on the curve's `ParamRow`, and the
|
||||||
|
/// reason is Slint's: a row's models are compared by identity, so a fresh
|
||||||
|
/// list of names built on every parameter event would make the row look
|
||||||
|
/// changed every time, and rewriting a row rebuilds the repeater item
|
||||||
|
/// underneath it — destroying the `TouchArea` holding the drag in
|
||||||
|
/// progress. The same hazard `rows`'s in-place point update exists to
|
||||||
|
/// avoid. Nothing in this list is a drag target, so up here it is safe to
|
||||||
|
/// replace wholesale, exactly as [`Self::curve_samples`] is.
|
||||||
|
pub fn curve_channels(&self) -> Vec<String> {
|
||||||
|
for op in &self.scoped_capabilities() {
|
||||||
|
let Some(presentation) = &op.presentation else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if presentation.choose(supported) != Some(WidgetKind::ToneCurve) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(runs) = curve_runs(op, presentation) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if runs.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return runs
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.subject.map(labels::resolve).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which curve the widget is plotting, as an index into
|
||||||
|
/// [`Self::curve_channels`].
|
||||||
|
pub fn curve_channel(&self) -> i32 {
|
||||||
|
self.curve_channel as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plot a different one of the operation's curves.
|
||||||
|
///
|
||||||
|
/// Out-of-range indices are ignored rather than clamped: the only thing
|
||||||
|
/// that can send one is a stale interface event, and quietly moving the
|
||||||
|
/// selection somewhere the user did not point is worse than doing nothing.
|
||||||
|
pub fn set_curve_channel(&mut self, index: i32) {
|
||||||
|
let Ok(index) = usize::try_from(index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if index < self.curve_channels().len() {
|
||||||
|
self.curve_channel = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plotted curve's shape, sampled for drawing.
|
||||||
///
|
///
|
||||||
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
|
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
|
||||||
/// is the line the shader applies. The alternative — reading the curve
|
/// is the line the shader applies. The alternative — reading the curve
|
||||||
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
|
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
|
||||||
/// polyline.
|
/// polyline.
|
||||||
|
///
|
||||||
|
/// The line drawn is the *selected* curve's own shape, not the composition
|
||||||
|
/// of it with the master. Two curves overlaid on one grid is a plot of two
|
||||||
|
/// things, and the one being dragged has to be the one whose points are
|
||||||
|
/// under the pointer.
|
||||||
pub fn curve_samples(&self) -> Vec<f32> {
|
pub fn curve_samples(&self) -> Vec<f32> {
|
||||||
const SAMPLES: usize = 96;
|
const SAMPLES: usize = 96;
|
||||||
|
|
||||||
|
// The selection is an index over the subjects the panel found, which
|
||||||
|
// for this operation is its channel order. Clamped rather than
|
||||||
|
// trusted: a selection made on one photograph outlives the change to
|
||||||
|
// the next.
|
||||||
|
let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)];
|
||||||
|
|
||||||
let mut xs = [0.0f32; curve::POINTS];
|
let mut xs = [0.0f32; curve::POINTS];
|
||||||
let mut ys = [0.0f32; curve::POINTS];
|
let mut ys = [0.0f32; curve::POINTS];
|
||||||
let mut found = false;
|
let mut found = false;
|
||||||
@@ -757,16 +919,18 @@ impl DevelopSession {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
found = true;
|
found = true;
|
||||||
for (i, p) in cap.params.iter().enumerate() {
|
// By id rather than by position, so which curve is plotted is
|
||||||
let point = i / 2;
|
// decided by naming it and not by arithmetic over the parameter
|
||||||
if point >= curve::POINTS {
|
// list.
|
||||||
break;
|
let value = |id| {
|
||||||
}
|
cap.params
|
||||||
if i % 2 == 0 {
|
.iter()
|
||||||
xs[point] = p.value;
|
.find(|p| p.id == id)
|
||||||
} else {
|
.map_or(0.0, |p| p.value)
|
||||||
ys[point] = p.value;
|
};
|
||||||
}
|
for i in 0..curve::POINTS {
|
||||||
|
xs[i] = value(curve::coordinate(channel, i, curve::Axis::X));
|
||||||
|
ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
@@ -3460,9 +3624,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_curve_collapses_to_a_single_row() {
|
fn the_curve_collapses_to_a_single_row() {
|
||||||
// Ten point parameters must appear as one curve control, not ten
|
// Every point parameter — all four curves' worth — must appear as one
|
||||||
// sliders — otherwise the widget and the sliders both render and the
|
// curve control, not as forty sliders. Otherwise the widget and the
|
||||||
// panel shows the same values twice.
|
// sliders both render and the panel shows the same values twice.
|
||||||
let graph = EditGraph::default_chain();
|
let graph = EditGraph::default_chain();
|
||||||
let curve_cap = graph
|
let curve_cap = graph
|
||||||
.capabilities()
|
.capabilities()
|
||||||
@@ -3470,7 +3634,11 @@ mod tests {
|
|||||||
.find(|c| c.id == curve::ID)
|
.find(|c| c.id == curve::ID)
|
||||||
.expect("the chain includes a tone curve");
|
.expect("the chain includes a tone curve");
|
||||||
|
|
||||||
assert_eq!(curve_cap.params.len(), curve::POINTS * 2);
|
assert_eq!(
|
||||||
|
curve_cap.params.len(),
|
||||||
|
curve::CHANNELS * curve::POINTS * 2,
|
||||||
|
"a master curve and one per colour channel"
|
||||||
|
);
|
||||||
let presentation = curve_cap
|
let presentation = curve_cap
|
||||||
.presentation
|
.presentation
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -3510,6 +3678,144 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The panel's whole knowledge of colour channels, asserted to be none.
|
||||||
|
///
|
||||||
|
/// It groups the widget's parameters by the subject the *operation* put on
|
||||||
|
/// them and finds four curves; nothing below says "red", and an operation
|
||||||
|
/// that grew a fifth curve would arrive here on its own.
|
||||||
|
#[test]
|
||||||
|
fn a_curve_widget_offers_one_run_per_subject() {
|
||||||
|
let graph = EditGraph::default_chain();
|
||||||
|
let cap = graph
|
||||||
|
.capabilities()
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.id == curve::ID)
|
||||||
|
.expect("tone curve present");
|
||||||
|
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||||||
|
|
||||||
|
let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation");
|
||||||
|
assert_eq!(runs.len(), curve::CHANNELS);
|
||||||
|
for (i, run) in runs.iter().enumerate() {
|
||||||
|
assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points");
|
||||||
|
assert_eq!(run.base, i * curve::POINTS * 2);
|
||||||
|
assert!(run.subject.is_some(), "run {i} is unnamed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switching_curve_repoints_the_row() {
|
||||||
|
use slint::Model as _;
|
||||||
|
|
||||||
|
// What a drag routes through. The row's `param_index` is the base of
|
||||||
|
// the curve *on show*, so picking a different one must move it — if it
|
||||||
|
// did not, dragging a point on the red curve would write to the
|
||||||
|
// master's.
|
||||||
|
let graph = EditGraph::default_chain();
|
||||||
|
let caps = graph.capabilities();
|
||||||
|
let curve_at = caps
|
||||||
|
.iter()
|
||||||
|
.position(|c| c.id == curve::ID)
|
||||||
|
.expect("tone curve present");
|
||||||
|
|
||||||
|
let mut bases = Vec::new();
|
||||||
|
for channel in 0..curve::CHANNELS {
|
||||||
|
let rows = rows_filtered(&caps, |_| true, channel);
|
||||||
|
let row = rows
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.op_index as usize == curve_at)
|
||||||
|
.expect("the curve has a row");
|
||||||
|
assert_eq!(row.kind, "curve");
|
||||||
|
assert_eq!(
|
||||||
|
row.points.row_count(),
|
||||||
|
curve::POINTS * 2,
|
||||||
|
"one curve's points, not all four curves'"
|
||||||
|
);
|
||||||
|
bases.push(row.param_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
bases,
|
||||||
|
(0..curve::CHANNELS)
|
||||||
|
.map(|i| (i * curve::POINTS * 2) as i32)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() {
|
||||||
|
// The selection outlives the photograph it was made on, and the next
|
||||||
|
// image's operation may offer fewer curves. Clamping keeps a plot on
|
||||||
|
// the grid; the alternative is a curve row that vanishes, which reads
|
||||||
|
// as the tone curve having disappeared from the panel.
|
||||||
|
let graph = EditGraph::default_chain();
|
||||||
|
let caps = graph.capabilities();
|
||||||
|
let rows = rows_filtered(&caps, |_| true, 99);
|
||||||
|
let row = rows
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.kind == "curve")
|
||||||
|
.expect("the curve still has a row");
|
||||||
|
assert_eq!(
|
||||||
|
row.param_index,
|
||||||
|
((curve::CHANNELS - 1) * curve::POINTS * 2) as i32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_operation_whose_points_are_unfaceted_is_one_curve() {
|
||||||
|
// A curve widget that spans a single unnamed curve — which is what
|
||||||
|
// this operation was before the channels arrived, and what any other
|
||||||
|
// node declaring a `tone_curve` widget over ten scalars would be.
|
||||||
|
// It must draw, and it must offer no choice.
|
||||||
|
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||||||
|
use slint::Model as _;
|
||||||
|
|
||||||
|
static IDS: [ParamId; 4] = [
|
||||||
|
ParamId("p0_x"),
|
||||||
|
ParamId("p0_y"),
|
||||||
|
ParamId("p1_x"),
|
||||||
|
ParamId("p1_y"),
|
||||||
|
];
|
||||||
|
let param = |id: ParamId| ParamCapability {
|
||||||
|
id,
|
||||||
|
label: LocalizedKey("param.point"),
|
||||||
|
kind: ParamKind::Scalar {
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.0,
|
||||||
|
scale: dr_pipeline::Scale::Linear,
|
||||||
|
unit: Unit::None,
|
||||||
|
precision: 4,
|
||||||
|
},
|
||||||
|
default: 0.0,
|
||||||
|
value: 0.0,
|
||||||
|
facet: None,
|
||||||
|
};
|
||||||
|
let plain = OpCapability {
|
||||||
|
id: OpId("invented_curve"),
|
||||||
|
label: LocalizedKey("op.invented_curve"),
|
||||||
|
active: false,
|
||||||
|
presentation: Some(Presentation {
|
||||||
|
widgets: &[WidgetKind::ToneCurve],
|
||||||
|
demand: WidgetDemand {
|
||||||
|
two_dimensional: true,
|
||||||
|
precise_pointing: true,
|
||||||
|
},
|
||||||
|
params: &IDS,
|
||||||
|
}),
|
||||||
|
params: IDS.iter().map(|id| param(*id)).collect(),
|
||||||
|
attributes: &[dr_pipeline::Attribute::Tone],
|
||||||
|
};
|
||||||
|
|
||||||
|
let presentation = plain.presentation.as_ref().expect("declares a widget");
|
||||||
|
let runs = curve_runs(&plain, presentation).expect("curve-shaped");
|
||||||
|
assert_eq!(runs.len(), 1, "one unnamed curve");
|
||||||
|
assert_eq!(runs[0].subject, None);
|
||||||
|
|
||||||
|
let rows = rows_from(&[plain]);
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].kind, "curve");
|
||||||
|
assert_eq!(rows[0].points.row_count(), IDS.len());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn curve_samples_start_on_the_diagonal() {
|
fn curve_samples_start_on_the_diagonal() {
|
||||||
// A fresh curve is the identity, so the drawn line must be the 45°
|
// A fresh curve is the identity, so the drawn line must be the 45°
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ pub fn resolve(key: &str) -> String {
|
|||||||
"param.channel.sat" => "Saturation".into(),
|
"param.channel.sat" => "Saturation".into(),
|
||||||
"param.channel.lum" => "Luminance".into(),
|
"param.channel.lum" => "Luminance".into(),
|
||||||
|
|
||||||
|
// The tone curve's four curves, which its points are *subject* to.
|
||||||
|
//
|
||||||
|
// Catalogued rather than derived because the master curve's key would
|
||||||
|
// otherwise read "Rgb": these are the terms of a four-way choice, and
|
||||||
|
// one of them miscapitalised is the one the eye goes to. The three
|
||||||
|
// colours would derive correctly and are written out beside it anyway,
|
||||||
|
// since a list where one entry is translated and three are guessed is
|
||||||
|
// the shape a half-finished translation takes.
|
||||||
|
"channel.rgb" => "RGB".into(),
|
||||||
|
"channel.red" => "Red".into(),
|
||||||
|
"channel.green" => "Green".into(),
|
||||||
|
"channel.blue" => "Blue".into(),
|
||||||
|
|
||||||
// The hue bands, which a faceted row is *subject* to.
|
// The hue bands, which a faceted row is *subject* to.
|
||||||
//
|
//
|
||||||
// Catalogued even where `derive` would produce the same word, because
|
// Catalogued even where `derive` would produce the same word, because
|
||||||
|
|||||||
@@ -583,8 +583,24 @@ pub(crate) fn sync_rows(
|
|||||||
// curve must be drawn whatever shape it is in.
|
// curve must be drawn whatever shape it is in.
|
||||||
rows.set_vec(current);
|
rows.set_vec(current);
|
||||||
curve_moved = true;
|
curve_moved = true;
|
||||||
|
|
||||||
|
// The curves the widget can switch between, named. They can only
|
||||||
|
// change with the operation set, which is what this branch means, so
|
||||||
|
// the walk that derives them is not on the parameter-event path.
|
||||||
|
let channels: Vec<slint::SharedString> = match session.borrow().as_ref() {
|
||||||
|
Some(s) => s.curve_channels().into_iter().map(Into::into).collect(),
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
window.set_curve_channels(slint::ModelRc::new(slint::VecModel::from(channels)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which curve is plotted, on every pass. Picking one that happens to be
|
||||||
|
// shaped like the last — two untouched curves are both the diagonal —
|
||||||
|
// moves no point, so this cannot ride on the resample below: the chips
|
||||||
|
// would go on highlighting the curve the user just navigated away from.
|
||||||
|
let channel = session.borrow().as_ref().map_or(0, |s| s.curve_channel());
|
||||||
|
window.set_curve_channel(channel);
|
||||||
|
|
||||||
if !curve_moved {
|
if !curve_moved {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1804,6 +1820,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
redraw(&w);
|
redraw(&w);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// Which of the curve's curves the plot is showing. **No redraw**, and
|
||||||
|
// that is the whole character of this control: it changes no
|
||||||
|
// parameter, so the photograph is already correct on screen and
|
||||||
|
// recomputing it would be a frame spent to produce the same pixels.
|
||||||
|
// For the same reason it records no history step — there is nothing
|
||||||
|
// to undo — and the sidecar never hears about it.
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.on_curve_channel_picked(move |index| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.set_curve_channel(index);
|
||||||
|
}
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- undo and redo (FR-DEV-5) ---------------------------------------
|
// ---- undo and redo (FR-DEV-5) ---------------------------------------
|
||||||
//
|
//
|
||||||
|
|||||||
+264
-1
@@ -22,7 +22,7 @@ use dr_types::FormatFilter;
|
|||||||
use slint::{ComponentHandle, Model as _};
|
use slint::{ComponentHandle, Model as _};
|
||||||
|
|
||||||
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
||||||
use crate::{AppWindow, LibraryCell, TimelineBar};
|
use crate::{AppWindow, KeywordRow, LibraryCell, TimelineBar};
|
||||||
|
|
||||||
/// Window size before the grid has reported its geometry.
|
/// Window size before the grid has reported its geometry.
|
||||||
///
|
///
|
||||||
@@ -2117,6 +2117,170 @@ fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
|
|||||||
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- keywords (FR-CAT-5, FR-CAT-6) ---------------------------------------
|
||||||
|
//
|
||||||
|
// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many
|
||||||
|
// join, what a rename does to the assignments. This part owns the *interaction*:
|
||||||
|
// which photographs the sheet is acting on, and keeping what it draws honest
|
||||||
|
// about what actually landed.
|
||||||
|
|
||||||
|
/// Redraw the keywording sheet against whatever is selected now.
|
||||||
|
///
|
||||||
|
/// Called when the sheet opens and after every assignment, rather than on every
|
||||||
|
/// selection change: the selection moves on each arrow key and the sheet is shut
|
||||||
|
/// for almost all of them, so computing coverage over a forty-image selection
|
||||||
|
/// on each one would be work nobody is looking at.
|
||||||
|
///
|
||||||
|
/// Re-read from the catalog rather than patched in place after a write. A word
|
||||||
|
/// applied to a selection that partly already had it moves from "3 of 12" to
|
||||||
|
/// "12 of 12", and a model updated by hand would have to reproduce the rule
|
||||||
|
/// that decides that — which is exactly the rule the catalog has just applied.
|
||||||
|
fn refresh_keywords(window: &AppWindow, ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) {
|
||||||
|
let borrow = ctl.catalog.borrow();
|
||||||
|
let Some(catalog) = borrow.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) {
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(e) => {
|
||||||
|
// The grid is entirely usable without the sheet, so this is logged
|
||||||
|
// rather than surfaced: a keyword read that failed must not put an
|
||||||
|
// error banner over a library the user is browsing.
|
||||||
|
log::debug!("reading keywords: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let model: Vec<KeywordRow> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| KeywordRow {
|
||||||
|
id: row.keyword.id.0 as i32,
|
||||||
|
name: row.keyword.name.into(),
|
||||||
|
coverage: match row.coverage {
|
||||||
|
dr_catalog::Coverage::None => 0,
|
||||||
|
dr_catalog::Coverage::Some => 1,
|
||||||
|
dr_catalog::Coverage::All => 2,
|
||||||
|
},
|
||||||
|
selected_count: row.selected_count as i32,
|
||||||
|
image_count: row.keyword.image_count as i32,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a keyword on the selection, or take it off.
|
||||||
|
///
|
||||||
|
/// # Why this does not write a sidecar
|
||||||
|
///
|
||||||
|
/// Every other judgement in this file — a star, a flag — is written to the
|
||||||
|
/// catalog and then queued to the image's sidecar, because the sidecar is what
|
||||||
|
/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in
|
||||||
|
/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and
|
||||||
|
/// `flag` and nothing else that is not an edit-graph parameter.
|
||||||
|
///
|
||||||
|
/// So a keyword is, for now, catalog state that reaches the user's other
|
||||||
|
/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than
|
||||||
|
/// through the sidecar. That is a real limitation and not a silent one: a
|
||||||
|
/// deleted catalog loses keywords where it would keep ratings, until the
|
||||||
|
/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same
|
||||||
|
/// queued write the stars have.
|
||||||
|
fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, assigning: bool) {
|
||||||
|
let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let images = coll.selected();
|
||||||
|
|
||||||
|
// The word as it will be *stored*, resolved before anything is written.
|
||||||
|
// The status line below quotes it back, and quoting what was typed would
|
||||||
|
// report a leading space the catalog is about to drop — leaving the user to
|
||||||
|
// wonder whether it mattered.
|
||||||
|
//
|
||||||
|
// This is also where a blank keyword is caught, which is why it happens
|
||||||
|
// before the selection check: "you typed nothing" is a better answer than
|
||||||
|
// "select an image first" to someone who pressed return on an empty field.
|
||||||
|
let word = match dr_catalog::keywords::normalise(word) {
|
||||||
|
Ok(word) => word,
|
||||||
|
Err(e) => {
|
||||||
|
// `BadName` carries text written to be read by the user rather than
|
||||||
|
// by a developer, so it is shown as it is.
|
||||||
|
window.set_library_error(format!("{e}").into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assigning with nothing selected still means something — it puts the word
|
||||||
|
// in the vocabulary, ready for the photographs it was typed for — so only
|
||||||
|
// the removal half needs a selection to act on.
|
||||||
|
if images.is_empty() && !assigning {
|
||||||
|
window.set_library_status("Select an image first".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcome = {
|
||||||
|
let borrow = ctl.catalog.borrow();
|
||||||
|
let Some(catalog) = borrow.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let conn = catalog.connection();
|
||||||
|
if assigning {
|
||||||
|
dr_catalog::keywords::assign(conn, &images, &word)
|
||||||
|
} else {
|
||||||
|
dr_catalog::keywords::unassign(conn, &images, &word)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let n = match outcome {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
window.set_library_error(format!("{e}").into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.set_library_error(slint::SharedString::new());
|
||||||
|
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
|
||||||
|
refresh_keywords(window, ctl, &images);
|
||||||
|
|
||||||
|
// A filtered grid may no longer hold what was just keyworded — taking
|
||||||
|
// "puffin" off an image while showing only puffins means it belongs
|
||||||
|
// elsewhere now. The same reasoning as a rating that falls below the star
|
||||||
|
// filter.
|
||||||
|
if !ctl.filter.borrow().is_unfiltered() {
|
||||||
|
load_window(window, ctl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the status line says about a keyword that just landed.
|
||||||
|
///
|
||||||
|
/// The honest count, not the requested one: "added to 3 of 12" is what
|
||||||
|
/// happened when nine of them already carried the word, and a message that
|
||||||
|
/// claimed twelve would be teaching the user that the counts are decorative.
|
||||||
|
fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String {
|
||||||
|
if selected == 0 {
|
||||||
|
return format!("Added “{word}” to the keyword list");
|
||||||
|
}
|
||||||
|
let verb = if assigning { "Added" } else { "Removed" };
|
||||||
|
let preposition = if assigning { "to" } else { "from" };
|
||||||
|
if changed == 0 {
|
||||||
|
return if assigning {
|
||||||
|
format!("Every selected photograph already had “{word}”")
|
||||||
|
} else {
|
||||||
|
format!("None of the selected photographs had “{word}”")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if changed == selected {
|
||||||
|
let what = if selected == 1 {
|
||||||
|
"1 photograph".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{selected} photographs")
|
||||||
|
};
|
||||||
|
return format!("{verb} “{word}” {preposition} {what}");
|
||||||
|
}
|
||||||
|
format!("{verb} “{word}” {preposition} {changed} of {selected}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply a judgement to a set of images: catalog first, then sidecars.
|
/// Apply a judgement to a set of images: catalog first, then sidecars.
|
||||||
///
|
///
|
||||||
/// # Order matters
|
/// # Order matters
|
||||||
@@ -4152,6 +4316,40 @@ pub fn wire<F>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------
|
||||||
|
//
|
||||||
|
// Three callbacks and no state of their own: the sheet's open/shut is local
|
||||||
|
// to the `.slint` file, and what a keyword applies to is the grid selection
|
||||||
|
// the collections controller already owns. A second copy of either here is
|
||||||
|
// a second thing that can disagree with the first.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let coll_for_keywords = coll_ctl.clone();
|
||||||
|
window.on_library_keywords_opened(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
refresh_keywords(&w, &ctl, &coll_for_keywords.selected());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_library_assign_keyword(move |word| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
apply_keyword(&w, &ctl, word.as_str(), true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_library_unassign_keyword(move |word| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
apply_keyword(&w, &ctl, word.as_str(), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- the filter bar ---------------------------------------------------
|
// --- the filter bar ---------------------------------------------------
|
||||||
//
|
//
|
||||||
// Each of these narrows what the grid *queries*, so all three reset the
|
// Each of these narrows what the grid *queries*, so all three reset the
|
||||||
@@ -5017,6 +5215,71 @@ mod tests {
|
|||||||
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
|
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- what the status line says about a keyword (FR-CAT-5) -------------
|
||||||
|
//
|
||||||
|
// Split out from the callback for the same reason `decide_drop` is: the
|
||||||
|
// sheet cannot be driven from a test, and this is the part that can
|
||||||
|
// actually mislead someone.
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
#[test]
|
||||||
|
fn a_partly_applied_keyword_reports_the_honest_count() {
|
||||||
|
// Nine of the twelve already had it. Claiming twelve is how a user
|
||||||
|
// learns that the counts are decorative.
|
||||||
|
assert_eq!(
|
||||||
|
keyword_summary("puffin", 3, 12, true),
|
||||||
|
"Added “puffin” to 3 of 12"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
#[test]
|
||||||
|
fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() {
|
||||||
|
assert_eq!(
|
||||||
|
keyword_summary("puffin", 0, 12, true),
|
||||||
|
"Every selected photograph already had “puffin”"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
keyword_summary("puffin", 0, 12, false),
|
||||||
|
"None of the selected photographs had “puffin”"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
#[test]
|
||||||
|
fn one_photograph_is_singular() {
|
||||||
|
// "Added to 1 photographs" is the kind of small wrongness that makes
|
||||||
|
// the rest of the interface look unfinished.
|
||||||
|
assert_eq!(
|
||||||
|
keyword_summary("puffin", 1, 1, true),
|
||||||
|
"Added “puffin” to 1 photograph"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
keyword_summary("puffin", 2, 2, true),
|
||||||
|
"Added “puffin” to 2 photographs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
#[test]
|
||||||
|
fn removing_a_keyword_reads_as_removal() {
|
||||||
|
assert_eq!(
|
||||||
|
keyword_summary("blurry", 4, 4, false),
|
||||||
|
"Removed “blurry” from 4 photographs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
#[test]
|
||||||
|
fn typing_a_word_with_nothing_selected_says_what_it_did_do() {
|
||||||
|
// It builds the vocabulary, which is a legitimate thing to do ahead of
|
||||||
|
// a shoot — so it must not report itself as having keyworded nothing.
|
||||||
|
assert_eq!(
|
||||||
|
keyword_summary("puffin", 0, 0, true),
|
||||||
|
"Added “puffin” to the keyword list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-EXP-7
|
/// TRACES: FR-EXP-7
|
||||||
#[test]
|
#[test]
|
||||||
fn a_selection_outside_the_loaded_window_still_resolves() {
|
fn a_selection_outside_the_loaded_window_still_resolves() {
|
||||||
|
|||||||
+59
-13
@@ -365,10 +365,17 @@ component ParamControl inherits Rectangle {
|
|||||||
in property <ParamRow> data;
|
in property <ParamRow> data;
|
||||||
/// Curve rows only; ignored by every other kind.
|
/// Curve rows only; ignored by every other kind.
|
||||||
in property <[float]> curve-samples;
|
in property <[float]> curve-samples;
|
||||||
|
/// The curves this widget can plot, named. Empty, or one entry, where
|
||||||
|
/// there is nothing to choose between — see `curve-channel-picked`.
|
||||||
|
in property <[string]> curve-channels;
|
||||||
|
/// Which of `curve-channels` is on the grid.
|
||||||
|
in property <int> curve-channel;
|
||||||
|
|
||||||
callback param-changed(int, int, float);
|
callback param-changed(int, int, float);
|
||||||
callback param-reset(int, int);
|
callback param-reset(int, int);
|
||||||
callback curve-reset(int);
|
callback curve-reset(int);
|
||||||
|
/// Plot a different one of the operation's curves.
|
||||||
|
callback curve-channel-picked(int);
|
||||||
callback drag-changed(bool);
|
callback drag-changed(bool);
|
||||||
|
|
||||||
height: layout.preferred-height;
|
height: layout.preferred-height;
|
||||||
@@ -420,20 +427,49 @@ component ParamControl inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if root.data.kind == "curve": CurveEditor {
|
// A curve, and — where the operation offers more than one — the choice
|
||||||
points: root.data.points;
|
// of which curve is on the grid.
|
||||||
samples: root.curve-samples;
|
//
|
||||||
drag-changed(on) => { root.drag-changed(on); }
|
// One plot rather than four stacked ones: the curves are read against
|
||||||
// A point carries two parameters, so the parameter index is the
|
// the diagonal and against each other, which needs the grid large, and
|
||||||
// row's base plus the point's offset. This component still knows
|
// four grids at a quarter of the width would each be too small to
|
||||||
// nothing about which operation it belongs to.
|
// place a point in. So the selector switches the subject of a single
|
||||||
point-moved(point, x, y) => {
|
// plot, and the names in it come from the core — this file does not
|
||||||
root.param-changed(
|
// know that a colour channel is what is being chosen between, only
|
||||||
root.data.op-index, root.data.param-index + point * 2, x);
|
// that the widget said it spans several named things.
|
||||||
root.param-changed(
|
if root.data.kind == "curve": VerticalLayout {
|
||||||
root.data.op-index, root.data.param-index + point * 2 + 1, y);
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
if root.curve-channels.length > 1: Segmented {
|
||||||
|
// The operation's own name, which nothing else in this row
|
||||||
|
// draws: a curve row heads no group, so without this the plot
|
||||||
|
// would sit in the panel unlabelled.
|
||||||
|
label: root.data.op-label;
|
||||||
|
options: root.curve-channels;
|
||||||
|
selected: root.curve-channel;
|
||||||
|
picked(i) => { root.curve-channel-picked(i); }
|
||||||
|
}
|
||||||
|
|
||||||
|
CurveEditor {
|
||||||
|
points: root.data.points;
|
||||||
|
samples: root.curve-samples;
|
||||||
|
drag-changed(on) => { root.drag-changed(on); }
|
||||||
|
// A point carries two parameters, so the parameter index is
|
||||||
|
// the row's base plus the point's offset. The base is the
|
||||||
|
// first point of the curve *on show*, so switching curve
|
||||||
|
// re-points the drag and this component still knows nothing
|
||||||
|
// about which operation — or which curve — it is drawing.
|
||||||
|
point-moved(point, x, y) => {
|
||||||
|
root.param-changed(
|
||||||
|
root.data.op-index, root.data.param-index + point * 2, x);
|
||||||
|
root.param-changed(
|
||||||
|
root.data.op-index, root.data.param-index + point * 2 + 1, y);
|
||||||
|
}
|
||||||
|
// Resetting a curve resets the operation, which is all four of
|
||||||
|
// them — a photographer who double-clicks to start again means
|
||||||
|
// the control, not the curve that happens to be on show.
|
||||||
|
reset => { root.curve-reset(root.data.op-index); }
|
||||||
}
|
}
|
||||||
reset => { root.curve-reset(root.data.op-index); }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -825,9 +861,16 @@ export component AdjustPanel inherits Rectangle {
|
|||||||
/// The tone curve's sampled shape, evaluated in Rust by the same spline
|
/// The tone curve's sampled shape, evaluated in Rust by the same spline
|
||||||
/// the shader runs so the drawn line cannot disagree with the applied one.
|
/// the shader runs so the drawn line cannot disagree with the applied one.
|
||||||
in property <[float]> curve-samples;
|
in property <[float]> curve-samples;
|
||||||
|
/// The curves the tone curve widget can plot, named by the core. Fewer
|
||||||
|
/// than two of them means there is nothing to choose and no selector.
|
||||||
|
in property <[string]> curve-channels;
|
||||||
|
/// Which of them `curve-samples` and the row's points describe.
|
||||||
|
in property <int> curve-channel;
|
||||||
callback param-changed(int, int, float);
|
callback param-changed(int, int, float);
|
||||||
callback param-reset(int, int);
|
callback param-reset(int, int);
|
||||||
callback curve-reset(int);
|
callback curve-reset(int);
|
||||||
|
/// Plot a different one of the curve's curves.
|
||||||
|
callback curve-channel-picked(int);
|
||||||
/// Return every parameter of one operation to its default — the reset on
|
/// Return every parameter of one operation to its default — the reset on
|
||||||
/// a section's own header, beside the panel-wide one.
|
/// a section's own header, beside the panel-wide one.
|
||||||
callback op-reset(int);
|
callback op-reset(int);
|
||||||
@@ -986,12 +1029,15 @@ export component AdjustPanel inherits Rectangle {
|
|||||||
ParamControl {
|
ParamControl {
|
||||||
data: row;
|
data: row;
|
||||||
curve-samples: root.curve-samples;
|
curve-samples: root.curve-samples;
|
||||||
|
curve-channels: root.curve-channels;
|
||||||
|
curve-channel: root.curve-channel;
|
||||||
drag-changed(on) => { root.slider-dragging = on; }
|
drag-changed(on) => { root.slider-dragging = on; }
|
||||||
param-changed(op, param, v) => {
|
param-changed(op, param, v) => {
|
||||||
root.param-changed(op, param, v);
|
root.param-changed(op, param, v);
|
||||||
}
|
}
|
||||||
param-reset(op, param) => { root.param-reset(op, param); }
|
param-reset(op, param) => { root.param-reset(op, param); }
|
||||||
curve-reset(op) => { root.curve-reset(op); }
|
curve-reset(op) => { root.curve-reset(op); }
|
||||||
|
curve-channel-picked(i) => { root.curve-channel-picked(i); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-1
@@ -2,7 +2,7 @@ import { Theme } from "theme.slint";
|
|||||||
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
||||||
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||||
import { LaunchScreen } from "launch.slint";
|
import { LaunchScreen } from "launch.slint";
|
||||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||||
@@ -589,6 +589,25 @@ export component AppWindow inherits Window {
|
|||||||
/// the target's id, and whether to take the images out of the collection
|
/// the target's id, and whether to take the images out of the collection
|
||||||
/// currently being shown.
|
/// currently being shown.
|
||||||
callback library-file-in-collection(int, bool);
|
callback library-file-in-collection(int, bool);
|
||||||
|
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||||
|
/// Keywording the grid's selection. The catalog has been searchable by
|
||||||
|
/// keyword since it existed and there was nowhere to type one; this is it.
|
||||||
|
///
|
||||||
|
/// The vocabulary arrives already answered against the selection — each row
|
||||||
|
/// says how many of the selected photographs carry that word — because only
|
||||||
|
/// Rust knows what is selected, and a `.slint` file counting it would need
|
||||||
|
/// the selection as a second model that could disagree with the first.
|
||||||
|
in property <[KeywordRow]> library-keywords;
|
||||||
|
/// The sheet is opening: recompute the rows against the selection as it
|
||||||
|
/// stands now. Pulled rather than pushed, because the selection changes on
|
||||||
|
/// every arrow key and the sheet is shut for almost all of them.
|
||||||
|
callback library-keywords-opened();
|
||||||
|
/// Put a keyword on the selection, creating it if it is new. By name, so a
|
||||||
|
/// word typed into the field and a word tapped in the list are one path.
|
||||||
|
callback library-assign-keyword(string);
|
||||||
|
/// Take a keyword off the selection. Never deletes the keyword itself —
|
||||||
|
/// it stays in the vocabulary and on every other photograph that carries it.
|
||||||
|
callback library-unassign-keyword(string);
|
||||||
/// TRACES: FR-UI-2
|
/// TRACES: FR-UI-2
|
||||||
/// Whether a tap in the grid selects rather than opens, and the button
|
/// Whether a tap in the grid selects rather than opens, and the button
|
||||||
/// that turns it on. The long press does the same thing without it.
|
/// that turns it on. The long press does the same thing without it.
|
||||||
@@ -697,9 +716,14 @@ export component AppWindow inherits Window {
|
|||||||
// The tone curve's sampled shape, evaluated by the core so the drawn
|
// The tone curve's sampled shape, evaluated by the core so the drawn
|
||||||
// line and the applied one cannot disagree.
|
// line and the applied one cannot disagree.
|
||||||
in property <[float]> curve-samples;
|
in property <[float]> curve-samples;
|
||||||
|
// The curves that widget can plot, named by the core, and which of them
|
||||||
|
// `curve-samples` describes. Fewer than two means nothing to choose.
|
||||||
|
in property <[string]> curve-channels;
|
||||||
|
in property <int> curve-channel;
|
||||||
callback param-changed(int, int, float);
|
callback param-changed(int, int, float);
|
||||||
callback param-reset(int, int);
|
callback param-reset(int, int);
|
||||||
callback curve-reset(int);
|
callback curve-reset(int);
|
||||||
|
callback curve-channel-picked(int);
|
||||||
callback reset-all();
|
callback reset-all();
|
||||||
|
|
||||||
// --- copying settings between photographs (FR-DEV-6) ---
|
// --- copying settings between photographs (FR-DEV-6) ---
|
||||||
@@ -1268,6 +1292,10 @@ in property <bool> panel-visible: true;
|
|||||||
file-in-collection(id, moves) => {
|
file-in-collection(id, moves) => {
|
||||||
root.library-file-in-collection(id, moves);
|
root.library-file-in-collection(id, moves);
|
||||||
}
|
}
|
||||||
|
keywords: root.library-keywords;
|
||||||
|
keywords-opened() => { root.library-keywords-opened(); }
|
||||||
|
assign-keyword(word) => { root.library-assign-keyword(word); }
|
||||||
|
unassign-keyword(word) => { root.library-unassign-keyword(word); }
|
||||||
cursor: root.library-cursor;
|
cursor: root.library-cursor;
|
||||||
move-cursor(delta, extend) => {
|
move-cursor(delta, extend) => {
|
||||||
root.library-move-cursor(delta, extend);
|
root.library-move-cursor(delta, extend);
|
||||||
@@ -2154,6 +2182,11 @@ in property <bool> panel-visible: true;
|
|||||||
enabled: root.adjust-enabled;
|
enabled: root.adjust-enabled;
|
||||||
scope: root.adjust-scope;
|
scope: root.adjust-scope;
|
||||||
curve-samples: root.curve-samples;
|
curve-samples: root.curve-samples;
|
||||||
|
curve-channels: root.curve-channels;
|
||||||
|
curve-channel: root.curve-channel;
|
||||||
|
curve-channel-picked(i) => {
|
||||||
|
root.curve-channel-picked(i);
|
||||||
|
}
|
||||||
param-changed(op, param, value) => {
|
param-changed(op, param, value) => {
|
||||||
root.param-changed(op, param, value);
|
root.param-changed(op, param, value);
|
||||||
}
|
}
|
||||||
|
|||||||
+269
-1
@@ -9,12 +9,38 @@
|
|||||||
// must not look identical (FR-NC-6c).
|
// must not look identical (FR-NC-6c).
|
||||||
|
|
||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } from "widgets.slint";
|
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, Field } from "widgets.slint";
|
||||||
// The filing sheet lists the same rows the sidebar draws, from the same model:
|
// The filing sheet lists the same rows the sidebar draws, from the same model:
|
||||||
// two lists of collections that could disagree about what exists is one list
|
// two lists of collections that could disagree about what exists is one list
|
||||||
// too many.
|
// too many.
|
||||||
import { CollectionRow } from "collections.slint";
|
import { CollectionRow } from "collections.slint";
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
// One keyword in the keywording sheet, already answered against the selection.
|
||||||
|
//
|
||||||
|
// The three-way `coverage` is the whole reason this is a struct rather than a
|
||||||
|
// list of strings. Applying a word to forty photographs where thirty already
|
||||||
|
// carry it must not look like applying it to forty that carry none, and
|
||||||
|
// removing one that only some of them carry must not silently claim to have
|
||||||
|
// taken it off all forty. Rust computes it, because only Rust knows how big the
|
||||||
|
// selection is and how many of it each word covers.
|
||||||
|
export struct KeywordRow {
|
||||||
|
// Row id in `keyword_terms`, or 0 for a word an image carries that the
|
||||||
|
// vocabulary has no identity for yet. The sheet acts on `name`, never on
|
||||||
|
// this, so a 0 costs nothing — it is here so a future rename gesture has
|
||||||
|
// something to name.
|
||||||
|
id: int,
|
||||||
|
name: string,
|
||||||
|
// 0 none of the selection, 1 some of it, 2 all of it.
|
||||||
|
coverage: int,
|
||||||
|
// How many of the selected photographs carry it, for the "3 of 12" that
|
||||||
|
// makes `coverage: 1` a number rather than a shrug.
|
||||||
|
selected-count: int,
|
||||||
|
// How many photographs in the whole library carry it. Lets a word in
|
||||||
|
// regular use be told from one typed once by mistake.
|
||||||
|
image-count: int,
|
||||||
|
}
|
||||||
|
|
||||||
// One bar of the capture-time histogram.
|
// One bar of the capture-time histogram.
|
||||||
export struct TimelineBar {
|
export struct TimelineBar {
|
||||||
// 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet
|
// 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet
|
||||||
@@ -656,6 +682,9 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
callback remove-from-collection();
|
callback remove-from-collection();
|
||||||
/// Open the sheet that files the selection in a collection.
|
/// Open the sheet that files the selection in a collection.
|
||||||
callback add-to-collection();
|
callback add-to-collection();
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// Open the sheet that keywords the selection.
|
||||||
|
callback add-keyword();
|
||||||
callback toggle-select-mode();
|
callback toggle-select-mode();
|
||||||
callback change-library();
|
callback change-library();
|
||||||
callback toggle-pin-scope();
|
callback toggle-pin-scope();
|
||||||
@@ -694,6 +723,17 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
clicked => { root.add-to-collection(); }
|
clicked => { root.add-to-collection(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-5 | FR-CAT-6
|
||||||
|
// Keyword the selection. Beside "Add to collection" because they are the
|
||||||
|
// same thought — these photographs are *of* something, and they belong
|
||||||
|
// *with* something — and appearing under the same condition, because
|
||||||
|
// neither means anything without a selection to act on.
|
||||||
|
if root.selected-count > 0: Button {
|
||||||
|
text: "Keywords";
|
||||||
|
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||||
|
clicked => { root.add-keyword(); }
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-DEV-6
|
// TRACES: FR-DEV-6
|
||||||
// Batch-apply the copied settings. Shown only with both a selection and a
|
// Batch-apply the copied settings. Shown only with both a selection and a
|
||||||
// clipboard, because it is meaningless without either — and because a
|
// clipboard, because it is meaningless without either — and because a
|
||||||
@@ -1105,6 +1145,34 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// out of the one currently being shown.
|
/// out of the one currently being shown.
|
||||||
callback file-in-collection(int, bool);
|
callback file-in-collection(int, bool);
|
||||||
|
|
||||||
|
// --- keywording the selection (FR-CAT-5, FR-CAT-6) ----------------------
|
||||||
|
//
|
||||||
|
// The catalog has been searchable by keyword since it existed and there was
|
||||||
|
// never anywhere to type one. This sheet is that place, and it sits beside
|
||||||
|
// the filing sheet above because the two are the same gesture applied to
|
||||||
|
// two different kinds of label — pick the photographs, then say what they
|
||||||
|
// are — and a user who has learnt one should not have to learn the other.
|
||||||
|
//
|
||||||
|
// Assign and unassign travel by **name**, not by id. A word typed into the
|
||||||
|
// field and a word tapped in the list are then one path through Rust rather
|
||||||
|
// than two, and the sheet does not have to invent an id for a keyword that
|
||||||
|
// does not exist yet.
|
||||||
|
/// The vocabulary, already answered against the current selection.
|
||||||
|
in property <[KeywordRow]> keywords;
|
||||||
|
/// The sheet is opening: Rust answers by refreshing `keywords` against
|
||||||
|
/// whatever is selected *now*.
|
||||||
|
///
|
||||||
|
/// Pulled on open rather than pushed on every selection change, because the
|
||||||
|
/// selection changes on every arrow key and the sheet is shut for almost
|
||||||
|
/// all of them — recomputing coverage over a forty-image selection for a
|
||||||
|
/// panel nobody is looking at is work the grid cannot afford.
|
||||||
|
callback keywords-opened();
|
||||||
|
callback assign-keyword(string);
|
||||||
|
callback unassign-keyword(string);
|
||||||
|
/// Whether the sheet is up. Local, for the same reason `filing` is: it is a
|
||||||
|
/// disclosure rather than a preference, and what closes it is dismissing it.
|
||||||
|
property <bool> keywording: false;
|
||||||
|
|
||||||
// Cell geometry. Columns are derived from the available width so the grid
|
// Cell geometry. Columns are derived from the available width so the grid
|
||||||
// reflows with the window rather than fixing a count (FR-UI-1).
|
// reflows with the window rather than fixing a count (FR-UI-1).
|
||||||
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
||||||
@@ -1355,6 +1423,14 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// to is still there when the sheet closes.
|
// to is still there when the sheet closes.
|
||||||
root.actions-open = false;
|
root.actions-open = false;
|
||||||
}
|
}
|
||||||
|
add-keyword => {
|
||||||
|
// Ask for the vocabulary before showing the sheet, so
|
||||||
|
// it is answered against the selection as it stands now
|
||||||
|
// rather than as it stood when the grid last loaded.
|
||||||
|
root.keywords-opened();
|
||||||
|
root.keywording = true;
|
||||||
|
root.actions-open = false;
|
||||||
|
}
|
||||||
change-library => { root.change-library(); }
|
change-library => { root.change-library(); }
|
||||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||||
sync-now => { root.sync-now(); }
|
sync-now => { root.sync-now(); }
|
||||||
@@ -1429,6 +1505,14 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// to is still there when the sheet closes.
|
// to is still there when the sheet closes.
|
||||||
root.actions-open = false;
|
root.actions-open = false;
|
||||||
}
|
}
|
||||||
|
add-keyword => {
|
||||||
|
// Ask for the vocabulary before showing the sheet, so
|
||||||
|
// it is answered against the selection as it stands now
|
||||||
|
// rather than as it stood when the grid last loaded.
|
||||||
|
root.keywords-opened();
|
||||||
|
root.keywording = true;
|
||||||
|
root.actions-open = false;
|
||||||
|
}
|
||||||
change-library => { root.change-library(); }
|
change-library => { root.change-library(); }
|
||||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||||
sync-now => { root.sync-now(); }
|
sync-now => { root.sync-now(); }
|
||||||
@@ -1788,6 +1872,10 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// button, and a sheet it walked straight past would leave
|
// button, and a sheet it walked straight past would leave
|
||||||
// the user out of the grid with their selection gone.
|
// the user out of the grid with their selection gone.
|
||||||
if (event.text == Key.Back || event.text == Key.Escape) {
|
if (event.text == Key.Back || event.text == Key.Escape) {
|
||||||
|
if (root.keywording) {
|
||||||
|
root.keywording = false;
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
if (root.filing) {
|
if (root.filing) {
|
||||||
root.filing = false;
|
root.filing = false;
|
||||||
return accept;
|
return accept;
|
||||||
@@ -2486,4 +2574,184 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the keywording sheet (FR-CAT-5, FR-CAT-6) --------------------------
|
||||||
|
//
|
||||||
|
// "These are of…". Deliberately the same card, scrim and dismissal as the
|
||||||
|
// filing sheet above: a user who has filed a selection already knows how
|
||||||
|
// this works, and a second idiom for the same gesture would be a second
|
||||||
|
// thing to learn for no gain.
|
||||||
|
//
|
||||||
|
// It stays open after each word, where the filing sheet closes. Filing is
|
||||||
|
// one choice; keywording is usually several — "puffin", "Látrabjarg",
|
||||||
|
// "2026" — and a sheet that shut after each one would have to be reopened,
|
||||||
|
// and the selection re-confirmed, three times over.
|
||||||
|
if root.keywording: Rectangle {
|
||||||
|
background: #000000CC;
|
||||||
|
|
||||||
|
// Swallows the taps that miss the card, and closes. First, so the
|
||||||
|
// card's own controls sit above it.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.keywording = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
height: min(kw-sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
background: Theme.surface;
|
||||||
|
border-radius: Theme.radius;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.rule;
|
||||||
|
|
||||||
|
// Stops a press on the card reaching the scrim behind it.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
kw-sheet := VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.selected-count == 1
|
||||||
|
? "Keywords for 1 photograph"
|
||||||
|
: "Keywords for " + root.selected-count + " photographs";
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing a word applies it, whether or not it already exists.
|
||||||
|
// One field for both, because "is this keyword new?" is a
|
||||||
|
// question about the catalog and not about what the user meant,
|
||||||
|
// and Rust can answer it without being asked.
|
||||||
|
//
|
||||||
|
// The field clears itself on accept so the next word can be
|
||||||
|
// typed straight after — keywording a shoot is a run of them.
|
||||||
|
new-keyword := Field {
|
||||||
|
placeholder: "Type a keyword and press return";
|
||||||
|
accepted(text) => {
|
||||||
|
root.assign-keyword(text);
|
||||||
|
self.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
vertical-stretch: 1;
|
||||||
|
// A floor, so the list is not squeezed out of existence by
|
||||||
|
// the field and the button around it on a short window.
|
||||||
|
min-height: 120px;
|
||||||
|
viewport-height: root.keywords.length * (Theme.touch-target + 2px);
|
||||||
|
|
||||||
|
for word[i] in root.keywords: Rectangle {
|
||||||
|
y: i * (Theme.touch-target + 2px);
|
||||||
|
width: parent.width;
|
||||||
|
// A full touch target per row, for the same reason the
|
||||||
|
// filing sheet uses one: this is a place to hit once,
|
||||||
|
// with a thumb, holding a selection that took a minute
|
||||||
|
// to build (FR-UI-3).
|
||||||
|
height: Theme.touch-target;
|
||||||
|
background: kw-touch.pressed ? Theme.pressed
|
||||||
|
: (kw-touch.has-hover ? Theme.hover : transparent);
|
||||||
|
border-radius: Theme.radius-sm;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: Theme.gap-sm;
|
||||||
|
padding-right: Theme.gap-sm;
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
// Tick, dash, or nothing — the three states of
|
||||||
|
// `coverage`, drawn as three different marks rather
|
||||||
|
// than as two. A half-applied keyword shown as
|
||||||
|
// applied is a lie about photographs the user
|
||||||
|
// cannot see from here.
|
||||||
|
Rectangle {
|
||||||
|
width: 16px;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: Theme.radius-sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: word.coverage == 0 ? Theme.rule : Theme.active;
|
||||||
|
background: word.coverage == 2 ? Theme.active : transparent;
|
||||||
|
|
||||||
|
// The dash for "some of them". A bar rather
|
||||||
|
// than a tick, because a tick at half strength
|
||||||
|
// reads as a rendering artefact.
|
||||||
|
if word.coverage == 1: Rectangle {
|
||||||
|
width: 8px;
|
||||||
|
height: 2px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
background: Theme.active;
|
||||||
|
}
|
||||||
|
if word.coverage == 2: Icon {
|
||||||
|
name: "check";
|
||||||
|
ink: Theme.surface;
|
||||||
|
size: 12px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: word.name;
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "3 of 12" only where it says something the mark
|
||||||
|
// does not. For a word the whole selection carries,
|
||||||
|
// or none of it, the mark has already said it and
|
||||||
|
// the number would be noise on every row.
|
||||||
|
Text {
|
||||||
|
text: word.coverage == 1
|
||||||
|
? word.selected-count + " of " + root.selected-count
|
||||||
|
: (word.image-count > 0 ? word.image-count + "" : "");
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One target for both directions. A word the selection
|
||||||
|
// fully carries comes off; anything else goes on — so a
|
||||||
|
// partly-applied keyword is completed rather than
|
||||||
|
// removed, which is what a user tapping a dash means
|
||||||
|
// nine times in ten, and the tenth is one more tap
|
||||||
|
// away.
|
||||||
|
kw-touch := TouchArea {
|
||||||
|
clicked => {
|
||||||
|
if (word.coverage == 2) {
|
||||||
|
root.unassign-keyword(word.name);
|
||||||
|
} else {
|
||||||
|
root.assign-keyword(word.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.keywords.length == 0: Text {
|
||||||
|
text: "No keywords yet. Type one above to make the first.";
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
wrap: word-wrap;
|
||||||
|
width: parent.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Done";
|
||||||
|
clicked => { root.keywording = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user