# Conflicts:
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-09 20:55:49 -04:00
24 changed files with 492 additions and 34 deletions
+68 -16
View File
@@ -72,6 +72,12 @@ pub struct KeywordSet {
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";
@@ -82,16 +88,32 @@ pub fn note_recent(s: &mut Session, added: &[String]) {
if k.is_empty() {
continue;
}
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
s.recent_keywords.retain(|x| !same_set(x, &k));
s.recent_keywords.insert(0, k);
}
s.recent_keywords.truncate(9);
let _ = s.save_prefs();
}
/// 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| x.name.eq_ignore_ascii_case(n)));
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
@@ -109,13 +131,13 @@ pub fn specs() -> Vec<CommandSpec> {
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 name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
s.keyword_set = if same_set(name, RECENT) || name.is_empty() {
None
} else {
Some(
s.keyword_sets
.iter()
.find(|x| x.name.eq_ignore_ascii_case(name))
.find(|x| same_set(&x.name, name))
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
.name
.clone(),
@@ -129,24 +151,53 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Keyword Set",
[],
None,
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
"{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() && !n.eq_ignore_ascii_case(RECENT))
.filter(|n| !n.is_empty() && !same_set(n, RECENT))
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
.to_string();
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
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 {
current_keywords(s)
match renames.and_then(|at| s.keyword_sets.get(at)) {
Some(x) => x.keywords.clone(),
None => current_keywords(s),
}
};
keywords.truncate(9);
let set = KeywordSet { name: name.clone(), keywords };
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = set,
None => s.keyword_sets.push(set),
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()?;
@@ -156,11 +207,11 @@ pub fn specs() -> Vec<CommandSpec> {
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| !x.name.eq_ignore_ascii_case(name));
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| c.eq_ignore_ascii_case(name)) {
if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) {
s.keyword_set = None;
}
s.save_prefs()?;
@@ -179,7 +230,8 @@ pub fn specs() -> Vec<CommandSpec> {
.and_then(Value::as_u64)
.filter(|i| (1..=9).contains(i))
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
// (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);
+79
View File
@@ -183,3 +183,82 @@ fn a_set_keyword_toggles_whatever_its_case() {
s.execute("keyword.toggleFromSet", &json!({"index": 1, "ids": ids})).unwrap();
assert!(ids.iter().all(|id| keywords_of(&s, *id).is_empty()), "taken off both");
}
fn set_named(s: &mut Session, name: &str) -> serde_json::Value {
let sets = s.execute("keyword.sets", &json!({})).unwrap();
sets["sets"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned().unwrap_or(serde_json::Value::Null)
}
/// Edit Keyword Set: a set's nine slots keep their places, so each keyword stays on its ⌥ key; an
/// empty slot is allowed and does nothing.
#[test]
fn a_keyword_sets_slots_keep_their_places() {
let mut s = Session::with_demo();
let id = s.visible_cloned()[0].0;
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": [" ceremony ", "", "reception", ""]})).unwrap();
assert_eq!(set_named(&mut s, "Weddings")["keywords"], json!(["ceremony", "", "reception"]), "trailing empty slots go");
let before = keywords_of(&s, id);
let r = s.execute("keyword.toggleFromSet", &json!({"index": 2, "ids": [id]})).unwrap();
assert_eq!((r["changed"].as_u64(), keywords_of(&s, id)), (Some(0), before), "⌥2 is empty: nothing");
s.execute("keyword.toggleFromSet", &json!({"index": 3, "ids": [id]})).unwrap();
assert!(keywords_of(&s, id).contains(&"reception".to_string()), "⌥3 is reception");
}
/// A set holds each keyword once, whatever its case: a second one leaves its slot empty.
#[test]
fn a_keyword_set_holds_each_keyword_once() {
let mut s = Session::with_demo();
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["Lisbon", "harbour", "LISBON"]})).unwrap();
assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["Lisbon", "harbour"]));
}
/// Renaming a set keeps its place among the sets and keeps it current; a name another set has is
/// refused.
#[test]
fn renaming_a_keyword_set_keeps_its_place() {
let mut s = Session::with_demo();
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap();
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap();
s.execute("keyword.useSet", &json!({"name": "Weddings"})).unwrap();
let r = s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "weddings", "keywords": ["ceremony", "rings"]})).unwrap();
let names: Vec<String> = r["sets"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap().to_string()).collect();
assert_eq!(names, ["Recent Keywords", "Ceremonies", "Travel"]);
assert_eq!(r["current"], "Ceremonies");
assert_eq!(r["keywords"], json!(["ceremony", "rings"]));
let err = s.execute("keyword.saveSet", &json!({"name": "travel", "replace": "Ceremonies", "keywords": []})).unwrap_err().to_string();
assert!(err.contains("already"), "{err}");
assert!(s.execute("keyword.saveSet", &json!({"name": "Rings", "replace": "Lisbon", "keywords": []})).is_err(), "no such set");
}
/// Renaming a set without giving its keywords keeps its keywords (not the current set's).
#[test]
fn renaming_a_set_alone_keeps_its_keywords() {
let mut s = Session::with_demo();
s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap();
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap();
s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "Weddings"})).unwrap();
assert_eq!(set_named(&mut s, "Ceremonies")["keywords"], json!(["ceremony"]));
}
/// A new set (`new: true`, what Edit Set… on Recent Keywords saves) never takes another set's
/// name: saving over it silently is refused.
#[test]
fn a_new_set_doesnt_take_a_sets_name() {
let mut s = Session::with_demo();
s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour", "lisbon"]})).unwrap();
let err = s.execute("keyword.saveSet", &json!({"name": "travel", "new": true, "keywords": ["x"]})).unwrap_err().to_string();
assert!(err.contains("already"), "{err}");
assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["harbour", "lisbon"]), "left as it was");
}
/// Set names match whatever the case of any of their letters, for every set command alike.
#[test]
fn set_names_match_whatever_their_case() {
let mut s = Session::with_demo();
s.execute("keyword.saveSet", &json!({"name": "ÉTÉ", "keywords": ["sunset"]})).unwrap();
s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap();
s.execute("keyword.useSet", &json!({"name": "été"})).unwrap();
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "ÉTÉ");
s.execute("keyword.deleteSet", &json!({"name": "été"})).unwrap();
assert!(set_named(&mut s, "ÉTÉ").is_null(), "deleted");
}