LRC-LIB-KEYWORDS: File ▸ Import Keywords… and Export Keywords…

The File menu (beside the presets) imports a keyword list file
(Lightroom Classic's, Capture One's, Photo Supreme's .utf8) and exports
LightCraft's, with native file dialogs off the UI thread like the
other file commands; the desktop app gives the synchronous ones too.
Import says how many keywords it added and how many gained synonyms;
export says how many it wrote and names the keywords Capture One won't
import (; , < or > in a name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 16:42:38 -04:00
co-authored by Claude Opus 5.5
parent 3d75952562
commit 8160148a8b
21 changed files with 245 additions and 14 deletions
+2
View File
@@ -213,6 +213,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
// Face models need a folder to live in; the web has none.
pick_model_file: None,
save_preset_file: None,
pick_keyword_list: None,
save_keyword_list: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
write: Some(Box::new(download)),
+16
View File
@@ -472,6 +472,22 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_keyword_list: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
save_keyword_list: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
+5 -1
View File
@@ -216,5 +216,9 @@
"Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt",
"On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos",
"Remove “{name}”": "„{name}“ entfernen",
"There is a keyword set “{name}” already": "Es gibt bereits einen Stichwortsatz „{name}“"
"There is a keyword set “{name}” already": "Es gibt bereits einen Stichwortsatz „{name}“",
"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}"
}
+6 -1
View File
@@ -1921,5 +1921,10 @@
"Keywords (⌥1–⌥9)": "Stichwörter (⌥1–⌥9)",
"A keyword set needs a name": "Ein Stichwortsatz braucht einen Namen",
"Save as a new set": "Als neuen Satz speichern",
"Recent Keywords is built in: choose another name": "„Letzte Stichwörter“ ist vorgegeben: Wählen Sie einen anderen Namen"
"Recent Keywords is built in: choose another name": "„Letzte Stichwörter“ ist vorgegeben: Wählen Sie einen anderen Namen",
"Import Keywords…": "Stichwörter importieren…",
"Export Keywords…": "Stichwörter exportieren…",
"Import Keywords": "Stichwörter importieren",
"Export Keywords": "Stichwörter exportieren",
"Keyword Lists": "Stichwortlisten"
}
+5 -1
View File
@@ -216,5 +216,9 @@
"Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}",
"On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas",
"Remove “{name}”": "Quitar “{name}”",
"There is a keyword set “{name}” already": "Ya hay un conjunto de palabras clave “{name}”"
"There is a keyword set “{name}” already": "Ya hay un conjunto de palabras clave “{name}”",
"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}"
}
+6 -1
View File
@@ -1766,5 +1766,10 @@
"Keywords (⌥1–⌥9)": "Palabras clave (⌥1–⌥9)",
"A keyword set needs a name": "Un conjunto de palabras clave necesita un nombre",
"Save as a new set": "Guardar como conjunto nuevo",
"Recent Keywords is built in: choose another name": "“Palabras clave recientes” es fijo: elige otro nombre"
"Recent Keywords is built in: choose another name": "“Palabras clave recientes” es fijo: elige otro nombre",
"Import Keywords…": "Importar palabras clave…",
"Export Keywords…": "Exportar palabras clave…",
"Import Keywords": "Importar palabras clave",
"Export Keywords": "Exportar palabras clave",
"Keyword Lists": "Listas de palabras clave"
}
+5 -1
View File
@@ -216,5 +216,9 @@
"Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}",
"On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚",
"Remove “{name}”": "「{name}」を削除",
"There is a keyword set “{name}” already": "キーワードセット「{name}」はすでにあります"
"There is a keyword set “{name}” already": "キーワードセット「{name}」はすでにあります",
"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}"
}
+6 -1
View File
@@ -1766,5 +1766,10 @@
"Keywords (⌥1–⌥9)": "キーワード (⌥1–⌥9)",
"A keyword set needs a name": "キーワードセットには名前が必要です",
"Save as a new set": "新しいセットとして保存",
"Recent Keywords is built in: choose another name": "「最近使用したキーワード」は組み込みです。別の名前を選んでください"
"Recent Keywords is built in: choose another name": "「最近使用したキーワード」は組み込みです。別の名前を選んでください",
"Import Keywords…": "キーワードを読み込み…",
"Export Keywords…": "キーワードを書き出し…",
"Import Keywords": "キーワードを読み込み",
"Export Keywords": "キーワードを書き出し",
"Keyword Lists": "キーワードリスト"
}
+5 -1
View File
@@ -216,5 +216,9 @@
"Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}",
"On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas",
"Remove “{name}”": "Remover “{name}”",
"There is a keyword set “{name}” already": "Já existe um conjunto de palavras-chave “{name}”"
"There is a keyword set “{name}” already": "Já existe um conjunto de palavras-chave “{name}”",
"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}"
}
+6 -1
View File
@@ -1766,5 +1766,10 @@
"Keywords (⌥1–⌥9)": "Palavras-chave (⌥1–⌥9)",
"A keyword set needs a name": "Um conjunto de palavras-chave precisa de um nome",
"Save as a new set": "Salvar como novo conjunto",
"Recent Keywords is built in: choose another name": "“Palavras-chave recentes” é fixo: escolha outro nome"
"Recent Keywords is built in: choose another name": "“Palavras-chave recentes” é fixo: escolha outro nome",
"Import Keywords…": "Importar palavras-chave…",
"Export Keywords…": "Exportar palavras-chave…",
"Import Keywords": "Importar palavras-chave",
"Export Keywords": "Exportar palavras-chave",
"Keyword Lists": "Listas de palavras-chave"
}
+5 -1
View File
@@ -216,5 +216,9 @@
"Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}",
"On {have} of {of} selected photos": "На {have} из {of} выбранных фото",
"Remove “{name}”": "Убрать «{name}»",
"There is a keyword set “{name}” already": "Набор ключевых слов «{name}» уже есть"
"There is a keyword set “{name}” already": "Набор ключевых слов «{name}» уже есть",
"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}"
}
+6 -1
View File
@@ -1442,5 +1442,10 @@
"Set name": "Название набора",
"Save as a new set": "Сохранить как новый набор",
"Recent Keywords is built in: choose another name": "«Недавние ключевые слова» — встроенный набор: выберите другое название",
"Recent Keywords": "Недавние ключевые слова"
"Recent Keywords": "Недавние ключевые слова",
"Import Keywords…": "Импорт ключевых слов…",
"Export Keywords…": "Экспорт ключевых слов…",
"Import Keywords": "Импорт ключевых слов",
"Export Keywords": "Экспорт ключевых слов",
"Keyword Lists": "Списки ключевых слов"
}
+5 -1
View File
@@ -216,5 +216,9 @@
"Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}",
"On {have} of {of} selected photos": "所选 {of} 张照片中的 {have} 张",
"Remove “{name}”": "移除“{name}”",
"There is a keyword set “{name}” already": "已有关键字集“{name}”"
"There is a keyword set “{name}” already": "已有关键字集“{name}”",
"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}"
}
+6 -1
View File
@@ -1766,5 +1766,10 @@
"Keywords (⌥1–⌥9)": "关键字 (⌥1–⌥9)",
"A keyword set needs a name": "关键字集需要名称",
"Save as a new set": "另存为新关键字集",
"Recent Keywords is built in: choose another name": "“最近使用的关键字”为内置集:请另选名称"
"Recent Keywords is built in: choose another name": "“最近使用的关键字”为内置集:请另选名称",
"Import Keywords…": "导入关键字…",
"Export Keywords…": "导出关键字…",
"Import Keywords": "导入关键字",
"Export Keywords": "导出关键字",
"Keyword Lists": "关键字列表"
}
+5 -1
View File
@@ -216,5 +216,9 @@
"Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}",
"On {have} of {of} selected photos": "選取的 {of} 張照片中的 {have} 張",
"Remove “{name}”": "移除「{name}」",
"There is a keyword set “{name}” already": "已有關鍵字集「{name}」"
"There is a keyword set “{name}” already": "已有關鍵字集「{name}」",
"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}"
}
+6 -1
View File
@@ -1766,5 +1766,10 @@
"Keywords (⌥1–⌥9)": "關鍵字 (⌥1–⌥9)",
"A keyword set needs a name": "關鍵字集需要名稱",
"Save as a new set": "另存為新關鍵字集",
"Recent Keywords is built in: choose another name": "「最近使用的關鍵字」為內建集:請另選名稱"
"Recent Keywords is built in: choose another name": "「最近使用的關鍵字」為內建集:請另選名稱",
"Import Keywords…": "匯入關鍵字…",
"Export Keywords…": "匯出關鍵字…",
"Import Keywords": "匯入關鍵字",
"Export Keywords": "匯出關鍵字",
"Keyword Lists": "關鍵字清單"
}
+6
View File
@@ -42,6 +42,8 @@ mod tests_grid;
#[cfg(test)]
mod tests_keymap;
#[cfg(test)]
mod tests_keyword_files;
#[cfg(test)]
mod tests_keyword_list;
#[cfg(test)]
mod tests_keyword_set;
@@ -124,6 +126,10 @@ pub struct Services {
pub pick_tracklog: Option<PickFiles>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: Option<SaveFile>,
/// Open dialog for a keyword list file (`.txt`, Photo Supreme's `.utf8`; File ▸ Import Keywords…).
pub pick_keyword_list: Option<PickFiles>,
/// Save dialog for an exported keyword list (File ▸ Export Keywords…).
pub save_keyword_list: Option<SaveFile>,
/// Open dialog for point-curve preset files (`.lccurve`).
pub pick_curve_preset_files: Option<PickFiles>,
/// Save dialog for an exported `.lccurve` file.
+2
View File
@@ -77,6 +77,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
"---",
"file.importPresets",
"file.exportPresets",
"file.importKeywords",
"file.exportKeywords",
"---",
"dialog.export",
"app.exportPrevious",
+64
View File
@@ -164,6 +164,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
("file.importPresets", "Import Profiles & Presets…", None, "File"),
("file.exportPresets", "Export Presets…", None, "File"),
("file.importKeywords", "Import Keywords…", None, "File"),
("file.exportKeywords", "Export Keywords…", None, "File"),
// Edit panel ▸ Curve ▸ Point Curve dropdown
("file.importCurvePresets", "Import Point Curve Presets…", None, ""),
("file.exportCurvePresets", "Export Point Curve Presets…", None, ""),
@@ -1346,6 +1348,68 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
return Some(r);
}
"file.importKeywords" => {
// a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8)
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => x.to_string(),
None => {
let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]);
match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) {
Picked::Now(v) => match v.into_iter().next() {
Some(x) => x,
None => return Some(Ok(Value::Null)),
},
Picked::Later => return Some(Ok(Value::Null)),
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
}
}
};
let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string());
match &r {
Ok(v) => {
let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0));
let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added);
if updated > 0 {
msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated));
}
app.toast(&ctx, msg);
}
Err(e) => app.toast(&ctx, e.clone()),
}
return Some(r);
}
"file.exportKeywords" => {
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => x.to_string(),
None => {
let name = "Keywords.txt";
let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name);
match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) {
Picked::Now(v) => match v.into_iter().next() {
Some(x) => x,
None => return Some(Ok(Value::Null)),
},
Picked::Later => return Some(Ok(Value::Null)),
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
}
}
};
let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string());
if let Ok(v) = &r {
let n = v["keywords"].as_u64().unwrap_or(0);
let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n);
// Capture One's importer refuses ; , < > in a list: say which to rename first
let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
if !refuses.is_empty() {
msg.push_str(&crate::i18n::tr_format!(
" — Capture One won't import a list with ; , < or > in a keyword: {names}",
names = refuses.iter().take(5).copied().collect::<Vec<_>>().join(" · ")
));
}
app.toast(&ctx, msg);
}
return Some(r);
}
"file.exportPresets" => {
let group = p.get("group").and_then(Value::as_str).map(str::to_string);
let path = match p.get("path").and_then(Value::as_str) {
@@ -605,6 +605,8 @@ mod tests {
"Recent Keywords",
"Keywords (⌥1–⌥9)",
"Save as a new set",
"Import Keywords…",
"Export Keywords…",
];
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"] {
+76
View File
@@ -0,0 +1,76 @@
//! File ▸ Import Keywords… / Export Keywords…: keyword list files (Lightroom Classic's format,
//! which Capture One and Photo Supreme read and write too).
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn app() -> Headless {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.settle(SETTLE);
h
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-ui-keyword-files-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
h.settle(SETTLE);
r
}
fn toast(h: &Headless) -> String {
h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default()
}
/// The File menu offers Import Keywords… and Export Keywords….
#[test]
fn the_file_menu_offers_keyword_files() {
let mut h = app();
let r = h.request("ui.menu.list", json!({}), T);
let items = r["result"].to_string();
for id in ["file.importKeywords", "file.exportKeywords"] {
assert!(items.contains(id), "{id}");
}
}
/// Import Keywords… reads a list and says how many keywords it added.
#[test]
fn importing_keywords_says_what_it_added() {
let dir = temp_dir("import");
let mut h = app();
let path = dir.join("vocabulary.utf8");
std::fs::write(&path, "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n").unwrap();
let r = run(&mut h, "file.importKeywords", json!({"path": path.to_string_lossy()}));
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.session.catalog.has_keyword("Places|Portugal|Lisbon"));
assert!(toast(&h).contains("3"), "{}", toast(&h));
let _ = std::fs::remove_dir_all(&dir);
}
/// Export Keywords… writes the list, and warns about the keywords Capture One won't import.
#[test]
fn exporting_keywords_warns_about_capture_one() {
let dir = temp_dir("export");
let mut h = app();
let id = h.app.session.visible_cloned()[0].0;
run(&mut h, "photo.setMeta", json!({"ids": [id], "addKeywords": ["fish, chips"]}));
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!(std::fs::read_to_string(&path).unwrap().contains("fish, chips"));
assert!(toast(&h).contains("Capture One"), "{}", toast(&h));
let _ = std::fs::remove_dir_all(&dir);
}