Files
lightcraft/crates/catalog/src/keywords.rs
T
Matthew James SivakandClaude Opus 5.5 6797c6b483 fix: share the sidebar's off-screen row skip, keep widths stable (#1011)
Review follow-up on the keyword sidebar fix:
- The skip is a shared helper, `out_of_sight`, that Keywords and Folders
  call before a row's work; a row out of sight still takes its room, is
  listed for the control channel (agents and tests find it as before) and
  asks for its measured width, so the content no longer widens and
  narrows as rows scroll by.
- The scroll to a keyword made from the header is a per-frame request
  for any row id (`SCROLL_TO_ROW`), set before the sections are drawn and
  cleared after: no frame stamp, nothing left behind.
- keyword_rows/folder_rows are skip-or-draw then recurse (the drawn row
  is keyword_row/folder_row); leaves no longer look up an open state.
- keyword_tree builds children lists as levels are made and sorts by the
  lower-case name it already computed; empty_hint uses ROW_HEIGHT; the
  test reuses tests_panels::scroll_sidebar.

58,000 photos / 4,000 keywords, release: 1.6 ms per unchanged frame
(7.4 ms before the fix), keyword_tree 14 ms (156 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:11:02 +02:00

1474 lines
74 KiB
Rust

//! 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`).
//!
//! The **keyword list** holds keywords on their own ([`Op::SetKeyword`]): created before any
//! photo has them, or given attributes ([`KeywordInfo`]: synonyms, export options, person). The
//! tree is the keyword list together with the keywords photos carry.
use std::collections::BTreeMap;
use serde::{Deserialize, 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::<Vec<_>>().join("|")
}
/// The same name, whatever the case of any of its letters (not only ASCII ones).
pub fn same(a: &str, b: &str) -> bool {
a == b || a.to_lowercase() == b.to_lowercase()
}
/// `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`. By levels, not bytes:
/// `from` may be written in another case, which can change a letter's length (ẞ / ß).
pub fn reparent(k: &str, from: &str, to: &str) -> String {
let skip = from.split(SEP).filter(|s| !s.trim().is_empty()).count();
let rest: Vec<&str> = k.split(SEP).filter(|s| !s.trim().is_empty()).skip(skip).collect();
std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::<Vec<_>>().join("|")
}
/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, then the
/// names of `dc:subject` that aren't levels of those paths (Lightroom Classic writes each keyword's
/// name, its parents' and synonyms flat besides its path). Each once, cleaned.
pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec<String> {
let mut out: Vec<String> = hierarchical.iter().map(|k| clean(k)).collect();
out.extend(flat.iter().map(|k| clean(k)).filter(|k| !hierarchical.iter().any(|h| h.split(SEP).any(|level| same(level.trim(), k)))));
dedupe(&mut out);
out
}
/// The deepest a keyword list file may nest keywords: a file is input, so its nesting is bounded.
pub const MAX_LEVELS: usize = 64;
/// A keyword list as written by [`Catalog::keyword_list_text`]: the file's text, the keywords in
/// it (paths), and what the format can't hold and so is left out (a keyword, with those inside it,
/// as its path; a synonym as `path {synonym}`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KeywordListFile {
pub text: String,
pub written: Vec<String>,
pub unwritable: Vec<String>,
}
/// Read a keyword list file (Lightroom Classic's format): a keyword a line, those inside it
/// indented one tab more, a synonym in braces on a line of its own under its keyword, a keyword
/// left out of export in square brackets. Gives each keyword's path and attributes, in the order of
/// the file; a keyword twice is one. A line it can't read is an error that says which.
pub fn parse_keyword_list(text: &str) -> Result<Vec<(String, KeywordInfo)>> {
let bad = |n: usize, why: &str| CatalogError::Invalid(format!("line {n}: {why}"));
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
// Windows, Unix and classic Mac line ends alike
let text = text.replace("\r\n", "\n").replace('\r', "\n");
let mut out: Vec<(String, KeywordInfo)> = Vec::new();
// where each keyword is in `out`, by lower-case path (a search of `out` per line was quadratic)
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
// the keyword at each level above the line: its path and where it is in `out`
let mut above: Vec<(String, usize)> = Vec::new();
for (i, line) in text.split('\n').enumerate() {
let n = i + 1;
if line.trim().is_empty() {
continue;
}
let depth = line.chars().take_while(|c| *c == '\t').count();
let rest = line.trim_start_matches('\t');
// levels are tabs: spaces would flatten the hierarchy without a word
if rest.starts_with(' ') {
return Err(bad(n, "keywords are indented with tabs, one per level, not spaces"));
}
if rest.chars().any(char::is_control) {
return Err(bad(n, "a control character in a keyword"));
}
let body = rest.trim();
if let Some(synonym) = body.strip_prefix('{').and_then(|b| b.strip_suffix('}')) {
let Some(at) = depth.checked_sub(1).and_then(|d| above.get(d)).map(|(_, at)| *at) else {
return Err(bad(n, "a synonym (in braces) goes one tab under its keyword"));
};
let synonym = synonym.trim();
if synonym.contains(SEP) {
return Err(bad(n, "“|” separates a keyword's levels: a synonym can't hold one"));
}
if let Some((_, info)) = out.get_mut(at)
&& !synonym.is_empty()
&& !info.synonyms.iter().any(|s| same(s, synonym))
{
info.synonyms.push(synonym.to_string());
}
continue;
}
if depth > above.len() {
return Err(bad(n, "indented more than one tab below the keyword above it"));
}
if depth >= MAX_LEVELS {
return Err(bad(n, &format!("keywords nest {MAX_LEVELS} levels deep at most")));
}
let (name, left_out) = match body.strip_prefix('[').and_then(|b| b.strip_suffix(']')) {
Some(name) => (name.trim(), true),
None => (body, false),
};
if name.is_empty() {
return Err(bad(n, "a keyword needs a name"));
}
if name.contains(SEP) {
return Err(bad(n, "“|” separates a keyword's levels: a name can't hold one"));
}
above.truncate(depth);
let path = match above.last() {
Some((parent, _)) => format!("{parent}{SEP}{name}"),
None => name.to_string(),
};
let at = match index.get(&path.to_lowercase()) {
Some(at) => *at,
None => {
out.push((path.clone(), KeywordInfo::default()));
index.insert(path.to_lowercase(), out.len() - 1);
out.len() - 1
}
};
if left_out && let Some((_, info)) = out.get_mut(at) {
info.include_on_export = false;
}
// as first spelled: the keywords inside a repeat take that spelling
let spelled = out.get(at).map_or(path, |(p, _)| p.clone());
above.push((spelled, at));
}
Ok(out)
}
/// The keyword of `from` that `k` is under, the closest one when several are (`a|b` before `a`).
pub fn closest<'a>(k: &str, from: &'a [String]) -> Option<&'a String> {
from.iter().filter(|f| is_under(k, f)).max_by_key(|f| f.split(SEP).count())
}
/// Keep the first of case-insensitively equal keywords.
fn dedupe(v: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
v.retain(|k| !k.is_empty() && seen.insert(k.to_lowercase()));
}
/// What the keyword list knows about a keyword besides the photos that carry it: Lightroom
/// Classic's keyword tag options. A keyword is listed when it was created on its own or given
/// attributes; one that only photos carry has the defaults.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KeywordInfo {
/// Other words for it, exported with it (when `export_synonyms`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub synonyms: Vec<String>,
/// Written into exported files at all.
#[serde(default = "yes")]
pub include_on_export: bool,
/// The keywords containing it are exported with it (`travel|italy` also gives `travel`).
#[serde(default = "yes")]
pub export_containing: bool,
#[serde(default = "yes")]
pub export_synonyms: bool,
/// The keyword names a person.
#[serde(default)]
pub person: bool,
/// New keywords go inside this one (Put New Keywords Inside This Keyword): one keyword at
/// most, see [`Catalog::default_keyword_parent`].
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub new_keywords_inside: bool,
}
fn yes() -> bool {
true
}
impl Default for KeywordInfo {
fn default() -> Self {
KeywordInfo {
synonyms: Vec::new(),
include_on_export: true,
export_containing: true,
export_synonyms: true,
person: false,
new_keywords_inside: false,
}
}
}
/// A keyword in the library's keyword list: its path as written and its attributes.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListedKeyword {
pub path: String,
pub info: KeywordInfo,
}
/// A photo's keywords as an exported file carries them.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ExportKeywords {
/// Names, flat (`dc:subject`): each keyword's own, the keywords containing it and synonyms, as
/// their options say.
pub flat: Vec<String>,
/// Full paths (`lr:hierarchicalSubject`).
pub hierarchical: Vec<String>,
}
/// 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<KeywordNode>,
}
impl Catalog {
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
use std::collections::HashMap;
// every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and
// path as first written, and the photos counted (once per photo, even when two of its
// keywords share a parent: `seen` is the last photo counted); `sort` is the name in lower case
struct Level {
name: String,
sort: String,
path: String,
count: usize,
seen: usize,
}
let mut levels: Vec<Level> = Vec::new();
let mut by_lower: HashMap<String, usize> = HashMap::new();
// each level's children (by index), and the top level
let mut kids: Vec<Vec<usize>> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
// the keyword list first (no photos counted): a listed keyword is there without photos
let listed: Vec<String> = self.listed_keywords().map(|k| k.path.clone()).collect();
// each keyword as written → its levels (its own and those above it), worked out once: a
// large library repeats a few thousand keywords tens of thousands of times (issue #1011)
let mut chains: HashMap<&str, Vec<usize>> = HashMap::new();
let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice()));
for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() {
for k in keywords {
let chain = chains.entry(k.as_str()).or_insert_with(|| {
let mut chain: Vec<usize> = Vec::new();
let mut path = String::new();
let mut lower = String::new();
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
if !path.is_empty() {
path.push(SEP);
lower.push(SEP);
}
path.push_str(part);
let sort = part.to_lowercase();
lower.push_str(&sort);
let i = match by_lower.get(&lower) {
Some(&i) => i,
None => {
let i = levels.len();
match chain.last().and_then(|&p| kids.get_mut(p)) {
Some(k) => k.push(i),
None => roots.push(i),
}
levels.push(Level { name: part.to_string(), sort, path: path.clone(), count: 0, seen: 0 });
kids.push(Vec::new());
by_lower.insert(lower.clone(), i);
i
}
};
chain.push(i);
}
chain
});
if counts {
for &i in chain.iter() {
let Some(l) = levels.get_mut(i) else { continue };
// photo 0 is the keyword list, which counts nothing
if l.seen != photo {
l.seen = photo;
l.count += 1;
}
}
}
}
}
fn build(mut list: Vec<usize>, levels: &[Level], kids: &mut [Vec<usize>]) -> Vec<KeywordNode> {
list.sort_by(|&a, &b| levels.get(a).map(|l| &l.sort).cmp(&levels.get(b).map(|l| &l.sort)));
list.into_iter()
.filter_map(|i| {
let lv = levels.get(i)?;
let below = kids.get_mut(i).map(std::mem::take).unwrap_or_default();
let children = build(below, levels, kids);
Some(KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children })
})
.collect()
}
build(roots, &levels, &mut kids)
}
/// `SetMeta` ops for every photo whose keywords `f` changes.
fn keyword_ops(&self, f: impl Fn(&[String]) -> Vec<String>) -> Vec<Op> {
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()
}
/// `SetKeyword` ops turning the keyword list into what `f` makes of it (lower-case path →
/// listing): what it no longer has is taken off first, then what is new or changed is set.
fn list_ops(&self, f: impl FnOnce(&mut BTreeMap<String, ListedKeyword>)) -> Vec<Op> {
let before = &self.keyword_list;
let mut after = before.clone();
f(&mut after);
let gone = before.iter().filter(|(k, _)| !after.contains_key(*k)).map(|(_, l)| Op::SetKeyword { path: l.path.clone(), info: None });
let set = after
.iter()
.filter(|(k, l)| before.get(*k) != Some(*l))
.map(|(_, l)| Op::SetKeyword { path: l.path.clone(), info: Some(l.info.clone()) });
gone.chain(set).collect()
}
/// The keyword list once the keywords under each of `from` move to `to`: a listing landing on
/// one already there merges into it (that one's attributes stay, the synonyms gather).
fn list_move_ops(&self, from: &[String], to: &str) -> Vec<Op> {
self.list_ops(|list| {
// every moving listing leaves first, then each lands: one landing where another is
// about to leave isn't merged into it
let moving: Vec<String> = list.iter().filter(|(_, l)| closest(&l.path, from).is_some()).map(|(k, _)| k.clone()).collect();
let left: Vec<ListedKeyword> = moving.iter().filter_map(|key| list.remove(key)).collect();
for l in left {
let Some(f) = closest(&l.path, from) else { continue };
let path = reparent(&l.path, f, to);
match list.get_mut(&path.to_lowercase()) {
Some(there) => {
there.info.new_keywords_inside |= l.info.new_keywords_inside;
for s in l.info.synonyms {
if !there.info.synonyms.iter().any(|x| same(x, &s)) {
there.info.synonyms.push(s);
}
}
}
None => {
list.insert(path.to_lowercase(), ListedKeyword { path, info: l.info });
}
}
}
})
}
/// Rename `from` (and the keywords below it) to `to` on every photo and in the keyword list —
/// one batch. Renaming onto an existing keyword merges the two.
pub fn rename_keyword_ops(&self, from: &str, to: &str) -> Result<Op> {
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) && !same(&to, &from) {
return Err(CatalogError::Invalid("can't move a keyword below itself".into()));
}
let mut ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect());
ops.extend(self.list_move_ops(std::slice::from_ref(&from), &to));
Ok(Op::Batch { ops })
}
/// Remove `keyword` and the keywords below it from every photo and from the keyword list.
pub fn delete_keyword_ops(&self, keyword: &str) -> Result<Op> {
let k = clean(keyword);
if k.is_empty() {
return Err(CatalogError::Invalid("empty keyword".into()));
}
let mut ops = self.keyword_ops(|kws| kws.iter().filter(|x| !is_under(x, &k)).cloned().collect());
ops.extend(self.list_ops(|list| list.retain(|_, l| !is_under(&l.path, &k))));
Ok(Op::Batch { ops })
}
/// Merge several keywords (with their children) into `into`.
pub fn merge_keywords_ops(&self, from: &[String], into: &str) -> Result<Op> {
let into = clean(into);
let from: Vec<String> = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !same(f, &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 mut ops =
self.keyword_ops(|kws| kws.iter().map(|k| closest(k, &from).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect());
ops.extend(self.list_move_ops(&from, &into));
Ok(Op::Batch { ops })
}
/// What exported files carry for a photo with these keywords (Lightroom Classic's keyword tag
/// options, [`KeywordInfo`]): a keyword left out of export goes nowhere; otherwise its name
/// and full path, the keywords containing it that are exported themselves (unless "export
/// containing keywords" is off) and synonyms (unless "export synonyms" is off). Each name once,
/// whatever its case.
pub fn export_keywords(&self, keywords: &[String]) -> ExportKeywords {
let defaults = KeywordInfo::default();
let info = |path: &str| self.keyword_info(path).unwrap_or(&defaults);
let mut out = ExportKeywords::default();
let push = |v: &mut Vec<String>, s: &str| {
if !s.is_empty() && !v.iter().any(|x| same(x, s)) {
v.push(s.to_string());
}
};
for k in keywords {
let path = clean(k);
let me = info(&path);
if path.is_empty() || !me.include_on_export {
continue;
}
let levels: Vec<&str> = path.split(SEP).collect();
let mut names = |at: usize, i: &KeywordInfo| {
if let Some(name) = levels.get(at) {
push(&mut out.flat, name);
}
if i.export_synonyms {
for s in &i.synonyms {
push(&mut out.flat, s.trim());
}
}
};
names(levels.len().saturating_sub(1), me);
if me.export_containing {
for at in (0..levels.len().saturating_sub(1)).rev() {
let parent = levels.get(..=at).map(|l| l.join("|")).unwrap_or_default();
let pi = info(&parent);
if pi.include_on_export {
names(at, pi);
}
}
}
push(&mut out.hierarchical, &path);
}
out
}
/// The keyword list as a file (Lightroom Classic's format, [`parse_keyword_list`]): every
/// keyword of the tree, those inside one indented a tab more, synonyms in braces under their
/// keyword, a keyword left out of export in square brackets; by name. What the format can't hold
/// is left out and named, so the file always reads back.
pub fn keyword_list_text(&self) -> KeywordListFile {
// a name the list would read as something else, or not at all
fn unwritable(name: &str) -> bool {
name.trim().is_empty()
|| name != name.trim()
|| name.chars().any(char::is_control)
// a byte-order mark: on the first line the reader takes it for the file's own
|| name.starts_with('\u{feff}')
|| (name.starts_with('[') && name.ends_with(']'))
|| (name.starts_with('{') && name.ends_with('}'))
}
fn write(c: &Catalog, nodes: &[KeywordNode], depth: usize, file: &mut KeywordListFile) {
let tabs = "\t".repeat(depth);
for n in nodes {
// the keywords inside go with it: under its parent they'd be other keywords
if depth >= MAX_LEVELS || unwritable(&n.name) {
file.unwritable.push(n.path.clone());
continue;
}
let info = c.keyword_info(&n.path).cloned().unwrap_or_default();
if info.include_on_export {
file.text.push_str(&format!("{tabs}{}\n", n.name));
} else {
file.text.push_str(&format!("{tabs}[{}]\n", n.name));
}
file.written.push(n.path.clone());
// the reader trims synonyms and takes one per spelling: an empty one, or another's
// in a different case, is no loss; a line break or a “|” is
let mut synonyms: Vec<&str> = Vec::new();
for s in info.synonyms.iter().map(|s| s.trim()) {
if s.is_empty() || synonyms.iter().any(|x| same(x, s)) {
continue;
}
if s.chars().any(char::is_control) || s.contains(SEP) {
file.unwritable.push(format!("{} {{{s}}}", n.path));
} else {
synonyms.push(s);
file.text.push_str(&format!("{tabs}\t{{{s}}}\n"));
}
}
write(c, &n.children, depth + 1, file);
}
}
let mut file = KeywordListFile::default();
write(self, &self.keyword_tree(), 0, &mut file);
file
}
/// Import a keyword list (read with [`parse_keyword_list`]) — one batch: the keywords the
/// library doesn't have are added with the list's attributes, in the library's spelling of the
/// levels it has; those it has keep their attributes and gain the list's synonyms. Also says how
/// many keywords were added and how many gained synonyms.
pub fn import_keywords_ops(&self, entries: &[(String, KeywordInfo)]) -> (Op, usize, usize) {
// every keyword the library has, by lower-case path, as it spells it: learnt once from the
// tree (looking each line up among all the photos' keywords took minutes for big lists)
let mut known: std::collections::HashMap<String, String> = std::collections::HashMap::new();
fn learn(nodes: &[KeywordNode], known: &mut std::collections::HashMap<String, String>) {
for n in nodes {
known.insert(n.path.to_lowercase(), n.path.clone());
learn(&n.children, known);
}
}
learn(&self.keyword_tree(), &mut known);
// a path in the library's spelling of each level it has
let spell = |path: &str, known: &std::collections::HashMap<String, String>| -> String {
let levels: Vec<&str> = path.split(SEP).collect();
let mut out = String::new();
for (i, level) in levels.iter().enumerate() {
if !out.is_empty() {
out.push(SEP);
}
let prefix = levels.get(..=i).map(|l| l.join("|").to_lowercase()).unwrap_or_default();
match known.get(&prefix).and_then(|p| p.rsplit(SEP).next()) {
Some(name) => out.push_str(name),
None => out.push_str(level),
}
}
out
};
let (mut added, mut updated) = (0, 0);
let ops = self.list_ops(|list| {
for (path, info) in entries {
let path = spell(&clean(path), &known);
let key = path.to_lowercase();
if path.is_empty() {
continue;
}
if !known.contains_key(&key) {
list.insert(key.clone(), ListedKeyword { path: path.clone(), info: KeywordInfo { new_keywords_inside: false, ..info.clone() } });
// it and its parents are the library's now: what follows spells them so
let levels: Vec<&str> = path.split(SEP).collect();
for i in 0..levels.len() {
let prefix = levels.get(..=i).map(|l| l.join("|")).unwrap_or_default();
known.entry(prefix.to_lowercase()).or_insert(prefix);
}
added += 1;
continue;
}
// one the library has: the list's synonyms join its own (listed only if it gains some)
let mut info_now = list.get(&key).map(|l| l.info.clone()).unwrap_or_default();
let before = info_now.synonyms.len();
for s in &info.synonyms {
if !info_now.synonyms.iter().any(|x| same(x, s)) {
info_now.synonyms.push(s.clone());
}
}
if info_now.synonyms.len() > before {
let path = list.get(&key).map_or(path, |l| l.path.clone());
list.insert(key, ListedKeyword { path, info: info_now });
updated += 1;
}
}
});
(Op::Batch { ops }, added, updated)
}
/// The keyword is in the tree: listed or on a library photo (not one in Recently Deleted or
/// only browsed), itself or one below it.
pub fn has_keyword(&self, path: &str) -> bool {
self.keyword_path(path).is_some()
}
/// Where a keyword typed for a photo goes: inside the default parent when it is a new name (Put
/// New Keywords Inside This Keyword); a keyword the library has, or a path typed whole, as
/// typed (cleaned).
pub fn typed_keyword(&self, typed: &str) -> String {
let k = clean(typed);
match self.default_keyword_parent() {
Some(parent) if !k.is_empty() && !k.contains(SEP) && !self.has_keyword(&k) => format!("{parent}{SEP}{k}"),
_ => k,
}
}
/// `path` with each level the library has spelled as the library spells it (`TRAVEL|Rome` next
/// to `travel|italy` is `travel|Rome`).
fn spelled(&self, path: &str) -> String {
let levels: Vec<&str> = path.split(SEP).collect();
let mut out = String::new();
for (i, level) in levels.iter().enumerate() {
let prefix = levels.get(..=i).map(|l| l.join("|")).unwrap_or_default();
let name = self.keyword_path(&prefix).and_then(|p| p.rsplit(SEP).next().map(str::to_string)).unwrap_or_else(|| level.to_string());
if !out.is_empty() {
out.push(SEP);
}
out.push_str(&name);
}
out
}
/// The keyword new keywords go inside (Put New Keywords Inside This Keyword), if any.
pub fn default_keyword_parent(&self) -> Option<String> {
self.keyword_list.values().find(|l| l.info.new_keywords_inside).map(|l| l.path.clone())
}
/// Make `keyword` the one new keywords go inside (`None`: none) — one batch. It is listed if it
/// wasn't, keeping the attributes it has.
pub fn set_default_parent_ops(&self, keyword: Option<&str>) -> Result<Op> {
let path = match keyword {
Some(k) => Some(self.keyword_path(k).ok_or_else(|| CatalogError::Invalid(format!("no keyword “{}”", clean(k))))?),
None => None,
};
Ok(Op::Batch {
ops: self.list_ops(|list| {
for l in list.values_mut() {
l.info.new_keywords_inside = false;
}
if let Some(path) = path {
let l = list.entry(path.to_lowercase()).or_insert_with(|| ListedKeyword { path, info: KeywordInfo::default() });
l.info.new_keywords_inside = true;
}
}),
})
}
/// The keyword as written in the library (whatever the case of `path`): its listing, or the
/// levels a photo's keyword spells it with. `None` when there is no such keyword.
pub fn keyword_path(&self, path: &str) -> Option<String> {
let k = clean(path);
if k.is_empty() {
return None;
}
if let Some(l) = self.keyword_list.get(&k.to_lowercase()) {
return Some(l.path.clone());
}
// the levels of a listed keyword below it, or of a photo's
let levels = k.split(SEP).count();
let listed = self.keyword_list.values().map(|l| &l.path);
listed
.chain(self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter()))
.find(|x| is_under(x, &k))
.map(|x| x.split(SEP).map(str::trim).filter(|s| !s.is_empty()).take(levels).collect::<Vec<_>>().join("|"))
}
/// Create a keyword with these attributes, and give it to `photos` — one batch. A keyword that
/// exists already is refused.
pub fn create_keyword_ops(&self, path: &str, info: KeywordInfo, photos: &[crate::PhotoId]) -> Result<Op> {
let path = clean(path);
if path.is_empty() {
return Err(CatalogError::Invalid("keyword names can't be empty".into()));
}
if self.has_keyword(&path) {
return Err(CatalogError::KeywordExists(path));
}
let path = self.spelled(&path);
let mut ops = vec![Op::SetKeyword { path: path.clone(), info: Some(info) }];
for id in photos {
let p = self.photo(*id).ok_or(CatalogError::NoPhoto(*id))?;
let mut meta = p.meta.clone();
meta.keywords.push(path.clone());
dedupe(&mut meta.keywords);
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
}
Ok(Op::Batch { ops })
}
/// Move `keyword` (with the keywords below it) inside `parent`, or to the top level (`None`).
/// It keeps its name. Onto a keyword that is there already the two merge, which only happens
/// with `merge`; otherwise [`CatalogError::KeywordExists`].
pub fn move_keyword_ops(&self, keyword: &str, parent: Option<&str>, merge: bool) -> Result<Op> {
let Some(from) = self.keyword_path(keyword) else {
return Err(CatalogError::Invalid(format!("no keyword “{}”", clean(keyword))));
};
let leaf = from.rsplit(SEP).next().unwrap_or(&from);
let to = match parent.map(clean).filter(|p| !p.is_empty()) {
Some(p) if is_under(&p, &from) => return Err(CatalogError::Invalid("can't move a keyword inside itself".into())),
Some(p) => format!("{}{SEP}{leaf}", self.keyword_path(&p).unwrap_or(p)),
None => leaf.to_string(),
};
if same(&to, &from) {
return Ok(Op::Batch { ops: vec![] });
}
if !merge && self.has_keyword(&to) {
return Err(CatalogError::KeywordExists(to));
}
self.rename_keyword_ops(&from, &to)
}
/// Rename `keyword`'s last level to `name` (it stays where it is) and set its attributes — one
/// batch. A name that is taken is refused ([`CatalogError::KeywordExists`]): merging is its own
/// action.
pub fn edit_keyword_ops(&self, keyword: &str, name: &str, info: KeywordInfo) -> Result<Op> {
let Some(from) = self.keyword_path(keyword) else {
return Err(CatalogError::Invalid(format!("no keyword “{}”", clean(keyword))));
};
let name = name.trim();
if name.is_empty() || name.contains(SEP) {
return Err(CatalogError::Invalid("a keyword's name is one level, without “|”".into()));
}
let to = match from.rsplit_once(SEP) {
Some((parent, _)) => format!("{parent}{SEP}{name}"),
None => name.to_string(),
};
let mut ops = Vec::new();
if to != from {
if !same(&to, &from) && self.has_keyword(&to) {
return Err(CatalogError::KeywordExists(to));
}
if let Op::Batch { ops: renamed } = self.rename_keyword_ops(&from, &to)? {
ops = renamed;
}
}
// set the attributes only when they change something: an unlisted keyword with the default
// ones stays unlisted
let now = self.keyword_info(&from).cloned();
let changed = match &now {
Some(now) => *now != info || to != from,
None => info != KeywordInfo::default(),
};
if changed {
ops.push(Op::SetKeyword { path: to, info: Some(info) });
}
Ok(Op::Batch { ops })
}
/// Take off the keyword list the keywords no library photo has, nor any keyword below them — one
/// batch.
pub fn purge_unused_keywords_ops(&self) -> Op {
let used: Vec<&String> = self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter()).collect();
Op::Batch { ops: self.list_ops(|list| list.retain(|_, l| used.iter().any(|k| is_under(k, &l.path)))) }
}
/// 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<String> {
// the photos' keywords with their counts, and those only the list has (none)
let mut all = self.keywords();
for l in self.keyword_list.values() {
if !all.iter().any(|(k, _)| same(k, &l.path)) {
all.push((l.path.clone(), 0));
}
}
let has = |k: &str| current.iter().any(|c| same(c, 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<String, i64> = Default::default();
if !current.is_empty() {
for p in self.photos().filter(|p| p.in_library()) {
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<PhotoId>) {
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<String> {
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<_>>(), vec![("beach", 2), ("travel", 3)]);
let travel = &t[1];
assert_eq!(travel.children.iter().map(|n| (n.path.as_str(), n.count)).collect::<Vec<_>>(), 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);
}
/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next
/// photo with it counts again.
#[test]
fn tree_counts_a_repeated_keyword_once_per_photo() {
let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]);
let t = c.keyword_tree();
assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]);
}
#[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");
}
fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> {
let mut out = Vec::new();
for n in nodes {
out.push((n.path.clone(), n.count));
out.extend(tree_paths(&n.children));
}
out
}
/// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag):
/// it is in the tree with no photos, its parents too, and undo takes it away.
#[test]
fn a_keyword_listed_with_no_photos_is_in_the_tree() {
let (mut c, _) = lib(&[&["travel|France"]]);
let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]);
assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case");
c.apply(undo).unwrap();
assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]);
assert_eq!(c.keyword_info("travel|Italy"), None);
}
/// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported,
/// with the keywords containing it and its synonyms; not a person.
#[test]
fn a_listed_keyword_needs_a_name_and_starts_exported() {
let mut c = Catalog::new();
assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err());
let info = KeywordInfo::default();
assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty());
}
/// Changing a listed keyword's attributes is undone to the attributes it had, under the name as
/// it was written.
#[test]
fn changing_a_listed_keyword_is_undone_to_what_it_was() {
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() };
let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap();
assert_eq!(c.keyword_info("Weddings"), Some(&person));
assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) });
c.apply(undo).unwrap();
assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default()));
}
fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> {
let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect();
v.sort_by_key(|(p, _)| p.to_lowercase());
v
}
fn with_synonyms(words: &[&str]) -> KeywordInfo {
KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() }
}
/// Renaming (or moving) a keyword takes its attributes and its listed children along, photos
/// or not, in the same undo step.
#[test]
fn renaming_a_keyword_carries_its_listing_along() {
let (mut c, ids) = lib(&[&["travel|italy"]]);
c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap();
c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap();
let before = c.to_snapshot();
let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]);
assert_eq!(
listed(&c),
[("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())]
);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
/// A keyword only the list holds (no photos) can be renamed too.
#[test]
fn a_keyword_without_photos_can_be_renamed() {
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]);
}
/// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target
/// not listed yet takes the merged keyword's attributes.
#[test]
fn merging_keeps_the_targets_attributes_and_gathers_synonyms() {
let (mut c, _) = lib(&[&["holiday"], &["travel"]]);
let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) };
c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap();
c.apply(Op::SetKeyword {
path: "holiday".into(),
info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }),
})
.unwrap();
let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]);
// onto a keyword that isn't listed: it takes the merged one's attributes
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap();
let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]);
}
/// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos.
#[test]
fn deleting_a_keyword_takes_it_off_the_list() {
let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]);
for path in ["travel", "travel|spain", "beach"] {
c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap();
}
let before = c.to_snapshot();
let op = c.delete_keyword_ops("travel").unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]);
assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
/// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo
/// step. A keyword that exists already (listed, or on a photo) isn't created again.
#[test]
fn creating_a_keyword_lists_it_and_can_tag_photos() {
let (mut c, ids) = lib(&[&["beach"], &[]]);
let before = c.to_snapshot();
let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]);
assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
for exists in ["BEACH", "events|weddings"] {
if exists == "events|weddings" {
let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap();
c.apply(op).unwrap();
}
assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}");
}
assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err());
}
/// Moving a keyword nests it inside another (or takes it to the top level), with its children,
/// on photos and in the list. It can't go inside itself or one of its children, and moving it
/// where it already is changes nothing.
#[test]
fn moving_a_keyword_nests_it_inside_another() {
let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]);
// typed in any case, the keywords keep the spelling the library has
let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]);
let op = c.move_keyword_ops("europe|italy", None, false).unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level");
assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child");
assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself");
assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there");
assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword");
}
/// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move
/// asks first: refused unless merging is allowed.
#[test]
fn moving_onto_a_taken_name_merges_only_when_allowed() {
let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]);
let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err();
assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}");
let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap();
c.apply(op).unwrap();
assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()]));
}
/// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes,
/// in one undo step. A name that is taken is refused: merging is its own action.
#[test]
fn editing_a_keyword_renames_it_and_sets_its_attributes() {
let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]);
let before = c.to_snapshot();
let person = KeywordInfo { person: true, ..KeywordInfo::default() };
let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["travel|Italia"]);
assert_eq!(c.keyword_info("travel|italia"), Some(&person));
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
// attributes alone
let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]);
assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_))));
assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path");
assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err());
}
/// Purging takes off the list the keywords no photo has (nor any keyword below them), in one
/// undo step; keywords photos carry stay.
#[test]
fn purging_takes_unused_keywords_off_the_list() {
let (mut c, _) = lib(&[&["travel|italy"]]);
for path in ["travel", "travel|italy", "travel|spain", "weddings"] {
c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap();
}
let op = c.purge_unused_keywords_ops();
c.apply(op).unwrap();
assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["travel", "travel|italy"]);
assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]);
}
/// Renaming matches names whatever their case, also where a letter's case changes its length
/// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter.
#[test]
fn renaming_keeps_the_levels_below_whatever_the_case() {
let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]);
let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Road|nord"]);
let op = c.rename_keyword_ops("ẞA", "Weg").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[1]), ["Weg|ü"]);
}
fn exported(c: &Catalog, keywords: &[&str]) -> (Vec<String>, Vec<String>) {
let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::<Vec<_>>());
(e.flat, e.hierarchical)
}
/// By default a keyword is exported as its name together with the keywords containing it (flat,
/// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once.
#[test]
fn a_keyword_exports_its_name_its_parents_and_its_path() {
let c = Catalog::new();
let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]);
assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]);
assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]);
}
/// A keyword not included on export is left out, path and all; a parent left out (a keyword that
/// only organizes others) isn't exported as a containing keyword, but its children are.
#[test]
fn keywords_left_out_of_export_stay_out() {
let mut c = Catalog::new();
let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap();
c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap();
let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]);
assert_eq!(flat, ["Lisbon"]);
assert_eq!(paths, ["Places|Lisbon"]);
}
/// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go
/// out with it unless "export synonyms" is off.
#[test]
fn containing_keywords_and_synonyms_follow_their_options() {
let mut c = Catalog::new();
let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) };
c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap();
let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) };
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap();
let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]);
assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]);
}
/// Names match whatever the case of any letter, not only ASCII ones: changing only the case of
/// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing.
#[test]
fn names_match_whatever_the_case_of_any_letter() {
let (mut c, ids) = lib(&[&["Ärzte|Wien"]]);
let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]);
assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] });
let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]);
assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists");
}
/// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a
/// keyword like any other: it is in the tree, so it can be found, moved and renamed.
#[test]
fn the_parent_of_a_listed_keyword_is_a_keyword() {
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert!(c.has_keyword("events"));
assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events"));
let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap();
c.apply(op).unwrap();
assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]);
}
/// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it
/// can be created again and is purged as unused, and its spelling there isn't the library's.
#[test]
fn deleted_photos_dont_hold_keywords() {
let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]);
c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap();
assert!(!c.has_keyword("weddings"));
assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok());
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
let op = c.purge_unused_keywords_ops();
c.apply(op).unwrap();
assert!(listed(&c).is_empty(), "only a deleted photo has it: unused");
}
/// Merging several keywords moves each keyword under them by the closest one, whatever the
/// order they are given in, and every listing lands where its keyword does: one landing where
/// another is about to leave isn't merged into it.
#[test]
fn merging_overlapping_keywords_keeps_every_listing() {
let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]);
let person = KeywordInfo { person: true, ..KeywordInfo::default() };
c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap();
c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap();
let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap();
c.apply(op).unwrap();
assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()]));
assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]);
// the order of the keywords merged doesn't matter
let merged = |from: [&str; 2]| {
let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]);
let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap();
c.apply(op).unwrap();
kws(&c, ids[0])
};
assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"]));
assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]);
}
/// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making
/// another one so is one undo step; the mark moves with its keyword (also into a merge) and
/// goes with it.
#[test]
fn one_keyword_is_where_new_keywords_go() {
let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]);
let op = c.set_default_parent_ops(Some("events")).unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"));
let op = c.set_default_parent_ops(Some("travel")).unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one");
assert!(c.set_default_parent_ops(Some("lisbon")).is_err());
let op = c.rename_keyword_ops("travel", "Trips").unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips"));
let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge");
let op = c.delete_keyword_ops("events").unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent(), None);
let op = c.set_default_parent_ops(None).unwrap();
assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear");
c.apply(undo).unwrap();
assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was");
}
/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the
/// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes
/// back as one keyword, not three). Without paths, the names as they are.
#[test]
fn a_files_keywords_keep_their_hierarchy() {
let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]);
assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned");
assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]);
assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat");
}
/// A new keyword inside one the library has takes the library's spelling of it: creating
/// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel".
#[test]
fn a_new_keyword_keeps_the_spelling_of_its_parents() {
let (mut c, _) = lib(&[&["travel|italy"]]);
let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["travel|Rome"]);
assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel");
}
/// An edit that changes nothing is nothing: no listing, no undo step.
#[test]
fn an_edit_that_changes_nothing_is_nothing() {
let (mut c, _) = lib(&[&["beach"]]);
assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] });
c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap();
assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] });
// renamed with the default attributes: the photos change, nothing is listed
let (mut c, _) = lib(&[&["beach"]]);
let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap();
c.apply(op).unwrap();
assert!(listed(&c).is_empty());
}
/// Typing suggests keywords no photo has yet too (created in the Keyword List).
#[test]
fn suggestions_include_keywords_without_photos() {
let (mut c, _) = lib(&[&["beach"]]);
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]);
assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has");
}
/// A new name typed for a photo goes inside the default parent; a keyword the library has, and
/// a path typed whole, don't.
#[test]
fn typed_new_keywords_go_inside_the_default_parent() {
let (mut c, _) = lib(&[&["beach"], &["Events"]]);
assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent");
let op = c.set_default_parent_ops(Some("events")).unwrap();
c.apply(op).unwrap();
assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings");
assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it");
assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon");
}
/// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the
/// keywords inside it indented one tab more, a synonym in braces on a line of its own under its
/// keyword, a keyword left out of export in square brackets; by name.
#[test]
fn the_keyword_list_is_written_as_tab_indented_text() {
let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]);
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(with_synonyms(&["Lisboa"])) }).unwrap();
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n");
}
/// Reading a list gives each keyword's path and attributes: what was written comes back, and a
/// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too.
#[test]
fn a_keyword_list_reads_back() {
let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap();
let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
assert_eq!(
read,
[
("beach".to_string(), KeywordInfo::default()),
("Events".to_string(), KeywordInfo::default()),
("Events|Weddings".to_string(), with_synonyms(&["marriage"])),
("Places".to_string(), out),
("Places|Lisbon".to_string(), KeywordInfo::default()),
]
);
}
/// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line
/// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword,
/// left out of export), keywords a tab further in at each level.
#[test]
fn a_photo_supreme_vocabulary_file_reads() {
let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n";
let read = parse_keyword_list(file).unwrap();
let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"]
.map(|p| if p == "Places" { p.to_string() } else { format!("Places|{p}") })
);
assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported");
assert!(read[1..].iter().all(|(_, i)| i.include_on_export));
}
/// A list that can't be read says which line and why, and nothing of it is taken.
#[test]
fn a_bad_keyword_list_says_which_line() {
let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once");
assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing");
assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here");
let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect();
assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most");
}
/// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone
/// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is
/// refused at its line; so are control characters in a name and "|" in a synonym.
#[test]
fn a_keyword_list_from_elsewhere_reads_or_says_why_not() {
let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::<Vec<_>>();
assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]);
let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation");
assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs");
assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character");
assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym");
}
/// A keyword repeated in another case is one keyword, and the keywords inside either take the
/// first spelling.
#[test]
fn a_repeated_keyword_keeps_its_first_spelling() {
let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap();
assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["Events", "Events|Weddings", "Events|Birthdays"]);
}
/// What the list can't hold is left out and named, so the file always reads back: a name the
/// format would read as something else (in brackets or braces), one with a line break, a
/// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left
/// out go with it.
#[test]
fn an_exported_keyword_list_always_reads_back() {
let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::<Vec<_>>().join("|");
let (mut c, _) = lib(&[&["Travel|[draft]", "{todo}|Lisbon", "Weddings", "Line\nbreak", deep.as_str()]]);
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["marriage", "two\nlines"])) }).unwrap();
let KeywordListFile { text, unwritable, .. } = c.keyword_list_text();
let read = parse_keyword_list(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
assert!(read.iter().any(|(p, _)| p == "Travel"));
assert!(read.iter().all(|(p, _)| !p.contains("draft") && !p.contains("todo") && !p.contains("Line")), "{text}");
let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
assert_eq!(weddings, Some(vec!["marriage".to_string()]));
let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max();
assert_eq!(deepest, Some(MAX_LEVELS));
assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}");
assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}");
assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}");
assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}");
assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}");
}
/// The list leaves out only what it must, and names only what's lost: a name starting with a
/// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty
/// or another's in a different case is no loss. It also says which keywords it wrote.
#[test]
fn an_exported_keyword_list_names_only_what_it_loses() {
let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]);
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap();
let out = c.keyword_list_text();
let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text));
assert_eq!(out.unwritable, ["\u{feff}Albums"]);
assert_eq!(out.written, ["Weddings", "{draft"]);
assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["Weddings", "{draft"]);
let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()]));
}
/// A big list reads and imports at once, against a big library too: 40,000 keywords used to take
/// half a minute to read, and checking 5,000 new ones against 20,000 photos over a minute, with
/// the app frozen.
#[test]
fn a_big_keyword_list_imports_quickly() {
let mut text = String::new();
for g in 0..400 {
text.push_str(&format!("group {g}\n"));
for k in 0..99 {
text.push_str(&format!("\tkeyword {g}-{k}\n"));
}
}
let t0 = std::time::Instant::now();
let read = parse_keyword_list(&text).unwrap();
assert_eq!(read.len(), 40_000);
let mut c = Catalog::new();
let mut ops = Vec::new();
for i in 0..20_000u32 {
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 = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect();
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
c.apply(Op::Batch { ops }).unwrap();
let (op, added, _) = c.import_keywords_ops(&read[..5_000]);
c.apply(op).unwrap();
assert_eq!(added, 5_000);
let took = t0.elapsed();
assert!(took < std::time::Duration::from_secs(10), "{took:?}");
}
/// Importing a list adds the keywords the library doesn't have, with their attributes, and gives
/// those it has the list's synonyms (their own attributes stay), in one undo step.
#[test]
fn importing_a_keyword_list_adds_what_is_missing() {
let (mut c, _) = lib(&[&["Events|Weddings"]]);
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap();
let before = c.to_snapshot();
let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap();
let (op, added, updated) = c.import_keywords_ops(&read);
assert_eq!((added, updated), (2, 1));
let undo = c.apply(op).unwrap();
assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) }));
assert!(c.has_keyword("Events|Birthdays"));
assert_eq!(
listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(),
["Drafts", "Events|Birthdays", "Events|Weddings"],
"in the library's spelling"
);
assert!(!c.keyword_info("Drafts").unwrap().include_on_export);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
#[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");
}
}