mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
LR-EXP-NAMING: export file names use the Rename Photos tokens
rename::expand_tokens is shared by Rename Photos, import renaming and export naming, so {seq:N},
{date:%…}, {camera}, {title}, {ext} work in exports too (a bare {seq} still pads to 3 digits there).
New shared tokens: {num} (number at the end of the name), {folder}, {lens}, {iso}, {rating},
{creator}. An export name that expands to nothing keeps the original name. The MCP export tool
passes startNumber through and documents the tokens.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f5a4bb609d
commit
309922eac9
8 files changed
+133
-53
No files matched your search
@@ -61,7 +61,7 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||||
|
||||
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| {
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), 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())
|
||||
}),
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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",
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), 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")?;
|
||||
|
||||
+47
-32
@@ -434,8 +434,11 @@ pub struct ExportOptions {
|
||||
pub limit_kb: Option<u32>,
|
||||
pub sharpen: SharpenFor,
|
||||
pub sharpen_amount: SharpenAmount,
|
||||
/// File name template: `{name}` (original stem), `{seq}` (zero-padded to 3, counting from
|
||||
/// `start_number`), `{date}` (capture date, YYYYMMDD); the extension is appended.
|
||||
/// File name template with the batch-rename tokens ([`crate::rename::expand_tokens`]): `{name}`
|
||||
/// (original stem), `{seq}` (zero-padded to 3, counting from `start_number`; `{seq:N}` for N
|
||||
/// digits), `{date}` (capture date, YYYYMMDD; `{date:%Y-%m-%d}`…), `{num}`, `{folder}`,
|
||||
/// `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; the output
|
||||
/// extension is appended.
|
||||
pub naming: String,
|
||||
/// First `{seq}` value.
|
||||
pub start_number: u32,
|
||||
@@ -618,36 +621,23 @@ impl ExportOptions {
|
||||
if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space }
|
||||
}
|
||||
|
||||
/// Output file name for photo `stem` at 1-based position `seq` in a batch.
|
||||
pub fn file_name(&self, stem: &str, seq: usize) -> String {
|
||||
self.file_name_dated(stem, seq, None)
|
||||
}
|
||||
|
||||
/// [`ExportOptions::file_name`] with the capture time (ISO 8601) for `{date}`.
|
||||
pub fn file_name_dated(&self, stem: &str, seq: usize, captured: Option<&str>) -> String {
|
||||
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
|
||||
/// extension is kept for [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String {
|
||||
let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() };
|
||||
let n = seq + self.start_number.max(1) as usize - 1;
|
||||
let date: String = captured.map(|c| c.chars().take(10).filter(char::is_ascii_digit).collect()).unwrap_or_default();
|
||||
let name = base.replace("{name}", stem).replace("{seq}", &format!("{n:03}")).replace("{date}", &date);
|
||||
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
format!("{name}.{}", self.format.extension())
|
||||
}
|
||||
|
||||
/// Output file name for photo file `file_name` (the original's extension is kept for
|
||||
/// [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, file_name: &str, seq: usize) -> String {
|
||||
self.file_name_for_dated(file_name, seq, None)
|
||||
}
|
||||
|
||||
/// [`ExportOptions::file_name_for`] with the capture time for `{date}`.
|
||||
pub fn file_name_for_dated(&self, file_name: &str, seq: usize, captured: Option<&str>) -> String {
|
||||
let (stem, ext) = file_name.rsplit_once('.').unwrap_or((file_name, ""));
|
||||
let name = self.file_name_dated(stem, seq, captured);
|
||||
let name = crate::rename::expand_tokens(base, p, n, 3);
|
||||
let mut name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
let (stem, ext) = p.file_name.rsplit_once('.').unwrap_or((&p.file_name, ""));
|
||||
if name.trim().is_empty() {
|
||||
// every token came out empty (no title, no capture time…): keep the original name
|
||||
name = stem.to_string();
|
||||
}
|
||||
if self.format == ExportFormat::Original {
|
||||
let base = name.trim_end_matches('.');
|
||||
if ext.is_empty() { base.to_string() } else { format!("{base}.{ext}") }
|
||||
} else {
|
||||
name
|
||||
format!("{name}.{}", self.format.extension())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -962,7 +952,7 @@ pub fn prepare_export(
|
||||
seq: usize,
|
||||
) -> Result<PreparedExport, String> {
|
||||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||||
let file_name = o.file_name_for_dated(&p.file_name, seq, p.captured.as_deref());
|
||||
let file_name = o.file_name_for(p, seq);
|
||||
let work = if o.format.is_rendered() {
|
||||
let (w, h) = output_size(p, o);
|
||||
let meta = export_metadata(p, o);
|
||||
@@ -1249,12 +1239,35 @@ mod tests {
|
||||
assert_eq!(o.format, ExportFormat::Jpeg);
|
||||
assert_eq!(o.quality, 100);
|
||||
assert_eq!(o.resize, Some(Resize { dont_enlarge: true, height: 0, ..Resize::long_edge(2048) }));
|
||||
assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg");
|
||||
assert_eq!(o.file_name_for(&named("IMG/1.png"), 7), "IMG_1-007.jpg");
|
||||
assert_eq!(o.ppi, 240);
|
||||
let orig = ExportOptions { format: ExportFormat::Original, naming: "{name}-{seq}".into(), ..Default::default() };
|
||||
assert_eq!(orig.file_name_for("DSC_1.NEF", 2), "DSC_1-002.NEF");
|
||||
assert_eq!(orig.file_name_for("noext", 1), "noext-001");
|
||||
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for("a.cr2", 1), "a.dng");
|
||||
assert_eq!(orig.file_name_for(&named("DSC_1.NEF"), 2), "DSC_1-002.NEF");
|
||||
assert_eq!(orig.file_name_for(&named("noext"), 1), "noext-001");
|
||||
assert_eq!(ExportOptions { format: ExportFormat::Dng, ..Default::default() }.file_name_for(&named("a.cr2"), 1), "a.dng");
|
||||
}
|
||||
|
||||
fn named(file_name: &str) -> lightcraft_catalog::Photo {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
Photo::new(PhotoId(1), Source::Demo { scene: 0 }, file_name, "", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
/// Export naming takes the same tokens as Rename Photos and import renaming.
|
||||
#[test]
|
||||
fn naming_uses_the_rename_tokens() {
|
||||
let mut p = named("DSC_0815.NEF");
|
||||
p.captured = Some("2026-09-30T14:05:09".into());
|
||||
p.meta.camera = "Model X/2".into();
|
||||
p.meta.title = "Harbour".into();
|
||||
p.rating = 5;
|
||||
let o = |naming: &str| ExportOptions { naming: naming.into(), start_number: 9, ..Default::default() };
|
||||
assert_eq!(o("{date:%Y-%m-%d}_{title}_{seq:2}").file_name_for(&p, 1), "2026-09-30_Harbour_09.jpg");
|
||||
assert_eq!(o("{camera}-{num}-{rating}").file_name_for(&p, 1), "Model X_2-0815-5.jpg", "path separators become _");
|
||||
assert_eq!(o("{name}-{seq}").file_name_for(&p, 3), "DSC_0815-011.jpg", "a bare {{seq}} keeps its 3 digits");
|
||||
assert_eq!(o("{ext}_{name}").file_name_for(&p, 1), "NEF_DSC_0815.jpg");
|
||||
// nothing left after expanding: the original name
|
||||
p.meta.title.clear();
|
||||
assert_eq!(o("{title}").file_name_for(&p, 1), "DSC_0815.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1282,7 +1295,9 @@ mod tests {
|
||||
let full = ExportOptions::default();
|
||||
assert_eq!(ExportOptions::from_json(&full.to_json()), full);
|
||||
assert!(ExportOptions::has_size_param(&full.to_json()), "full size is explicit");
|
||||
assert_eq!(o.file_name_dated("IMG", 2, Some("2026-09-30T10:00:00")), "20260930-IMG-043.tif");
|
||||
let mut p = named("IMG.png");
|
||||
p.captured = Some("2026-09-30T10:00:00".into());
|
||||
assert_eq!(o.file_name_for(&p, 2), "20260930-IMG-043.tif");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+56
-13
@@ -1,10 +1,13 @@
|
||||
//! 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.
|
||||
//! Template tokens (shared with import renaming and export file naming): `{name}` (current name
|
||||
//! without extension), `{num}` (the number at the end of the name: `IMG_0042` → `0042`), `{seq}` /
|
||||
//! `{seq:3}` (sequence number, zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date;
|
||||
//! `%Y %y %m %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
|
||||
//! `{rating}`, `{title}`, `{creator}`, `{ext}` (extension, without the dot). Unknown tokens stay as
|
||||
//! typed. 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`…
|
||||
@@ -81,6 +84,21 @@ fn format_date(iso: &str, fmt: &str) -> String {
|
||||
|
||||
/// 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 out = expand_tokens(template, p, seq, 1);
|
||||
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}") }
|
||||
}
|
||||
|
||||
/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}`
|
||||
/// is zero-padded to `seq_width` digits.
|
||||
pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String {
|
||||
let (stem, ext) = split_ext(&p.file_name);
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
@@ -95,13 +113,27 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
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(),
|
||||
"num" => {
|
||||
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
|
||||
stem[stem.len() - digits..].to_string()
|
||||
}
|
||||
"seq" | "n" => {
|
||||
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(1).min(9);
|
||||
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
|
||||
format!("{seq:0w$}")
|
||||
}
|
||||
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
|
||||
"folder" => match &p.source {
|
||||
Source::File { path } => {
|
||||
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
|
||||
}
|
||||
Source::Demo { .. } => String::new(),
|
||||
},
|
||||
"camera" => p.meta.camera.clone(),
|
||||
"lens" => p.meta.lens.clone(),
|
||||
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
|
||||
"rating" => p.rating.to_string(),
|
||||
"title" => p.meta.title.clone(),
|
||||
"creator" => p.meta.creator.clone(),
|
||||
"ext" => ext.to_string(),
|
||||
_ => format!("{{{tok}}}"),
|
||||
};
|
||||
@@ -109,14 +141,7 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
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}") }
|
||||
out
|
||||
}
|
||||
|
||||
/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`).
|
||||
@@ -348,4 +373,22 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_folder_and_metadata_tokens() {
|
||||
let path = std::path::Path::new("shoots").join("2026-09 Coast").join("DSC_0815.NEF");
|
||||
let mut p = Photo::new(PhotoId(1), Source::File { path: path.to_string_lossy().into() }, "DSC_0815.NEF", "NEF", 1, 1, "2026-01-01T00:00:00");
|
||||
p.meta.lens = "24-70mm F2.8".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.meta.creator = "A. Person".into();
|
||||
p.rating = 4;
|
||||
assert_eq!(expand("{folder}_{num}", &p, 1), "2026-09 Coast_0815.NEF");
|
||||
assert_eq!(expand("{rating}star-{iso}-{lens}-{creator}", &p, 1), "4star-400-24-70mm F2.8-A. Person.NEF");
|
||||
// no number at the end of the name / no folder: empty
|
||||
p.file_name = "beach.NEF".into();
|
||||
p.source = Source::Demo { scene: 0 };
|
||||
assert_eq!(expand("{folder}{name}{num}", &p, 1), "beach.NEF");
|
||||
// a bare {seq} is padded to the caller's width
|
||||
assert_eq!(expand_tokens("{seq}|{seq:2}", &p, 7, 3), "007|07");
|
||||
}
|
||||
}
|
||||
@@ -210,4 +210,23 @@ mod tests {
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The export file-name template takes the Rename Photos tokens.
|
||||
#[test]
|
||||
fn export_naming_tokens() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-export-naming-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut h = Headless::demo();
|
||||
let ids: Vec<u64> = h.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
for (id, title) in ids.iter().zip(["Harbour", "Hills"]) {
|
||||
h.call("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [id], "title": title}})).unwrap();
|
||||
h.call("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 4}})).unwrap();
|
||||
}
|
||||
let params = json!({"dir": dir.to_string_lossy(), "ids": ids, "longEdge": 32, "format": "png", "naming": "{title}_{rating}star_{seq:2}", "startNumber": 5});
|
||||
let r = h.call("engine.execute", json!({"command": "app.export", "params": params})).unwrap();
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["Harbour_4star_05.png", "Hills_4star_06.png"], "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"},
|
||||
"sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]},
|
||||
"sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]},
|
||||
"naming": {"type": "string", "description": "e.g. {name}-{seq}"},
|
||||
"naming": {"type": "string", "description": "File-name template, e.g. {name}-{seq} or {date:%Y-%m-%d}_{title}_{seq:2} (tokens: {name} {num} {seq} {seq:N} {date} {date:%…} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext})"},
|
||||
"startNumber": {"type": "integer", "description": "First {seq} value (default 1)"},
|
||||
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
|
||||
"removeLocation": {"type": "boolean"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
@@ -547,6 +548,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
"sharpen",
|
||||
"sharpenAmount",
|
||||
"naming",
|
||||
"startNumber",
|
||||
"metadata",
|
||||
"removeLocation",
|
||||
"watermark",
|
||||
|
||||
@@ -305,7 +305,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
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…")
|
||||
egui::RichText::new("Tokens: {name} {num} {seq} {seq:3} {date} {date:%Y-%m-%d} {folder} {camera} {lens} {iso} {rating} {title} {creator}. 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();
|
||||
@@ -577,9 +577,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression);
|
||||
}
|
||||
field(ui, "File name", |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date}").desired_width(f32::INFINITY))
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(f32::INFINITY))
|
||||
});
|
||||
if opts.naming.contains("{seq}") {
|
||||
if opts.naming.contains("{seq") {
|
||||
let mut v = opts.start_number as f64;
|
||||
if num(ui, &START_NUMBER, &mut v) {
|
||||
opts.start_number = v as u32;
|
||||
|
||||
+2
-2
@@ -136,7 +136,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 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) |
|
||||
| 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-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming); 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 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” |
|
||||
| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) |
|
||||
| LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | |
|
||||
@@ -417,7 +417,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
|
||||
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
|
||||
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text or `image` + `imageWidth`), `crates/engine/src/export.rs` (`Watermark`) | text (size, colour, shadow) or a graphic with transparency (width as % of the photo, converted to the output colour space); position, inset, opacity |
|
||||
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`: `{name}`, `{seq}`, `{date}`; `startNumber`) | free-form template rather than a list of named schemes |
|
||||
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes |
|
||||
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker) | folder picker on the desktop; existing files get `-2`, `-3`… by default |
|
||||
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |
|
||||
| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output |
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- Colour-label filter with several labels at once; expandable folder tree in Local.
|
||||
- Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.
|
||||
- Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.
|
||||
- Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.
|
||||
|
||||
### Editing
|
||||
- Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.
|
||||
|
||||
Reference in new issue
Block a user