mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
1761 lines
91 KiB
Rust
1761 lines
91 KiB
Rust
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
|
|
//! metadata, albums, import.
|
|
|
|
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param};
|
|
use crate::{LibrarySource, Result, Selection, Session};
|
|
|
|
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
|
|
let at = before.iter().position(|id| Some(*id) == s.selection.active);
|
|
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
|
|
s.selection.ids.retain(|id| remaining.contains(id));
|
|
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
|
|
if s.selection.active.is_none()
|
|
&& let Some(at) = at
|
|
{
|
|
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
|
|
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
|
|
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
|
|
}
|
|
}
|
|
|
|
/// The auto-import folder's visible files with their sizes (the file-system half of
|
|
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
|
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
|
|
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
|
|
Ok(rd
|
|
.flatten()
|
|
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
|
|
.filter_map(|e| {
|
|
let m = std::fs::metadata(e.path()).ok()?;
|
|
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// `library.import`'s params, checked (no file-system calls).
|
|
pub struct ImportRequest {
|
|
pub paths: Vec<String>,
|
|
pub opts: crate::import::ImportOptions,
|
|
pub album: Option<u64>,
|
|
pub album_name: Option<String>,
|
|
}
|
|
|
|
/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a
|
|
/// worker thread, with the same options).
|
|
pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
|
let paths = strs(p, "paths");
|
|
let mode = match str_param(p, "mode").unwrap_or("add") {
|
|
"add" => crate::import::ImportMode::Add,
|
|
"copy" => crate::import::ImportMode::Copy,
|
|
"move" => crate::import::ImportMode::Move,
|
|
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
|
|
};
|
|
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
|
|
Some(id) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?),
|
|
None => None,
|
|
};
|
|
let album = p.get("album").and_then(Value::as_u64);
|
|
if let Some(a) = album
|
|
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
|
|
{
|
|
return Err(bad("library.import", "album must be a regular album"));
|
|
}
|
|
let organize = match str_param(p, "organize") {
|
|
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
|
|
bad("library.import", format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"))
|
|
})?,
|
|
None => Default::default(),
|
|
};
|
|
if let crate::import::Organize::Template(t) = &organize
|
|
&& let Some(e) = crate::rename::folder_template_error(t)
|
|
{
|
|
return Err(bad("library.import", e));
|
|
}
|
|
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
|
if let Some(n) = &metadata_preset
|
|
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
|
|
{
|
|
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
|
|
}
|
|
let on_deleted = match str_param(p, "onDeleted") {
|
|
Some(v) => {
|
|
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))?
|
|
}
|
|
None => Default::default(),
|
|
};
|
|
let opts = crate::import::ImportOptions {
|
|
on_deleted,
|
|
mode,
|
|
preset,
|
|
keywords: strs(p, "keywords"),
|
|
destination: str_param(p, "destination").map(str::to_string),
|
|
organize,
|
|
rename: str_param(p, "rename").map(str::to_string),
|
|
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
|
|
metadata_preset,
|
|
convert_dng: bool_or(p, "dng", false),
|
|
local: bool_or(p, "local", false),
|
|
};
|
|
let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
|
Ok(ImportRequest { paths, opts, album, album_name })
|
|
}
|
|
|
|
/// After a batch of an import was committed: the album (a new one named `album_name` when there
|
|
/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with
|
|
/// `album`).
|
|
pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option<u64>, album_name: Option<&str>) -> Result<Value> {
|
|
let mut report = serde_json::to_value(report).unwrap_or_default();
|
|
// photos an import restored from Recently Deleted take part like new ones (album, selection)
|
|
let imported: Vec<u64> =
|
|
["imported", "restored"].iter().flat_map(|k| report[*k].as_array().into_iter().flatten().filter_map(Value::as_u64)).collect();
|
|
if album.is_none()
|
|
&& let Some(name) = album_name
|
|
&& !imported.is_empty()
|
|
{
|
|
let r = s.execute("album.create", &json!({"name": name}))?;
|
|
album = r["id"].as_u64();
|
|
}
|
|
if let Some(a) = album
|
|
&& !imported.is_empty()
|
|
{
|
|
// (album.addPhotos wants a selection: the new photos are about to be it)
|
|
s.selection = Selection::single(PhotoId(imported[0]));
|
|
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
|
|
report["album"] = json!(a);
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
|
|
p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`")))
|
|
}
|
|
|
|
/// An optional album id: absent or `null` is `None`, anything but a number is an error.
|
|
fn opt_album_param(p: &Value, key: &str, c: &str) -> Result<Option<AlbumId>> {
|
|
match p.get(key) {
|
|
None | Some(Value::Null) => Ok(None),
|
|
Some(v) => v.as_u64().map(|n| Some(AlbumId(n))).ok_or_else(|| bad(c, format!("`{key}` must be an album id or null"))),
|
|
}
|
|
}
|
|
|
|
fn strs(p: &Value, key: &str) -> Vec<String> {
|
|
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
|
|
}
|
|
|
|
fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
|
|
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
|
|
}
|
|
|
|
/// The targets an album can hold and how many were browsed (Local) photos, which album views
|
|
/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members.
|
|
fn album_targets(s: &Session, targets: Vec<PhotoId>) -> (Vec<PhotoId>, usize) {
|
|
let before = targets.len();
|
|
let kept: Vec<PhotoId> = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect();
|
|
let skipped = before - kept.len();
|
|
(kept, skipped)
|
|
}
|
|
|
|
/// Album targets, or the error for a selection that was nothing but Local photos.
|
|
fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Result<(Vec<PhotoId>, usize)> {
|
|
let (kept, skipped) = album_targets(s, targets);
|
|
if kept.is_empty() && skipped > 0 {
|
|
return Err(bad(command, super::LOCAL_ONLY));
|
|
}
|
|
Ok((kept, skipped))
|
|
}
|
|
|
|
/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep
|
|
/// showing `i/n` (issue #1493).
|
|
fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize> {
|
|
let mut ops: Vec<Op> = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
|
|
let n = ops.len();
|
|
if n == 0 {
|
|
return Ok(0);
|
|
}
|
|
ops.extend(s.catalog.remove_from_stacks_ops(ids));
|
|
s.commit(label, Op::Batch { ops })?;
|
|
Ok(n)
|
|
}
|
|
|
|
/// Apply one op per target as a single undo step.
|
|
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
|
|
let targets = s.targets(p)?;
|
|
let ops: Vec<Op> = targets.iter().filter_map(|id| f(*id)).collect();
|
|
let n = ops.len();
|
|
if n > 0 {
|
|
s.commit(label, Op::Batch { ops })?;
|
|
}
|
|
Ok(json!({"changed": n}))
|
|
}
|
|
|
|
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
|
|
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
|
|
let mut ops = Vec::new();
|
|
for id in s.targets(p)? {
|
|
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
|
|
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
|
|
if rating != old {
|
|
ops.push(Op::SetRating { id, rating });
|
|
}
|
|
}
|
|
let n = ops.len();
|
|
if n > 0 {
|
|
s.commit(label, Op::Batch { ops })?;
|
|
}
|
|
advance_if(s, p);
|
|
Ok(json!({"changed": n}))
|
|
}
|
|
|
|
/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged,
|
|
/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step.
|
|
fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> {
|
|
let mut ops = Vec::new();
|
|
for id in s.targets(p)? {
|
|
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag;
|
|
let flag = step(old);
|
|
if flag != old {
|
|
ops.push(Op::SetFlag { id, flag });
|
|
}
|
|
}
|
|
let n = ops.len();
|
|
if n > 0 {
|
|
s.commit(label, Op::Batch { ops })?;
|
|
}
|
|
advance_if(s, p);
|
|
Ok(json!({"changed": n}))
|
|
}
|
|
|
|
fn advance_if(s: &mut Session, p: &Value) {
|
|
if bool_or(p, "advance", false) {
|
|
let _ = step(s, 1);
|
|
}
|
|
}
|
|
|
|
fn step(s: &mut Session, d: isize) -> Result<Value> {
|
|
let vis = s.visible_cloned();
|
|
if vis.is_empty() {
|
|
return ok();
|
|
}
|
|
let cur = s.selection.active.and_then(|a| vis.iter().position(|x| *x == a));
|
|
let next = match cur {
|
|
Some(i) => (i as isize + d).clamp(0, vis.len() as isize - 1) as usize,
|
|
None => 0,
|
|
};
|
|
s.end_interaction()?;
|
|
s.selection = Selection::single(vis[next]);
|
|
s.active_mask = None;
|
|
s.active_spot = None;
|
|
Ok(json!({"active": vis[next].0}))
|
|
}
|
|
|
|
/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input
|
|
/// can't overflow).
|
|
fn row_step(p: &Value) -> isize {
|
|
p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize
|
|
}
|
|
|
|
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
|
let targets = s.targets(p)?;
|
|
let mut ops = Vec::new();
|
|
for id in targets {
|
|
if let Some(d) = s.develop_of(id) {
|
|
let mut d = (*d).clone();
|
|
d.orientation = d.orientation.rotated(cw);
|
|
// the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo)
|
|
let turn = lightcraft_geom::Orientation::Normal.rotated(cw);
|
|
d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect);
|
|
d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w));
|
|
ops.extend(s.develop_op(id, d, "Rotate"));
|
|
}
|
|
}
|
|
let n = ops.len();
|
|
s.commit(if cw { "Rotate Right" } else { "Rotate Left" }, Op::Batch { ops })?;
|
|
Ok(json!({"changed": n}))
|
|
}
|
|
|
|
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
|
|
let targets = s.targets(p)?;
|
|
let mut ops = Vec::new();
|
|
for id in targets {
|
|
if let Some(d) = s.develop_of(id) {
|
|
let mut d = (*d).clone();
|
|
if horizontal {
|
|
d.crop.flip_h = !d.crop.flip_h;
|
|
} else {
|
|
d.crop.flip_v = !d.crop.flip_v;
|
|
}
|
|
ops.extend(s.develop_op(id, d, "Flip"));
|
|
}
|
|
}
|
|
let n = ops.len();
|
|
s.commit("Flip", Op::Batch { ops })?;
|
|
Ok(json!({"changed": n}))
|
|
}
|
|
|
|
/// `library.selectBy`: select the photos in view that match every given criterion.
|
|
fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "library.selectBy";
|
|
let flag: Option<lightcraft_catalog::Flag> = match p.get("flag") {
|
|
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(C, "flag is pick, reject or none"))?),
|
|
None => None,
|
|
};
|
|
let rating = p.get("rating").and_then(Value::as_u64).map(|r| r.min(5) as u8);
|
|
let op = p.get("ratingOp").and_then(Value::as_str).unwrap_or("gte");
|
|
let label: Option<Option<lightcraft_catalog::ColorLabel>> = match p.get("label").and_then(Value::as_str) {
|
|
Some("none") => Some(None),
|
|
Some(l) => Some(Some(serde_json::from_value(json!(l)).map_err(|_| bad(C, format!("unknown label `{l}`")))?)),
|
|
None => None,
|
|
};
|
|
if flag.is_none() && rating.is_none() && label.is_none() {
|
|
return Err(bad(C, "give flag, rating and/or label"));
|
|
}
|
|
let matches = |ph: &lightcraft_catalog::Photo| {
|
|
flag.is_none_or(|f| ph.flag == f)
|
|
&& rating.is_none_or(|r| match op {
|
|
"eq" => ph.rating == r,
|
|
"lte" => ph.rating <= r,
|
|
_ => ph.rating >= r,
|
|
})
|
|
&& label.is_none_or(|l| ph.label == l)
|
|
};
|
|
let mut ids: Vec<PhotoId> = if bool_or(p, "add", false) { s.selection.ids.clone() } else { Vec::new() };
|
|
for id in s.visible_cloned() {
|
|
if !ids.contains(&id) && s.catalog.photo(id).is_some_and(|ph| matches(ph)) {
|
|
ids.push(id);
|
|
}
|
|
}
|
|
let active = s.selection.active.filter(|a| ids.contains(a)).or(ids.first().copied());
|
|
let n = ids.len();
|
|
s.selection = Selection { ids, active };
|
|
Ok(json!({"selected": n}))
|
|
}
|
|
|
|
/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote.
|
|
const MAX_SEED: u64 = 1 << 53;
|
|
|
|
/// `seed` as an integer in `0..2^53`, or `default` when absent or null; anything else is an error.
|
|
fn seed_param(p: &Value, cmd: &str, default: u64) -> Result<u64> {
|
|
match p.get("seed") {
|
|
None | Some(Value::Null) => Ok(default),
|
|
Some(v) => v.as_u64().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")),
|
|
}
|
|
}
|
|
|
|
/// The seed after `prev` at time `now`: a mix of `prev` and the clock text, so repeated reshuffles
|
|
/// within one clock tick still differ (barring a 2^-53 collision). Kept below 2^53 so JSON
|
|
/// clients that read numbers as doubles (web, MCP agents) get the same seed back.
|
|
fn next_seed(prev: u64, now: &str) -> u64 {
|
|
let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b)));
|
|
(lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1)
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
// ---- view source / filter / sort
|
|
cmd!(
|
|
"library.source",
|
|
"Show Source",
|
|
[],
|
|
None,
|
|
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}",
|
|
always,
|
|
|s, p| {
|
|
let kind = str_param(p, "kind").unwrap_or("all");
|
|
s.source = match kind {
|
|
"all" => LibrarySource::All,
|
|
"recentlyAdded" => LibrarySource::RecentlyAdded,
|
|
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
|
|
"picks" => LibrarySource::Picks,
|
|
"missing" => LibrarySource::Missing,
|
|
"album" => {
|
|
let a = album_param(p, "id", "library.source")?;
|
|
if s.catalog.album(a).is_none_or(|a| a.folder) {
|
|
return Err(bad("library.source", "no such album"));
|
|
}
|
|
LibrarySource::Album(a)
|
|
}
|
|
"libraryFolder" => {
|
|
let path = str_param(p, "path").filter(|d| !d.trim().is_empty() && !lightcraft_catalog::query::folder_key(d).is_empty());
|
|
let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?;
|
|
if lightcraft_catalog::folders::is_startup_disk(path) {
|
|
return Err(bad("library.source", "the startup disk's path covers every disk: choose a folder in it"));
|
|
}
|
|
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
|
let ids = s.catalog.query(&f, &Sort::default());
|
|
if ids.is_empty() {
|
|
return Err(bad("library.source", format!("{path}: no photo in the library was imported from it")));
|
|
}
|
|
if covers_other_disks(s, path, &ids) {
|
|
return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one")));
|
|
}
|
|
s.library_folder = Some(path.to_string());
|
|
// one folder at a time: a folder filter left over would be ignored
|
|
s.filter.library_folder = None;
|
|
LibrarySource::LibraryFolder
|
|
}
|
|
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
|
|
};
|
|
s.keep_active_in_view();
|
|
Ok(json!({"count": s.visible().len()}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"library.filter",
|
|
"Filter",
|
|
[],
|
|
None,
|
|
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}",
|
|
always,
|
|
|s, p| {
|
|
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
|
|
lightcraft_develop::presets::deep_merge(&mut v, p);
|
|
let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?;
|
|
let mut f = f;
|
|
// a blank folder is no folder: no hidden "filters active" state
|
|
f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty());
|
|
if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {
|
|
return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));
|
|
}
|
|
// checked like a smart album's rules when this call sets them: an unknown field or a value
|
|
// that can't match is an error, not an empty grid. Rules that stopped checking since
|
|
// (their album was deleted) don't block other filter changes.
|
|
if let Some(rules) = f.rule_set.as_mut() {
|
|
rules.upgrade();
|
|
}
|
|
let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null());
|
|
if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) {
|
|
return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
|
}
|
|
s.filter = f;
|
|
Ok(json!({"count": s.visible().len()}))
|
|
}
|
|
),
|
|
cmd!(
|
|
query "library.folders",
|
|
"Library Folders",
|
|
[],
|
|
None,
|
|
"{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
|
always,
|
|
|s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
|
|
),
|
|
cmd!(
|
|
"library.removeFolder",
|
|
"Remove Folder from Library",
|
|
[],
|
|
None,
|
|
"{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}",
|
|
always,
|
|
|s, p| {
|
|
const C: &str = "library.removeFolder";
|
|
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
|
if lightcraft_catalog::query::folder_key(path).is_empty() {
|
|
return Err(bad(C, format!("{path}: not a folder")));
|
|
}
|
|
if lightcraft_catalog::folders::is_startup_disk(path) {
|
|
return Err(bad(C, "choose a folder, not the whole startup disk"));
|
|
}
|
|
if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) {
|
|
return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it")));
|
|
}
|
|
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
|
let ids = s.catalog.query(&f, &Sort::default());
|
|
if ids.is_empty() {
|
|
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
|
|
}
|
|
if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) {
|
|
return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));
|
|
}
|
|
mark_deleted(s, &ids, "Remove Folder from Library")?;
|
|
if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
|
|
s.filter.library_folder = None;
|
|
}
|
|
// the folder being shown is gone: back to everything, as for a deleted album
|
|
if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
|
|
s.library_folder = None;
|
|
if s.source == LibrarySource::LibraryFolder {
|
|
s.source = LibrarySource::All;
|
|
}
|
|
}
|
|
let vis = s.visible_cloned();
|
|
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
|
Ok(json!({"removed": ids.len()}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"folder.label",
|
|
"Set Folder Color Label",
|
|
[],
|
|
None,
|
|
"{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",
|
|
always,
|
|
|s, p| {
|
|
const C: &str = "folder.label";
|
|
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
|
let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? {
|
|
"none" => None,
|
|
x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?),
|
|
};
|
|
// taking a label off always works, also once the folder's photos are gone
|
|
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
|
if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {
|
|
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
|
|
}
|
|
if label.is_none() && s.catalog.folder_record(path).is_none() {
|
|
return Ok(json!({"path": path, "label": label}));
|
|
}
|
|
let op = s.catalog.folder_label_op(path, label);
|
|
s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?;
|
|
Ok(json!({"path": path, "label": label}))
|
|
}
|
|
),
|
|
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
|
|
s.filter = Default::default();
|
|
Ok(json!({"count": s.visible().len()}))
|
|
}),
|
|
cmd!(
|
|
"library.sort",
|
|
"Sort",
|
|
["View", "Sort"],
|
|
None,
|
|
"{key?: captureDate|importDate|editDate|fileName|rating|fileSize|random, ascending?: bool, group?: auto|none|day|month|year, seed?: u64 (the shuffle `random` gives)}",
|
|
always,
|
|
|s, p| {
|
|
let key: SortKey = match p.get("key") {
|
|
Some(k) => serde_json::from_value(k.clone()).map_err(|e| bad("library.sort", e.to_string()))?,
|
|
None => s.sort.key,
|
|
};
|
|
let group = match str_param(p, "group") {
|
|
Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?,
|
|
None => s.sort.group,
|
|
};
|
|
let mut seed = seed_param(p, "library.sort", s.sort.seed)?;
|
|
let explicit = p.get("seed").is_some_and(|v| !v.is_null());
|
|
if key == SortKey::Random && s.sort.key != SortKey::Random && !explicit {
|
|
// switching to Random is a fresh shuffle, not whatever seed was left behind
|
|
seed = next_seed(seed, &(s.clock)());
|
|
}
|
|
s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group, seed };
|
|
ok()
|
|
}
|
|
),
|
|
cmd!(
|
|
"library.refresh",
|
|
"Refresh View",
|
|
["View"],
|
|
None,
|
|
"{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed",
|
|
always,
|
|
|s, _| {
|
|
s.refresh_view();
|
|
ok()
|
|
}
|
|
),
|
|
cmd!(
|
|
"library.holdView",
|
|
"Hold View Until Refreshed",
|
|
["View"],
|
|
None,
|
|
"{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing",
|
|
always,
|
|
|s, p| {
|
|
let on = match p.get("on") {
|
|
None | Some(Value::Null) => !s.hold_view,
|
|
Some(Value::Bool(on)) => *on,
|
|
Some(_) => return Err(bad("library.holdView", "on must be true or false")),
|
|
};
|
|
if on != s.hold_view {
|
|
let was = s.hold_view;
|
|
s.hold_view = on;
|
|
if let Err(e) = s.save_prefs() {
|
|
// not kept: not changed either, so asking again does what was asked
|
|
s.hold_view = was;
|
|
return Err(e);
|
|
}
|
|
s.refresh_view();
|
|
}
|
|
Ok(json!({"hold": s.hold_view}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"library.shuffle",
|
|
"Reshuffle",
|
|
["View", "Sort"],
|
|
None,
|
|
"{seed?: 0..2^53-1} — sort at random; without `seed` a new shuffle each time",
|
|
always,
|
|
|s, p| {
|
|
let seed = match seed_param(p, "library.shuffle", s.sort.seed)? {
|
|
given if p.get("seed").is_some_and(|v| !v.is_null()) => given,
|
|
// derived from the previous seed and the session clock: reproducible under a test
|
|
// clock, and no RNG needed
|
|
prev => next_seed(prev, &(s.clock)()),
|
|
};
|
|
s.sort = Sort { key: SortKey::Random, seed, ..s.sort };
|
|
ok()
|
|
}
|
|
),
|
|
cmd!(
|
|
query "library.groups",
|
|
"Date Groups",
|
|
[],
|
|
None,
|
|
"{by?: day|month|year (default: the sort's grouping; auto = day)} → [{key, label, start, count}] date headers of the grid",
|
|
always,
|
|
|s, p| {
|
|
let by = match str_param(p, "by") {
|
|
Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.groups", "by must be auto|none|day|month|year"))?,
|
|
None => s.sort.group,
|
|
};
|
|
let vis = s.visible_cloned();
|
|
Ok(serde_json::to_value(s.catalog.date_runs(&vis, s.sort.key, by)).unwrap_or_default())
|
|
}
|
|
),
|
|
// ---- selection
|
|
cmd!(
|
|
"library.select",
|
|
"Select Photos",
|
|
[],
|
|
None,
|
|
"{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library",
|
|
always,
|
|
|s, p| {
|
|
let ids = ids_param(p).unwrap_or_default();
|
|
let active = p.get("active").and_then(Value::as_u64).map(PhotoId);
|
|
let mode = str_param(p, "mode").unwrap_or("replace");
|
|
// An id that is not in the library is an error, and the selection stays as it was:
|
|
// selecting it would otherwise report success and leave the session with no usable
|
|
// active photo (#182).
|
|
if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) {
|
|
return Err(bad("library.select", format!("no such photo {}", id.0)));
|
|
}
|
|
s.end_interaction()?;
|
|
match mode {
|
|
"replace" => {
|
|
s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) };
|
|
}
|
|
"add" => {
|
|
for id in ids {
|
|
if !s.selection.contains(id) {
|
|
s.selection.ids.push(id);
|
|
}
|
|
s.selection.active = Some(id);
|
|
}
|
|
}
|
|
"toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)),
|
|
"range" => {
|
|
let vis = s.visible_cloned();
|
|
if let Some(id) = ids.first() {
|
|
s.selection.extend_to(*id, &vis);
|
|
}
|
|
}
|
|
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
|
|
}
|
|
s.active_mask = None;
|
|
s.active_spot = None;
|
|
Ok(json!({"selected": s.selection.ids.len()}))
|
|
}
|
|
),
|
|
cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {
|
|
let vis = s.visible_cloned();
|
|
s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis };
|
|
Ok(json!({"selected": s.selection.ids.len()}))
|
|
}),
|
|
cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| {
|
|
Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default())
|
|
}),
|
|
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
|
|
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
|
|
let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta;
|
|
let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description,
|
|
"copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url,
|
|
"creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country,
|
|
"keywords": m.keywords});
|
|
s.meta_clipboard = Some(v.clone());
|
|
Ok(v)
|
|
}),
|
|
cmd!(
|
|
"photo.pasteMetadata",
|
|
"Paste Metadata",
|
|
["Photo"],
|
|
None,
|
|
"{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}",
|
|
has_selection,
|
|
|s, p| {
|
|
let c = "photo.pasteMetadata";
|
|
let clip = s.meta_clipboard.clone().ok_or_else(|| bad(c, "nothing copied (photo.copyMetadata)"))?;
|
|
let fields: Option<Vec<String>> =
|
|
p.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
|
let mut params = serde_json::Map::new();
|
|
for (k, v) in clip.as_object().into_iter().flatten() {
|
|
if fields.as_ref().is_none_or(|f| f.iter().any(|x| x == k)) {
|
|
params.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
if let Some(ids) = p.get("ids") {
|
|
params.insert("ids".into(), ids.clone());
|
|
}
|
|
s.execute("photo.setMeta", &Value::Object(params))
|
|
}
|
|
),
|
|
cmd!(
|
|
"library.selectBy",
|
|
"Select by Flag, Rating or Label",
|
|
[],
|
|
None,
|
|
"{flag?: pick|reject|none, rating?: 0..5, ratingOp?: gte|eq|lte (default gte), label?: red|…|none, add?: bool (extend the selection)} — among the photos in view",
|
|
always,
|
|
select_by
|
|
),
|
|
cmd!("library.selectNone", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| {
|
|
let a = s.selection.active;
|
|
s.selection = Selection { ids: a.into_iter().collect(), active: a };
|
|
ok()
|
|
}),
|
|
cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)),
|
|
cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)),
|
|
cmd!(
|
|
"library.down",
|
|
"Photo Below",
|
|
[],
|
|
Some("Down"),
|
|
"{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}",
|
|
always,
|
|
|s, p| { step(s, row_step(p)) }
|
|
),
|
|
cmd!(
|
|
"library.up",
|
|
"Photo Above",
|
|
[],
|
|
Some("Up"),
|
|
"{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}",
|
|
always,
|
|
|s, p| { step(s, -row_step(p)) }
|
|
),
|
|
// ---- rating / flags / labels
|
|
cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| {
|
|
let r = super::f64_req(p, "rating", "photo.rate")? as i64;
|
|
if !(0..=5).contains(&r) {
|
|
return Err(bad("photo.rate", "rating must be 0..5"));
|
|
}
|
|
let v = for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))?;
|
|
advance_if(s, p);
|
|
Ok(v)
|
|
}),
|
|
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
|
change_rating(s, p, -1, "Decrease Rating")
|
|
}),
|
|
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
|
change_rating(s, p, 1, "Increase Rating")
|
|
}),
|
|
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
|
|
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
|
|
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
|
|
advance_if(s, p);
|
|
Ok(v)
|
|
}),
|
|
// Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux)
|
|
cmd!(
|
|
"photo.cycleFlag",
|
|
"Cycle Flag",
|
|
["Photo", "Set Flag"],
|
|
Some("`"),
|
|
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged",
|
|
has_selection,
|
|
|s, p| {
|
|
change_flag(s, p, "Cycle Flag", |f| match f {
|
|
Flag::None => Flag::Pick,
|
|
Flag::Pick => Flag::Reject,
|
|
Flag::Reject => Flag::None,
|
|
})
|
|
}
|
|
),
|
|
cmd!(
|
|
"photo.increaseFlag",
|
|
"Increase Flag Status",
|
|
["Photo", "Set Flag"],
|
|
Some("Cmd+Up"),
|
|
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)",
|
|
has_selection,
|
|
|s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) }
|
|
),
|
|
cmd!(
|
|
"photo.decreaseFlag",
|
|
"Decrease Flag Status",
|
|
["Photo", "Set Flag"],
|
|
Some("Cmd+Down"),
|
|
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)",
|
|
has_selection,
|
|
|s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) }
|
|
),
|
|
cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| {
|
|
for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))
|
|
}),
|
|
cmd!("photo.reject", "Flag as Reject", ["Photo", "Set Flag"], Some("X"), "{ids?}", has_selection, |s, p| {
|
|
for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))
|
|
}),
|
|
cmd!("photo.unflag", "Unflag", ["Photo", "Set Flag"], Some("U"), "{ids?}", has_selection, |s, p| {
|
|
for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))
|
|
}),
|
|
cmd!(
|
|
"photo.label",
|
|
"Set Color Label",
|
|
["Photo", "Set Color Label"],
|
|
None,
|
|
"{label: red|yellow|green|blue|purple|none, ids?}",
|
|
has_selection,
|
|
|s, p| {
|
|
let l = match str_param(p, "label") {
|
|
None | Some("none") => None,
|
|
Some(x) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?),
|
|
};
|
|
for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))
|
|
}
|
|
),
|
|
// ---- orientation
|
|
cmd!("photo.rotateLeft", "Rotate Left", ["Photo"], Some("Cmd+["), "{ids?}", has_selection, |s, p| rotate(s, p, false)),
|
|
cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)),
|
|
cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)),
|
|
cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)),
|
|
// ---- face / pet regions
|
|
cmd!(
|
|
"photo.removeRegion",
|
|
"Remove Face Box",
|
|
[],
|
|
None,
|
|
"{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back",
|
|
always,
|
|
|s, p| {
|
|
let id =
|
|
p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
|
|
let index = p
|
|
.get("index")
|
|
.and_then(Value::as_u64)
|
|
.and_then(|i| usize::try_from(i).ok())
|
|
.ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?;
|
|
let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone();
|
|
if index >= meta.regions.len() {
|
|
return Err(bad("photo.removeRegion", "no such region"));
|
|
}
|
|
let gone = meta.regions.remove(index);
|
|
s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?;
|
|
s.skip_auto_write = true;
|
|
Ok(json!({"removed": gone.name}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"photo.setRegion",
|
|
"Resize Face Box",
|
|
[],
|
|
None,
|
|
"{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar",
|
|
always,
|
|
|s, p| {
|
|
const C: &str = "photo.setRegion";
|
|
// a drag is one undo step however many frames it took: the caller commits once, on release
|
|
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
|
|
let index = p
|
|
.get("index")
|
|
.and_then(Value::as_u64)
|
|
.and_then(|i| usize::try_from(i).ok())
|
|
.ok_or_else(|| bad(C, "missing or invalid `index`"))?;
|
|
let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?;
|
|
let num = |k: &str| {
|
|
r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number")))
|
|
};
|
|
let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?);
|
|
let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0));
|
|
let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0));
|
|
if x1 - x0 < 0.005 || y1 - y0 < 0.005 {
|
|
return Err(bad(C, "the box would be too small"));
|
|
}
|
|
let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone();
|
|
let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?;
|
|
region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 };
|
|
s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?;
|
|
s.skip_auto_write = true;
|
|
Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}}))
|
|
}
|
|
),
|
|
// ---- delete / restore
|
|
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
|
let before = s.visible_cloned();
|
|
let targets = s.targets(p)?;
|
|
let n = mark_deleted(s, &targets, "Delete")?;
|
|
select_after_deletion(s, &before);
|
|
Ok(json!({"changed": n}))
|
|
}),
|
|
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
|
id,
|
|
deleted: false
|
|
}))),
|
|
cmd!(
|
|
"library.emptyRecentlyDeleted",
|
|
"Empty Recently Deleted",
|
|
["Photo"],
|
|
None,
|
|
"{} → {deleted} — removes every photo in Recently Deleted from the library (files on disk stay)",
|
|
always,
|
|
|s, _| {
|
|
let ids: Vec<PhotoId> = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect();
|
|
if ids.is_empty() {
|
|
return Ok(json!({"deleted": 0}));
|
|
}
|
|
let op = s.catalog.delete_photos_permanently_ops(&ids);
|
|
s.commit("Empty Recently Deleted", op)?;
|
|
s.selection = Selection::default();
|
|
Ok(json!({"deleted": ids.len()}))
|
|
}
|
|
),
|
|
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
|
let before = s.visible_cloned();
|
|
let t = s.targets(p)?;
|
|
let op = s.catalog.delete_photos_permanently_ops(&t);
|
|
s.commit("Delete Permanently", op)?;
|
|
select_after_deletion(s, &before);
|
|
Ok(json!({"deleted": t.len()}))
|
|
}),
|
|
// ---- metadata
|
|
cmd!(
|
|
"photo.setMeta",
|
|
"Edit Info",
|
|
[],
|
|
None,
|
|
"{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
|
|
has_selection,
|
|
|s, p| {
|
|
let strs =
|
|
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
|
|
let targets = s.targets(p)?;
|
|
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
|
|
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
|
|
None => None,
|
|
Some(Value::Null) => Some(None),
|
|
Some(Value::String(t)) if t.trim().is_empty() => Some(None),
|
|
Some(Value::String(t)) => Some(Some(
|
|
parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?,
|
|
)),
|
|
Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) {
|
|
(Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))),
|
|
_ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")),
|
|
},
|
|
Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),
|
|
};
|
|
let status = match str_param(p, "copyrightStatus") {
|
|
Some(t) => Some(
|
|
CopyrightStatus::parse(t)
|
|
.ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?,
|
|
),
|
|
None => None,
|
|
};
|
|
let mut ops = Vec::new();
|
|
for id in targets {
|
|
let Some(ph) = s.catalog.photo(id) else { continue };
|
|
let mut m = ph.meta.clone();
|
|
if let Some(g) = gps {
|
|
m.gps = g;
|
|
}
|
|
if let Some(st) = status {
|
|
m.copyright_status = st;
|
|
}
|
|
for (k, field) in [
|
|
("title", &mut m.title),
|
|
("caption", &mut m.caption),
|
|
("copyright", &mut m.copyright),
|
|
("usageTerms", &mut m.usage_terms),
|
|
("copyrightUrl", &mut m.copyright_url),
|
|
("creator", &mut m.creator),
|
|
("location", &mut m.location),
|
|
("city", &mut m.city),
|
|
("state", &mut m.state),
|
|
("country", &mut m.country),
|
|
("altText", &mut m.alt_text),
|
|
("extendedDescription", &mut m.extended_description),
|
|
] {
|
|
if let Some(v) = str_param(p, k) {
|
|
*field = v.to_string();
|
|
}
|
|
}
|
|
// keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case
|
|
use lightcraft_catalog::keywords::{clean, same};
|
|
let add = |m: &mut lightcraft_catalog::Meta, k: &str| {
|
|
let k = clean(k);
|
|
if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {
|
|
m.keywords.push(k);
|
|
}
|
|
};
|
|
if let Some(k) = strs("keywords") {
|
|
m.keywords.clear();
|
|
for k in k {
|
|
add(&mut m, &k);
|
|
}
|
|
}
|
|
for k in strs("addKeywords").unwrap_or_default() {
|
|
add(&mut m, &k);
|
|
}
|
|
for k in strs("removeKeywords").unwrap_or_default() {
|
|
let k = clean(&k);
|
|
m.keywords.retain(|x| !same(&clean(x), &k));
|
|
}
|
|
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
|
}
|
|
let n = ops.len();
|
|
s.commit("Edit Info", Op::Batch { ops })?;
|
|
// keywords just added become the Recent Keywords set
|
|
let added: Vec<String> = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect();
|
|
if n > 0 && !added.is_empty() {
|
|
crate::cmd::keywords::note_recent(s, &added);
|
|
}
|
|
Ok(json!({"changed": n}))
|
|
}
|
|
),
|
|
// ---- albums
|
|
cmd!(
|
|
"album.create",
|
|
"New Album",
|
|
["File"],
|
|
None,
|
|
"{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)",
|
|
always,
|
|
|s, p| {
|
|
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
|
|
if name.is_empty() {
|
|
return Err(bad("album.create", "empty name"));
|
|
}
|
|
let folder = bool_or(p, "folder", false);
|
|
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
|
let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) };
|
|
let id = s.catalog.alloc_album_id();
|
|
let cover = photos.first().copied();
|
|
s.commit(
|
|
if folder { "New Folder" } else { "New Album" },
|
|
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
|
|
)?;
|
|
Ok(json!({"id": id.0, "skipped": skipped}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"album.createSmart",
|
|
"New Smart Album…",
|
|
[],
|
|
None,
|
|
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)",
|
|
always,
|
|
|s, p| {
|
|
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
|
|
if name.is_empty() {
|
|
return Err(bad("album.createSmart", "empty name"));
|
|
}
|
|
let rules = match p.get("rules") {
|
|
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?,
|
|
None => view_rules(s),
|
|
};
|
|
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
|
let id = s.catalog.alloc_album_id();
|
|
let album = Album { parent, smart: Some(Box::new(rules)), ..Album::new(id, name) };
|
|
s.commit("New Smart Album", Op::AddAlbum { album })?;
|
|
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
|
|
}
|
|
),
|
|
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| {
|
|
use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for};
|
|
Ok(json!(FIELDS
|
|
.iter()
|
|
.map(|(id, label, kind)| {
|
|
let k = match kind {
|
|
Kind::Text => "text",
|
|
Kind::Keywords => "keywords",
|
|
Kind::Number => "number",
|
|
Kind::Album => "album",
|
|
Kind::Date => "date",
|
|
Kind::Choice(_) => "choice",
|
|
Kind::Bool => "bool",
|
|
};
|
|
let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
|
if let Kind::Choice(c) = kind {
|
|
v["choices"] = json!(c.iter().map(|c| c.0).collect::<Vec<_>>());
|
|
v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::<serde_json::Map<_, _>>().into();
|
|
}
|
|
v
|
|
})
|
|
.collect::<Vec<_>>()))
|
|
}),
|
|
cmd!(
|
|
"album.setRules",
|
|
"Edit Smart Album",
|
|
[],
|
|
None,
|
|
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}",
|
|
always,
|
|
|s, p| {
|
|
let id = album_param(p, "id", "album.setRules")?;
|
|
let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?;
|
|
let rules = if bool_or(p, "fromView", false) {
|
|
// the view is saved as it is, but never as a loop (showing A, which tests this
|
|
// album, and saving that view here would make it test itself)
|
|
let view = view_rules(s);
|
|
let loops: Vec<String> = view
|
|
.rule_set
|
|
.as_ref()
|
|
.map(|r| r.check_for(&s.catalog, Some(id)))
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.chain(s.catalog.album_filter_problem(&view, Some(id)))
|
|
.filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop)
|
|
.map(|p| p.to_string())
|
|
.collect();
|
|
if !loops.is_empty() {
|
|
return Err(bad("album.setRules", loops.join("; ")));
|
|
}
|
|
view
|
|
} else {
|
|
let replace = bool_or(p, "replace", false);
|
|
let folder = cur.library_folder.clone();
|
|
let base = if replace { Default::default() } else { cur };
|
|
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?;
|
|
// the rules dialog has no folder field: replacing its rules keeps the folder
|
|
// unless the call says (`libraryFolder: null`) to drop it
|
|
if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() {
|
|
r.library_folder = folder;
|
|
}
|
|
r
|
|
};
|
|
let set = Op::SetAlbumRules { id, rules: Box::new(rules) };
|
|
// a new name goes with the rules: one step, applied (and undone) together
|
|
let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n));
|
|
match rename {
|
|
Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?,
|
|
None => s.commit("Edit Smart Album", set)?,
|
|
};
|
|
Ok(json!({"count": s.catalog.album_count(id)}))
|
|
}
|
|
),
|
|
cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| {
|
|
let id = album_param(p, "id", "album.rename")?;
|
|
let name = str_param(p, "name").ok_or_else(|| bad("album.rename", "missing name"))?.to_string();
|
|
s.commit("Rename Album", Op::RenameAlbum { id, name })?;
|
|
ok()
|
|
}),
|
|
cmd!(
|
|
"album.delete",
|
|
"Delete Album",
|
|
[],
|
|
None,
|
|
"{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step",
|
|
always,
|
|
|s, p| {
|
|
let id = album_param(p, "id", "album.delete")?;
|
|
let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder;
|
|
let mut children = std::collections::BTreeMap::<AlbumId, Vec<AlbumId>>::new();
|
|
for album in s.catalog.albums() {
|
|
if let Some(parent) = album.parent {
|
|
children.entry(parent).or_default().push(album.id);
|
|
}
|
|
}
|
|
// Iterative traversal: even a malformed cycle is rejected before any change.
|
|
// Reverse parent-first order removes children first; the batch inverse restores
|
|
// parents first, including memberships, smart rules, covers and manual ordering.
|
|
let mut pending = vec![id];
|
|
let mut order = Vec::new();
|
|
let mut removed = std::collections::BTreeSet::new();
|
|
while let Some(next) = pending.pop() {
|
|
if !removed.insert(next) {
|
|
return Err(bad("album.delete", "cycle in album folders"));
|
|
}
|
|
order.push(next);
|
|
if let Some(nested) = children.remove(&next) {
|
|
pending.extend(nested);
|
|
}
|
|
}
|
|
let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect();
|
|
s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?;
|
|
if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) {
|
|
s.source = LibrarySource::All;
|
|
}
|
|
ok()
|
|
}
|
|
),
|
|
cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| {
|
|
let id = album_param(p, "id", "album.move")?;
|
|
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
|
s.commit("Move Album", Op::MoveAlbum { id, parent })?;
|
|
ok()
|
|
}),
|
|
cmd!(
|
|
"album.reorder",
|
|
"Reorder Album",
|
|
[],
|
|
None,
|
|
"{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}: how many edits it took (0: already there)",
|
|
always,
|
|
|s, p| {
|
|
let id = album_param(p, "id", "album.reorder")?;
|
|
let me = s.catalog.album(id).ok_or_else(|| bad("album.reorder", "no such album"))?;
|
|
let parent = if p.get("parent").is_some() { opt_album_param(p, "parent", "album.reorder")? } else { me.parent };
|
|
let before = opt_album_param(p, "before", "album.reorder")?;
|
|
if before == Some(id) {
|
|
// before itself: where it is
|
|
return Ok(json!({"changed": 0}));
|
|
}
|
|
let mut order: Vec<AlbumId> = s.catalog.album_children(parent).into_iter().filter(|a| a.id != id).map(|a| a.id).collect();
|
|
let at = match before {
|
|
Some(b) => {
|
|
let at = order.iter().position(|x| *x == b).ok_or_else(|| bad("album.reorder", "`before` is not inside that folder"))?;
|
|
if s.catalog.album(b).is_none_or(|x| x.folder != me.folder) {
|
|
return Err(bad("album.reorder", "folders are placed among folders and albums among albums"));
|
|
}
|
|
at
|
|
}
|
|
None if me.folder => order.iter().take_while(|x| s.catalog.album(**x).is_some_and(|a| a.folder)).count(),
|
|
None => order.len(),
|
|
};
|
|
order.insert(at.min(order.len()), id);
|
|
let moved = me.parent != parent;
|
|
let mut ops = Vec::new();
|
|
if moved {
|
|
ops.push(Op::MoveAlbum { id, parent });
|
|
}
|
|
for (i, aid) in order.iter().enumerate() {
|
|
let want = u32::try_from(i).ok();
|
|
// a moved album starts without a place in its new folder
|
|
let have = if moved && *aid == id { None } else { s.catalog.album(*aid).and_then(|a| a.order) };
|
|
if have != want {
|
|
ops.push(Op::SetAlbumOrder { id: *aid, order: want });
|
|
}
|
|
}
|
|
if ops.is_empty() {
|
|
return Ok(json!({"changed": 0}));
|
|
}
|
|
let changed = ops.len();
|
|
s.commit("Reorder Album", Op::Batch { ops })?;
|
|
Ok(json!({"changed": changed}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"album.sort",
|
|
"Sort Albums by Name",
|
|
[],
|
|
None,
|
|
"{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}: how many albums lost their place",
|
|
always,
|
|
|s, p| {
|
|
let parent = opt_album_param(p, "parent", "album.sort")?;
|
|
let ops: Vec<Op> = s
|
|
.catalog
|
|
.album_children(parent)
|
|
.into_iter()
|
|
.filter(|a| a.order.is_some())
|
|
.map(|a| Op::SetAlbumOrder { id: a.id, order: None })
|
|
.collect();
|
|
if ops.is_empty() {
|
|
return Ok(json!({"changed": 0}));
|
|
}
|
|
let changed = ops.len();
|
|
s.commit("Sort Albums by Name", Op::Batch { ops })?;
|
|
Ok(json!({"changed": changed}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"album.addPhotos",
|
|
"Add to Album",
|
|
["Photo"],
|
|
None,
|
|
"{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error",
|
|
has_library_selection,
|
|
|s, p| {
|
|
let id = album_param(p, "id", "album.addPhotos")?;
|
|
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
|
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
|
if al.is_smart() || al.folder {
|
|
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
|
}
|
|
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
|
|
let mut photos = al.photos.clone();
|
|
let before = photos.len();
|
|
// looked up, not searched for: adding thousands to a big album stays linear
|
|
let mut held: std::collections::HashSet<PhotoId> = photos.iter().copied().collect();
|
|
for t in targets {
|
|
if held.insert(t) {
|
|
photos.push(t);
|
|
}
|
|
}
|
|
let added = photos.len() - before;
|
|
let cover = al.cover.or(photos.first().copied());
|
|
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
|
|
Ok(json!({"added": added, "skipped": skipped}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"album.toggleTarget",
|
|
"Add to Target Album",
|
|
[],
|
|
None,
|
|
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
|
|
has_library_selection,
|
|
|s, p| {
|
|
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?;
|
|
let (targets, skipped) = targets;
|
|
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
|
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
|
Some(id) => id,
|
|
None => {
|
|
let id = s.catalog.alloc_album_id();
|
|
let album = Album { quick: true, ..Album::new(id, "Quick Collection") };
|
|
s.commit("New Quick Collection", Op::AddAlbum { album })?;
|
|
id
|
|
}
|
|
};
|
|
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
|
|
// looked up, not searched for: thousands toggled in a big album stay linear
|
|
let mut held: std::collections::HashSet<PhotoId> = al.photos.iter().copied().collect();
|
|
let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t));
|
|
let photos: Vec<PhotoId> = if all_in {
|
|
let targets: std::collections::HashSet<PhotoId> = targets.iter().copied().collect();
|
|
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
|
|
} else {
|
|
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect()
|
|
};
|
|
let name = al.name.clone();
|
|
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
|
|
s.commit(
|
|
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
|
|
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
|
|
)?;
|
|
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped}))
|
|
}
|
|
),
|
|
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
|
|
s.target_album = match p.get("id").and_then(Value::as_u64) {
|
|
Some(a) => {
|
|
let id = AlbumId(a);
|
|
let al = s.catalog.album(id).ok_or_else(|| bad("album.setTarget", "no such album"))?;
|
|
if al.is_smart() || al.folder {
|
|
return Err(bad("album.setTarget", "smart albums and folders can't hold photos"));
|
|
}
|
|
Some(id)
|
|
}
|
|
None => None,
|
|
};
|
|
Ok(json!({"target": s.target_album.map(|a| a.0)}))
|
|
}),
|
|
cmd!("album.clearQuick", "Clear Quick Collection", [], None, "{}", always, |s, _| {
|
|
let Some(id) = s.catalog.quick_collection() else { return ok() };
|
|
s.commit(
|
|
"Clear Quick Collection",
|
|
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos: vec![] }, Op::SetAlbumCover { id, cover: None }] },
|
|
)?;
|
|
ok()
|
|
}),
|
|
cmd!(
|
|
"library.autoImport",
|
|
"Auto Import Settings",
|
|
[],
|
|
None,
|
|
"{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings",
|
|
always,
|
|
|s, p| {
|
|
if let Some(f) = p.get("folder") {
|
|
s.import_defaults.auto_folder = match f.as_str().map(str::trim).filter(|f| !f.is_empty()) {
|
|
Some(f) if std::path::Path::new(f).is_dir() || cfg!(target_arch = "wasm32") => Some(f.to_string()),
|
|
Some(f) => return Err(bad("library.autoImport", format!("`{f}` is not a folder"))),
|
|
None => None,
|
|
};
|
|
}
|
|
if let Some(c) = p.get("copy").and_then(Value::as_bool) {
|
|
s.import_defaults.auto_copy = c;
|
|
}
|
|
if let Some(a) = p.get("album") {
|
|
s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string);
|
|
}
|
|
s.save_prefs()?;
|
|
let d = &s.import_defaults;
|
|
Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"library.autoImportScan",
|
|
"Auto Import Now",
|
|
[],
|
|
None,
|
|
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}",
|
|
always,
|
|
|s, p| {
|
|
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
|
|
// only files that stopped growing: a file still being written is left for later
|
|
let known: std::collections::HashSet<String> = s
|
|
.catalog
|
|
.photos()
|
|
.filter_map(|p| match &p.source {
|
|
lightcraft_catalog::Source::File { path } => Some(path.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
// the folder's files and sizes: listed here, or already listed on a worker thread (the app)
|
|
let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) {
|
|
Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(),
|
|
None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?,
|
|
};
|
|
let mut fresh = Vec::new();
|
|
for (ps, size) in listing {
|
|
if known.contains(&ps) {
|
|
continue;
|
|
}
|
|
// each file is tried once (a non-photo isn't retried every scan)
|
|
if s.auto_import_seen.get(&ps) == Some(&u64::MAX) {
|
|
continue;
|
|
}
|
|
let seen = s.auto_import_seen.insert(ps.clone(), size);
|
|
if size > 0 && seen == Some(size) {
|
|
fresh.push(ps);
|
|
}
|
|
}
|
|
if fresh.is_empty() {
|
|
return Ok(json!({"imported": [], "folder": folder}));
|
|
}
|
|
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
|
|
// a photo deleted from the library doesn't come back on its own because its file
|
|
// is still in the watched folder (a copy's source is not a known path)
|
|
let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"});
|
|
if let Some(a) = s.import_defaults.auto_album.clone() {
|
|
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
|
|
Some(al) => params["album"] = json!(al.id.0),
|
|
None => params["albumName"] = json!(a),
|
|
}
|
|
}
|
|
for f in &fresh {
|
|
s.auto_import_seen.insert(f.clone(), u64::MAX);
|
|
}
|
|
if p.get("start").and_then(Value::as_bool) == Some(false) {
|
|
// the caller imports them (the app: on a worker thread)
|
|
return Ok(json!({"imported": [], "folder": folder, "import": params}));
|
|
}
|
|
let sel = s.selection.clone();
|
|
let r = s.execute("library.import", ¶ms)?;
|
|
// arriving photos don't take over the selection
|
|
s.selection = sel;
|
|
let mut result = json!({"imported": r["imported"], "folder": folder});
|
|
if let Some(warnings) = r.get("warnings") {
|
|
result["warnings"] = warnings.clone();
|
|
}
|
|
Ok(result)
|
|
}
|
|
),
|
|
cmd!(
|
|
"album.removePhotos",
|
|
"Remove from Album",
|
|
["Photo"],
|
|
None,
|
|
"{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}",
|
|
has_selection,
|
|
|s, p| {
|
|
let id = match p.get("id") {
|
|
Some(_) => album_param(p, "id", "album.removePhotos")?,
|
|
None => match s.source {
|
|
LibrarySource::Album(a) => a,
|
|
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
|
|
},
|
|
};
|
|
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
|
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
|
|
if al.is_smart() {
|
|
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
|
|
}
|
|
if al.folder {
|
|
return Err(bad("album.removePhotos", "album folders don't hold photos"));
|
|
}
|
|
let name = al.name.clone();
|
|
let targets: std::collections::HashSet<PhotoId> = targets.into_iter().collect();
|
|
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
|
|
let removed = al.photos.len().saturating_sub(photos.len());
|
|
if removed > 0 {
|
|
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
|
|
}
|
|
Ok(json!({"removed": removed, "album": name}))
|
|
}
|
|
),
|
|
cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| {
|
|
let id = album_param(p, "id", "album.setCover")?;
|
|
let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active());
|
|
s.commit("Set Album Cover", Op::SetAlbumCover { id, cover: photo })?;
|
|
ok()
|
|
}),
|
|
// ---- import
|
|
cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None,
|
|
"{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required",
|
|
always, |_, p| {
|
|
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
|
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
|
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
|
}),
|
|
cmd!(
|
|
"library.importLightroom",
|
|
"Import Lightroom Catalog",
|
|
[],
|
|
None,
|
|
"{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.",
|
|
always,
|
|
|s, p| {
|
|
let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?;
|
|
crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false))
|
|
}
|
|
),
|
|
cmd!(
|
|
query "library.importPreview",
|
|
"Review Import",
|
|
[],
|
|
None,
|
|
"{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added",
|
|
always,
|
|
|s, p| {
|
|
let paths = strs(p, "paths");
|
|
if paths.is_empty() {
|
|
return Err(bad("library.importPreview", "no paths"));
|
|
}
|
|
let c = crate::import::scan(s, &paths);
|
|
let dups = c.iter().filter(|c| c.duplicate.is_some()).count();
|
|
Ok(json!({"scanned": c.len(), "duplicates": dups, "candidates": c}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"library.import",
|
|
"Import Photos",
|
|
["File"],
|
|
Some("Cmd+Shift+I"),
|
|
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, warnings?: [{path, code: cameraProfile|uprightGeometry|unmappedXmp, profile?, mode?: off|auto|level|vertical|full|guided|unknown, fields?}], moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
|
always,
|
|
|s, p| {
|
|
let req = import_params(s, p)?;
|
|
if req.paths.is_empty() {
|
|
return Err(bad("library.import", "no paths"));
|
|
}
|
|
let undo0 = s.undo.len();
|
|
let report = crate::import::import_with(s, &req.paths, &req.opts)?;
|
|
let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?;
|
|
// one undo step for the whole import
|
|
let ids_of = |k: &str| -> Vec<u64> { report[k].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() };
|
|
let (imported, restored) = (ids_of("imported"), ids_of("restored"));
|
|
let n = s.undo.len().saturating_sub(undo0);
|
|
let (verb, count) = if imported.is_empty() && !restored.is_empty() { ("Restore", restored.len()) } else { ("Add", imported.len()) };
|
|
s.merge_undo(n, &format!("{verb} {count} Photo{}", if count == 1 { "" } else { "s" }));
|
|
let imported = if imported.is_empty() { restored } else { imported };
|
|
if let Some(f) = imported.first() {
|
|
s.selection = Selection::single(PhotoId(*f));
|
|
}
|
|
Ok(report)
|
|
}
|
|
),
|
|
// ---- persistence
|
|
cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| {
|
|
let photos = s.catalog.len();
|
|
let albums = s.catalog.albums().count();
|
|
let (rendered_n, rendered_bytes) = s.media.rendered.mem_usage();
|
|
let (sources_n, sources_bytes) = s.media.source_usage();
|
|
let disk = s.media.rendered.disk().map(|d| {
|
|
use std::sync::atomic::Ordering::Relaxed;
|
|
json!({
|
|
"path": d.dir().display().to_string(),
|
|
"bytes": d.size(),
|
|
"hits": d.hits.load(Relaxed),
|
|
"misses": d.misses.load(Relaxed),
|
|
"writes": d.writes.load(Relaxed),
|
|
})
|
|
});
|
|
let cache = json!({
|
|
"renderedInMemory": rendered_n, "renderedBytes": rendered_bytes,
|
|
"sourcesInMemory": sources_n, "sourceBytes": sources_bytes,
|
|
"disk": disk,
|
|
});
|
|
let Some(lib) = &s.library else {
|
|
return Ok(json!({"persistent": false, "photos": photos, "albums": albums, "cache": cache}));
|
|
};
|
|
let j = lib.journal();
|
|
Ok(json!({
|
|
"persistent": true,
|
|
"path": lib.dir.display().to_string(),
|
|
"photos": photos,
|
|
"albums": albums,
|
|
"seq": j.seq(),
|
|
"snapshotSeq": j.snapshot_seq(),
|
|
"logRecords": j.log_records(),
|
|
"logBytes": j.log_bytes(),
|
|
"lastError": lib.last_error,
|
|
// settings files that were unreadable or damaged at open (kept, defaults used)
|
|
"settingsWarnings": lib.settings_warnings,
|
|
// changes applied in memory whose write failed (retried by every save)
|
|
"unsavedOps": s.unsaved().map_or(0, |u| u.0),
|
|
"unsavedError": s.unsaved().map(|u| u.1),
|
|
// untouched Local records forgotten when the library opened
|
|
"forgotLocal": lib.forgot_local.as_ref().map(|p| json!({"forgotten": p.evict.len(), "local": p.local, "keptRecent": p.kept_recent, "keptTouched": p.kept_touched, "keptInUse": p.kept_in_use})),
|
|
"persistence": j.stats(),
|
|
"cache": cache,
|
|
"load": {
|
|
"created": lib.report.created,
|
|
"replayed": lib.report.replayed,
|
|
"tornBytes": lib.report.torn_bytes,
|
|
"damaged": lib.report.damaged,
|
|
},
|
|
}))
|
|
}),
|
|
cmd!("library.compact", "Optimize Library", ["File"], None, "{}", has_library, |s, _| {
|
|
s.compact_library()?;
|
|
ok()
|
|
}),
|
|
cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| {
|
|
s.media.rendered.clear();
|
|
s.media.clear_sources();
|
|
ok()
|
|
}),
|
|
]
|
|
}
|
|
|
|
/// `base` with a partial Filter (JSON) merged on top.
|
|
/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see
|
|
/// [`merge_rules`]).
|
|
fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
|
let mut v = serde_json::to_value(base).unwrap_or_default();
|
|
lightcraft_develop::presets::deep_merge(&mut v, patch);
|
|
let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
|
if let Some(rules) = f.rule_set.as_mut() {
|
|
rules.upgrade();
|
|
}
|
|
Ok(f)
|
|
}
|
|
|
|
/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`;
|
|
/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a
|
|
/// problem too).
|
|
fn merge_rules(
|
|
base: &lightcraft_catalog::Filter,
|
|
patch: &Value,
|
|
c: &str,
|
|
cat: &lightcraft_catalog::Catalog,
|
|
owner: Option<AlbumId>,
|
|
) -> Result<lightcraft_catalog::Filter> {
|
|
let f = merge_filter(base, patch, c)?;
|
|
// only rules this change sets are checked: one that stopped checking since (its album was
|
|
// deleted) doesn't block editing the album's other settings
|
|
let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null());
|
|
let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default();
|
|
// and a loop through the album field the change sets
|
|
if patch.get("album").is_some_and(|a| !a.is_null()) {
|
|
problems.extend(cat.album_filter_problem(&f, owner));
|
|
}
|
|
if !problems.is_empty() {
|
|
return Err(bad(c, problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
|
}
|
|
Ok(f)
|
|
}
|
|
|
|
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
|
|
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
|
|
/// one disk but a way to reach several.
|
|
pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
|
ids.iter()
|
|
.filter_map(|id| s.catalog.photo(*id))
|
|
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
|
|
.any(|v| v != "/" && !lightcraft_catalog::query::folder_within(path, &v))
|
|
}
|
|
|
|
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
|
|
/// its rules with the filter bar's settings on top.
|
|
fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
|
use lightcraft_catalog::Filter;
|
|
let smart = match s.source {
|
|
LibrarySource::Album(a) => s.catalog.album(a).and_then(|a| a.smart.as_deref().cloned()),
|
|
_ => None,
|
|
};
|
|
match smart {
|
|
Some(base) => {
|
|
// overlay the fields the filter bar changed
|
|
let cur = serde_json::to_value(&s.filter).unwrap_or_default();
|
|
let def = serde_json::to_value(Filter::default()).unwrap_or_default();
|
|
let mut patch = serde_json::Map::new();
|
|
if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) {
|
|
for (k, v) in c {
|
|
if d.get(k) != Some(v) {
|
|
patch.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
}
|
|
// the view as it is: a rule that no longer checks (its album was deleted) just matches
|
|
// nothing there, and mustn't cost the filter bar's settings
|
|
merge_filter(&base, &Value::Object(patch), "").unwrap_or(base)
|
|
}
|
|
None => {
|
|
let mut f = s.source.to_filter(&s.filter, &s.catalog);
|
|
f.deleted = false;
|
|
if s.source == LibrarySource::LibraryFolder {
|
|
f.library_folder = s.library_folder.clone();
|
|
}
|
|
f
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
/// The current view (source + filter) as smart-album rules (see `album.createSmart`).
|
|
pub fn view_rules(&self) -> lightcraft_catalog::Filter {
|
|
view_rules(self)
|
|
}
|
|
}
|
|
|
|
fn has_library(s: &Session) -> std::result::Result<(), String> {
|
|
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
|
|
}
|
|
|
|
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
|
|
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
|
|
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
|
|
let t = t.trim();
|
|
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
|
|
let (a, b) = match t.split_once(',') {
|
|
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
|
|
None => {
|
|
let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
|
|
(t[..i].trim().to_string(), t[i..].trim().to_string())
|
|
}
|
|
};
|
|
let one = |s: &str, pos: char, neg: char| -> Option<f64> {
|
|
let up = s.to_ascii_uppercase();
|
|
let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 };
|
|
let nums: Vec<f64> =
|
|
up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::<f64>().ok()).collect::<Option<_>>()?;
|
|
let v = match nums.as_slice() {
|
|
[d] => *d,
|
|
[d, m] => d + m / 60.0,
|
|
[d, m, s] => d + m / 60.0 + s / 3600.0,
|
|
_ => return None,
|
|
};
|
|
let _ = pos;
|
|
Some(sign * v)
|
|
};
|
|
let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?);
|
|
(la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod gps_tests {
|
|
#[test]
|
|
fn coordinates_people_type() {
|
|
let p = super::parse_gps;
|
|
assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276)));
|
|
let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap();
|
|
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
|
|
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
|
|
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
|
|
assert_eq!(p("95, 10"), None);
|
|
assert_eq!(p("hello"), None);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "library/tests_relative_ratings.rs"]
|
|
mod relative_rating_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "library/tests_flag_cycle.rs"]
|
|
mod flag_cycle_tests;
|