mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
Found while testing crates/engine/src/rename.rs:919 (start parsed at crates/engine/src/cmd/manage.rs:12) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
338 lines
17 KiB
Rust
338 lines
17 KiB
Rust
//! Photo management commands: batch rename, capture time, colour label names and sets.
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use lightcraft_catalog::{ColorLabel, Op};
|
||
|
||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection, str_param};
|
||
use crate::Result;
|
||
|
||
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
|
||
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
|
||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1);
|
||
let ids = s.targets(p)?;
|
||
// every number of the sequence `start..start + n` must fit
|
||
let last = start.checked_add((ids.len() as u64).saturating_sub(1));
|
||
let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok());
|
||
let start = start.ok_or_else(|| bad(c, "start out of range"))?;
|
||
Ok((ids, template, start))
|
||
}
|
||
|
||
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
|
||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct LabelSet {
|
||
pub name: String,
|
||
pub names: [String; 5],
|
||
}
|
||
|
||
/// Our built-in sets.
|
||
pub fn builtin_label_sets() -> Vec<LabelSet> {
|
||
let set = |name: &str, n: [&str; 5]| LabelSet { name: name.into(), names: n.map(str::to_string) };
|
||
vec![set("Colors", ["", "", "", "", ""]), set("Review", ["Reject", "Needs Work", "Approved", "Retouch", "Print"])]
|
||
}
|
||
|
||
fn all_label_sets(s: &crate::Session) -> Vec<LabelSet> {
|
||
builtin_label_sets().into_iter().chain(s.label_sets.iter().cloned()).collect()
|
||
}
|
||
|
||
/// The set whose names are the catalog's current ones.
|
||
fn current_label_set(s: &crate::Session) -> Option<String> {
|
||
let cur: [String; 5] = ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string());
|
||
all_label_sets(s)
|
||
.into_iter()
|
||
.find(|x| x.names.iter().zip(&cur).all(|(a, b)| a.trim() == b.trim() || (a.trim().is_empty() && b.is_empty())))
|
||
.map(|x| x.name)
|
||
}
|
||
|
||
/// Every set (built-ins first) and the one in use: `{sets: [{name, names, builtin}], current}`.
|
||
pub fn label_sets_json(s: &crate::Session) -> Value {
|
||
let builtin = builtin_label_sets().len();
|
||
let sets: Vec<Value> =
|
||
all_label_sets(s).iter().enumerate().map(|(i, x)| json!({"name": x.name, "names": x.names, "builtin": i < builtin})).collect();
|
||
json!({"sets": sets, "current": current_label_set(s)})
|
||
}
|
||
|
||
fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||
ColorLabel::ALL
|
||
.iter()
|
||
.zip(names)
|
||
.filter_map(|(l, n)| {
|
||
let n = n.trim();
|
||
let name = (!n.is_empty() && !n.eq_ignore_ascii_case(&format!("{l:?}"))).then(|| n.to_string());
|
||
(s.catalog.custom_label_name(*l) != name.as_deref()).then_some(Op::SetLabelName { label: *l, name })
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Geotag photos from a GPX track log by capture time (Lightroom Classic's Map ▸ Tracklog ▸
|
||
/// Auto-Tag Photos). GPX times are UTC; capture times are the camera's local clock, so a photo's
|
||
/// recorded zone (Exif `OffsetTimeOriginal`) or the `offset` parameter converts them.
|
||
fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||
use lightcraft_meta::{DateTime, Match, parse_gpx};
|
||
const C: &str = "photo.autoTagTracklog";
|
||
let text = match (str_param(p, "gpx"), str_param(p, "path")) {
|
||
(Some(t), _) => t.to_string(),
|
||
(None, Some(path)) => std::fs::read_to_string(path).map_err(|e| bad(C, format!("can't read {path}: {e}")))?,
|
||
(None, None) => return Err(bad(C, "missing `path` (a .gpx file) or `gpx` (its text)")),
|
||
};
|
||
let log = parse_gpx(&text).map_err(|e| bad(C, e.to_string()))?;
|
||
let (start, end) = log.span().ok_or_else(|| bad(C, "the track log has no timed track points"))?;
|
||
// minutes east of UTC; `None` = not given (zone-less capture times are then read as UTC)
|
||
let offset: Option<i64> = match p.get("offset") {
|
||
None | Some(Value::Null) => None,
|
||
Some(Value::String(o)) if o.trim().is_empty() => None,
|
||
Some(Value::Number(h)) => {
|
||
let h = h.as_f64().unwrap_or(0.0);
|
||
if h.abs() > 18.0 {
|
||
return Err(bad(C, "offset is at most ±18 hours"));
|
||
}
|
||
Some((h * 60.0).round() as i64)
|
||
}
|
||
Some(Value::String(o)) => {
|
||
Some(DateTime::parse_offset(o).ok_or_else(|| bad(C, format!("can't read offset `{o}` (e.g. +02:00 or -5)")))? as i64)
|
||
}
|
||
Some(_) => return Err(bad(C, "offset is `+HH:MM` or a number of hours")),
|
||
};
|
||
let max_gap = f64_or(p, "maxGap", 600.0);
|
||
if max_gap.is_nan() || max_gap < 0.0 {
|
||
return Err(bad(C, "maxGap must be ≥ 0 seconds"));
|
||
}
|
||
let replace = bool_or(p, "replace", false);
|
||
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
|
||
let mut ops = Vec::new();
|
||
let mut photos = Vec::new();
|
||
for id in s.targets(p)? {
|
||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||
if ph.meta.gps.is_some() && !replace {
|
||
has_gps += 1;
|
||
continue;
|
||
}
|
||
let Some(dt) = ph.captured.as_deref().and_then(DateTime::parse_iso) else {
|
||
no_time += 1;
|
||
continue;
|
||
};
|
||
// UTC seconds: the recorded zone wins, else the caller's offset
|
||
let utc = match dt.offset_minutes {
|
||
Some(_) => dt.unix_seconds(),
|
||
None => {
|
||
if offset.is_none() {
|
||
assumed_utc += 1;
|
||
}
|
||
dt.unix_seconds() - offset.unwrap_or(0) * 60
|
||
}
|
||
} as f64
|
||
+ dt.millis as f64 / 1000.0;
|
||
let Some((g, m)) = log.locate(utc, max_gap) else {
|
||
outside += 1;
|
||
continue;
|
||
};
|
||
if m == Match::Interpolated {
|
||
interpolated += 1;
|
||
}
|
||
let pos = ((g.latitude * 1e7).round() / 1e7, (g.longitude * 1e7).round() / 1e7);
|
||
photos.push(json!({"id": id.0, "gps": [pos.0, pos.1], "match": if m == Match::Interpolated { "interpolated" } else { "nearest" }}));
|
||
if ph.meta.gps != Some(pos) {
|
||
let mut meta = ph.meta.clone();
|
||
meta.gps = Some(pos);
|
||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||
}
|
||
}
|
||
let tagged = photos.len();
|
||
if !bool_or(p, "dryRun", false) && !ops.is_empty() {
|
||
s.commit("Auto-Tag from Tracklog", Op::Batch { ops })?;
|
||
}
|
||
let iso = |t: f64| format!("{}Z", lightcraft_catalog::dates::civil(t.floor() as i64));
|
||
Ok(json!({
|
||
"tagged": tagged,
|
||
"interpolated": interpolated,
|
||
"nearest": tagged - interpolated,
|
||
"skipped": {"noTime": no_time, "outside": outside, "hasGps": has_gps},
|
||
"assumedUtc": assumed_utc,
|
||
"points": log.points.len(),
|
||
"start": iso(start),
|
||
"end": iso(end),
|
||
"photos": photos,
|
||
}))
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| {
|
||
Ok(crate::rename::token_help_json())
|
||
}),
|
||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
|
||
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
|
||
}),
|
||
cmd!(
|
||
"photo.rename",
|
||
"Rename Photos",
|
||
[],
|
||
None,
|
||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||
has_selection,
|
||
|s, p| {
|
||
let (ids, template, start) = rename_args(s, p, "photo.rename")?;
|
||
let plans = s.plan_rename(&ids, &template, start);
|
||
let n = s.apply_rename(&plans)?;
|
||
Ok(json!({"renamed": n, "plans": plans}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"photo.setCaptureTime",
|
||
"Edit Capture Time",
|
||
[],
|
||
None,
|
||
"{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..]}",
|
||
has_selection,
|
||
|s, p| {
|
||
use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso};
|
||
let c = "photo.setCaptureTime";
|
||
let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
|
||
if targets.is_empty() {
|
||
return Err(bad(c, "no photos"));
|
||
}
|
||
// photos without a capture time start from their import time
|
||
let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default();
|
||
// seconds; a shift beyond 10,000 years is a mistake (and would overflow), not a date
|
||
const MAX_SHIFT: f64 = 10_000.0 * 365.25 * 86_400.0;
|
||
let shift = f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0;
|
||
if shift.is_nan() || shift.abs() > MAX_SHIFT {
|
||
return Err(bad(c, "the shift is more than 10,000 years"));
|
||
}
|
||
let mut delta = shift.round() as i64;
|
||
let mut each: Option<String> = None;
|
||
if let Some(t) = str_param(p, "time") {
|
||
let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?;
|
||
if bool_or(p, "each", false) {
|
||
each = Some(t);
|
||
} else {
|
||
let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]);
|
||
let anchor_date = base(s, anchor);
|
||
// An impossible calendar date cannot anchor changes to the other photos.
|
||
let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or(""))
|
||
.and_then(|_| iso_seconds(&anchor_date))
|
||
.ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||
delta += iso_seconds(&t).unwrap_or(from) - from;
|
||
}
|
||
}
|
||
let mut ops = Vec::new();
|
||
let mut out = Vec::new();
|
||
let mut out_of_range = 0usize;
|
||
for id in &targets {
|
||
let from = match &each {
|
||
Some(t) => t.clone(),
|
||
None => base(s, *id),
|
||
};
|
||
let Some(new) = shift_iso(&from, delta) else {
|
||
// a date that reads but would leave years 0000–9999 is refused below; one
|
||
// that doesn't read is skipped, as before
|
||
if iso_seconds(&from).is_some() {
|
||
out_of_range += 1;
|
||
}
|
||
continue;
|
||
};
|
||
out.push(json!(new));
|
||
if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {
|
||
ops.push(Op::SetCaptured { id: *id, captured: Some(new) });
|
||
}
|
||
}
|
||
if out_of_range > 0 {
|
||
return Err(bad(c, format!("the shift would take {out_of_range} photo(s) outside the years 0000–9999; nothing was changed")));
|
||
}
|
||
let n = ops.len();
|
||
if n > 0 {
|
||
s.commit("Edit Capture Time", Op::Batch { ops })?;
|
||
}
|
||
Ok(json!({"changed": n, "captured": out}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"photo.autoTagTracklog",
|
||
"Auto-Tag Photos from Tracklog",
|
||
[],
|
||
None,
|
||
"{path?: GPX file | gpx?: GPX text, ids?, offset?: camera clock's UTC offset for photos whose capture time has no zone (`+02:00`, or hours; default UTC), maxGap?: seconds (600), replace?: bool (also photos that already have GPS), dryRun?: bool} → {tagged, interpolated, nearest, skipped: {noTime, outside, hasGps}, assumedUtc, points, start, end, photos: [{id, gps, match}]} — positions from the track log by capture time; one undo step",
|
||
has_selection,
|
||
auto_tag_tracklog
|
||
),
|
||
cmd!(query "label.names", "Color Label Names", [], None, "{} → [{label, name, custom}]", always, |s, _| {
|
||
Ok(json!(ColorLabel::ALL
|
||
.iter()
|
||
.map(|l| json!({"label": format!("{l:?}").to_lowercase(), "name": s.catalog.label_name(*l), "custom": s.catalog.custom_label_name(*l)}))
|
||
.collect::<Vec<_>>()))
|
||
}),
|
||
cmd!(
|
||
"label.setNames",
|
||
"Edit Color Label Names",
|
||
[],
|
||
None,
|
||
"{names: {red?: name|null, yellow?, green?, blue?, purple?}} — null or empty restores the colour's name",
|
||
always,
|
||
|s, p| {
|
||
let names = p.get("names").and_then(Value::as_object).ok_or_else(|| bad("label.setNames", "missing `names`"))?;
|
||
let mut ops = Vec::new();
|
||
for (k, v) in names {
|
||
let label = ColorLabel::parse(k).ok_or_else(|| bad("label.setNames", format!("unknown label `{k}`")))?;
|
||
let name =
|
||
v.as_str().map(str::trim).filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(&format!("{label:?}"))).map(str::to_string);
|
||
if s.catalog.custom_label_name(label) != name.as_deref() {
|
||
ops.push(Op::SetLabelName { label, name });
|
||
}
|
||
}
|
||
let n = ops.len();
|
||
if n > 0 {
|
||
s.commit("Edit Label Names", Op::Batch { ops })?;
|
||
}
|
||
Ok(json!({"changed": n}))
|
||
}
|
||
),
|
||
cmd!(query "label.sets", "Color Label Sets", [], None, "{} → {sets: [{name, names: [red, yellow, green, blue, purple], builtin}], current: name|null}", always, |s, _| Ok(label_sets_json(s))),
|
||
cmd!("label.applySet", "Apply Color Label Set", [], None, "{name} — use a set's label names (undoable)", always, |s, p| {
|
||
let name = str_param(p, "name").ok_or_else(|| bad("label.applySet", "missing `name`"))?;
|
||
let set = all_label_sets(s)
|
||
.into_iter()
|
||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||
.ok_or_else(|| bad("label.applySet", format!("no label set `{name}`")))?;
|
||
let ops = set_names_ops(s, &set.names);
|
||
let n = ops.len();
|
||
if n > 0 {
|
||
s.commit(&format!("Label Set: {}", set.name), Op::Batch { ops })?;
|
||
}
|
||
Ok(json!({"changed": n}))
|
||
}),
|
||
cmd!(
|
||
"label.saveSet",
|
||
"Save Color Label Set",
|
||
[],
|
||
None,
|
||
"{name} — save the current label names as a set (replaces a user set of that name)",
|
||
always,
|
||
|s, p| {
|
||
let name =
|
||
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("label.saveSet", "missing `name`"))?.to_string();
|
||
if builtin_label_sets().iter().any(|b| b.name.eq_ignore_ascii_case(&name)) {
|
||
return Err(bad("label.saveSet", format!("`{name}` is a built-in set")));
|
||
}
|
||
let set = LabelSet { name: name.clone(), names: ColorLabel::ALL.map(|l| s.catalog.custom_label_name(l).unwrap_or("").to_string()) };
|
||
match s.label_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||
Some(x) => *x = set,
|
||
None => s.label_sets.push(set),
|
||
}
|
||
s.save_prefs()?;
|
||
Ok(json!({"name": name}))
|
||
}
|
||
),
|
||
cmd!("label.deleteSet", "Delete Color Label Set", [], None, "{name} — user sets only", always, |s, p| {
|
||
let name = str_param(p, "name").ok_or_else(|| bad("label.deleteSet", "missing `name`"))?;
|
||
let before = s.label_sets.len();
|
||
s.label_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
|
||
if s.label_sets.len() == before {
|
||
return Err(bad("label.deleteSet", format!("no user label set `{name}`")));
|
||
}
|
||
s.save_prefs()?;
|
||
Ok(json!({"deleted": name}))
|
||
}),
|
||
]
|
||
}
|