mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
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:
co-authored by
Claude Opus 5.5
parent
7fd21517ae
commit
53c4b295b9
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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"));
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})),
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user