mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
M1.4: crash-safe catalog persistence (op log + snapshot)
- catalog: Store trait (FsStore: temp+fsync+rename+dir fsync; MemStore), Journal: CRC-checked JSON-lines op log with sequence numbers + atomic snapshots; torn final record dropped and truncated, stale records after a snapshot skipped, mid-log damage preserved as catalog.log.damaged-N. - PushHistory op so each edit logs one history step (not the full history). - engine: Session::open_library/persist/close_library; every top-level command is fsynced before returning; presets.json + view.json; library.info / library.compact commands. - app: default library ~/Pictures/LightCraft Library (LIGHTCRAFT_LIBRARY or --library DIR override), --memory for throwaway sessions, snapshot on quit. - tests: proptest replay(log)==state with random snapshots/reopens, every truncation of the last record, stale log, mid-log damage, FsStore, engine restart without close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
08617b02d9
commit
e7b3724b5f
Generated
+2
@@ -1991,7 +1991,9 @@ dependencies = [
|
|||||||
name = "lightcraft-catalog"
|
name = "lightcraft-catalog"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
"lightcraft-develop",
|
"lightcraft-develop",
|
||||||
|
"log",
|
||||||
"proptest",
|
"proptest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ log = "0.4"
|
|||||||
rayon = "1"
|
rayon = "1"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
bytemuck = { version = "1", features = ["derive"] }
|
bytemuck = { version = "1", features = ["derive"] }
|
||||||
|
crc32fast = "1"
|
||||||
|
siphasher = "1"
|
||||||
|
|
||||||
# formats
|
# formats
|
||||||
quick-xml = "0.38"
|
quick-xml = "0.38"
|
||||||
|
|||||||
@@ -221,8 +221,9 @@ LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estima
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||||
cargo run --release -p lightcraft # opens with a procedurally generated demo library
|
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
|
||||||
cargo run --release -p lightcraft -- ~/Pictures/trip # or point it at your photos (folders are scanned)
|
cargo run --release -p lightcraft -- ~/Pictures/trip # import your photos (folders are scanned)
|
||||||
|
cargo run --release -p lightcraft -- --memory # a throwaway in-memory demo session (writes nothing)
|
||||||
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
|
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
|
||||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
//! LightCraft desktop app.
|
//! LightCraft desktop app.
|
||||||
//!
|
//!
|
||||||
//! Usage: `lightcraft [--control <port>] [--demo] [files or folders…]`
|
//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control <port>] [files or folders…]`
|
||||||
|
//!
|
||||||
|
//! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else
|
||||||
|
//! `$LIGHTCRAFT_LIBRARY`, else `~/Pictures/LightCraft Library`; a new library starts with the
|
||||||
|
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
|
||||||
|
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
|
||||||
|
//! nothing (demo photos unless files are given; used by the README showcase scripts).
|
||||||
//!
|
//!
|
||||||
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||||
@@ -25,6 +31,9 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
fn on_exit(&mut self) {
|
fn on_exit(&mut self) {
|
||||||
save_prefs(&self.0);
|
save_prefs(&self.0);
|
||||||
|
if let Err(e) = self.0.session.close_library() {
|
||||||
|
eprintln!("lightcraft: saving the library failed: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,15 +112,50 @@ fn collect(path: &std::path::Path, out: &mut Vec<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The persistent library session (or an in-memory one with `--memory` / if the library can't open).
|
||||||
|
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> Session {
|
||||||
|
let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs();
|
||||||
|
if in_memory {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
let Some(dir) = dir else {
|
||||||
|
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY); running in memory");
|
||||||
|
return fallback();
|
||||||
|
};
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let mut s = Session::new().with_fs();
|
||||||
|
match s.open_library(&dir, seed_demo) {
|
||||||
|
Ok(r) => {
|
||||||
|
let (replayed, torn) = (r.replayed, r.torn_bytes);
|
||||||
|
eprintln!(
|
||||||
|
"lightcraft: library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
|
||||||
|
dir.display(),
|
||||||
|
s.catalog.len(),
|
||||||
|
if torn > 0 { ", torn tail repaired" } else { "" },
|
||||||
|
t0.elapsed().as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("lightcraft: can't open library {}: {e}; running in memory", dir.display());
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> eframe::Result {
|
fn main() -> eframe::Result {
|
||||||
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
let mut demo = true;
|
let mut seed_demo = true;
|
||||||
|
let mut in_memory = false;
|
||||||
|
let mut library_dir = lightcraft_engine::library::default_dir();
|
||||||
let mut args = std::env::args().skip(1);
|
let mut args = std::env::args().skip(1);
|
||||||
while let Some(a) = args.next() {
|
while let Some(a) = args.next() {
|
||||||
match a.as_str() {
|
match a.as_str() {
|
||||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||||
"--no-demo" => demo = false,
|
"--library" => library_dir = args.next().map(std::path::PathBuf::from),
|
||||||
|
"--no-demo" => seed_demo = false,
|
||||||
|
"--memory" | "--demo" => in_memory = true,
|
||||||
"--version" => {
|
"--version" => {
|
||||||
println!("lightcraft {}", env!("CARGO_PKG_VERSION"));
|
println!("lightcraft {}", env!("CARGO_PKG_VERSION"));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -134,7 +178,7 @@ fn main() -> eframe::Result {
|
|||||||
"LightCraft",
|
"LightCraft",
|
||||||
options,
|
options,
|
||||||
Box::new(move |cc| {
|
Box::new(move |cc| {
|
||||||
let session = if demo && files.is_empty() { Session::with_demo() } else { Session::new() }.with_fs();
|
let session = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||||
let mut app = LightcraftApp::new(session, services());
|
let mut app = LightcraftApp::new(session, services());
|
||||||
load_prefs(&mut app);
|
load_prefs(&mut app);
|
||||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ lightcraft-develop = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
crc32fast = { workspace = true }
|
||||||
|
log = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = { workspace = true }
|
proptest = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
//! 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.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.
|
||||||
|
//!
|
||||||
|
//! Loading = snapshot + replay of the records with `seq > snapshot seq`. Recovery rules:
|
||||||
|
//! - a **torn final record** (crash mid-append: truncated line or bad CRC at the end) is dropped
|
||||||
|
//! and the file is cut back to the last good record, so later appends start on a clean line;
|
||||||
|
//! - records already covered by the snapshot (crash between writing the snapshot and resetting
|
||||||
|
//! the log) are skipped;
|
||||||
|
//! - a bad record **followed by good ones** is real damage: replay stops there, the log is kept
|
||||||
|
//! as `catalog.log.damaged-<seq>`, and a fresh snapshot is written so the library stays usable.
|
||||||
|
|
||||||
|
use crate::store::Store;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct SnapshotPolicy {
|
||||||
|
pub max_records: u64,
|
||||||
|
pub max_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SnapshotPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
SnapshotPolicy { max_records: 2000, max_bytes: 16 << 20 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happened while loading.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct LoadReport {
|
||||||
|
/// `seq` of the snapshot that was loaded (0 = none).
|
||||||
|
pub snapshot_seq: u64,
|
||||||
|
/// Log records applied on top of the snapshot.
|
||||||
|
pub replayed: usize,
|
||||||
|
/// Records skipped because the snapshot already contained them.
|
||||||
|
pub stale: usize,
|
||||||
|
/// Records whose op failed to apply (should never happen; reported, not fatal).
|
||||||
|
pub failed: usize,
|
||||||
|
/// Bytes of a torn final record that were dropped.
|
||||||
|
pub torn_bytes: u64,
|
||||||
|
/// The damaged log was preserved under this name.
|
||||||
|
pub damaged: Option<String>,
|
||||||
|
/// No catalog files existed (a new library).
|
||||||
|
pub created: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Journal {
|
||||||
|
store: Box<dyn Store>,
|
||||||
|
/// `seq` of the last durable op.
|
||||||
|
seq: u64,
|
||||||
|
snapshot_seq: u64,
|
||||||
|
log_records: u64,
|
||||||
|
log_bytes: u64,
|
||||||
|
pub policy: SnapshotPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io(e: std::io::Error) -> CatalogError {
|
||||||
|
CatalogError::Io(e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One log line (without the newline) for op number `seq`.
|
||||||
|
pub fn encode_record(seq: u64, op: &Op) -> String {
|
||||||
|
let body = serde_json::to_string(op).unwrap_or_default();
|
||||||
|
let crc = crc32fast::hash(body.as_bytes());
|
||||||
|
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.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
serde_json::from_str(body).ok().map(|op| (seq, op))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SnapFile {
|
||||||
|
format: String,
|
||||||
|
version: u32,
|
||||||
|
seq: u64,
|
||||||
|
catalog: Catalog,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Journal {
|
||||||
|
/// Open (or create) the catalog in `store`: load the snapshot, replay the log, repair a torn
|
||||||
|
/// tail. Fails only if the snapshot itself is unreadable (then nothing is modified).
|
||||||
|
pub fn open(mut store: Box<dyn Store>) -> Result<(Journal, Catalog, LoadReport)> {
|
||||||
|
let mut report = LoadReport::default();
|
||||||
|
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 {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
(s.catalog, s.seq)
|
||||||
|
}
|
||||||
|
None => (Catalog::new(), 0),
|
||||||
|
};
|
||||||
|
report.snapshot_seq = snapshot_seq;
|
||||||
|
let mut j = Journal { store, seq: snapshot_seq, snapshot_seq, log_records: 0, log_bytes: 0, policy: SnapshotPolicy::default() };
|
||||||
|
let log = log.unwrap_or_default();
|
||||||
|
|
||||||
|
// Scan records; `good_end` is the byte offset just past the last good record.
|
||||||
|
let mut pos = 0usize;
|
||||||
|
let mut good_end = 0usize;
|
||||||
|
let mut damaged_at: Option<usize> = None;
|
||||||
|
let mut needs_newline = false;
|
||||||
|
while pos < log.len() {
|
||||||
|
let (line_end, next, has_nl) = match log[pos..].iter().position(|b| *b == b'\n') {
|
||||||
|
Some(i) => (pos + i, pos + i + 1, true),
|
||||||
|
None => (log.len(), log.len(), false),
|
||||||
|
};
|
||||||
|
let line = std::str::from_utf8(&log[pos..line_end]).ok().map(|l| l.trim_end_matches('\r'));
|
||||||
|
if line.is_some_and(|l| l.trim().is_empty()) {
|
||||||
|
pos = next;
|
||||||
|
good_end = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match line.and_then(decode_record) {
|
||||||
|
Some((seq, op)) if seq <= j.seq => {
|
||||||
|
// already in the snapshot
|
||||||
|
let _ = op;
|
||||||
|
report.stale += 1;
|
||||||
|
}
|
||||||
|
Some((seq, op)) if seq == j.seq + 1 => {
|
||||||
|
if catalog.apply(op).is_err() {
|
||||||
|
report.failed += 1;
|
||||||
|
} else {
|
||||||
|
report.replayed += 1;
|
||||||
|
}
|
||||||
|
j.seq = seq;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 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());
|
||||||
|
if rest_has_good {
|
||||||
|
damaged_at = Some(pos);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.log_records += 1;
|
||||||
|
pos = next;
|
||||||
|
good_end = next;
|
||||||
|
needs_newline = !has_nl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(at) = damaged_at {
|
||||||
|
let name = format!("{LOG}.damaged-{}", j.seq);
|
||||||
|
j.store.write_atomic(&name, &log).map_err(io)?;
|
||||||
|
log::warn!("catalog log damaged at byte {at}; kept as {name}; state recovered up to op {}", j.seq);
|
||||||
|
report.damaged = Some(name);
|
||||||
|
j.snapshot(&catalog)?;
|
||||||
|
} else {
|
||||||
|
if good_end < log.len() {
|
||||||
|
report.torn_bytes = (log.len() - good_end) as u64;
|
||||||
|
log::warn!("catalog log: dropped a torn final record ({} bytes)", report.torn_bytes);
|
||||||
|
j.store.truncate(LOG, good_end as u64).map_err(io)?;
|
||||||
|
} else if needs_newline {
|
||||||
|
j.store.append(LOG, b"\n").map_err(io)?;
|
||||||
|
}
|
||||||
|
j.log_bytes = good_end as u64;
|
||||||
|
}
|
||||||
|
catalog.revision = 0;
|
||||||
|
Ok((j, catalog, report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append ops (already applied to the live catalog) durably, in order.
|
||||||
|
pub fn append(&mut self, ops: &[Op]) -> Result<()> {
|
||||||
|
if ops.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut buf = String::new();
|
||||||
|
let mut seq = self.seq;
|
||||||
|
for op in ops {
|
||||||
|
seq += 1;
|
||||||
|
buf.push_str(&encode_record(seq, op));
|
||||||
|
buf.push('\n');
|
||||||
|
}
|
||||||
|
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
|
||||||
|
self.seq = seq;
|
||||||
|
self.log_records += ops.len() as u64;
|
||||||
|
self.log_bytes += buf.len() as u64;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
|
||||||
|
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
|
||||||
|
let body = serde_json::to_string(catalog).map_err(|e| CatalogError::Invalid(e.to_string()))?;
|
||||||
|
let file = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{},\"catalog\":{body}}}\n", self.seq);
|
||||||
|
self.store.write_atomic(SNAPSHOT, file.as_bytes()).map_err(io)?;
|
||||||
|
// A crash here leaves old records in the log; they are skipped by seq on load.
|
||||||
|
self.store.write_atomic(LOG, b"").map_err(io)?;
|
||||||
|
self.snapshot_seq = self.seq;
|
||||||
|
self.log_records = 0;
|
||||||
|
self.log_bytes = 0;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The log is long enough to be worth compacting.
|
||||||
|
pub fn wants_snapshot(&self) -> bool {
|
||||||
|
self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seq(&self) -> u64 {
|
||||||
|
self.seq
|
||||||
|
}
|
||||||
|
pub fn snapshot_seq(&self) -> u64 {
|
||||||
|
self.snapshot_seq
|
||||||
|
}
|
||||||
|
pub fn log_records(&self) -> u64 {
|
||||||
|
self.log_records
|
||||||
|
}
|
||||||
|
pub fn log_bytes(&self) -> u64 {
|
||||||
|
self.log_bytes
|
||||||
|
}
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
self.store.describe()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,21 +2,25 @@
|
|||||||
//!
|
//!
|
||||||
//! State changes only through [`Op`]s. [`Catalog::apply`] returns the inverse op, which gives:
|
//! State changes only through [`Op`]s. [`Catalog::apply`] returns the inverse op, which gives:
|
||||||
//! - **persistence**: ops are appended to a log (JSON lines) and replayed on load after the last
|
//! - **persistence**: ops are appended to a log (JSON lines) and replayed on load after the last
|
||||||
//! snapshot — crash-safe and diff-friendly;
|
//! snapshot — crash-safe and diff-friendly (see [`journal`]);
|
||||||
//! - **undo/redo**: the engine keeps inverse ops;
|
//! - **undo/redo**: the engine keeps inverse ops;
|
||||||
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
|
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod journal;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
|
pub mod store;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub use journal::{Journal, LoadReport, SnapshotPolicy};
|
||||||
use lightcraft_develop::DevelopSettings;
|
use lightcraft_develop::DevelopSettings;
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
pub use store::{FsStore, MemStore, Store};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||||
pub enum CatalogError {
|
pub enum CatalogError {
|
||||||
@@ -28,10 +32,15 @@ pub enum CatalogError {
|
|||||||
Invalid(String),
|
Invalid(String),
|
||||||
#[error("corrupt catalog data: {0}")]
|
#[error("corrupt catalog data: {0}")]
|
||||||
Corrupt(String),
|
Corrupt(String),
|
||||||
|
#[error("catalog storage: {0}")]
|
||||||
|
Io(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, CatalogError>;
|
pub type Result<T> = std::result::Result<T, CatalogError>;
|
||||||
|
|
||||||
|
/// Maximum History entries kept per photo.
|
||||||
|
pub const HISTORY_LIMIT: usize = 200;
|
||||||
|
|
||||||
/// Every catalog mutation.
|
/// Every catalog mutation.
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "op", rename_all = "camelCase")]
|
#[serde(tag = "op", rename_all = "camelCase")]
|
||||||
@@ -76,6 +85,12 @@ pub enum Op {
|
|||||||
id: PhotoId,
|
id: PhotoId,
|
||||||
history: Vec<HistoryStep>,
|
history: Vec<HistoryStep>,
|
||||||
},
|
},
|
||||||
|
/// Append one History entry (dropping the oldest beyond [`HISTORY_LIMIT`]). Logged instead of
|
||||||
|
/// a full `SetHistory` so each edit costs one step in the op log.
|
||||||
|
PushHistory {
|
||||||
|
id: PhotoId,
|
||||||
|
step: HistoryStep,
|
||||||
|
},
|
||||||
AddAlbum {
|
AddAlbum {
|
||||||
album: Album,
|
album: Album,
|
||||||
},
|
},
|
||||||
@@ -227,6 +242,16 @@ impl Catalog {
|
|||||||
let p = self.photo_mut(id)?;
|
let p = self.photo_mut(id)?;
|
||||||
Op::SetHistory { id, history: std::mem::replace(&mut p.history, history) }
|
Op::SetHistory { id, history: std::mem::replace(&mut p.history, history) }
|
||||||
}
|
}
|
||||||
|
Op::PushHistory { id, step } => {
|
||||||
|
let p = self.photo_mut(id)?;
|
||||||
|
let old = p.history.clone();
|
||||||
|
p.history.push(step);
|
||||||
|
if p.history.len() > HISTORY_LIMIT {
|
||||||
|
let n = p.history.len() - HISTORY_LIMIT;
|
||||||
|
p.history.drain(..n);
|
||||||
|
}
|
||||||
|
Op::SetHistory { id, history: old }
|
||||||
|
}
|
||||||
Op::AddAlbum { album } => {
|
Op::AddAlbum { album } => {
|
||||||
if self.albums.contains_key(&album.id) {
|
if self.albums.contains_key(&album.id) {
|
||||||
return Err(CatalogError::Invalid(format!("album {:?} exists", album.id)));
|
return Err(CatalogError::Invalid(format!("album {:?} exists", album.id)));
|
||||||
@@ -352,3 +377,5 @@ impl Catalog {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_journal;
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ pub struct Photo {
|
|||||||
/// Raw files: the camera's as-shot white balance (Kelvin, tint).
|
/// Raw files: the camera's as-shot white balance (Kelvin, tint).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub as_shot_wb: Option<(f64, f64)>,
|
pub as_shot_wb: Option<(f64, f64)>,
|
||||||
|
/// Hash of the file's bytes (hex), for duplicate detection and preview-cache keys.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content_hash: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Photo {
|
impl Photo {
|
||||||
@@ -169,6 +172,7 @@ impl Photo {
|
|||||||
deleted: false,
|
deleted: false,
|
||||||
duration: None,
|
duration: None,
|
||||||
as_shot_wb: None,
|
as_shot_wb: None,
|
||||||
|
content_hash: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn is_edited(&self) -> bool {
|
pub fn is_edited(&self) -> bool {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! Byte storage for the catalog files (`catalog.snap`, `catalog.log`).
|
||||||
|
//!
|
||||||
|
//! [`FsStore`] is the native implementation (a library directory); [`MemStore`] keeps files in
|
||||||
|
//! memory (tests, ephemeral sessions) and lets tests simulate crashes by editing the bytes. A web
|
||||||
|
//! host can implement [`Store`] over OPFS.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// A flat namespace of named files with the few operations the journal needs.
|
||||||
|
pub trait Store: Send {
|
||||||
|
/// The whole file, or `None` if it doesn't exist.
|
||||||
|
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>>;
|
||||||
|
/// Replace a file atomically: after a crash either the old or the new content is visible,
|
||||||
|
/// never a mix (native: write temp + fsync + rename + fsync directory).
|
||||||
|
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||||
|
/// Append and make durable (fsync) before returning.
|
||||||
|
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||||
|
/// Cut a file to `len` bytes (drop a torn tail before appending again).
|
||||||
|
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()>;
|
||||||
|
/// Human-readable location (diagnostics).
|
||||||
|
fn describe(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files in a directory.
|
||||||
|
pub struct FsStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
/// Open append handle (kept between appends; dropped on rewrite/truncate).
|
||||||
|
appender: Option<(String, std::fs::File)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsStore {
|
||||||
|
/// Use `dir` (created if missing).
|
||||||
|
pub fn open(dir: impl AsRef<Path>) -> io::Result<FsStore> {
|
||||||
|
std::fs::create_dir_all(dir.as_ref())?;
|
||||||
|
Ok(FsStore { dir: dir.as_ref().to_path_buf(), appender: None })
|
||||||
|
}
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
fn path(&self, name: &str) -> PathBuf {
|
||||||
|
self.dir.join(name)
|
||||||
|
}
|
||||||
|
fn sync_dir(&self) {
|
||||||
|
// Makes the rename durable on POSIX; opening a directory fails on Windows (where
|
||||||
|
// `rename` is already journaled by NTFS), so errors are ignored.
|
||||||
|
if let Ok(d) = std::fs::File::open(&self.dir) {
|
||||||
|
let _ = d.sync_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store for FsStore {
|
||||||
|
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
|
||||||
|
match std::fs::read(self.path(name)) {
|
||||||
|
Ok(b) => Ok(Some(b)),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
|
||||||
|
self.appender = None;
|
||||||
|
}
|
||||||
|
let tmp = self.path(&format!("{name}.tmp"));
|
||||||
|
{
|
||||||
|
let mut f = std::fs::File::create(&tmp)?;
|
||||||
|
f.write_all(data)?;
|
||||||
|
f.sync_all()?;
|
||||||
|
}
|
||||||
|
std::fs::rename(&tmp, self.path(name))?;
|
||||||
|
self.sync_dir();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
if self.appender.as_ref().is_none_or(|(n, _)| n != name) {
|
||||||
|
let existed = self.path(name).exists();
|
||||||
|
let f = std::fs::OpenOptions::new().create(true).append(true).open(self.path(name))?;
|
||||||
|
if !existed {
|
||||||
|
self.sync_dir();
|
||||||
|
}
|
||||||
|
self.appender = Some((name.to_string(), f));
|
||||||
|
}
|
||||||
|
let (_, f) = self.appender.as_mut().expect("appender just opened");
|
||||||
|
f.write_all(data)?;
|
||||||
|
f.sync_data()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
|
||||||
|
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
|
||||||
|
self.appender = None;
|
||||||
|
}
|
||||||
|
let f = std::fs::OpenOptions::new().write(true).open(self.path(name))?;
|
||||||
|
f.set_len(len)?;
|
||||||
|
f.sync_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe(&self) -> String {
|
||||||
|
self.dir.display().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory files. Clones share the same files (so a test can "reopen" or corrupt them).
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct MemStore {
|
||||||
|
pub files: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemStore {
|
||||||
|
pub fn new() -> MemStore {
|
||||||
|
MemStore::default()
|
||||||
|
}
|
||||||
|
pub fn get(&self, name: &str) -> Option<Vec<u8>> {
|
||||||
|
self.files.lock().unwrap_or_else(|e| e.into_inner()).get(name).cloned()
|
||||||
|
}
|
||||||
|
pub fn set(&self, name: &str, data: Vec<u8>) {
|
||||||
|
self.files.lock().unwrap_or_else(|e| e.into_inner()).insert(name.to_string(), data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store for MemStore {
|
||||||
|
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
|
||||||
|
Ok(self.get(name))
|
||||||
|
}
|
||||||
|
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||||
|
self.set(name, data.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||||
|
self.files.lock().unwrap_or_else(|e| e.into_inner()).entry(name.to_string()).or_default().extend_from_slice(data);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
|
||||||
|
if let Some(f) = self.files.lock().unwrap_or_else(|e| e.into_inner()).get_mut(name) {
|
||||||
|
f.truncate(len as usize);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn describe(&self) -> String {
|
||||||
|
"memory".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
//! Journal (op log + snapshot) tests: replay(log) == state, torn tails, stale logs, damage.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use lightcraft_develop::DevelopSettings;
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use crate::journal::{LOG, SNAPSHOT, decode_record, encode_record};
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
fn open(m: &MemStore) -> (Journal, Catalog, LoadReport) {
|
||||||
|
Journal::open(Box::new(m.clone())).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interpret a random action against the live catalog as an op (may be invalid: then the live
|
||||||
|
/// apply fails and, like in the engine, nothing is logged).
|
||||||
|
fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
|
||||||
|
let photos: Vec<PhotoId> = c.photos().map(|p| p.id).collect();
|
||||||
|
let albums: Vec<AlbumId> = c.albums().map(|a| a.id).collect();
|
||||||
|
let pid = photos.get(a as usize % photos.len().max(1)).copied().unwrap_or(PhotoId(99));
|
||||||
|
let aid = albums.get(a as usize % albums.len().max(1)).copied().unwrap_or(AlbumId(99));
|
||||||
|
match kind % 10 {
|
||||||
|
0 | 1 => {
|
||||||
|
let id = c.alloc_photo_id();
|
||||||
|
let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01");
|
||||||
|
p.content_hash = Some(format!("{:032x}", id.0 * 7919));
|
||||||
|
Op::AddPhoto { photo: Box::new(p) }
|
||||||
|
}
|
||||||
|
2 => Op::SetRating { id: pid, rating: b % 7 },
|
||||||
|
3 => Op::SetFlag { id: pid, flag: [Flag::None, Flag::Pick, Flag::Reject][b as usize % 3] },
|
||||||
|
4 => {
|
||||||
|
let mut d = DevelopSettings::default();
|
||||||
|
d.light.exposure = b as f64 / 37.0 - 2.0;
|
||||||
|
d.light.contrast = -(a as f64) * 0.37;
|
||||||
|
let settings = Arc::new(d);
|
||||||
|
Op::Batch {
|
||||||
|
ops: vec![
|
||||||
|
Op::SetDevelop { id: pid, settings: settings.clone(), label: "Exposure".into(), edited: Some(format!("t{b}")) },
|
||||||
|
Op::PushHistory { id: pid, step: HistoryStep { label: "Exposure".into(), settings } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
let id = c.alloc_album_id();
|
||||||
|
Op::AddAlbum { album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None } }
|
||||||
|
}
|
||||||
|
6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() },
|
||||||
|
7 => Op::RenameAlbum { id: aid, name: format!("Renamed {b} ✓") },
|
||||||
|
8 => Op::SetDeleted { id: pid, deleted: b.is_multiple_of(2) },
|
||||||
|
_ => c.delete_permanently_ops(pid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||||
|
|
||||||
|
/// replay(snapshot + log) == live state, with snapshots at random points and reopening
|
||||||
|
/// (resuming appends) in between.
|
||||||
|
#[test]
|
||||||
|
fn replay_equals_state(actions in proptest::collection::vec((0u8..20, 0u8..255, 0u8..255, 0u8..12), 1..80)) {
|
||||||
|
let m = MemStore::new();
|
||||||
|
let (mut j, mut live, r) = open(&m);
|
||||||
|
prop_assert!(r.created);
|
||||||
|
for (kind, a, b, ctl) in actions {
|
||||||
|
let op = op_for(&mut live, kind, a, b);
|
||||||
|
if live.apply(op.clone()).is_ok() {
|
||||||
|
j.append(&[op]).unwrap();
|
||||||
|
}
|
||||||
|
match ctl {
|
||||||
|
0 => j.snapshot(&live).unwrap(),
|
||||||
|
1 => {
|
||||||
|
let (j2, c2, _) = open(&m);
|
||||||
|
prop_assert_eq!(c2.to_snapshot(), live.to_snapshot());
|
||||||
|
j = j2;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (_, loaded, r) = open(&m);
|
||||||
|
prop_assert_eq!(r.failed, 0);
|
||||||
|
prop_assert_eq!(loaded.to_snapshot(), live.to_snapshot());
|
||||||
|
// allocation continues where it left off
|
||||||
|
prop_assert_eq!(loaded.clone().alloc_photo_id(), live.clone().alloc_photo_id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_catalog_with_log() -> (MemStore, Catalog, Catalog) {
|
||||||
|
let m = MemStore::new();
|
||||||
|
let (mut j, mut c, _) = open(&m);
|
||||||
|
for k in 0..6u8 {
|
||||||
|
let op = op_for(&mut c, k, k, k * 3);
|
||||||
|
if c.apply(op.clone()).is_ok() {
|
||||||
|
j.append(&[op]).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let before_last = c.clone();
|
||||||
|
let op = Op::SetRating { id: c.photos().next().unwrap().id, rating: 5 };
|
||||||
|
c.apply(op.clone()).unwrap();
|
||||||
|
j.append(&[op]).unwrap();
|
||||||
|
(m, before_last, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A crash mid-append: every possible truncation of the last record still loads (to the state
|
||||||
|
/// before that op), the torn bytes are cut off, and appending afterwards works.
|
||||||
|
#[test]
|
||||||
|
fn torn_last_record_is_tolerated() {
|
||||||
|
let (m, before_last, full) = sample_catalog_with_log();
|
||||||
|
let log = m.get(LOG).unwrap();
|
||||||
|
let last_start = log[..log.len() - 1].iter().rposition(|b| *b == b'\n').unwrap() + 1;
|
||||||
|
for cut in last_start..log.len() {
|
||||||
|
let t = MemStore::new();
|
||||||
|
t.set(LOG, log[..cut].to_vec());
|
||||||
|
let (mut j, mut c, r) = open(&t);
|
||||||
|
// the full record minus only its newline is complete and kept
|
||||||
|
let expect = if cut == log.len() - 1 { &full } else { &before_last };
|
||||||
|
assert_eq!(c.to_snapshot(), expect.to_snapshot(), "cut at {cut}");
|
||||||
|
assert!(r.damaged.is_none());
|
||||||
|
if cut < log.len() - 1 {
|
||||||
|
assert_eq!(r.torn_bytes as usize, cut - last_start, "cut at {cut}");
|
||||||
|
}
|
||||||
|
// keeps working after recovery
|
||||||
|
let id = c.photos().next().unwrap().id;
|
||||||
|
let op = Op::SetFlag { id, flag: Flag::Reject };
|
||||||
|
c.apply(op.clone()).unwrap();
|
||||||
|
j.append(&[op]).unwrap();
|
||||||
|
let (_, c2, r2) = open(&t);
|
||||||
|
assert_eq!(c2.to_snapshot(), c.to_snapshot(), "cut at {cut}");
|
||||||
|
assert_eq!(r2.torn_bytes, 0);
|
||||||
|
}
|
||||||
|
// garbage bytes (a partially flushed page) at the end are a torn tail too
|
||||||
|
let t = MemStore::new();
|
||||||
|
let mut g = log.clone();
|
||||||
|
g.extend_from_slice(&[0, 0, 0, b'{', 0xff]);
|
||||||
|
t.set(LOG, g);
|
||||||
|
let (_, c, r) = open(&t);
|
||||||
|
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||||
|
assert_eq!(r.torn_bytes, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crash after the snapshot was written but before the log was reset: the stale records are
|
||||||
|
/// skipped by sequence number.
|
||||||
|
#[test]
|
||||||
|
fn stale_log_after_snapshot_is_skipped() {
|
||||||
|
let (m, _, full) = sample_catalog_with_log();
|
||||||
|
let old_log = m.get(LOG).unwrap();
|
||||||
|
let (mut j, c, _) = open(&m);
|
||||||
|
j.snapshot(&c).unwrap();
|
||||||
|
assert!(m.get(LOG).unwrap().is_empty());
|
||||||
|
m.set(LOG, old_log);
|
||||||
|
let (_, c2, r) = open(&m);
|
||||||
|
assert_eq!(c2.to_snapshot(), full.to_snapshot());
|
||||||
|
assert!(r.stale > 0 && r.replayed == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Damage in the middle of the log (not a torn tail): recover what precedes it, keep the
|
||||||
|
/// damaged file, and write a consistent snapshot.
|
||||||
|
#[test]
|
||||||
|
fn mid_log_damage_is_preserved_and_recovered() {
|
||||||
|
let (m, _, _) = sample_catalog_with_log();
|
||||||
|
let log = m.get(LOG).unwrap();
|
||||||
|
let text = String::from_utf8(log).unwrap();
|
||||||
|
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
|
||||||
|
assert!(lines.len() > 4);
|
||||||
|
lines[2] = lines[2].replacen("\"op\":{\"op\":\"", "\"op\":{\"op\":\"x", 1);
|
||||||
|
let bad = format!("{}\n", lines.join("\n")).into_bytes();
|
||||||
|
m.set(LOG, bad.clone());
|
||||||
|
let (_, c, r) = open(&m);
|
||||||
|
let name = r.damaged.clone().expect("damage reported");
|
||||||
|
assert_eq!(m.get(&name).unwrap(), bad);
|
||||||
|
assert_eq!(r.replayed, 2);
|
||||||
|
// the recovered state was snapshotted: reopening is clean and identical
|
||||||
|
let (_, c2, r2) = open(&m);
|
||||||
|
assert_eq!(c2.to_snapshot(), c.to_snapshot());
|
||||||
|
assert!(r2.damaged.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_codec() {
|
||||||
|
let op = Op::RenameAlbum { id: AlbumId(3), name: "Line\nbreak \"quoted\" }".into() };
|
||||||
|
let line = encode_record(42, &op);
|
||||||
|
assert!(!line.contains('\n'));
|
||||||
|
assert_eq!(decode_record(&line), Some((42, op)));
|
||||||
|
assert_eq!(decode_record(&line.replace("Line", "Lime")), None, "crc mismatch");
|
||||||
|
assert_eq!(decode_record(&line[..line.len() - 1]), None);
|
||||||
|
// log lines stay plain JSON
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(v["op"]["op"], "renameAlbum");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_history_is_bounded_and_invertible() {
|
||||||
|
let mut c = Catalog::new();
|
||||||
|
let id = c.alloc_photo_id();
|
||||||
|
c.apply(Op::AddPhoto { photo: Box::new(Photo::new(id, Source::Demo { scene: 1 }, "a", "JPEG", 1, 1, "t")) }).unwrap();
|
||||||
|
let step = |i: usize| HistoryStep { label: format!("s{i}"), settings: Arc::new(DevelopSettings::default()) };
|
||||||
|
for i in 0..HISTORY_LIMIT + 5 {
|
||||||
|
c.apply(Op::PushHistory { id, step: step(i) }).unwrap();
|
||||||
|
}
|
||||||
|
let h = &c.photo(id).unwrap().history;
|
||||||
|
assert_eq!(h.len(), HISTORY_LIMIT);
|
||||||
|
assert_eq!(h[0].label, "s5");
|
||||||
|
let before = c.to_snapshot();
|
||||||
|
let inv = c.apply(Op::PushHistory { id, step: step(999) }).unwrap();
|
||||||
|
c.apply(inv).unwrap();
|
||||||
|
assert_eq!(c.to_snapshot(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fs_store_roundtrip() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("lc-journal-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let (mut j, mut c, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||||
|
assert!(r.created);
|
||||||
|
let id = c.alloc_photo_id();
|
||||||
|
let add = Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: "/a.jpg".into() }, "a.jpg", "JPEG", 1, 1, "t")) };
|
||||||
|
c.apply(add.clone()).unwrap();
|
||||||
|
c.apply(Op::SetRating { id, rating: 4 }).unwrap();
|
||||||
|
j.append(&[add, Op::SetRating { id, rating: 4 }]).unwrap();
|
||||||
|
drop(j);
|
||||||
|
let (mut j, c2, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||||
|
assert_eq!((r.replayed, r.created), (2, false));
|
||||||
|
assert_eq!(c2.to_snapshot(), c.to_snapshot());
|
||||||
|
j.snapshot(&c2).unwrap();
|
||||||
|
assert_eq!(std::fs::metadata(dir.join(LOG)).unwrap().len(), 0);
|
||||||
|
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
|
||||||
|
let (_, c3, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||||
|
assert_eq!((r.snapshot_seq, r.replayed), (2, 0));
|
||||||
|
assert_eq!(c3.to_snapshot(), c.to_snapshot());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
@@ -382,5 +382,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
}
|
}
|
||||||
Ok(json!({"imported": imported.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
Ok(json!({"imported": imported.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||||
}),
|
}),
|
||||||
|
// ---- persistence
|
||||||
|
cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| {
|
||||||
|
let photos = s.catalog.len();
|
||||||
|
let albums = s.catalog.albums().count();
|
||||||
|
let Some(lib) = &s.library else {
|
||||||
|
return Ok(json!({"persistent": false, "photos": photos, "albums": albums}));
|
||||||
|
};
|
||||||
|
let j = lib.journal();
|
||||||
|
Ok(json!({
|
||||||
|
"persistent": true,
|
||||||
|
"path": lib.dir.display().to_string(),
|
||||||
|
"photos": photos,
|
||||||
|
"albums": albums,
|
||||||
|
"seq": j.seq(),
|
||||||
|
"snapshotSeq": j.snapshot_seq(),
|
||||||
|
"logRecords": j.log_records(),
|
||||||
|
"logBytes": j.log_bytes(),
|
||||||
|
"lastError": lib.last_error,
|
||||||
|
"load": {
|
||||||
|
"created": lib.report.created,
|
||||||
|
"replayed": lib.report.replayed,
|
||||||
|
"tornBytes": lib.report.torn_bytes,
|
||||||
|
"damaged": lib.report.damaged,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
cmd!("library.compact", "Optimize Library", ["File"], None, "{}", has_library, |s, _| {
|
||||||
|
s.compact_library()?;
|
||||||
|
ok()
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_library(s: &Session) -> std::result::Result<(), String> {
|
||||||
|
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
pub mod cmd;
|
pub mod cmd;
|
||||||
pub mod demo;
|
pub mod demo;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod library;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod presets;
|
pub mod presets;
|
||||||
mod view;
|
mod view;
|
||||||
@@ -85,6 +86,8 @@ pub struct Session {
|
|||||||
depth: u32,
|
depth: u32,
|
||||||
/// Selected mask (Masking panel), by mask id.
|
/// Selected mask (Masking panel), by mask id.
|
||||||
pub active_mask: Option<u32>,
|
pub active_mask: Option<u32>,
|
||||||
|
/// The persistent library this session writes to (`None` = in-memory only).
|
||||||
|
pub library: Option<library::Library>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Session {
|
impl Default for Session {
|
||||||
@@ -115,6 +118,7 @@ impl Session {
|
|||||||
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
|
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
active_mask: None,
|
active_mask: None,
|
||||||
|
library: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +144,11 @@ impl Session {
|
|||||||
self.journal.drain(..1000);
|
self.journal.drain(..1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if self.depth == 0 && self.library.is_some() {
|
||||||
|
// Make the command durable before reporting success (on failure the ops stay pending,
|
||||||
|
// are retried after the next command, and `library.info` reports the error).
|
||||||
|
let _ = self.persist();
|
||||||
|
}
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,15 +225,11 @@ impl Session {
|
|||||||
|
|
||||||
/// The op that sets a photo's develop settings and appends a History entry (for batches).
|
/// The op that sets a photo's develop settings and appends a History entry (for batches).
|
||||||
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
|
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
|
||||||
let p = self.catalog.photo(id)?;
|
self.catalog.photo(id)?;
|
||||||
let settings = Arc::new(settings);
|
let settings = Arc::new(settings);
|
||||||
let mut history = p.history.clone();
|
let step = lightcraft_catalog::HistoryStep { label: label.into(), settings: settings.clone() };
|
||||||
history.push(lightcraft_catalog::HistoryStep { label: label.into(), settings: settings.clone() });
|
|
||||||
if history.len() > 200 {
|
|
||||||
history.remove(0);
|
|
||||||
}
|
|
||||||
Some(Op::Batch {
|
Some(Op::Batch {
|
||||||
ops: vec![Op::SetDevelop { id, settings, label: label.into(), edited: Some((self.clock)()) }, Op::SetHistory { id, history }],
|
ops: vec![Op::SetDevelop { id, settings, label: label.into(), edited: Some((self.clock)()) }, Op::PushHistory { id, step }],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,3 +305,5 @@ impl Session {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_library;
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! A persistent library: a directory holding the catalog (op log + snapshot), user presets, the
|
||||||
|
//! last view state and the preview cache.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! LightCraft Library/
|
||||||
|
//! catalog.snap catalog.log (lightcraft-catalog journal)
|
||||||
|
//! presets.json view.json (user presets + favourites; last source/filter/sort/selection)
|
||||||
|
//! thumbs/ (rendered thumbnail cache, safe to delete)
|
||||||
|
//! Originals/ (photos imported with "copy into library")
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Every top-level [`Session::execute`] persists the ops it produced (fsynced) before returning,
|
||||||
|
//! so a crash loses at most the command in flight. The log is compacted into a snapshot when it
|
||||||
|
//! grows (see [`lightcraft_catalog::SnapshotPolicy`]) and on [`Session::close_library`].
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use lightcraft_catalog::{FsStore, Journal, LoadReport, Store};
|
||||||
|
use lightcraft_develop::Preset;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{EngineError, LibrarySource, Result, Selection, Session};
|
||||||
|
|
||||||
|
/// Library directory name inside the user's Pictures folder.
|
||||||
|
pub const DEFAULT_NAME: &str = "LightCraft Library";
|
||||||
|
|
||||||
|
/// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library`
|
||||||
|
/// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows).
|
||||||
|
pub fn default_dir() -> Option<PathBuf> {
|
||||||
|
if let Some(p) = std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()) {
|
||||||
|
return Some(PathBuf::from(p));
|
||||||
|
}
|
||||||
|
let home = if cfg!(windows) { std::env::var_os("USERPROFILE") } else { std::env::var_os("HOME") }?;
|
||||||
|
Some(PathBuf::from(home).join("Pictures").join(DEFAULT_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Library {
|
||||||
|
pub dir: PathBuf,
|
||||||
|
journal: Journal,
|
||||||
|
/// What happened when the catalog was loaded.
|
||||||
|
pub report: LoadReport,
|
||||||
|
/// Last persistence error (shown by the UI; ops stay pending and are retried).
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
presets_written: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
struct PresetsFile {
|
||||||
|
user: Vec<Preset>,
|
||||||
|
favorites: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct ViewFile {
|
||||||
|
source: LibrarySource,
|
||||||
|
filter: lightcraft_catalog::Filter,
|
||||||
|
sort: lightcraft_catalog::Sort,
|
||||||
|
selection: Selection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library {
|
||||||
|
pub fn journal(&self) -> &Journal {
|
||||||
|
&self.journal
|
||||||
|
}
|
||||||
|
pub fn thumbs_dir(&self) -> PathBuf {
|
||||||
|
self.dir.join("thumbs")
|
||||||
|
}
|
||||||
|
pub fn originals_dir(&self) -> PathBuf {
|
||||||
|
self.dir.join("Originals")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn presets_json(presets: &[Preset]) -> String {
|
||||||
|
let f = PresetsFile {
|
||||||
|
user: presets.iter().filter(|p| !p.builtin).cloned().collect(),
|
||||||
|
favorites: presets.iter().filter(|p| p.builtin && p.favorite).map(|p| p.id.clone()).collect(),
|
||||||
|
};
|
||||||
|
serde_json::to_string_pretty(&f).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(dir: &Path, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||||
|
FsStore::open(dir)?.write_atomic(name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Open (or create) the library at `dir` into this session, replacing its catalog. With
|
||||||
|
/// `seed_demo`, a newly created library starts with the procedural demo photos.
|
||||||
|
pub fn open_library(&mut self, dir: impl AsRef<Path>, seed_demo: bool) -> Result<&LoadReport> {
|
||||||
|
let dir = dir.as_ref().to_path_buf();
|
||||||
|
let store = FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())))?;
|
||||||
|
let (mut journal, catalog, report) = Journal::open(Box::new(store))?;
|
||||||
|
self.catalog = catalog;
|
||||||
|
self.undo.clear();
|
||||||
|
self.redo.clear();
|
||||||
|
self.interaction = None;
|
||||||
|
self.pending_log.clear();
|
||||||
|
self.selection = Selection::default();
|
||||||
|
self.source = LibrarySource::All;
|
||||||
|
if report.created && seed_demo {
|
||||||
|
crate::demo::load(self);
|
||||||
|
journal.snapshot(&self.catalog)?;
|
||||||
|
}
|
||||||
|
// presets
|
||||||
|
if let Ok(bytes) = std::fs::read(dir.join("presets.json"))
|
||||||
|
&& let Ok(f) = serde_json::from_slice::<PresetsFile>(&bytes)
|
||||||
|
{
|
||||||
|
for p in &mut self.presets {
|
||||||
|
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
|
||||||
|
}
|
||||||
|
for u in f.user {
|
||||||
|
if !self.presets.iter().any(|p| p.id == u.id) {
|
||||||
|
self.presets.push(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// view state
|
||||||
|
if let Ok(bytes) = std::fs::read(dir.join("view.json"))
|
||||||
|
&& let Ok(v) = serde_json::from_slice::<ViewFile>(&bytes)
|
||||||
|
{
|
||||||
|
self.source = v.source;
|
||||||
|
self.filter = v.filter;
|
||||||
|
self.sort = v.sort;
|
||||||
|
self.selection = v.selection;
|
||||||
|
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
|
||||||
|
self.selection.active = self.selection.active.filter(|id| self.catalog.photo(*id).is_some());
|
||||||
|
}
|
||||||
|
if self.selection.active.is_none()
|
||||||
|
&& let Some(first) = self.visible_cloned().first()
|
||||||
|
{
|
||||||
|
self.selection = Selection::single(*first);
|
||||||
|
}
|
||||||
|
let presets_written = presets_json(&self.presets);
|
||||||
|
self.library = Some(Library { dir, journal, report, last_error: None, presets_written });
|
||||||
|
Ok(&self.library.as_ref().expect("just set").report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
|
||||||
|
/// Called after every top-level command; cheap when nothing changed.
|
||||||
|
pub fn persist(&mut self) -> Result<()> {
|
||||||
|
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||||
|
if !self.pending_log.is_empty() {
|
||||||
|
if let Err(e) = lib.journal.append(&self.pending_log) {
|
||||||
|
lib.last_error = Some(e.to_string());
|
||||||
|
log::error!("library: {e}");
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
self.pending_log.clear();
|
||||||
|
lib.last_error = None;
|
||||||
|
}
|
||||||
|
// Never snapshot mid-interaction: the catalog then holds an uncommitted preview value.
|
||||||
|
if lib.journal.wants_snapshot() && self.interaction.is_none() {
|
||||||
|
lib.journal.snapshot(&self.catalog)?;
|
||||||
|
}
|
||||||
|
let presets = presets_json(&self.presets);
|
||||||
|
if presets != lib.presets_written {
|
||||||
|
if let Err(e) = write_atomic(&lib.dir, "presets.json", presets.as_bytes()) {
|
||||||
|
log::error!("library: presets: {e}");
|
||||||
|
} else {
|
||||||
|
lib.presets_written = presets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist if commands left ops pending (cheap; frontends call it once per frame for state
|
||||||
|
/// changed outside [`Session::execute`]).
|
||||||
|
pub fn persist_if_dirty(&mut self) {
|
||||||
|
if self.library.is_some() && !self.pending_log.is_empty() {
|
||||||
|
let _ = self.persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
|
||||||
|
pub fn close_library(&mut self) -> Result<()> {
|
||||||
|
if self.library.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let _ = self.end_interaction();
|
||||||
|
self.persist()?;
|
||||||
|
let view = ViewFile { source: self.source, filter: self.filter.clone(), sort: self.sort, selection: self.selection.clone() };
|
||||||
|
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||||
|
lib.journal.snapshot(&self.catalog)?;
|
||||||
|
if let Ok(v) = serde_json::to_vec_pretty(&view) {
|
||||||
|
let _ = write_atomic(&lib.dir, "view.json", &v);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact the log into a snapshot now.
|
||||||
|
pub fn compact_library(&mut self) -> Result<()> {
|
||||||
|
self.persist()?;
|
||||||
|
if self.interaction.is_some() {
|
||||||
|
return Err(EngineError::Other("can't compact during an interaction".into()));
|
||||||
|
}
|
||||||
|
if let Some(lib) = self.library.as_mut() {
|
||||||
|
lib.journal.snapshot(&self.catalog)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! Persistent library: commands survive a restart (with and without a clean close).
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::Session;
|
||||||
|
|
||||||
|
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||||
|
let d = std::env::temp_dir().join(format!("lc-engine-{tag}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(dir: &std::path::Path, seed: bool) -> Session {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.open_library(dir, seed).unwrap();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edits_survive_restart_without_close() {
|
||||||
|
let dir = temp_dir("crash");
|
||||||
|
let mut s = open(&dir, true);
|
||||||
|
assert!(s.library.as_ref().unwrap().report.created);
|
||||||
|
assert!(s.catalog.len() > 5, "seeded demo");
|
||||||
|
let id = s.selection.active.unwrap();
|
||||||
|
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
|
||||||
|
s.execute("photo.flag", &json!({"flag": "reject"})).unwrap();
|
||||||
|
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.25})).unwrap();
|
||||||
|
// a slider drag: one undo step, logged once at the end
|
||||||
|
s.execute("develop.beginInteraction", &json!({"label": "Contrast"})).unwrap();
|
||||||
|
for v in [5, 10, 20, 33] {
|
||||||
|
s.execute("develop.set", &json!({"control": "light.contrast", "value": v})).unwrap();
|
||||||
|
}
|
||||||
|
s.execute("develop.endInteraction", &json!({})).unwrap();
|
||||||
|
s.execute("album.create", &json!({"name": "Keepers", "addSelected": true})).unwrap();
|
||||||
|
s.execute("preset.create", &json!({"name": "Bright"})).unwrap();
|
||||||
|
s.execute("edit.undo", &json!({})).ok();
|
||||||
|
s.execute("edit.redo", &json!({})).ok();
|
||||||
|
let expect = s.catalog.to_snapshot();
|
||||||
|
let info = s.execute("library.info", &json!({})).unwrap();
|
||||||
|
assert!(info["logRecords"].as_u64().unwrap() >= 5, "{info}");
|
||||||
|
drop(s); // no close: like a crash
|
||||||
|
|
||||||
|
let s2 = open(&dir, true);
|
||||||
|
let r = &s2.library.as_ref().unwrap().report;
|
||||||
|
assert!(!r.created && r.replayed >= 5, "{r:?}");
|
||||||
|
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||||
|
let p = s2.catalog.photo(id).unwrap();
|
||||||
|
assert_eq!((p.rating, p.develop.light.exposure, p.develop.light.contrast), (5, 1.25, 33.0));
|
||||||
|
assert!(s2.catalog.albums().any(|a| a.name == "Keepers" && a.photos == vec![id]));
|
||||||
|
assert!(s2.presets.iter().any(|p| p.name == "Bright" && !p.builtin));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_writes_snapshot_and_view_state() {
|
||||||
|
let dir = temp_dir("close");
|
||||||
|
let mut s = open(&dir, true);
|
||||||
|
let ids = s.visible_cloned();
|
||||||
|
s.execute("library.select", &json!({"ids": [ids[3].0]})).unwrap();
|
||||||
|
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
|
||||||
|
let expect = s.catalog.to_snapshot();
|
||||||
|
s.close_library().unwrap();
|
||||||
|
assert_eq!(std::fs::metadata(dir.join("catalog.log")).unwrap().len(), 0);
|
||||||
|
|
||||||
|
let s2 = open(&dir, true);
|
||||||
|
let r = &s2.library.as_ref().unwrap().report;
|
||||||
|
assert_eq!(r.replayed, 0);
|
||||||
|
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||||
|
assert_eq!(s2.selection.active, Some(ids[3]));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_library_without_seed_is_empty_and_compacts() {
|
||||||
|
let dir = temp_dir("empty");
|
||||||
|
let mut s = open(&dir, false);
|
||||||
|
assert!(s.catalog.is_empty());
|
||||||
|
s.execute("album.create", &json!({"name": "A"})).unwrap();
|
||||||
|
s.execute("library.compact", &json!({})).unwrap();
|
||||||
|
let info = s.execute("library.info", &json!({})).unwrap();
|
||||||
|
assert_eq!(info["logRecords"], 0);
|
||||||
|
assert_eq!(info["snapshotSeq"], info["seq"]);
|
||||||
|
let s2 = open(&dir, true);
|
||||||
|
assert_eq!(s2.catalog.len(), 0, "an existing library is never seeded");
|
||||||
|
assert_eq!(s2.catalog.albums().count(), 1);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
@@ -201,6 +201,7 @@ impl LightcraftApp {
|
|||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
self.renderer.poll(ctx, &mut self.session);
|
self.renderer.poll(ctx, &mut self.session);
|
||||||
|
self.session.persist_if_dirty();
|
||||||
self.collect_screenshots(ctx);
|
self.collect_screenshots(ctx);
|
||||||
self.issue_screenshots(ctx);
|
self.issue_screenshots(ctx);
|
||||||
if self.fonts_ready {
|
if self.fonts_ready {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ The screenshots in `docs/images/` are captured by driving LightCraft over its co
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# public-domain set (downloaded into the gitignored corpus/, see assets/ATTRIBUTION.md for sources)
|
# public-domain set (downloaded into the gitignored corpus/, see assets/ATTRIBUTION.md for sources)
|
||||||
cargo run --release -p lightcraft -- --control 7980 corpus/images/pd &
|
cargo run --release -p lightcraft -- --memory --control 7980 corpus/images/pd &
|
||||||
python3 docs/showcase/run.py docs/showcase/pd-edits.jsonl
|
python3 docs/showcase/run.py docs/showcase/pd-edits.jsonl
|
||||||
|
|
||||||
# procedural demo library
|
# procedural demo library
|
||||||
cargo run --release -p lightcraft -- --control 7980 &
|
cargo run --release -p lightcraft -- --memory --control 7980 &
|
||||||
python3 docs/showcase/run.py docs/showcase/demo.jsonl
|
python3 docs/showcase/run.py docs/showcase/demo.jsonl
|
||||||
|
|
||||||
# compress for the repo (macOS)
|
# compress for the repo (macOS)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Demo-library showcase: run LightCraft with `--control 7980` (no files → procedural demo library)
|
# Demo-library showcase: run LightCraft with `--memory --control 7980` (no files → procedural demo library)
|
||||||
{"method":"ui.resize","params":{"width":1600,"height":1000}}
|
{"method":"ui.resize","params":{"width":1600,"height":1000}}
|
||||||
{"method":"engine.execute","params":{"command":"view.photoGrid"}}
|
{"method":"engine.execute","params":{"command":"view.photoGrid"}}
|
||||||
{"method":"ui.set","params":{"leftPanel":true,"right":"edit","thumbSize":230,"openSections":["light","color"],"presets":false}}
|
{"method":"ui.set","params":{"leftPanel":true,"right":"edit","thumbSize":230,"openSections":["light","color"],"presets":false}}
|
||||||
|
|||||||
Reference in New Issue
Block a user