mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
595 lines
24 KiB
Rust
595 lines
24 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)]
|
|
|
|
pub mod cmd;
|
|
pub mod crs;
|
|
pub mod crs_masks;
|
|
pub mod demo;
|
|
pub mod devices;
|
|
pub mod export;
|
|
pub mod files;
|
|
pub mod import;
|
|
pub mod library;
|
|
pub mod media;
|
|
pub mod memory;
|
|
pub mod merge;
|
|
pub mod preset_import;
|
|
pub mod preset_luminar;
|
|
pub mod presets;
|
|
pub mod rename;
|
|
pub mod sidecar;
|
|
pub mod smart;
|
|
mod view;
|
|
|
|
use std::sync::Arc;
|
|
|
|
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
|
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, 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),
|
|
#[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,
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
pub struct Session {
|
|
/// 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).
|
|
visible: Vec<PhotoId>,
|
|
visible_key: Option<(u64, String)>,
|
|
/// Photos in the current source with no filter on (cached like `visible`).
|
|
total: Option<((u64, String), usize)>,
|
|
pub undo: Vec<UndoEntry>,
|
|
pub redo: Vec<UndoEntry>,
|
|
pub interaction: Option<Interaction>,
|
|
/// Copied develop settings (partial JSON) for Paste.
|
|
pub clipboard: Option<Value>,
|
|
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
|
pub browse: Option<Browse>,
|
|
/// 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>,
|
|
/// 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>,
|
|
/// 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 (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>,
|
|
}
|
|
|
|
impl Default for Session {
|
|
fn default() -> Self {
|
|
Session::new()
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
pub fn new() -> Session {
|
|
Session {
|
|
auto_sync: false,
|
|
catalog: Catalog::new(),
|
|
source: LibrarySource::All,
|
|
filter: Filter::default(),
|
|
sort: Sort::default(),
|
|
selection: Selection::default(),
|
|
visible: Vec::new(),
|
|
visible_key: None,
|
|
total: None,
|
|
undo: Vec::new(),
|
|
redo: Vec::new(),
|
|
interaction: None,
|
|
clipboard: None,
|
|
meta_clipboard: None,
|
|
browse: 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,
|
|
active_spot: None,
|
|
library: None,
|
|
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(),
|
|
preview_build: None,
|
|
import_defaults: import::ImportDefaults::default(),
|
|
cache_mb: 0,
|
|
smart_previews_dir: None,
|
|
}
|
|
}
|
|
|
|
/// 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()))?;
|
|
(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 };
|
|
let log_start = self.pending_log.len();
|
|
let was_active = self.active();
|
|
self.depth += 1;
|
|
let r = (spec.run)(self, params);
|
|
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() && spec.journal && self.depth == 0 {
|
|
self.journal.push((id.to_string(), params.clone()));
|
|
if self.journal.len() > 10_000 {
|
|
self.journal.drain(..1000);
|
|
}
|
|
}
|
|
if r.is_ok() && self.depth == 0 && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
|
|
self.auto_write_sidecars(&self.pending_log[log_start..]);
|
|
}
|
|
if self.depth == 0 && self.library.is_some() {
|
|
// Make the command durable before reporting success (on failure the ops stay pending,
|
|
// are retried after the next command, and `library.info` reports the error).
|
|
let _ = self.persist();
|
|
}
|
|
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.catalog.apply(op)?;
|
|
self.pending_log.push(fwd);
|
|
self.undo.push(UndoEntry { label: label.to_string(), op: inv });
|
|
if self.undo.len() > 1000 {
|
|
self.undo.remove(0);
|
|
}
|
|
self.redo.clear();
|
|
Ok(())
|
|
}
|
|
|
|
/// Leaving photo `id` after editing it: keep its settings as an automatic version (when they
|
|
/// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped).
|
|
/// Saved with the library but not an undo step.
|
|
pub fn auto_version(&mut self, id: PhotoId) {
|
|
let Some(p) = self.catalog.photo(id) else { return };
|
|
if !p.is_edited() || p.versions.last().is_some_and(|v| *v.settings == *p.develop) || self.interaction.is_some() {
|
|
return;
|
|
}
|
|
let mut versions = p.versions.clone();
|
|
let created = (self.clock)();
|
|
let name = lightcraft_catalog::dates::display_time(&created);
|
|
versions.push(lightcraft_catalog::Version { name, created, settings: p.develop.clone(), auto: true });
|
|
let autos = versions.iter().filter(|v| v.auto).count();
|
|
if autos > AUTO_VERSIONS
|
|
&& let Some(i) = versions.iter().position(|v| v.auto)
|
|
{
|
|
versions.remove(i);
|
|
}
|
|
let op = Op::SetVersions { id, versions };
|
|
if self.catalog.apply(op.clone()).is_ok() {
|
|
self.pending_log.push(op);
|
|
}
|
|
}
|
|
|
|
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
|
|
/// depends on the state the previous one left).
|
|
pub fn merge_undo(&mut self, n: usize, label: &str) {
|
|
if n < 2 || n > self.undo.len() {
|
|
return;
|
|
}
|
|
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 } });
|
|
}
|
|
|
|
/// 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> {
|
|
let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
|
let redo = match self.apply_with_files(&e.op) {
|
|
Ok(r) => r,
|
|
Err(err) => {
|
|
self.undo.push(e);
|
|
return Err(err);
|
|
}
|
|
};
|
|
self.pending_log.push(e.op);
|
|
self.redo.push(UndoEntry { label: e.label.clone(), op: redo });
|
|
Ok(e.label)
|
|
}
|
|
|
|
pub fn redo_step(&mut self) -> Result<String> {
|
|
let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
|
let undo = match self.apply_with_files(&e.op) {
|
|
Ok(r) => r,
|
|
Err(err) => {
|
|
self.redo.push(e);
|
|
return Err(err);
|
|
}
|
|
};
|
|
self.pending_log.push(e.op);
|
|
self.undo.push(UndoEntry { label: e.label.clone(), op: undo });
|
|
Ok(e.label)
|
|
}
|
|
|
|
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing).
|
|
fn apply_with_files(&mut self, op: &Op) -> Result<Op> {
|
|
let moves = self.file_moves(op);
|
|
Session::move_files(&moves)?;
|
|
match self.catalog.apply(op.clone()) {
|
|
Ok(inv) => Ok(inv),
|
|
Err(e) => {
|
|
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
|
let _ = Session::move_files(&back);
|
|
Err(e.into())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 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"] {
|
|
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] {
|
|
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
|
if self.visible_key.as_ref() != Some(&key) {
|
|
// "in the last N days" rules count back from the session's clock
|
|
lightcraft_catalog::rules::set_now(Some((self.clock)()));
|
|
let mut f = self.source.to_filter(&self.filter, &self.catalog);
|
|
if self.source == LibrarySource::Folder {
|
|
// no folder chosen: nothing (an empty path matches nothing)
|
|
let b = self.browse.clone().unwrap_or_default();
|
|
f.folder = Some(b.path);
|
|
f.subfolders = b.subfolders;
|
|
}
|
|
self.visible = self.catalog.query(&f, &self.sort);
|
|
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();
|
|
self.visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
|
|
if !self.sort.ascending {
|
|
self.visible.reverse();
|
|
}
|
|
}
|
|
if self.source == LibrarySource::RecentlyAdded {
|
|
// newest import first, whatever the sort (the grid groups by import day)
|
|
let cat = &self.catalog;
|
|
self.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 {
|
|
let missing: std::collections::HashSet<PhotoId> = cmd::missing::missing(self).into_iter().map(|m| m.0).collect();
|
|
self.visible.retain(|id| missing.contains(id));
|
|
}
|
|
if self.source != LibrarySource::RecentlyDeleted {
|
|
self.visible = self.catalog.arrange_stacks(&self.visible);
|
|
}
|
|
self.visible_key = Some(key);
|
|
}
|
|
&self.visible
|
|
}
|
|
|
|
/// 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 key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse));
|
|
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;
|
|
}
|
|
let n = self.catalog.query(&f, &self.sort).len();
|
|
self.total = Some((key, n));
|
|
}
|
|
self.total.as_ref().map(|t| t.1)
|
|
}
|
|
|
|
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
|
|
self.visible().to_vec()
|
|
}
|
|
|
|
/// 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() }
|
|
}
|
|
}
|
|
|
|
/// 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_color;
|
|
#[cfg(test)]
|
|
mod tests_export;
|
|
#[cfg(test)]
|
|
mod tests_import;
|
|
#[cfg(test)]
|
|
mod tests_libops;
|
|
#[cfg(test)]
|
|
mod tests_library;
|
|
#[cfg(test)]
|
|
mod tests_merge;
|
|
#[cfg(test)]
|
|
mod tests_organize;
|
|
#[cfg(test)]
|
|
mod tests_prefs;
|
|
#[cfg(test)]
|
|
mod tests_spots;
|
|
#[cfg(test)]
|
|
mod tests_xmp;
|