From 86c4048ad6e8f998cbe8a5f0b5796a558ef6fab5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 21:08:33 -0400 Subject: [PATCH] LRC-LIB-IMPORT: copy to any folder, organise by day / month / flat, rename on import, metadata preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit library.import gains destination (copies go there instead of the library's Originals/; no library needed), organize (date | month | flat), rename (file-name template with the rename tokens, numbered from renameStart; copies are catalogued under their new names) and metadataPreset. The Add Photos dialog: Copy → Copy to (Choose… / Library), Folders, Rename with a live example, Metadata preset; batches keep the numbering going. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 26 +++++++- crates/engine/src/import.rs | 84 ++++++++++++++++++++++---- crates/engine/src/tests_import.rs | 45 ++++++++++++++ crates/ui-egui/src/headless.rs | 45 ++++++++++++++ crates/ui-egui/src/import.rs | 98 +++++++++++++++++++++++++++++-- docs/parity.md | 8 +-- 6 files changed, 286 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index d267f79d2..6d1a2dc32 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -553,7 +553,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/), 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 ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}", always, |s, p| { let paths = strs(p, "paths"); @@ -577,7 +577,29 @@ pub fn specs() -> Vec { { return Err(bad("library.import", "album must be a regular album")); } - let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() }; + 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)")))? + } + None => Default::default(), + }; + 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)) + { + return Err(bad("library.import", format!("unknown metadata preset `{n}`"))); + } + let opts = crate::import::ImportOptions { + mode, + preset, + keywords: strs(p, "keywords"), + destination: str_param(p, "destination").map(str::to_string), + organize, + rename: str_param(p, "rename").map(str::to_string), + rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize, + metadata_preset, + ..Default::default() + }; let undo0 = s.undo.len(); let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default(); let imported: Vec = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 6a0997eba..ec23f8417 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -52,6 +52,39 @@ pub struct ImportOptions { /// Browsing a folder: photos come in as `local` (not in the library), and a file with the /// same content as one already known is still listed. pub local: bool, + /// Copy: where the copies go (default: the library's `Originals/`). + pub destination: Option, + /// Copy: the folders inside the destination. + pub organize: Organize, + /// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`, + /// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`. + pub rename: Option, + pub rename_start: usize, + /// A metadata preset (by name) applied to every imported photo. + pub metadata_preset: Option, +} + +/// How copies are filed in the destination. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Organize { + /// `YYYY/YYYY-MM-DD/` by capture date. + #[default] + ByDay, + /// `YYYY/YYYY-MM/`. + ByMonth, + /// All in the destination itself. + Flat, +} + +impl Organize { + pub fn parse(s: &str) -> Option { + match s { + "date" | "day" | "byDay" => Some(Organize::ByDay), + "month" | "byMonth" => Some(Organize::ByMonth), + "flat" | "none" | "intoOneFolder" => Some(Organize::Flat), + _ => None, + } + } } /// A file found by [`scan`], for the import review. @@ -257,13 +290,20 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec> { paths.iter().map(|p| probe(p)).collect() } -/// `Originals/YYYY/YYYY-MM-DD/name`, made unique. -fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result { +/// 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 { let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated"); let year = day.get(..4).unwrap_or("undated"); - let dir = lib.join("Originals").join(year).join(day); + 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(), + }; std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let name = Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()); + let name = name + .map(str::to_string) + .unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into())); let (stem, ext) = match name.rsplit_once('.') { Some((s, e)) => (s.to_string(), format!(".{e}")), None => (name.clone(), String::new()), @@ -362,9 +402,17 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c // added file in its own storage already, so "copy" there means "add". let mode = if mode == ImportMode::Copy && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode }; let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - if mode == ImportMode::Copy && lib_dir.is_none() { - return Err(crate::EngineError::Other("copying into the library needs an open library".into())); + // where copies go: the chosen folder, else the library's Originals/ + let copy_root = opts + .destination + .as_deref() + .filter(|d| !d.trim().is_empty()) + .map(std::path::PathBuf::from) + .or_else(|| lib_dir.as_ref().map(|l| l.join("Originals"))); + if mode == ImportMode::Copy && copy_root.is_none() { + return Err(crate::EngineError::Other("copying needs a destination folder or an open library".into())); } + let mut seq = opts.rename_start.max(1); let files = expand(paths, lib_dir.as_deref()); let mut report = ImportReport { scanned: files.len(), ..Default::default() }; @@ -413,9 +461,21 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" }); continue; } - let stored = match (mode, &lib_dir) { - (ImportMode::Copy, Some(lib)) if !Path::new(&path).starts_with(lib) => { - match copy_into_library(lib, &path, info.captured.as_deref().unwrap_or(&now)) { + let stored = match (mode, ©_root) { + (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)) => + { + 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()) { Ok(p) => p, Err(e) => { report.failed.push((path, e)); @@ -429,7 +489,8 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c if let Some(h) = &info.content_hash { by_hash.insert(h.clone(), id); } - let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); + // a copy is catalogued under its new name + let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now); p.kind = info.kind; p.file_size = info.file_size; @@ -453,6 +514,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c Err(e) => log::warn!("import {path}: XMP: {e}"), } } + if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { + crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); + } for k in &opts.keywords { let k = lightcraft_catalog::keywords::clean(k); if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) { diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 46e8cbebd..b318106c2 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -211,3 +211,48 @@ fn recently_added_covers_recent_imports_newest_first() { assert_eq!(names, ["new.png", "mid.png"], "the last 30 days, newest import first"); let _ = std::fs::remove_dir_all(&dir); } + +/// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in +/// import order), and a metadata preset on every photo. +#[test] +fn copy_with_destination_organize_rename_and_metadata_preset() { + let src = temp_dir("orgsrc"); + let dest = temp_dir("orgdest"); + write_png(&src.join("a.png"), 1); + write_png(&src.join("b.png"), 2); + let mut s = Session::new().with_fs(); + s.execute("metadata.savePreset", &json!({"name": "Studio", "fields": {"copyright": "© Studio", "creator": "Sam"}})).unwrap(); + assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "metadataPreset": "Nope"})).is_err()); + assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy", "organize": "weekly"})).is_err()); + let r = s + .execute( + "library.import", + &json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat", + "rename": "Shoot-{seq:3}", "renameStart": 7, "metadataPreset": "Studio"}), + ) + .unwrap(); + assert_eq!(ids(&r, "imported"), 2, "{r}"); + let mut names: Vec = s.catalog.photos().map(|p| p.file_name.clone()).collect(); + names.sort(); + assert_eq!(names, ["Shoot-007.png", "Shoot-008.png"], "catalogued under the new names"); + assert!(dest.join("Shoot-007.png").exists() && dest.join("Shoot-008.png").exists(), "flat: straight into the destination"); + assert!(s.catalog.photos().all(|p| p.meta.copyright == "© Studio" && p.meta.creator == "Sam")); + // by month, no library needed when a destination is given + let dest2 = temp_dir("orgdest2"); + write_png(&src.join("c.png"), 3); + let r = s + .execute( + "library.import", + &json!({"paths": [src.join("c.png").to_string_lossy()], "mode": "copy", "destination": dest2.to_string_lossy(), "organize": "month"}), + ) + .unwrap(); + assert_eq!(ids(&r, "imported"), 1, "{r}"); + let p = s.catalog.photos().find(|p| p.file_name == "c.png").unwrap(); + let lightcraft_catalog::Source::File { path } = &p.source else { panic!() }; + let rel = Path::new(path).strip_prefix(&dest2).unwrap(); + assert_eq!(rel.components().count(), 3, "YYYY/YYYY-MM/c.png: {rel:?}"); + assert_eq!(rel.parent().unwrap().file_name().unwrap().len(), 7); + for d in [&src, &dest, &dest2] { + let _ = std::fs::remove_dir_all(d); + } +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b9f7a2f47..c504a46f7 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -943,6 +943,51 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across + /// the import's batches. + #[test] + fn import_copy_renames_into_destination() { + let base = std::env::temp_dir().join(format!("lc-ui-import-copy-{}", 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(); + for i in 0..10u8 { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 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(format!("IMG_{i:02}.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 app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.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"]["candidates"], 10, "{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 { + assert!(opts.copy); + assert_eq!(opts.destination, dest.to_string_lossy(), "Choose… sets the folder"); + opts.organize = "flat".into(); + opts.rename = "Trip-{seq:2}".into(); + } else { + panic!("no import dialog"); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.import.is_none(), "finished"); + let mut names: Vec = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + names.sort(); + let want: Vec = (1..=10).map(|i| format!("Trip-{i:02}.png")).collect(); + assert_eq!(names, want, "numbered across batches of {}", crate::import::BATCH); + let _ = std::fs::remove_dir_all(&base); + } + /// 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 da9f2019f..208aae999 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -15,7 +15,7 @@ use crate::theme::Tokens; use crate::widgets::register; /// Files per batch (one batch per frame, so the progress window updates). -const BATCH: usize = 8; +pub(crate) const BATCH: usize = 8; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -32,6 +32,14 @@ pub struct ImportDialog { pub preset: String, /// Comma-separated keywords. pub keywords: String, + /// Copy: destination folder ("" = the library's Originals/). + pub destination: String, + /// Copy: `date` (YYYY/YYYY-MM-DD), `month` or `flat`. + pub organize: String, + /// Copy: file-name template for the copies ("" = keep the names). + pub rename: String, + /// Metadata preset name ("" = none). + pub metadata_preset: String, } impl ImportDialog { @@ -103,6 +111,21 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result if !d.preset.is_empty() { params["preset"] = json!(d.preset); } + if !d.metadata_preset.is_empty() { + params["metadataPreset"] = json!(d.metadata_preset); + } + if d.copy { + if !d.destination.trim().is_empty() { + params["destination"] = json!(d.destination.trim()); + } + if !d.organize.is_empty() { + params["organize"] = json!(d.organize); + } + if !d.rename.trim().is_empty() { + params["rename"] = json!(d.rename.trim()); + params["renameStart"] = json!(1); + } + } let total = queue.len(); app.import = Some(ImportTask { queue, total, params, undo0, ..Default::default() }); app.renderer.forget_imports(); @@ -116,6 +139,10 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let batch: Vec = task.queue.drain(..n).collect(); let mut p = task.params.clone(); p["paths"] = json!(batch); + // renamed copies keep counting across batches + if p.get("rename").is_some() { + p["renameStart"] = json!(1 + task.imported); + } let r = app.session.execute("library.import", &p); let task = app.import.as_mut().expect("import task"); task.done += n; @@ -216,12 +243,60 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { if crate::widgets::text_button(ui, "importAdd", "Add in place", !d.copy).on_hover_text("Reference the files where they are").clicked() { d.copy = false; } - let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk); - let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy into library", d.copy)).inner; - if r.on_hover_text("Copy into the library's Originals/YYYY/YYYY-MM-DD/ folders").clicked() { + let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some(); + let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy", d.copy)).inner; + if r.on_hover_text("Copy the files (into the library's Originals/, or a folder you choose)").clicked() { d.copy = true; } }); + if d.copy { + field(ui, "Copy to", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() }; + ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label)); + if app.services.pick_folder.is_some() + && crate::widgets::text_button(ui, "importDest", "Choose…", false).clicked() + && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) + { + d.destination = f; + } + if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", "Library", false).clicked() { + d.destination.clear(); + } + }); + field(ui, "Folders", |ui| { + let opts = [("date", "By day (YYYY/YYYY-MM-DD)"), ("month", "By month (YYYY/YYYY-MM)"), ("flat", "Into one folder")]; + 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| { + for (k, label) in opts { + if ui.selectable_label(key == k, label).clicked() { + d.organize = k.to_string(); + } + } + }); + }); + 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)); + register(ui.ctx(), "field:importRename", r.rect); + }); + 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 }, + &c.name, + &c.format, + 0, + 0, + "", + ); + q.captured = c.captured.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)); + } + } let albums: Vec<(u64, String)> = { let mut v: Vec<(u64, String)> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); @@ -270,6 +345,21 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } }); }); + if !app.session.metadata_presets.is_empty() { + field(ui, "Metadata", |ui| { + let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() }; + egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| { + if ui.selectable_label(d.metadata_preset.is_empty(), "None").clicked() { + d.metadata_preset.clear(); + } + for m in &app.session.metadata_presets { + if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() { + d.metadata_preset = m.name.clone(); + } + } + }); + }); + } field(ui, "Keywords", |ui| { let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text("comma, separated").desired_width(f32::INFINITY)); register(ui.ctx(), "field:importKeywords", r.rect); diff --git a/docs/parity.md b/docs/parity.md index 348004be1..e91dec30b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) | | Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | | Z. Keyboard shortcuts (desktop) | 67 | 9 | 4 | 1 | 49/52 (94%) | 16/23 (70%) | -| Lightroom Classic extras | 14 | 16 | 49 | 9 | — | 13/21 (62%) | -| **Total** | 328 | 48 | 124 | 37 | 193/198 (97%) | 115/144 (80%) | +| Lightroom Classic extras | 15 | 15 | 49 | 9 | — | 14/21 (67%) | +| **Total** | 329 | 47 | 124 | 37 | 193/198 (97%) | 116/144 (81%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.4%** of 500 in-scope rows — P0 98.7% of 198 · P1 88.5% of 144 · P2 18.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.5%** of 500 in-scope rows — P0 98.7% of 198 · P1 88.9% of 144 · P2 18.4% of 158. ## Top gaps @@ -685,7 +685,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: copy`) | copy/add + duplicate skip; no file renaming, apply-during-import, destination organising, import presets | +| 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-AUTOIMPORT | Watched-folder import | P2 | ⬜ | | | | 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` | grid, square grid, loupe, compare, survey; no people view; grid cell styles: filenames only |