diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 0b4ba2fd..92a53195 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -56,6 +56,16 @@ pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec { /// 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, + pub unwritable: Vec, +} + /// 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 @@ -428,45 +438,54 @@ impl Catalog { /// 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. Also names 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}`), so the file always reads back. - pub fn keyword_list_text(&self) -> (String, Vec) { + /// 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, out: &mut String, left: &mut Vec) { + 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) { - left.push(n.path.clone()); + file.unwritable.push(n.path.clone()); continue; } let info = c.keyword_info(&n.path).cloned().unwrap_or_default(); if info.include_on_export { - out.push_str(&format!("{tabs}{}\n", n.name)); + file.text.push_str(&format!("{tabs}{}\n", n.name)); } else { - out.push_str(&format!("{tabs}[{}]\n", n.name)); + file.text.push_str(&format!("{tabs}[{}]\n", n.name)); } - for s in &info.synonyms { - if unwritable(s) || s.contains(SEP) { - left.push(format!("{} {{{s}}}", n.path)); + 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 { - out.push_str(&format!("{tabs}\t{{{s}}}\n")); + synonyms.push(s); + file.text.push_str(&format!("{tabs}\t{{{s}}}\n")); } } - write(c, &n.children, depth + 1, out, left); + write(c, &n.children, depth + 1, file); } } - let (mut out, mut left) = (String::new(), Vec::new()); - write(self, &self.keyword_tree(), 0, &mut out, &mut left); - (out, left) + 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 @@ -1243,7 +1262,7 @@ mod tests { 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().0, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n"); + 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 @@ -1323,7 +1342,7 @@ mod tests { let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::>().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 (text, unwritable) = c.keyword_list_text(); + 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}"); @@ -1338,6 +1357,22 @@ mod tests { 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::>(), ["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. diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index df4750ac..b2c7e90f 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -434,20 +434,21 @@ pub fn specs() -> Vec { "{path?} — the keyword list as a file (Lightroom Classic's format, which Capture One, Photo Supreme and Bridge read) → {keywords, captureOneRefuses: keywords with ; , < > that Capture One's importer refuses, unwritable: keywords left out because the format can't hold them (a name in brackets or braces, a line break, deeper than 64 levels; a synonym as \"path {synonym}\"), text when there is no path}", always, |s, p| { - let (text, unwritable) = s.catalog.keyword_list_text(); - let keywords = text.lines().filter(|l| !l.trim_start().starts_with('{')).count(); - let refuses: Vec = text - .lines() - .map(|l| l.trim().trim_start_matches('[').trim_end_matches(']').to_string()) - .filter(|l| !l.starts_with('{') && l.contains(CAPTURE_ONE_REFUSES)) + let file = s.catalog.keyword_list_text(); + // Capture One's importer refuses ; , < > in a keyword's name + let refuses: Vec<&str> = file + .written + .iter() + .filter_map(|p| p.rsplit(lightcraft_catalog::keywords::SEP).next()) + .filter(|n| n.contains(CAPTURE_ONE_REFUSES)) .collect(); - let mut r = json!({"keywords": keywords, "captureOneRefuses": refuses, "unwritable": unwritable}); + let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable}); match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) { Some(path) => { - std::fs::write(path, &text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; r["path"] = json!(path); } - None => r["text"] = json!(text), + None => r["text"] = json!(file.text), } Ok(r) } diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index d364544d..b98b45de 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -289,10 +289,15 @@ fn exporting_keywords_names_what_the_list_cant_hold() { let mut s = Session::with_demo(); let id = s.visible_cloned()[0].0; s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Travel|[draft]", "Travel|Lisbon"]})).unwrap(); + let before = s.execute("keyword.export", &json!({})).unwrap()["keywords"].as_u64().unwrap(); + // a name starting with a brace is written, counted and checked for Capture One like any other + s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": ["{draft, notes"]})).unwrap(); let r = s.execute("keyword.export", &json!({})).unwrap(); + assert_eq!(r["keywords"].as_u64(), Some(before + 1), "{r}"); + assert_eq!(r["captureOneRefuses"], json!(["{draft, notes"]), "{r}"); assert_eq!(r["unwritable"], json!(["Travel|[draft]"]), "{r}"); let text = r["text"].as_str().unwrap(); - assert!(text.contains("Travel\n\tLisbon\n") && !text.contains("draft"), "{text}"); + assert!(text.contains("Travel\n\tLisbon\n") && !text.contains("[draft]"), "{text}"); s.execute("keyword.import", &json!({"text": text})).unwrap(); }