From 967edfcd80b852ae596f779ff28026d996a9a455 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 16:29:09 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: keyword.import and keyword.export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `keyword.export {path?}` writes the keyword list file (Lightroom Classic's format, which Capture One, Photo Supreme and Bridge read), or gives its text, and names the keywords Capture One's importer refuses (none of ; , < > may be in a list it reads). - `keyword.import {path | text}` reads one (Photo Supreme's Formatted Vocabulary File too) as one undo step → {added, updated}. A line it can't read is named and nothing is taken; a file larger than 16 MB or not UTF-8 is refused. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/keywords.rs | 63 +++++++++++++++++++++++++ crates/engine/src/tests_keyword_list.rs | 42 +++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 4af1a87f1..40af75e71 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -95,6 +95,24 @@ pub fn note_recent(s: &mut Session, added: &[String]) { let _ = s.save_prefs(); } +/// The largest keyword list file read: a file is input (a list of 100,000 keywords is ~2 MB). +const MAX_LIST_BYTES: u64 = 16 << 20; + +/// The characters Capture One's keyword importer refuses in a list (`|` can't be in a name here). +const CAPTURE_ONE_REFUSES: [char; 4] = [';', ',', '<', '>']; + +/// A keyword list file's text: at most `MAX_LIST_BYTES`, UTF-8 (with or without a byte-order mark). +fn read_list(path: &str) -> Result { + use std::io::Read; + let file = std::fs::File::open(path).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?; + let mut bytes = Vec::new(); + file.take(MAX_LIST_BYTES + 1).read_to_end(&mut bytes).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?; + if bytes.len() as u64 > MAX_LIST_BYTES { + return Err(bad("keyword.import", format!("{path}: larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20))); + } + String::from_utf8(bytes).map_err(|_| bad("keyword.import", format!("{path}: not UTF-8 text"))) +} + /// A set's slots as typed: each cleaned in its place (an empty one is an empty slot, so the others /// keep their ⌥ keys), each keyword once whatever its case (a second one leaves its slot empty), /// nine at most, no empty ones at the end. @@ -404,6 +422,51 @@ pub fn specs() -> Vec { Ok(json!({"keyword": s.catalog.default_keyword_parent()})) } ), + cmd!( + "keyword.export", + "Export Keywords", + [], + None, + "{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, text when there is no path}", + always, + |s, p| { + let text = 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)) + .collect(); + let mut r = json!({"keywords": keywords, "captureOneRefuses": refuses}); + 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}")))?; + r["path"] = json!(path); + } + None => r["text"] = json!(text), + } + Ok(r) + } + ), + cmd!( + "keyword.import", + "Import Keywords", + [], + None, + "{path | text} — a keyword list (Lightroom Classic's format, also Capture One's and Photo Supreme's Formatted Vocabulary File): adds the keywords the library doesn't have, gives those it has the list's synonyms; one undo step → {added, updated}", + always, + |s, p| { + let text = match (str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()), str_param(p, "text")) { + (Some(path), _) => read_list(path)?, + (None, Some(text)) => text.to_string(), + (None, None) => return Err(bad("keyword.import", "give `path` or `text`")), + }; + let entries = lightcraft_catalog::keywords::parse_keyword_list(&text).map_err(|e| bad("keyword.import", e.to_string()))?; + let (op, added, updated) = s.catalog.import_keywords_ops(&entries); + commit_keywords(s, "Import Keywords", op, |k| Some(k.to_string()))?; + Ok(json!({"added": added, "updated": updated})) + } + ), cmd!( "keyword.rename", "Rename Keyword", diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index dfcdb388f..ead8567b3 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -262,3 +262,45 @@ fn set_names_match_whatever_their_case() { s.execute("keyword.deleteSet", &json!({"name": "été"})).unwrap(); assert!(set_named(&mut s, "ÉTÉ").is_null(), "deleted"); } + +/// Export Keywords writes the keyword list file, and names the keywords Capture One's importer +/// refuses (it allows none of ; , < > in a list); without a path it gives the text. +#[test] +fn exporting_keywords_writes_the_list() { + let dir = temp_dir("export-keywords"); + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Places|Lisbon", "fish, chips"]})).unwrap(); + let path = dir.join("keywords.txt"); + let r = s.execute("keyword.export", &json!({"path": path.to_string_lossy()})).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("Places\n\tLisbon\n"), "{text}"); + assert!(r["keywords"].as_u64().unwrap() >= 3, "{r}"); + assert_eq!(r["captureOneRefuses"], json!(["fish, chips"]), "{r}"); + let r = s.execute("keyword.export", &json!({})).unwrap(); + assert_eq!(r["text"].as_str(), Some(text.as_str())); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Import Keywords reads a keyword list file (or text) as one undo step and says what it added; +/// a file it can't read says which line, and nothing is taken. +#[test] +fn importing_keywords_reads_a_list() { + let dir = temp_dir("import-keywords"); + let mut s = Session::with_demo(); + let path = dir.join("vocabulary.utf8"); + std::fs::write(&path, "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n").unwrap(); + let undo = s.undo.len(); + let r = s.execute("keyword.import", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((r["added"].as_u64(), r["updated"].as_u64()), (Some(3), Some(0)), "{r}"); + assert_eq!(s.undo.len(), undo + 1); + assert!(s.catalog.has_keyword("Places|Portugal|Lisbon")); + let r = s.execute("keyword.import", &json!({"text": "Events\n\t{celebrations}\n"})).unwrap(); + assert_eq!(r["added"], 1); + let err = s.execute("keyword.import", &json!({"text": "Events\n\t\tWeddings\n"})).unwrap_err().to_string(); + assert!(err.contains("line 2"), "{err}"); + std::fs::write(dir.join("bad.txt"), [0xff, 0xfe, 0x00, 0x41]).unwrap(); + assert!(s.execute("keyword.import", &json!({"path": dir.join("bad.txt").to_string_lossy()})).is_err(), "not UTF-8: refused"); + assert!(s.execute("keyword.import", &json!({})).is_err(), "a path or text"); + let _ = std::fs::remove_dir_all(&dir); +}