mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
LRC-LIB-KEYWORDS: an exported keyword list always reads back
The list format can't hold some names. A name in brackets or braces would read back as a left-out keyword or a synonym. A line break or a leading space would split it or break the indentation. A keyword nested deeper than 64 levels won't read back either. These keywords, and those inside them, are now left out of the file. keyword.export names them as `unwritable`, and the toast says which. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9511132a31
commit
91e8a0ac37
@@ -428,11 +428,26 @@ 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.
|
||||
pub fn keyword_list_text(&self) -> String {
|
||||
fn write(c: &Catalog, nodes: &[KeywordNode], depth: usize, out: &mut String) {
|
||||
/// 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<String>) {
|
||||
// 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)
|
||||
|| (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<String>) {
|
||||
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());
|
||||
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));
|
||||
@@ -440,14 +455,18 @@ impl Catalog {
|
||||
out.push_str(&format!("{tabs}[{}]\n", n.name));
|
||||
}
|
||||
for s in &info.synonyms {
|
||||
out.push_str(&format!("{tabs}\t{{{s}}}\n"));
|
||||
if unwritable(s) || s.contains(SEP) {
|
||||
left.push(format!("{} {{{s}}}", n.path));
|
||||
} else {
|
||||
out.push_str(&format!("{tabs}\t{{{s}}}\n"));
|
||||
}
|
||||
}
|
||||
write(c, &n.children, depth + 1, out);
|
||||
write(c, &n.children, depth + 1, out, left);
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
write(self, &self.keyword_tree(), 0, &mut out);
|
||||
out
|
||||
let (mut out, mut left) = (String::new(), Vec::new());
|
||||
write(self, &self.keyword_tree(), 0, &mut out, &mut left);
|
||||
(out, left)
|
||||
}
|
||||
|
||||
/// Import a keyword list (read with [`parse_keyword_list`]) — one batch: the keywords the
|
||||
@@ -1224,7 +1243,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(), "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n");
|
||||
assert_eq!(c.keyword_list_text().0, "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
|
||||
@@ -1295,6 +1314,30 @@ mod tests {
|
||||
assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["Events", "Events|Weddings", "Events|Birthdays"]);
|
||||
}
|
||||
|
||||
/// What the list can't hold is left out and named, so the file always reads back: a name the
|
||||
/// format would read as something else (in brackets or braces), one with a line break, a
|
||||
/// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left
|
||||
/// out go with it.
|
||||
#[test]
|
||||
fn an_exported_keyword_list_always_reads_back() {
|
||||
let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::<Vec<_>>().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 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}");
|
||||
let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
|
||||
assert_eq!(weddings, Some(vec!["marriage".to_string()]));
|
||||
let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max();
|
||||
assert_eq!(deepest, Some(MAX_LEVELS));
|
||||
assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}");
|
||||
assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}");
|
||||
assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}");
|
||||
assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}");
|
||||
assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -427,17 +427,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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}",
|
||||
"{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 = s.catalog.keyword_list_text();
|
||||
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))
|
||||
.collect();
|
||||
let mut r = json!({"keywords": keywords, "captureOneRefuses": refuses});
|
||||
let mut r = json!({"keywords": keywords, "captureOneRefuses": refuses, "unwritable": 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}")))?;
|
||||
|
||||
@@ -277,11 +277,25 @@ fn exporting_keywords_writes_the_list() {
|
||||
assert!(text.contains("Places\n\tLisbon\n"), "{text}");
|
||||
assert!(r["keywords"].as_u64().unwrap() >= 3, "{r}");
|
||||
assert_eq!(r["captureOneRefuses"], json!(["fish, chips"]), "{r}");
|
||||
assert_eq!(r["unwritable"], json!([]), "{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);
|
||||
}
|
||||
|
||||
/// A keyword the format can't hold is left out of the file and named, so the file reads back.
|
||||
#[test]
|
||||
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 r = s.execute("keyword.export", &json!({})).unwrap();
|
||||
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}");
|
||||
s.execute("keyword.import", &json!({"text": text})).unwrap();
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -220,5 +220,6 @@
|
||||
"Added {n} keyword{}": "{n} Stichwort/Stichwörter hinzugefügt{:.0}",
|
||||
"; {n} gained synonyms": "; {n} erhielten Synonyme",
|
||||
"Exported {n} keyword{}": "{n} Stichwort/Stichwörter exportiert{:.0}",
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}"
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}",
|
||||
" — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}"
|
||||
}
|
||||
|
||||
@@ -220,5 +220,6 @@
|
||||
"Added {n} keyword{}": "Se añadieron {n} palabra{} clave",
|
||||
"; {n} gained synonyms": "; {n} recibieron sinónimos",
|
||||
"Exported {n} keyword{}": "Se exportaron {n} palabra{} clave",
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One no importa una lista con ; , < o > en una palabra clave: {names}"
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One no importa una lista con ; , < o > en una palabra clave: {names}",
|
||||
" — left out, as a keyword list can't hold them: {names}": " — omitidas, porque una lista de palabras clave no puede contenerlas: {names}"
|
||||
}
|
||||
|
||||
@@ -220,5 +220,6 @@
|
||||
"Added {n} keyword{}": "{n}件のキーワードを追加しました{:.0}",
|
||||
"; {n} gained synonyms": "。{n}件に同義語を追加",
|
||||
"Exported {n} keyword{}": "{n}件のキーワードを書き出しました{:.0}",
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One は ; , < > を含むキーワードがあるリストを読み込みません: {names}"
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One は ; , < > を含むキーワードがあるリストを読み込みません: {names}",
|
||||
" — left out, as a keyword list can't hold them: {names}": " — キーワードリストに書けないため省略: {names}"
|
||||
}
|
||||
|
||||
@@ -220,5 +220,6 @@
|
||||
"Added {n} keyword{}": "Palavras-chave adicionadas: {n}{:.0}",
|
||||
"; {n} gained synonyms": "; {n} receberam sinônimos",
|
||||
"Exported {n} keyword{}": "Palavras-chave exportadas: {n}{:.0}",
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — O Capture One não importa uma lista com ; , < ou > em uma palavra-chave: {names}"
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — O Capture One não importa uma lista com ; , < ou > em uma palavra-chave: {names}",
|
||||
" — left out, as a keyword list can't hold them: {names}": " — omitidas, pois uma lista de palavras-chave não pode contê-las: {names}"
|
||||
}
|
||||
|
||||
@@ -220,5 +220,6 @@
|
||||
"Added {n} keyword{}": "Добавлено ключевых слов: {n}{:.0}",
|
||||
"; {n} gained synonyms": "; синонимы добавлены к {n}",
|
||||
"Exported {n} keyword{}": "Экспортировано ключевых слов: {n}{:.0}",
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не импортирует список, где в ключевом слове есть ; , < или >: {names}"
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не импортирует список, где в ключевом слове есть ; , < или >: {names}",
|
||||
" — left out, as a keyword list can't hold them: {names}": " — пропущены: список ключевых слов не может их содержать: {names}"
|
||||
}
|
||||
|
||||
@@ -220,5 +220,6 @@
|
||||
"Added {n} keyword{}": "已添加 {n} 个关键字{:.0}",
|
||||
"; {n} gained synonyms": ";{n} 个获得了同义词",
|
||||
"Exported {n} keyword{}": "已导出 {n} 个关键字{:.0}",
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不会导入关键字中含有 ; , < 或 > 的列表:{names}"
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不会导入关键字中含有 ; , < 或 > 的列表:{names}",
|
||||
" — left out, as a keyword list can't hold them: {names}": " — 已省略,关键字列表无法容纳: {names}"
|
||||
}
|
||||
|
||||
@@ -220,5 +220,6 @@
|
||||
"Added {n} keyword{}": "已加入 {n} 個關鍵字{:.0}",
|
||||
"; {n} gained synonyms": ";{n} 個獲得了同義詞",
|
||||
"Exported {n} keyword{}": "已匯出 {n} 個關鍵字{:.0}",
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不會匯入關鍵字中含有 ; , < 或 > 的清單:{names}"
|
||||
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不會匯入關鍵字中含有 ; , < 或 > 的清單:{names}",
|
||||
" — left out, as a keyword list can't hold them: {names}": " — 已省略,關鍵字清單無法容納: {names}"
|
||||
}
|
||||
|
||||
@@ -1406,6 +1406,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
names = refuses.iter().take(5).copied().collect::<Vec<_>>().join(" · ")
|
||||
));
|
||||
}
|
||||
// what the format can't hold (a name in brackets, a line break…) isn't in the file
|
||||
let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
if !left.is_empty() {
|
||||
msg.push_str(&crate::i18n::tr_format!(
|
||||
" — left out, as a keyword list can't hold them: {names}",
|
||||
names = left.iter().take(5).copied().collect::<Vec<_>>().join(" · ")
|
||||
));
|
||||
}
|
||||
app.toast(&ctx, msg);
|
||||
}
|
||||
return Some(r);
|
||||
|
||||
@@ -74,3 +74,17 @@ fn exporting_keywords_warns_about_capture_one() {
|
||||
assert!(toast(&h).contains("Capture One"), "{}", toast(&h));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A keyword the list format can't hold is left out of the file, and the toast names it.
|
||||
#[test]
|
||||
fn exporting_keywords_names_what_it_left_out() {
|
||||
let dir = temp_dir("export-left-out");
|
||||
let mut h = app();
|
||||
let id = h.app.session.visible_cloned()[0].0;
|
||||
run(&mut h, "photo.setMeta", json!({"ids": [id], "addKeywords": ["Travel|[draft]"]}));
|
||||
let path = dir.join("keywords.txt");
|
||||
let r = run(&mut h, "file.exportKeywords", json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(toast(&h).contains("Travel|[draft]"), "{}", toast(&h));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user