diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 8582673f4..96187cfad 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -1,8 +1,9 @@ //! Crash-safe catalog persistence: an append-only op log plus periodic snapshots. //! //! Files (in a [`Store`]): -//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":{…}}`, the -//! state after op `N`; replaced atomically (temp + fsync + rename). +//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":V,"seq":N,"catalog":{…}}`, the +//! state after op `N` in catalog format `V` ([`VERSION`]); replaced atomically (temp + fsync + +//! rename). //! - `catalog.log` — JSON lines, one per op applied after the snapshot: //! `{"seq":N,"crc":C,"op":{…}}` where `C` is the CRC-32 of the op's JSON text. Appends are //! fsynced before [`Journal::append`] returns. @@ -33,6 +34,32 @@ //! //! At most one snapshot is in flight; a synchronous [`Journal::snapshot`] and dropping the journal //! first wait for it, so an older snapshot can never replace a newer one. +//! +//! # Format versions +//! +//! The snapshot's `version` is the catalog format, and it covers the log beside it: the log has +//! no header of its own, and is only ever appended to under a snapshot of this build's format. +//! +//! | Version | Written by | Adds | +//! |---|---|---| +//! | (none) | early builds: a log without a snapshot | | +//! | 1 | up to v0.2.0 | | +//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` | +//! +//! Rules: +//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a +//! serialized field to the catalog, a photo, an album, a stack or develop settings that an older +//! build would misread or silently drop. New fields must deserialize from older data +//! (`#[serde(default)]`), so newer builds keep reading every older format. +//! - **Opening an older format upgrades it**: right after loading, [`Journal::open`] writes a +//! snapshot in this build's format (also for a new library), before anything is appended — so a +//! library is never a mix, and older builds refuse it from then on instead of reading part of it. +//! [`LoadReport::upgraded_from`] says when that happened. +//! - **A newer format is refused, untouched**: a snapshot whose `version` is above [`VERSION`], +//! or a log record whose CRC matches (so it is exactly what was written) but whose op doesn't +//! parse, fails [`Journal::open`] with [`CatalogError::Newer`] before any file is modified — it +//! is never mistaken for a torn tail or damage. (Builds up to v0.2.0 refuse a v2 snapshot with +//! "unsupported format".) use crate::store::Store; use crate::{Catalog, CatalogError, Op, Result}; @@ -40,7 +67,9 @@ use crate::{Catalog, CatalogError, Op, Result}; pub const SNAPSHOT: &str = "catalog.snap"; pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; -const VERSION: u32 = 1; +/// The catalog format this build writes (and the newest it reads). See the module docs → +/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. +pub const VERSION: u32 = 2; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] @@ -75,6 +104,9 @@ pub struct LoadReport { pub damaged: Option, /// No catalog files existed (a new library). pub created: bool, + /// The library was in an older catalog format (`0` = before format versions: a log without a + /// snapshot) and was rewritten in this version's format ([`VERSION`]). + pub upgraded_from: Option, } /// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr @@ -180,18 +212,46 @@ pub fn encode_record(seq: u64, op: &Op) -> String { format!("{{\"seq\":{seq},\"crc\":{crc},\"op\":{body}}}") } -/// Parse one log line (without the newline). `None` if it's malformed or the CRC doesn't match. +/// Parse one log line (without the newline). `None` if it's malformed, the CRC doesn't match, or +/// the op is one this version doesn't know (see [`Journal::open`]). pub fn decode_record(line: &str) -> Option<(u64, Op)> { - let rest = line.strip_prefix("{\"seq\":")?; - let (seq, rest) = rest.split_once(",\"crc\":")?; - let (crc, rest) = rest.split_once(",\"op\":")?; - let body = rest.strip_suffix('}')?; - let seq: u64 = seq.parse().ok()?; - let crc: u32 = crc.parse().ok()?; - if crc32fast::hash(body.as_bytes()) != crc { - return None; + match decode_line(line) { + Line::Record(seq, op) => Some((seq, op)), + Line::Newer(_) | Line::Bad => None, } - serde_json::from_str(body).ok().map(|op| (seq, op)) +} + +/// One log line, decoded. +enum Line { + Record(u64, Op), + /// The CRC matches — the line is exactly what was written — but the op doesn't parse: it + /// was written by a newer LightCraft (an op or field this version doesn't know), not damaged. + Newer(u64), + /// Malformed or a CRC mismatch: torn or damaged. + Bad, +} + +fn decode_line(line: &str) -> Line { + let parts = || { + let rest = line.strip_prefix("{\"seq\":")?; + let (seq, rest) = rest.split_once(",\"crc\":")?; + let (crc, rest) = rest.split_once(",\"op\":")?; + let body = rest.strip_suffix('}')?; + Some((seq.parse::().ok()?, crc.parse::().ok()?, body)) + }; + let Some((seq, crc, body)) = parts() else { return Line::Bad }; + if crc32fast::hash(body.as_bytes()) != crc { + return Line::Bad; + } + match serde_json::from_str(body) { + Ok(op) => Line::Record(seq, op), + Err(_) => Line::Newer(seq), + } +} + +/// The error for a library written by a newer LightCraft. +fn newer(what: String) -> CatalogError { + CatalogError::Newer(format!("{what}; this version reads catalog format v{VERSION} and older")) } /// A line that doesn't decode but ends with a whole record: a fragment of a failed append followed @@ -229,10 +289,16 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() }) } +/// Just the identification of `catalog.snap` (read before the catalog itself). #[derive(serde::Deserialize)] -struct SnapFile { +struct SnapHeader { format: String, version: u32, +} + +/// The whole `catalog.snap` (after [`SnapHeader`] was checked; `format` and `version` are ignored). +#[derive(serde::Deserialize)] +struct SnapFile { seq: u64, catalog: Catalog, } @@ -245,15 +311,20 @@ impl Journal { let snap = store.read(SNAPSHOT).map_err(io)?; let log = store.read(LOG).map_err(io)?; report.created = snap.is_none() && log.is_none(); - let (mut catalog, snapshot_seq) = match snap { + let (mut catalog, snapshot_seq, snapshot_version) = match snap { Some(bytes) => { - let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?; - if s.format != FORMAT || s.version > VERSION { - return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: unsupported format {} v{}", s.format, s.version))); + // the header first: a newer snapshot may not parse as this version's catalog + let h: SnapHeader = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?; + if h.format != FORMAT { + return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: not a LightCraft catalog (format {:?})", h.format))); } - (s.catalog, s.seq) + if h.version > VERSION { + return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version))); + } + let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?; + (s.catalog, s.seq, Some(h.version)) } - None => (Catalog::new(), 0), + None => (Catalog::new(), 0, None), }; report.snapshot_seq = snapshot_seq; let mut j = Journal { @@ -296,6 +367,11 @@ impl Journal { report.torn_fragments += 1; record = Some(r); } + if record.is_none() + && let Some(Line::Newer(seq)) = line.map(decode_line) + { + return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know"))); + } match record { Some((seq, op)) if seq <= j.seq => { // already in the snapshot @@ -312,7 +388,15 @@ impl Journal { } _ => { // bad record (or a gap): torn tail if nothing good follows, else damage - let rest_has_good = log[next..].split(|b| *b == b'\n').any(|l| std::str::from_utf8(l).ok().and_then(decode_record).is_some()); + let mut rest_has_good = false; + for l in log[next..].split(|b| *b == b'\n') { + match std::str::from_utf8(l).map(|l| decode_line(l.trim_end_matches('\r'))) { + Ok(Line::Record(..)) => rest_has_good = true, + // refused before anything is modified + Ok(Line::Newer(seq)) => return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know"))), + Ok(Line::Bad) | Err(_) => {} + } + } if rest_has_good { damaged_at = Some(pos); } @@ -344,6 +428,27 @@ impl Journal { j.log_bytes = good_end as u64; } } + // Bring an older (or new, or snapshot-less) library to this version's format before + // anything is appended: a snapshot in this format makes older builds refuse the library + // instead of misreading ops or dropping fields they don't know. + if report.created { + // tiny: written directly, not counted as a compaction + let empty = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":0,\"catalog\":{}}}\n", catalog.to_snapshot()); + if let Err(e) = j.store.write_atomic(SNAPSHOT, empty.as_bytes()) { + log::warn!("catalog: can't write the new library's snapshot: {e}"); + } + } else if report.damaged.is_none() && snapshot_version != Some(VERSION) { + report.upgraded_from = Some(snapshot_version.unwrap_or(0)); + match j.snapshot(&catalog) { + Ok(()) => { + if let Some(v) = report.upgraded_from { + log::info!("catalog: upgraded from format v{v} to v{VERSION}"); + } + } + // nothing lost: the old files are intact and still load; retried on next open + Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"), + } + } catalog.revision = 0; Ok((j, catalog, report)) } diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 88bb719e1..b183a13c2 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -5,6 +5,11 @@ //! snapshot — crash-safe and diff-friendly (see [`journal`]); //! - **undo/redo**: the engine keeps inverse ops; //! - **determinism**: replaying the log reproduces the state exactly (property-tested). +//! +//! **Catalog format version** ([`journal::VERSION`], see [`journal`] → *Format versions*): adding +//! an [`Op`] variant or a serialized field means bumping it. Newer builds read every older format +//! (and upgrade it on open); older builds refuse a newer library with [`CatalogError::Newer`] +//! instead of reading part of it. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -46,6 +51,10 @@ pub enum CatalogError { Corrupt(String), #[error("catalog storage: {0}")] Io(String), + /// The library was written by a newer LightCraft (a newer catalog format, or a change this + /// version doesn't know). Nothing was read into the session and nothing was modified. + #[error("this library was written by a newer version of LightCraft ({0}); update LightCraft to open it. The library was left unchanged.")] + Newer(String), } pub type Result = std::result::Result; @@ -643,6 +652,8 @@ mod tests; #[cfg(test)] mod tests_background; #[cfg(test)] +mod tests_format_version; +#[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_local; diff --git a/crates/catalog/src/tests_background.rs b/crates/catalog/src/tests_background.rs index 0e06936e0..5235c3e17 100644 --- a/crates/catalog/src/tests_background.rs +++ b/crates/catalog/src/tests_background.rs @@ -180,7 +180,8 @@ fn failed_background_snapshot_loses_nothing_and_retries_later() { wait_written(&j); assert!(j.poll().is_err()); assert_eq!((j.snapshot_seq(), j.stats().failed_snapshots), (0, 1)); - assert!(g.files.get(SNAPSHOT).is_none()); + // still the (empty) snapshot written when the library was created + assert_eq!(g.snapshot_seq_on_disk(), Some(0)); assert_recovers(&g, &live); // no retry storm: the next attempt waits for another `max_records` records assert!(!j.wants_snapshot()); diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs new file mode 100644 index 000000000..3f2e8816c --- /dev/null +++ b/crates/catalog/src/tests_format_version.rs @@ -0,0 +1,186 @@ +//! Issue #102: catalog format versions. Older formats load and are upgraded; a newer format is +//! refused with a clear error and left untouched (never read in part, truncated or set aside). + +use crate::journal::{LOG, SNAPSHOT, VERSION, encode_record}; +use crate::*; + +fn photo(c: &mut Catalog) -> Op { + let id = c.alloc_photo_id(); + Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) } +} + +/// A catalog with two photos and the log line for a third op on top of it. +fn legacy_parts() -> (Catalog, String, Catalog) { + let mut c = Catalog::new(); + for _ in 0..2 { + let op = photo(&mut c); + c.apply(op).unwrap(); + } + let base = c.clone(); + let id = c.photos().next().unwrap().id; + let op = Op::SetRating { id, rating: 4 }; + c.apply(op.clone()).unwrap(); + (base, format!("{}\n", encode_record(3, &op)), c) +} + +fn snapshot_version(m: &MemStore) -> u64 { + let v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap(); + v["version"].as_u64().unwrap() +} + +#[test] +fn v1_library_loads_and_is_upgraded() { + let (base, log, full) = legacy_parts(); + let m = MemStore::new(); + m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes()); + m.set(LOG, log.into_bytes()); + let (mut j, c, r) = Journal::open(Box::new(m.clone())).unwrap(); + assert_eq!(c.to_snapshot(), full.to_snapshot()); + assert_eq!((r.replayed, r.upgraded_from), (1, Some(1))); + // rewritten in this format before anything is appended + assert_eq!(snapshot_version(&m), u64::from(VERSION)); + assert!(m.get(LOG).unwrap().is_empty()); + assert_eq!(j.seq(), 3); + j.append(&[Op::SetLabelName { label: ColorLabel::ALL[0], name: Some("x".into()) }]).unwrap(); + drop(j); + let (_, _, r) = Journal::open(Box::new(m)).unwrap(); + assert_eq!((r.replayed, r.upgraded_from), (1, None)); +} + +#[test] +fn versionless_log_only_library_is_upgraded() { + let mut c = Catalog::new(); + let op = photo(&mut c); + c.apply(op.clone()).unwrap(); + let m = MemStore::new(); + m.set(LOG, format!("{}\n", encode_record(1, &op)).into_bytes()); + let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap(); + assert_eq!(loaded.to_snapshot(), c.to_snapshot()); + assert_eq!(r.upgraded_from, Some(0)); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); +} + +/// A new library gets a snapshot in this format at once, so an older build never sees a bare log +/// it would read in part. +#[test] +fn new_library_is_written_in_this_format() { + let m = MemStore::new(); + let (_, _, r) = Journal::open(Box::new(m.clone())).unwrap(); + assert!(r.created); + assert_eq!(r.upgraded_from, None); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); +} + +fn assert_refused_untouched(m: &MemStore) { + let before = m.files.lock().unwrap().clone(); + let e = Journal::open(Box::new(m.clone())).err().expect("refused"); + assert!(matches!(e, CatalogError::Newer(_)), "{e:?}"); + let msg = e.to_string(); + assert!(msg.contains("newer version of LightCraft") && msg.contains("left unchanged"), "{msg}"); + assert_eq!(*m.files.lock().unwrap(), before, "nothing modified, nothing renamed"); +} + +#[test] +fn newer_snapshot_is_refused_untouched() { + let (base, log, _) = legacy_parts(); + let m = MemStore::new(); + m.set( + SNAPSHOT, + format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{}}}\n", VERSION + 1, base.to_snapshot()).into_bytes(), + ); + m.set(LOG, log.into_bytes()); + assert_refused_untouched(&m); + // even when its catalog doesn't parse as this version's + let m = MemStore::new(); + m.set( + SNAPSHOT, + format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{{\"photos\":7}}}}\n", VERSION + 1).into_bytes(), + ); + assert_refused_untouched(&m); + let e = Journal::open(Box::new(m)).err().unwrap().to_string(); + assert!(e.contains(&format!("v{}", VERSION + 1)), "{e}"); +} + +/// A log record with a valid CRC whose op this version doesn't know: refused, not taken for a +/// torn tail (truncated) or damage (renamed) — wherever it is in the log. +#[test] +fn unknown_op_in_the_log_is_refused_untouched() { + let (base, log, _) = legacy_parts(); + let future = |seq: u64| { + let body = r#"{"op":"fromTheFuture","id":1,"what":"something new"}"#; + format!("{{\"seq\":{seq},\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())) + }; + let snap = format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()); + let id = base.photos().next().unwrap().id; + let after = format!("{}\n", encode_record(5, &Op::SetRating { id, rating: 1 })); + for log in [format!("{log}{}", future(4)), format!("{log}{}{after}", future(4)), format!("{}{log}", future(3))] { + let m = MemStore::new(); + m.set(SNAPSHOT, snap.clone().into_bytes()); + m.set(LOG, log.into_bytes()); + assert_refused_untouched(&m); + } + // a known op with a field of an unknown shape is "newer" too + let body = r#"{"op":"setRating","id":1,"rating":"five stars"}"#; + let m = MemStore::new(); + m.set(SNAPSHOT, snap.into_bytes()); + m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes()); + assert_refused_untouched(&m); +} + +/// A CRC mismatch is still damage / a torn tail (not "newer"). +#[test] +fn bad_crc_is_still_a_torn_tail() { + let (base, log, _) = legacy_parts(); + let m = MemStore::new(); + m.set( + SNAPSHOT, + format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes(), + ); + let body = r#"{"op":"fromTheFuture"}"#; + m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":1,\"op\":{body}}}\n").into_bytes()); + let (_, _, r) = Journal::open(Box::new(m)).unwrap(); + assert!(r.torn_bytes > 0 && r.damaged.is_none(), "{r:?}"); +} + +/// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks +/// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the +/// variant here under the new version. +#[test] +fn op_variants_are_versioned() { + fn since(op: &Op) -> u32 { + match op { + Op::AddPhoto { .. } + | Op::RemovePhoto { .. } + | Op::SetRating { .. } + | Op::SetFlag { .. } + | Op::SetLabel { .. } + | Op::SetDevelop { .. } + | Op::SetMeta { .. } + | Op::SetDeleted { .. } + | Op::SetLocal { .. } + | Op::SetVersions { .. } + | Op::SetHistory { .. } + | Op::PushHistory { .. } + | Op::AddAlbum { .. } + | Op::RemoveAlbum { .. } + | Op::RenameAlbum { .. } + | Op::MoveAlbum { .. } + | Op::SetAlbumPhotos { .. } + | Op::SetAlbumCover { .. } + | Op::SetAlbumRules { .. } + | Op::AddStack { .. } + | Op::RemoveStack { .. } + | Op::SetStack { .. } + | Op::SetCaptured { .. } + | Op::SetAnalysis { .. } + | Op::SetFile { .. } + | Op::Relink { .. } + | Op::SetContent { .. } + | Op::SetLabelName { .. } + | Op::Batch { .. } => 1, + Op::SetBrowsed { .. } => 2, + } + } + let newest = since(&Op::SetBrowsed { folder: String::new(), at: None }); + assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); +} diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 6bf7c85c2..0c9513e34 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -283,7 +283,12 @@ fn streamed_snapshot_keeps_the_on_disk_format() { let (mut j, c, _) = open(&m); assert_eq!(c.to_snapshot(), full.to_snapshot()); j.snapshot(&c).unwrap(); - let legacy = format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{},\"catalog\":{}}}\n", j.seq(), c.to_snapshot()); + let legacy = format!( + "{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":{},\"catalog\":{}}}\n", + crate::journal::VERSION, + j.seq(), + c.to_snapshot() + ); assert_eq!(String::from_utf8(m.get(SNAPSHOT).unwrap()).unwrap(), legacy); assert_eq!(j.stats().last_snapshot.bytes, legacy.len() as u64); diff --git a/docs/parity.md b/docs/parity.md index 5e28b5546..bce51cc28 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -488,7 +488,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | every change fsynced before the command returns; a failed write fails the command ("saved in memory but not written to disk"), keeps the change queued and retries; top-bar warning until saved; a write that fails part-way is cut back to the last whole record so the retry never reads back as damage (logs from older builds with such a fragment load in full); quitting saves queued changes in the closing snapshot even when the log can't be appended to | +| LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | every change fsynced before the command returns; a failed write fails the command ("saved in memory but not written to disk"), keeps the change queued and retries; top-bar warning until saved; a write that fails part-way is cut back to the last whole record so the retry never reads back as damage (logs from older builds with such a fragment load in full); quitting saves queued changes in the closing snapshot even when the log can't be appended to; versioned catalog format (`journal::VERSION`): older libraries are upgraded on open, a library from a newer LightCraft is refused untouched ("written by a newer version") | | LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`) | covers ratings, albums, deletes, edits | | LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | | | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 7003a4d51..4a99dcd97 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -14,6 +14,10 @@ afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written still saves your queued changes in the closing snapshot. +- The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library + written by a newer LightCraft is refused with "this library was written by a newer version of LightCraft" and left + untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library, + LightCraft 0.2.0 and older refuse it ("unsupported format … v2"). - Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter layout is read from each file instead of assumed.