mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
shift_iso writes back only a time it fully reads (a date alone, or HH:MM with optional seconds and fraction, an optional zone, nothing after); iso_seconds stays lenient for sorting and rules. photo.setCaptureTime leaves such photos as they were and counts them as `unreadable`, and a set that would move other photos by an unreadable time is refused; alone, the photo gets the typed time. The dialog's preview says the photo's time can't be read, OK says what was left, any error keeps the dialog open (button and ui.dialog.confirm), and Set mode doesn't start from an unreadable time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
353 lines
18 KiB
Rust
353 lines
18 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: [..], unreadable: photos skipped because their capture time can't be read}",
|
||
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"))?;
|
||
// the others move by the anchor's change, so it must read in full, not just
|
||
// its leading digits (a Lightroom catalog's capture time is copied verbatim);
|
||
// alone, it simply gets the time, which repairs it
|
||
if shift_iso(&anchor_date, 0).is_some() {
|
||
delta += iso_seconds(&t).unwrap_or(from) - from;
|
||
} else if targets.len() == 1 {
|
||
each = Some(t);
|
||
} else {
|
||
return Err(bad(
|
||
c,
|
||
"the active photo's capture time can't be read, so the other photos can't move by the same amount; set its time with it selected alone",
|
||
));
|
||
}
|
||
}
|
||
}
|
||
let mut ops = Vec::new();
|
||
let mut out = Vec::new();
|
||
let mut out_of_range = 0usize;
|
||
let mut unreadable = 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 and counted
|
||
if shift_iso(&from, 0).is_some() {
|
||
out_of_range += 1;
|
||
} else {
|
||
unreadable += 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, "unreadable": unreadable}))
|
||
}
|
||
),
|
||
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}))
|
||
}),
|
||
]
|
||
}
|