mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
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:
co-authored by
Claude Opus 5.5
parent
1d8d57ce9a
commit
967edfcd80
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user