# Conflicts:
#	crates/ui-egui/src/tests_panels.rs
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas committed 2026-10-09 20:55:50 -04:00
commit 2421f2223b
27 files changed
+890 -18

No files matched your search

+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"))
+391
View File
@@ -53,6 +53,103 @@ pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec<String> {
out
}
/// The deepest a keyword list file may nest keywords: a file is input, so its nesting is bounded.
pub const MAX_LEVELS: usize = 64;
/// A keyword list as written by [`Catalog::keyword_list_text`]: the file's text, the keywords in
/// it (paths), and 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}`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KeywordListFile {
pub text: String,
pub written: Vec<String>,
pub unwritable: Vec<String>,
}
/// Read a keyword list file (Lightroom Classic's format): a keyword a line, those inside it
/// indented one tab more, a synonym in braces on a line of its own under its keyword, a keyword
/// left out of export in square brackets. Gives each keyword's path and attributes, in the order of
/// the file; a keyword twice is one. A line it can't read is an error that says which.
pub fn parse_keyword_list(text: &str) -> Result<Vec<(String, KeywordInfo)>> {
let bad = |n: usize, why: &str| CatalogError::Invalid(format!("line {n}: {why}"));
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
// Windows, Unix and classic Mac line ends alike
let text = text.replace("\r\n", "\n").replace('\r', "\n");
let mut out: Vec<(String, KeywordInfo)> = Vec::new();
// where each keyword is in `out`, by lower-case path (a search of `out` per line was quadratic)
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
// the keyword at each level above the line: its path and where it is in `out`
let mut above: Vec<(String, usize)> = Vec::new();
for (i, line) in text.split('\n').enumerate() {
let n = i + 1;
if line.trim().is_empty() {
continue;
}
let depth = line.chars().take_while(|c| *c == '\t').count();
let rest = line.trim_start_matches('\t');
// levels are tabs: spaces would flatten the hierarchy without a word
if rest.starts_with(' ') {
return Err(bad(n, "keywords are indented with tabs, one per level, not spaces"));
}
if rest.chars().any(char::is_control) {
return Err(bad(n, "a control character in a keyword"));
}
let body = rest.trim();
if let Some(synonym) = body.strip_prefix('{').and_then(|b| b.strip_suffix('}')) {
let Some(at) = depth.checked_sub(1).and_then(|d| above.get(d)).map(|(_, at)| *at) else {
return Err(bad(n, "a synonym (in braces) goes one tab under its keyword"));
};
let synonym = synonym.trim();
if synonym.contains(SEP) {
return Err(bad(n, "“|” separates a keyword's levels: a synonym can't hold one"));
}
if let Some((_, info)) = out.get_mut(at)
&& !synonym.is_empty()
&& !info.synonyms.iter().any(|s| same(s, synonym))
{
info.synonyms.push(synonym.to_string());
}
continue;
}
if depth > above.len() {
return Err(bad(n, "indented more than one tab below the keyword above it"));
}
if depth >= MAX_LEVELS {
return Err(bad(n, &format!("keywords nest {MAX_LEVELS} levels deep at most")));
}
let (name, left_out) = match body.strip_prefix('[').and_then(|b| b.strip_suffix(']')) {
Some(name) => (name.trim(), true),
None => (body, false),
};
if name.is_empty() {
return Err(bad(n, "a keyword needs a name"));
}
if name.contains(SEP) {
return Err(bad(n, "“|” separates a keyword's levels: a name can't hold one"));
}
above.truncate(depth);
let path = match above.last() {
Some((parent, _)) => format!("{parent}{SEP}{name}"),
None => name.to_string(),
};
let at = match index.get(&path.to_lowercase()) {
Some(at) => *at,
None => {
out.push((path.clone(), KeywordInfo::default()));
index.insert(path.to_lowercase(), out.len() - 1);
out.len() - 1
}
};
if left_out && let Some((_, info)) = out.get_mut(at) {
info.include_on_export = false;
}
// as first spelled: the keywords inside a repeat take that spelling
let spelled = out.get(at).map_or(path, |(p, _)| p.clone());
above.push((spelled, at));
}
Ok(out)
}
/// The keyword of `from` that `k` is under, the closest one when several are (`a|b` before `a`).
pub fn closest<'a>(k: &str, from: &'a [String]) -> Option<&'a String> {
from.iter().filter(|f| is_under(k, f)).max_by_key(|f| f.split(SEP).count())
@@ -339,6 +436,126 @@ impl Catalog {
out
}
/// 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. What the format can't hold
/// is left out and named, so the file always reads back.
pub fn keyword_list_text(&self) -> KeywordListFile {
// 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)
// a byte-order mark: on the first line the reader takes it for the file's own
|| name.starts_with('\u{feff}')
|| (name.starts_with('[') && name.ends_with(']'))
|| (name.starts_with('{') && name.ends_with('}'))
}
fn write(c: &Catalog, nodes: &[KeywordNode], depth: usize, file: &mut KeywordListFile) {
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) {
file.unwritable.push(n.path.clone());
continue;
}
let info = c.keyword_info(&n.path).cloned().unwrap_or_default();
if info.include_on_export {
file.text.push_str(&format!("{tabs}{}\n", n.name));
} else {
file.text.push_str(&format!("{tabs}[{}]\n", n.name));
}
file.written.push(n.path.clone());
// the reader trims synonyms and takes one per spelling: an empty one, or another's
// in a different case, is no loss; a line break or a “|” is
let mut synonyms: Vec<&str> = Vec::new();
for s in info.synonyms.iter().map(|s| s.trim()) {
if s.is_empty() || synonyms.iter().any(|x| same(x, s)) {
continue;
}
if s.chars().any(char::is_control) || s.contains(SEP) {
file.unwritable.push(format!("{} {{{s}}}", n.path));
} else {
synonyms.push(s);
file.text.push_str(&format!("{tabs}\t{{{s}}}\n"));
}
}
write(c, &n.children, depth + 1, file);
}
}
let mut file = KeywordListFile::default();
write(self, &self.keyword_tree(), 0, &mut file);
file
}
/// Import a keyword list (read with [`parse_keyword_list`]) — one batch: the keywords the
/// library doesn't have are added with the list's attributes, in the library's spelling of the
/// levels it has; those it has keep their attributes and gain the list's synonyms. Also says how
/// many keywords were added and how many gained synonyms.
pub fn import_keywords_ops(&self, entries: &[(String, KeywordInfo)]) -> (Op, usize, usize) {
// every keyword the library has, by lower-case path, as it spells it: learnt once from the
// tree (looking each line up among all the photos' keywords took minutes for big lists)
let mut known: std::collections::HashMap<String, String> = std::collections::HashMap::new();
fn learn(nodes: &[KeywordNode], known: &mut std::collections::HashMap<String, String>) {
for n in nodes {
known.insert(n.path.to_lowercase(), n.path.clone());
learn(&n.children, known);
}
}
learn(&self.keyword_tree(), &mut known);
// a path in the library's spelling of each level it has
let spell = |path: &str, known: &std::collections::HashMap<String, String>| -> String {
let levels: Vec<&str> = path.split(SEP).collect();
let mut out = String::new();
for (i, level) in levels.iter().enumerate() {
if !out.is_empty() {
out.push(SEP);
}
let prefix = levels.get(..=i).map(|l| l.join("|").to_lowercase()).unwrap_or_default();
match known.get(&prefix).and_then(|p| p.rsplit(SEP).next()) {
Some(name) => out.push_str(name),
None => out.push_str(level),
}
}
out
};
let (mut added, mut updated) = (0, 0);
let ops = self.list_ops(|list| {
for (path, info) in entries {
let path = spell(&clean(path), &known);
let key = path.to_lowercase();
if path.is_empty() {
continue;
}
if !known.contains_key(&key) {
list.insert(key.clone(), ListedKeyword { path: path.clone(), info: KeywordInfo { new_keywords_inside: false, ..info.clone() } });
// it and its parents are the library's now: what follows spells them so
let levels: Vec<&str> = path.split(SEP).collect();
for i in 0..levels.len() {
let prefix = levels.get(..=i).map(|l| l.join("|")).unwrap_or_default();
known.entry(prefix.to_lowercase()).or_insert(prefix);
}
added += 1;
continue;
}
// one the library has: the list's synonyms join its own (listed only if it gains some)
let mut info_now = list.get(&key).map(|l| l.info.clone()).unwrap_or_default();
let before = info_now.synonyms.len();
for s in &info.synonyms {
if !info_now.synonyms.iter().any(|x| same(x, s)) {
info_now.synonyms.push(s.clone());
}
}
if info_now.synonyms.len() > before {
let path = list.get(&key).map_or(path, |l| l.path.clone());
list.insert(key, ListedKeyword { path, info: info_now });
updated += 1;
}
}
});
(Op::Batch { ops }, added, updated)
}
/// The keyword is in the tree: listed or on a library photo (not one in Recently Deleted or
/// only browsed), itself or one below it.
pub fn has_keyword(&self, path: &str) -> bool {
@@ -1036,6 +1253,180 @@ mod tests {
assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon");
}
/// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the
/// keywords inside it indented one tab more, a synonym in braces on a line of its own under its
/// keyword, a keyword left out of export in square brackets; by name.
#[test]
fn the_keyword_list_is_written_as_tab_indented_text() {
let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]);
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().text, "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
/// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too.
#[test]
fn a_keyword_list_reads_back() {
let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap();
let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
assert_eq!(
read,
[
("beach".to_string(), KeywordInfo::default()),
("Events".to_string(), KeywordInfo::default()),
("Events|Weddings".to_string(), with_synonyms(&["marriage"])),
("Places".to_string(), out),
("Places|Lisbon".to_string(), KeywordInfo::default()),
]
);
}
/// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line
/// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword,
/// left out of export), keywords a tab further in at each level.
#[test]
fn a_photo_supreme_vocabulary_file_reads() {
let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n";
let read = parse_keyword_list(file).unwrap();
let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"]
.map(|p| if p == "Places" { p.to_string() } else { format!("Places|{p}") })
);
assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported");
assert!(read[1..].iter().all(|(_, i)| i.include_on_export));
}
/// A list that can't be read says which line and why, and nothing of it is taken.
#[test]
fn a_bad_keyword_list_says_which_line() {
let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once");
assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing");
assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here");
let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect();
assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most");
}
/// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone
/// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is
/// refused at its line; so are control characters in a name and "|" in a synonym.
#[test]
fn a_keyword_list_from_elsewhere_reads_or_says_why_not() {
let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::<Vec<_>>();
assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]);
let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation");
assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs");
assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character");
assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym");
}
/// A keyword repeated in another case is one keyword, and the keywords inside either take the
/// first spelling.
#[test]
fn a_repeated_keyword_keeps_its_first_spelling() {
let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap();
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 KeywordListFile { 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:?}");
}
/// The list leaves out only what it must, and names only what's lost: a name starting with a
/// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty
/// or another's in a different case is no loss. It also says which keywords it wrote.
#[test]
fn an_exported_keyword_list_names_only_what_it_loses() {
let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]);
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap();
let out = c.keyword_list_text();
let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text));
assert_eq!(out.unwritable, ["\u{feff}Albums"]);
assert_eq!(out.written, ["Weddings", "{draft"]);
assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["Weddings", "{draft"]);
let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()]));
}
/// 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.
#[test]
fn a_big_keyword_list_imports_quickly() {
let mut text = String::new();
for g in 0..400 {
text.push_str(&format!("group {g}\n"));
for k in 0..99 {
text.push_str(&format!("\tkeyword {g}-{k}\n"));
}
}
let t0 = std::time::Instant::now();
let read = parse_keyword_list(&text).unwrap();
assert_eq!(read.len(), 40_000);
let mut c = Catalog::new();
let mut ops = Vec::new();
for i in 0..20_000u32 {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01");
p.meta.keywords = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect();
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
c.apply(Op::Batch { ops }).unwrap();
let (op, added, _) = c.import_keywords_ops(&read[..5_000]);
c.apply(op).unwrap();
assert_eq!(added, 5_000);
let took = t0.elapsed();
assert!(took < std::time::Duration::from_secs(10), "{took:?}");
}
/// Importing a list adds the keywords the library doesn't have, with their attributes, and gives
/// those it has the list's synonyms (their own attributes stay), in one undo step.
#[test]
fn importing_a_keyword_list_adds_what_is_missing() {
let (mut c, _) = lib(&[&["Events|Weddings"]]);
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap();
let before = c.to_snapshot();
let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap();
let (op, added, updated) = c.import_keywords_ops(&read);
assert_eq!((added, updated), (2, 1));
let undo = c.apply(op).unwrap();
assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) }));
assert!(c.has_keyword("Events|Birthdays"));
assert_eq!(
listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(),
["Drafts", "Events|Birthdays", "Events|Weddings"],
"in the library's spelling"
);
assert!(!c.keyword_info("Drafts").unwrap().include_on_export);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn suggestions_rank_co_occurrence_and_prefixes() {
let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]);
+71
View File
@@ -95,6 +95,28 @@ 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)));
}
// Notepad and some exporters save UTF-16: say how to get the file in, not just that it isn't
if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
return Err(bad("keyword.import", format!("{path}: UTF-16 text — save it as UTF-8 and import it again")));
}
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 +426,55 @@ 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, 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 file = s.catalog.keyword_list_text();
// Capture One's importer refuses ; , < > in a keyword's name
let refuses: Vec<&str> = file
.written
.iter()
.filter_map(|p| p.rsplit(lightcraft_catalog::keywords::SEP).next())
.filter(|n| n.contains(CAPTURE_ONE_REFUSES))
.collect();
let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable});
match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) {
Some(path) => {
std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
r["path"] = json!(path);
}
None => r["text"] = json!(file.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)) if text.len() as u64 > MAX_LIST_BYTES => {
return Err(bad("keyword.import", format!("larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
}
(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",
+77
View File
@@ -262,3 +262,80 @@ 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}");
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 before = s.execute("keyword.export", &json!({})).unwrap()["keywords"].as_u64().unwrap();
// a name starting with a brace is written, counted and checked for Capture One like any other
s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": ["{draft, notes"]})).unwrap();
let r = s.execute("keyword.export", &json!({})).unwrap();
assert_eq!(r["keywords"].as_u64(), Some(before + 1), "{r}");
assert_eq!(r["captureOneRefuses"], json!(["{draft, notes"]), "{r}");
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();
}
/// A list saved as UTF-16 (as Windows' Notepad can) says to save it as UTF-8, and text too big to
/// be a keyword list is refused, as a file that big is.
#[test]
fn importing_keywords_refuses_what_it_cant_read() {
let dir = temp_dir("import-keywords-refused");
let mut s = Session::with_demo();
let utf16: Vec<u8> = [0xff, 0xfe].into_iter().chain("Travel\r\n".encode_utf16().flat_map(u16::to_le_bytes)).collect();
std::fs::write(dir.join("travel.txt"), utf16).unwrap();
let err = s.execute("keyword.import", &json!({"path": dir.join("travel.txt").to_string_lossy()})).unwrap_err().to_string();
assert!(err.contains("UTF-16") && err.contains("UTF-8"), "{err}");
let big = "Travel\n".repeat((17 << 20) / 7);
let err = s.execute("keyword.import", &json!({"text": big})).unwrap_err().to_string();
assert!(err.contains("16 MB"), "{err}");
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);
}
+6 -1
View File
@@ -216,5 +216,10 @@
"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{}": "Stichwörter hinzugefügt: {n}{:.0}",
"; {n} gained synonyms": "; mit neuen Synonymen: {n}",
"Exported {n} keyword{}": "Stichwörter exportiert: {n}{:.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}",
" — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}"
}
+6 -1
View File
@@ -1984,5 +1984,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"
}
+6 -1
View File
@@ -216,5 +216,10 @@
"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{}": "Palabras clave añadidas: {n}{:.0}",
"; {n} gained synonyms": "; con sinónimos nuevos: {n}",
"Exported {n} keyword{}": "Palabras clave exportadas: {n}{:.0}",
" — 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}"
}
+6 -1
View File
@@ -1829,5 +1829,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"
}
+6 -1
View File
@@ -216,5 +216,10 @@
"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}",
" — left out, as a keyword list can't hold them: {names}": " — キーワードリストに書けないため省略: {names}"
}
+6 -1
View File
@@ -1831,5 +1831,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 -1
View File
@@ -216,5 +216,10 @@
"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}",
" — left out, as a keyword list can't hold them: {names}": " — omitidas, pois uma lista de palavras-chave não pode contê-las: {names}"
}
+6 -1
View File
@@ -1829,5 +1829,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"
}
+6 -1
View File
@@ -216,5 +216,10 @@
"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}",
" — left out, as a keyword list can't hold them: {names}": " — пропущены: список ключевых слов не может их содержать: {names}"
}
+6 -1
View File
@@ -1541,5 +1541,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": "Списки ключевых слов"
}
+6 -1
View File
@@ -216,5 +216,10 @@
"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}",
" — left out, as a keyword list can't hold them: {names}": " — 已省略,关键字列表无法容纳: {names}"
}
+6 -1
View File
@@ -1829,5 +1829,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 -1
View File
@@ -216,5 +216,10 @@
"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}",
" — left out, as a keyword list can't hold them: {names}": " — 已省略,關鍵字清單無法容納: {names}"
}
+6 -1
View File
@@ -1829,5 +1829,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
@@ -50,6 +50,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;
@@ -134,6 +136,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",
+83 -3
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, ""),
@@ -256,7 +258,8 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if matches!(id, "library.inspectLightroom" | "library.importLightroom") {
let ctx = egui::Context::default();
// the app's own context: a fresh one's repaints reach no window and its clock starts at zero
let ctx = app.tasks.repaint.clone().unwrap_or_default();
return Some(crate::lightroom_import::command(app, id, p, &ctx));
}
if let Some(language) = language_from_command(id) {
@@ -266,7 +269,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
crate::i18n::set_language(app.ui.language);
return Some(Ok(json!(app.ui.language)));
}
let ctx = egui::Context::default();
// the app's own context: a toast's time comes from its clock (a fresh context's clock is at 0,
// and a toast set by it was long over by the app's clock: it never showed)
let ctx = app.tasks.repaint.clone().unwrap_or_default();
let r: Result<Value, String> = match id {
"view.photoGrid" => {
app.ui.view = ViewMode::PhotoGrid;
@@ -1224,7 +1229,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
if kept > 0 {
msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept);
}
app.toast(&egui::Context::default(), msg);
let ctx = app.tasks.repaint.clone().unwrap_or_default();
app.toast(&ctx, msg);
}
r
}
@@ -1367,6 +1373,80 @@ 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 Err(e) = &r {
app.toast(&ctx, e.clone());
}
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(" · ")
));
}
// 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}",
// escaped: a line break in a name would break the toast
names = left.iter().take(5).map(|n| n.escape_debug().to_string()).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"] {
+104
View File
@@ -0,0 +1,104 @@
//! 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);
}
/// 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));
// a name with a line break in it shows on one line
run(&mut h, "photo.setMeta", json!({"ids": [id], "addKeywords": ["Line\nbreak"]}));
run(&mut h, "file.exportKeywords", json!({"path": path.to_string_lossy()}));
assert!(toast(&h).contains("Line\\nbreak") && !toast(&h).contains('\n'), "{}", toast(&h));
let _ = std::fs::remove_dir_all(&dir);
}
/// A list that can't be written (a folder that isn't there) says why, as importing one does.
#[test]
fn exporting_keywords_says_why_it_failed() {
let mut h = app();
let path = std::env::temp_dir().join("lc-no-such-folder-for-keywords").join("keywords.txt");
let r = run(&mut h, "file.exportKeywords", json!({"path": path.to_string_lossy()}));
assert_eq!(r["ok"], false, "{r}");
assert!(toast(&h).contains("keywords.txt"), "{}", toast(&h));
}
+43
View File
@@ -1116,3 +1116,46 @@ fn deleting_a_populated_album_folder_from_its_menu_is_undoable() {
assert_eq!(h.app.session.catalog.to_snapshot(), before);
assert!(has(&h, &format!("source:folder:{}", folder.0)));
}
/// A menu command's toast shows, however long the app has been running: commands made their
/// toasts with a fresh context, whose clock is at 0, so by the app's clock they were already over
/// (Auto Advance On, Imported presets, …).
#[test]
fn a_menu_commands_toast_shows_after_a_while() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = crate::headless::Headless::new(app, [1000.0, 700.0], 1.0);
for _ in 0..600 {
h.step();
}
let r = h.request("engine.execute", serde_json::json!({"command": "view.autoAdvance"}), std::time::Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("Auto Advance")), "{:?}", h.app.ui.toast);
}
/// Auto-Tag from Tracklog's toast shows too: it was stamped by a clock of its own, which starts at
/// zero, so a while into a session it had always already expired.
#[test]
fn the_tracklog_toast_shows_after_a_while() {
let dir = std::env::temp_dir().join(format!("lc-tracklog-toast-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let gpx = dir.join("walk.gpx");
std::fs::write(
&gpx,
r#"<?xml version="1.0"?><gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg><trkpt lat="46.0" lon="7.0"><time>2026-05-01T10:00:00Z</time></trkpt></trkseg></trk></gpx>"#,
)
.unwrap();
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = crate::headless::Headless::new(app, [1000.0, 700.0], 1.0);
for _ in 0..600 {
h.step();
}
let p = serde_json::json!({"command": "photo.tagFromTracklog", "params": {"path": gpx.to_string_lossy(), "offset": 0}});
let r = h.request("engine.execute", p, std::time::Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("Tagged")), "{:?}", h.app.ui.toast);
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -752,7 +752,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down |
| LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' |
| LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` |
| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut |
| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields |
| LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) |
| LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step |
| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people
+8
View File
@@ -38,6 +38,14 @@
means no (it used to count as yes), and a value that is neither yes nor no is refused.
- Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name.
### Keyword lists
- File ▸ Import Keywords… and Export Keywords… read and write keyword list files as Lightroom Classic does (a keyword
a line, a tab per level, synonyms in braces, keywords left out of export in brackets). Capture One, darktable, Adobe
Bridge and Photo Supreme (its Formatted Vocabulary File) use the same files, so keyword lists move between them and
LightCraft. Importing adds the keywords you don't have, in one undo step; a file it can't read says which line, or
to save it as UTF-8. Exporting warns about keywords Capture One won't import, and names any keyword the format can't
hold (such as a name in brackets), which it leaves out so the file always reads back.
### Keyword sets
- Keyword Set ▸ Edit Set… edits a set's nine keywords in place, one field per ⌥ key, and renames it (or saves the edits as a new
set). An empty field leaves its key empty instead of moving the keywords after it; editing Recent Keywords and naming