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 <noreply@anthropic.com>
This commit is contained in:
parent
90c910a5f6
commit
76b2f82b7a
|
|
@ -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 }
|
||||
|
|
@ -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<T> = std::result::Result<T, CatalogError>;
|
||||
|
||||
/// Every catalog mutation.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "camelCase")]
|
||||
pub enum Op {
|
||||
AddPhoto {
|
||||
photo: Box<Photo>,
|
||||
},
|
||||
RemovePhoto {
|
||||
id: PhotoId,
|
||||
},
|
||||
SetRating {
|
||||
id: PhotoId,
|
||||
rating: u8,
|
||||
},
|
||||
SetFlag {
|
||||
id: PhotoId,
|
||||
flag: Flag,
|
||||
},
|
||||
SetLabel {
|
||||
id: PhotoId,
|
||||
label: Option<ColorLabel>,
|
||||
},
|
||||
SetDevelop {
|
||||
id: PhotoId,
|
||||
settings: Arc<DevelopSettings>,
|
||||
label: String,
|
||||
edited: Option<String>,
|
||||
},
|
||||
SetMeta {
|
||||
id: PhotoId,
|
||||
meta: Box<Meta>,
|
||||
},
|
||||
SetDeleted {
|
||||
id: PhotoId,
|
||||
deleted: bool,
|
||||
},
|
||||
SetVersions {
|
||||
id: PhotoId,
|
||||
versions: Vec<Version>,
|
||||
},
|
||||
SetHistory {
|
||||
id: PhotoId,
|
||||
history: Vec<HistoryStep>,
|
||||
},
|
||||
AddAlbum {
|
||||
album: Album,
|
||||
},
|
||||
RemoveAlbum {
|
||||
id: AlbumId,
|
||||
},
|
||||
RenameAlbum {
|
||||
id: AlbumId,
|
||||
name: String,
|
||||
},
|
||||
MoveAlbum {
|
||||
id: AlbumId,
|
||||
parent: Option<AlbumId>,
|
||||
},
|
||||
SetAlbumPhotos {
|
||||
id: AlbumId,
|
||||
photos: Vec<PhotoId>,
|
||||
},
|
||||
SetAlbumCover {
|
||||
id: AlbumId,
|
||||
cover: Option<PhotoId>,
|
||||
},
|
||||
/// Several ops as one step (undo applies the inverses in reverse).
|
||||
Batch {
|
||||
ops: Vec<Op>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Catalog {
|
||||
photos: BTreeMap<PhotoId, Arc<Photo>>,
|
||||
albums: BTreeMap<AlbumId, Album>,
|
||||
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<Photo>> {
|
||||
self.photos.get(&id)
|
||||
}
|
||||
pub fn photos(&self) -> impl Iterator<Item = &Arc<Photo>> {
|
||||
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<Item = &Album> {
|
||||
self.albums.values()
|
||||
}
|
||||
pub fn albums_of(&self, id: PhotoId) -> Vec<AlbumId> {
|
||||
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<Op> {
|
||||
let inv = self.apply_inner(op)?;
|
||||
self.revision += 1;
|
||||
Ok(inv)
|
||||
}
|
||||
|
||||
fn apply_inner(&mut self, op: Op) -> Result<Op> {
|
||||
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<Op> = 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<Catalog> {
|
||||
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<usize> {
|
||||
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::<Op>(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;
|
||||
|
|
@ -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<Flag> {
|
||||
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> {
|
||||
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<f32>,
|
||||
pub aperture: Option<f32>,
|
||||
pub shutter: String,
|
||||
pub iso: Option<u32>,
|
||||
pub location: String,
|
||||
pub gps: Option<(f64, f64)>,
|
||||
pub title: String,
|
||||
pub caption: String,
|
||||
pub copyright: String,
|
||||
pub creator: String,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Version {
|
||||
pub name: String,
|
||||
pub created: String,
|
||||
pub settings: Arc<DevelopSettings>,
|
||||
#[serde(default)]
|
||||
pub auto: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HistoryStep {
|
||||
pub label: String,
|
||||
pub settings: Arc<DevelopSettings>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub imported: String,
|
||||
#[serde(default)]
|
||||
pub meta: Meta,
|
||||
#[serde(default)]
|
||||
pub rating: u8,
|
||||
#[serde(default)]
|
||||
pub flag: Flag,
|
||||
#[serde(default)]
|
||||
pub label: Option<ColorLabel>,
|
||||
pub develop: Arc<DevelopSettings>,
|
||||
#[serde(default)]
|
||||
pub edited: Option<String>,
|
||||
#[serde(default)]
|
||||
pub versions: Vec<Version>,
|
||||
#[serde(default)]
|
||||
pub history: Vec<HistoryStep>,
|
||||
/// In "Recently Deleted".
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
/// Video duration in seconds.
|
||||
#[serde(default)]
|
||||
pub duration: Option<f64>,
|
||||
}
|
||||
|
||||
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<AlbumId>,
|
||||
/// Folders hold albums, not photos.
|
||||
#[serde(default)]
|
||||
pub folder: bool,
|
||||
#[serde(default)]
|
||||
pub photos: Vec<PhotoId>,
|
||||
#[serde(default)]
|
||||
pub cover: Option<PhotoId>,
|
||||
}
|
||||
|
|
@ -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<Flag>,
|
||||
pub label: Option<ColorLabel>,
|
||||
pub kind: Option<MediaKind>,
|
||||
pub edited: Option<bool>,
|
||||
pub album: Option<AlbumId>,
|
||||
/// Show "Recently Deleted" instead of the library.
|
||||
pub deleted: bool,
|
||||
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
|
||||
pub date: Option<String>,
|
||||
pub keyword: Option<String>,
|
||||
pub camera: Option<String>,
|
||||
}
|
||||
|
||||
#[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<PhotoId> {
|
||||
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<DateGroup> {
|
||||
let mut map: std::collections::BTreeMap<String, std::collections::BTreeMap<String, usize>> = 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<String, usize> = 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)>,
|
||||
}
|
||||
|
|
@ -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::<Vec<_>>(), before.photos().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[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<PhotoId> = (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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue