diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index ba97e0f8..ecc97a82 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -93,6 +93,42 @@ impl Catalog { self.group_ops(result, sources, true) } + /// Move `photo` `delta` places within its stack (towards the top for negative; index 0 is the + /// top). `None` when it isn't stacked or can't move. + pub fn move_in_stack_ops(&self, photo: PhotoId, delta: isize) -> Option { + let st = self.stack_of(photo)?; + let i = st.photos.iter().position(|p| *p == photo)?; + let to = (i as isize + delta).clamp(0, st.photos.len() as isize - 1) as usize; + if to == i { + return None; + } + let mut photos = st.photos.clone(); + let p = photos.remove(i); + photos.insert(to, p); + Some(Op::SetStack { id: st.id, photos, collapsed: st.collapsed }) + } + + /// Split `photo`'s stack in two: the photos before it stay, it and the ones after it form a + /// new stack (topped by it). A part with a single photo is no longer stacked. + pub fn split_stack_ops(&mut self, photo: PhotoId) -> Option { + let st = self.stack_of(photo)?.clone(); + let i = st.photos.iter().position(|p| *p == photo)?; + if i == 0 { + return None; + } + let (head, tail) = st.photos.split_at(i); + let mut ops = vec![if head.len() >= 2 { + Op::SetStack { id: st.id, photos: head.to_vec(), collapsed: st.collapsed } + } else { + Op::RemoveStack { id: st.id } + }]; + if tail.len() >= 2 { + let id = self.alloc_stack_id(); + ops.push(Op::AddStack { stack: Stack { id, photos: tail.to_vec(), collapsed: st.collapsed } }); + } + Some(Op::Batch { ops }) + } + /// Ops that dissolve every stack containing one of `photos`. pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec { self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect() diff --git a/crates/engine/src/cmd/metadata.rs b/crates/engine/src/cmd/metadata.rs new file mode 100644 index 00000000..ca306f29 --- /dev/null +++ b/crates/engine/src/cmd/metadata.rs @@ -0,0 +1,154 @@ +//! Metadata presets: named sets of descriptive fields (copyright, creator, place, keywords…) +//! applied to photos in one step or to every import (Settings → Import). Saved with the library. + +use lightcraft_catalog::Meta; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::{Result, Session}; + +/// The fields a metadata preset can hold (photo.setMeta names). +pub const FIELDS: &[&str] = &["title", "caption", "copyright", "creator", "location", "city", "state", "country", "keywords"]; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MetadataPreset { + pub name: String, + /// photo.setMeta fields; `keywords` are added, not replaced. + pub fields: Value, +} + +fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> { + Some(match k { + "title" => &mut m.title, + "caption" => &mut m.caption, + "copyright" => &mut m.copyright, + "creator" => &mut m.creator, + "location" => &mut m.location, + "city" => &mut m.city, + "state" => &mut m.state, + "country" => &mut m.country, + _ => return None, + }) +} + +/// Apply preset `fields` to `m`: text fields replace, keywords are added. +pub fn apply_to(m: &mut Meta, fields: &Value) { + for (k, v) in fields.as_object().into_iter().flatten() { + if k == "keywords" { + for kw in v.as_array().into_iter().flatten().filter_map(Value::as_str) { + if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(kw)) { + m.keywords.push(kw.to_string()); + } + } + } else if let (Some(f), Some(t)) = (text_field(m, k), v.as_str()) { + *f = t.to_string(); + } + } +} + +fn list(s: &Session) -> Value { + json!(s.metadata_presets) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "metadata.savePreset"; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string(); + let fields = match p.get("fields") { + Some(Value::Object(o)) => { + if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) { + return Err(bad(C, format!("unknown field `{k}` ({})", FIELDS.join(", ")))); + } + Value::Object(o.clone()) + } + Some(_) => return Err(bad(C, "`fields` must be an object")), + // from the active photo: its non-empty fields among `only` (default: copyright, creator, place) + None => { + let id = s.active().ok_or_else(|| bad(C, "no `fields` and no active photo"))?; + let mut m = s.catalog.photo(id).ok_or_else(|| bad(C, "no photo"))?.meta.clone(); + let only: Vec = p + .get("only") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_else(|| ["copyright", "creator", "location", "city", "state", "country"].map(String::from).to_vec()); + let mut o = Map::new(); + for k in only.iter().filter(|k| FIELDS.contains(&k.as_str())) { + if k == "keywords" { + if !m.keywords.is_empty() { + o.insert(k.clone(), json!(m.keywords)); + } + } else if let Some(v) = text_field(&mut m, k).filter(|v| !v.trim().is_empty()) { + o.insert(k.clone(), json!(v.clone())); + } + } + Value::Object(o) + } + }; + let preset = MetadataPreset { name: name.clone(), fields }; + match s.metadata_presets.iter_mut().find(|m| m.name.eq_ignore_ascii_case(&name)) { + Some(m) => *m = preset, + None => s.metadata_presets.push(preset), + } + s.save_prefs()?; + Ok(list(s)) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "metadata.deletePreset"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let n = s.metadata_presets.len(); + s.metadata_presets.retain(|m| !m.name.eq_ignore_ascii_case(name)); + if s.metadata_presets.len() == n { + return Err(bad(C, format!("no metadata preset `{name}`"))); + } + if s.import_defaults.metadata_preset.as_deref().is_some_and(|m| m.eq_ignore_ascii_case(name)) { + s.import_defaults.metadata_preset = None; + } + s.save_prefs()?; + Ok(list(s)) +} + +/// photo.setMeta with the preset's fields (keywords added) on `ids` / the selection. +fn apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "metadata.applyPreset"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let preset = s + .metadata_presets + .iter() + .find(|m| m.name.eq_ignore_ascii_case(name)) + .cloned() + .ok_or_else(|| bad(C, format!("no metadata preset `{name}`")))?; + let mut params = Map::new(); + for (k, v) in preset.fields.as_object().into_iter().flatten() { + params.insert(if k == "keywords" { "addKeywords".into() } else { k.clone() }, v.clone()); + } + if let Some(ids) = p.get("ids") { + params.insert("ids".into(), ids.clone()); + } + s.execute("photo.setMeta", &Value::Object(params)) +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "metadata.presets", "Metadata Presets", [], None, "{} → [{name, fields}]", always, |s, _| Ok(list(s))), + cmd!( + "metadata.savePreset", + "Save Metadata Preset", + [], + None, + "{name, fields?: {copyright?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default copyright, creator, place)} — adds or replaces", + always, + save + ), + cmd!("metadata.deletePreset", "Delete Metadata Preset", [], None, "{name}", always, delete), + cmd!( + "metadata.applyPreset", + "Apply Metadata Preset", + [], + None, + "{name, ids?} — text fields replace, keywords are added; one undo step", + always, + apply + ), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6fe797d1..14b9e77a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,6 +15,7 @@ mod library; mod manage; mod masks; mod merge; +pub mod metadata; pub mod missing; mod organize; mod prefs; @@ -125,6 +126,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(before::specs()); v.extend(browse::specs()); v.extend(missing::specs()); + v.extend(metadata::specs()); v }) } diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 255fc088..a4b42ba0 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -164,6 +164,29 @@ pub fn specs() -> Vec { let op = s.catalog.set_top_ops(id); Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?})) }), + cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?; + let op = s.catalog.move_in_stack_ops(id, -1); + Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?})) + }), + cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?; + let op = s.catalog.move_in_stack_ops(id, 1); + Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?})) + }), + cmd!( + "stack.split", + "Split Stack", + ["Photo", "Stack"], + None, + "{id?} — this photo and the ones after it become their own stack", + in_stack, + |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?; + let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?; + Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?})) + } + ), cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { let stacks = stacks_of(s, &s.targets(p)); let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 88209ee0..ba11a38d 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -48,6 +48,17 @@ fn prefs(s: &mut Session, p: &Value) -> Result { if let Some(v) = i.get("creator").and_then(Value::as_str) { d.creator = v.trim().to_string(); } + match i.get("metadataPreset") { + Some(Value::String(n)) if n.is_empty() || n == "none" => d.metadata_preset = None, + Some(Value::String(n)) => { + if !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n)) { + return Err(bad(ID, format!("unknown metadata preset `{n}` (see metadata.presets)"))); + } + d.metadata_preset = Some(n.clone()); + } + Some(Value::Null) => d.metadata_preset = None, + _ => {} + } if let Some(a) = i.get("cameras").and_then(Value::as_array) { d.cameras.clear(); for c in a { diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 5236d97a..5c1b8af4 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -127,6 +127,8 @@ pub struct ImportDefaults { pub copyright: String, /// Creator given to imported photos that don't name one (empty = none). pub creator: String, + /// Metadata preset applied to every imported photo (`metadata.*`; `None` = none). + pub metadata_preset: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -164,6 +166,9 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) { if p.meta.creator.trim().is_empty() && !d.creator.trim().is_empty() { p.meta.creator = d.creator.trim().to_string(); } + if let Some(mp) = d.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); + } let base = p.camera_defaults(); let raw = p.kind == lightcraft_catalog::MediaKind::Raw; let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2ee8be40..855899a8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -114,6 +114,8 @@ pub struct Session { pub last_export: Option, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec, + /// Metadata presets (`metadata.*`), persisted in prefs.json. + pub metadata_presets: Vec, /// Before/After: the "before" settings chosen per photo (this session; default: the photo's /// import state). See `cmd/before.rs`. pub before: std::collections::HashMap>, @@ -163,6 +165,7 @@ impl Session { xmp: sidecar::XmpPrefs::default(), last_export: None, export_presets: Vec::new(), + metadata_presets: Vec::new(), before: Default::default(), import_probes: Default::default(), import_defaults: import::ImportDefaults::default(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 31c196a3..ee2185ad 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -108,6 +108,8 @@ struct PrefsFile { last_export: Option, /// The user's export presets. export_presets: Vec, + /// Metadata presets. + metadata_presets: Vec, /// Develop defaults for imported photos. import: crate::import::ImportDefaults, /// Thumbnail disk cache budget (MB, 0 = default). @@ -174,6 +176,7 @@ impl Session { self.xmp = prefs.xmp; self.last_export = prefs.last_export; self.export_presets = prefs.export_presets; + self.metadata_presets = prefs.metadata_presets; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; // view state @@ -286,6 +289,7 @@ impl Session { xmp: self.xmp, last_export: self.last_export.clone(), export_presets: self.export_presets.clone(), + metadata_presets: self.metadata_presets.clone(), import: self.import_defaults.clone(), cache_mb: self.cache_mb, }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1f616c93..8f34e763 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -381,3 +381,28 @@ fn paste_from_previous_and_copy_paste_metadata() { s.execute("photo.pasteMetadata", &json!({"ids": [ids[2].0]})).unwrap(); assert_eq!(s.catalog.photo(ids[2]).unwrap().meta.creator, "Me"); } + +#[test] +fn stacks_move_and_split() { + let mut s = demo(); + let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); + s.execute("library.select", &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "active": ids[0].0})).unwrap(); + s.execute("stack.group", &json!({"collapsed": false})).unwrap(); + let order = |s: &Session| s.catalog.stack_of(ids[0]).or(s.catalog.stack_of(ids[4])).map(|st| st.photos.clone()).unwrap_or_default(); + let before = order(&s); + assert_eq!(before.len(), 5); + s.execute("stack.moveUp", &json!({"id": before[2].0})).unwrap(); + assert_eq!(order(&s)[1], before[2]); + s.execute("stack.moveDown", &json!({"id": before[2].0})).unwrap(); + assert_eq!(order(&s), before); + // split at the 4th photo: 3 stay, 2 form a new stack + s.execute("stack.split", &json!({"id": before[3].0})).unwrap(); + assert_eq!(s.catalog.stack_of(before[0]).unwrap().photos, before[..3].to_vec()); + assert_eq!(s.catalog.stack_of(before[3]).unwrap().photos, before[3..].to_vec()); + // splitting at the last photo of a 2-stack leaves both unstacked + s.execute("stack.split", &json!({"id": before[4].0})).unwrap(); + assert!(s.catalog.stack_of(before[3]).is_none() && s.catalog.stack_of(before[4]).is_none()); + assert!(s.execute("stack.split", &json!({"id": before[0].0})).is_err(), "the top can't split"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.stack_of(before[3]).unwrap().photos.len(), 2); +} diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 235e6400..0fa9b3ab 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -163,3 +163,42 @@ fn default_copyright_and_creator_fill_gaps_on_import() { assert_eq!(s.import_defaults.creator, "Me"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn metadata_presets_apply_to_photos_and_imports() { + let dir = temp_dir("metapresets"); + let lib = dir.join("lib"); + png(&dir.join("new.png")); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.execute("metadata.savePreset", &json!({"name": "Studio", "fields": {"copyright": "© Studio", "creator": "Ann", "keywords": ["studio"]}})) + .unwrap(); + assert!(s.execute("metadata.savePreset", &json!({"name": "Bad", "fields": {"iso": "100"}})).is_err()); + // on import + s.execute("library.preferences", &json!({"import": {"metadataPreset": "studio"}})).unwrap(); + assert!(s.execute("library.preferences", &json!({"import": {"metadataPreset": "nope"}})).is_err()); + s.execute("library.import", &json!({"paths": [dir.join("new.png").to_string_lossy()]})).unwrap(); + let p = photo_named(&s, "new.png"); + assert_eq!((p.meta.copyright.as_str(), p.meta.creator.as_str(), p.meta.keywords.clone()), ("© Studio", "Ann", vec!["studio".to_string()])); + let id = p.id; + // applied to a photo: text replaces, keywords add (one undo step) + s.execute("photo.setMeta", &json!({"ids": [id.0], "keywords": ["mine"], "creator": "Bob"})).unwrap(); + s.execute("metadata.applyPreset", &json!({"name": "Studio", "ids": [id.0]})).unwrap(); + let m = &s.catalog.photo(id).unwrap().meta; + assert_eq!(m.creator, "Ann"); + assert_eq!(m.keywords, vec!["mine".to_string(), "studio".to_string()]); + // from the active photo, then persisted + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.setMeta", &json!({"city": "Lyon"})).unwrap(); + s.execute("metadata.savePreset", &json!({"name": "Here"})).unwrap(); + let saved = s.metadata_presets.iter().find(|m| m.name == "Here").unwrap().fields.clone(); + assert_eq!(saved, json!({"copyright": "© Studio", "creator": "Ann", "city": "Lyon"})); + drop(s); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.metadata_presets.len(), 2); + assert_eq!(s.import_defaults.metadata_preset.as_deref(), Some("studio")); + s.execute("metadata.deletePreset", &json!({"name": "STUDIO"})).unwrap(); + assert!(s.import_defaults.metadata_preset.is_none(), "deleting the import preset clears it"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index e0bffae7..50c62456 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -147,6 +147,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Set Rating", "@Set Flag", "@Set Color Label", + "@Metadata Preset", "view.autoAdvance", "---", "photo.rotateLeft", @@ -353,6 +354,20 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { devices.into_iter().map(|d| item("file.addFromDevice", json!({"path": d.path}), format!("{}…", d.name), None, true, None)).collect() } } + "Metadata Preset" => { + let sel = !app.session.selection.ids.is_empty() || has; + let mut v: Vec = app + .session + .metadata_presets + .iter() + .map(|m| item("metadata.applyPreset", json!({"name": m.name}), m.name.clone(), None, sel, None)) + .collect(); + if !v.is_empty() { + v.push(MenuNode::Separator); + } + v.push(item("dialog.saveMetadataPreset", Value::Null, "Save Metadata Preset…", None, has, None)); + v + } "Select by" => { let mut v: Vec = [("pick", "Picks"), ("reject", "Rejects"), ("none", "Unflagged")] .into_iter() @@ -771,6 +786,26 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn metadata_preset_menu() { + let mut app = app(); + app.session.execute("photo.setMeta", &json!({"copyright": "© Me", "creator": "Me"})).unwrap(); + run_item(&mut app, "dialog.saveMetadataPreset", Value::Null).unwrap(); + if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { + *value = "Mine".into(); + } + let d = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { id, params, .. }) = find(&all, "metadata.applyPreset") else { panic!("no preset item") }; + assert_eq!(params["name"], "Mine"); + let other = app.session.visible()[1]; + app.session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); + run_item(&mut app, id, params.clone()).unwrap(); + assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me"); + } + #[test] fn shortcut_text_per_platform() { assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5aaa8600..0744a5a5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -94,6 +94,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("file.addFromDevice", "Add from Device", None, ""), ("file.findMissing", "Find Missing Photos…", None, "File"), ("photo.locate", "Locate Missing File…", None, ""), + ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), ("file.importPresets", "Import Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), @@ -592,6 +593,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // from the active photo's copyright, creator and place + crate::panels::dialogs::prompt(app, "Save Metadata Preset", "Preset name", "", "metadata.savePreset", json!({}), "name"); + Ok(Value::Null) + } "file.findMissing" => { let folder = match p.get("folder").and_then(Value::as_str) { Some(f) => Some(f.to_string()), diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index d7c31921..26d5f2bf 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -586,6 +586,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("Ungroup Stack", "stack.ungroup", stacked), ("Remove from Stack", "stack.remove", stacked), ("Set as Top of Stack", "stack.setTop", stacked), + ("Move Up in Stack", "stack.moveUp", stacked), + ("Move Down in Stack", "stack.moveDown", stacked), + ("Split Stack", "stack.split", stacked), ("Expand/Collapse Stack", "stack.toggle", stacked), ] { if ui.add_enabled(on, egui::Button::new(label)).clicked() { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index cfd47a37..2504e9a2 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -248,6 +248,29 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } }); } + if !app.session.metadata_presets.is_empty() { + row(ui, t, "Metadata preset", |ui| { + let cur = d.metadata_preset.clone(); + let mut pick = None; + let r = egui::ComboBox::from_id_salt("settingsMetaPreset") + .width(240.0) + .selected_text(cur.clone().unwrap_or_else(|| "None".into())) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), "None").clicked() { + pick = Some(String::new()); + } + for m in &app.session.metadata_presets { + if ui.selectable_label(cur.as_deref() == Some(m.name.as_str()), &m.name).clicked() { + pick = Some(m.name.clone()); + } + } + }); + register(ui.ctx(), "combo:settingsMetaPreset", r.response.rect); + if let Some(n) = pick { + let _ = app.run("library.preferences", json!({"import": {"metadataPreset": n}})); + } + }); + } heading(ui, t, "XMP sidecars"); let mut xmp = app.session.xmp; if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") { diff --git a/docs/parity.md b/docs/parity.md index 408baf52..1069194e 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) | 12 | 1 | 4 | 1 | 8/8 (100%) | 4/5 (80%) | | Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | | Z. Keyboard shortcuts (desktop) | 65 | 10 | 5 | 1 | 49/52 (94%) | 14/23 (61%) | -| Lightroom Classic extras | 10 | 19 | 50 | 9 | — | 9/21 (43%) | -| **Total** | 315 | 58 | 127 | 37 | 193/198 (97%) | 103/144 (72%) | +| Lightroom Classic extras | 12 | 17 | 50 | 9 | — | 11/21 (52%) | +| **Total** | 317 | 56 | 127 | 37 | 193/198 (97%) | 105/144 (73%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **68.8%** of 500 in-scope rows — P0 98.7% of 198 · P1 83.7% of 144 · P2 17.7% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.0%** of 500 in-scope rows — P0 98.7% of 198 · P1 84.4% of 144 · P2 17.7% of 158. ## Top gaps @@ -698,11 +698,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:view.filterBar`, `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label/kind/edited/camera/lens/keyword, clear, save as smart album; no filter presets, lock, or multi-select columns | -| LRC-LIB-STACKS | Stacks (full) | P1 | 🟡 | `crates/catalog/src/stacks.rs` | group/ungroup/toggle/top/remove/auto by time; no split stack or move up/down | +| LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ⬜ | | | | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | 🟡 | `crates/catalog/src/keywords.rs`, `cmd:keyword.list` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); no keyword sets or painter | -| LRC-LIB-METADATA | Metadata panel & presets | P1 | 🟡 | `cmd:photo.setMeta`, `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:dialog.captureTime` | IPTC core fields, accessibility text, place, capture-time edit; no metadata presets, copyright status | +| LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings); no copyright status field | | LRC-LIB-QUICKDEV | Quick develop | P2 | ⬜ | | | | LRC-LIB-PEOPLE | People view | P2 | ⬜ | | | | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | |