diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 8c21d6579..bf5f95fbd 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -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 { @@ -15,8 +15,8 @@ fn strs(p: &Value, key: &str) -> Vec { } } -/// 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) -> Result { 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 { + 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 { 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", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aab6c0277..b676c8fba 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -247,6 +247,8 @@ pub struct Session { pub keyword_sets: Vec, pub keyword_set: Option, pub recent_keywords: Vec, + /// "Put new keywords inside this keyword": the parent `keyword.create` uses unless told. + pub keyword_parent: Option, /// 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>, @@ -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, diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index a83dc4a82..1524e7032 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -149,6 +149,8 @@ struct PrefsFile { keyword_sets: Vec, keyword_set: Option, recent_keywords: Vec, + /// The parent new keywords go inside. + keyword_parent: Option, /// 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()), diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index 04a78fa8c..261ad99a8 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -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 { + 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 = 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()); +}