From 53c4b295b9373f64ffad8ef0fd3ee278f68b2c6d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:06:33 -0400 Subject: [PATCH] LR-LIB-RENAME: batch rename with templates, preview, safe on-disk renames and undo Op::SetFile (catalog only; engine moves files + XMP sidecars first, never overwriting, rolling back on failure); undo/redo move files back and forth; virtual copies follow; photo.rename / photo.renamePreview; Rename Photos dialog (Photo menu, grid context menu). Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 15 ++ crates/catalog/src/tests_journal.rs | 3 + crates/engine/src/cmd/manage.rs | 35 +++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 31 ++- crates/engine/src/rename.rs | 351 +++++++++++++++++++++++++++ crates/engine/src/tests_libops.rs | 93 +++++++ crates/ui-egui/src/headless.rs | 19 ++ crates/ui-egui/src/menubar.rs | 2 + crates/ui-egui/src/menus.rs | 8 +- crates/ui-egui/src/panels/dialogs.rs | 38 +++ crates/ui-egui/src/panels/grid.rs | 3 + crates/ui-egui/src/state.rs | 5 + docs/parity.md | 6 +- 14 files changed, 605 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/manage.rs create mode 100644 crates/engine/src/rename.rs diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 652d74320..8c5852f0a 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -137,6 +137,13 @@ pub enum Op { photos: Vec, collapsed: bool, }, + /// Rename a photo: its file name and the source it points to. Applying the op never touches + /// the disk — the engine moves the file before it commits (and on undo/redo). + SetFile { + id: PhotoId, + file_name: String, + source: Source, + }, /// Several ops as one step (undo applies the inverses in reverse). Batch { ops: Vec, @@ -421,6 +428,14 @@ impl Catalog { s.collapsed = collapsed; old } + Op::SetFile { id, file_name, source } => { + if file_name.trim().is_empty() { + return Err(CatalogError::Invalid("empty file name".into())); + } + let p = self.photo_mut(id)?; + let old_name = std::mem::replace(&mut p.file_name, file_name); + Op::SetFile { id, file_name: old_name, source: std::mem::replace(&mut p.source, source) } + } Op::Batch { ops } => { let mut inverses = Vec::with_capacity(ops.len()); for op in ops { diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 01d1f03d4..f14ebe373 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -67,6 +67,9 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { } 13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) }, 14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }), + 9 if b.is_multiple_of(2) => { + Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } } + } 15 => match c.photo(pid).cloned() { // a virtual copy: same source, own id, copy_of/copy_name set Some(src) => { diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs new file mode 100644 index 000000000..11a7eba45 --- /dev/null +++ b/crates/engine/src/cmd/manage.rs @@ -0,0 +1,35 @@ +//! Photo management commands: batch rename, capture time, colour label names. + +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_selection, str_param}; +use crate::Result; + +fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec, 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) as usize; + Ok((s.targets(p), template, start)) +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), 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} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), 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})) + } + ), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 825c89f3f..d3e6457b0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -9,6 +9,7 @@ mod develop; mod edit; mod keywords; mod library; +mod manage; mod masks; mod merge; mod organize; @@ -109,6 +110,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(masks::specs()); v.extend(organize::specs()); v.extend(keywords::specs()); + v.extend(manage::specs()); v.extend(merge::specs()); v.extend(query::specs()); v.extend(xmp::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe3527e87..092285bd7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod library; pub mod media; pub mod merge; pub mod presets; +pub mod rename; pub mod sidecar; mod view; @@ -205,7 +206,13 @@ impl Session { pub fn undo_step(&mut self) -> Result { let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; - let redo = self.catalog.apply(e.op.clone())?; + let redo = match self.apply_with_files(&e.op) { + Ok(r) => r, + Err(err) => { + self.undo.push(e); + return Err(err); + } + }; self.pending_log.push(e.op); self.redo.push(UndoEntry { label: e.label.clone(), op: redo }); Ok(e.label) @@ -213,12 +220,32 @@ impl Session { pub fn redo_step(&mut self) -> Result { let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; - let undo = self.catalog.apply(e.op.clone())?; + let undo = match self.apply_with_files(&e.op) { + Ok(r) => r, + Err(err) => { + self.redo.push(e); + return Err(err); + } + }; self.pending_log.push(e.op); self.undo.push(UndoEntry { label: e.label.clone(), op: undo }); Ok(e.label) } + /// Apply an undo/redo op, first moving the files its renames imply (all or nothing). + fn apply_with_files(&mut self, op: &Op) -> Result { + let moves = self.file_moves(op); + Session::move_files(&moves)?; + match self.catalog.apply(op.clone()) { + Ok(inv) => Ok(inv), + Err(e) => { + let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); + let _ = Session::move_files(&back); + Err(e.into()) + } + } + } + /// Ops applied since the last call, for the op-log store. pub fn drain_log(&mut self) -> Vec { std::mem::take(&mut self.pending_log) diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs new file mode 100644 index 000000000..e01e6569c --- /dev/null +++ b/crates/engine/src/rename.rs @@ -0,0 +1,351 @@ +//! Batch rename: a file-name template applied to the selected photos, renaming the files on +//! disk (with their XMP sidecars) safely. +//! +//! Template tokens: `{name}` (current name without extension), `{seq}` / `{seq:3}` (sequence number, +//! zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date; `%Y %y %m %d %H %M %S`), +//! `{camera}`, `{title}`, `{ext}` (extension, without the dot). Characters that aren't allowed in +//! file names become `-`. The original extension is always kept. +//! +//! Safety: +//! - a target that exists on disk, or that another photo in the batch gets, receives a `-1`, `-2`… +//! suffix — no file is ever overwritten; +//! - the moves happen before the catalog changes; if one fails, the ones already done are moved +//! back and nothing is committed; +//! - the catalog op ([`Op::SetFile`]) is undoable: undo/redo move the files back and forth (see +//! [`crate::Session::undo_step`]); +//! - virtual copies follow their master's file. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde::Serialize; + +use crate::sidecar::SidecarNaming; +use crate::{EngineError, Result, Session}; + +/// One planned rename. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct RenamePlan { + pub id: u64, + pub from: String, + pub to: String, + /// File sources: the old and new paths. + #[serde(skip_serializing_if = "Option::is_none")] + pub from_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to_path: Option, +} + +fn split_ext(name: &str) -> (&str, &str) { + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => (stem, ext), + _ => (name, ""), + } +} + +fn sanitize(s: &str) -> String { + let t: String = + s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { '-' } else { c }).collect(); + t.trim().trim_matches('.').trim().to_string() +} + +/// `%Y%m%d`-style formatting of an ISO local time. +fn format_date(iso: &str, fmt: &str) -> String { + let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string(); + let mut out = String::new(); + let mut it = fmt.chars(); + while let Some(c) = it.next() { + if c != '%' { + out.push(c); + continue; + } + match it.next() { + Some('Y') => out.push_str(&part(0, 4)), + Some('y') => out.push_str(&part(2, 4)), + Some('m') => out.push_str(&part(5, 7)), + Some('d') => out.push_str(&part(8, 10)), + Some('H') => out.push_str(&part(11, 13)), + Some('M') => out.push_str(&part(14, 16)), + Some('S') => out.push_str(&part(17, 19)), + Some('%') => out.push('%'), + Some(o) => { + out.push('%'); + out.push(o); + } + None => out.push('%'), + } + } + out +} + +/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension. +pub fn expand(template: &str, p: &Photo, seq: usize) -> String { + let (stem, ext) = split_ext(&p.file_name); + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + out.push_str(&rest[..i]); + let Some(j) = rest[i..].find('}') else { + out.push_str(&rest[i..]); + rest = ""; + break; + }; + let tok = &rest[i + 1..i + j]; + let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); + let v = match name.trim().to_ascii_lowercase().as_str() { + "name" | "filename" => stem.to_string(), + "seq" | "n" => { + let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(1).min(9); + format!("{seq:0w$}") + } + "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), + "camera" => p.meta.camera.clone(), + "title" => p.meta.title.clone(), + "ext" => ext.to_string(), + _ => format!("{{{tok}}}"), + }; + out.push_str(&v); + rest = &rest[i + j + 1..]; + } + out.push_str(rest); + let mut stem_out = sanitize(&out); + if stem_out.is_empty() { + stem_out = sanitize(stem); + } + if stem_out.is_empty() { + stem_out = "photo".into(); + } + if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") } +} + +/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). +fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { + let mut v = Vec::new(); + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + let p = crate::sidecar::sidecar_path(path, naming); + v.push((p.with_extension("XMP"), naming, true)); + v.push((p, naming, false)); + } + v +} + +/// Move `from` to `to` (never over an existing file) together with its sidecars. On error nothing +/// is left moved. +pub fn move_file(from: &str, to: &str) -> std::result::Result<(), String> { + if from == to { + return Ok(()); + } + let (f, t) = (Path::new(from), Path::new(to)); + // a case-only change on a case-insensitive file system "exists" already: that is this file + let case_only = from.to_lowercase() == to.to_lowercase(); + if t.exists() && !case_only { + return Err(format!("{to} already exists")); + } + let mut done: Vec<(PathBuf, PathBuf)> = Vec::new(); + let mv = |a: &Path, b: &Path| -> std::io::Result<()> { + if case_only { + // via a temporary name, so case-insensitive file systems see a real change + let tmp = a.with_file_name(format!(".lc-rename-{}", std::process::id())); + std::fs::rename(a, &tmp)?; + std::fs::rename(&tmp, b).inspect_err(|_| { + let _ = std::fs::rename(&tmp, a); + }) + } else { + std::fs::rename(a, b) + } + }; + mv(f, t).map_err(|e| format!("rename {from}: {e}"))?; + done.push((f.to_path_buf(), t.to_path_buf())); + for (sc, naming, upper) in sidecars(from) { + if !sc.is_file() { + continue; + } + let mut dst = crate::sidecar::sidecar_path(to, naming); + if upper { + dst = dst.with_extension("XMP"); + } + if dst.exists() && !case_only { + continue; // never overwrite: the old sidecar stays where it was + } + if let Err(e) = mv(&sc, &dst) { + for (a, b) in done.iter().rev() { + let _ = std::fs::rename(b, a); + } + return Err(format!("rename {}: {e}", sc.display())); + } + done.push((sc, dst)); + } + Ok(()) +} + +impl Session { + /// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions. + pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec { + // photos sharing one file (virtual copies) rename once + let mut seen_paths: HashSet = HashSet::new(); + let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch + let mut out = Vec::new(); + let mut seq = start; + for id in ids { + let Some(p) = self.catalog.photo(*id) else { continue }; + if let Source::File { path } = &p.source + && !seen_paths.insert(path.clone()) + { + continue; + } + let want = expand(template, p, seq); + seq += 1; + let (stem, ext) = split_ext(&want); + let (stem, ext) = (stem.to_string(), ext.to_string()); + let candidate = |k: usize| { + if k == 0 { + want.clone() + } else if ext.is_empty() { + format!("{stem}-{k}") + } else { + format!("{stem}-{k}.{ext}") + } + }; + let (to, to_path) = match &p.source { + Source::File { path } => { + let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); + let mut k = 0; + loop { + let name = candidate(k); + let tp = dir.join(&name).to_string_lossy().to_string(); + let key = tp.to_lowercase(); + let same = key == path.to_lowercase(); + // free: not claimed in this batch and not on disk (unless it is this very file). + // A file this batch moves away still counts as taken: simple and safe. + let on_disk = Path::new(&tp).exists() && !same; + if !taken.contains(&key) && !on_disk { + taken.insert(key); + break (name, Some(tp)); + } + k += 1; + } + } + Source::Demo { .. } => { + let mut k = 0; + loop { + let name = candidate(k); + if taken.insert(format!("demo:{}", name.to_lowercase())) { + break (name, None); + } + k += 1; + } + } + }; + let from_path = match &p.source { + Source::File { path } => Some(path.clone()), + _ => None, + }; + out.push(RenamePlan { id: id.0, from: p.file_name.clone(), to, from_path, to_path }); + } + out + } + + /// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op + /// that updates the photos (and their virtual copies). + pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result { + let mut moved: Vec<(String, String)> = Vec::new(); + for pl in plans { + if let (Some(a), Some(b)) = (&pl.from_path, &pl.to_path) + && a != b + { + if let Err(e) = move_file(a, b) { + for (a, b) in moved.iter().rev() { + let _ = move_file(b, a); + } + return Err(EngineError::Other(e)); + } + moved.push((a.clone(), b.clone())); + } + } + let mut ops = Vec::new(); + let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect(); + for p in self.catalog.photos() { + let plan = match &p.source { + Source::File { path } => by_path.get(path.as_str()).copied(), + Source::Demo { .. } => plans.iter().find(|x| x.id == p.id.0 && x.from_path.is_none()), + }; + let Some(plan) = plan else { continue }; + let source = match &plan.to_path { + Some(t) => Source::File { path: t.clone() }, + None => p.source.clone(), + }; + if plan.to != p.file_name || source != p.source { + ops.push(Op::SetFile { id: p.id, file_name: plan.to.clone(), source }); + } + } + let n = ops.len(); + if n > 0 + && let Err(e) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops }) + { + for (a, b) in moved.iter().rev() { + let _ = move_file(b, a); + } + return Err(e); + } + Ok(n) + } + + /// File moves an undo/redo op implies: `SetFile` ops whose source path differs from the photo's + /// current one. + pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> { + fn rec(s: &Session, op: &Op, v: &mut Vec<(String, String)>) { + match op { + Op::Batch { ops } => ops.iter().for_each(|o| rec(s, o, v)), + Op::SetFile { id, source: Source::File { path: to }, .. } => { + if let Some(Source::File { path: from }) = s.catalog.photo(*id).map(|p| &p.source) + && from != to + && !v.iter().any(|(f, _)| f == from) + { + v.push((from.clone(), to.clone())); + } + } + _ => {} + } + } + let mut v = Vec::new(); + rec(self, op, &mut v); + v + } + + /// Move the files `moves` names; all or nothing. + pub(crate) fn move_files(moves: &[(String, String)]) -> Result<()> { + let mut done: Vec<&(String, String)> = Vec::new(); + for m in moves { + if let Err(e) = move_file(&m.0, &m.1) { + for (a, b) in done.iter().rev().map(|m| (&m.0, &m.1)) { + let _ = move_file(b, a); + } + return Err(EngineError::Other(format!("can't undo the rename: {e}"))); + } + done.push(m); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn templates() { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00"); + p.captured = Some("2026-09-30T14:05:09".into()); + p.meta.camera = "Model X/2".into(); + p.meta.title = "Sunset: beach".into(); + assert_eq!(expand("{name}", &p, 1), "IMG_0042.CR2"); + assert_eq!(expand("Trip-{seq:3}", &p, 7), "Trip-007.CR2"); + assert_eq!(expand("{date}_{name}", &p, 1), "20260930_IMG_0042.CR2"); + assert_eq!(expand("{date:%Y-%m-%d %H.%M.%S}", &p, 1), "2026-09-30 14.05.09.CR2"); + assert_eq!(expand("{camera} {title}", &p, 1), "Model X-2 Sunset- beach.CR2"); + assert_eq!(expand("{unknown}-{seq}", &p, 12), "{unknown}-12.CR2"); + assert_eq!(expand(" ", &p, 1), "IMG_0042.CR2", "empty template keeps the name"); + assert_eq!(expand("../{name}", &p, 1), "-IMG_0042.CR2", "no path components"); + } +} diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index 6cc68355c..74aad1b9e 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -93,3 +93,96 @@ fn keyword_rename_merge_delete_undo_and_replay() { assert!(s.execute("keyword.merge", &json!({"from": [], "into": "x"})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +fn write_png(path: &std::path::Path, seed: u8) { + let (w, h) = (24usize, 16usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 11) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +fn path_of(s: &Session, id: u64) -> String { + match &s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().source { + lightcraft_catalog::Source::File { path } => path.clone(), + _ => panic!("not a file"), + } +} + +/// Batch rename on disk: collisions with existing files and within the batch get suffixes, sidecars +/// move along, virtual copies follow, undo/redo move the files back and forth, a failed move rolls +/// the batch back, and the op log replays the new paths. +#[test] +fn batch_rename_files_collisions_undo_and_replay() { + let src = temp_dir("rename-src"); + let lib = temp_dir("rename-lib"); + write_png(&src.join("a.png"), 1); + write_png(&src.join("b.png"), 2); + std::fs::write(src.join("Trip-001.png"), b"not ours").unwrap(); + std::fs::write(src.join("a.xmp"), b"").unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 2, "{r}"); + s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap(); + s.execute("photo.virtualCopy", &json!({})).unwrap(); + let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap().id.0; + + // preview: the existing Trip-001.png is skipped, the copy shares its master's file + let pv = s.execute("photo.renamePreview", &json!({"ids": [ids[0], copy, ids[1]], "template": "Trip-{seq:3}"})).unwrap(); + assert_eq!(pv.as_array().unwrap().len(), 2, "{pv}"); + assert_eq!(pv[0]["to"], "Trip-001-1.png"); + assert_eq!(pv[1]["to"], "Trip-002.png"); + let r = s.execute("photo.rename", &json!({"ids": [ids[0], copy, ids[1]], "template": "Trip-{seq:3}"})).unwrap(); + assert_eq!(r["renamed"], 3, "{r}"); + assert!(src.join("Trip-001-1.png").is_file() && src.join("Trip-002.png").is_file()); + assert!(!src.join("a.png").exists() && !src.join("b.png").exists()); + assert_eq!(std::fs::read(src.join("Trip-001.png")).unwrap(), b"not ours", "never overwritten"); + assert!(src.join("Trip-001-1.xmp").is_file() && !src.join("a.xmp").exists(), "sidecar moved"); + assert_eq!(path_of(&s, copy), path_of(&s, ids[0]), "the virtual copy follows"); + assert_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(ids[1])).unwrap().file_name, "Trip-002.png"); + + // undo moves the files back; redo renames again + s.execute("edit.undo", &json!({})).unwrap(); + assert!(src.join("a.png").is_file() && src.join("b.png").is_file() && src.join("a.xmp").is_file()); + assert_eq!(path_of(&s, ids[0]), src.join("a.png").to_string_lossy()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(src.join("Trip-002.png").is_file() && !src.join("b.png").exists()); + + // collisions inside the batch + let r = s.execute("photo.rename", &json!({"ids": [ids[0], ids[1]], "template": "same"})).unwrap(); + assert_eq!(r["plans"][0]["to"], "same.png"); + assert_eq!(r["plans"][1]["to"], "same-1.png"); + + // a failing move rolls the whole batch back and changes nothing + std::fs::remove_file(src.join("same-1.png")).unwrap(); + let before = s.catalog.to_snapshot(); + assert!(s.execute("photo.rename", &json!({"ids": [ids[0], ids[1]], "template": "x-{seq}"})).is_err()); + assert!(src.join("same.png").is_file() && !src.join("x-1.png").exists(), "rolled back"); + assert_eq!(s.catalog.to_snapshot(), before); + + // the op log replays the renames + let expect = s.catalog.to_snapshot(); + drop(s); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.catalog.to_snapshot(), expect); + assert_eq!(path_of(&s, ids[0]), src.join("same.png").to_string_lossy()); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} + +/// Demo photos (no file) rename in the catalog only. +#[test] +fn rename_demo_photos_in_catalog() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().iter().take(3).map(|p| p.0).collect(); + let r = s.execute("photo.rename", &json!({"ids": ids, "template": "{date:%Y-%m-%d}_{seq:2}", "start": 7})).unwrap(); + assert_eq!(r["renamed"], 3); + let name = &s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap().file_name; + assert!(name.ends_with("_07.jpg") || name.contains("_07."), "{name}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap().file_name.starts_with("LC")); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 9404cd567..2a5ebd93a 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -437,6 +437,25 @@ mod tests { h.settle(SETTLE); } + /// Photo > Rename Photos…: the dialog previews and renames the selection. + #[test] + fn rename_dialog_renames_the_selection() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": vis}}), t); + let tree = h.request("ui.menu.tree", json!({}), t); + assert!(tree.to_string().contains("Rename 2 Photos…"), "live menu label"); + h.request("engine.execute", json!({"command": "dialog.rename", "params": {"template": "Trip-{seq:2}", "start": 5}}), t); + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let name = |id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().file_name.clone(); + assert!(name(vis[0]).starts_with("Trip-05."), "{}", name(vis[0])); + assert!(name(vis[1]).starts_with("Trip-06."), "{}", name(vis[1])); + h.settle(SETTLE); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 8e4efd5d3..688467489 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -136,6 +136,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.saveMetadataToFile", "photo.readMetadataFromFile", "app.showInFinder", + "dialog.rename", "---", "photo.delete", ], @@ -225,6 +226,7 @@ fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String { "edit.redo" => app.session.redo.last().map(|e| format!("Redo {}", e.label)).unwrap_or_else(|| "Redo".into()), "photo.delete" if n > 1 => format!("Delete {n} Photos"), "photo.virtualCopy" if n > 1 => format!("Create {n} Virtual Copies"), + "dialog.rename" if n > 1 => format!("Rename {n} Photos…"), _ => label.to_string(), } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4d31a0024..aedeb75b5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -76,6 +76,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), ("app.export", "Export Now", None, ""), ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), + ("dialog.rename", "Rename Photos…", None, "Photo"), ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), ]; @@ -329,6 +330,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let template = p.get("template").and_then(Value::as_str).unwrap_or("{name}").to_string(); + app.ui.dialog = Some(Dialog::Rename { template, start: p.get("start").and_then(Value::as_u64).unwrap_or(1) as u32 }); + Ok(Value::Null) + } "dialog.createPreset" => { app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); Ok(Value::Null) @@ -439,7 +445,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option bool { match id { s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", - "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.copySettings" => app.session.active().is_some(), + "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.copySettings" => app.session.active().is_some(), "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "app.showInFinder" => { app.services.reveal.is_some() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 57e990f03..ed3f48574 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -22,6 +22,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::NewAlbum { folder: true, .. } => "Create Folder", Dialog::NewAlbum { .. } => "Create Album", Dialog::RenameAlbum { .. } => "Rename Album", + Dialog::Rename { .. } => "Rename Photos", Dialog::RenameKeyword { .. } => "Rename Keyword", Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", @@ -72,6 +73,42 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .color(t.text_dim), ); } + Dialog::Rename { template, start } => { + let n = app.session.targets(&json!({})).len(); + ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); + field(ui, "Template", |ui| { + let r = ui.add(egui::TextEdit::singleline(template).hint_text("{name}").desired_width(f32::INFINITY)); + crate::widgets::register(ui.ctx(), "field:renameTemplate", r.rect); + }); + field(ui, "Presets", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (label, tpl)) in + [("Name", "{name}"), ("Date–Name", "{date}_{name}"), ("Title–Seq", "{title}-{seq:3}"), ("Custom–Seq", "Photo-{seq:3}")].iter().enumerate() + { + if crate::widgets::text_button(ui, &format!("renamePreset-{i}"), label, template == tpl).clicked() { + *template = tpl.to_string(); + } + } + }); + field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); + ui.label( + egui::RichText::new("Tokens: {name} {seq} {seq:3} {date} {date:%Y-%m-%d} {camera} {title}. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…") + .color(t.text_dim), + ); + let preview = app.session.execute("photo.renamePreview", &json!({"template": template, "start": start})).unwrap_or_default(); + egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { + for pl in preview.as_array().into_iter().flatten().take(6) { + ui.label(egui::RichText::new(pl["from"].as_str().unwrap_or("")).color(t.text_dim)); + ui.label(egui::RichText::new("→").color(t.text_dim)); + ui.label(egui::RichText::new(pl["to"].as_str().unwrap_or("")).color(t.text)); + ui.end_row(); + } + }); + let more = preview.as_array().map_or(0, Vec::len).saturating_sub(6); + if more > 0 { + ui.label(egui::RichText::new(format!("… and {more} more")).color(t.text_dim)); + } + } Dialog::RenameKeyword { from, to } => { let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); let r = ui.add(egui::TextEdit::singleline(to).hint_text("New name").desired_width(f32::INFINITY)); @@ -321,6 +358,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})), Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})), Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 4dbacf6b4..2efa08ed5 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -431,6 +431,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } }); + if ui.button("Rename…").clicked() { + let _ = app.run("dialog.rename", json!({})); + } if ui.button("Create Virtual Copy").clicked() { let _ = app.run("photo.virtualCopy", json!({})); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9d5ceb9d6..61c561a8d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -151,6 +151,11 @@ pub enum Dialog { id: u64, name: String, }, + /// Batch rename the selected photos with a file-name template. + Rename { + template: String, + start: u32, + }, /// Rename a keyword on every photo (children included). RenameKeyword { from: String, diff --git a/docs/parity.md b/docs/parity.md index 605d12c37..8f93ca313 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -75,7 +75,7 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 12. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). 13. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. -14. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +14. **LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — capture-time edit, label menu/names. 15. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. 16. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). @@ -156,7 +156,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ⬜ | | | -| LR-LIB-RENAME | Batch rename | P1 | ⬜ | | | +| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {seq:N} {date:%Y%m%d} {camera} {title}; preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow | | LR-LIB-CAPTURETIME | Edit capture time | P1 | ⬜ | | `photo.setMeta` has no capture-time field | | LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ⬜ | | | | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | @@ -587,7 +587,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-PHOTO-BW | Convert to B&W | P0 | ✅ | `cmd:develop.treatment` | | | MENU-PHOTO-RESET | Reset edits / crop | P0 | ✅ | `cmd:develop.reset`, `cmd:crop.reset` | | | MENU-PHOTO-UPDATEAI | Update AI settings | P2 | ⬜ | | | -| MENU-PHOTO-RENAME | Rename N photos… | P1 | ⬜ | | | +| MENU-PHOTO-RENAME | Rename N photos… | P1 | ✅ | `cmd:dialog.rename` | live “Rename N Photos…” label | | MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ⬜ | | | | MENU-PHOTO-COVER | Set as album cover | P2 | ✅ | `cmd:album.setCover` | | | MENU-PHOTO-DELETE | Delete N photos… | P0 | ✅ | `cmd:photo.delete` | no confirmation; static label |