//! 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 activity; #[cfg(not(target_arch = "wasm32"))] pub mod apple_photos; pub mod availability; pub(crate) mod camera_preview; pub mod camera_profiles; pub mod cmd; pub mod config; pub mod contact_sheet; pub mod crs; pub mod crs_masks; pub mod demo; pub mod denoise; pub mod devices; pub mod display; pub mod doc_path; mod errors; pub mod export; pub mod face_download; pub mod face_tiles; #[cfg(not(target_arch = "wasm32"))] mod faces_index; #[cfg(not(target_arch = "wasm32"))] mod faces_worker; pub mod files; #[cfg(not(target_arch = "wasm32"))] mod focus_scan; pub mod fonts; pub mod guard; pub mod import; mod import_move; pub mod lens_profiles; pub mod library; mod lightroom_archive; pub mod lightroom_catalog; pub mod lightroom_folder; pub mod lightroom_job; mod lightroom_sqlite; pub mod logging; pub mod media; pub mod memory; pub mod merge; mod model_download; pub mod open_path; pub mod originals; mod pdf; pub mod preset_import; pub mod preset_luminar; pub mod presets; pub mod print; pub mod print_color; pub mod print_pdf; pub mod profile_refs; pub mod proof; pub mod rename; pub mod segment; pub mod sidecar; pub mod slide; pub mod slide_overlay; pub mod slide_template; pub mod slide_title; pub mod slideshow; pub mod slideshow_export; pub mod smart; pub mod sync; pub mod text; mod view; pub mod walk; use std::sync::Arc; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use errors::{AiModel, ErrorKind}; 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), /// The folder (or file) asked for is a Lightroom Classic catalog or part of one, not a /// LightCraft library (issue #891): nothing was created or opened. `catalog`: the `.lrcat` /// to import with `library.importLightroom`, when one was found. #[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))] LightroomCatalog { path: std::path::PathBuf, catalog: Option }, /// The work was cancelled (by the person, an agent or a newer request): not a failure. #[error("{0}")] Cancelled(String), /// The feature needs `model`, which is not installed (or still downloading): a frontend /// offers its download (terms first) on [`ErrorKind::ModelMissing`], not on the message. #[error("{msg}")] ModelMissing { model: AiModel, msg: 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. An /// empty `to` removes the (empty) folder `from`, an empty `from` creates `to` (Create Folder). pub folder: Option, /// Inverses of logged path corrections from a partially failed history step. pub(crate) retry_inverse: Vec, /// Sidecars still separated from their photo after a failed move or rollback. pub(crate) retry_sidecars: Vec, } /// 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, .. } | Op::SetHistoryCursor { 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, } /// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. const MAX_FOLLOWED: usize = 256; /// 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)>, /// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop /// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once. pub hold_view: bool, /// The view last worked out, the catalog's revision then, and the photos in it before stacks are arranged. /// A small edit is followed from it: only the photos the edit touched are asked about again and put at their /// place in the order ([`Session::visible`]). It is also what a held view holds ([`Session::hold_view`]). /// Another source, filter or sort is another view, and so is a smart album whose rules were changed. base: Option<(String, u64, Vec)>, /// How many times the view followed an edit from the list it had, and how many times it was worked out from /// every photo (`ui.inspect` → `perf`): what tells a slow library from a slow edit. pub view_followed: u64, pub view_worked_out: u64, /// Tests: work the view out whole every time (what following has to equal). #[cfg(test)] never_follow: bool, /// 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, /// Undo steps committed in this session, ever (the history drops its oldest past 1000, so /// its length can't tell how many steps a command just made; see [`Session::commits`]). commits: u64, pub redo: Vec, /// Exact filesystem repairs from discarded history; catalog inverses stay discarded. pub(crate) pending_sidecar_repairs: 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, /// The order of Reload from Disk runs, so an older one finishing late is dropped ([`cmd::reload`]). pub(crate) reload_tickets: cmd::reload::Tickets, /// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by /// [`Session::retry_unsaved_sidecars`] once their file is reachable. pub(crate) unsaved_sidecars: std::collections::BTreeSet, /// 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, /// Reverse geocoding: the parsed place names and their download (`metadata.*PlaceNames`). pub place_names: cmd::geocode::PlaceNames, /// 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, /// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`). #[cfg(not(target_arch = "wasm32"))] pub(crate) focus_scan: focus_scan::FocusScan, /// 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, /// Built-in then user presets; its generation tells savers and caches when it changed. pub presets: presets::PresetList, /// 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 options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`, /// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses /// them. Persisted in prefs.json. pub last_merge: std::collections::BTreeMap, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec, /// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives /// with the session, so the app, CLI, control channel and MCP all see the same print. pub print: print::template::PrintSettings, /// Saved print templates (`print.template.*`), persisted in prefs.json. pub print_templates: 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, /// Mask effect presets (the Masking panel's Effect menu). pub mask_effects: Vec, /// The effect a new mask of each kind (`brush`, `linear`…) starts with: the one last applied /// to a mask of that kind. pub mask_tool_effects: std::collections::BTreeMap, /// 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, /// 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 Synchronize Folder scan, which `folder.synchronize` acts on while it is current /// (see [`Session::take_folder_changes`]). pub folder_changes: Option, /// Background tasks in flight (imports, exports, preview builds…), for the activity stack and /// `activity.list` / `activity.cancel`. pub activity: activity::Activity, /// The last (or running) slideshow export (`slideshow.export`). pub slideshow_export: Option>, /// 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, /// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json). pub slideshow: slideshow::PlaybackSettings, /// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in /// prefs.json). pub slide_layout: slide::SlideSettings, /// Slideshow overlays (identity plate, rating stars, text overlays, watermark). pub slide_overlays: slide_overlay::OverlaySettings, /// Slideshow intro and ending screens (`slideshow.titles`; persisted in prefs.json). pub slide_titles: slide_title::TitleSettings, /// Where user slideshow templates are kept (`/slideshow-templates`; /// [`Session::with_default_slide_templates`]). `None`: the built-in templates only. pub slide_templates_dir: Option, /// The slideshow settings before the last template was applied ("Previous settings"; this /// session only: library preferences have no undo). pub slide_templates_previous: Option, /// The templates as last listed. pub(crate) slide_templates_cache: slide_template::Cache, /// The background image the slides last rendered (shared by slide jobs: rendered once). pub slide_backdrop: std::sync::Arc, /// 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, /// Adds exported files to Apple Photos ([`apple_photos`]): `/usr/bin/osascript`, installed by /// [`Session::with_fs`] on macOS; `None` elsewhere and in sessions without a file system. Tests /// put a fake here. #[cfg(not(target_arch = "wasm32"))] pub apple_photos: Option, /// This session's Apple Photos imports, running or finished (`export.photosImports`). #[cfg(not(target_arch = "wasm32"))] pub apple_photos_imports: apple_photos::Imports, /// Apple Photos imports run on a worker by default (`export.addToPhotos` without `wait`, /// `app.export` without `background`): set by the desktop app, which answers control requests /// on its UI thread. Off (the default), they wait for Photos, so a one-shot process /// (lightcraft-cli, a script) never ends in the middle of one. #[cfg(not(target_arch = "wasm32"))] pub apple_photos_background: bool, } 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, hold_view: false, base: None, view_followed: 0, view_worked_out: 0, #[cfg(test)] never_follow: false, visible_gen: 0, total: None, undo: Vec::new(), commits: 0, redo: Vec::new(), pending_sidecar_repairs: Vec::new(), interaction: None, skip_auto_write: false, reload_tickets: Default::default(), unsaved_sidecars: Default::default(), denoise: Default::default(), face_models_dir: None, face_downloads: Default::default(), place_names: Default::default(), face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), #[cfg(not(target_arch = "wasm32"))] focus_scan: Default::default(), clipboard: None, meta_clipboard: None, browse: None, library_folder: None, previous_active: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), presets: presets::PresetList::new(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, last_merge: Default::default(), export_presets: Vec::new(), print: Default::default(), print_templates: Vec::new(), metadata_presets: Vec::new(), filter_presets: Vec::new(), curve_presets: Vec::new(), mask_effects: Vec::new(), mask_tool_effects: Default::default(), 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(), before: Default::default(), import_probes: Default::default(), folder_changes: None, activity: Default::default(), preview_build: None, slideshow_export: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, slideshow: slideshow::PlaybackSettings::default(), slide_layout: slide::SlideSettings::default(), slide_overlays: slide_overlay::OverlaySettings::default(), slide_titles: slide_title::TitleSettings::default(), slide_templates_dir: None, slide_templates_previous: None, slide_templates_cache: None, slide_backdrop: Default::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, #[cfg(not(target_arch = "wasm32"))] apple_photos: None, #[cfg(not(target_arch = "wasm32"))] apple_photos_imports: Default::default(), #[cfg(not(target_arch = "wasm32"))] apple_photos_background: false, } } /// 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()))?; // a command that validates the photos a call names isn't held back by the selection if !(spec.explicit_targets && cmd::names_photos(params)) { (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)).map_err(|e| match e { // a shared parameter check ([`Session::targets`]) names the command it ran for EngineError::BadParams { cmd, msg } if cmd.is_empty() => EngineError::BadParams { cmd: id.to_string(), msg }, e => e, }) } /// 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_since(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.apply_catalog(op)?; self.pending_log.push(fwd); self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None, retry_inverse: Vec::new(), retry_sidecars: Vec::new() }); self.commits = self.commits.wrapping_add(1); if self.undo.len() > 1000 { let mut dropped = self.undo.remove(0); self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars); } for mut dropped in self.redo.drain(..) { self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars); } Ok(()) } fn apply_catalog(&mut self, op: Op) -> Result { // Retain only cache IDs while the catalog consumes the operation. Cloning an import // batch here also cloned every photo, although commit already keeps the journal copy. let mut invalidated = Vec::new(); // photos whose file was replaced: the faces made from the old pixels go too (issue #1524) let mut replaced = Vec::new(); let mut pending = vec![&op]; while let Some(op) = pending.pop() { match op { Op::SetContent { id, .. } => { invalidated.push(*id); replaced.push(*id); } Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::RemovePhoto { id } => invalidated.push(*id), Op::AddPhoto { photo } => invalidated.push(photo.id), Op::Batch { ops } => pending.extend(ops), _ => {} } } let inv = self.catalog.apply(op)?; for id in invalidated { self.media.forget(id); } #[cfg(not(target_arch = "wasm32"))] for id in replaced { self.faces.content_replaced(id); } #[cfg(target_arch = "wasm32")] let _ = replaced; Ok(inv) } /// [`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, a raw's as-shot white read by a decode, raws rechecked after an update): journaled like any op, but not /// an undo step, and it leaves the redo stack alone. 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). /// How many undo steps were committed in this session so far: what a command that commits /// several steps compares before and after to merge them ([`Session::merge_undo`]). pub fn commits(&self) -> u64 { self.commits } pub fn merge_undo(&mut self, n: usize, label: &str) { if n < 2 || n > self.undo.len() { return; } let Some(entries) = self.undo.get(self.undo.len() - n..) else { return }; // Folder moves and partially applied file steps stay on their own. if entries.iter().any(|e| e.folder.is_some() || !e.retry_inverse.is_empty() || !e.retry_sidecars.is_empty()) { 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, retry_inverse: Vec::new(), retry_sidecars: Vec::new(), }); } /// 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 { self.undo_step_with(&rename::RealFs) } pub(crate) fn undo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result { let mut e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let redo = match self.apply_with_files(&mut e, fs) { 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), retry_inverse: Vec::new(), retry_sidecars: Vec::new(), }); Ok(e.label) } pub fn redo_step(&mut self) -> Result { self.redo_step_with(&rename::RealFs) } pub(crate) fn redo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result { let mut e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; let undo = match self.apply_with_files(&mut e, fs) { 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), retry_inverse: Vec::new(), retry_sidecars: Vec::new(), }); 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, entry: &mut UndoEntry, fs: &dyn rename::MoveFs) -> Result { // The entry is detached from self.undo/self.redo: include it before any global // repair can retire, then retry revalidates it before moving its own packets. rename::validate_sidecars(fs, &entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?; // Repair discarded file steps before another path change can stale their exact paths. // Unrelated metadata history must remain usable while a sidecar is still unwritable. if entry.folder.is_some() || !entry.retry_sidecars.is_empty() || !self.file_moves(&entry.op).is_empty() { self.repair_sidecars_with(fs).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?; } rename::retry_sidecars(fs, &mut entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?; let op = &entry.op; let folder = entry.folder.as_ref(); if let Some(f) = folder { cmd::browse::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::folder_on_disk(&f.to, &f.from); } }; let moves = self.file_moves(op); if let Err(mut e) = rename::move_all(fs, &moves) { entry.retry_sidecars.append(&mut e.pending_sidecars); if e.stuck.is_empty() { undo_folder(); } let (message, mut inverses) = self.follow_stuck(op, e, false); // A later successful retry observes the corrected paths. Retain their old paths // now, so the opposite history direction still restores the entire original step. inverses.append(&mut entry.retry_inverse); entry.retry_inverse = inverses; return Err(EngineError::Other(format!("can't move the files back: {message}"))); } match self.apply_catalog(op.clone()) { Ok(inv) => { if let Some(f) = folder { cmd::browse::follow_folder(self, &f.from, &f.to); } if entry.retry_inverse.is_empty() { Ok(inv) } else { let mut ops = match inv { Op::Batch { ops } => ops, op => vec![op], }; ops.append(&mut entry.retry_inverse); Ok(Op::Batch { ops }) } } Err(e) => { let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); if let Err(mut be) = rename::move_all(fs, &back) { entry.retry_sidecars.append(&mut be.pending_sidecars); return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message))); } undo_folder(); Err(e) } } } /// 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 the rendering process (Sync doesn't carry it either), 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", "process"] { 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)); // What a held view is told apart by: what is shown and how, and for a smart album its rules (rules that were // changed ask something else). Not the checks of Missing Photos below: they fill the view in, they are // no other view. let asks = matches!(self.source, LibrarySource::Picks) || matches!(self.source, LibrarySource::Album(a) if self.catalog.album(a).is_some_and(|al| al.is_smart())); let rules = match self.source { LibrarySource::Album(a) if asks => self.catalog.album(a).map(|al| format!("{:?}", al.smart)).unwrap_or_default(), _ => String::new(), }; let view = format!("{}|{rules}", key.1); 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 f = self.view_filter(self.source, &self.filter); // A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's // rules, a flag, the filter bar), as long as they are still where the view looks: in the library for // a view that is only a question, else in the album or folder shown. Taking a photo out of an album // or the library takes it out of the view at once, held or not. // (only a view that asks something can lose a photo to an edit: the others are queried as ever, at no // cost for the hold) let holds = self.hold_view && (asks || self.filter != Filter::default()); let within = holds.then(|| { if asks { self.view_filter(LibrarySource::All, &Filter::default()) } else if self.source == LibrarySource::RecentlyAdded { // (how far back it reaches was worked out for `f`: not a second pass over the library) Filter { imported_from: f.imported_from.clone(), ..Default::default() } } else { self.view_filter(self.source, &Filter::default()) } }); // Views whose order or photos are not the query's alone are worked out whole each time: an album in // its own order, Recently Added (how far back it reaches moves with every import), Missing Photos. let own_order = matches!(self.source, LibrarySource::Album(a) if self.sort.key == lightcraft_catalog::SortKey::CaptureDate && self.filter == Filter::default() && self.catalog.album(a).is_some_and(|al| !al.is_smart())); let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing); // A view that asks about "the last N days" (itself, or through a smart album it looks into) changes // with the clock as well as with edits: a photo nobody touched can stop matching. It is asked whole. let clocked = f.depends_on_now() || (!f.albums_tested().is_empty() && self.catalog.albums().any(|al| al.smart.as_deref().is_some_and(Filter::depends_on_now))); let base = self.base.take().filter(|(of, _, _)| *of == view); // A small edit to the same view: the photos it touched are taken out, asked about again, and put back // at their place in the order. The others neither stopped matching nor moved. #[cfg(test)] let clocked = clocked || self.never_follow; let followed = base.as_ref().filter(|_| !whole && !clocked).and_then(|(_, revision, ids)| { let touched = self.catalog.touched_since(*revision).filter(|touched| touched.len() <= MAX_FOLLOWED)?; Some(self.catalog.follow(ids, &touched, &f, &self.sort, within.as_ref())) }); match followed { Some(_) => self.view_followed += 1, None => self.view_worked_out += 1, } let mut visible = match (followed, &base, &within) { (Some(ids), _, _) => ids, (None, Some((_, _, held)), Some(within)) => { let keep: std::collections::HashSet = held.iter().copied().collect(); self.catalog.query_keeping(&f, &self.sort, &keep, within) } _ => self.catalog.query(&f, &self.sort), }; // what the view shows now is what the next edit is followed from, and what it holds until it is // refreshed (taken before stacks are arranged, so a photo folded into a stack is held with it) self.base = (!whole || holds).then(|| (view, self.catalog.revision, visible.clone())); 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() { // each photo's place looked up, not searched for (a big album sorted in O(n log n)) let mut place: std::collections::HashMap = std::collections::HashMap::with_capacity(al.photos.len()); for (i, id) in al.photos.iter().enumerate() { place.entry(*id).or_insert(i); } visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX)); if !self.sort.ascending { visible.reverse(); } } if self.source == LibrarySource::RecentlyAdded { // newest import first, whatever the sort direction (the grid groups by import day); // a batch stays in file order (name, then id) let cat = &self.catalog; visible.sort_by(|a, b| { let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default(); let name = |id: &PhotoId| cat.photo(*id).map(|p| p.file_name.as_str()); let by_name = match (name(a), name(b)) { (Some(x), Some(y)) => lightcraft_catalog::query::cmp_name(x, y), _ => std::cmp::Ordering::Equal, }; key(b).cmp(&key(a)).then(by_name).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 } /// `source` as a filter over `base` (the filter bar's, or none), with the folder it shows. fn view_filter(&self, source: LibrarySource, base: &Filter) -> Filter { let mut f = source.to_filter(base, &self.catalog); if 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 source == LibrarySource::LibraryFolder { // no folder chosen: nothing (`.` names no folder) f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); } f } /// Let go of what a held view kept: the view shows what matches now ([`Session::hold_view`]). pub fn refresh_view(&mut self) { self.base = None; self.visible_key = None; } /// [`Session::visible`] worked out from every photo, whatever was followed before: what following an edit /// has to equal. /// Nothing the session keeps is changed by asking: the next edit is followed from where the last one left the /// view, so a list followed many times over is compared, not only one followed once. #[cfg(test)] pub(crate) fn visible_in_full(&mut self) -> Vec { let kept = (self.base.clone(), self.visible_key.clone(), self.visible.clone(), self.visible_gen, self.view_followed, self.view_worked_out); self.never_follow = true; self.visible_key = None; let whole = self.visible().to_vec(); self.never_follow = false; (self.base, self.visible_key, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept; whole } /// 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; } if self.filter == Filter::default() && !self.hold_view { // nothing narrows the source: what the view lists is all of it, and is counted already (not a view // that holds photos that stopped matching: those are not of the source any more) let _ = self.visible(); if let Some((_, revision, ids)) = &self.base && *revision == self.catalog.revision { return Some(ids.len()); } } 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())); } // (how many, not which: nothing to put in order) let n = self.catalog.count(&f); self.total = Some((key, n)); } self.total.as_ref().map(|t| t.1) } pub fn visible_cloned(&mut self) -> Vec { self.visible().to_vec() } /// After the source changes, the active photo follows the rule every source shares: it stays /// when the view shows it, else the view's first photo becomes the active (and only selected) /// one, or nothing when the view is empty. A view never keeps an active photo it doesn't show /// (issue #1447: a Local folder kept a library photo active, so Detail and the info showed a /// photo the grid didn't). pub fn keep_active_in_view(&mut self) { let active = self.selection.active; let vis = self.visible(); if active.is_some_and(|a| vis.contains(&a)) { return; } let first = vis.first().copied(); self.selection = first.map(Selection::single).unwrap_or_default(); } /// 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: the explicit `ids` (photo ids) or `id` (a photo id) param, else [`Self::selected`]. /// An `ids` or `id` that is there but isn't photo ids is an error, never the selection or part of the list /// (issue #1506): a mistyped target mustn't turn a command meant for one photo into one for every selected photo. pub fn targets(&self, p: &Value) -> Result> { let invalid = |what: &str| EngineError::BadParams { cmd: String::new(), msg: format!("`{what}` must be photo ids (whole numbers)") }; match p.get("ids") { None | Some(Value::Null) => {} Some(Value::Array(a)) => return a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| invalid("ids"))).collect(), Some(_) => return Err(invalid("ids")), } match p.get("id") { None | Some(Value::Null) => Ok(self.selected()), Some(v) => v.as_u64().map(|id| vec![PhotoId(id)]).ok_or_else(|| invalid("id")), } } /// The photos commands act on when a call names none: the selection, else the active photo. pub fn selected(&self) -> Vec { 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_album_remove; #[cfg(test)] mod tests_albums_local; #[cfg(test)] mod tests_color; #[cfg(test)] mod tests_crop_angle; #[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)] #[cfg(not(target_arch = "wasm32"))] mod tests_face_tiles; #[cfg(test)] mod tests_filesystem_history; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests_focus_scan; #[cfg(test)] mod tests_folders; #[cfg(test)] mod tests_forget_local; #[cfg(test)] mod tests_history_cursor; #[cfg(test)] mod tests_hold_view; #[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_lightroom_folder; #[cfg(test)] mod tests_mask_effects; #[cfg(test)] mod tests_merge; #[cfg(test)] mod tests_move_to_share; #[cfg(test)] mod tests_organize; #[cfg(test)] mod tests_persist; #[cfg(test)] mod tests_prefs; #[cfg(test)] mod tests_presets_scale; #[cfg(test)] mod tests_print; #[cfg(test)] mod tests_process; #[cfg(test)] mod tests_recheck; #[cfg(test)] mod tests_segment; #[cfg(test)] mod tests_settings_files; #[cfg(test)] mod tests_slide; #[cfg(test)] mod tests_slideshow_export; #[cfg(test)] mod tests_spots; #[cfg(test)] mod tests_sync; #[cfg(test)] mod tests_view_follow; #[cfg(test)] mod tests_xmp;