LRC-LIB-METADATA: Copyright Status, Rights Usage Terms and Copyright Info URL

Catalog Meta gets copyright_status (unknown / copyrighted / publicDomain), usage_terms and
copyright_url, read and written as XMP Rights Management fields (xmpRights:Marked, UsageTerms,
WebStatement) on import, sidecars and exports (kept by "copyright only"). photo.setMeta,
copy/paste metadata, metadata presets, a Copyright Status smart-album rule and the Info panel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
storybored379andClaude Opus 5.5 committed 2026-10-04 08:01:00 -07:00
1 parent fb8e1e93aa
commit 18153682f2
15 files changed
+334 -21

No files matched your search

+66
View File
@@ -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<CopyrightStatus> {
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<bool> {
match self {
CopyrightStatus::Unknown => None,
CopyrightStatus::Copyrighted => Some(true),
CopyrightStatus::PublicDomain => Some(false),
}
}
pub fn from_marked(m: Option<bool>) -> 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<String>,
}
+12
View File
@@ -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"}]}}]}));
+18 -5
View File
@@ -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<CommandSpec> {
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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
},
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<CommandSpec> {
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),
+38 -9
View File
@@ -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<Value> {
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<Value> {
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 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", "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<CommandSpec> {
"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
),
+20 -1
View File
@@ -854,7 +854,16 @@ fn parse_shutter(s: &str) -> Option<f64> {
pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option<Metadata> {
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());
}
+3
View File
@@ -33,6 +33,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
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(),
};
+15
View File
@@ -72,6 +72,10 @@ pub struct SidecarData {
pub title: Option<String>,
pub caption: Option<String>,
pub copyright: Option<String>,
/// `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain.
pub copyright_marked: Option<bool>,
pub usage_terms: Option<String>,
pub copyright_url: Option<String>,
pub creator: Option<String>,
pub location: Option<String>,
pub city: Option<String>,
@@ -100,6 +104,9 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
title: m.title.clone(),
caption: m.caption.clone(),
copyright: m.copyright.clone(),
copyright_marked: m.copyright_marked,
usage_terms: m.usage_terms.clone(),
copyright_url: m.copyright_url.clone(),
creator: m.artist.clone(),
location: lc("location").or_else(|| m.sublocation.clone()),
city: m.city.clone(),
@@ -152,6 +159,8 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> 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),
+79
View File
@@ -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("<xmpRights:Marked>True</xmpRights:Marked>"), "{xmp}");
assert!(xmp.contains("<xmpRights:WebStatement>https://example.com/rights</xmpRights:WebStatement>"), "{xmp}");
assert!(std::fs::read_to_string(src.join("b.xmp")).unwrap().contains("<xmpRights:Marked>False</xmpRights:Marked>"));
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);
}
+10 -1
View File
@@ -83,6 +83,12 @@ pub struct Metadata {
// descriptive / user
pub artist: Option<String>,
pub copyright: Option<String>,
/// Copyright status (`xmpRights:Marked`): `true` = copyrighted, `false` = public domain, `None` = unknown.
pub copyright_marked: Option<bool>,
/// Rights usage terms (`xmpRights:UsageTerms`).
pub usage_terms: Option<String>,
/// Copyright info URL (`xmpRights:WebStatement`).
pub copyright_url: Option<String>,
pub title: Option<String>,
pub caption: Option<String>,
/// 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();
}
+39
View File
@@ -420,6 +420,13 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
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 <resale>".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("<xmpRights:Marked>False</xmpRights:Marked>"), "{x}");
assert!(x.contains("<xmpRights:UsageTerms>\n <rdf:Alt>\n <rdf:li xml:lang=\"x-default\">CC0</rdf:li>"), "{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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:r="http://ns.adobe.com/xap/1.0/rights/" r:Marked="{marked}" r:WebStatement="https://example.org/c"/>
</rdf:RDF></x:xmpmeta>"#
);
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("<a><b></a>").is_ok() || parse_xmp("<a><b></a>").is_err());
+5 -1
View File
@@ -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
+22
View File
@@ -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));
+4 -4
View File
@@ -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 | ⬜ | | |
+1
View File
@@ -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.
+2
View File
@@ -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`) |