mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
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 <noreply@anthropic.com>
184 lines
7.2 KiB
Rust
184 lines
7.2 KiB
Rust
//! 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::{CopyrightStatus, 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",
|
|
"copyrightStatus",
|
|
"usageTerms",
|
|
"copyrightUrl",
|
|
"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,
|
|
"usageTerms" => &mut m.usage_terms,
|
|
"copyrightUrl" => &mut m.copyright_url,
|
|
"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 == "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());
|
|
}
|
|
}
|
|
} 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(", "))));
|
|
}
|
|
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")),
|
|
// 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", "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()));
|
|
}
|
|
}
|
|
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?, 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
|
|
),
|
|
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
|
|
),
|
|
]
|
|
}
|