mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
Conflicts: - apps/lightcraft/src/native_menu.rs: upstream's item swapping (macOS keys given up for real, Help menu not swapped, Settings / Quit tracked) with KEY-GRID's keymap-aware rows. `Item.label` stays the text the row shows (native_item_presentation), so `Item::native_label` reuses it instead of formatting it again; Settings / Quit store theirs through display_item_label, as upstream's native_label did. No early return after a rebuild (upstream). - crates/ui-egui/src/i18n.rs: upstream's tests (#2729: one test per check across languages, gaps reported, never a failure) plus the batch's two new ones (dropped-photos notice, Polish counts); production code as merged. - grid.rs, keyword_list.rs, masking.rs: the translation tests #2729 removed stay removed; the batch's new keyword and segmenter tests stay. - cmd/library.rs, ui-egui/src/lib.rs: both test modules, each with its #[cfg(test)]. - xtask/src/main.rs: #2302's test_selected and upstream's translation_gaps. - docs: tracker / gap rows take both sides' edits, headers list both sides' changes, history rows of both sides newest-first. What's New: one section per date, newest first (the batch had 10-22 above 10-23, and second 10-21 and 10-19 sections); no line lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
538 lines
26 KiB
Rust
538 lines
26 KiB
Rust
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
|
|
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
|
|
|
|
use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, is_under, reparent};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
|
use crate::{Result, Session};
|
|
|
|
fn strs(p: &Value, key: &str) -> Vec<String> {
|
|
match p.get(key) {
|
|
Some(Value::String(s)) => vec![s.clone()],
|
|
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Commit a keyword batch; returns `{changed: photos changed, listed: keyword list changes}`. The
|
|
/// keyword filter follows a renamed keyword and is cleared when its keyword is deleted.
|
|
fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option<String>) -> Result<Value> {
|
|
use lightcraft_catalog::Op;
|
|
let (photos, listed) = match &op {
|
|
Op::Batch { ops } => {
|
|
(ops.iter().filter(|o| matches!(o, Op::SetMeta { .. })).count(), ops.iter().filter(|o| matches!(o, Op::SetKeyword { .. })).count())
|
|
}
|
|
Op::SetMeta { .. } => (1, 0),
|
|
_ => (0, 1),
|
|
};
|
|
if photos + listed > 0 {
|
|
s.commit(label, op)?;
|
|
}
|
|
if let Some(k) = s.filter.keyword.clone() {
|
|
s.filter.keyword = follow(&k);
|
|
}
|
|
// a filter put aside by Enable Filters follows the keyword too
|
|
if let Some(f) = s.shelved_filter.as_mut()
|
|
&& let Some(k) = f.keyword.clone()
|
|
{
|
|
f.keyword = follow(&k);
|
|
}
|
|
Ok(json!({"changed": photos, "listed": listed}))
|
|
}
|
|
|
|
/// A keyword's attributes from command params over `base`: those not given keep their value.
|
|
fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo {
|
|
KeywordInfo {
|
|
synonyms: if p.get("synonyms").is_some() {
|
|
strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
|
} else {
|
|
base.synonyms
|
|
},
|
|
include_on_export: bool_or(p, "includeOnExport", base.include_on_export),
|
|
export_containing: bool_or(p, "exportContaining", base.export_containing),
|
|
export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms),
|
|
person: bool_or(p, "person", base.person),
|
|
new_keywords_inside: base.new_keywords_inside,
|
|
}
|
|
}
|
|
|
|
/// The keyword a command names (`keyword`), as the library writes it.
|
|
fn named_keyword(s: &Session, p: &Value, command: &str) -> Result<String> {
|
|
let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?;
|
|
s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k))))
|
|
}
|
|
|
|
/// A catalog refusal as a command error; a taken name says how to merge.
|
|
fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError {
|
|
match e {
|
|
lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")),
|
|
e => bad(command, e.to_string()),
|
|
}
|
|
}
|
|
|
|
/// A named set of up to nine keywords.
|
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct KeywordSet {
|
|
pub name: String,
|
|
pub keywords: Vec<String>,
|
|
}
|
|
|
|
/// Two set names (or recent keywords) are the same whatever the case of any of their letters, as
|
|
/// keywords are.
|
|
fn same_set(a: &str, b: &str) -> bool {
|
|
lightcraft_catalog::keywords::same(a.trim(), b.trim())
|
|
}
|
|
|
|
/// The set name meaning "the nine most recently added keywords".
|
|
pub const RECENT: &str = "Recent Keywords";
|
|
|
|
/// Remember `added` as the most recent keywords (newest first, nine kept).
|
|
pub fn note_recent(s: &mut Session, added: &[String]) {
|
|
for k in added.iter().rev() {
|
|
let k = clean(k);
|
|
if k.is_empty() {
|
|
continue;
|
|
}
|
|
s.recent_keywords.retain(|x| !same_set(x, &k));
|
|
s.recent_keywords.insert(0, k);
|
|
}
|
|
s.recent_keywords.truncate(9);
|
|
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.
|
|
pub fn slots(typed: &[String]) -> Vec<String> {
|
|
let mut out: Vec<String> = Vec::new();
|
|
for k in typed.iter().take(9) {
|
|
let k = clean(k);
|
|
let twice = !k.is_empty() && out.iter().any(|x| lightcraft_catalog::keywords::same(x, &k));
|
|
out.push(if twice { String::new() } else { k });
|
|
}
|
|
while out.last().is_some_and(String::is_empty) {
|
|
out.pop();
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
|
|
pub fn current_keywords(s: &Session) -> Vec<String> {
|
|
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| same_set(&x.name, n)));
|
|
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
|
|
v.truncate(9);
|
|
v
|
|
}
|
|
|
|
/// `{sets: [{name, keywords}], current, keywords}` (Recent Keywords first).
|
|
pub fn keyword_sets_json(s: &Session) -> Value {
|
|
let mut sets = vec![json!({"name": RECENT, "keywords": s.recent_keywords})];
|
|
sets.extend(s.keyword_sets.iter().map(|x| json!({"name": x.name, "keywords": x.keywords})));
|
|
json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)})
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
|
|
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
|
|
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
|
|
s.keyword_set = if same_set(name, RECENT) || name.is_empty() {
|
|
None
|
|
} else {
|
|
Some(
|
|
s.keyword_sets
|
|
.iter()
|
|
.find(|x| same_set(&x.name, name))
|
|
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
|
|
.name
|
|
.clone(),
|
|
)
|
|
};
|
|
s.save_prefs()?;
|
|
Ok(keyword_sets_json(s))
|
|
}),
|
|
cmd!(
|
|
"keyword.saveSet",
|
|
"Save Keyword Set",
|
|
[],
|
|
None,
|
|
"{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames, new?: refuse a name another set has} — replaces a set of that name (or `replace`, in its place) and makes it current",
|
|
always,
|
|
|s, p| {
|
|
let name = str_param(p, "name")
|
|
.map(str::trim)
|
|
.filter(|n| !n.is_empty() && !same_set(n, RECENT))
|
|
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
|
|
.to_string();
|
|
let same_name = |x: &KeywordSet, n: &str| same_set(&x.name, n);
|
|
// the set it renames, if any (an empty `replace` is none)
|
|
let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) {
|
|
Some(old) => Some(
|
|
s.keyword_sets
|
|
.iter()
|
|
.position(|x| same_name(x, old))
|
|
.ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?,
|
|
),
|
|
None => None,
|
|
};
|
|
// keywords not given: the renamed set's own, else the current nine
|
|
let keywords = if p.get("keywords").is_some() {
|
|
slots(&strs(p, "keywords"))
|
|
} else {
|
|
match renames.and_then(|at| s.keyword_sets.get(at)) {
|
|
Some(x) => x.keywords.clone(),
|
|
None => current_keywords(s),
|
|
}
|
|
};
|
|
let set = KeywordSet { name: name.clone(), keywords };
|
|
match renames {
|
|
// rename: in its place, onto a name no other set has
|
|
Some(at) => {
|
|
if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) {
|
|
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
|
}
|
|
if let Some(x) = s.keyword_sets.get_mut(at) {
|
|
*x = set;
|
|
}
|
|
}
|
|
// a new set (`new`) never takes another set's name
|
|
None if bool_or(p, "new", false) && s.keyword_sets.iter().any(|x| same_name(x, &name)) => {
|
|
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
|
}
|
|
None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) {
|
|
Some(x) => *x = set,
|
|
None => s.keyword_sets.push(set),
|
|
},
|
|
}
|
|
s.keyword_set = Some(name);
|
|
s.save_prefs()?;
|
|
Ok(keyword_sets_json(s))
|
|
}
|
|
),
|
|
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
|
|
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
|
|
let before = s.keyword_sets.len();
|
|
s.keyword_sets.retain(|x| !same_set(&x.name, name));
|
|
if s.keyword_sets.len() == before {
|
|
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
|
|
}
|
|
if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) {
|
|
s.keyword_set = None;
|
|
}
|
|
s.save_prefs()?;
|
|
Ok(keyword_sets_json(s))
|
|
}),
|
|
cmd!(
|
|
"keyword.toggleFromSet",
|
|
"Toggle Keyword from Set",
|
|
[],
|
|
None,
|
|
"{index: 1..9, ids?} — the set's keyword N: added to the target photos, or removed when they all have it",
|
|
super::has_selection,
|
|
|s, p| {
|
|
let i = p
|
|
.get("index")
|
|
.and_then(Value::as_u64)
|
|
.filter(|i| (1..=9).contains(i))
|
|
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
|
|
// (an empty slot does nothing)
|
|
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
|
|
return Ok(json!({"changed": 0}));
|
|
};
|
|
let ids = s.targets(p)?;
|
|
let all = !ids.is_empty()
|
|
&& ids.iter().all(|id| {
|
|
s.catalog
|
|
.photo(*id)
|
|
.is_some_and(|ph| ph.meta.keywords.iter().any(|x| lightcraft_catalog::keywords::same(&clean(x), &clean(&k))))
|
|
});
|
|
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
|
|
let key = if all { "removeKeywords" } else { "addKeywords" };
|
|
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
|
|
let recent = s.recent_keywords.clone();
|
|
let mut r = s.execute("photo.setMeta", &json!({"ids": ids, key: [k.clone()]}))?;
|
|
if s.keyword_set.is_none() {
|
|
s.recent_keywords = recent;
|
|
}
|
|
r["keyword"] = json!(k);
|
|
r["added"] = json!(!all);
|
|
Ok(r)
|
|
}
|
|
),
|
|
cmd!(query "keyword.list", "Keywords", [], None, "{} → [{name, path, count, children}] keyword tree (`a|b|c` keywords are hierarchical)", always, |s, _| {
|
|
Ok(serde_json::to_value(s.catalog.keyword_tree()).unwrap_or_default())
|
|
}),
|
|
cmd!(
|
|
query "keyword.suggest",
|
|
"Keyword Suggestions",
|
|
[],
|
|
None,
|
|
"{prefix?: typed text, ids?, limit?: 12} → keywords to suggest for the photos (co-occurring / most used, or matching the prefix)",
|
|
always,
|
|
|s, p| {
|
|
let mut current: Vec<String> = Vec::new();
|
|
for id in s.targets(p)? {
|
|
if let Some(ph) = s.catalog.photo(id) {
|
|
current.extend(ph.meta.keywords.iter().cloned());
|
|
}
|
|
}
|
|
let n = p.get("limit").and_then(Value::as_u64).unwrap_or(12) as usize;
|
|
Ok(json!(s.catalog.keyword_suggestions(¤t, str_param(p, "prefix").unwrap_or(""), n)))
|
|
}
|
|
),
|
|
cmd!(
|
|
query "keyword.info",
|
|
"Keyword Info",
|
|
[],
|
|
None,
|
|
"{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}",
|
|
always,
|
|
|s, p| {
|
|
let path = named_keyword(s, p, "keyword.info")?;
|
|
let info = s.catalog.keyword_info(&path).cloned();
|
|
let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count();
|
|
let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default();
|
|
if let Some(o) = v.as_object_mut() {
|
|
o.insert("path".into(), json!(path));
|
|
o.insert("listed".into(), json!(info.is_some()));
|
|
o.insert("count".into(), json!(count));
|
|
o.entry("synonyms").or_insert(json!([]));
|
|
}
|
|
Ok(v)
|
|
}
|
|
),
|
|
cmd!(
|
|
"keyword.create",
|
|
"Create Keyword",
|
|
[],
|
|
None,
|
|
"{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}",
|
|
always,
|
|
|s, p| {
|
|
let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?;
|
|
let parent = match p.get("parent") {
|
|
Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))),
|
|
Some(_) => None,
|
|
None => s.catalog.default_keyword_parent(),
|
|
};
|
|
let path = clean(&match parent {
|
|
Some(parent) => format!("{parent}|{name}"),
|
|
None => name,
|
|
});
|
|
let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() };
|
|
let op = s
|
|
.catalog
|
|
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
|
|
.map_err(|e| refused("keyword.create", e, ""))?;
|
|
s.commit("Create Keyword", op)?;
|
|
if !photos.is_empty() {
|
|
note_recent(s, std::slice::from_ref(&path));
|
|
}
|
|
Ok(json!({"keyword": path}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"keyword.edit",
|
|
"Edit Keyword",
|
|
[],
|
|
None,
|
|
"{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value",
|
|
always,
|
|
|s, p| {
|
|
let from = named_keyword(s, p, "keyword.edit")?;
|
|
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
|
let name = str_param(p, "name").unwrap_or(&leaf).to_string();
|
|
let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default());
|
|
let op =
|
|
s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?;
|
|
let to = match from.rsplit_once('|') {
|
|
Some((parent, _)) => format!("{parent}|{}", name.trim()),
|
|
None => name.trim().to_string(),
|
|
};
|
|
commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
|
}
|
|
),
|
|
cmd!(
|
|
"keyword.move",
|
|
"Move Keyword",
|
|
[],
|
|
None,
|
|
"{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`",
|
|
always,
|
|
|s, p| {
|
|
let from = named_keyword(s, p, "keyword.move")?;
|
|
let parent = match p.get("parent") {
|
|
Some(Value::String(k)) => Some(k.clone()),
|
|
Some(Value::Null) => None,
|
|
_ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),
|
|
};
|
|
// where it lands, as the catalog spells it: a parent that doesn't exist yet is made
|
|
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
|
let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) {
|
|
Some(parent) => format!("{parent}|{leaf}"),
|
|
None => leaf,
|
|
};
|
|
let op = s
|
|
.catalog
|
|
.move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false))
|
|
.map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?;
|
|
commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
|
}
|
|
),
|
|
cmd!(
|
|
"keyword.purgeUnused",
|
|
"Purge Unused Keywords",
|
|
[],
|
|
None,
|
|
"{} — takes off the keyword list the keywords no photo has → {purged}",
|
|
always,
|
|
|s, _| {
|
|
let op = s.catalog.purge_unused_keywords_ops();
|
|
let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?;
|
|
Ok(json!({"purged": r["listed"]}))
|
|
}
|
|
),
|
|
cmd!(
|
|
"keyword.setDefaultParent",
|
|
"Put New Keywords Inside",
|
|
[],
|
|
None,
|
|
"{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)",
|
|
always,
|
|
|s, p| {
|
|
let keyword = match p.get("keyword") {
|
|
Some(Value::Null) | None => None,
|
|
Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?),
|
|
};
|
|
let op = s.catalog.set_default_parent_ops(keyword.as_deref()).map_err(|e| bad("keyword.setDefaultParent", e.to_string()))?;
|
|
commit_keywords(s, "Put New Keywords Inside", op, |k| Some(k.to_string()))?;
|
|
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) => {
|
|
s.check_write_target(path).map_err(|e| bad("keyword.export", e))?;
|
|
// A folder that isn't there is an error, not created.
|
|
lightcraft_catalog::safe_file::write_atomic(std::path::Path::new(path), file.text.as_bytes())
|
|
.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",
|
|
[],
|
|
None,
|
|
"{from, to} — on every photo, children included (`a` → `b` renames `a|x` to `b|x`); renaming onto an existing keyword merges them",
|
|
always,
|
|
|s, p| {
|
|
let from = str_param(p, "from").ok_or_else(|| bad("keyword.rename", "missing `from`"))?.to_string();
|
|
let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string();
|
|
let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?;
|
|
let (f, t) = (clean(&from), clean(&to));
|
|
commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() }))
|
|
}
|
|
),
|
|
cmd!(
|
|
"keyword.delete",
|
|
"Delete Keyword",
|
|
[],
|
|
None,
|
|
"{keyword} — removes it (and the keywords below it) from every photo",
|
|
always,
|
|
|s, p| {
|
|
let k = str_param(p, "keyword").ok_or_else(|| bad("keyword.delete", "missing `keyword`"))?.to_string();
|
|
let op = s.catalog.delete_keyword_ops(&k).map_err(|e| bad("keyword.delete", e.to_string()))?;
|
|
let c = clean(&k);
|
|
commit_keywords(s, "Delete Keyword", op, |f| (!is_under(f, &c)).then(|| f.to_string()))
|
|
}
|
|
),
|
|
cmd!(
|
|
"keyword.merge",
|
|
"Merge Keywords",
|
|
[],
|
|
None,
|
|
"{from: [keyword], into: keyword} — replaces each `from` keyword (children included) with `into` on every photo",
|
|
always,
|
|
|s, p| {
|
|
let from = strs(p, "from");
|
|
let into = str_param(p, "into").ok_or_else(|| bad("keyword.merge", "missing `into`"))?.to_string();
|
|
let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?;
|
|
let (from, into) = (from.iter().map(|f| clean(f)).collect::<Vec<_>>(), clean(&into));
|
|
commit_keywords(s, "Merge Keywords", op, |k| {
|
|
Some(match closest(k, &from) {
|
|
Some(f) => reparent(k, f, &into),
|
|
None => k.to_string(),
|
|
})
|
|
})
|
|
}
|
|
),
|
|
]
|
|
}
|