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:
parent
b2c8d9611f
commit
b27c3bc20f
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 | ⬜ | | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue