diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 369f4041..aceadcdc 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -93,6 +93,65 @@ pub enum Source { Demo { scene: u32 }, } +/// Copyright status (IPTC / XMP Rights Management `xmpRights:Marked`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CopyrightStatus { + /// Not stated (no `xmpRights:Marked`). + #[default] + Unknown, + /// `xmpRights:Marked` = True. + Copyrighted, + /// `xmpRights:Marked` = False. + PublicDomain, +} + +impl CopyrightStatus { + pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain]; + /// `unknown` / `copyrighted` / `publicDomain` (also `public domain`, `public-domain`). + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { + "unknown" | "" => Some(CopyrightStatus::Unknown), + "copyrighted" => Some(CopyrightStatus::Copyrighted), + "publicdomain" => Some(CopyrightStatus::PublicDomain), + _ => None, + } + } + /// The id used by commands (`photo.setMeta`'s `copyrightStatus`). + pub fn id(self) -> &'static str { + match self { + CopyrightStatus::Unknown => "unknown", + CopyrightStatus::Copyrighted => "copyrighted", + CopyrightStatus::PublicDomain => "publicDomain", + } + } + pub fn label(self) -> &'static str { + match self { + CopyrightStatus::Unknown => "Unknown", + CopyrightStatus::Copyrighted => "Copyrighted", + CopyrightStatus::PublicDomain => "Public Domain", + } + } + /// As `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain. + pub fn marked(self) -> Option { + match self { + CopyrightStatus::Unknown => None, + CopyrightStatus::Copyrighted => Some(true), + CopyrightStatus::PublicDomain => Some(false), + } + } + pub fn from_marked(m: Option) -> CopyrightStatus { + match m { + None => CopyrightStatus::Unknown, + Some(true) => CopyrightStatus::Copyrighted, + Some(false) => CopyrightStatus::PublicDomain, + } + } + pub fn is_unknown(&self) -> bool { + *self == CopyrightStatus::Unknown + } +} + /// Descriptive + capture metadata. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -115,6 +174,13 @@ pub struct Meta { pub alt_text: String, pub extended_description: String, pub copyright: String, + /// Copyright status, rights usage terms and copyright info URL (IPTC Core rights fields). + #[serde(skip_serializing_if = "CopyrightStatus::is_unknown")] + pub copyright_status: CopyrightStatus, + #[serde(skip_serializing_if = "String::is_empty")] + pub usage_terms: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub copyright_url: String, pub creator: String, pub keywords: Vec, } diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index b5925781..4b6060f7 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -70,6 +70,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ("location", "Location", Kind::Text), ("creator", "Creator", Kind::Text), ("copyright", "Copyright", Kind::Text), + ("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])), ("captureDate", "Capture Date", Kind::Date), ("importDate", "Import Date", Kind::Date), ("editDate", "Edit Date", Kind::Date), @@ -311,6 +312,10 @@ impl Rule { "location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")), "creator" => text(&m.creator), "copyright" => text(&m.copyright), + "copyrightStatus" => { + let want = crate::CopyrightStatus::parse(&want); + (want == Some(m.copyright_status)) == (op == "is") + } "captureDate" => date_op(op, p.captured.as_deref(), value), "importDate" => date_op(op, Some(&p.imported), value), "editDate" => date_op(op, p.edited.as_deref(), value), @@ -440,6 +445,13 @@ mod tests { yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]})); no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]})); yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]})); + // copyright status (unknown until set) + yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]})); + no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]})); + let mut pd = photo(2); + pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain; + assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat)); + assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat)); // nested: rating ≥ 4 and (label is blue or keywords contain food) yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [ {"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]})); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b4c3b292..666477a2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1,7 +1,7 @@ //! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete, //! metadata, albums, import. -use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, GroupBy, Op, PhotoId, Sort, SortKey}; +use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param}; @@ -252,11 +252,12 @@ pub fn specs() -> Vec { cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| { Ok(serde_json::to_value(crate::devices::devices()).unwrap_or_default()) }), - cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright, creator, location and keywords of the active photo", has_active, |s, _| { + cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| { let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?; let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta; let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description, - "copyright": m.copyright, "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country, + "copyright": m.copyright, "copyrightStatus": m.copyright_status.id(), "usageTerms": m.usage_terms, "copyrightUrl": m.copyright_url, + "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country, "keywords": m.keywords}); s.meta_clipboard = Some(v.clone()); Ok(v) @@ -266,7 +267,7 @@ pub fn specs() -> Vec { "Paste Metadata", ["Photo"], None, - "{ids?, fields?: [title|caption|copyright|creator|location|keywords] (default: all copied)}", + "{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}", has_selection, |s, p| { let c = "photo.pasteMetadata"; @@ -370,7 +371,7 @@ pub fn specs() -> Vec { "Edit Info", [], None, - "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", + "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", has_selection, |s, p| { let strs = @@ -390,6 +391,13 @@ pub fn specs() -> Vec { }, Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")), }; + let status = match str_param(p, "copyrightStatus") { + Some(t) => Some( + CopyrightStatus::parse(t) + .ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?, + ), + None => None, + }; let mut ops = Vec::new(); for id in targets { let Some(ph) = s.catalog.photo(id) else { continue }; @@ -397,10 +405,15 @@ pub fn specs() -> Vec { if let Some(g) = gps { m.gps = g; } + if let Some(st) = status { + m.copyright_status = st; + } for (k, field) in [ ("title", &mut m.title), ("caption", &mut m.caption), ("copyright", &mut m.copyright), + ("usageTerms", &mut m.usage_terms), + ("copyrightUrl", &mut m.copyright_url), ("creator", &mut m.creator), ("location", &mut m.location), ("city", &mut m.city), diff --git a/crates/engine/src/cmd/metadata.rs b/crates/engine/src/cmd/metadata.rs index ca306f29..ca52f681 100644 --- a/crates/engine/src/cmd/metadata.rs +++ b/crates/engine/src/cmd/metadata.rs @@ -1,7 +1,7 @@ //! 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 lightcraft_catalog::{CopyrightStatus, Meta}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; @@ -9,7 +9,20 @@ 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"]; +pub const FIELDS: &[&str] = &[ + "title", + "caption", + "copyright", + "copyrightStatus", + "usageTerms", + "copyrightUrl", + "creator", + "location", + "city", + "state", + "country", + "keywords", +]; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MetadataPreset { @@ -23,6 +36,8 @@ fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> { "title" => &mut m.title, "caption" => &mut m.caption, "copyright" => &mut m.copyright, + "usageTerms" => &mut m.usage_terms, + "copyrightUrl" => &mut m.copyright_url, "creator" => &mut m.creator, "location" => &mut m.location, "city" => &mut m.city, @@ -35,7 +50,11 @@ fn text_field<'a>(m: &'a mut Meta, k: &str) -> Option<&'a mut String> { /// 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" { + if k == "copyrightStatus" { + if let Some(st) = v.as_str().and_then(CopyrightStatus::parse) { + m.copyright_status = st; + } + } else 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()); @@ -59,6 +78,9 @@ fn save(s: &mut Session, p: &Value) -> Result { if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) { return Err(bad(C, format!("unknown field `{k}` ({})", FIELDS.join(", ")))); } + if let Some(v) = o.get("copyrightStatus").filter(|v| v.as_str().and_then(CopyrightStatus::parse).is_none()) { + return Err(bad(C, format!("copyrightStatus {v}: unknown, copyrighted or publicDomain"))); + } Value::Object(o.clone()) } Some(_) => return Err(bad(C, "`fields` must be an object")), @@ -66,17 +88,24 @@ fn save(s: &mut Session, p: &Value) -> Result { 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 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", "copyrightStatus", "usageTerms", "copyrightUrl", "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 k == "copyrightStatus" { + if !m.copyright_status.is_unknown() { + o.insert(k.clone(), json!(m.copyright_status.id())); + } } else if let Some(v) = text_field(&mut m, k).filter(|v| !v.trim().is_empty()) { o.insert(k.clone(), json!(v.clone())); } @@ -136,7 +165,7 @@ pub fn specs() -> Vec { "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", + "{name, fields?: {copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, title?, caption?, location?, city?, state?, country?, keywords?: [..]}, only?: [field] (from the active photo when `fields` is absent; default the copyright fields, creator, place)} — adds or replaces", always, save ), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 388e0b8b..9c3c49a6 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -854,7 +854,16 @@ fn parse_shutter(s: &str) -> Option { pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option { let m = &photo.meta; let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); - let mut out = Metadata { copyright: text(&m.copyright), artist: text(&m.creator), software: Some("LightCraft".into()), ..Default::default() }; + // copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL + let mut out = Metadata { + copyright: text(&m.copyright), + copyright_marked: m.copyright_status.marked(), + usage_terms: text(&m.usage_terms), + copyright_url: text(&m.copyright_url), + artist: text(&m.creator), + software: Some("LightCraft".into()), + ..Default::default() + }; match o.metadata { MetadataPolicy::None => return None, MetadataPolicy::Copyright => return Some(out), @@ -1170,6 +1179,9 @@ mod tests { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); p.meta.camera = "Synthetic X2".into(); p.meta.copyright = "(c) Me".into(); + p.meta.copyright_status = lightcraft_catalog::CopyrightStatus::Copyrighted; + p.meta.usage_terms = "Editorial use only".into(); + p.meta.copyright_url = "https://example.com/rights".into(); p.meta.shutter = "1/250".into(); p.meta.gps = Some((43.0, -110.0)); let all = export_metadata(&p, &ExportOptions::default()).unwrap(); @@ -1181,12 +1193,19 @@ mod tests { assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some()); let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me")); + // "copyright only" keeps all the copyright info: status, usage terms, info URL + assert_eq!( + (c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()), + (Some(true), Some("Editorial use only"), Some("https://example.com/rights")) + ); assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); // embedded and readable back from the JPEG let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap(); let back = lightcraft_meta::extract(&jpg); assert_eq!(back.model.as_deref(), Some("Synthetic X2")); assert_eq!(back.copyright.as_deref(), Some("(c) Me")); + assert_eq!((back.copyright_marked, back.usage_terms.as_deref()), (Some(true), Some("Editorial use only"))); + assert_eq!(back.copyright_url.as_deref(), Some("https://example.com/rights")); assert!(back.gps.is_some()); } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 94a6ffc4..32296c5a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -33,6 +33,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { title: m.title.clone().unwrap_or_default(), caption: m.caption.clone().unwrap_or_default(), copyright: m.copyright.clone().unwrap_or_default(), + copyright_status: lightcraft_catalog::CopyrightStatus::from_marked(m.copyright_marked), + usage_terms: m.usage_terms.clone().unwrap_or_default(), + copyright_url: m.copyright_url.clone().unwrap_or_default(), creator: m.artist.clone().unwrap_or_default(), keywords: m.keywords.clone(), }; diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 714478d3..030f79eb 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -72,6 +72,10 @@ pub struct SidecarData { pub title: Option, pub caption: Option, pub copyright: Option, + /// `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain. + pub copyright_marked: Option, + pub usage_terms: Option, + pub copyright_url: Option, pub creator: Option, pub location: Option, pub city: Option, @@ -100,6 +104,9 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result bool { (&sc.title, &mut m.title), (&sc.caption, &mut m.caption), (&sc.copyright, &mut m.copyright), + (&sc.usage_terms, &mut m.usage_terms), + (&sc.copyright_url, &mut m.copyright_url), (&sc.creator, &mut m.creator), (&sc.location, &mut m.location), (&sc.city, &mut m.city), @@ -164,6 +173,9 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool { *dst = v.clone(); } } + if let Some(marked) = sc.copyright_marked { + m.copyright_status = lightcraft_catalog::CopyrightStatus::from_marked(Some(marked)); + } if let Some(k) = &sc.keywords { m.keywords = k.clone(); } @@ -212,6 +224,9 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String { state: nz(&p.meta.state), country: nz(&p.meta.country), copyright: nz(&p.meta.copyright), + copyright_marked: p.meta.copyright_status.marked(), + usage_terms: nz(&p.meta.usage_terms), + copyright_url: nz(&p.meta.copyright_url), artist: nz(&p.meta.creator), keywords: p.meta.keywords.clone(), rating: Some(p.rating.min(5) as i8), diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 2f00a315..6545cdb0 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -445,3 +445,82 @@ fn dng_export_compression_choices() { } assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}"); } + +/// Copyright status, rights usage terms and copyright info URL: edited with `photo.setMeta` (one +/// undo step), copied / pasted, kept in metadata presets, written to the sidecar as XMP Rights +/// Management fields and read back on a fresh import. +#[test] +fn copyright_status_usage_terms_and_url_round_trip() { + use lightcraft_catalog::CopyrightStatus; + let src = temp_dir("rights-src"); + let lib = temp_dir("rights-lib"); + write_png(&src.join("a.png"), 1); + write_png(&src.join("b.png"), 2); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; + let (a, b) = (id(&s, "a.png"), id(&s, "b.png")); + let meta = |s: &Session, id: PhotoId| s.catalog.photo(id).unwrap().meta.clone(); + assert_eq!(meta(&s, a).copyright_status, CopyrightStatus::Unknown); + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + s.execute( + "photo.setMeta", + &json!({"copyright": "© 2026 A. Person", "copyrightStatus": "copyrighted", "usageTerms": "Editorial use only", "copyrightUrl": "https://example.com/rights"}), + ) + .unwrap(); + let m = meta(&s, a); + assert_eq!( + (m.copyright_status, m.usage_terms.as_str(), m.copyright_url.as_str()), + (CopyrightStatus::Copyrighted, "Editorial use only", "https://example.com/rights") + ); + assert!(s.execute("photo.setMeta", &json!({"copyrightStatus": "maybe"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(meta(&s, a).copyright_status, CopyrightStatus::Unknown, "one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + // copy → paste onto b + let clip = s.execute("photo.copyMetadata", &json!({})).unwrap(); + assert_eq!(clip["copyrightStatus"], "copyrighted"); + s.execute("photo.pasteMetadata", &json!({"ids": [b.0], "fields": ["copyrightStatus", "usageTerms"]})).unwrap(); + let mb = meta(&s, b); + assert_eq!((mb.copyright_status, mb.usage_terms.as_str(), mb.copyright_url.as_str()), (CopyrightStatus::Copyrighted, "Editorial use only", "")); + // a metadata preset from the active photo carries the copyright fields; applying it sets them + s.execute("metadata.savePreset", &json!({"name": "Rights"})).unwrap(); + let presets = s.execute("metadata.presets", &json!({})).unwrap(); + assert_eq!(presets[0]["fields"]["copyrightStatus"], "copyrighted", "{presets}"); + assert_eq!(presets[0]["fields"]["copyrightUrl"], "https://example.com/rights"); + s.execute("photo.setMeta", &json!({"ids": [b.0], "copyrightStatus": "publicDomain", "usageTerms": ""})).unwrap(); + s.execute("metadata.applyPreset", &json!({"name": "Rights", "ids": [b.0]})).unwrap(); + let mb = meta(&s, b); + assert_eq!((mb.copyright_status, mb.copyright_url.as_str()), (CopyrightStatus::Copyrighted, "https://example.com/rights")); + assert!(s.execute("metadata.savePreset", &json!({"name": "Bad", "fields": {"copyrightStatus": "sort of"}})).is_err()); + // b becomes public domain; smart-album rule on the status + s.execute("photo.setMeta", &json!({"ids": [b.0], "copyrightStatus": "public domain"})).unwrap(); + let r = s + .execute( + "album.createSmart", + &json!({"name": "PD", "rules": {"ruleSet": {"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]}}}), + ) + .unwrap(); + assert_eq!(r["count"], 1, "{r}"); + // sidecars: XMP Rights Management fields, read back by a fresh library + s.execute("photo.saveMetadataToFile", &json!({"ids": [a.0, b.0]})).unwrap(); + let xmp = std::fs::read_to_string(src.join("a.xmp")).unwrap(); + assert!(xmp.contains("True"), "{xmp}"); + assert!(xmp.contains("https://example.com/rights"), "{xmp}"); + assert!(std::fs::read_to_string(src.join("b.xmp")).unwrap().contains("False")); + let expect_a = meta(&s, a); + drop(s); + std::fs::remove_dir_all(&lib).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let ma = meta(&s, id(&s, "a.png")); + assert_eq!( + (ma.copyright.as_str(), ma.copyright_status, ma.usage_terms.as_str(), ma.copyright_url.as_str()), + (expect_a.copyright.as_str(), CopyrightStatus::Copyrighted, "Editorial use only", "https://example.com/rights") + ); + assert_eq!(meta(&s, id(&s, "b.png")).copyright_status, CopyrightStatus::PublicDomain); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 31132c5b..59dd5b34 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -83,6 +83,12 @@ pub struct Metadata { // descriptive / user pub artist: Option, pub copyright: Option, + /// Copyright status (`xmpRights:Marked`): `true` = copyrighted, `false` = public domain, `None` = unknown. + pub copyright_marked: Option, + /// Rights usage terms (`xmpRights:UsageTerms`). + pub usage_terms: Option, + /// Copyright info URL (`xmpRights:WebStatement`). + pub copyright_url: Option, pub title: Option, pub caption: Option, /// Accessibility text (`Iptc4xmpCore:AltTextAccessibility`). @@ -142,6 +148,9 @@ impl Metadata { height, artist, copyright, + copyright_marked, + usage_terms, + copyright_url, title, caption, alt_text, @@ -164,7 +173,7 @@ impl Metadata { /// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with /// `other`'s where `other` has them — the XMP-over-EXIF precedence rule. pub fn overlay_user_fields(&mut self, other: &Metadata) { - overlay!(self, other, rating, label, title, caption, artist, copyright, gps); + overlay!(self, other, rating, label, title, caption, artist, copyright, copyright_marked, usage_terms, copyright_url, gps); if !other.keywords.is_empty() { self.keywords = other.keywords.clone(); } diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 0c686d89..706976eb 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -420,6 +420,13 @@ pub fn parse_xmp(s: &str) -> Result { state: first("photoshop:State"), country: first("photoshop:Country"), copyright: first("dc:rights"), + copyright_marked: first("xmpRights:Marked").and_then(|s| match s.to_ascii_lowercase().as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }), + usage_terms: first("xmpRights:UsageTerms"), + copyright_url: first("xmpRights:WebStatement"), rating: num("xmp:Rating").map(|v| v.round().clamp(-1.0, 5.0) as i8), label: first("xmp:Label"), ..Default::default() @@ -519,6 +526,8 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { push("photoshop:City", meta.city.clone()); push("photoshop:State", meta.state.clone()); push("photoshop:Country", meta.country.clone()); + push("xmpRights:Marked", meta.copyright_marked.map(|m| if m { "True" } else { "False" }.to_string())); + push("xmpRights:WebStatement", meta.copyright_url.clone()); if let Some(g) = meta.gps { push("exif:GPSLatitude", Some(fmt_gps_coord(g.latitude, 'N', 'S'))); push("exif:GPSLongitude", Some(fmt_gps_coord(g.longitude, 'E', 'W'))); @@ -566,6 +575,7 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { array(&mut x, "Iptc4xmpCore:AltTextAccessibility", "Alt", &one(&meta.alt_text), true); array(&mut x, "Iptc4xmpCore:ExtDescrAccessibility", "Alt", &one(&meta.extended_description), true); array(&mut x, "dc:rights", "Alt", &one(&meta.copyright), true); + array(&mut x, "xmpRights:UsageTerms", "Alt", &one(&meta.usage_terms), true); array(&mut x, "dc:creator", "Seq", &one(&meta.artist), false); array(&mut x, "dc:subject", "Bag", &meta.keywords, false); array(&mut x, "lr:hierarchicalSubject", "Bag", &meta.hierarchical_keywords, false); @@ -620,6 +630,9 @@ mod tests { height: Some(6000), artist: Some("Ann \"Photo\" Lee".into()), copyright: Some("© 2022".into()), + copyright_marked: Some(true), + usage_terms: Some("Editorial use only; no ".into()), + copyright_url: Some("https://example.com/licence?a=1&b=2".into()), title: Some("Tïtle".into()), caption: Some("Line one\nline two".into()), alt_text: Some("A tree by the sea".into()), @@ -683,6 +696,32 @@ mod tests { assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]); } + /// Copyright status, usage terms and info URL (XMP Rights Management schema): written as + /// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI). + #[test] + fn rights_management_fields() { + let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() }; + let x = write_xmp(&m, None); + assert!(x.contains("False"), "{x}"); + assert!(x.contains("\n \n CC0"), "{x}"); + assert!(!x.contains("WebStatement"), "unset fields are left out"); + assert_eq!(parse_xmp(&x).unwrap().metadata, m); + // attribute form, other prefixes; anything but True / False is "unknown" + let read = |marked: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata + }; + assert_eq!(read("True").copyright_marked, Some(true)); + assert_eq!(read("false").copyright_marked, Some(false)); + assert_eq!(read("").copyright_marked, None); + assert_eq!(read("maybe").copyright_marked, None); + assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); + } + #[test] fn malformed_inputs() { assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b911896b..a11bbe3c 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -735,7 +735,7 @@ mod tests { let t = Duration::from_secs(10); h.request("ui.set", json!({"view": "detail", "right": "info"}), t); h.settle(SETTLE); - for (key, text) in [("altText", "A lake at dawn"), ("city", "Zermatt")] { + for (key, text) in [("altText", "A lake at dawn"), ("usageTerms", "Editorial use only"), ("city", "Zermatt")] { let r = h.request("ui.clickWidget", json!({"id": format!("field:{key}")}), t); assert_eq!(r["ok"], true, "{r}"); h.request("ui.key", json!({"key": "A", "cmd": true}), t); @@ -748,6 +748,10 @@ mod tests { } let m = &h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta; assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt")); + assert_eq!(m.usage_terms, "Editorial use only"); + // the copyright status picker is on the panel too + let w = h.request("ui.widgets", json!({"filter": "copyrightStatus"}), t); + assert!(w["result"].to_string().contains("field:copyrightStatus"), "{w}"); } /// Local: a folder's photos show without joining the library; the breadcrumb, Include diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index ab31da25..1f954b40 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -432,6 +432,13 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("Alt Text", "altText", &m.alt_text, 2), ("Extended Description", "extendedDescription", &m.extended_description, 3), ("Copyright", "copyright", &m.copyright, 1), + ] { + meta_field(app, ui, label, key, value, lines); + } + copyright_status(app, ui, m.copyright_status); + for (label, key, value, lines) in [ + ("Rights Usage Terms", "usageTerms", &m.usage_terms, 2), + ("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1), ("Creator", "creator", &m.creator, 1), ] { meta_field(app, ui, label, key, value, lines); @@ -596,6 +603,21 @@ fn human_size(bytes: u64) -> String { /// A labelled metadata text field: the typed text lives in egui memory while focused and is /// saved (photo.setMeta `key`) when the field loses focus. +/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). +fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("Copyright Status").size(11.5).color(t.text_dim)); + let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(current.label()).show_ui(ui, |ui| { + for st in lightcraft_catalog::CopyrightStatus::ALL { + if ui.selectable_label(st == current, st.label()).clicked() && st != current { + let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()})); + } + } + }); + register(ui.ctx(), "field:copyrightStatus", r.response.rect); + ui.add_space(6.0); +} + fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim)); diff --git a/docs/parity.md b/docs/parity.md index a33caa46..06be8dba 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -190,10 +190,10 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people | +| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people | | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding | -| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright, creator, place fields, keywords; `fields` picks a subset | +| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | | LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | @@ -696,14 +696,14 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; like the file manager, not an undo step) | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | 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 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ✅ | `crates/catalog/src/keywords.rs`, `cmd:keyword.list`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops) | -| 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-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; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | ⬜ | | | | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 68c8a04d..fff4e9e7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -16,6 +16,7 @@ - Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG. - Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews. - Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}. +- Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports. ### Editing - Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions. diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index eea43e2a..6ed40a4b 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -20,6 +20,8 @@ What we write (standard namespaces, so other tools can read the metadata): | Rating 0–5 | `xmp:Rating` | | Colour label | `xmp:Label` (`Red`, `Yellow`, `Green`, `Blue`, `Purple`) | | Title / caption / copyright / creator | `dc:title` / `dc:description` / `dc:rights` / `dc:creator` | +| Copyright status | `xmpRights:Marked` (`True` copyrighted, `False` public domain, absent = unknown) | +| Rights usage terms / copyright info URL | `xmpRights:UsageTerms` / `xmpRights:WebStatement` | | Keywords | `dc:subject` | | Capture time, GPS | `exif:DateTimeOriginal`, `photoshop:DateCreated`, `exif:GPSLatitude`/`GPSLongitude` | | Pick / reject flag | `lc:flag` (`pick`, `reject`, `none`) |