diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 17f7a4219..84b46ccc9 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -61,7 +61,7 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec { pub fn specs() -> Vec { vec![ - cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {seq} {seq:N} {date} {date:%Y%m%d} {camera} {title} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| { + 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 { "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")?; diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f4c05cf9e..388e0b8be 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -434,8 +434,11 @@ pub struct ExportOptions { pub limit_kb: Option, 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 { 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] diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index e01e6569c..2b540c857 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -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"); + } } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index d45edc4cc..c5154ca4a 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -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 = 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 = 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); + } } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 81d9a8114..38f4704d8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -192,7 +192,8 @@ pub fn helper_tools(has_ui: bool) -> Vec { "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", diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 2a7843824..9c8c2fcfb 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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; diff --git a/docs/parity.md b/docs/parity.md index 171b5aa04..ff9be462d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 6cccbc102..b0ca3a00b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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.