Files
lightcraft/crates/catalog/src/keywords.rs
T
Nathaniel LedfordandClaude Opus 5.5 060da5bb5e LRC-LIB-KEYWORDS: keyword list export names only what it loses
Export now leaves out only what it must, and its counts are exact:

- A name starting with a byte-order mark is left out. On the first
  line, the reader would take the mark for the file's own.
- A synonym in braces is written, since it reads back. An empty
  synonym, or one that repeats another in a different case, is
  skipped quietly, as the reader would drop it anyway.
- keyword.export counts and checks for Capture One the keywords it
  actually wrote, now returned with the text. It used to guess from
  the lines, so a name starting with a brace was missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:02:07 -04:00

1444 lines
73 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, HashSet};
// every keyword level (`travel`, `travel|italy`…) by its 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)
struct Level {
name: String,
path: String,
count: usize,
parent: Option<String>,
}
let mut levels: HashMap<String, Level> = HashMap::new();
let mut this_photo: HashSet<String> = HashSet::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();
let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice()));
for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) {
this_photo.clear();
for k in keywords {
let mut path = String::new();
let mut lower = String::new();
let mut parent: Option<String> = None;
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);
lower.push_str(&part.to_lowercase());
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
name: part.to_string(),
path: path.clone(),
count: 0,
parent: parent.clone(),
});
if counts && this_photo.insert(lower.clone()) {
l.count += 1;
}
parent = Some(lower.clone());
}
}
}
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
for (lower, l) in &levels {
kids.entry(l.parent.clone()).or_default().push(lower.clone());
}
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
list.sort_by_key(|l| levels[l].name.to_lowercase());
list.into_iter()
.map(|l| {
let lv = &levels[&l];
let children = build(Some(l.clone()), levels, kids);
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
})
.collect()
}
build(None, &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);
}
#[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");
}
}