1086 lines
47 KiB
Rust
1086 lines
47 KiB
Rust
//! 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 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 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 originals;
|
||
pub mod preset_import;
|
||
pub mod preset_luminar;
|
||
pub mod presets;
|
||
pub mod proof;
|
||
pub mod rename;
|
||
pub mod segment;
|
||
pub mod sidecar;
|
||
pub mod smart;
|
||
pub mod sync;
|
||
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),
|
||
/// 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<std::path::PathBuf> },
|
||
#[error("{0}")]
|
||
Other(String),
|
||
}
|
||
|
||
pub type Result<T> = std::result::Result<T, EngineError>;
|
||
|
||
/// 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<FolderMove>,
|
||
/// Inverses of logged path corrections from a partially failed history step.
|
||
pub(crate) retry_inverse: Vec<Op>,
|
||
/// Sidecars still separated from their photo after a failed move or rollback.
|
||
pub(crate) retry_sidecars: Vec<rename::SidecarMove>,
|
||
}
|
||
|
||
/// 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<PhotoId> {
|
||
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<DevelopSettings>,
|
||
}
|
||
|
||
/// 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 library revision, and by the
|
||
/// full catalog revision when the filter, sort or source reads develop state).
|
||
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
|
||
visible: Arc<[PhotoId]>,
|
||
visible_key: Option<ViewKey>,
|
||
/// 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<(ViewKey, usize)>,
|
||
pub undo: Vec<UndoEntry>,
|
||
/// 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<UndoEntry>,
|
||
/// Exact filesystem repairs from discarded history; catalog inverses stay discarded.
|
||
pub(crate) pending_sidecar_repairs: Vec<rename::SidecarMove>,
|
||
pub interaction: Option<Interaction>,
|
||
/// 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<std::path::PathBuf>,
|
||
/// Face model downloads started this session (the staged files wait in `<face_models_dir>/.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,
|
||
/// Copied develop settings (partial JSON) for Paste.
|
||
pub clipboard: Option<Value>,
|
||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||
pub browse: Option<Browse>,
|
||
/// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported
|
||
/// from it and from the folders inside it.
|
||
pub library_folder: Option<String>,
|
||
/// Copied metadata (`photo.copyMetadata`): photo.setMeta params.
|
||
pub meta_clipboard: Option<Value>,
|
||
/// The photo that was active before the current one (Paste Settings from Previous).
|
||
pub previous_active: Option<PhotoId>,
|
||
/// Groups last used for Copy (Lightroom remembers them).
|
||
pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,
|
||
pub presets: Vec<lightcraft_develop::Preset>,
|
||
/// Favourite profile ids (persisted with the library, like preset favourites).
|
||
pub profile_favorites: Vec<String>,
|
||
/// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]).
|
||
pub profile_recent: Vec<String>,
|
||
/// Executed commands (actions / debugging / replay).
|
||
pub journal: Vec<(String, Value)>,
|
||
/// Ops applied since the last `drain_log` (for persistence).
|
||
pending_log: Vec<Op>,
|
||
pub media: media::MediaCache,
|
||
/// Current time provider (ISO 8601); injectable for tests and wasm.
|
||
pub clock: Box<dyn Fn() -> String + Send>,
|
||
depth: u32,
|
||
/// Selected mask (Masking panel), by mask id.
|
||
pub active_mask: Option<u32>,
|
||
/// 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<usize>,
|
||
/// The persistent library this session writes to (`None` = in-memory only).
|
||
pub library: Option<library::Library>,
|
||
/// 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<serde_json::Value>,
|
||
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
|
||
pub export_presets: Vec<export::ExportPreset>,
|
||
/// Metadata presets (`metadata.*`), persisted in prefs.json.
|
||
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
|
||
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
|
||
pub filter_presets: Vec<cmd::filters::FilterPreset>,
|
||
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
|
||
/// persisted in prefs.json.
|
||
pub curve_presets: Vec<cmd::curves::CurvePreset>,
|
||
/// Saved colour-label name sets.
|
||
pub label_sets: Vec<cmd::manage::LabelSet>,
|
||
/// Look-alike signatures by content key (Find Similar).
|
||
pub signatures: std::collections::HashMap<String, [f32; 64]>,
|
||
/// Imported `.cube` LUT profiles.
|
||
pub lut_profiles: Vec<cmd::lut_profiles::LutProfile>,
|
||
/// The target album B adds to (`None` = the Quick Collection).
|
||
pub target_album: Option<lightcraft_catalog::AlbumId>,
|
||
/// 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<String, u64>,
|
||
/// 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<cmd::keywords::KeywordSet>,
|
||
pub keyword_set: Option<String>,
|
||
pub recent_keywords: Vec<String>,
|
||
/// 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<PhotoId, Arc<DevelopSettings>>,
|
||
/// File probes from the last import review (`library.importPreview`), reused by the import.
|
||
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
|
||
/// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current
|
||
/// (see [`Session::take_folder_changes`]).
|
||
pub folder_changes: Option<sync::FolderChanges>,
|
||
/// 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) Build Previews.
|
||
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
|
||
/// 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<std::path::PathBuf>,
|
||
/// 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<apple_photos::Runner>,
|
||
/// 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,
|
||
visible_gen: 0,
|
||
total: None,
|
||
undo: Vec::new(),
|
||
commits: 0,
|
||
redo: Vec::new(),
|
||
pending_sidecar_repairs: Vec::new(),
|
||
interaction: None,
|
||
skip_auto_write: false,
|
||
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(),
|
||
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(),
|
||
before: Default::default(),
|
||
import_probes: Default::default(),
|
||
folder_changes: None,
|
||
activity: 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,
|
||
#[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<Value> {
|
||
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))
|
||
}
|
||
|
||
/// 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<Value>) -> Result<Value> {
|
||
self.run_command(id, None, f)
|
||
}
|
||
|
||
fn run_command(&mut self, id: &str, journal: Option<&Value>, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||
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<CommandInfo> {
|
||
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<Op> {
|
||
let inv = self.catalog.apply(op.clone())?;
|
||
let mut pending = vec![&op];
|
||
while let Some(op) = pending.pop() {
|
||
match op {
|
||
Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id),
|
||
Op::AddPhoto { photo } => self.media.forget(photo.id),
|
||
Op::Batch { ops } => pending.extend(ops),
|
||
_ => {}
|
||
}
|
||
}
|
||
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): 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<String> {
|
||
self.undo_step_with(&rename::RealFs)
|
||
}
|
||
|
||
pub(crate) fn undo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result<String> {
|
||
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<String> {
|
||
self.redo_step_with(&rename::RealFs)
|
||
}
|
||
|
||
pub(crate) fn redo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result<String> {
|
||
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<Op> {
|
||
// 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<Op> {
|
||
std::mem::take(&mut self.pending_log)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- develop edits
|
||
|
||
/// The photo being edited (the active photo).
|
||
pub fn active(&self) -> Option<PhotoId> {
|
||
self.selection.active.filter(|id| self.catalog.photo(*id).is_some())
|
||
}
|
||
|
||
pub fn develop_of(&self, id: PhotoId) -> Option<Arc<DevelopSettings>> {
|
||
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<Op> {
|
||
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<Op> {
|
||
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] {
|
||
// a develop edit (a slider tick) only redoes the query when the filter, sort or source reads develop state
|
||
let reads_develop = self.source_reads_develop() || self.sort.reads_develop() || self.catalog.filter_reads_develop(&self.filter);
|
||
let revision = self.catalog.view_revision(reads_develop);
|
||
// the Missing Photos view fills in as the background checks find files gone
|
||
let availability =
|
||
(self.source == LibrarySource::Missing && self.media.availability.is_background()).then(|| self.media.availability.generation());
|
||
if !self.visible_key.as_ref().is_some_and(|k| k.is(self, revision, true, availability)) {
|
||
let key = ViewKey::of(self, revision, true, availability);
|
||
// "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<usize> {
|
||
if self.source == LibrarySource::Missing {
|
||
return None;
|
||
}
|
||
let revision = self.catalog.view_revision(self.source_reads_develop());
|
||
if !self.total.as_ref().is_some_and(|t| t.0.is(self, revision, false, None)) {
|
||
let key = ViewKey::of(self, revision, false, None);
|
||
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)
|
||
}
|
||
|
||
/// Whether what the current source holds can change with a develop edit (a smart album
|
||
/// whose rules read develop state; see [`lightcraft_catalog::revision`]).
|
||
fn source_reads_develop(&self) -> bool {
|
||
match self.source {
|
||
LibrarySource::Album(a) => self.catalog.album_reads_develop(a),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
|
||
self.visible().to_vec()
|
||
}
|
||
|
||
/// The visible photos without copying them, with their generation: equal generations mean
|
||
/// the very same list (same library, 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<PhotoId> {
|
||
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() }
|
||
}
|
||
}
|
||
|
||
/// What [`Session::visible`] (or [`Session::source_total`]) was worked out from. Compared every
|
||
/// frame, so without formatting or copying anything.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct ViewKey {
|
||
revision: lightcraft_catalog::ViewRevision,
|
||
source: LibrarySource,
|
||
/// The filter bar and sort (`None` for the source total, which ignores them).
|
||
view: Option<(Filter, Sort)>,
|
||
browse: Option<Browse>,
|
||
library_folder: Option<String>,
|
||
availability: Option<u64>,
|
||
}
|
||
|
||
impl ViewKey {
|
||
fn of(s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option<u64>) -> ViewKey {
|
||
ViewKey {
|
||
revision,
|
||
source: s.source,
|
||
view: filtered.then(|| (s.filter.clone(), s.sort)),
|
||
browse: s.browse.clone(),
|
||
library_folder: s.library_folder.clone(),
|
||
availability,
|
||
}
|
||
}
|
||
|
||
fn is(&self, s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option<u64>) -> bool {
|
||
let view = match &self.view {
|
||
Some((f, sort)) => filtered && *f == s.filter && *sort == s.sort,
|
||
None => !filtered,
|
||
};
|
||
self.revision == revision
|
||
&& self.source == s.source
|
||
&& view
|
||
&& self.browse == s.browse
|
||
&& self.library_folder == s.library_folder
|
||
&& self.availability == availability
|
||
}
|
||
}
|
||
|
||
/// 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<Value> {
|
||
match (old, new) {
|
||
(Value::Object(a), Value::Object(b)) => {
|
||
let m: serde_json::Map<String, Value> =
|
||
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_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)]
|
||
mod tests_filesystem_history;
|
||
#[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_invalidation;
|
||
#[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_merge;
|
||
#[cfg(test)]
|
||
mod tests_organize;
|
||
#[cfg(test)]
|
||
mod tests_persist;
|
||
#[cfg(test)]
|
||
mod tests_prefs;
|
||
#[cfg(test)]
|
||
mod tests_process;
|
||
#[cfg(test)]
|
||
mod tests_segment;
|
||
#[cfg(test)]
|
||
mod tests_settings_files;
|
||
#[cfg(test)]
|
||
mod tests_spots;
|
||
#[cfg(test)]
|
||
mod tests_sync;
|
||
#[cfg(test)]
|
||
mod tests_xmp;
|