Let a photographer name the state they liked, and go back to it or look at it
FR-DEV-5 asked for named snapshots of an edit state and FR-DEV-7 for a comparison against a chosen one, and neither existed. The history stack is per sitting and forgotten with it, on purpose — the gap that mattered was an automatically saved mis-drag with no way back, and that was closed first. What was left was the other half: a state the photographer wants to keep *because* it is worth keeping, which is a different thing from a step and is not served by making the steps last longer. A snapshot is an edit state, and an edit state is exactly what a sidecar version stores, so it is stored as one: a `[version]` block carrying `snapshot-of = <uuid>`. The parameters, the masks and their parts, the repairs and the film all arrive through the blocks that already carry them, a merge keys on the uuid as it does for any version, and a build that predates the key reads the block as a named version and keeps it — the right failure. Only the pointer is new. The one reader that has to know is `default_version`, which must never answer with a snapshot: a file whose edit is missing is not a file whose edit is one of its saved moments. The snapshots of an edit are listed by that pointer, oldest first, the same on every device. Writing them back removes what this sitting deleted and puts in what it holds, and leaves standing whatever it never saw — a snapshot the other device took since the photograph was opened here is not this device's to remove by not knowing about it. That is the rule the version merge already keeps, applied one level down, and it is why the save carries the deleted ids rather than replacing the list wholesale as the masks are. Each is re-pointed at the uuid the save settled on, because the default may have been fused onto its canonical identity since the snapshot was taken. Restoring is one history step, so undo takes it back whole, as a paste is. Taking and deleting are not steps: they change nothing about the photograph, and an undo that removed a snapshot would be undoing a decision to remember. Holding the eye beside one renders the snapshot and hands the edit straight back — the same suspension "Before" uses, against a point the photographer chose rather than the file. Two sessions on the same photograph get ids that cannot collide, stamped with the second and a random word, because the merge folds equal ids into one.
This commit is contained in:
@@ -126,6 +126,23 @@ pub struct Version {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
pub is_default: bool,
|
||||
/// TRACES: FR-DEV-5
|
||||
/// The version this is a named snapshot of, if it is one.
|
||||
///
|
||||
/// A snapshot *is* an edit state, which is exactly what a version stores,
|
||||
/// so it is stored as one: the parameters, the masks and their parts, the
|
||||
/// repairs and the film all arrive through the blocks that already carry
|
||||
/// them, and a merge keys on the uuid as it does for any other version.
|
||||
/// What sets a snapshot apart is only this pointer — it belongs to
|
||||
/// another version's history rather than standing beside it as a variant
|
||||
/// (FR-CAT-12), so a reader listing what a photograph *is* skips it, and
|
||||
/// a reader listing what one edit *was* finds it here.
|
||||
///
|
||||
/// Written as `snapshot-of`. A build that predates the key reads the
|
||||
/// block as an ordinary named version and keeps it, which is the right
|
||||
/// failure: nothing is lost, and the newer build finds it as a snapshot
|
||||
/// again.
|
||||
pub snapshot_of: Option<String>,
|
||||
/// Monotonic per-edit counter (FR-NC-8).
|
||||
///
|
||||
/// The primary merge discriminator, ahead of [`Self::modified`]: a device
|
||||
@@ -222,6 +239,7 @@ impl Version {
|
||||
uuid: uuid.into(),
|
||||
name: name.into(),
|
||||
is_default: false,
|
||||
snapshot_of: None,
|
||||
revision: 1,
|
||||
device: String::new(),
|
||||
modified: 0,
|
||||
@@ -267,6 +285,12 @@ impl Version {
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// Whether this version is a named snapshot of another one.
|
||||
pub fn is_snapshot(&self) -> bool {
|
||||
self.snapshot_of.is_some()
|
||||
}
|
||||
|
||||
/// Record `graph` into this version, bumping the revision.
|
||||
///
|
||||
/// The revision bump is what makes this the write path rather than a
|
||||
@@ -645,7 +669,52 @@ impl Sidecar {
|
||||
.values()
|
||||
.filter(|v| v.is_default)
|
||||
.max_by_key(|v| (v.revision, v.modified))
|
||||
.or_else(|| self.versions.values().next())
|
||||
// Never a snapshot: a file with no default and a snapshot in it
|
||||
// is a file whose edit is *missing*, and answering with a saved
|
||||
// state of it would open the photograph at a moment the
|
||||
// photographer deliberately stepped away from.
|
||||
.or_else(|| self.versions.values().find(|v| !v.is_snapshot()))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// The named snapshots of one version, oldest first.
|
||||
///
|
||||
/// Taken time is `modified`, so the order is the order they were taken in
|
||||
/// whichever device took them; the uuid breaks a tie so the list reads
|
||||
/// the same on every device.
|
||||
pub fn snapshots_of(&self, uuid: &str) -> Vec<&Version> {
|
||||
let mut out: Vec<&Version> = self
|
||||
.versions
|
||||
.values()
|
||||
.filter(|v| v.snapshot_of.as_deref() == Some(uuid))
|
||||
.collect();
|
||||
out.sort_by(|a, b| (a.modified, &a.uuid).cmp(&(b.modified, &b.uuid)));
|
||||
out
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5 | FR-NC-9
|
||||
/// Write a session's snapshots of `uuid` into the file.
|
||||
///
|
||||
/// `removed` are the ones the session deleted, taken out by id;
|
||||
/// `snapshots` are the ones it holds, put in. A snapshot in the file that
|
||||
/// is in neither — one another device took since this session opened
|
||||
/// the photograph — is left standing, which is the same rule
|
||||
/// [`Version::merge`] keeps for a version only one side has: never treat
|
||||
/// "I did not see it" as "I removed it".
|
||||
///
|
||||
/// Each snapshot is re-pointed at `uuid` on the way in, because the
|
||||
/// default version may have been fused onto a canonical identity since
|
||||
/// the snapshot was taken, and a snapshot of a uuid nothing carries is
|
||||
/// a snapshot of nothing.
|
||||
pub fn replace_snapshots(&mut self, uuid: &str, snapshots: Vec<Version>, removed: &[String]) {
|
||||
for id in removed {
|
||||
self.versions.remove(id);
|
||||
}
|
||||
for mut snapshot in snapshots {
|
||||
snapshot.snapshot_of = Some(uuid.to_string());
|
||||
snapshot.is_default = false;
|
||||
self.put(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-8 | FR-NC-9
|
||||
@@ -763,6 +832,10 @@ impl Sidecar {
|
||||
if v.is_default {
|
||||
let _ = writeln!(out, "default = 1");
|
||||
}
|
||||
// TRACES: FR-DEV-5
|
||||
if let Some(of) = &v.snapshot_of {
|
||||
let _ = writeln!(out, "snapshot-of = {of}");
|
||||
}
|
||||
let _ = writeln!(out, "revision = {}", v.revision);
|
||||
if !v.device.is_empty() {
|
||||
let _ = writeln!(out, "device = {}", v.device);
|
||||
@@ -942,6 +1015,8 @@ impl Sidecar {
|
||||
match key {
|
||||
"name" => version.name = value.to_string(),
|
||||
"default" => version.is_default = value != "0",
|
||||
// TRACES: FR-DEV-5
|
||||
"snapshot-of" => version.snapshot_of = Some(value.to_string()),
|
||||
"revision" => version.revision = value.parse().unwrap_or(0),
|
||||
"device" => version.device = value.to_string(),
|
||||
"modified" => version.modified = value.parse().unwrap_or(0),
|
||||
@@ -2347,6 +2422,89 @@ mod tests {
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// A snapshot is a version with a pointer, and the pointer survives the
|
||||
/// file: it comes back as a snapshot of the edit it was taken from, with
|
||||
/// the edit it stored, and the photograph still opens at its default.
|
||||
#[test]
|
||||
fn a_snapshot_round_trips_as_a_snapshot_of_its_edit() {
|
||||
let mut sidecar = Sidecar::new();
|
||||
let mut current = Version::from_graph("u1", "Default", &edited());
|
||||
current.is_default = true;
|
||||
sidecar.put(current);
|
||||
|
||||
let mut mono = EditGraph::default_chain();
|
||||
mono.set_param(saturation::ID, saturation::SATURATION, -100.0);
|
||||
let mut snapshot = Version::from_graph("snap-1", "Black and white", &mono);
|
||||
snapshot.snapshot_of = Some("u1".into());
|
||||
snapshot.modified = 7;
|
||||
sidecar.put(snapshot);
|
||||
|
||||
let text = sidecar.to_text();
|
||||
assert!(text.contains("snapshot-of = u1"), "{text}");
|
||||
let parsed = Sidecar::parse(&text).expect("valid");
|
||||
|
||||
assert_eq!(parsed.default_version().expect("default").uuid, "u1");
|
||||
let snapshots = parsed.snapshots_of("u1");
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
assert_eq!(snapshots[0].name, "Black and white");
|
||||
assert!(snapshots[0].is_snapshot());
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
snapshots[0].apply(&mut g).expect_no_film();
|
||||
assert_eq!(
|
||||
g.param(saturation::ID, saturation::SATURATION),
|
||||
Some(-100.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// A file whose only versions are snapshots has no edit to open, and
|
||||
/// must not answer with one of the snapshots as if it were.
|
||||
#[test]
|
||||
fn a_snapshot_is_never_the_default() {
|
||||
let mut sidecar = Sidecar::new();
|
||||
let mut snapshot = Version::from_graph("snap-1", "Earlier", &edited());
|
||||
snapshot.snapshot_of = Some("gone".into());
|
||||
sidecar.put(snapshot);
|
||||
assert!(sidecar.default_version().is_none());
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5 | FR-NC-9
|
||||
/// Writing a session's snapshots back removes what it deleted and keeps
|
||||
/// what it never saw — another device's snapshot is not this device's to
|
||||
/// remove by not knowing about it.
|
||||
#[test]
|
||||
fn replacing_snapshots_removes_only_what_was_deleted() {
|
||||
let mut sidecar = Sidecar::new();
|
||||
let mut current = Version::from_graph("u1", "Default", &edited());
|
||||
current.is_default = true;
|
||||
sidecar.put(current);
|
||||
for id in ["mine-1", "mine-2", "theirs-1"] {
|
||||
let mut v = Version::from_graph(id, id, &edited());
|
||||
v.snapshot_of = Some("u1".into());
|
||||
sidecar.put(v);
|
||||
}
|
||||
|
||||
// This session loaded mine-1 and mine-2, deleted mine-2, took mine-3.
|
||||
let mut kept = Version::from_graph("mine-1", "mine-1", &edited());
|
||||
kept.snapshot_of = Some("u1".into());
|
||||
let taken = Version::from_graph("mine-3", "mine-3", &edited());
|
||||
sidecar.replace_snapshots("u1", vec![kept, taken], &["mine-2".to_string()]);
|
||||
|
||||
let ids: Vec<&str> = sidecar
|
||||
.snapshots_of("u1")
|
||||
.iter()
|
||||
.map(|v| v.uuid.as_str())
|
||||
.collect();
|
||||
assert_eq!(ids, ["mine-1", "mine-3", "theirs-1"]);
|
||||
assert_eq!(
|
||||
sidecar.versions["mine-3"].snapshot_of.as_deref(),
|
||||
Some("u1"),
|
||||
"a snapshot taken without a pointer is pointed at the edit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_bumps_the_revision() {
|
||||
// FR-NC-9 resolves by revision; a write that did not bump it would
|
||||
|
||||
Reference in New Issue
Block a user