From f577ccf8284eee82c998e13eecc6a0dcb33d96f0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:21:13 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: Will Export shows what exported files carry Lightroom Classic's Keywording box can show the keywords that will be exported rather than those entered. A Keywords / Will Export switch under the box now does the same for the selection: the names exported files carry as the keyword tag options say (a keyword left out of export goes, parents and synonyms come), one only some of the photos carry marked with an asterisk. Read only; cached per selection. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/lib.rs | 18 ++++++ crates/ui-egui/src/panels/keyword_list.rs | 2 + crates/ui-egui/src/panels/keywording.rs | 74 +++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 8 ++- crates/ui-egui/src/state.rs | 4 ++ crates/ui-egui/src/tests_keywording.rs | 15 +++++ 13 files changed, 141 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index be30fa17..785e0fa9 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1912,5 +1912,7 @@ "Drop here for the top level": "Hier ablegen für die oberste Ebene", "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen.", "Select photos to give them keywords.": "Wählen Sie Fotos aus, um ihnen Stichwörter zu geben.", - "Add to All Selected Photos": "Allen ausgewählten Fotos hinzufügen" + "Add to All Selected Photos": "Allen ausgewählten Fotos hinzufügen", + "Will Export": "Wird exportiert", + "No keywords are exported": "Keine Stichwörter werden exportiert" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 1095fac2..dc0726d6 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "Soltar aquí para el nivel superior", "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave.", "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave.", - "Add to All Selected Photos": "Añadir a todas las fotos seleccionadas" + "Add to All Selected Photos": "Añadir a todas las fotos seleccionadas", + "Will Export": "Se exportará", + "No keywords are exported": "No se exporta ninguna palabra clave" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index c17f3b3e..5f0cd0c4 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "ここにドロップすると最上位に移動", "Their photos and the keywords below them come together under one keyword.": "写真と下位のキーワードが1つのキーワードにまとめられます。", "Select photos to give them keywords.": "写真を選択するとキーワードを付けられます。", - "Add to All Selected Photos": "選択したすべての写真に追加" + "Add to All Selected Photos": "選択したすべての写真に追加", + "Will Export": "書き出される内容", + "No keywords are exported": "書き出されるキーワードはありません" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2c26013a..1414b5e0 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "Solte aqui para o nível superior", "Their photos and the keywords below them come together under one keyword.": "Suas fotos e as palavras-chave abaixo delas ficam sob uma só palavra-chave.", "Select photos to give them keywords.": "Selecione fotos para dar palavras-chave a elas.", - "Add to All Selected Photos": "Adicionar a todas as fotos selecionadas" + "Add to All Selected Photos": "Adicionar a todas as fotos selecionadas", + "Will Export": "Será exportado", + "No keywords are exported": "Nenhuma palavra-chave é exportada" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 94d63635..d7029078 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1431,5 +1431,7 @@ "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом.", "Select photos to give them keywords.": "Выберите фото, чтобы назначить им ключевые слова.", "Person": "Человек", - "Add to All Selected Photos": "Добавить ко всем выбранным фото" + "Add to All Selected Photos": "Добавить ко всем выбранным фото", + "Will Export": "Будет экспортировано", + "No keywords are exported": "Ключевые слова не экспортируются" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 184b117e..67545963 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "拖放到此处以移至顶层", "Their photos and the keywords below them come together under one keyword.": "它们的照片和下级关键字将合并到同一个关键字下。", "Select photos to give them keywords.": "选择照片以为其添加关键字。", - "Add to All Selected Photos": "添加到所有所选照片" + "Add to All Selected Photos": "添加到所有所选照片", + "Will Export": "将导出", + "No keywords are exported": "没有要导出的关键字" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index aa02f290..448fa70c 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "拖放到此處以移至最上層", "Their photos and the keywords below them come together under one keyword.": "它們的照片與下層關鍵字將合併到同一個關鍵字下。", "Select photos to give them keywords.": "選取照片以加入關鍵字。", - "Add to All Selected Photos": "加入所有選取的照片" + "Add to All Selected Photos": "加入所有選取的照片", + "Will Export": "將匯出", + "No keywords are exported": "沒有要匯出的關鍵字" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 55138bd7..0e2fed5a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1080,6 +1080,8 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, + /// The Keywording box's Will Export names, by library revision and selection. + keyword_export: Option<(u64, u64, std::sync::Arc>)>, /// The Keywording box's chips, by library revision and selection. keyword_chips: Option<(u64, u64, std::sync::Arc>)>, /// The Keyword List's tick boxes, by library revision and selection. @@ -1170,6 +1172,22 @@ impl Caches { } } } + /// What exported files carry for the selection (the Keywording box's Will Export). + pub(crate) fn keyword_export( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc> { + let key = key_of(selection); + match &self.keyword_export { + Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), + _ => { + let c = std::sync::Arc::new(panels::keywording::will_export(cat, selection)); + self.keyword_export = Some((cat.revision, key, c.clone())); + c + } + } + } /// The selection's keywords (the Keywording box's chips). pub(crate) fn keyword_chips( &mut self, diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 432f75c9..fb51a14c 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -593,6 +593,8 @@ mod tests { "No keywords match", "Add to Selected Photos", "Remove from Selected Photos", + "Keywords", + "Will Export", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); for code in ["de", "es", "pt-br", "ru", "ja", "zh-hans", "zh-hant"] { diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 09107dc1..c3e47717 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -9,6 +9,47 @@ use crate::LightcraftApp; use crate::theme::Tokens; use crate::widgets::register; +/// The Keywording box's view: the keywords (chips), or what exported files will carry. +pub fn view_switch(app: &mut LightcraftApp, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + for (export, id, label) in [(false, "keywords", "Keywords"), (true, "willExport", "Will Export")] { + let r = ui.selectable_label(app.ui.keywording_will_export == export, crate::i18n::tr(label)); + register(ui.ctx(), format!("keywordView:{id}"), r.rect); + if r.clicked() { + app.ui.keywording_will_export = export; + } + } + }); +} + +/// What exported files will carry for the selection, read only: one name only some of the photos +/// carry is marked with an asterisk. +pub fn export_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let selection = app.session.selection.ids.clone(); + let names = app.caches.keyword_export(&app.session.catalog, &selection); + if names.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No keywords are exported")).color(t.text_dim)); + return; + } + ui.horizontal_wrapped(|ui| { + for n in names.iter() { + let partial = !n.on_all(); + let text = format!("{}{}", n.path, if partial { " *" } else { "" }); + let r = egui::Frame::NONE + .stroke(egui::Stroke::new(1.0, t.field_border)) + .corner_radius(10.0) + .inner_margin(egui::Margin::symmetric(8, 2)) + .show(ui, |ui| ui.label(egui::RichText::new(text).color(if partial { t.text_dim } else { t.text_label }))) + .response; + register(ui.ctx(), format!("keywordExport:{}", n.path), r.rect); + if partial { + r.on_hover_text(crate::i18n::tr_format!("On {have} of {of} selected photos", have = n.have, of = n.of)); + } + } + }); +} + /// A chip's menu: it acts on the selected photos (deleting a keyword from the whole library is the /// Keyword List's). fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { @@ -93,6 +134,20 @@ impl Chip { /// The keywords of the selected `photos`, each once whatever its case, with how many of them have /// it; by name. +/// "Will Export" (Lightroom Classic's Keyword Tags ▸ Will Export): the names exported files carry +/// for the selected `photos`, as the keyword tag options say, each with how many of them carry it; +/// by name. +pub(crate) fn will_export(catalog: &Catalog, photos: &[PhotoId]) -> Vec { + let mut have: std::collections::BTreeMap = Default::default(); + for id in photos { + let Some(p) = catalog.photo(*id) else { continue }; + for name in catalog.export_keywords(&p.meta.keywords).flat { + have.entry(name.to_lowercase()).or_insert_with(|| (name.clone(), 0)).1 += 1; + } + } + have.into_values().map(|(path, have)| Chip { path, have, of: photos.len() }).collect() +} + pub(crate) fn chips(catalog: &Catalog, photos: &[PhotoId]) -> Vec { use lightcraft_catalog::keywords::clean; // by lower-case keyword: the photos with it (each once) @@ -152,4 +207,23 @@ mod tests { let (c, ids) = library(&[&["Beach"], &["beach"], &["BEACH", "beach"]]); assert_eq!(shown(&chips(&c, &ids)), [("Beach", 3, 3)]); } + + /// "Will Export": the names exported files carry for the selection, as the keyword tag options + /// say (a keyword left out goes, its parents and synonyms come), each with how many of the + /// selected photos carry it. + #[test] + fn will_export_shows_what_exported_files_carry() { + use lightcraft_catalog::keywords::KeywordInfo; + let (mut c, ids) = library(&[&["Places|Lisbon", "draft"], &["Places|Lisbon"]]); + let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap(); + c.apply(Op::SetKeyword { + path: "Places|Lisbon".into(), + info: Some(KeywordInfo { synonyms: vec!["Lisboa".into()], ..KeywordInfo::default() }), + }) + .unwrap(); + assert_eq!(shown(&will_export(&c, &ids)), [("Lisboa", 2, 2), ("Lisbon", 2, 2)]); + assert!(will_export(&c, &[]).is_empty()); + } } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index d92acaab..f4c5fe89 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -787,7 +787,13 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } ui.data_mut(|d| d.insert_temp(kid, text)); ui.add_space(8.0); - super::keywording::chip_row(app, ui); + super::keywording::view_switch(app, ui); + ui.add_space(4.0); + if app.ui.keywording_will_export { + super::keywording::export_row(app, ui); + } else { + super::keywording::chip_row(app, ui); + } ui.add_space(10.0); keyword_set(app, ui); ui.add_space(8.0); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 20fddc3b..f4dd8ead 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -449,6 +449,9 @@ pub struct UiState { /// The keyword painter: clicking a photo in the grid toggles this keyword on it. #[serde(skip)] pub keyword_painter: Option, + /// The Keywording box shows what exported files will carry (Will Export) instead of the + /// keywords. + pub keywording_will_export: bool, /// The Keyword List's open levels (lower-case paths). pub keyword_list_open: Vec, /// The keyword picked in the Keyword List (− deletes it, Edit edits it). @@ -795,6 +798,7 @@ impl Default for UiState { slideshow: None, second_window: false, keyword_painter: None, + keywording_will_export: false, keyword_list_open: Vec::new(), keyword_list_selected: None, dragging_keyword: None, diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index c669d709..3de8c53a 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -119,3 +119,18 @@ fn keyword_set_buttons_reflect_the_selection() { assert!(has(&h, &format!("kwSetSome:{}", at("Weddings"))), "only the active one has Weddings"); assert!(!has(&h, &format!("kwSetOn:{}", at("Weddings")))); } + +/// "Will Export" shows the names exported files will carry for the selection instead of the chips: +/// a keyword left out of export isn't there; "Keywords" brings the chips back. +#[test] +fn will_export_shows_what_exports_carry() { + let (mut h, _) = two_selected(); + let r = h.request("engine.execute", json!({"command": "keyword.edit", "params": {"keyword": "Weddings", "includeOnExport": false}}), T); + assert_eq!(r["ok"], true, "{r}"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordView:willExport"})); + assert!(has(&h, "keywordExport:beach")); + assert!(!has(&h, "keywordExport:Weddings"), "left out of export"); + assert!(!has(&h, "keywordChip:beach"), "the chips give way"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordView:keywords"})); + assert!(has(&h, "keywordChip:beach") && !has(&h, "keywordExport:beach")); +}