LRC-LIB-METADATA + LRC-LIB-STACKS: metadata presets, split stack, move within a stack

- metadata.savePreset / applyPreset / presets / deletePreset: named sets of copyright,
  creator, title, caption, place and keywords (text replaces, keywords add; one undo step),
  saved with the library; Photo → Metadata Preset (apply, Save Metadata Preset… from the active
  photo's copyright, creator and place); Settings → Import applies one to every import
  (library.preferences import.metadataPreset).
- stack.split (this photo and those after it become their own stack; single leftovers are
  unstacked), stack.moveUp / moveDown; in the Stack menus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 16:33:11 -04:00
parent b2c8d9611f
commit b27c3bc20f
15 changed files with 374 additions and 5 deletions

View File

@ -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<Op> {
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<Op> {
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<Op> {
self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect()

View File

@ -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<Value> {
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<String> = 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<Value> {
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<Value> {
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<CommandSpec> {
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
),
]
}

View File

@ -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
})
}

View File

@ -164,6 +164,29 @@ pub fn specs() -> Vec<CommandSpec> {
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);

View File

@ -48,6 +48,17 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
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 {

View File

@ -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<String>,
}
#[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));

View File

@ -114,6 +114,8 @@ pub struct Session {
pub last_export: Option<serde_json::Value>,
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
pub export_presets: Vec<export::ExportPreset>,
/// Metadata presets (`metadata.*`), persisted in prefs.json.
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
/// 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<PhotoId, Arc<DevelopSettings>>,
@ -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(),

View File

@ -108,6 +108,8 @@ struct PrefsFile {
last_export: Option<serde_json::Value>,
/// The user's export presets.
export_presets: Vec<crate::export::ExportPreset>,
/// Metadata presets.
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
/// 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,
})

View File

@ -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::<Vec<_>>(), "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);
}

View File

@ -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);
}

View File

@ -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<Vec<MenuNode>> {
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<MenuNode> = 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<MenuNode> = [("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<MenuNode> = 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");

View File

@ -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<Re
app.ui.quit = true;
Ok(Value::Null)
}
"dialog.saveMetadataPreset" => {
// 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()),

View File

@ -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() {

View File

@ -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") {

View File

@ -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.
<!-- /parity:summary -->
## 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 | ⬜ | | |