mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 14:39:41 -06:00
LR-LIB-KEYWORD: library-wide keyword rename/delete/merge, left-panel keyword tree, suggestions
Catalog keyword ops (one SetMeta batch: undoable, replayed from the op log), hierarchical a|b|c keywords (tree with counts, parent filters include children), keyword.* commands, rename/merge dialogs, Keywords-panel suggestions; catalog, engine and headless tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
89b8aa3f77
commit
7fd21517ae
@@ -0,0 +1,257 @@
|
||||
//! Library-wide keyword operations: the keyword tree (hierarchical keywords are written
|
||||
//! `parent|child|grandchild`), rename / delete / merge across every photo, and suggestions.
|
||||
//!
|
||||
//! Rename, delete and merge produce one [`Op::Batch`] of [`Op::SetMeta`]s — one per photo that
|
||||
//! changes — so they are a single undo step and replay from the op log like any other edit.
|
||||
//! Keyword names compare case-insensitively; renaming a keyword renames its children too
|
||||
//! (`travel|italy` → `trips|italy`).
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{Catalog, CatalogError, Meta, Op, Result};
|
||||
|
||||
/// Separator of hierarchical keyword levels.
|
||||
pub const SEP: char = '|';
|
||||
|
||||
/// Normalize a keyword: trim every level, drop empty levels.
|
||||
pub fn clean(k: &str) -> String {
|
||||
k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect::<Vec<_>>().join("|")
|
||||
}
|
||||
|
||||
/// `k` is `parent` or one of its descendants (`parent|…`), ignoring case.
|
||||
pub fn is_under(k: &str, parent: &str) -> bool {
|
||||
let (k, p) = (k.to_lowercase(), parent.to_lowercase());
|
||||
k == p || k.strip_prefix(&p).is_some_and(|rest| rest.starts_with(SEP))
|
||||
}
|
||||
|
||||
/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`.
|
||||
fn reparent(k: &str, from: &str, to: &str) -> String {
|
||||
let rest = &k[from.len().min(k.len())..];
|
||||
if to.is_empty() { rest.trim_start_matches(SEP).to_string() } else { format!("{to}{rest}") }
|
||||
}
|
||||
|
||||
/// Keep the first of case-insensitively equal keywords.
|
||||
fn dedupe(v: &mut Vec<String>) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
v.retain(|k| !k.is_empty() && seen.insert(k.to_lowercase()));
|
||||
}
|
||||
|
||||
/// One node of the keyword tree.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct KeywordNode {
|
||||
/// This level's name (`italy`).
|
||||
pub name: String,
|
||||
/// The full keyword (`travel|italy`).
|
||||
pub path: String,
|
||||
/// Photos (not deleted) with this keyword or one below it.
|
||||
pub count: usize,
|
||||
pub children: Vec<KeywordNode>,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
|
||||
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
|
||||
fn insert(nodes: &mut Vec<KeywordNode>, parts: &[&str], prefix: &str, photo: u64, seen: &mut std::collections::HashSet<(String, u64)>) {
|
||||
let Some((first, rest)) = parts.split_first() else { return };
|
||||
let path = if prefix.is_empty() { first.to_string() } else { format!("{prefix}|{first}") };
|
||||
let i = match nodes.iter().position(|n| n.name.eq_ignore_ascii_case(first)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
nodes.push(KeywordNode { name: first.to_string(), path: path.clone(), count: 0, children: Vec::new() });
|
||||
nodes.len() - 1
|
||||
}
|
||||
};
|
||||
if seen.insert((path.to_lowercase(), photo)) {
|
||||
nodes[i].count += 1;
|
||||
}
|
||||
let p = nodes[i].path.clone();
|
||||
insert(&mut nodes[i].children, rest, &p, photo, seen);
|
||||
}
|
||||
fn sort(nodes: &mut [KeywordNode]) {
|
||||
nodes.sort_by_key(|n| n.name.to_lowercase());
|
||||
for n in nodes {
|
||||
sort(&mut n.children);
|
||||
}
|
||||
}
|
||||
let mut roots = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for p in self.photos().filter(|p| !p.deleted) {
|
||||
for k in &p.meta.keywords {
|
||||
let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
insert(&mut roots, &parts, "", p.id.0, &mut seen);
|
||||
}
|
||||
}
|
||||
sort(&mut roots);
|
||||
roots
|
||||
}
|
||||
|
||||
/// `SetMeta` ops for every photo whose keywords `f` changes.
|
||||
fn keyword_ops(&self, f: impl Fn(&[String]) -> Vec<String>) -> Vec<Op> {
|
||||
self.photos()
|
||||
.filter_map(|p| {
|
||||
let mut kws = f(&p.meta.keywords);
|
||||
dedupe(&mut kws);
|
||||
(kws != p.meta.keywords).then(|| Op::SetMeta { id: p.id, meta: Box::new(Meta { keywords: kws, ..p.meta.clone() }) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rename `from` (and the keywords below it) to `to` on every photo — one batch. Renaming onto
|
||||
/// an existing keyword merges the two.
|
||||
pub fn rename_keyword_ops(&self, from: &str, to: &str) -> Result<Op> {
|
||||
let (from, to) = (clean(from), clean(to));
|
||||
if from.is_empty() || to.is_empty() {
|
||||
return Err(CatalogError::Invalid("keyword names can't be empty".into()));
|
||||
}
|
||||
if is_under(&to, &from) && !to.eq_ignore_ascii_case(&from) {
|
||||
return Err(CatalogError::Invalid("can't move a keyword below itself".into()));
|
||||
}
|
||||
let ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect());
|
||||
Ok(Op::Batch { ops })
|
||||
}
|
||||
|
||||
/// Remove `keyword` and the keywords below it from every photo.
|
||||
pub fn delete_keyword_ops(&self, keyword: &str) -> Result<Op> {
|
||||
let k = clean(keyword);
|
||||
if k.is_empty() {
|
||||
return Err(CatalogError::Invalid("empty keyword".into()));
|
||||
}
|
||||
Ok(Op::Batch { ops: self.keyword_ops(|kws| kws.iter().filter(|x| !is_under(x, &k)).cloned().collect()) })
|
||||
}
|
||||
|
||||
/// Merge several keywords (with their children) into `into`.
|
||||
pub fn merge_keywords_ops(&self, from: &[String], into: &str) -> Result<Op> {
|
||||
let into = clean(into);
|
||||
let from: Vec<String> = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case(&into)).collect();
|
||||
if into.is_empty() || from.is_empty() {
|
||||
return Err(CatalogError::Invalid("merge needs keywords and a target".into()));
|
||||
}
|
||||
if from.iter().any(|f| is_under(&into, f)) {
|
||||
return Err(CatalogError::Invalid("can't merge a keyword into one below it".into()));
|
||||
}
|
||||
let ops = self.keyword_ops(|kws| {
|
||||
kws.iter().map(|k| from.iter().find(|f| is_under(k, f)).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect()
|
||||
});
|
||||
Ok(Op::Batch { ops })
|
||||
}
|
||||
|
||||
/// Keyword suggestions for a photo that has `current` keywords: with a typed `prefix`, the
|
||||
/// library's keywords containing it (those starting with it first); otherwise keywords that
|
||||
/// appear together with `current` on other photos, then the most used ones. Most frequent
|
||||
/// first, at most `n`, never one of `current`.
|
||||
pub fn keyword_suggestions(&self, current: &[String], prefix: &str, n: usize) -> Vec<String> {
|
||||
let all = self.keywords();
|
||||
let has = |k: &str| current.iter().any(|c| c.eq_ignore_ascii_case(k));
|
||||
let q = prefix.trim().to_lowercase();
|
||||
let mut scored: Vec<(i64, String)> = if !q.is_empty() {
|
||||
all.into_iter()
|
||||
.filter(|(k, _)| !has(k))
|
||||
.filter_map(|(k, c)| {
|
||||
let l = k.to_lowercase();
|
||||
let leaf = l.rsplit(SEP).next().unwrap_or(&l).to_string();
|
||||
let rank = if l.starts_with(&q) || leaf.starts_with(&q) {
|
||||
2
|
||||
} else if l.contains(&q) {
|
||||
1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some((rank * 1_000_000 + c as i64, k))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let mut co: std::collections::HashMap<String, i64> = Default::default();
|
||||
if !current.is_empty() {
|
||||
for p in self.photos().filter(|p| !p.deleted) {
|
||||
if p.meta.keywords.iter().any(|k| has(k)) {
|
||||
for k in p.meta.keywords.iter().filter(|k| !has(k)) {
|
||||
*co.entry(k.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
all.into_iter().filter(|(k, _)| !has(k)).map(|(k, c)| (co.get(&k).copied().unwrap_or(0) * 1_000_000 + c as i64, k)).collect()
|
||||
};
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase())));
|
||||
scored.into_iter().take(n).map(|(_, k)| k).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Filter, Photo, PhotoId, Source};
|
||||
|
||||
fn lib(kws: &[&[&str]]) -> (Catalog, Vec<PhotoId>) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ids = Vec::new();
|
||||
for k in kws {
|
||||
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 = k.iter().map(|s| s.to_string()).collect();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
(c, ids)
|
||||
}
|
||||
fn kws(c: &Catalog, id: PhotoId) -> Vec<String> {
|
||||
c.photo(id).unwrap().meta.keywords.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_counts_photos_per_level() {
|
||||
let (c, _) = lib(&[&["travel|Italy|Rome", "beach"], &["Travel|italy"], &["travel|France", "travel|italy|rome"], &["beach"]]);
|
||||
let t = c.keyword_tree();
|
||||
assert_eq!(t.iter().map(|n| (n.name.as_str(), n.count)).collect::<Vec<_>>(), vec![("beach", 2), ("travel", 3)]);
|
||||
let travel = &t[1];
|
||||
assert_eq!(travel.children.iter().map(|n| (n.path.as_str(), n.count)).collect::<Vec<_>>(), vec![("travel|France", 1), ("travel|Italy", 3)]);
|
||||
assert_eq!(travel.children[1].children[0].path, "travel|Italy|Rome");
|
||||
assert_eq!(travel.children[1].children[0].count, 2);
|
||||
// filtering by a parent finds its children
|
||||
let f = Filter { keyword: Some("travel|italy".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Default::default()).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_delete_merge_are_single_undoable_batches() {
|
||||
let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]);
|
||||
let before = c.to_snapshot();
|
||||
let op = c.rename_keyword_ops("travel|italy", "Europe|Italy").unwrap();
|
||||
let Op::Batch { ops } = &op else { panic!() };
|
||||
assert_eq!(ops.len(), 3, "only photos that change");
|
||||
let inv = c.apply(op).unwrap();
|
||||
assert_eq!(kws(&c, ids[0]), ["Europe|Italy|rome", "beach"]);
|
||||
assert_eq!(kws(&c, ids[1]), ["Europe|Italy"]);
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.to_snapshot(), before);
|
||||
// merge `italia` into the existing hierarchical keyword: duplicates collapse
|
||||
let op = c.merge_keywords_ops(&["italia".into()], "travel|italy").unwrap();
|
||||
c.apply(op).unwrap();
|
||||
assert_eq!(kws(&c, ids[2]), ["travel|italy"]);
|
||||
// delete removes the keyword and its children everywhere
|
||||
let op = c.delete_keyword_ops("TRAVEL").unwrap();
|
||||
c.apply(op).unwrap();
|
||||
assert_eq!(kws(&c, ids[0]), ["beach"]);
|
||||
assert!(kws(&c, ids[1]).is_empty());
|
||||
assert_eq!(c.keywords(), vec![("beach".to_string(), 1), ("sea".to_string(), 1)]);
|
||||
// invalid requests
|
||||
assert!(c.rename_keyword_ops("beach", " ").is_err());
|
||||
assert!(c.rename_keyword_ops("a", "a|b").is_err());
|
||||
assert!(c.merge_keywords_ops(&["a".into()], "a|b").is_err());
|
||||
assert_eq!(clean(" a | |b "), "a|b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestions_rank_co_occurrence_and_prefixes() {
|
||||
let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]);
|
||||
// co-occurring with `dog` first (park twice), then the most used
|
||||
let s = c.keyword_suggestions(&["dog".into()], "", 3);
|
||||
assert_eq!(s, ["park", "ball", "beach"]);
|
||||
let s = c.keyword_suggestions(&[], "", 2);
|
||||
assert_eq!(s, ["cat", "dog"]);
|
||||
let s = c.keyword_suggestions(&["park".into()], "pa", 5);
|
||||
assert_eq!(s, ["parade"]);
|
||||
let s = c.keyword_suggestions(&[], "E", 5);
|
||||
assert_eq!(s, ["eagle", "beach", "parade"], "prefix matches before substring matches");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod dates;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod model;
|
||||
pub mod query;
|
||||
pub mod stacks;
|
||||
@@ -19,6 +20,7 @@ use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use journal::{Journal, LoadReport, SnapshotPolicy};
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use model::*;
|
||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||
|
||||
@@ -31,6 +31,7 @@ pub struct Filter {
|
||||
pub deleted: bool,
|
||||
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
|
||||
pub date: Option<String>,
|
||||
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
|
||||
pub keyword: Option<String>,
|
||||
pub camera: Option<String>,
|
||||
/// Lens (case-insensitive substring).
|
||||
@@ -95,7 +96,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
"focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
|
||||
"camera" => p.meta.camera.to_lowercase().contains(val),
|
||||
"lens" => p.meta.lens.to_lowercase().contains(val),
|
||||
"keyword" | "kw" => p.meta.keywords.iter().any(|k| k.to_lowercase() == val),
|
||||
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
|
||||
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
|
||||
"edited" => (val == "true" || val == "yes") == p.is_edited(),
|
||||
"date" => p.date().starts_with(val),
|
||||
@@ -202,7 +203,7 @@ impl Filter {
|
||||
return false;
|
||||
}
|
||||
if let Some(k) = &self.keyword
|
||||
&& !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(k))
|
||||
&& !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge.
|
||||
|
||||
use lightcraft_catalog::keywords::{clean, is_under};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, 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 how many photos changed. 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> {
|
||||
let n = match &op {
|
||||
lightcraft_catalog::Op::Batch { ops } => ops.len(),
|
||||
_ => 1,
|
||||
};
|
||||
if n > 0 {
|
||||
s.commit(label, op)?;
|
||||
}
|
||||
if let Some(k) = s.filter.keyword.clone() {
|
||||
s.filter.keyword = follow(&k);
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
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!(
|
||||
"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) { format!("{t}{}", &k[f.len().min(k.len())..]) } 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 from.iter().find(|f| is_under(k, f)) {
|
||||
Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]),
|
||||
None => k.to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
mod color;
|
||||
mod develop;
|
||||
mod edit;
|
||||
mod keywords;
|
||||
mod library;
|
||||
mod masks;
|
||||
mod merge;
|
||||
@@ -107,6 +108,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(color::specs());
|
||||
v.extend(masks::specs());
|
||||
v.extend(organize::specs());
|
||||
v.extend(keywords::specs());
|
||||
v.extend(merge::specs());
|
||||
v.extend(query::specs());
|
||||
v.extend(xmp::specs());
|
||||
|
||||
@@ -32,3 +32,64 @@ fn date_groups_follow_sort_and_grouping() {
|
||||
assert_eq!(s.sort.group, lightcraft_catalog::GroupBy::Year);
|
||||
assert!(s.execute("library.sort", &json!({"group": "week"})).is_err());
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-libops-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn keywords_of(s: &Session, id: u64) -> Vec<String> {
|
||||
s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone()
|
||||
}
|
||||
|
||||
/// Keyword rename / merge / delete: one undo step each, replayed from the op log after a restart,
|
||||
/// and the keyword filter follows the renamed keyword.
|
||||
#[test]
|
||||
fn keyword_rename_merge_delete_undo_and_replay() {
|
||||
let dir = temp_dir("kw");
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
let ids: Vec<u64> = s.visible_cloned().iter().take(4).map(|p| p.0).collect();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[0], ids[1]], "keywords": ["travel|italy|rome", "sea"]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[2]], "keywords": ["Italia"]})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [ids[3]], "keywords": ["travel|france", "sea"]})).unwrap();
|
||||
let tree = s.execute("keyword.list", &json!({})).unwrap();
|
||||
let travel = tree.as_array().unwrap().iter().find(|n| n["name"] == "travel").unwrap().clone();
|
||||
assert_eq!(travel["count"], 3, "{tree}");
|
||||
assert_eq!(travel["children"][1]["path"], "travel|italy");
|
||||
// filter by a parent keyword: its children match
|
||||
s.execute("library.filter", &json!({"keyword": "travel|italy"})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
let undo_before = s.undo.len();
|
||||
let r = s.execute("keyword.rename", &json!({"from": "travel|italy", "to": "Europe|Italy"})).unwrap();
|
||||
assert_eq!(r["changed"], 2);
|
||||
assert_eq!(s.undo.len(), undo_before + 1, "one undo step");
|
||||
assert_eq!(keywords_of(&s, ids[0]), ["Europe|Italy|rome", "sea"]);
|
||||
assert_eq!(s.filter.keyword.as_deref(), Some("Europe|Italy"), "the filter follows the rename");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("keyword.merge", &json!({"from": ["Italia"], "into": "Europe|Italy"})).unwrap();
|
||||
assert_eq!(keywords_of(&s, ids[2]), ["Europe|Italy"]);
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
s.execute("keyword.delete", &json!({"keyword": "travel"})).unwrap();
|
||||
assert_eq!(keywords_of(&s, ids[3]), ["sea"]);
|
||||
// suggestions: keywords used together with `sea` first
|
||||
let sug = s.execute("keyword.suggest", &json!({"ids": [ids[3]]})).unwrap();
|
||||
assert_eq!(sug[0], "Europe|Italy|rome", "{sug}");
|
||||
let sug = s.execute("keyword.suggest", &json!({"ids": [ids[3]], "prefix": "eur"})).unwrap();
|
||||
assert_eq!(sug, json!(["Europe|Italy|rome", "Europe|Italy"]), "most used first");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(keywords_of(&s, ids[3]), ["travel|france", "sea"]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// restart without a clean close: the log replays to the same state
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.open_library(&dir, false).unwrap();
|
||||
assert_eq!(s.catalog.to_snapshot(), expect);
|
||||
// invalid
|
||||
assert!(s.execute("keyword.rename", &json!({"from": "sea", "to": ""})).is_err());
|
||||
assert!(s.execute("keyword.merge", &json!({"from": [], "into": "x"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -399,6 +399,44 @@ mod tests {
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// Keywords: the left-panel tree filters (children included), opens levels, the rename dialog
|
||||
/// renames library-wide, and the Keywords panel adds a suggestion.
|
||||
#[test]
|
||||
fn keyword_list_filters_renames_and_suggests() {
|
||||
// tall: the demo library's own keywords come first in the list
|
||||
let mut h = demo([1300.0, 1800.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
|
||||
let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10));
|
||||
ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]}));
|
||||
ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]}));
|
||||
h.request("ui.set", json!({"leftPanel": true}), t);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel"));
|
||||
assert_eq!(h.app.session.visible_cloned().len(), 3);
|
||||
// open the level, filter by the child
|
||||
h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(h.app.session.visible_cloned().len(), 2);
|
||||
h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "travel".into(), to: "trips".into() });
|
||||
h.settle(SETTLE);
|
||||
let r = h.request("ui.dialog.confirm", json!({}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(h.app.session.filter.keyword.as_deref(), Some("trips|italy"));
|
||||
assert_eq!(h.app.session.visible_cloned().len(), 2);
|
||||
// Keywords panel: suggestions co-occurring with the photo's keywords
|
||||
h.request("engine.execute", json!({"command": "library.clearFilter"}), t);
|
||||
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t);
|
||||
ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]}));
|
||||
h.request("ui.set", json!({"right": "keywords"}), t);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string()));
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_request_is_answered_without_a_window() {
|
||||
let mut h = demo([800.0, 500.0]);
|
||||
|
||||
@@ -22,6 +22,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::NewAlbum { folder: true, .. } => "Create Folder",
|
||||
Dialog::NewAlbum { .. } => "Create Album",
|
||||
Dialog::RenameAlbum { .. } => "Rename Album",
|
||||
Dialog::RenameKeyword { .. } => "Rename Keyword",
|
||||
Dialog::MergeKeywords { .. } => "Merge Keywords",
|
||||
Dialog::NewSmartAlbum { .. } => "Create Smart Album",
|
||||
Dialog::AutoStack { .. } => "Auto-Stack by Capture Time",
|
||||
Dialog::CreatePreset { .. } => "Create Preset",
|
||||
@@ -70,6 +72,45 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
Dialog::RenameKeyword { from, to } => {
|
||||
let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count();
|
||||
let r = ui.add(egui::TextEdit::singleline(to).hint_text("New name").desired_width(f32::INFINITY));
|
||||
crate::widgets::register(ui.ctx(), "field:keywordName", r.rect);
|
||||
r.request_focus();
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
confirm = true;
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.",
|
||||
if n == 1 { "" } else { "s" }
|
||||
))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
Dialog::MergeKeywords { from, into } => {
|
||||
ui.label(egui::RichText::new(format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::<Vec<_>>().join(", "))).color(t.text_label));
|
||||
let r = ui.add(egui::TextEdit::singleline(into).hint_text("Keyword").desired_width(f32::INFINITY));
|
||||
crate::widgets::register(ui.ctx(), "field:keywordInto", r.rect);
|
||||
r.request_focus();
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
confirm = true;
|
||||
}
|
||||
let options: Vec<String> = app
|
||||
.session
|
||||
.catalog
|
||||
.keyword_suggestions(from, into, 12)
|
||||
.into_iter()
|
||||
.filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f)))
|
||||
.collect();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for k in options {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(&k).color(t.text_label)).corner_radius(10.0)).clicked() {
|
||||
*into = k;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
|
||||
r.request_focus();
|
||||
@@ -280,6 +321,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
match dlg {
|
||||
Dialog::NewAlbum { name, folder } => app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})),
|
||||
Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})),
|
||||
Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})),
|
||||
Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})),
|
||||
Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})),
|
||||
Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})),
|
||||
Dialog::CreatePreset { name, group } => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The left "My Photos" panel: library sources, albums tree, and date groups.
|
||||
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use lightcraft_catalog::{Album, AlbumId};
|
||||
use lightcraft_catalog::{Album, AlbumId, KeywordNode};
|
||||
use lightcraft_engine::LibrarySource;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -108,6 +108,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let _ = app.run("library.filter", json!({"date": v}));
|
||||
}
|
||||
}
|
||||
keywords_section(app, ui);
|
||||
ui.add_space(10.0);
|
||||
if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0)
|
||||
.clicked()
|
||||
@@ -165,3 +166,73 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click
|
||||
/// filters the grid by the keyword (children included), the triangle opens a level, and the
|
||||
/// context menu renames, merges or deletes the keyword across the library.
|
||||
fn keywords_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let tree = app.session.catalog.keyword_tree();
|
||||
if tree.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let (kr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
|
||||
ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, "Keywords", t.semibold(13.5), t.text_label);
|
||||
keyword_rows(app, ui, &tree, 0.0);
|
||||
}
|
||||
|
||||
fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
for n in nodes {
|
||||
let open_id = egui::Id::new(("kw-open", n.path.to_lowercase()));
|
||||
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false);
|
||||
let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path));
|
||||
let resp = row(app, ui, &format!("keyword:{}", n.path), Icon::Tag, &n.name, Some(n.count), sel, indent);
|
||||
if !n.children.is_empty() {
|
||||
// disclosure triangle left of the icon
|
||||
let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y);
|
||||
let tri = Rect::from_center_size(c, vec2(14.0, 14.0));
|
||||
let tr = ui.interact(tri, egui::Id::new(("kw-tri", n.path.to_lowercase())), Sense::click());
|
||||
register(ui.ctx(), format!("keywordToggle:{}", n.path), tri);
|
||||
let col = if tr.hovered() { t.text } else { t.text_dim };
|
||||
let pts = if open {
|
||||
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
|
||||
} else {
|
||||
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE));
|
||||
if tr.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
}
|
||||
}
|
||||
let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) });
|
||||
if resp.clicked() {
|
||||
let v = if sel { serde_json::Value::Null } else { json!(n.path) };
|
||||
let _ = app.run("library.filter", json!({"keyword": v}));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let has_sel = app.session.active().is_some();
|
||||
if ui.add_enabled(has_sel, egui::Button::new("Add to Selected Photos")).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]}));
|
||||
}
|
||||
if ui.add_enabled(has_sel, egui::Button::new("Remove from Selected Photos")).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Rename Keyword…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() });
|
||||
}
|
||||
if ui.button("Merge into…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() });
|
||||
}
|
||||
if ui.button("Delete Keyword").clicked() {
|
||||
let _ = app.run("keyword.delete", json!({"keyword": n.path}));
|
||||
}
|
||||
});
|
||||
if open && !n.children.is_empty() {
|
||||
keyword_rows(app, ui, &n.children, indent + 16.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,24 +377,35 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for k in &p.meta.keywords {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(format!("{k} ×")).color(t.text_label)).corner_radius(10.0));
|
||||
let r =
|
||||
ui.add(egui::Button::new(egui::RichText::new(format!("{} ×", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0));
|
||||
if r.clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("Keywords in library").color(t.text_dim));
|
||||
let all = app.session.catalog.keywords();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (k, n) in all {
|
||||
if !p.meta.keywords.contains(&k)
|
||||
&& ui.add(egui::Button::new(egui::RichText::new(format!("{k} {n}")).color(t.text_dim)).frame(false)).clicked()
|
||||
{
|
||||
let _ = app.run("photo.setMeta", json!({"addKeywords": [k]}));
|
||||
// suggestions: completions of the typed text, else keywords used together with this
|
||||
// photo's keywords, else the most used ones
|
||||
let typed = ui.data(|d| d.get_temp::<String>(kid).unwrap_or_default());
|
||||
let last = typed.rsplit(',').next().unwrap_or("").trim().to_string();
|
||||
let suggestions = app.session.catalog.keyword_suggestions(&p.meta.keywords, &last, 12);
|
||||
if !suggestions.is_empty() {
|
||||
ui.label(egui::RichText::new("Suggestions").color(t.text_dim));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for k in suggestions {
|
||||
let r = ui
|
||||
.add(egui::Button::new(egui::RichText::new(format!("+ {}", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0));
|
||||
register(ui.ctx(), format!("kwSuggest:{k}"), r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"addKeywords": [k]}));
|
||||
if !last.is_empty() {
|
||||
ui.data_mut(|d| d.insert_temp(kid, String::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,16 @@ pub enum Dialog {
|
||||
id: u64,
|
||||
name: String,
|
||||
},
|
||||
/// Rename a keyword on every photo (children included).
|
||||
RenameKeyword {
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
/// Merge keywords into another one on every photo.
|
||||
MergeKeywords {
|
||||
from: Vec<String>,
|
||||
into: String,
|
||||
},
|
||||
/// Auto-stack by capture time: the largest gap between consecutive shots, in seconds.
|
||||
AutoStack {
|
||||
gap: f32,
|
||||
|
||||
+2
-3
@@ -66,7 +66,6 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i
|
||||
6. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset;
|
||||
live preview while hovering presets/profiles/versions. Medium effort.
|
||||
7. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER).
|
||||
8. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing.
|
||||
9. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
|
||||
XMP prefs, cache, GPU. Medium effort.
|
||||
10. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination
|
||||
@@ -150,7 +149,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | |
|
||||
| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle |
|
||||
| LR-LIB-LABEL | Colour labels | P1 | 🟡 | `cmd:photo.label`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | no label menu/buttons, no purple key, label names not editable |
|
||||
| LR-LIB-KEYWORD | Keywords | P0 | 🟡 | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`) | no library-wide rename/delete, no keyword browser in the left panel |
|
||||
| LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop |
|
||||
| LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | |
|
||||
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
|
||||
| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M |
|
||||
@@ -722,7 +721,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` | group/ungroup/toggle/top/remove/auto by time; no split stack or move up/down |
|
||||
| LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' |
|
||||
| LRC-LIB-LABELS | Colour-label sets | P1 | ⬜ | | |
|
||||
| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ⬜ | | flat keywords only (LR-LIB-KEYWORD) |
|
||||
| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | 🟡 | `crates/catalog/src/keywords.rs`, `cmd:keyword.list` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); no keyword sets or painter |
|
||||
| LRC-LIB-METADATA | Metadata panel & presets | P1 | 🟡 | `cmd:photo.setMeta`, `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile` | no metadata presets, capture-time edit, copyright status |
|
||||
| LRC-LIB-QUICKDEV | Quick develop | P2 | ⬜ | | |
|
||||
| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | |
|
||||
|
||||
Reference in New Issue
Block a user