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>
This commit is contained in:
Nathaniel Ledford
2026-10-09 17:02:07 -04:00
co-authored by Claude Opus 5.5
parent a970bdac51
commit 060da5bb5e
3 changed files with 69 additions and 28 deletions
+10 -9
View File
@@ -434,20 +434,21 @@ pub fn specs() -> Vec<CommandSpec> {
"{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<String> = 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)
}
+6 -1
View File
@@ -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();
}