diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index d8fad84b4..92a507073 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -735,7 +735,7 @@ pub fn specs() -> Vec { "Add Photos…", ["File"], Some("Cmd+Shift+I"), - "{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}", + "{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}", always, |s, p| { let paths = strs(p, "paths"); diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 84b46ccc9..8b9ee8a24 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -61,7 +61,10 @@ 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} {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| { + cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| { + Ok(crate::rename::token_help_json()) + }), + 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}; photo.renameTokens explains each), 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 +73,7 @@ pub fn specs() -> Vec { "Rename Photos", [], 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", + "{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}; photo.renameTokens explains each), 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/rename.rs b/crates/engine/src/rename.rs index 2b540c857..0c077d27d 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -82,6 +82,113 @@ fn format_date(iso: &str, fmt: &str) -> String { out } +/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and +/// command description shows; a test checks that [`expand_tokens`] knows each of them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct TokenHelp { + /// The token as typed, e.g. `{seq:3}`. + pub tag: &'static str, + /// Other spellings with the same meaning. + pub aliases: &'static [&'static str], + pub meaning: &'static str, +} + +/// Every template token, in the order the help shows them. +pub const TOKENS: &[TokenHelp] = &[ + TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" }, + TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" }, + TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" }, + TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" }, + TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" }, + TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" }, + TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" }, + TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" }, + TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" }, + TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" }, + TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" }, + TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" }, + TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" }, + TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" }, +]; + +/// The `%` directives of `{date:…}`. +pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ + ("%Y", "year, 4 digits"), + ("%y", "year, 2 digits"), + ("%m", "month 01–12"), + ("%d", "day 01–31"), + ("%H", "hour 00–23"), + ("%M", "minute"), + ("%S", "second"), + ("%%", "a literal %"), +]; + +/// How templates behave, one sentence each (shown under the token list). +pub const TEMPLATE_NOTES: &[&str] = &[ + "The original extension is always added; {ext} only puts it inside the name as well.", + "A blank template keeps the original names.", + "{date} is the capture time; a photo without one uses the time it was imported.", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.", + "Unknown tags stay as typed — check the preview for a {typo}.", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….", +]; + +/// The tokens as one line (`{name} {num} {seq} …`), for compact hints. +pub fn token_summary() -> String { + TOKENS.iter().map(|t| t.tag).collect::>().join(" ") +} + +/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48). +pub fn sample_photo() -> Photo { + let mut p = + Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00"); + p.captured = Some("2026-01-14T05:58:48".into()); + p.rating = 4; + p.meta.camera = "Canon EOS R5".into(); + p.meta.lens = "RF24-70mm F2.8".into(); + p.meta.iso = Some(400); + p.meta.title = "Harbour".into(); + p.meta.creator = "Ann Lee".into(); + p +} + +/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column. +pub fn token_example(tag: &str) -> String { + expand_tokens(tag, &sample_photo(), 1, 1) +} + +/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning. +pub fn unknown_tokens(template: &str) -> Vec { + let p = sample_photo(); + let mut out = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let Some(j) = rest[i..].find('}') else { break }; + let tag = &rest[i..i + j + 1]; + if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) { + out.push(tag.to_string()); + } + rest = &rest[i + j + 1..]; + } + out +} + +/// Every token with its meaning and example, the date directives and the notes, as JSON (the +/// `photo.renameTokens` command). +pub fn token_help_json() -> serde_json::Value { + let tokens: Vec = TOKENS + .iter() + .map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)})) + .collect(); + let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect(); + serde_json::json!({ + "tokens": tokens, + "dateDirectives": directives, + "notes": TEMPLATE_NOTES, + "sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48", + }) +} + /// 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); @@ -358,6 +465,41 @@ impl Session { mod tests { use super::*; + /// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is + /// replaced, never left literal, and the help's examples match the documented behaviour. + #[test] + fn token_help_matches_the_implementation() { + let p = sample_photo(); + for t in TOKENS { + for tag in std::iter::once(&t.tag).chain(t.aliases) { + let v = expand_tokens(tag, &p, 1, 1); + assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})"); + assert!(!v.is_empty(), "{tag}: the sample photo should give an example"); + } + } + assert_eq!(token_example("{seq:3}"), "001"); + assert_eq!(token_example("{date}"), "20260114"); + assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848"); + assert_eq!(token_example("{ext}"), "CR3"); + assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3"); + for (d, _) in DATE_DIRECTIVES { + let v = format_date("2026-01-14T05:58:48", d); + assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive"); + } + assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]); + assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty()); + let json = token_help_json(); + assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len()); + assert_eq!(json["tokens"][0]["example"], "IMG_0042"); + // the command descriptions that list the tokens list all of them + for id in ["photo.rename", "photo.renamePreview", "library.import"] { + let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); + for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) { + assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag); + } + } + } + #[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"); diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 69c0a1f86..72df4eaf7 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1259,6 +1259,52 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one + /// inserts it at the text cursor (not appended, not replacing the template). + #[test] + fn import_rename_tags_insert_at_the_cursor() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-tags-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join("IMG_0007.png"), png).unwrap(); + let lib = dir.join("lib"); + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + session.open_library(&lib, false).unwrap(); + let mut app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 1000.0], 1.0); + let t = Duration::from_secs(10); + let r = + h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog { + opts.rename = "Trip-_x".into(); + } + // the cursor sits after "Trip-" + let id = egui::Id::new("import-rename"); + let mut st = egui::text_edit::TextEditState::default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5)))); + st.store(&h.view.ctx, id); + let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTags"}), t); + assert_eq!(r["ok"], true, "{r}"); + let seq3 = lightcraft_engine::rename::TOKENS.iter().position(|x| x.tag == "{seq:3}").unwrap(); + let r = h.request("ui.clickWidget", json!({"id": format!("button:importRenameTag-{seq3}")}), t); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; + assert_eq!(opts.rename, "Trip-{seq:3}_x", "inserted at the cursor"); + // a second tag goes after the first (the cursor moved past it) + let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTag-0"}), t); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") }; + assert_eq!(opts.rename, "Trip-{seq:3}{name}_x"); + let _ = std::fs::remove_dir_all(&dir); + } + /// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through /// the engine; the delete confirmation guards ⌫. #[test] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 719b235a6..58f6b3024 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -449,26 +449,39 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let r = ui.checkbox(&mut d.dng, "Copy as DNG"); register(ui.ctx(), "check:importDng", r.rect); }); - field(ui, "Rename", |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.rename).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(f32::INFINITY)); + let rename_id = egui::Id::new("import-rename"); + let tags_open = field(ui, "Rename", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = (ui.available_width() - 50.0).max(80.0); + let r = ui.add(egui::TextEdit::singleline(&mut d.rename).id(rename_id).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(w)); register(ui.ctx(), "field:importRename", r.rect); + tag_toggle(ui, "importRename") }); + if tags_open { + tag_help(ui, "importRename", &mut d.rename, rename_id); + } if !d.rename.trim().is_empty() && let Some(c) = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none()).map(|(c, _)| c) { let mut q = lightcraft_catalog::Photo::new( lightcraft_catalog::PhotoId(0), - lightcraft_catalog::Source::Demo { scene: 0 }, + lightcraft_catalog::Source::File { path: c.path.clone() }, &c.name, &c.format, 0, 0, - "", + // a photo without a capture time is dated by the import (now) + &(app.session.clock)(), ); q.captured = c.captured.clone(); + // the probe's metadata, so {camera}, {title}… preview as they will import + if let Some(info) = app.session.import_probes.get(&c.path) { + q.meta = info.meta.clone(); + } let example = lightcraft_engine::rename::expand(d.rename.trim(), &q, 1); ui.label(egui::RichText::new(format!("{} → {example}", c.name)).color(Tokens::get(ui.ctx()).text_dim)); } + unknown_tags_warning(ui, &d.rename); } let albums: Vec<(u64, String)> = { let mut v: Vec<(u64, String)> = @@ -602,6 +615,85 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial } } +/// The "Tags" toggle beside a template field (`button:Tags`); returns whether the tag help +/// ([`tag_help`]) is open. +pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool { + let id = egui::Id::new((key, "tags-open")); + let mut open = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or(false); + let r = crate::widgets::text_button(ui, &format!("{key}Tags"), "Tags", open).on_hover_text("Show the template tags; click one to insert it"); + if r.clicked() { + open = !open; + ui.ctx().data_mut(|d| d.insert_temp(id, open)); + } + open +} + +/// "Unknown tag {x} stays as typed" under a template field, when it has one. +pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) { + let unknown = lightcraft_engine::rename::unknown_tokens(template); + if !unknown.is_empty() { + let s = if unknown.len() == 1 { "" } else { "s" }; + let text = format!("Unknown tag{s} {} stay{} as typed (see Tags)", unknown.join(" "), if unknown.len() == 1 { "s" } else { "" }); + ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution)); + } +} + +/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection); +/// appended when the field never had a cursor. The cursor ends up after the tag. +pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) { + use egui::text::{CCursor, CCursorRange}; + let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default(); + let n = text.chars().count(); + let (a, b) = state.cursor.char_range().map_or((n, n), |r| { + let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n)); + (x.min(y), x.max(y)) + }); + let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); + let (ba, bb) = (byte(a), byte(b)); + text.replace_range(ba..bb, tag); + state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count())))); + state.store(ctx, edit_id); + ctx.memory_mut(|m| m.request_focus(edit_id)); +} + +/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its +/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag +/// (`button:Tag-`) inserts it at the cursor of the field `edit_id`. +pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) { + use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example}; + let t = Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| { + ui.label( + egui::RichText::new("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.").color(t.text_dim).small(), + ); + egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| { + egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| { + for (i, tok) in TOKENS.iter().enumerate() { + let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( + if tok.aliases.is_empty() { + "Insert at the cursor".to_string() + } else { + format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) + }, + ); + register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect); + if r.clicked() { + insert_at_cursor(ui.ctx(), edit_id, text, tok.tag); + } + ui.label(egui::RichText::new(tok.meaning).color(t.text_label)); + ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim)); + ui.end_row(); + } + }); + let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {m}")).collect(); + ui.label(egui::RichText::new(format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small()); + for n in TEMPLATE_NOTES { + ui.label(egui::RichText::new(format!("• {n}")).color(t.text_dim).small()); + } + }); + }); +} + /// A labelled row (fixed label column). fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 24153a8b1..1cb732f4f 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -289,10 +289,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { 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)); + let template_id = egui::Id::new("rename-template"); + let tags_open = field(ui, "Template", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = (ui.available_width() - 50.0).max(80.0); + let r = ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)); crate::widgets::register(ui.ctx(), "field:renameTemplate", r.rect); + crate::import::tag_toggle(ui, "renameTemplate") }); + if tags_open { + crate::import::tag_help(ui, "renameTemplate", template, template_id); + } + crate::import::unknown_tags_warning(ui, template); field(ui, "Presets", |ui| { ui.spacing_mut().item_spacing.x = 4.0; for (i, (label, tpl)) in @@ -305,7 +313,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} {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…") + egui::RichText::new("Tags: see Tags beside the template. 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(); @@ -576,9 +584,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { use lightcraft_engine::export::DngCompression as Z; 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} · {title} · {folder}").desired_width(f32::INFINITY)) + let naming_id = egui::Id::new("export-naming"); + let tags_open = field(ui, "File name", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = (ui.available_width() - 50.0).max(80.0); + ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)); + crate::import::tag_toggle(ui, "exportNaming") }); + if tags_open { + crate::import::tag_help(ui, "exportNaming", &mut opts.naming, naming_id); + } + crate::import::unknown_tags_warning(ui, &opts.naming); if opts.naming.contains("{seq") { let mut v = opts.start_number as f64; if num(ui, &START_NUMBER, &mut v) { diff --git a/docs/parity.md b/docs/parity.md index 513bb69eb..17bc02889 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -137,7 +137,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} {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-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; 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` | | @@ -687,7 +687,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder; rename template with live example, numbered across the import); develop preset, metadata preset, keywords, album on import | +| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import | | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | | LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view |