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:
commit
42c19ddd1a
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue