mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 20:48:33 -06:00
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:
co-authored by
Claude Opus 5.5
parent
cdca2b9cf8
commit
10c98459fe
@@ -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(¤t, 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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user