diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 203081dff..0b4ba2fd5 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -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) { + // 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) { 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::>(), ["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::>().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. diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 40af75e71..02c9d0d10 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -427,17 +427,17 @@ pub fn specs() -> Vec { "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 = 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}")))?; diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index ead8567b3..2f8b81d3a 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -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] diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index a618b92aa..00395fef1 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index e4af9a0a4..1ee335b34 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index f28f8025e..e4976420b 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 9caa5832c..b6c1c23b4 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 9857ebe8c..cd55e708f 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index ed682c803..e006cdfd9 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 07b04a79c..c47f45691 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -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}" } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9f3a3918f..dc8644360 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1406,6 +1406,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option>().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::>().join(" · ") + )); + } app.toast(&ctx, msg); } return Some(r); diff --git a/crates/ui-egui/src/tests_keyword_files.rs b/crates/ui-egui/src/tests_keyword_files.rs index 3eeed518b..257af9cd3 100644 --- a/crates/ui-egui/src/tests_keyword_files.rs +++ b/crates/ui-egui/src/tests_keyword_files.rs @@ -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); +}