LRC-LIB-KEYWORDS: keyword list commands and the default parent

- `keyword.create {name, parent?, attributes…, addToSelected?}`: a
  keyword inside a parent with its attributes, given to the selection
  at once; one undo step.
- `keyword.edit {keyword, name?, attributes…}`: what isn't given keeps
  its value.
- `keyword.move {keyword, parent | null, merge?}`: onto a taken name
  it says so, and merges only with `merge: true`.
- `keyword.purgeUnused`, `keyword.info` (attributes, listed, count).
- `keyword.setDefaultParent` (Lightroom Classic's "Put New Keywords
  Inside this Keyword"): new keywords go inside it unless `parent` is
  given (null = the top level). It is kept with the library, follows
  its keyword's rename or move, and goes when the keyword is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 14:12:15 -04:00
co-authored by Claude Opus 5.5
parent cdca2b9cf8
commit 10c98459fe
4 changed files with 275 additions and 4 deletions
+167 -4
View File
@@ -1,10 +1,10 @@
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
use lightcraft_catalog::keywords::{clean, is_under, reparent};
use lightcraft_catalog::keywords::{KeywordInfo, clean, is_under, reparent};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
use crate::{Result, Session};
fn strs(p: &Value, key: &str) -> Vec<String> {
@@ -15,8 +15,8 @@ fn strs(p: &Value, key: &str) -> Vec<String> {
}
}
/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed
/// keyword and is cleared when its keyword is deleted.
/// Commit a keyword batch; returns how many changes it made. The keyword filter and the default
/// parent follow a renamed keyword and are cleared when their keyword is deleted.
fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option<String>) -> Result<Value> {
let n = match &op {
lightcraft_catalog::Op::Batch { ops } => ops.len(),
@@ -28,9 +28,45 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol
if let Some(k) = s.filter.keyword.clone() {
s.filter.keyword = follow(&k);
}
if let Some(k) = s.keyword_parent.clone() {
let followed = follow(&k).filter(|k| s.catalog.has_keyword(k));
if followed.as_deref() != Some(k.as_str()) {
s.keyword_parent = followed;
let _ = s.save_prefs();
}
}
Ok(json!({"changed": n}))
}
/// A keyword's attributes from command params over `base`: those not given keep their value.
fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo {
KeywordInfo {
synonyms: if p.get("synonyms").is_some() {
strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
} else {
base.synonyms
},
include_on_export: bool_or(p, "includeOnExport", base.include_on_export),
export_containing: bool_or(p, "exportContaining", base.export_containing),
export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms),
person: bool_or(p, "person", base.person),
}
}
/// The keyword a command names (`keyword`), as the library writes it.
fn named_keyword(s: &Session, p: &Value, command: &str) -> Result<String> {
let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?;
s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k))))
}
/// A catalog refusal as a command error; a taken name says how to merge.
fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError {
match e {
lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")),
e => bad(command, e.to_string()),
}
}
/// A named set of up to nine keywords.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct KeywordSet {
@@ -185,6 +221,133 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!(s.catalog.keyword_suggestions(&current, str_param(p, "prefix").unwrap_or(""), n)))
}
),
cmd!(
query "keyword.info",
"Keyword Info",
[],
None,
"{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}",
always,
|s, p| {
let path = named_keyword(s, p, "keyword.info")?;
let info = s.catalog.keyword_info(&path).cloned();
let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count();
let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default();
if let Some(o) = v.as_object_mut() {
o.insert("path".into(), json!(path));
o.insert("listed".into(), json!(info.is_some()));
o.insert("count".into(), json!(count));
o.entry("synonyms").or_insert(json!([]));
}
Ok(v)
}
),
cmd!(
"keyword.create",
"Create Keyword",
[],
None,
"{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}",
always,
|s, p| {
let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?;
let parent = match p.get("parent") {
Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))),
Some(_) => None,
None => s.keyword_parent.clone(),
};
let path = clean(&match parent {
Some(parent) => format!("{parent}|{name}"),
None => name,
});
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
let op = s
.catalog
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
.map_err(|e| refused("keyword.create", e, ""))?;
s.commit("Create Keyword", op)?;
if !photos.is_empty() {
note_recent(s, std::slice::from_ref(&path));
}
Ok(json!({"keyword": path}))
}
),
cmd!(
"keyword.edit",
"Edit Keyword",
[],
None,
"{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value",
always,
|s, p| {
let from = named_keyword(s, p, "keyword.edit")?;
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
let name = str_param(p, "name").unwrap_or(&leaf).to_string();
let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default());
let op =
s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?;
let to = match from.rsplit_once('|') {
Some((parent, _)) => format!("{parent}|{}", name.trim()),
None => name.trim().to_string(),
};
commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
}
),
cmd!(
"keyword.move",
"Move Keyword",
[],
None,
"{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`",
always,
|s, p| {
let from = named_keyword(s, p, "keyword.move")?;
let parent = match p.get("parent") {
Some(Value::String(k)) => Some(k.clone()),
Some(Value::Null) => None,
_ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),
};
let op = s
.catalog
.move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false))
.map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?;
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
let to = match parent.as_deref().and_then(|k| s.catalog.keyword_path(k)) {
Some(parent) => format!("{parent}|{leaf}"),
None => leaf,
};
commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
}
),
cmd!(
"keyword.purgeUnused",
"Purge Unused Keywords",
[],
None,
"{} — takes off the keyword list the keywords no photo has → {purged}",
always,
|s, _| {
let op = s.catalog.purge_unused_keywords_ops();
let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?;
Ok(json!({"purged": r["changed"]}))
}
),
cmd!(
"keyword.setDefaultParent",
"Put New Keywords Inside",
[],
None,
"{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)",
always,
|s, p| {
s.keyword_parent = match p.get("keyword") {
Some(Value::Null) | None => None,
Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?),
};
let _ = s.save_prefs();
Ok(json!({"keyword": s.keyword_parent}))
}
),
cmd!(
"keyword.rename",
"Rename Keyword",
+3
View File
@@ -247,6 +247,8 @@ pub struct Session {
pub keyword_sets: Vec<cmd::keywords::KeywordSet>,
pub keyword_set: Option<String>,
pub recent_keywords: Vec<String>,
/// "Put new keywords inside this keyword": the parent `keyword.create` uses unless told.
pub keyword_parent: Option<String>,
/// 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>>,
@@ -332,6 +334,7 @@ impl Session {
keyword_sets: Vec::new(),
keyword_set: None,
recent_keywords: Vec::new(),
keyword_parent: None,
before: Default::default(),
import_probes: Default::default(),
preview_build: None,
+4
View File
@@ -149,6 +149,8 @@ struct PrefsFile {
keyword_sets: Vec<crate::cmd::keywords::KeywordSet>,
keyword_set: Option<String>,
recent_keywords: Vec<String>,
/// The parent new keywords go inside.
keyword_parent: Option<String>,
/// Develop defaults for imported photos.
import: crate::import::ImportDefaults,
/// Thumbnail disk cache budget (MB, 0 = default).
@@ -338,6 +340,7 @@ impl Session {
self.keyword_sets = prefs.keyword_sets;
self.keyword_set = prefs.keyword_set;
self.recent_keywords = prefs.recent_keywords;
self.keyword_parent = prefs.keyword_parent;
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS);
@@ -567,6 +570,7 @@ impl Session {
keyword_sets: self.keyword_sets.clone(),
keyword_set: self.keyword_set.clone(),
recent_keywords: self.recent_keywords.clone(),
keyword_parent: self.keyword_parent.clone(),
import: self.import_defaults.clone(),
cache_mb: self.cache_mb,
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
+101
View File
@@ -19,3 +19,104 @@ fn the_filter_follows_a_rename_whatever_the_case() {
s.execute("keyword.merge", &json!({"from": ["ROAD"], "into": "Weg"})).unwrap();
assert_eq!(s.filter.keyword.as_deref(), Some("Weg|é"));
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-keyword-list-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn keywords_of(s: &Session, id: u64) -> Vec<String> {
s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone()
}
/// Create Keyword: a name inside a parent, with attributes, given to the selected photos at once,
/// in one undo step; `keyword.info` reads it back. A keyword that exists isn't created again.
#[test]
fn creating_a_keyword_with_attributes_and_the_selection() {
let mut s = Session::with_demo();
let id = s.visible_cloned()[0].0;
s.execute("library.select", &json!({"ids": [id]})).unwrap();
let undo = s.undo.len();
let r = s
.execute("keyword.create", &json!({"name": "Weddings", "parent": "Events", "synonyms": ["marriage"], "person": false, "addToSelected": true}))
.unwrap();
assert_eq!(r["keyword"], "Events|Weddings", "{r}");
assert_eq!(s.undo.len(), undo + 1, "one undo step");
assert!(keywords_of(&s, id).contains(&"Events|Weddings".to_string()));
let info = s.execute("keyword.info", &json!({"keyword": "events|weddings"})).unwrap();
assert_eq!(info["path"], "Events|Weddings");
assert_eq!(info["synonyms"], json!(["marriage"]));
assert_eq!((info["includeOnExport"].as_bool(), info["listed"].as_bool(), info["count"].as_u64()), (Some(true), Some(true), Some(1)));
let err = s.execute("keyword.create", &json!({"name": "weddings", "parent": "events"})).unwrap_err().to_string();
assert!(err.contains("already"), "{err}");
assert!(s.execute("keyword.info", &json!({"keyword": "lisbon"})).is_err(), "no such keyword");
}
/// "Put new keywords inside this keyword": new keywords go inside the default parent unless the
/// command says otherwise (`parent: null` = the top level). It follows the parent's rename, goes
/// when the parent is deleted, and is kept with the library.
#[test]
fn new_keywords_go_inside_the_default_parent() {
let dir = temp_dir("default-parent");
let mut s = Session::new();
s.open_library(&dir, true).unwrap();
s.execute("keyword.create", &json!({"name": "Events"})).unwrap();
s.execute("keyword.setDefaultParent", &json!({"keyword": "events"})).unwrap();
assert_eq!(s.keyword_parent.as_deref(), Some("Events"));
assert_eq!(s.execute("keyword.create", &json!({"name": "Birthdays"})).unwrap()["keyword"], "Events|Birthdays");
assert_eq!(s.execute("keyword.create", &json!({"name": "Travel", "parent": null})).unwrap()["keyword"], "Travel");
s.execute("keyword.rename", &json!({"from": "Events", "to": "Occasions"})).unwrap();
assert_eq!(s.keyword_parent.as_deref(), Some("Occasions"), "follows the rename");
drop(s);
let mut s = Session::new();
s.open_library(&dir, false).unwrap();
assert_eq!(s.keyword_parent.as_deref(), Some("Occasions"), "kept with the library");
s.execute("keyword.delete", &json!({"keyword": "occasions"})).unwrap();
assert_eq!(s.keyword_parent, None, "gone with its keyword");
assert!(s.execute("keyword.setDefaultParent", &json!({"keyword": "lisbon"})).is_err(), "no such keyword");
let _ = std::fs::remove_dir_all(&dir);
}
/// Edit Keyword: a new name and attributes in one step; attributes not given keep their values.
#[test]
fn editing_a_keyword_changes_what_it_is_given() {
let mut s = Session::with_demo();
s.execute("keyword.create", &json!({"name": "Weddings", "synonyms": ["marriage"]})).unwrap();
let undo = s.undo.len();
s.execute("keyword.edit", &json!({"keyword": "weddings", "name": "Marriages", "includeOnExport": false})).unwrap();
assert_eq!(s.undo.len(), undo + 1);
let info = s.execute("keyword.info", &json!({"keyword": "Marriages"})).unwrap();
assert_eq!((info["includeOnExport"].as_bool(), info["synonyms"].clone()), (Some(false), json!(["marriage"])));
assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err());
}
/// Move Keyword: inside another or to the top level. Onto a name that is taken it is refused with
/// a message saying it would merge, unless `merge: true`.
#[test]
fn moving_a_keyword_merges_only_when_asked() {
let mut s = Session::with_demo();
let ids: Vec<u64> = s.visible_cloned().iter().take(2).map(|p| p.0).collect();
s.execute("photo.setMeta", &json!({"ids": [ids[0]], "keywords": ["Rome"]})).unwrap();
s.execute("photo.setMeta", &json!({"ids": [ids[1]], "keywords": ["Europe|Rome"]})).unwrap();
let err = s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe"})).unwrap_err().to_string();
assert!(err.contains("merge"), "{err}");
s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe", "merge": true})).unwrap();
assert_eq!(keywords_of(&s, ids[0]), ["Europe|Rome"]);
s.execute("keyword.move", &json!({"keyword": "europe|rome", "parent": null})).unwrap();
assert_eq!((keywords_of(&s, ids[0]), keywords_of(&s, ids[1])), (vec!["Rome".to_string()], vec!["Rome".to_string()]));
assert!(s.execute("keyword.move", &json!({"keyword": "rome"})).is_err(), "`parent` must be said, null for the top level");
}
/// Purge Unused Keywords takes off the list the keywords no photo has, in one undo step.
#[test]
fn purging_unused_keywords() {
let mut s = Session::with_demo();
s.execute("keyword.create", &json!({"name": "Weddings"})).unwrap();
let r = s.execute("keyword.purgeUnused", &json!({})).unwrap();
assert_eq!(r["purged"], 1, "{r}");
assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_ok());
}