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:
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user