diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 5973975d6..796cd0eaf 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -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)] diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index dde020157..9f128e83f 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -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) { 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, + /// 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 = HashMap::new(); let mut this_photo: HashSet = 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 = 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 = 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"]]); diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 884ec6583..4ddb171e5 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -217,6 +217,12 @@ pub enum Op { folder: String, at: Option, }, + /// 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, + }, /// Several ops as one step (undo applies the inverses in reverse). Batch { ops: Vec, @@ -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, + /// 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, /// 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 { + 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 { diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index 4291f45fa..054179519 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -299,6 +299,7 @@ impl Op { | Op::SetAlbumRules { .. } | Op::RemoveStack { .. } | Op::SetLabelName { .. } + | Op::SetKeyword { .. } | Op::SetBrowsed { .. } => {} } } diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index cb80ce67e..9d4b323a6 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -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"); } diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 0594b6d83..3bcb6526b 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -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") } }