LRC-LIB-KEYWORDS: a keyword list that holds keywords without photos

Keywords existed only as `a|b|c` strings on photos: one couldn't be
created before a photo had it, nor a parent made to organize under.
The catalog now has a keyword list (`Op::SetKeyword`, format version
4): a keyword listed on its own is in the tree with no photos, its
parents too, and carries Lightroom Classic's keyword tag options
(`KeywordInfo`: synonyms, include on export, export containing
keywords, export synonyms, person), exported by default. Undo restores
what was listed, as it was written. Version 3 libraries open and are
rewritten as 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 14:00:13 -04:00
co-authored by Claude Opus 5.5
parent e92095a57a
commit 21cd3b75fd
6 changed files with 156 additions and 7 deletions
+2 -1
View File
@@ -46,6 +46,7 @@
//! | 1 | up to v0.2.0 | |
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
//! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` |
//! | 4 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -70,7 +71,7 @@ pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
/// The catalog format this build writes (and the newest it reads). See the module docs →
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
pub const VERSION: u32 = 3;
pub const VERSION: u32 = 4;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
+96 -4
View File
@@ -5,8 +5,12 @@
//! 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 serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::{Catalog, CatalogError, Meta, Op, Result};
@@ -36,6 +40,45 @@ fn dedupe(v: &mut Vec<String>) {
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`) and found by search.
#[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,
}
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 }
}
}
/// 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,
}
/// One node of the keyword tree.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct KeywordNode {
@@ -63,9 +106,12 @@ impl Catalog {
}
let mut levels: HashMap<String, Level> = HashMap::new();
let mut this_photo: HashSet<String> = HashSet::new();
for p in self.photos().filter(|p| p.in_library()) {
// 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 &p.meta.keywords {
for k in keywords {
let mut path = String::new();
let mut lower = String::new();
let mut parent: Option<String> = None;
@@ -82,7 +128,7 @@ impl Catalog {
count: 0,
parent: parent.clone(),
});
if this_photo.insert(lower.clone()) {
if counts && this_photo.insert(lower.clone()) {
l.count += 1;
}
parent = Some(lower.clone());
@@ -263,6 +309,52 @@ mod tests {
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()));
}
#[test]
fn suggestions_rank_co_occurrence_and_prefixes() {
let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]);
+32
View File
@@ -217,6 +217,12 @@ pub enum Op {
folder: String,
at: Option<String>,
},
/// List a keyword in the library's keyword list with these attributes, or take it off the list
/// (`None`; photos that carry it keep it). Format version 4.
SetKeyword {
path: String,
info: Option<keywords::KeywordInfo>,
},
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec<Op>,
@@ -239,6 +245,9 @@ pub struct Catalog {
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
browsed: BTreeMap<String, String>,
/// Keywords listed on their own or given attributes, by lower-case path (see [`keywords`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
keyword_list: BTreeMap<String, keywords::ListedKeyword>,
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
@@ -366,6 +375,14 @@ impl Catalog {
pub fn label_name(&self, l: ColorLabel) -> String {
self.label_names.get(&l).cloned().unwrap_or_else(|| format!("{l:?}"))
}
/// A listed keyword's attributes (any case), `None` when it isn't listed.
pub fn keyword_info(&self, path: &str) -> Option<&keywords::KeywordInfo> {
self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| &k.info)
}
/// The keyword list: keywords listed on their own or given attributes, by path.
pub fn listed_keywords(&self) -> impl Iterator<Item = &keywords::ListedKeyword> {
self.keyword_list.values()
}
/// The custom name of a colour label, if any.
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
self.label_names.get(&l).map(String::as_str)
@@ -612,6 +629,21 @@ impl Catalog {
};
Op::SetLabelName { label, name: old }
}
Op::SetKeyword { path, info } => {
let path = keywords::clean(&path);
if path.is_empty() {
return Err(CatalogError::Invalid("keyword names can't be empty".into()));
}
let key = path.to_lowercase();
let old = match info {
Some(info) => self.keyword_list.insert(key, keywords::ListedKeyword { path: path.clone(), info }),
None => self.keyword_list.remove(&key),
};
match old {
Some(old) => Op::SetKeyword { path: old.path, info: Some(old.info) },
None => Op::SetKeyword { path, info: None },
}
}
Op::SetBrowsed { folder, at } => {
let folder = crate::query::folder_key(&folder);
let old = match at {
+1
View File
@@ -299,6 +299,7 @@ impl Op {
| Op::SetAlbumRules { .. }
| Op::RemoveStack { .. }
| Op::SetLabelName { .. }
| Op::SetKeyword { .. }
| Op::SetBrowsed { .. } => {}
}
}
+17 -2
View File
@@ -57,7 +57,21 @@ fn v2_library_loads_and_is_upgraded() {
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!((r.replayed, r.upgraded_from), (1, Some(2)));
assert_eq!(snapshot_version(&m), 3);
assert_eq!(snapshot_version(&m), u64::from(VERSION));
}
/// A library saved before keywords could be listed on their own opens as it was and is rewritten
/// as v4.
#[test]
fn v3_library_loads_and_is_upgraded() {
let (base, log, full) = legacy_parts();
let m = MemStore::new();
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":3,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
m.set(LOG, log.into_bytes());
let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!((r.replayed, r.upgraded_from), (1, Some(3)));
assert_eq!(snapshot_version(&m), 4);
}
#[test]
@@ -193,8 +207,9 @@ fn op_variants_are_versioned() {
| Op::Batch { .. } => 1,
Op::SetBrowsed { .. } => 2,
Op::SetAlbumOrder { .. } => 3,
Op::SetKeyword { .. } => 4,
}
}
let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None });
let newest = since(&Op::SetKeyword { path: "travel".into(), info: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
}
+8
View File
@@ -78,6 +78,14 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) },
14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }),
9 if b.is_multiple_of(5) => Op::SetLabelName { label: ColorLabel::ALL[a as usize % 5], name: (a % 3 != 1).then(|| format!("Label {b}")) },
9 if b % 7 == 5 => Op::SetKeyword {
path: format!("travel|place {}", b % 4),
info: (!b.is_multiple_of(3)).then(|| crate::keywords::KeywordInfo {
person: a.is_multiple_of(2),
synonyms: vec![format!("trip {a}")],
..Default::default()
}),
},
9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) },
9 if b.is_multiple_of(2) => {
Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } }