Read the library's sidecars, so a cull done elsewhere arrives

Judgements only ever travelled outward. A rating went to the catalog and to the
photograph's sidecar, the sidecar reached the server, and there it stopped: the
scan indexes files, `derived_sync` exchanges thumbnails, face shards and
collections, `dr_catalog::merge` reconciles everything in a catalog except
`versions.rating` and `versions.flag`, and the one sidecar reader that existed
ran when a single photograph was opened in develop and handed its answer to the
develop graph. `JobKind::ReadSidecar` was declared for exactly this when the job
queue was written and was never enqueued or handled anywhere.

The grid draws `versions.rating`. So a day of culling on the tablet could not
reach the laptop by any path the application had, and the laptop's catalog says
so plainly: 23,568 images, one of them judged.

`pull_sidecars` closes it, off the back of work the scan already does.
`dr_sync::scan` reports the `.drsc` files it meets in listings it was making
anyway — no extra request, and a directory whose ETag is unchanged is still
pruned before it is listed at all. A new `sidecars` table records the ETag of
each one this device has taken in, so the fetch is one GET per sidecar that
genuinely changed rather than one per photograph. A library nobody has edited
costs nothing.

The judgement is taken rather than maximised. The sidecar is the authoritative
store and the fuse has already settled any contest between devices on
`revision`, so lowering a rating from four to one on the tablet lowers it here —
taking the larger would have refused every demotion the photographer ever made,
which is most of what a second pass over a shoot is. A zero is the exception: it
means *never judged*, not "judged zero", so a sidecar carrying none cannot erase
a star this device holds. That is `merge_judgement`'s asymmetry and it carries
the same known cost — clearing a rating does not propagate.

A sidecar names a stem, so both halves of a RAW-and-JPEG pair are judged: they
are one photograph (FR-CAT-11) sharing one document, and judging only one of
them would leave the grid disagreeing with itself over which it drew. The `LIKE`
that finds them is a filter, not the decision — `sidecar_path` is applied to
every candidate, because a folder is entitled to contain a `%` and a rating
landing on the wrong frame would be silent and permanent.

Failing to read one is not a failure to scan: the ETag goes unrecorded, the
ratings already here stay where they are, and the next scan tries again. The
count is reported to the status line as well as the log, because a grid that
silently gains three hundred stars is indistinguishable from one that has gone
wrong — and because while this number was structurally zero there was nothing to
tell the photographer their cull had not arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 16:14:03 +02:00
co-authored by Claude Opus 5
parent ca2a135e28
commit 1ad35e2b87
5 changed files with 656 additions and 45 deletions
+49 -1
View File
@@ -15,7 +15,7 @@ use rusqlite::Connection;
use crate::error::CatalogError;
/// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 12;
pub const SCHEMA_VERSION: i64 = 13;
/// Apply migrations up to [`SCHEMA_VERSION`].
///
@@ -112,6 +112,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?;
}
if from < 13 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V13)?;
tx.pragma_update(None, "user_version", 13)?;
tx.commit()?;
}
Ok(from)
}
@@ -521,6 +528,47 @@ const V12: &str = r#"
DELETE FROM face_index WHERE source_edge <= 1024;
"#;
const V13: &str = r#"
-- TRACES: FR-CAT-8 | FR-NC-9
-- Which sidecars this device has read, and at what ETag.
--
-- The sidecar is the authoritative store for a rating and an edit, and until
-- this table existed nothing ever read one back into the catalog: judgements
-- travelled outward only. A cull done on a tablet reached the server and
-- stopped there, because the scan indexes photographs, the derived sync moves
-- thumbnails and collections, and the one reader that existed ran when a single
-- photograph was opened in develop and fed only the develop graph. The grid
-- draws `versions.rating`, so another device's afternoon of culling was
-- invisible on this one -- permanently, by every path the app had.
--
-- What this holds is the ETag, not the content. It is the record of what has
-- already been taken in, so a pull fetches only what changed: `dr_sync::scan`
-- reports every sidecar it saw in listings it was making anyway, and this
-- decides which of them are worth a GET.
--
-- Keyed on the sidecar's own remote path rather than on an image id. One
-- sidecar can describe two images -- a RAW and the JPEG beside it are one
-- photograph (FR-CAT-11) and share a document -- and a path is what the scan
-- reports and what a fetch addresses, so keying on anything else would mean
-- deriving one from the other in two places.
--
-- Rebuildable like the rest of the catalog: losing this table costs one pass
-- that re-reads every sidecar and reaches exactly the same state.
--
-- `IF NOT EXISTS` because NFR-R5 asks for migrations that are idempotent on
-- retry, and this one can genuinely be re-entered: a catalog whose
-- `user_version` was rewound -- by a rollback to an older build, or by a
-- recovery -- would otherwise fail its next open on a table it already has.
CREATE TABLE IF NOT EXISTS sidecars (
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
etag TEXT,
-- Unix seconds, for diagnosing a pull that is not making progress.
read_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(root_id, path)
);
"#;
const V9: &str = r#"
-- TRACES: FR-CULL-8
-- A record that face detection has *run* on an image, distinct from what it
+100
View File
@@ -28,11 +28,36 @@ pub struct ScanProgress {
pub images_found: usize,
}
/// TRACES: FR-CAT-8 | FR-NC-9
/// Extension of a DarkRoom sidecar, as it appears in a listing.
///
/// Mirrors `dr_pipeline::sidecar::EXTENSION`. Duplicated rather than shared for
/// the reason [`TRASH_DIR`] is duplicated in `dr_catalog`: this crate depends
/// on nothing above it, and one `const` is a smaller price than a dependency
/// on the whole edit format to recognise four characters in a filename.
pub const SIDECAR_EXTENSION: &str = "drsc";
/// The result of a scan.
#[derive(Debug, Clone, Default)]
pub struct ScanResult {
/// Files matching the format filter.
pub images: Vec<RemoteEntry>,
/// TRACES: FR-CAT-8 | FR-NC-9
/// Sidecars seen in the same listings, with the ETag they had.
///
/// **Collected here because it is free.** A sidecar is a file in the same
/// directory as the photograph it describes, so every one of them is
/// already in a `PROPFIND` response this walk has paid for. Discovering
/// them any other way — a probe per image — would be one request per
/// photograph over a link that may be mobile data, which is why the pull
/// did not exist at all before this.
///
/// The validator is what makes the pull incremental: a sidecar whose ETag
/// is unchanged since the last scan holds nothing this device has not
/// already read, and is not fetched. ETag pruning means an untouched
/// subtree is never even listed, so a library nobody has edited costs
/// nothing.
pub sidecars: Vec<RemoteEntry>,
/// Directories whose contents were **actually listed**, with the ETag
/// observed at that moment.
///
@@ -231,6 +256,10 @@ where
if filter.allows_name(entry.path.name()) {
result.images.push(entry);
result.progress.images_found += 1;
} else if is_sidecar(entry.path.name()) {
// Not counted in `images_found`: the progress figure is
// what the user is shown, and it means photographs.
result.sidecars.push(entry);
}
}
}
@@ -241,10 +270,22 @@ where
// Sort so a scan is reproducible and the grid has a stable order.
result.images.sort_by(|a, b| a.path.cmp(&b.path));
result.sidecars.sort_by(|a, b| a.path.cmp(&b.path));
result.directories.sort_by(|a, b| a.0.cmp(&b.0));
Ok(result)
}
/// Whether a filename is a DarkRoom sidecar.
///
/// Case-insensitive on the extension alone. A server that upper-cased the
/// suffix — or a file copied through a filesystem that did — still describes a
/// photograph, and failing to recognise it would silently lose the edit rather
/// than fail visibly.
fn is_sidecar(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(stem, ext)| !stem.is_empty() && ext.eq_ignore_ascii_case(SIDECAR_EXTENSION))
}
/// Whether pruning is worth attempting against this backend.
///
/// Only propagating ETags make an unchanged parent prove an unchanged
@@ -867,4 +908,63 @@ mod tests {
// the root plus its two children.
assert_eq!(r.directories.len(), 3);
}
/// TRACES: FR-CAT-8 | FR-NC-9
/// Sidecars are reported, so a judgement made elsewhere can be read back.
///
/// They are already in every listing the walk pays for; collecting them
/// costs no request. Discovering them any other way would be a probe per
/// photograph, which is why nothing read them at all before this.
#[tokio::test]
async fn sidecars_are_collected_from_the_listings_the_walk_already_makes() {
let mut b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
b.tree.insert(
"Photos/2025".into(),
vec![
file("Photos/2025/a.CR2"),
file("Photos/2025/a.drsc"),
file("Photos/2025/b.jpg"),
// Upper-cased by a filesystem somewhere along the way; still a
// sidecar, and losing it would lose the edit silently.
file("Photos/2025/b.DRSC"),
],
);
let before = *b.lists.borrow();
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
assert_eq!(
r.sidecars
.iter()
.map(|e| e.path.as_str().to_string())
.collect::<Vec<_>>(),
vec!["Photos/2025/a.drsc", "Photos/2025/b.DRSC"]
);
assert_eq!(*b.lists.borrow() - before, 3, "no extra requests");
// And they are not photographs: the count the user is shown must not
// double because a library has been edited.
assert_eq!(r.progress.images_found, 3);
assert!(r.images.iter().all(|e| !e.path.name().contains("drsc")));
}
/// A file whose *name* is only an extension is not a sidecar for anything.
#[test]
fn a_bare_extension_is_not_a_sidecar() {
assert!(is_sidecar("a.drsc"));
assert!(is_sidecar("a.DRSC"));
assert!(is_sidecar("a.b.drsc"));
assert!(!is_sidecar(".drsc"));
assert!(!is_sidecar("drsc"));
assert!(!is_sidecar("a.drsc.tmp"));
assert!(!is_sidecar("a.CR2"));
}
}
+43 -43
View File
File diff suppressed because one or more lines are too long
+449
View File
@@ -70,6 +70,14 @@ pub enum ScanMessage {
total: usize,
pruned: usize,
elapsed_ms: u64,
/// TRACES: FR-CAT-8 | FR-NC-9
/// Judgements this scan took *in* from other devices' sidecars.
///
/// Counted and reported rather than left to the log because it is the
/// only visible sign that a cull made elsewhere has arrived. A grid
/// that silently gains three hundred stars is indistinguishable from
/// one that has gone wrong.
judgements: usize,
},
/// The scan could not finish.
///
@@ -1365,6 +1373,25 @@ fn run_scan(
persist(&catalog, &root, &result).map_err(ScanFailure::local)?;
// TRACES: FR-CAT-8 | FR-NC-9
// Take in what other devices have judged. After `persist`, because a
// judgement lands on an image's version row and the image has to be in
// the catalog first — a sidecar seen in the same listing as a
// photograph this scan has only just discovered is the ordinary case
// on a library another device imported.
//
// Deliberately not fatal. A pull that fails leaves the ratings this
// device already had exactly where they were and the sidecar's ETag
// unrecorded, so the next scan tries again; failing the whole scan over
// it would blank a grid that was working.
let judgements = match pull_sidecars(&*backend, &catalog, &root, &result.sidecars).await {
Ok(n) => n,
Err(e) => {
log::warn!("reading sidecars from the library: {e}");
0
}
};
// Report what the catalog holds, not what this pass listed. An
// incremental rescan lists only what changed, so its own count is
// near zero on a healthy library.
@@ -1375,11 +1402,254 @@ fn run_scan(
total,
pruned: result.progress.directories_pruned,
elapsed_ms: started.elapsed().as_millis() as u64,
judgements,
});
Ok(())
})
}
/// TRACES: FR-CAT-8 | FR-CAT-9 | FR-NC-9
/// Take other devices' judgements out of the library's sidecars.
///
/// # Why this had to exist
///
/// A rating is written to the catalog and to the photograph's sidecar, and the
/// sidecar is the authoritative one (ARCH §6.12). Nothing ever read one back.
/// The scan indexed files, `derived_sync` exchanged thumbnails, collections
/// and keywords, `dr_catalog::merge` reconciled everything in the catalog
/// *except* `versions.rating` and `versions.flag`, and the single sidecar
/// reader ran when one photograph was opened in develop and handed its result
/// to the develop graph. `JobKind::ReadSidecar` had been declared for this
/// since the queue was written and was never enqueued or handled.
///
/// So judgements travelled outward only. The grid draws `versions.rating`, and
/// a cull done on another device could not reach it by any path the app had.
///
/// # What it costs
///
/// Nothing on a library nobody has edited. The sidecars come from listings the
/// walk was making anyway, an unchanged directory is pruned before it is
/// listed at all, and a sidecar whose ETag matches what this device last read
/// is skipped without a request. What is left is one GET per sidecar that
/// genuinely changed — which is the number of photographs somebody edited.
///
/// # Why a judgement can only be added, never withdrawn
///
/// `Version::merge`'s rule, applied here: zero is *unjudged*, and a device that
/// has never rated a frame is indistinguishable from one that deliberately
/// cleared it. Taking a remote zero over a local star would let a device that
/// was never involved erase an afternoon's culling. So a remote zero is
/// ignored, and the cost is that clearing a rating does not propagate.
///
/// Returns how many images gained a judgement.
async fn pull_sidecars(
backend: &dyn RemoteBackend,
catalog: &Catalog,
root: &str,
seen: &[dr_sync::RemoteEntry],
) -> Result<usize, String> {
if seen.is_empty() {
return Ok(0);
}
let conn = catalog.connection();
let root_id: i64 = conn
.query_row(
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
[root],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
let known = load_sidecar_etags(catalog, root_id);
let mut applied = 0usize;
for entry in seen {
let path = entry.path.as_str();
if known.get(path).is_some_and(|e| *e == entry.validator) {
continue;
}
let bytes = match backend.get(&RemoteId::Path(entry.path.clone()), None).await {
Ok(b) => b,
// Gone between the listing and the fetch, or not on this device and
// not worth materialising a whole library for. Neither is an error,
// and neither records an ETag — so the next scan tries again.
Err(e) => {
log::debug!("reading sidecar {path}: {e}");
continue;
}
};
let text = String::from_utf8_lossy(&bytes);
let mut sidecar = match dr_pipeline::Sidecar::parse(&text) {
Ok(s) => s,
// Unreadable is not empty. Recording the ETag would mean never
// looking at it again, and a build that understands it may be
// along; leaving it unrecorded costs one GET per scan and keeps
// the door open.
Err(e) => {
log::warn!("sidecar at {path} is unreadable ({e})");
continue;
}
};
sidecar.fuse_default_versions(None);
let Some(version) = sidecar.default_version() else {
// A sidecar with no default version — someone else's virtual copy
// and nothing more. Nothing to take, but it *was* read, so its
// ETag is recorded and it is not fetched again.
record_sidecar_read(conn, root_id, path, &entry.validator);
continue;
};
match apply_judgement(conn, root_id, path, version.rating, version.flag) {
Ok(n) => {
applied += n;
record_sidecar_read(conn, root_id, path, &entry.validator);
}
Err(e) => log::debug!("applying {path}: {e}"),
}
}
if applied > 0 {
log::info!("{applied} judgement(s) arrived from other devices");
}
Ok(applied)
}
/// What this device has already read, so a pull fetches only what changed.
fn load_sidecar_etags(
catalog: &Catalog,
root_id: i64,
) -> std::collections::HashMap<String, dr_sync::Validator> {
let mut out = std::collections::HashMap::new();
let Ok(mut stmt) = catalog
.connection()
.prepare("SELECT path, etag FROM sidecars WHERE root_id = ?1 AND etag IS NOT NULL")
else {
return out;
};
if let Ok(rows) = stmt.query_map([root_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
}) {
for (path, etag) in rows.flatten() {
out.insert(path, dr_sync::Validator::new(etag));
}
}
out
}
/// Remember that this sidecar has been taken in at this ETag.
///
/// Written only after the judgement has landed, for the same reason
/// `dr_sync::scan` records a directory's ETag only after listing it: recording
/// it first would let a failure look like work already done, and the edit would
/// never be read again.
fn record_sidecar_read(
conn: &rusqlite::Connection,
root_id: i64,
path: &str,
etag: &dr_sync::Validator,
) {
let done = conn.execute(
"INSERT INTO sidecars(root_id, path, etag, read_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(root_id, path) DO UPDATE SET
etag = excluded.etag, read_at = excluded.read_at",
rusqlite::params![root_id, path, etag.as_str(), now_secs()],
);
if let Err(e) = done {
log::debug!("recording sidecar {path}: {e}");
}
}
/// Apply one sidecar's judgement to the image or images it describes.
///
/// # Why more than one image
///
/// A sidecar is named for the stem it shares with its photograph, so a RAW and
/// the JPEG the camera wrote beside it — one photograph under FR-CAT-11 — share
/// a document, and both rows have to carry the judgement or the grid disagrees
/// with itself depending on which of the pair it is showing.
///
/// # Why the match is verified in Rust
///
/// The `LIKE` narrows the search to rows sharing the stem, and it is only a
/// filter: `library::sidecar_path` is what actually decides, applied to each
/// candidate. A path holding a `%`, a `_` or a bracket would otherwise match
/// more than it should, and a judgement landing on the wrong photograph is a
/// silent, permanent wrong.
///
/// Returns how many images gained a judgement they did not have.
fn apply_judgement(
conn: &rusqlite::Connection,
root_id: i64,
sidecar: &str,
rating: u8,
flag: u8,
) -> Result<usize, String> {
let stem = sidecar
.rsplit_once('.')
.map(|(s, _)| s)
.unwrap_or(sidecar)
.to_string();
// The escape is the point: `%` and `_` are wildcards, and a photographer's
// folder is entitled to contain both.
let prefix = stem
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
let candidates: Vec<(i64, String)> = {
let mut stmt = conn
.prepare(
"SELECT id, source_ref FROM images
WHERE root_id = ?1 AND source_ref LIKE ?2 ESCAPE '\\'",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(rusqlite::params![root_id, format!("{prefix}.%")], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.map_err(|e| e.to_string())?;
rows.filter_map(Result::ok)
.filter(|(_, source): &(i64, String)| sidecar_path(source) == sidecar)
.collect()
};
let mut applied = 0usize;
for (image, _) in candidates {
let id = dr_types::ImageId(image as u64);
let version =
dr_catalog::rating::default_version_id(conn, id).map_err(|e| e.to_string())?;
// The sidecar's value is taken, not the larger of the two. It is the
// authoritative store and the fuse above has already resolved any
// contest between devices on `revision` — so lowering a rating from
// four to one on the tablet has to lower it here, and taking a maximum
// would quietly refuse every demotion the photographer ever made.
//
// A zero is the one thing not taken: it means *never judged* rather
// than "judged zero", so a sidecar that carries none cannot erase a
// star this device holds. The same asymmetry, and the same direction
// of caution, as `dr_pipeline::sidecar::merge_judgement`. The cost is
// the one that rule always carries — clearing a rating does not
// propagate.
let changed = conn
.execute(
"UPDATE versions
SET rating = CASE WHEN ?2 > 0 THEN ?2 ELSE rating END,
flag = CASE WHEN ?3 > 0 THEN ?3 ELSE flag END
WHERE id = ?1
AND ((?2 > 0 AND rating <> ?2) OR (?3 > 0 AND flag <> ?3))",
rusqlite::params![version, rating.min(5) as i64, flag.min(2) as i64],
)
.map_err(|e| e.to_string())?;
applied += changed;
}
Ok(applied)
}
/// Read back the folder ETags stored by a previous scan.
///
/// A failure here is not fatal — an empty map simply means no pruning, which
@@ -5339,6 +5609,7 @@ mod tests {
images: vec![entry("PhotosRaw/a.CR2", 1001, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
@@ -5365,6 +5636,7 @@ mod tests {
images: vec![entry("PhotosRaw/a.CR2", 1001, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
@@ -5382,6 +5654,7 @@ mod tests {
images: vec![entry("PhotosRaw/a.CR2", 1001, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
@@ -5403,6 +5676,7 @@ mod tests {
images: vec![entry("PhotosRaw/a.CR2", 4242, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
@@ -5423,6 +5697,7 @@ mod tests {
],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
@@ -5440,6 +5715,7 @@ mod tests {
images: vec![entry("PhotosRaw/a.CR2", 1, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
persist(&catalog, "PhotosRaw", &result).unwrap();
@@ -5464,6 +5740,7 @@ mod tests {
),
],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
@@ -5487,6 +5764,7 @@ mod tests {
images,
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
@@ -5566,6 +5844,7 @@ mod tests {
images,
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
catalog
@@ -5905,6 +6184,7 @@ mod tests {
images: vec![entry("PhotosRaw/a.CR2", 7777, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
@@ -6035,6 +6315,7 @@ mod tests {
images,
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
sidecars: Vec::new(),
},
)
.unwrap();
@@ -6860,6 +7141,174 @@ mod tests {
}
}
/// TRACES: FR-CAT-8 | FR-NC-9
/// Taking another device's judgement out of a sidecar and into the grid.
#[cfg(test)]
mod reading_judgements_back {
use super::*;
/// A remote library holding the given files, indexed as a scan leaves it.
fn library(files: &[&str]) -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
for (i, f) in files.iter().enumerate() {
c.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[f],
)
.unwrap();
let image = c.last_insert_rowid();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![image, 1000 + i as i64],
)
.unwrap();
}
dr_catalog::rating::ensure_default_versions(c).unwrap();
cat
}
fn judgements(cat: &Catalog) -> Vec<(String, i64, i64)> {
let c = cat.connection();
let mut stmt = c
.prepare(
"SELECT i.source_ref, v.rating, v.flag FROM images i
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
ORDER BY i.source_ref",
)
.unwrap();
let v = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.map(Result::unwrap)
.collect();
v
}
/// The regression, in one line: a rating in a sidecar reaches the grid.
/// Before this there was no path by which it could.
#[test]
fn a_rating_from_another_device_reaches_the_catalog() {
let cat = library(&["2026/a.CR2"]);
let n = apply_judgement(cat.connection(), 1, "2026/a.drsc", 4, 1).unwrap();
assert_eq!(n, 1);
assert_eq!(judgements(&cat), vec![("2026/a.CR2".to_string(), 4, 1)]);
}
/// A RAW and the JPEG beside it are one photograph (FR-CAT-11) sharing one
/// sidecar, so both rows have to carry the judgement — otherwise the grid
/// disagrees with itself depending which of the pair it draws.
#[test]
fn both_halves_of_a_raw_and_jpeg_pair_are_judged() {
let cat = library(&["2026/a.CR2", "2026/a.jpg"]);
let n = apply_judgement(cat.connection(), 1, "2026/a.drsc", 3, 0).unwrap();
assert_eq!(n, 2);
assert_eq!(
judgements(&cat),
vec![
("2026/a.CR2".to_string(), 3, 0),
("2026/a.jpg".to_string(), 3, 0)
]
);
}
/// A demotion has to travel. Taking the larger of the two would refuse
/// every rating the photographer ever lowered — and lowering one is most
/// of what a second pass over a shoot does.
#[test]
fn a_lowered_rating_travels() {
let cat = library(&["2026/a.CR2"]);
apply_judgement(cat.connection(), 1, "2026/a.drsc", 4, 0).unwrap();
apply_judgement(cat.connection(), 1, "2026/a.drsc", 1, 0).unwrap();
assert_eq!(judgements(&cat)[0].1, 1);
}
/// But a zero is *unjudged*, not "judged zero". A device that never culled
/// the frame must not erase the stars of one that did.
#[test]
fn an_unjudged_sidecar_does_not_erase_a_local_rating() {
let cat = library(&["2026/a.CR2"]);
apply_judgement(cat.connection(), 1, "2026/a.drsc", 5, 2).unwrap();
assert_eq!(
apply_judgement(cat.connection(), 1, "2026/a.drsc", 0, 0).unwrap(),
0
);
assert_eq!(judgements(&cat), vec![("2026/a.CR2".to_string(), 5, 2)]);
}
/// Idempotent: a scan runs repeatedly, and a sidecar whose judgement is
/// already in the catalog must report no change or the status line claims
/// work that did not happen.
#[test]
fn applying_the_same_judgement_twice_changes_nothing() {
let cat = library(&["2026/a.CR2"]);
assert_eq!(
apply_judgement(cat.connection(), 1, "2026/a.drsc", 4, 1).unwrap(),
1
);
assert_eq!(
apply_judgement(cat.connection(), 1, "2026/a.drsc", 4, 1).unwrap(),
0
);
}
/// The `LIKE` is a filter and not the decision. A stem holding a wildcard
/// would otherwise reach photographs it has nothing to do with, and a
/// rating landing on the wrong frame is silent and permanent.
#[test]
fn a_wildcard_in_a_path_does_not_reach_another_photograph() {
// `_` is LIKE's single-character wildcard, so an unescaped `a_b` stem
// would also match `axb`.
let cat = library(&["2026/a_b.CR2", "2026/axb.CR2"]);
apply_judgement(cat.connection(), 1, "2026/a_b.drsc", 5, 0).unwrap();
assert_eq!(
judgements(&cat),
vec![
("2026/a_b.CR2".to_string(), 5, 0),
("2026/axb.CR2".to_string(), 0, 0)
]
);
}
/// A stem that is a prefix of another must not spill onto it: `a.drsc`
/// describes `a.CR2`, never `ab.CR2`.
#[test]
fn a_shared_prefix_is_not_a_shared_sidecar() {
let cat = library(&["2026/a.CR2", "2026/ab.CR2"]);
apply_judgement(cat.connection(), 1, "2026/a.drsc", 5, 0).unwrap();
assert_eq!(
judgements(&cat),
vec![
("2026/a.CR2".to_string(), 5, 0),
("2026/ab.CR2".to_string(), 0, 0)
]
);
}
/// A sidecar for a photograph this device has not indexed is not an error:
/// the scan may have pruned the folder, or the file may be a format this
/// device does not accept.
#[test]
fn a_sidecar_with_no_photograph_here_is_not_a_failure() {
let cat = library(&["2026/a.CR2"]);
assert_eq!(
apply_judgement(cat.connection(), 1, "2026/elsewhere.drsc", 5, 0).unwrap(),
0
);
}
}
/// TRACES: FR-NC-8 | FR-NC-9
/// What a write does to a sidecar another device has already edited.
#[cfg(test)]
+15 -1
View File
@@ -1219,10 +1219,12 @@ fn drain_scan(
total,
pruned,
elapsed_ms,
judgements,
} => {
log::info!(
"scan complete: {total} images ({found} listed, \
{pruned} folders unchanged) in {elapsed_ms} ms"
{pruned} folders unchanged, {judgements} judgements \
taken in) in {elapsed_ms} ms"
);
w.set_library_scanning(false);
@@ -1260,6 +1262,18 @@ fn drain_scan(
} else {
format!("{total} images · {secs:.1}s")
};
// TRACES: FR-CAT-8 | FR-NC-9
// Said out loud, because a grid that silently gains
// three hundred stars is indistinguishable from one
// that has gone wrong — and because for as long as
// this number was structurally zero, the photographer
// had no way to tell that a cull made on another
// device had failed to arrive.
let status = if judgements > 0 {
format!("{status} · {judgements} from other devices")
} else {
status
};
job.finish(status.clone());
w.set_library_status(status.into());