//! The LightCraft engine façade. //! //! Every user-visible action is a command with a stable id (`photo.rate`, `develop.set`, //! `album.create`, `mask.add`…) and JSON parameters. The egui UI, the CLI, the control channel and //! the MCP server all go through [`Session::execute`]. //! //! State: a [`Catalog`] (mutated only by ops, so every change is undoable and journaled), the //! library view (filter/sort/source), the selection, the develop clipboard, presets, and caches //! of decoded source proxies. Rendering is done by [`RenderJob`]s that are `Send` so frontends can //! run them off the UI thread. #![forbid(unsafe_code)] // Model/cache types remain available without linking the optional inference crate. #[cfg(not(feature = "denoise"))] extern crate lightcraft_denoise_core as lightcraft_denoise; pub mod availability; mod camera_preview; pub mod camera_profiles; pub mod cmd; pub mod config; pub mod crs; pub mod crs_masks; pub mod demo; pub mod denoise; pub mod devices; pub mod export; pub mod face_download; #[cfg(not(target_arch = "wasm32"))] mod faces_index; #[cfg(not(target_arch = "wasm32"))] mod faces_worker; pub mod files; pub mod fonts; pub mod guard; pub mod import; mod import_move; pub mod library; mod lightroom_archive; pub mod lightroom_catalog; pub mod lightroom_job; mod lightroom_sqlite; pub mod logging; pub mod media; pub mod memory; pub mod merge; mod model_download; pub mod originals; pub mod preset_import; pub mod preset_luminar; pub mod presets; pub mod rename; pub mod segment; pub mod sidecar; pub mod smart; mod view; pub mod walk; use std::sync::Arc; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use fonts::{CRAFT_FONTS, CraftFont}; use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort}; use lightcraft_develop::DevelopSettings; pub use media::{RenderJob, SourceLevel}; use serde_json::Value; pub use view::{Browse, FilterChip, LibrarySource, Selection, SelectionState, filter_chips}; pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline}; #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] UnknownCommand(String), #[error("command `{0}` is not available right now: {1}")] Disabled(String, String), #[error("invalid parameters for `{cmd}`: {msg}")] BadParams { cmd: String, msg: String }, #[error("{0}")] Catalog(#[from] lightcraft_catalog::CatalogError), /// The command's change is applied (in memory, undoable) but its journal records could not /// be written. They stay queued and are written by the next successful save. #[error("saved in memory but not written to disk: {0}; LightCraft will retry")] NotSaved(String), /// Another process (the app, `lightcraft-cli`, another computer) has the library open. #[error("{0}")] LibraryInUse(String), #[error("{0}")] Other(String), } pub type Result = std::result::Result; /// One undo step: the inverse op and a label. #[derive(Clone, Debug)] pub struct UndoEntry { pub label: String, pub op: Op, /// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder /// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. pub folder: Option, } /// The one photo an undo step changes, when it changes exactly one through its edit, rating, flag, /// label, metadata or versions (`depth` bounds nested batches). fn single_photo(op: &Op, depth: usize) -> Option { match op { Op::SetDevelop { id, .. } | Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetMeta { id, .. } | Op::SetVersions { id, .. } | Op::SetHistory { id, .. } | Op::PushHistory { id, .. } => Some(*id), Op::Batch { ops } if depth < 8 => { let mut ids = ops.iter().map(|o| single_photo(o, depth + 1)); let first = ids.next()??; ids.all(|id| id == Some(first)).then_some(first) } _ => None, } } /// A folder renamed or moved on disk as part of an undo step. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FolderMove { pub from: String, pub to: String, } impl FolderMove { fn reversed(&self) -> Self { Self { from: self.to.clone(), to: self.from.clone() } } } /// An in-progress slider drag / brush stroke: one undo step when it ends. #[derive(Clone, Debug)] pub struct Interaction { pub label: String, pub photo: PhotoId, pub original: Arc, } /// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one). static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); pub struct Session { library_generation: u64, /// Auto Sync: edits to the active photo also change the other selected photos (the settings /// that changed, nothing else). pub auto_sync: bool, pub catalog: Catalog, pub source: LibrarySource, pub filter: Filter, pub sort: Sort, pub selection: Selection, /// The grid order for the current source/filter/sort (cached by catalog revision). /// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]). visible: Arc<[PhotoId]>, visible_key: Option<(u64, String)>, /// Identifies the current `visible` list: a new value (unique in the process) every time it /// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids. visible_gen: u64, /// Photos in the current source with no filter on (cached like `visible`). total: Option<((u64, String), usize)>, pub undo: Vec, pub redo: Vec, pub interaction: Option, /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. pub(crate) skip_auto_write: bool, /// AI denoise: the model in use, the photos that have their picture and the work in progress. pub(crate) denoise: denoise::State, /// Where the host keeps face models (one folder each); `None` where there is no file system (the web). pub face_models_dir: Option, /// Face model downloads started this session (the staged files wait in `/.downloads`). pub face_downloads: face_download::Downloads, /// The user's own list of models to download (`catalog.json` in the models folder), as last read. pub(crate) face_catalog: lightcraft_faces::catalog::Catalog, /// The loaded recognition model and the face embeddings made with it. #[cfg(not(target_arch = "wasm32"))] pub(crate) faces: faces_index::FacesState, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. pub browse: Option, /// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported /// from it and from the folders inside it. pub library_folder: Option, /// Copied metadata (`photo.copyMetadata`): photo.setMeta params. pub meta_clipboard: Option, /// The photo that was active before the current one (Paste Settings from Previous). pub previous_active: Option, /// Groups last used for Copy (Lightroom remembers them). pub copy_groups: Vec, pub presets: Vec, /// Favourite profile ids (persisted with the library, like preset favourites). pub profile_favorites: Vec, /// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]). pub profile_recent: Vec, /// Executed commands (actions / debugging / replay). pub journal: Vec<(String, Value)>, /// Ops applied since the last `drain_log` (for persistence). pending_log: Vec, pub media: media::MediaCache, /// Current time provider (ISO 8601); injectable for tests and wasm. pub clock: Box String + Send>, depth: u32, /// Selected mask (Masking panel), by mask id. pub active_mask: Option, /// AI masks (SAM 3): the model and the last photo prepared for it. pub segmenter: segment::Segmenter, /// Selected spot (Remove panel), by index into the active photo's spots. pub active_spot: Option, /// The persistent library this session writes to (`None` = in-memory only). pub library: Option, /// Identity of current library opening; refreshed only after a successful open. pub(crate) library_identity: Arc<()>, /// XMP sidecar preferences (persisted with the library). pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. pub last_export: Option, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec, /// Metadata presets (`metadata.*`), persisted in prefs.json. pub metadata_presets: Vec, /// Saved filter-bar settings (`filter.*`), persisted in prefs.json. pub filter_presets: Vec, /// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]), /// persisted in prefs.json. pub curve_presets: Vec, /// Saved colour-label name sets. pub label_sets: Vec, /// Look-alike signatures by content key (Find Similar). pub signatures: std::collections::HashMap, /// Imported `.cube` LUT profiles. pub lut_profiles: Vec, /// The target album B adds to (`None` = the Quick Collection). pub target_album: Option, /// Auto import: files seen in the watched folder and their size then (a file is imported once /// its size held between two scans). pub auto_import_seen: std::collections::HashMap, /// Keyword sets (⌥1–⌥9 apply the current set's keywords), the one in use (`None` = Recent /// Keywords) and the recently added keywords, newest first. pub keyword_sets: Vec, pub keyword_set: Option, pub recent_keywords: Vec, /// "Put new keywords inside this keyword": the parent `keyword.create` uses unless told. pub keyword_parent: Option, /// Before/After: the "before" settings chosen per photo (this session; default: the photo's /// import state). See `cmd/before.rs`. pub before: std::collections::HashMap>, /// File probes from the last import review (`library.importPreview`), reused by the import. pub import_probes: std::collections::HashMap, /// The last (or running) Build Previews. pub preview_build: Option>, /// Develop defaults applied on import (persisted in prefs.json). pub import_defaults: import::ImportDefaults, /// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json). pub cache_mb: u32, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). pub smart_previews_dir: Option, /// Untouched Local records of folders not browsed for this many days are forgotten when the /// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`. pub forget_local_days: u32, } impl Default for Session { fn default() -> Self { Session::new() } } impl Session { pub fn library_generation(&self) -> u64 { self.library_generation } pub fn new() -> Session { Session { library_generation: LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed), auto_sync: false, catalog: Catalog::new(), source: LibrarySource::All, filter: Filter::default(), sort: Sort::default(), selection: Selection::default(), visible: Vec::new().into(), visible_key: None, visible_gen: 0, total: None, undo: Vec::new(), redo: Vec::new(), interaction: None, skip_auto_write: false, denoise: Default::default(), face_models_dir: None, face_downloads: Default::default(), face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), clipboard: None, meta_clipboard: None, browse: None, library_folder: None, previous_active: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), presets: presets::builtin(), profile_favorites: Vec::new(), profile_recent: Vec::new(), journal: Vec::new(), pending_log: Vec::new(), media: media::MediaCache::default(), clock: Box::new(|| "2026-09-30T12:00:00".to_string()), depth: 0, active_mask: None, segmenter: segment::Segmenter::default(), active_spot: None, library: None, library_identity: Arc::new(()), xmp: sidecar::XmpPrefs::default(), last_export: None, export_presets: Vec::new(), metadata_presets: Vec::new(), filter_presets: Vec::new(), curve_presets: Vec::new(), label_sets: Vec::new(), signatures: Default::default(), lut_profiles: Vec::new(), target_album: None, auto_import_seen: Default::default(), keyword_sets: Vec::new(), keyword_set: None, recent_keywords: Vec::new(), keyword_parent: None, before: Default::default(), import_probes: Default::default(), preview_build: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, } } /// A session with the procedurally generated demo library loaded. pub fn with_demo() -> Session { let mut s = Session::new(); demo::load(&mut s); s } /// Run a command by id. THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; (spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?; let empty = Value::Object(Default::default()); let params = if params.is_null() { &empty } else { params }; self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)) } /// Run `f` as command `id` with what [`Session::execute`] does around every command: the /// panic guard, auto versions, XMP sidecar auto-write and the durable save (a failed save is /// `NotSaved`). Not journaled. The app's import task commits its batches this way. pub fn execute_fn(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { self.run_command(id, None, f) } fn run_command(&mut self, id: &str, journal: Option<&Value>, f: impl FnOnce(&mut Session) -> Result) -> Result { let log_start = self.pending_log.len(); let was_active = self.active(); self.depth += 1; // last-resort guard: a panic in a command is that command's error, not a crash let r = guard::catch(&format!("`{id}`"), || f(self)).unwrap_or_else(|e| Err(EngineError::Other(e))); self.depth -= 1; if self.depth == 0 && was_active.is_some() && self.active() != was_active { self.previous_active = was_active; if let Some(left) = was_active { self.auto_version(left); } } if r.is_ok() && let Some(params) = journal && self.depth == 0 { self.journal.push((id.to_string(), params.clone())); if self.journal.len() > 10_000 { self.journal.drain(..1000); } } if self.depth == 0 { let skip = std::mem::take(&mut self.skip_auto_write); if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start { self.auto_write_sidecars(&self.pending_log[log_start..]); } } if self.depth == 0 && self.library.is_some() { // Make the command durable before reporting success. When the command's own records // can't be appended, it fails with `NotSaved`: the change stays applied in memory and // queued, and the next save (any later command, or the frame loop) retries it. Other // persistence trouble (a failed compaction, an older queued change still unwritten by // a command that changed nothing) doesn't fail the command; `library.info` reports it. let produced = self.pending_log.len() > log_start; if let Err(e @ EngineError::NotSaved(_)) = self.persist() && produced && r.is_ok() { return Err(e); } } r } pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } // ---------------------------------------------------------------- ops, undo /// Apply an op as one undoable step. pub fn commit(&mut self, label: &str, op: Op) -> Result<()> { let fwd = op.clone(); let inv = self.catalog.apply(op)?; self.pending_log.push(fwd); self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None }); if self.undo.len() > 1000 { self.undo.remove(0); } self.redo.clear(); Ok(()) } /// [`Session::commit`] for an op that goes with a folder already renamed on disk; the undo /// step renames it back (`folder` is the undo direction: current place → old place). pub(crate) fn commit_with_folder(&mut self, label: &str, op: Op, folder: FolderMove) -> Result<()> { self.commit(label, op)?; if let Some(e) = self.undo.last_mut() { e.folder = Some(folder); } Ok(()) } /// The box to cut a face's picture from: the detector's, when the scan has looked at the face (every face is then shown /// equally close, however loosely or tightly its own region was drawn), else the region's own box. pub fn face_view(&self, id: PhotoId, rect: lightcraft_geom::Rect) -> lightcraft_geom::Rect { #[cfg(not(target_arch = "wasm32"))] if let Some(v) = self.faces.index.view(id.0, &rect) { return v; } let _ = id; rect } /// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background /// scan): journaled like any op, but not an undo step, and it leaves the redo stack alone. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> { let fwd = op.clone(); self.catalog.apply(op)?; self.pending_log.push(fwd); Ok(()) } /// Leaving photo `id` after editing it: keep its settings as an automatic version (when they /// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped). /// Saved with the library but not an undo step. pub fn auto_version(&mut self, id: PhotoId) { let Some(p) = self.catalog.photo(id) else { return }; if !p.is_edited() || p.versions.last().is_some_and(|v| *v.settings == *p.develop) || self.interaction.is_some() { return; } let mut versions = p.versions.clone(); let created = (self.clock)(); let name = lightcraft_catalog::dates::display_time(&created); versions.push(lightcraft_catalog::Version { name, created, settings: p.develop.clone(), auto: true }); let autos = versions.iter().filter(|v| v.auto).count(); if autos > AUTO_VERSIONS && let Some(i) = versions.iter().position(|v| v.auto) { versions.remove(i); } let op = Op::SetVersions { id, versions }; if self.catalog.apply(op.clone()).is_ok() { self.pending_log.push(op); } } /// Fold the last `n` undo steps into one (commands that commit step by step because each op /// depends on the state the previous one left). pub fn merge_undo(&mut self, n: usize, label: &str) { if n < 2 || n > self.undo.len() { return; } // a step that moves a folder on disk stays on its own if self.undo[self.undo.len() - n..].iter().any(|e| e.folder.is_some()) { return; } let tail = self.undo.split_off(self.undo.len() - n); let ops = tail.into_iter().rev().map(|e| e.op).collect(); self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops }, folder: None }); } /// Apply without recording undo (interactive previews). fn apply_silent(&mut self, op: Op) -> Result<()> { self.catalog.apply(op)?; Ok(()) } pub fn undo_step(&mut self) -> Result { let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let redo = match self.apply_with_files(&e.op, e.folder.as_ref()) { Ok(r) => r, Err(err) => { self.undo.push(e); return Err(err); } }; self.show_undone(&e.op); self.pending_log.push(e.op); self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) }); Ok(e.label) } pub fn redo_step(&mut self) -> Result { let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; let undo = match self.apply_with_files(&e.op, e.folder.as_ref()) { Ok(r) => r, Err(err) => { self.redo.push(e); return Err(err); } }; self.show_undone(&e.op); self.pending_log.push(e.op); self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) }); Ok(e.label) } /// After an undo or redo that changed one photo (its edit, rating, flag, label, metadata or /// versions), that photo becomes the active one, as in Lightroom Classic, so the change is on /// screen (issue #293). It joins the selection if it isn't in it; nothing changes for steps /// that touch several photos, albums or files, or for a photo not in the current view. fn show_undone(&mut self, op: &Op) { let Some(id) = single_photo(op, 0) else { return }; if self.selection.active == Some(id) || !self.visible().contains(&id) { return; } if !self.selection.contains(id) { self.selection = Selection { ids: vec![id], active: Some(id) }; } else { self.selection.active = Some(id); } self.active_mask = None; self.active_spot = None; } /// Apply an undo/redo op, first renaming the step's folder and moving the files its renames /// imply (all or nothing). Files that can't be moved back after a failure are reported and the /// library follows them (a logged change outside the undo history), so none goes missing; the /// folder is only moved back when no file was left behind at its new path. fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result { let fs = rename::RealFs; if let Some(f) = folder { cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?; } let undo_folder = || { if let Some(f) = folder { let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from); } }; let moves = self.file_moves(op); if let Err(e) = rename::move_all(&fs, &moves) { if e.stuck.is_empty() { undo_folder(); } return Err(EngineError::Other(format!("can't move the files back: {}", self.follow_stuck(op, e, false)))); } match self.catalog.apply(op.clone()) { Ok(inv) => { if let Some(f) = folder { cmd::browse::follow_folder(self, &f.from, &f.to); } Ok(inv) } Err(e) => { let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); if let Err(be) = rename::move_all(&fs, &back) { return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message))); } undo_folder(); Err(e.into()) } } } /// Ops applied since the last call, for the op-log store. pub fn drain_log(&mut self) -> Vec { std::mem::take(&mut self.pending_log) } // ---------------------------------------------------------------- develop edits /// The photo being edited (the active photo). pub fn active(&self) -> Option { self.selection.active.filter(|id| self.catalog.photo(*id).is_some()) } pub fn develop_of(&self, id: PhotoId) -> Option> { self.catalog.photo(id).map(|p| p.develop.clone()) } /// Change a photo's develop settings. During an interaction the change is previewed without an /// undo step; otherwise it is committed with a history entry. pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> { let now = (self.clock)(); let settings = Arc::new(settings); if let Some(i) = &self.interaction && i.photo == id { return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) }); } let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?]; ops.extend(self.auto_sync_ops(id, &settings, label)); let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } }; self.commit(label, op) } /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the /// other selected photos: only the settings that changed; never spot removal or red eye (they /// belong to one photo's pixels), nor history / snapshot restores. fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec { if !self.auto_sync || self.active() != Some(id) || self.selection.ids.len() < 2 || label.starts_with("History:") || label.starts_with("Restore ") { return Vec::new(); } let Some(old) = self.develop_of(id) else { return Vec::new() }; let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() }; if let Some(o) = delta.as_object_mut() { for k in ["spots", "red_eye", "version"] { o.remove(k); } if o.is_empty() { return Vec::new(); } } self.selection .ids .iter() .filter(|x| **x != id) .filter_map(|x| self.develop_of(*x).map(|d| (*x, d))) .filter_map(|(x, d)| { let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0); (synced != *d).then(|| self.develop_op(x, synced, label)).flatten() }) .collect() } /// 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 { self.catalog.photo(id)?; let settings = Arc::new(settings); let step = lightcraft_catalog::HistoryStep { label: label.into(), settings: settings.clone() }; Some(Op::Batch { ops: vec![Op::SetDevelop { id, settings, label: label.into(), edited: Some((self.clock)()) }, Op::PushHistory { id, step }], }) } pub fn begin_interaction(&mut self, label: &str) -> Result<()> { if self.interaction.is_some() { self.end_interaction()?; } let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?; let original = self.develop_of(id).unwrap_or_default(); self.interaction = Some(Interaction { label: label.into(), photo: id, original }); Ok(()) } /// Commit the interaction as one undo step (no-op if nothing changed). pub fn end_interaction(&mut self) -> Result<()> { let Some(i) = self.interaction.take() else { return Ok(()) }; let Some(cur) = self.develop_of(i.photo) else { return Ok(()) }; if *cur == *i.original { return Ok(()); } // Restore the original silently, then commit the final value as one step. self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?; self.set_develop(i.photo, (*cur).clone(), &i.label) } pub fn cancel_interaction(&mut self) -> Result<()> { if let Some(i) = self.interaction.take() { self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original, label: i.label, edited: None })?; } Ok(()) } // ---------------------------------------------------------------- library view /// Photos shown in the grid/filmstrip for the current source, filter and sort. pub fn visible(&mut self) -> &[PhotoId] { let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder)); if self.source == LibrarySource::Missing && self.media.availability.is_background() { // the view fills in as the background checks find files gone key.1.push_str(&format!("|{}", self.media.availability.generation())); } if self.visible_key.as_ref() != Some(&key) { // "in the last N days" rules count back from the session's clock lightcraft_catalog::rules::set_now(Some((self.clock)())); let mut f = self.source.to_filter(&self.filter, &self.catalog); if self.source == LibrarySource::Folder { // no folder chosen: nothing (an empty path matches nothing) let b = self.browse.clone().unwrap_or_default(); f.folder = Some(b.path); f.subfolders = b.subfolders; } if self.source == LibrarySource::LibraryFolder { // no folder chosen: nothing (`.` names no folder) f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); } let mut visible = self.catalog.query(&f, &self.sort); if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() { // the shown folder lost its last photo (deleted, moved, removed): everything, not // an empty grid under the name of a folder that is gone from the sidebar self.source = LibrarySource::All; self.library_folder = None; return self.visible(); } if matches!(self.source, LibrarySource::Album(_)) && self.sort.key == lightcraft_catalog::SortKey::CaptureDate && let LibrarySource::Album(a) = self.source && let Some(al) = self.catalog.album(a) && !al.is_smart() && self.filter == Filter::default() { let order = al.photos.clone(); visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX)); if !self.sort.ascending { visible.reverse(); } } if self.source == LibrarySource::RecentlyAdded { // newest import first, whatever the sort (the grid groups by import day) let cat = &self.catalog; visible.sort_by(|a, b| { let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default(); key(b).cmp(&key(a)).then(a.cmp(b)) }); } if self.source == LibrarySource::Missing { // only the photos the query kept (library photos, not Local browse records) are checked let (cat, avail) = (&self.catalog, &self.media.availability); visible.retain(|id| cmd::missing::is_missing(cat, avail, *id)); } if self.source != LibrarySource::RecentlyDeleted { visible = self.catalog.arrange_stacks(&visible); } self.visible = visible.into(); self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.visible_key = Some(key); } &self.visible } /// Whether the library still holds a photo imported from the folder a `LibraryFolder` source /// shows. fn folder_holds_photos(&self) -> bool { let f = Filter { library_folder: self.library_folder.clone(), ..Default::default() }; self.library_folder.is_some() && !self.catalog.query(&f, &Sort::default()).is_empty() } /// Photos in the current source (folder, album, …) before the filter bar and search narrow /// them; `None` where that is not a separate number (Missing Photos). pub fn source_total(&mut self) -> Option { if self.source == LibrarySource::Missing { return None; } let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder)); if self.total.as_ref().map(|t| &t.0) != Some(&key) { let mut f = self.source.to_filter(&Filter::default(), &self.catalog); if self.source == LibrarySource::Folder { let b = self.browse.clone().unwrap_or_default(); f.folder = Some(b.path); f.subfolders = b.subfolders; } if self.source == LibrarySource::LibraryFolder { f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); } let n = self.catalog.query(&f, &self.sort).len(); self.total = Some((key, n)); } self.total.as_ref().map(|t| t.1) } pub fn visible_cloned(&mut self) -> Vec { self.visible().to_vec() } /// The visible photos without copying them, with their generation: equal generations mean /// the very same list (same catalog revision, source, filter, sort), so a frontend can cache /// whatever it derives from the list under that number. pub fn visible_shared(&mut self) -> (u64, Arc<[PhotoId]>) { self.visible(); (self.visible_gen, self.visible.clone()) } /// Targets of photo commands: explicit `ids`/`id` param, else the selection. pub fn targets(&self, p: &Value) -> Vec { if let Some(a) = p.get("ids").and_then(Value::as_array) { return a.iter().filter_map(Value::as_u64).map(PhotoId).collect(); } if let Some(id) = p.get("id").and_then(Value::as_u64) { return vec![PhotoId(id)]; } if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() } } } /// Automatic versions kept per photo. pub const AUTO_VERSIONS: usize = 20; /// The parts of `new` that differ from `old` (objects recurse; anything else is taken whole). pub fn json_delta(old: &Value, new: &Value) -> Option { match (old, new) { (Value::Object(a), Value::Object(b)) => { let m: serde_json::Map = b.iter().filter_map(|(k, nv)| json_delta(a.get(k).unwrap_or(&Value::Null), nv).map(|d| (k.clone(), d))).collect(); (!m.is_empty()).then_some(Value::Object(m)) } (a, b) if a == b => None, (_, b) => Some(b.clone()), } } #[cfg(test)] mod tests; #[cfg(test)] mod tests_album_order; #[cfg(test)] mod tests_color; #[cfg(test)] mod tests_denoise; #[cfg(test)] mod tests_export; #[cfg(test)] mod tests_face_models; #[cfg(test)] #[cfg(not(target_arch = "wasm32"))] mod tests_face_recognize; #[cfg(test)] mod tests_folders; #[cfg(test)] mod tests_forget_local; #[cfg(test)] mod tests_import; #[cfg(test)] mod tests_import_move; #[cfg(test)] mod tests_keyword_list; #[cfg(test)] mod tests_libops; #[cfg(test)] mod tests_library; #[cfg(test)] mod tests_merge; #[cfg(test)] mod tests_organize; #[cfg(test)] mod tests_persist; #[cfg(test)] mod tests_prefs; #[cfg(test)] mod tests_segment; #[cfg(test)] mod tests_settings_files; #[cfg(test)] mod tests_spots; #[cfg(test)] mod tests_xmp;