From 7fd21517ae107405aeb887eabd3040c1ec4880cc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:00:15 -0400 Subject: [PATCH] LR-LIB-KEYWORD: library-wide keyword rename/delete/merge, left-panel keyword tree, suggestions Catalog keyword ops (one SetMeta batch: undoable, replayed from the op log), hierarchical a|b|c keywords (tree with counts, parent filters include children), keyword.* commands, rename/merge dialogs, Keywords-panel suggestions; catalog, engine and headless tests. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 257 +++++++++++++++++++++++++++ crates/catalog/src/lib.rs | 2 + crates/catalog/src/query.rs | 5 +- crates/engine/src/cmd/keywords.rs | 108 +++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/tests_libops.rs | 61 +++++++ crates/ui-egui/src/headless.rs | 38 ++++ crates/ui-egui/src/panels/dialogs.rs | 43 +++++ crates/ui-egui/src/panels/left.rs | 73 +++++++- crates/ui-egui/src/panels/right.rs | 33 ++-- crates/ui-egui/src/state.rs | 10 ++ docs/parity.md | 5 +- 12 files changed, 620 insertions(+), 17 deletions(-) create mode 100644 crates/catalog/src/keywords.rs create mode 100644 crates/engine/src/cmd/keywords.rs diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs new file mode 100644 index 000000000..d62d45c57 --- /dev/null +++ b/crates/catalog/src/keywords.rs @@ -0,0 +1,257 @@ +//! Library-wide keyword operations: the keyword tree (hierarchical keywords are written +//! `parent|child|grandchild`), rename / delete / merge across every photo, and suggestions. +//! +//! Rename, delete and merge produce one [`Op::Batch`] of [`Op::SetMeta`]s — one per photo that +//! changes — so they are a single undo step and replay from the op log like any other edit. +//! Keyword names compare case-insensitively; renaming a keyword renames its children too +//! (`travel|italy` → `trips|italy`). + +use serde::Serialize; + +use crate::{Catalog, CatalogError, Meta, Op, Result}; + +/// Separator of hierarchical keyword levels. +pub const SEP: char = '|'; + +/// Normalize a keyword: trim every level, drop empty levels. +pub fn clean(k: &str) -> String { + k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect::>().join("|") +} + +/// `k` is `parent` or one of its descendants (`parent|…`), ignoring case. +pub fn is_under(k: &str, parent: &str) -> bool { + let (k, p) = (k.to_lowercase(), parent.to_lowercase()); + k == p || k.strip_prefix(&p).is_some_and(|rest| rest.starts_with(SEP)) +} + +/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. +fn reparent(k: &str, from: &str, to: &str) -> String { + let rest = &k[from.len().min(k.len())..]; + if to.is_empty() { rest.trim_start_matches(SEP).to_string() } else { format!("{to}{rest}") } +} + +/// Keep the first of case-insensitively equal keywords. +fn dedupe(v: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + v.retain(|k| !k.is_empty() && seen.insert(k.to_lowercase())); +} + +/// One node of the keyword tree. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct KeywordNode { + /// This level's name (`italy`). + pub name: String, + /// The full keyword (`travel|italy`). + pub path: String, + /// Photos (not deleted) with this keyword or one below it. + pub count: usize, + pub children: Vec, +} + +impl Catalog { + /// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive). + pub fn keyword_tree(&self) -> Vec { + fn insert(nodes: &mut Vec, parts: &[&str], prefix: &str, photo: u64, seen: &mut std::collections::HashSet<(String, u64)>) { + let Some((first, rest)) = parts.split_first() else { return }; + let path = if prefix.is_empty() { first.to_string() } else { format!("{prefix}|{first}") }; + let i = match nodes.iter().position(|n| n.name.eq_ignore_ascii_case(first)) { + Some(i) => i, + None => { + nodes.push(KeywordNode { name: first.to_string(), path: path.clone(), count: 0, children: Vec::new() }); + nodes.len() - 1 + } + }; + if seen.insert((path.to_lowercase(), photo)) { + nodes[i].count += 1; + } + let p = nodes[i].path.clone(); + insert(&mut nodes[i].children, rest, &p, photo, seen); + } + fn sort(nodes: &mut [KeywordNode]) { + nodes.sort_by_key(|n| n.name.to_lowercase()); + for n in nodes { + sort(&mut n.children); + } + } + let mut roots = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for p in self.photos().filter(|p| !p.deleted) { + for k in &p.meta.keywords { + let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect(); + insert(&mut roots, &parts, "", p.id.0, &mut seen); + } + } + sort(&mut roots); + roots + } + + /// `SetMeta` ops for every photo whose keywords `f` changes. + fn keyword_ops(&self, f: impl Fn(&[String]) -> Vec) -> Vec { + self.photos() + .filter_map(|p| { + let mut kws = f(&p.meta.keywords); + dedupe(&mut kws); + (kws != p.meta.keywords).then(|| Op::SetMeta { id: p.id, meta: Box::new(Meta { keywords: kws, ..p.meta.clone() }) }) + }) + .collect() + } + + /// Rename `from` (and the keywords below it) to `to` on every photo — one batch. Renaming onto + /// an existing keyword merges the two. + pub fn rename_keyword_ops(&self, from: &str, to: &str) -> Result { + let (from, to) = (clean(from), clean(to)); + if from.is_empty() || to.is_empty() { + return Err(CatalogError::Invalid("keyword names can't be empty".into())); + } + if is_under(&to, &from) && !to.eq_ignore_ascii_case(&from) { + return Err(CatalogError::Invalid("can't move a keyword below itself".into())); + } + let ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect()); + Ok(Op::Batch { ops }) + } + + /// Remove `keyword` and the keywords below it from every photo. + pub fn delete_keyword_ops(&self, keyword: &str) -> Result { + let k = clean(keyword); + if k.is_empty() { + return Err(CatalogError::Invalid("empty keyword".into())); + } + Ok(Op::Batch { ops: self.keyword_ops(|kws| kws.iter().filter(|x| !is_under(x, &k)).cloned().collect()) }) + } + + /// Merge several keywords (with their children) into `into`. + pub fn merge_keywords_ops(&self, from: &[String], into: &str) -> Result { + let into = clean(into); + let from: Vec = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case(&into)).collect(); + if into.is_empty() || from.is_empty() { + return Err(CatalogError::Invalid("merge needs keywords and a target".into())); + } + if from.iter().any(|f| is_under(&into, f)) { + return Err(CatalogError::Invalid("can't merge a keyword into one below it".into())); + } + let ops = self.keyword_ops(|kws| { + kws.iter().map(|k| from.iter().find(|f| is_under(k, f)).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect() + }); + Ok(Op::Batch { ops }) + } + + /// Keyword suggestions for a photo that has `current` keywords: with a typed `prefix`, the + /// library's keywords containing it (those starting with it first); otherwise keywords that + /// appear together with `current` on other photos, then the most used ones. Most frequent + /// first, at most `n`, never one of `current`. + pub fn keyword_suggestions(&self, current: &[String], prefix: &str, n: usize) -> Vec { + let all = self.keywords(); + let has = |k: &str| current.iter().any(|c| c.eq_ignore_ascii_case(k)); + let q = prefix.trim().to_lowercase(); + let mut scored: Vec<(i64, String)> = if !q.is_empty() { + all.into_iter() + .filter(|(k, _)| !has(k)) + .filter_map(|(k, c)| { + let l = k.to_lowercase(); + let leaf = l.rsplit(SEP).next().unwrap_or(&l).to_string(); + let rank = if l.starts_with(&q) || leaf.starts_with(&q) { + 2 + } else if l.contains(&q) { + 1 + } else { + return None; + }; + Some((rank * 1_000_000 + c as i64, k)) + }) + .collect() + } else { + let mut co: std::collections::HashMap = Default::default(); + if !current.is_empty() { + for p in self.photos().filter(|p| !p.deleted) { + if p.meta.keywords.iter().any(|k| has(k)) { + for k in p.meta.keywords.iter().filter(|k| !has(k)) { + *co.entry(k.clone()).or_default() += 1; + } + } + } + } + all.into_iter().filter(|(k, _)| !has(k)).map(|(k, c)| (co.get(&k).copied().unwrap_or(0) * 1_000_000 + c as i64, k)).collect() + }; + scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase()))); + scored.into_iter().take(n).map(|(_, k)| k).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Filter, Photo, PhotoId, Source}; + + fn lib(kws: &[&[&str]]) -> (Catalog, Vec) { + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for k in kws { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); + p.meta.keywords = k.iter().map(|s| s.to_string()).collect(); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + (c, ids) + } + fn kws(c: &Catalog, id: PhotoId) -> Vec { + c.photo(id).unwrap().meta.keywords.clone() + } + + #[test] + fn tree_counts_photos_per_level() { + let (c, _) = lib(&[&["travel|Italy|Rome", "beach"], &["Travel|italy"], &["travel|France", "travel|italy|rome"], &["beach"]]); + let t = c.keyword_tree(); + assert_eq!(t.iter().map(|n| (n.name.as_str(), n.count)).collect::>(), vec![("beach", 2), ("travel", 3)]); + let travel = &t[1]; + assert_eq!(travel.children.iter().map(|n| (n.path.as_str(), n.count)).collect::>(), vec![("travel|France", 1), ("travel|Italy", 3)]); + assert_eq!(travel.children[1].children[0].path, "travel|Italy|Rome"); + assert_eq!(travel.children[1].children[0].count, 2); + // filtering by a parent finds its children + let f = Filter { keyword: Some("travel|italy".into()), ..Default::default() }; + assert_eq!(c.query(&f, &Default::default()).len(), 3); + } + + #[test] + fn rename_delete_merge_are_single_undoable_batches() { + let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]); + let before = c.to_snapshot(); + let op = c.rename_keyword_ops("travel|italy", "Europe|Italy").unwrap(); + let Op::Batch { ops } = &op else { panic!() }; + assert_eq!(ops.len(), 3, "only photos that change"); + let inv = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Europe|Italy|rome", "beach"]); + assert_eq!(kws(&c, ids[1]), ["Europe|Italy"]); + c.apply(inv).unwrap(); + assert_eq!(c.to_snapshot(), before); + // merge `italia` into the existing hierarchical keyword: duplicates collapse + let op = c.merge_keywords_ops(&["italia".into()], "travel|italy").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[2]), ["travel|italy"]); + // delete removes the keyword and its children everywhere + let op = c.delete_keyword_ops("TRAVEL").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["beach"]); + assert!(kws(&c, ids[1]).is_empty()); + assert_eq!(c.keywords(), vec![("beach".to_string(), 1), ("sea".to_string(), 1)]); + // invalid requests + assert!(c.rename_keyword_ops("beach", " ").is_err()); + assert!(c.rename_keyword_ops("a", "a|b").is_err()); + assert!(c.merge_keywords_ops(&["a".into()], "a|b").is_err()); + assert_eq!(clean(" a | |b "), "a|b"); + } + + #[test] + fn suggestions_rank_co_occurrence_and_prefixes() { + let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); + // co-occurring with `dog` first (park twice), then the most used + let s = c.keyword_suggestions(&["dog".into()], "", 3); + assert_eq!(s, ["park", "ball", "beach"]); + let s = c.keyword_suggestions(&[], "", 2); + assert_eq!(s, ["cat", "dog"]); + let s = c.keyword_suggestions(&["park".into()], "pa", 5); + assert_eq!(s, ["parade"]); + let s = c.keyword_suggestions(&[], "E", 5); + assert_eq!(s, ["eagle", "beach", "parade"], "prefix matches before substring matches"); + } +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 40d78b56f..652d74320 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -9,6 +9,7 @@ pub mod dates; pub mod journal; +pub mod keywords; pub mod model; pub mod query; pub mod stacks; @@ -19,6 +20,7 @@ use std::sync::Arc; pub use dates::{DateRun, GroupBy}; pub use journal::{Journal, LoadReport, SnapshotPolicy}; +pub use keywords::KeywordNode; use lightcraft_develop::DevelopSettings; pub use model::*; pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey}; diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index fc280c03f..046378193 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -31,6 +31,7 @@ pub struct Filter { pub deleted: bool, /// Capture date prefix (`2026`, `2026-04`, `2026-04-12`). pub date: Option, + /// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`). pub keyword: Option, pub camera: Option, /// Lens (case-insensitive substring). @@ -95,7 +96,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)), "camera" => p.meta.camera.to_lowercase().contains(val), "lens" => p.meta.lens.to_lowercase().contains(val), - "keyword" | "kw" => p.meta.keywords.iter().any(|k| k.to_lowercase() == val), + "keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)), "type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val), "edited" => (val == "true" || val == "yes") == p.is_edited(), "date" => p.date().starts_with(val), @@ -202,7 +203,7 @@ impl Filter { return false; } if let Some(k) = &self.keyword - && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(k)) + && !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k)) { return false; } diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs new file mode 100644 index 000000000..e330e65b6 --- /dev/null +++ b/crates/engine/src/cmd/keywords.rs @@ -0,0 +1,108 @@ +//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge. + +use lightcraft_catalog::keywords::{clean, is_under}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::{Result, Session}; + +fn strs(p: &Value, key: &str) -> Vec { + match p.get(key) { + Some(Value::String(s)) => vec![s.clone()], + Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + _ => Vec::new(), + } +} + +/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed +/// keyword and is cleared when its 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(), + _ => 1, + }; + if n > 0 { + s.commit(label, op)?; + } + if let Some(k) = s.filter.keyword.clone() { + s.filter.keyword = follow(&k); + } + Ok(json!({"changed": n})) +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "keyword.list", "Keywords", [], None, "{} → [{name, path, count, children}] keyword tree (`a|b|c` keywords are hierarchical)", always, |s, _| { + Ok(serde_json::to_value(s.catalog.keyword_tree()).unwrap_or_default()) + }), + cmd!( + query "keyword.suggest", + "Keyword Suggestions", + [], + None, + "{prefix?: typed text, ids?, limit?: 12} → keywords to suggest for the photos (co-occurring / most used, or matching the prefix)", + always, + |s, p| { + let mut current: Vec = Vec::new(); + for id in s.targets(p) { + if let Some(ph) = s.catalog.photo(id) { + current.extend(ph.meta.keywords.iter().cloned()); + } + } + let n = p.get("limit").and_then(Value::as_u64).unwrap_or(12) as usize; + Ok(json!(s.catalog.keyword_suggestions(¤t, str_param(p, "prefix").unwrap_or(""), n))) + } + ), + cmd!( + "keyword.rename", + "Rename Keyword", + [], + None, + "{from, to} — on every photo, children included (`a` → `b` renames `a|x` to `b|x`); renaming onto an existing keyword merges them", + always, + |s, p| { + let from = str_param(p, "from").ok_or_else(|| bad("keyword.rename", "missing `from`"))?.to_string(); + let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string(); + let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?; + let (f, t) = (clean(&from), clean(&to)); + commit_keywords(s, "Rename Keyword", op, |k| { + Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() }) + }) + } + ), + cmd!( + "keyword.delete", + "Delete Keyword", + [], + None, + "{keyword} — removes it (and the keywords below it) from every photo", + always, + |s, p| { + let k = str_param(p, "keyword").ok_or_else(|| bad("keyword.delete", "missing `keyword`"))?.to_string(); + let op = s.catalog.delete_keyword_ops(&k).map_err(|e| bad("keyword.delete", e.to_string()))?; + let c = clean(&k); + commit_keywords(s, "Delete Keyword", op, |f| (!is_under(f, &c)).then(|| f.to_string())) + } + ), + cmd!( + "keyword.merge", + "Merge Keywords", + [], + None, + "{from: [keyword], into: keyword} — replaces each `from` keyword (children included) with `into` on every photo", + always, + |s, p| { + let from = strs(p, "from"); + let into = str_param(p, "into").ok_or_else(|| bad("keyword.merge", "missing `into`"))?.to_string(); + let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?; + let (from, into) = (from.iter().map(|f| clean(f)).collect::>(), clean(&into)); + commit_keywords(s, "Merge Keywords", op, |k| { + Some(match from.iter().find(|f| is_under(k, f)) { + Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]), + None => k.to_string(), + }) + }) + } + ), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8a385ec56..825c89f3f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,7 @@ mod color; mod develop; mod edit; +mod keywords; mod library; mod masks; mod merge; @@ -107,6 +108,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(color::specs()); v.extend(masks::specs()); v.extend(organize::specs()); + v.extend(keywords::specs()); v.extend(merge::specs()); v.extend(query::specs()); v.extend(xmp::specs()); diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index d4e79dbe9..6cc68355c 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -32,3 +32,64 @@ fn date_groups_follow_sort_and_grouping() { assert_eq!(s.sort.group, lightcraft_catalog::GroupBy::Year); assert!(s.execute("library.sort", &json!({"group": "week"})).is_err()); } + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-libops-{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() +} + +/// Keyword rename / merge / delete: one undo step each, replayed from the op log after a restart, +/// and the keyword filter follows the renamed keyword. +#[test] +fn keyword_rename_merge_delete_undo_and_replay() { + let dir = temp_dir("kw"); + let mut s = Session::new(); + s.open_library(&dir, true).unwrap(); + let ids: Vec = s.visible_cloned().iter().take(4).map(|p| p.0).collect(); + s.execute("photo.setMeta", &json!({"ids": [ids[0], ids[1]], "keywords": ["travel|italy|rome", "sea"]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[2]], "keywords": ["Italia"]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[3]], "keywords": ["travel|france", "sea"]})).unwrap(); + let tree = s.execute("keyword.list", &json!({})).unwrap(); + let travel = tree.as_array().unwrap().iter().find(|n| n["name"] == "travel").unwrap().clone(); + assert_eq!(travel["count"], 3, "{tree}"); + assert_eq!(travel["children"][1]["path"], "travel|italy"); + // filter by a parent keyword: its children match + s.execute("library.filter", &json!({"keyword": "travel|italy"})).unwrap(); + assert_eq!(s.visible().len(), 2); + let undo_before = s.undo.len(); + let r = s.execute("keyword.rename", &json!({"from": "travel|italy", "to": "Europe|Italy"})).unwrap(); + assert_eq!(r["changed"], 2); + assert_eq!(s.undo.len(), undo_before + 1, "one undo step"); + assert_eq!(keywords_of(&s, ids[0]), ["Europe|Italy|rome", "sea"]); + assert_eq!(s.filter.keyword.as_deref(), Some("Europe|Italy"), "the filter follows the rename"); + assert_eq!(s.visible().len(), 2); + s.execute("keyword.merge", &json!({"from": ["Italia"], "into": "Europe|Italy"})).unwrap(); + assert_eq!(keywords_of(&s, ids[2]), ["Europe|Italy"]); + assert_eq!(s.visible().len(), 3); + s.execute("keyword.delete", &json!({"keyword": "travel"})).unwrap(); + assert_eq!(keywords_of(&s, ids[3]), ["sea"]); + // suggestions: keywords used together with `sea` first + let sug = s.execute("keyword.suggest", &json!({"ids": [ids[3]]})).unwrap(); + assert_eq!(sug[0], "Europe|Italy|rome", "{sug}"); + let sug = s.execute("keyword.suggest", &json!({"ids": [ids[3]], "prefix": "eur"})).unwrap(); + assert_eq!(sug, json!(["Europe|Italy|rome", "Europe|Italy"]), "most used first"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(keywords_of(&s, ids[3]), ["travel|france", "sea"]); + s.execute("edit.redo", &json!({})).unwrap(); + // restart without a clean close: the log replays to the same state + let expect = s.catalog.to_snapshot(); + drop(s); + let mut s = Session::new(); + s.open_library(&dir, false).unwrap(); + assert_eq!(s.catalog.to_snapshot(), expect); + // invalid + assert!(s.execute("keyword.rename", &json!({"from": "sea", "to": ""})).is_err()); + assert!(s.execute("keyword.merge", &json!({"from": [], "into": "x"})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b4ce19516..9404cd567 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -399,6 +399,44 @@ mod tests { h.settle(SETTLE); } + /// Keywords: the left-panel tree filters (children included), opens levels, the rename dialog + /// renames library-wide, and the Keywords panel adds a suggestion. + #[test] + fn keyword_list_filters_renames_and_suggests() { + // tall: the demo library's own keywords come first in the list + let mut h = demo([1300.0, 1800.0]); + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10)); + ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]})); + ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]})); + h.request("ui.set", json!({"leftPanel": true}), t); + let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel")); + assert_eq!(h.app.session.visible_cloned().len(), 3); + // open the level, filter by the child + h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t); + let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.visible_cloned().len(), 2); + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "travel".into(), to: "trips".into() }); + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.filter.keyword.as_deref(), Some("trips|italy")); + assert_eq!(h.app.session.visible_cloned().len(), 2); + // Keywords panel: suggestions co-occurring with the photo's keywords + h.request("engine.execute", json!({"command": "library.clearFilter"}), t); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t); + ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]})); + h.request("ui.set", json!({"right": "keywords"}), t); + let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string())); + h.settle(SETTLE); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0ac333088..57e990f03 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -22,6 +22,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::NewAlbum { folder: true, .. } => "Create Folder", Dialog::NewAlbum { .. } => "Create Album", Dialog::RenameAlbum { .. } => "Rename Album", + Dialog::RenameKeyword { .. } => "Rename Keyword", + Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::AutoStack { .. } => "Auto-Stack by Capture Time", Dialog::CreatePreset { .. } => "Create Preset", @@ -70,6 +72,45 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .color(t.text_dim), ); } + Dialog::RenameKeyword { from, to } => { + let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); + let r = ui.add(egui::TextEdit::singleline(to).hint_text("New name").desired_width(f32::INFINITY)); + crate::widgets::register(ui.ctx(), "field:keywordName", r.rect); + r.request_focus(); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + confirm = true; + } + ui.label( + egui::RichText::new(format!( + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.", + if n == 1 { "" } else { "s" } + )) + .color(t.text_dim), + ); + } + Dialog::MergeKeywords { from, into } => { + ui.label(egui::RichText::new(format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::>().join(", "))).color(t.text_label)); + let r = ui.add(egui::TextEdit::singleline(into).hint_text("Keyword").desired_width(f32::INFINITY)); + crate::widgets::register(ui.ctx(), "field:keywordInto", r.rect); + r.request_focus(); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + confirm = true; + } + let options: Vec = app + .session + .catalog + .keyword_suggestions(from, into, 12) + .into_iter() + .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) + .collect(); + ui.horizontal_wrapped(|ui| { + for k in options { + if ui.add(egui::Button::new(egui::RichText::new(&k).color(t.text_label)).corner_radius(10.0)).clicked() { + *into = k; + } + } + }); + } Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); r.request_focus(); @@ -280,6 +321,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})), Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), + Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})), Dialog::CreatePreset { name, group } => { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 48ecc1754..08093504c 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1,7 +1,7 @@ //! The left "My Photos" panel: library sources, albums tree, and date groups. use egui::{Align2, Rect, Sense, pos2, vec2}; -use lightcraft_catalog::{Album, AlbumId}; +use lightcraft_catalog::{Album, AlbumId, KeywordNode}; use lightcraft_engine::LibrarySource; use serde_json::json; @@ -108,6 +108,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("library.filter", json!({"date": v})); } } + keywords_section(app, ui); ui.add_space(10.0); if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0) .clicked() @@ -165,3 +166,73 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } }); } + +/// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click +/// filters the grid by the keyword (children included), the triangle opens a level, and the +/// context menu renames, merges or deletes the keyword across the library. +fn keywords_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let tree = app.session.catalog.keyword_tree(); + if tree.is_empty() { + return; + } + ui.add_space(10.0); + let (kr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); + ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, "Keywords", t.semibold(13.5), t.text_label); + keyword_rows(app, ui, &tree, 0.0); +} + +fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { + let t = Tokens::get(ui.ctx()); + for n in nodes { + let open_id = egui::Id::new(("kw-open", n.path.to_lowercase())); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); + let resp = row(app, ui, &format!("keyword:{}", n.path), Icon::Tag, &n.name, Some(n.count), sel, indent); + if !n.children.is_empty() { + // disclosure triangle left of the icon + let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y); + let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tr = ui.interact(tri, egui::Id::new(("kw-tri", n.path.to_lowercase())), Sense::click()); + register(ui.ctx(), format!("keywordToggle:{}", n.path), tri); + let col = if tr.hovered() { t.text } else { t.text_dim }; + let pts = if open { + vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] + } else { + vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE)); + if tr.clicked() { + open = !open; + ui.data_mut(|d| d.insert_temp(open_id, open)); + } + } + let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(n.path) }; + let _ = app.run("library.filter", json!({"keyword": v})); + } + resp.context_menu(|ui| { + let has_sel = app.session.active().is_some(); + if ui.add_enabled(has_sel, egui::Button::new("Add to Selected Photos")).clicked() { + let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); + } + if ui.add_enabled(has_sel, egui::Button::new("Remove from Selected Photos")).clicked() { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); + } + ui.separator(); + if ui.button("Rename Keyword…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); + } + if ui.button("Merge into…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); + } + if ui.button("Delete Keyword").clicked() { + let _ = app.run("keyword.delete", json!({"keyword": n.path})); + } + }); + if open && !n.children.is_empty() { + keyword_rows(app, ui, &n.children, indent + 16.0); + } + } +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 016c9a1d0..b97a099de 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -377,24 +377,35 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(8.0); ui.horizontal_wrapped(|ui| { for k in &p.meta.keywords { - let r = ui.add(egui::Button::new(egui::RichText::new(format!("{k} ×")).color(t.text_label)).corner_radius(10.0)); + let r = + ui.add(egui::Button::new(egui::RichText::new(format!("{} ×", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0)); if r.clicked() { let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]})); } } }); ui.add_space(10.0); - ui.label(egui::RichText::new("Keywords in library").color(t.text_dim)); - let all = app.session.catalog.keywords(); - ui.horizontal_wrapped(|ui| { - for (k, n) in all { - if !p.meta.keywords.contains(&k) - && ui.add(egui::Button::new(egui::RichText::new(format!("{k} {n}")).color(t.text_dim)).frame(false)).clicked() - { - let _ = app.run("photo.setMeta", json!({"addKeywords": [k]})); + // suggestions: completions of the typed text, else keywords used together with this + // photo's keywords, else the most used ones + let typed = ui.data(|d| d.get_temp::(kid).unwrap_or_default()); + let last = typed.rsplit(',').next().unwrap_or("").trim().to_string(); + let suggestions = app.session.catalog.keyword_suggestions(&p.meta.keywords, &last, 12); + if !suggestions.is_empty() { + ui.label(egui::RichText::new("Suggestions").color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + for k in suggestions { + let r = ui + .add(egui::Button::new(egui::RichText::new(format!("+ {}", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0)); + register(ui.ctx(), format!("kwSuggest:{k}"), r.rect); + if r.clicked() { + let _ = app.run("photo.setMeta", json!({"addKeywords": [k]})); + if !last.is_empty() { + ui.data_mut(|d| d.insert_temp(kid, String::new())); + } + } } - } - }); + }); + } }); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e3ba2821..9d5ceb9d6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -151,6 +151,16 @@ pub enum Dialog { id: u64, name: String, }, + /// Rename a keyword on every photo (children included). + RenameKeyword { + from: String, + to: String, + }, + /// Merge keywords into another one on every photo. + MergeKeywords { + from: Vec, + into: String, + }, /// Auto-stack by capture time: the largest gap between consecutive shots, in seconds. AutoStack { gap: f32, diff --git a/docs/parity.md b/docs/parity.md index 8136d9b19..605d12c37 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -66,7 +66,6 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 6. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; live preview while hovering presets/profiles/versions. Medium effort. 7. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). -8. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. 9. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), XMP prefs, cache, GPU. Medium effort. 10. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination @@ -150,7 +149,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | | | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | 🟡 | `cmd:photo.label`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | no label menu/buttons, no purple key, label names not editable | -| LR-LIB-KEYWORD | Keywords | P0 | 🟡 | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`) | no library-wide rename/delete, no keyword browser in the left panel | +| LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | | LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | @@ -722,7 +721,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | 🟡 | `crates/catalog/src/stacks.rs` | group/ungroup/toggle/top/remove/auto by time; no split stack or move up/down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ⬜ | | | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ⬜ | | flat keywords only (LR-LIB-KEYWORD) | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | 🟡 | `crates/catalog/src/keywords.rs`, `cmd:keyword.list` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); no keyword sets or painter | | LRC-LIB-METADATA | Metadata panel & presets | P1 | 🟡 | `cmd:photo.setMeta`, `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile` | no metadata presets, capture-time edit, copyright status | | LRC-LIB-QUICKDEV | Quick develop | P2 | ⬜ | | | | LRC-LIB-PEOPLE | People view | P2 | ⬜ | | |