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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 13:06:33 -04:00
co-authored by Claude Opus 5.5
parent 7fd21517ae
commit 53c4b295b9
14 changed files with 605 additions and 6 deletions
+15
View File
@@ -137,6 +137,13 @@ pub enum Op {
photos: Vec<PhotoId>,
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<Op>,
@@ -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 {
+3
View File
@@ -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) => {
+35
View File
@@ -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<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) as usize;
Ok((s.targets(p), template, start))
}
pub fn specs() -> Vec<CommandSpec> {
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}))
}
),
]
}
+2
View File
@@ -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());
+29 -2
View File
@@ -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<String> {
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<String> {
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<Op> {
let moves = self.file_moves(op);
Session::move_files(&moves)?;
match self.catalog.apply(op.clone()) {
Ok(inv) => Ok(inv),
Err(e) => {
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
let _ = Session::move_files(&back);
Err(e.into())
}
}
}
/// Ops applied since the last call, for the op-log store.
pub fn drain_log(&mut self) -> Vec<Op> {
std::mem::take(&mut self.pending_log)
+351
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to_path: Option<String>,
}
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<RenamePlan> {
// photos sharing one file (virtual copies) rename once
let mut seen_paths: HashSet<String> = HashSet::new();
let mut taken: HashSet<String> = 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<usize> {
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");
}
}
+93
View File
@@ -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"<x:xmpmeta xmlns:x='adobe:ns:meta/'></x:xmpmeta>").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<u64> = 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<u64> = 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"));
}
+19
View File
@@ -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<u64> = 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]);
+2
View File
@@ -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(),
}
}
+7 -1
View File
@@ -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<Re
app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() });
Ok(Value::Null)
}
"dialog.rename" => {
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<Re
pub fn ui_enabled(app: &LightcraftApp, id: &str) -> 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()
+38
View File
@@ -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<serde_jso
match dlg {
Dialog::NewAlbum { name, folder } => 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})),
+3
View File
@@ -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!({}));
}
+5
View File
@@ -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,
+3 -3
View File
@@ -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 |