From 76b2f82b7afbad2b96ce84a68cae550a43b46771 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 11:23:43 -0400 Subject: [PATCH] M0.6: catalog v0 (ops with inverses, albums/folders, ratings/flags/labels, search tokens, filter/sort, snapshot + op-log replay) Co-Authored-By: Claude Opus 5.5 --- crates/catalog/Cargo.toml | 18 ++ crates/catalog/src/lib.rs | 354 ++++++++++++++++++++++++++++++++++++ crates/catalog/src/model.rs | 193 ++++++++++++++++++++ crates/catalog/src/query.rs | 196 ++++++++++++++++++++ crates/catalog/src/tests.rs | 143 +++++++++++++++ 5 files changed, 904 insertions(+) create mode 100644 crates/catalog/Cargo.toml create mode 100644 crates/catalog/src/lib.rs create mode 100644 crates/catalog/src/model.rs create mode 100644 crates/catalog/src/query.rs create mode 100644 crates/catalog/src/tests.rs diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml new file mode 100644 index 00000000..c5f6dc86 --- /dev/null +++ b/crates/catalog/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lightcraft-catalog" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft library: photos, albums, ratings/flags, search/filter/sort, op-log persistence (L3)" + +[lints] +workspace = true + +[dependencies] +lightcraft-develop = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs new file mode 100644 index 00000000..1381edbc --- /dev/null +++ b/crates/catalog/src/lib.rs @@ -0,0 +1,354 @@ +//! The LightCraft library (catalog). +//! +//! 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 +//! snapshot — crash-safe and diff-friendly; +//! - **undo/redo**: the engine keeps inverse ops; +//! - **determinism**: replaying the log reproduces the state exactly (property-tested). +#![forbid(unsafe_code)] + +pub mod model; +pub mod query; + +use std::collections::BTreeMap; +use std::sync::Arc; + +use lightcraft_develop::DevelopSettings; +pub use model::*; +pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum CatalogError { + #[error("no such photo {0:?}")] + NoPhoto(PhotoId), + #[error("no such album {0:?}")] + NoAlbum(AlbumId), + #[error("invalid: {0}")] + Invalid(String), + #[error("corrupt catalog data: {0}")] + Corrupt(String), +} + +pub type Result = std::result::Result; + +/// Every catalog mutation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "camelCase")] +pub enum Op { + AddPhoto { + photo: Box, + }, + RemovePhoto { + id: PhotoId, + }, + SetRating { + id: PhotoId, + rating: u8, + }, + SetFlag { + id: PhotoId, + flag: Flag, + }, + SetLabel { + id: PhotoId, + label: Option, + }, + SetDevelop { + id: PhotoId, + settings: Arc, + label: String, + edited: Option, + }, + SetMeta { + id: PhotoId, + meta: Box, + }, + SetDeleted { + id: PhotoId, + deleted: bool, + }, + SetVersions { + id: PhotoId, + versions: Vec, + }, + SetHistory { + id: PhotoId, + history: Vec, + }, + AddAlbum { + album: Album, + }, + RemoveAlbum { + id: AlbumId, + }, + RenameAlbum { + id: AlbumId, + name: String, + }, + MoveAlbum { + id: AlbumId, + parent: Option, + }, + SetAlbumPhotos { + id: AlbumId, + photos: Vec, + }, + SetAlbumCover { + id: AlbumId, + cover: Option, + }, + /// Several ops as one step (undo applies the inverses in reverse). + Batch { + ops: Vec, + }, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Catalog { + photos: BTreeMap>, + albums: BTreeMap, + next_photo: u64, + next_album: u64, + /// Increments on every applied op. + #[serde(skip)] + pub revision: u64, +} + +impl Catalog { + pub fn new() -> Catalog { + Catalog { next_photo: 1, next_album: 1, ..Default::default() } + } + + // ---- ids + + pub fn alloc_photo_id(&mut self) -> PhotoId { + let id = PhotoId(self.next_photo.max(1)); + self.next_photo = id.0 + 1; + id + } + pub fn alloc_album_id(&mut self) -> AlbumId { + let id = AlbumId(self.next_album.max(1)); + self.next_album = id.0 + 1; + id + } + + // ---- reads + + pub fn photo(&self, id: PhotoId) -> Option<&Arc> { + self.photos.get(&id) + } + pub fn photos(&self) -> impl Iterator> { + self.photos.values() + } + pub fn len(&self) -> usize { + self.photos.len() + } + pub fn is_empty(&self) -> bool { + self.photos.is_empty() + } + pub fn album(&self, id: AlbumId) -> Option<&Album> { + self.albums.get(&id) + } + pub fn albums(&self) -> impl Iterator { + self.albums.values() + } + pub fn albums_of(&self, id: PhotoId) -> Vec { + self.albums.values().filter(|a| a.photos.contains(&id)).map(|a| a.id).collect() + } + + // ---- writes + + fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> { + self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id)) + } + fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> { + self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id)) + } + + /// Apply an op; returns its inverse. On error nothing changes. + pub fn apply(&mut self, op: Op) -> Result { + let inv = self.apply_inner(op)?; + self.revision += 1; + Ok(inv) + } + + fn apply_inner(&mut self, op: Op) -> Result { + Ok(match op { + Op::AddPhoto { photo } => { + if self.photos.contains_key(&photo.id) { + return Err(CatalogError::Invalid(format!("photo {:?} exists", photo.id))); + } + let id = photo.id; + self.next_photo = self.next_photo.max(id.0 + 1); + self.photos.insert(id, Arc::new(*photo)); + Op::RemovePhoto { id } + } + Op::RemovePhoto { id } => { + let p = self.photos.remove(&id).ok_or(CatalogError::NoPhoto(id))?; + // album membership is restored by the batch the engine builds (see `delete_permanently`) + Op::AddPhoto { photo: Box::new((*p).clone()) } + } + Op::SetRating { id, rating } => { + if rating > 5 { + return Err(CatalogError::Invalid("rating must be 0..=5".into())); + } + let p = self.photo_mut(id)?; + let old = std::mem::replace(&mut p.rating, rating); + Op::SetRating { id, rating: old } + } + Op::SetFlag { id, flag } => { + let p = self.photo_mut(id)?; + Op::SetFlag { id, flag: std::mem::replace(&mut p.flag, flag) } + } + Op::SetLabel { id, label } => { + let p = self.photo_mut(id)?; + Op::SetLabel { id, label: std::mem::replace(&mut p.label, label) } + } + Op::SetDevelop { id, settings, label, edited } => { + let p = self.photo_mut(id)?; + let old = std::mem::replace(&mut p.develop, settings); + let old_edit = std::mem::replace(&mut p.edited, edited); + Op::SetDevelop { id, settings: old, label, edited: old_edit } + } + Op::SetMeta { id, meta } => { + let p = self.photo_mut(id)?; + Op::SetMeta { id, meta: Box::new(std::mem::replace(&mut p.meta, *meta)) } + } + Op::SetDeleted { id, deleted } => { + let p = self.photo_mut(id)?; + Op::SetDeleted { id, deleted: std::mem::replace(&mut p.deleted, deleted) } + } + Op::SetVersions { id, versions } => { + let p = self.photo_mut(id)?; + Op::SetVersions { id, versions: std::mem::replace(&mut p.versions, versions) } + } + Op::SetHistory { id, history } => { + let p = self.photo_mut(id)?; + Op::SetHistory { id, history: std::mem::replace(&mut p.history, history) } + } + Op::AddAlbum { album } => { + if self.albums.contains_key(&album.id) { + return Err(CatalogError::Invalid(format!("album {:?} exists", album.id))); + } + if let Some(parent) = album.parent + && !self.albums.get(&parent).is_some_and(|a| a.folder) + { + return Err(CatalogError::Invalid("parent must be an existing folder".into())); + } + let id = album.id; + self.next_album = self.next_album.max(id.0 + 1); + self.albums.insert(id, album); + Op::RemoveAlbum { id } + } + Op::RemoveAlbum { id } => { + if self.albums.values().any(|a| a.parent == Some(id)) { + return Err(CatalogError::Invalid("folder is not empty".into())); + } + let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?; + Op::AddAlbum { album: a } + } + Op::RenameAlbum { id, name } => { + let a = self.album_mut(id)?; + Op::RenameAlbum { id, name: std::mem::replace(&mut a.name, name) } + } + Op::MoveAlbum { id, parent } => { + if let Some(p) = parent { + if p == id || !self.albums.get(&p).is_some_and(|a| a.folder) { + return Err(CatalogError::Invalid("parent must be another folder".into())); + } + // no cycles + let mut cur = Some(p); + while let Some(c) = cur { + if c == id { + return Err(CatalogError::Invalid("cannot move a folder into itself".into())); + } + cur = self.albums.get(&c).and_then(|a| a.parent); + } + } + let a = self.album_mut(id)?; + Op::MoveAlbum { id, parent: std::mem::replace(&mut a.parent, parent) } + } + Op::SetAlbumPhotos { id, photos } => { + let a = self.album_mut(id)?; + if a.folder && !photos.is_empty() { + return Err(CatalogError::Invalid("folders can't hold photos".into())); + } + Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) } + } + Op::SetAlbumCover { id, cover } => { + let a = self.album_mut(id)?; + Op::SetAlbumCover { id, cover: std::mem::replace(&mut a.cover, cover) } + } + Op::Batch { ops } => { + let mut inverses = Vec::with_capacity(ops.len()); + for op in ops { + match self.apply_inner(op) { + Ok(inv) => inverses.push(inv), + Err(e) => { + // roll back what was applied + for inv in inverses.into_iter().rev() { + let _ = self.apply_inner(inv); + } + return Err(e); + } + } + } + inverses.reverse(); + Op::Batch { ops: inverses } + } + }) + } + + /// Ops that remove a photo permanently including album memberships (one undoable batch). + pub fn delete_permanently_ops(&self, id: PhotoId) -> Op { + let mut ops: Vec = self + .albums + .values() + .filter(|a| a.photos.contains(&id)) + .map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| *p != id).collect() }) + .collect(); + ops.push(Op::RemovePhoto { id }); + Op::Batch { ops } + } + + // ---- persistence + + /// Full snapshot as JSON. + pub fn to_snapshot(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } + + pub fn from_snapshot(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string())) + } + + /// Replay an op log (JSON lines) on top of `self`. A torn final line (crash mid-write) is ignored. + pub fn replay(&mut self, log: &str) -> Result { + let lines: Vec<&str> = log.lines().filter(|l| !l.trim().is_empty()).collect(); + let mut n = 0; + for (i, line) in lines.iter().enumerate() { + match serde_json::from_str::(line) { + Ok(op) => { + self.apply(op)?; + n += 1; + } + Err(e) if i + 1 == lines.len() => { + let _ = e; + break; + } + Err(e) => return Err(CatalogError::Corrupt(format!("log line {}: {e}", i + 1))), + } + } + Ok(n) + } + + pub fn op_to_log_line(op: &Op) -> String { + let mut s = serde_json::to_string(op).unwrap_or_default(); + s.push('\n'); + s + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs new file mode 100644 index 00000000..f43287ac --- /dev/null +++ b/crates/catalog/src/model.rs @@ -0,0 +1,193 @@ +//! Catalog records. + +use std::sync::Arc; + +use lightcraft_develop::DevelopSettings; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PhotoId(pub u64); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AlbumId(pub u64); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaKind { + #[default] + Image, + Raw, + Video, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Flag { + #[default] + None, + Pick, + Reject, +} + +impl Flag { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "pick" | "picked" | "flagged" => Some(Flag::Pick), + "reject" | "rejected" => Some(Flag::Reject), + "none" | "unflagged" | "" => Some(Flag::None), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ColorLabel { + Red, + Yellow, + Green, + Blue, + Purple, +} + +impl ColorLabel { + pub const ALL: [ColorLabel; 5] = [ColorLabel::Red, ColorLabel::Yellow, ColorLabel::Green, ColorLabel::Blue, ColorLabel::Purple]; + pub fn parse(s: &str) -> Option { + ColorLabel::ALL.into_iter().find(|c| format!("{c:?}").eq_ignore_ascii_case(s)) + } +} + +/// Where the pixels come from. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Source { + /// A file on disk (native) or in the browser's storage (web). + File { path: String }, + /// A procedurally generated demo scene (by `lightcraft-scenes` id). + Demo { scene: u32 }, +} + +/// Descriptive + capture metadata. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Meta { + pub camera: String, + pub lens: String, + pub focal_mm: Option, + pub aperture: Option, + pub shutter: String, + pub iso: Option, + pub location: String, + pub gps: Option<(f64, f64)>, + pub title: String, + pub caption: String, + pub copyright: String, + pub creator: String, + pub keywords: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Version { + pub name: String, + pub created: String, + pub settings: Arc, + #[serde(default)] + pub auto: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct HistoryStep { + pub label: String, + pub settings: Arc, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Photo { + pub id: PhotoId, + pub source: Source, + pub file_name: String, + #[serde(default)] + pub kind: MediaKind, + pub format: String, + pub width: u32, + pub height: u32, + #[serde(default)] + pub file_size: u64, + /// ISO 8601 local capture time. + #[serde(default)] + pub captured: Option, + pub imported: String, + #[serde(default)] + pub meta: Meta, + #[serde(default)] + pub rating: u8, + #[serde(default)] + pub flag: Flag, + #[serde(default)] + pub label: Option, + pub develop: Arc, + #[serde(default)] + pub edited: Option, + #[serde(default)] + pub versions: Vec, + #[serde(default)] + pub history: Vec, + /// In "Recently Deleted". + #[serde(default)] + pub deleted: bool, + /// Video duration in seconds. + #[serde(default)] + pub duration: Option, +} + +impl Photo { + pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo { + Photo { + id, + source, + file_name: file_name.to_string(), + kind: MediaKind::Image, + format: format.to_string(), + width, + height, + file_size: 0, + captured: None, + imported: imported.to_string(), + meta: Meta::default(), + rating: 0, + flag: Flag::None, + label: None, + develop: Arc::new(DevelopSettings::default()), + edited: None, + versions: Vec::new(), + history: Vec::new(), + deleted: false, + duration: None, + } + } + pub fn is_edited(&self) -> bool { + !self.develop.is_unedited() + } + /// Capture time if known, else import time (sort key). + pub fn date(&self) -> &str { + self.captured.as_deref().unwrap_or(&self.imported) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Album { + pub id: AlbumId, + pub name: String, + /// Containing folder (an album with `folder = true`). + #[serde(default)] + pub parent: Option, + /// Folders hold albums, not photos. + #[serde(default)] + pub folder: bool, + #[serde(default)] + pub photos: Vec, + #[serde(default)] + pub cover: Option, +} diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs new file mode 100644 index 00000000..76390b0c --- /dev/null +++ b/crates/catalog/src/query.rs @@ -0,0 +1,196 @@ +//! Filtering, search and sorting. + +use serde::{Deserialize, Serialize}; + +use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RatingOp { + #[default] + AtLeast, + Exactly, + AtMost, +} + +/// What the grid shows. Empty/None fields don't filter. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Filter { + /// Free-text search: every token must match filename, title, caption, keywords, camera, lens, + /// location or format. Tokens like `rating:3`, `flag:pick`, `iso:>800`, `camera:x2` are fielded. + pub text: String, + pub rating: u8, + pub rating_op: RatingOp, + pub flag: Option, + pub label: Option, + pub kind: Option, + pub edited: Option, + pub album: Option, + /// Show "Recently Deleted" instead of the library. + pub deleted: bool, + /// Capture date prefix (`2026`, `2026-04`, `2026-04-12`). + pub date: Option, + pub keyword: Option, + pub camera: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SortKey { + #[default] + CaptureDate, + ImportDate, + EditDate, + FileName, + Rating, + FileSize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Sort { + pub key: SortKey, + pub ascending: bool, +} + +impl Default for Sort { + fn default() -> Self { + Sort { key: SortKey::CaptureDate, ascending: false } + } +} + +fn token_matches(p: &Photo, tok: &str) -> bool { + let t = tok.to_lowercase(); + if let Some((field, val)) = t.split_once(':') { + let num = |s: &str| -> Option<(char, f64)> { + let (op, rest) = match s.chars().next()? { + c @ ('>' | '<' | '=') => (c, &s[1..]), + _ => ('=', s), + }; + rest.parse().ok().map(|v| (op, v)) + }; + let cmp = |x: f64, s: &str| match num(s) { + Some(('>', v)) => x > v, + Some(('<', v)) => x < v, + Some((_, v)) => (x - v).abs() < 1e-9, + None => false, + }; + return match field { + "rating" | "stars" => cmp(p.rating as f64, val), + "flag" => Flag::parse(val) == Some(p.flag), + "label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)), + "iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)), + "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)), + "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)), + "camera" => p.meta.camera.to_lowercase().contains(val), + "lens" => p.meta.lens.to_lowercase().contains(val), + "keyword" | "kw" => p.meta.keywords.iter().any(|k| k.to_lowercase() == val), + "type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val), + "edited" => (val == "true" || val == "yes") == p.is_edited(), + "date" => p.date().starts_with(val), + "name" | "file" => p.file_name.to_lowercase().contains(val), + _ => false, + }; + } + let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format]; + hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t)) +} + +impl Filter { + pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { + if p.deleted != self.deleted { + return false; + } + if self.rating > 0 { + let ok = match self.rating_op { + RatingOp::AtLeast => p.rating >= self.rating, + RatingOp::Exactly => p.rating == self.rating, + RatingOp::AtMost => p.rating <= self.rating, + }; + if !ok { + return false; + } + } + if self.flag.is_some_and(|f| f != p.flag) || self.label.is_some_and(|l| Some(l) != p.label) || self.kind.is_some_and(|k| k != p.kind) { + return false; + } + if self.edited.is_some_and(|e| e != p.is_edited()) { + return false; + } + if let Some(a) = self.album + && !cat.album(a).is_some_and(|al| al.photos.contains(&p.id)) + { + return false; + } + if let Some(d) = &self.date + && !p.date().starts_with(d.as_str()) + { + return false; + } + if let Some(k) = &self.keyword + && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(k)) + { + return false; + } + if let Some(c) = &self.camera + && !p.meta.camera.eq_ignore_ascii_case(c) + { + return false; + } + self.text.split_whitespace().all(|tok| token_matches(p, tok)) + } +} + +impl Catalog { + /// Photos matching `filter`, in `sort` order (ties broken by id for stability). + pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec { + let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches(p, self)).collect(); + v.sort_by(|a, b| { + let o = match sort.key { + SortKey::CaptureDate => a.date().cmp(b.date()), + SortKey::ImportDate => a.imported.cmp(&b.imported), + SortKey::EditDate => a.edited.cmp(&b.edited), + SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()), + SortKey::Rating => a.rating.cmp(&b.rating), + SortKey::FileSize => a.file_size.cmp(&b.file_size), + } + .then(a.id.cmp(&b.id)); + if sort.ascending { o } else { o.reverse() } + }); + v.into_iter().map(|p| p.id).collect() + } + + /// Year → month → count tree for the "By Date" section. + pub fn date_groups(&self) -> Vec { + let mut map: std::collections::BTreeMap> = Default::default(); + for p in self.photos().filter(|p| !p.deleted) { + let d = p.date(); + if d.len() >= 7 { + *map.entry(d[..4].to_string()).or_default().entry(d[..7].to_string()).or_default() += 1; + } + } + map.into_iter() + .rev() + .map(|(year, months)| DateGroup { count: months.values().sum(), months: months.into_iter().rev().map(|(m, c)| (m, c)).collect(), year }) + .collect() + } + + /// All keywords with usage counts, sorted by name. + pub fn keywords(&self) -> Vec<(String, usize)> { + let mut m: std::collections::BTreeMap = Default::default(); + for p in self.photos().filter(|p| !p.deleted) { + for k in &p.meta.keywords { + *m.entry(k.clone()).or_default() += 1; + } + } + m.into_iter().collect() + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct DateGroup { + pub year: String, + pub count: usize, + pub months: Vec<(String, usize)>, +} diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs new file mode 100644 index 00000000..37ecec43 --- /dev/null +++ b/crates/catalog/src/tests.rs @@ -0,0 +1,143 @@ +use std::sync::Arc; + +use lightcraft_develop::DevelopSettings; + +use super::*; + +fn photo(c: &mut Catalog, name: &str, date: &str) -> PhotoId { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00"); + p.captured = Some(date.to_string()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id +} + +#[test] +fn apply_and_inverse_roundtrip() { + let mut c = Catalog::new(); + let a = photo(&mut c, "a.jpg", "2026-04-01T10:00:00"); + let before = c.clone(); + let ops = vec![ + Op::SetRating { id: a, rating: 4 }, + Op::SetFlag { id: a, flag: Flag::Pick }, + Op::SetLabel { id: a, label: Some(ColorLabel::Red) }, + Op::SetDevelop { + id: a, + settings: Arc::new(DevelopSettings { treatment: lightcraft_develop::Treatment::Bw, ..Default::default() }), + label: "B&W".into(), + edited: Some("x".into()), + }, + ]; + let mut invs = Vec::new(); + for op in ops { + invs.push(c.apply(op).unwrap()); + } + assert_eq!(c.photo(a).unwrap().rating, 4); + for inv in invs.into_iter().rev() { + c.apply(inv).unwrap(); + } + assert_eq!(c.photos().collect::>(), before.photos().collect::>()); +} + +#[test] +fn invalid_ops_change_nothing() { + let mut c = Catalog::new(); + let a = photo(&mut c, "a.jpg", "2026-04-01"); + let snap = c.to_snapshot(); + assert!(c.apply(Op::SetRating { id: a, rating: 9 }).is_err()); + assert!(c.apply(Op::SetRating { id: PhotoId(999), rating: 1 }).is_err()); + // batch with a failing op rolls back + let r = c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }, Op::SetFlag { id: PhotoId(77), flag: Flag::Pick }] }); + assert!(r.is_err()); + assert_eq!(c.to_snapshot(), snap); +} + +#[test] +fn albums_and_folders() { + let mut c = Catalog::new(); + let a = photo(&mut c, "a.jpg", "2026-04-01"); + let f = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None } }).unwrap(); + let al = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None } }).unwrap(); + assert_eq!(c.albums_of(a), vec![al]); + assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder"); + assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos"); + assert!(c.apply(Op::MoveAlbum { id: f, parent: Some(f) }).is_err()); + let del = c.delete_permanently_ops(a); + let inv = c.apply(del).unwrap(); + assert!(c.photo(a).is_none() && c.album(al).unwrap().photos.is_empty()); + c.apply(inv).unwrap(); + assert!(c.photo(a).is_some() && c.album(al).unwrap().photos == vec![a]); +} + +#[test] +fn filter_search_sort() { + let mut c = Catalog::new(); + let a = photo(&mut c, "beach.jpg", "2026-05-01T10:00:00"); + let b = photo(&mut c, "alps.jpg", "2026-04-01T10:00:00"); + let d = photo(&mut c, "city.jpg", "2025-12-24T10:00:00"); + c.apply(Op::SetRating { id: b, rating: 5 }).unwrap(); + c.apply(Op::SetFlag { id: d, flag: Flag::Reject }).unwrap(); + let mut m = c.photo(a).unwrap().meta.clone(); + m.keywords = vec!["ocean".into(), "summer".into()]; + m.iso = Some(1600); + c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap(); + + let all = c.query(&Filter::default(), &Sort::default()); + assert_eq!(all, vec![a, b, d], "newest first"); + let asc = c.query(&Filter::default(), &Sort { key: SortKey::FileName, ascending: true }); + assert_eq!(asc, vec![b, a, d]); + assert_eq!(c.query(&Filter { rating: 4, ..Default::default() }, &Sort::default()), vec![b]); + assert_eq!(c.query(&Filter { flag: Some(Flag::Reject), ..Default::default() }, &Sort::default()), vec![d]); + assert_eq!(c.query(&Filter { text: "summer".into(), ..Default::default() }, &Sort::default()), vec![a]); + assert_eq!(c.query(&Filter { text: "iso:>800".into(), ..Default::default() }, &Sort::default()), vec![a]); + assert_eq!(c.query(&Filter { text: "date:2025".into(), ..Default::default() }, &Sort::default()), vec![d]); + assert_eq!(c.query(&Filter { date: Some("2026-04".into()), ..Default::default() }, &Sort::default()), vec![b]); + c.apply(Op::SetDeleted { id: d, deleted: true }).unwrap(); + assert_eq!(c.query(&Filter::default(), &Sort::default()).len(), 2); + assert_eq!(c.query(&Filter { deleted: true, ..Default::default() }, &Sort::default()), vec![d]); + let g = c.date_groups(); + assert_eq!(g[0].year, "2026"); + assert_eq!(g[0].count, 2); + assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]); +} + +#[test] +fn snapshot_and_log_replay() { + let mut c = Catalog::new(); + let snap0 = c.to_snapshot(); + let mut log = String::new(); + let a = c.alloc_photo_id(); + let ops = vec![ + Op::AddPhoto { photo: Box::new(Photo::new(a, Source::File { path: "/x/a.jpg".into() }, "a.jpg", "JPEG", 10, 10, "t")) }, + Op::SetRating { id: a, rating: 3 }, + Op::SetFlag { id: a, flag: Flag::Pick }, + ]; + for op in ops { + log.push_str(&Catalog::op_to_log_line(&op)); + c.apply(op).unwrap(); + } + let mut r = Catalog::from_snapshot(&snap0).unwrap(); + // torn last line is tolerated + let torn = format!("{log}{{\"op\":\"setRat"); + assert_eq!(r.replay(&torn).unwrap(), 3); + assert_eq!(r.to_snapshot(), c.to_snapshot()); + assert!(Catalog::from_snapshot("{nope").is_err()); +} + +proptest::proptest! { + #[test] + fn random_ops_undo_to_start(ratings in proptest::collection::vec((0usize..3, 0u8..6, 0u8..3), 1..40)) { + let mut c = Catalog::new(); + let ids: Vec = (0..3).map(|i| photo(&mut c, &format!("p{i}.jpg"), "2026-01-01")).collect(); + let start = c.to_snapshot(); + let mut invs = Vec::new(); + for (i, r, f) in ratings { + let op = if f == 0 { Op::SetRating { id: ids[i], rating: r } } else { Op::SetFlag { id: ids[i], flag: [Flag::None, Flag::Pick, Flag::Reject][f as usize] } }; + if let Ok(inv) = c.apply(op) { invs.push(inv); } + } + for inv in invs.into_iter().rev() { c.apply(inv).unwrap(); } + proptest::prop_assert_eq!(c.to_snapshot(), start); + } +}