Merge pull request #72 from storytold/fix/issue-32-folder-template

Issue #32: Import: custom date-based folder template for Copy
This commit is contained in:
Brandon Thomas 2026-10-04 14:00:34 -04:00 committed by GitHub
commit 42c19ddd1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 344 additions and 58 deletions

View File

@ -735,7 +735,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, 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?}",
"{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 (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, 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");
@ -760,11 +760,19 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("library.import", "album must be a regular album"));
}
let organize = match str_param(p, "organize") {
Some(o) => {
crate::import::Organize::parse(o).ok_or_else(|| bad("library.import", format!("unknown organize `{o}` (date|month|flat)")))?
}
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
bad(
"library.import",
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
)
})?,
None => Default::default(),
};
if let crate::import::Organize::Template(t) = &organize
&& let Some(e) = crate::rename::folder_template_error(t)
{
return Err(bad("library.import", e));
}
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &metadata_preset
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))

View File

@ -67,8 +67,9 @@ pub struct ImportOptions {
pub convert_dng: bool,
}
/// How copies are filed in the destination.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// How copies are filed in the destination. The date is the capture time, else the time of the
/// import.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Organize {
/// `YYYY/YYYY-MM-DD/` by capture date.
#[default]
@ -77,17 +78,36 @@ pub enum Organize {
ByMonth,
/// All in the destination itself.
Flat,
/// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own
/// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`];
/// never outside the destination).
Template(String),
}
impl Organize {
/// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`).
pub fn parse(s: &str) -> Option<Organize> {
match s {
"date" | "day" | "byDay" => Some(Organize::ByDay),
"month" | "byMonth" => Some(Organize::ByMonth),
"flat" | "none" | "intoOneFolder" => Some(Organize::Flat),
t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())),
_ => None,
}
}
/// The folders (inside the destination) a copy of `p` goes to.
pub fn folders(&self, p: &Photo) -> Vec<String> {
let date = p.date();
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
let year = day.get(..4).unwrap_or("undated");
match self {
Organize::ByDay => vec![year.into(), day.into()],
Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()],
Organize::Flat => Vec::new(),
Organize::Template(t) => crate::rename::expand_folder(t, p, 1),
}
}
}
/// A file found by [`scan`], for the import review.
@ -323,16 +343,10 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
.collect()
}
/// Copy `src` into `root` (filed per `organize` by `date`) as `name` (default: its own name),
/// made unique with -1, -2…; returns the new path.
fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Option<&str>) -> Result<String, String> {
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
let year = day.get(..4).unwrap_or("undated");
let dir = match organize {
Organize::ByDay => root.join(year).join(day),
Organize::ByMonth => root.join(year).join(day.get(..7).unwrap_or("undated")),
Organize::Flat => root.to_path_buf(),
};
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
/// returns the new path.
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result<String, String> {
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = name
.map(str::to_string)
@ -575,17 +589,17 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
(ImportMode::Copy, Some(root))
if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) =>
{
// the templates see the photo as it will be catalogued (undated: the import time)
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
// the template sees the photo as it will be catalogued
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let n = crate::rename::expand(t, &q, seq);
seq += 1;
n
});
match copy_into(root, &path, info.captured.as_deref().unwrap_or(&now), opts.organize, name.as_deref()) {
match copy_into(root, &path, &opts.organize.folders(&q), name.as_deref()) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));

View File

@ -157,6 +157,41 @@ pub fn token_example(tag: &str) -> String {
expand_tokens(tag, &sample_photo(), 1, 1)
}
/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be
/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels.
pub fn folder_template_error(template: &str) -> Option<String> {
let t = template.trim();
if t.is_empty() {
return Some("the folder template is empty".into());
}
let b = t.as_bytes();
if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') {
return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into());
}
if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) {
return Some("the folder template may not contain . or .. folders".into());
}
None
}
/// The folders (relative to the destination) a folder template gives `p`: the template's own `/`
/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder
/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and
/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty
/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s
/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped.
pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec<String> {
template
.split(['/', '\\'])
.map(str::trim)
.filter(|s| !s.is_empty() && !matches!(*s, "." | ".."))
.map(|s| {
let v = sanitize(&expand_tokens(s, p, seq, 1));
if v.is_empty() { "unknown".to_string() } else { v }
})
.collect()
}
/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning.
pub fn unknown_tokens(template: &str) -> Vec<String> {
let p = sample_photo();
@ -465,6 +500,31 @@ impl Session {
mod tests {
use super::*;
#[test]
fn folder_templates_stay_inside_the_destination() {
let mut p = sample_photo();
assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]);
assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too");
// metadata can't add levels or climb out
p.meta.camera = "../../etc/x".into();
p.meta.title = "..".into();
assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]);
p.meta.camera = "C:\\Windows".into();
assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]);
// missing metadata → `unknown`; missing capture time → the import time
p.meta.camera.clear();
p.captured = None;
assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]);
for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] {
assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected");
}
for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] {
assert_eq!(folder_template_error(ok), None, "{ok:?}");
}
// even unchecked, rejected levels are skipped rather than followed
assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]);
}
/// 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]

View File

@ -257,6 +257,37 @@ fn copy_with_destination_organize_rename_and_metadata_preset() {
}
}
/// Copy with a custom folder template: `{date:%Y}/{date:%Y%m%d}` files a photo under
/// `2026/20260114/` (an undated file by the import time); templates that would leave the
/// destination are refused.
#[test]
fn copy_with_a_folder_template() {
let src = temp_dir("tplsrc");
let dest = temp_dir("tpldest");
write_png(&src.join("a.png"), 1);
let mut s = Session::new().with_fs();
s.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
for bad in ["../{date}", "/tmp/{date}", "{date:%Y}/../x", "C:/x"] {
let r = s.execute(
"library.import",
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": bad}),
);
assert!(r.is_err(), "{bad} should be refused");
}
assert_eq!(s.catalog.len(), 0);
let r = s
.execute(
"library.import",
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"}),
)
.unwrap();
assert_eq!(ids(&r, "imported"), 1, "{r}");
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
for d in [&src, &dest] {
let _ = std::fs::remove_dir_all(d);
}
}
/// Auto import: files in the watched folder are added once their size held between two scans,
/// into the named album; non-photos are tried once; the selection stays put.
#[test]

View File

@ -72,6 +72,26 @@ fn tools_list_has_helpers_and_every_command() {
assert!(r["result"]["tools"].as_array().unwrap().iter().all(|t| !t["name"].as_str().unwrap().starts_with("cmd_")));
}
/// The `import` helper passes the copy options through: a folder template files the copy.
#[test]
fn import_tool_copies_with_a_folder_template() {
let base = std::env::temp_dir().join(format!("lc-mcp-import-tpl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("a.png"), png).unwrap();
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args =
json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"});
let r = call_tool(&mut b, "import", &args);
assert!(!r.is_error, "{r:?}");
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn command_tools_run_commands() {
let mut b = Headless::demo();

View File

@ -96,7 +96,14 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"import",
"Import photos",
"Add image/raw files to the library. Folders are scanned recursively for photos; relative paths resolve against the server's working directory. The first imported photo becomes active. Returns the new photo ids.",
json!({"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, "album": {"type": "integer", "description": "Also add to this album id"}}),
json!({
"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"},
"album": {"type": "integer", "description": "Also add to this album id"},
"mode": {"type": "string", "enum": ["add", "copy"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/)"},
"destination": {"type": "string", "description": "Copy: destination folder"},
"organize": {"type": "string", "description": "Copy: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"},
"rename": {"type": "string", "description": "Copy: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"}
}),
&["paths"],
),
tool(
@ -454,8 +461,10 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
return ToolResult::error(format!("no photos found in {paths:?}"));
}
let mut p = json!({"paths": files});
if let Some(a) = args.get("album") {
p["album"] = a.clone();
for k in ["album", "mode", "destination", "organize", "rename"] {
if let Some(v) = args.get(k).filter(|v| !v.is_null()) {
p[k] = v.clone();
}
}
ToolResult::from(exec(b, "library.import", p).map(|mut r| {
if let Some(o) = r.as_object_mut() {

View File

@ -268,7 +268,14 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
}
}
"ui.dialog.confirm" => match app.ui.dialog.take() {
Some(d) => wrap(crate::panels::dialogs::confirm_dialog(app, &d)),
Some(d) => {
let r = crate::panels::dialogs::confirm_dialog(app, &d);
// the import review stays open on an error, as with its button
if r.is_err() && matches!(d, crate::state::Dialog::Import { .. }) {
app.ui.dialog = Some(d);
}
wrap(r)
}
None => err("no dialog open"),
},
"ui.dialog.cancel" => {

View File

@ -1259,6 +1259,59 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
/// Import review, Copy, Folders → Custom template…: the default `{date:%Y}/{date:%Y%m%d}`
/// files copies under `2026/20260114/`, the example line shows the full destination, and a
/// template that would climb out of the destination is refused.
#[test]
fn import_copy_into_a_custom_folder_template() {
let base = std::env::temp_dir().join(format!("lc-ui-import-tpl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("IMG_01.png"), png).unwrap();
let dest_s = dest.to_string_lossy().to_string();
let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() };
let mut session = lightcraft_engine::Session::with_demo().with_fs();
// undated files are filed by the import time
session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let mut app = LightcraftApp::new(session, services);
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": [src.to_string_lossy()]}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
for id in ["button:importCopy", "button:importDest"] {
let r = h.request("ui.clickWidget", json!({"id": id}), t);
assert_eq!(r["ok"], true, "{id}: {r}");
}
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
opts.organize = "custom".into();
opts.folder_template = "../{date:%Y}".into();
}
h.step();
// refused: the dialog stays open, nothing is imported
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_ne!(r["ok"], true, "{r}");
assert!(h.app.import.is_none());
let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("dialog closed") };
opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into();
let opts = opts.clone();
let sep = std::path::MAIN_SEPARATOR;
let example = crate::import::example_destination(&h.app, &opts).expect("example");
assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}IMG_01.png", dest.to_string_lossy()));
h.step();
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importExample"), "example shown");
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(dest.join("2026").join("20260114").join("IMG_01.png").is_file());
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]

View File

@ -1,6 +1,7 @@
//! The import review dialog (File → Add Photos…): the files found under the chosen files and
//! folders as a grid of thumbnails with checkboxes (duplicates marked and unchecked), the
//! destination (add in place / copy into the library's dated `Originals/` folders), an album
//! destination (add in place / copy into the library's `Originals/` or a chosen folder, filed by day, by month,
//! into one folder or by a custom folder template, optionally renamed), an album
//! (existing or new), a preset and keywords to apply. Importing runs in small batches, one per
//! frame, with a progress window; the whole import is one undo step.
@ -35,8 +36,10 @@ pub struct ImportDialog {
pub keywords: String,
/// Copy: destination folder ("" = the library's Originals/).
pub destination: String,
/// Copy: `date` (YYYY/YYYY-MM-DD), `month` or `flat`.
/// Copy: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or `custom` ([`ImportDialog::folder_template`]).
pub organize: String,
/// Copy, `custom`: the folder template, e.g. `{date:%Y}/{date:%Y%m%d}`.
pub folder_template: String,
/// Copy: file-name template for the copies ("" = keep the names).
pub rename: String,
/// Metadata preset name ("" = none).
@ -61,6 +64,22 @@ impl ImportDialog {
.map(|(c, _)| c.path.clone())
.collect()
}
/// The `organize` param of `library.import` (`None` = the default, by day); an unusable
/// custom folder template is an error.
pub fn organize_param(&self) -> Result<Option<String>, String> {
match self.organize.as_str() {
"" => Ok(None),
"custom" => {
let t = self.folder_template.trim();
if let Some(e) = lightcraft_engine::rename::folder_template_error(t) {
return Err(e);
}
// a plain folder name ("Imports") is a one-level template too
Ok(Some(if t.contains(['{', '/', '\\']) { t.to_string() } else { format!("{t}/") }))
}
o => Ok(Some(o.to_string())),
}
}
fn keywords(&self) -> Vec<String> {
self.keywords.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_string).collect()
}
@ -277,8 +296,8 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
if !d.destination.trim().is_empty() {
params["destination"] = json!(d.destination.trim());
}
if !d.organize.is_empty() {
params["organize"] = json!(d.organize);
if let Some(o) = d.organize_param()? {
params["organize"] = json!(o);
}
if !d.rename.trim().is_empty() {
params["rename"] = json!(d.rename.trim());
@ -434,17 +453,53 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
field(ui, "Folders", |ui| {
let opts = [("date", "By day (YYYY/YYYY-MM-DD)"), ("month", "By month (YYYY/YYYY-MM)"), ("flat", "Into one folder")];
let opts = [
("date", "By day (YYYY/YYYY-MM-DD)"),
("month", "By month (YYYY/YYYY-MM)"),
("flat", "Into one folder"),
("custom", "Custom template…"),
];
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
for (k, label) in opts {
if ui.selectable_label(key == k, label).clicked() {
let r = ui.selectable_label(key == k, label);
register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect);
if r.clicked() {
d.organize = k.to_string();
if k == "custom" && d.folder_template.trim().is_empty() {
d.folder_template = DEFAULT_FOLDER_TEMPLATE.into();
}
}
}
});
register(ui.ctx(), "combo:importOrganize", combo.response.rect);
});
if d.organize == "custom" {
let folders_id = egui::Id::new("import-folder-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(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w));
register(ui.ctx(), "field:importFolderTemplate", r.rect);
tag_toggle(ui, "importFolders")
});
if tags_open {
tag_help(ui, "importFolders", &mut d.folder_template, folders_id);
}
let t = Tokens::get(ui.ctx());
match lightcraft_engine::rename::folder_template_error(&d.folder_template) {
Some(e) => {
ui.label(egui::RichText::new(e).color(t.caution));
}
None => unknown_tags_warning(ui, &d.folder_template),
}
ui.label(
egui::RichText::new("Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").")
.color(t.text_dim)
.small(),
);
}
field(ui, "Raw files", |ui| {
let r = ui.checkbox(&mut d.dng, "Copy as DNG");
register(ui.ctx(), "check:importDng", r.rect);
@ -460,28 +515,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
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::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);
if let Some(example) = example_destination(app, d) {
let t = Tokens::get(ui.ctx());
let r = ui.label(egui::RichText::new(format!("Example: {example}")).color(t.text_dim));
register(ui.ctx(), "label:importExample", r.rect);
ui.label(
egui::RichText::new("Folders and {date} use the capture time; a photo without one uses today's date.").color(t.text_dim).small(),
);
}
}
let albums: Vec<(u64, String)> = {
let mut v: Vec<(u64, String)> =
@ -694,6 +736,45 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id:
});
}
/// The folder template the Custom choice starts with (`2026/20260114/`).
pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}";
/// Where the first selected photo would be copied to (destination, folders, name), for the
/// dialog's example line; `None` without a photo or with an unusable folder template.
pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<String> {
let c = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none() && c.error.is_none()).map(|(c, _)| c)?;
let organize = match d.organize_param().ok()? {
Some(o) => lightcraft_engine::import::Organize::parse(&o)?,
None => Default::default(),
};
let mut q = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(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 name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) };
let root = if d.destination.trim().is_empty() {
app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string())
} else {
d.destination.trim().trim_end_matches(['/', '\\']).to_string()
};
let sep = std::path::MAIN_SEPARATOR_STR;
let mut parts = vec![root];
parts.extend(organize.folders(&q));
parts.push(name);
Some(parts.join(sep))
}
/// A labelled row (fixed label column).
fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let t = Tokens::get(ui.ctx());

View File

@ -745,8 +745,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
close = true;
}
if confirm {
let _ = confirm_dialog(app, &dlg);
close = true;
match confirm_dialog(app, &dlg) {
// the import review stays open on an error (e.g. an unusable folder template)
Err(e) if matches!(dlg, Dialog::Import { .. }) => app.toast(ctx, e),
_ => close = true,
}
}
app.ui.dialog = if close { None } else { Some(dlg) };
}

View File

@ -76,7 +76,7 @@ During development you can also point the client at `cargo run --release -p ligh
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now |
| `run_command {command, params?}` | Run any command by id |
| `import {paths, album?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active |
| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) |
| `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) |
| `select_photos {ids, active?, mode?}` | Set the selection / active photo |
| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value |

View File

@ -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, Tags picker (insert at cursor) and unknown-tag warning, 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 / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114, kept inside the destination; example destination shown; 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 |