mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
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:
co-authored by
Claude Opus 5.5
parent
e92095a57a
commit
21cd3b75fd
@@ -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)]
|
||||
|
||||
@@ -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"]]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -299,6 +299,7 @@ impl Op {
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::SetKeyword { .. }
|
||||
| Op::SetBrowsed { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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") } }
|
||||
|
||||
Reference in New Issue
Block a user