LRC-LIB-KEYWORDS: keyword.import and keyword.export

- `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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 16:29:09 -04:00
co-authored by Claude Opus 5.5
parent 1d8d57ce9a
commit 967edfcd80
2 changed files with 105 additions and 0 deletions
+63
View File
@@ -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<String> {
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<CommandSpec> {
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<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))
.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",
+42
View File
@@ -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);
}