Merge pull request #6425 from Hassan1305/feat-4936-edit-custom-dictionary

#4936: Edit › Edit Custom Dictionary… dialog
This commit is contained in:
BFlat
2026-11-11 02:01:47 +07:00
committed by GitHub
38 changed files with 536 additions and 20 deletions
+71
View File
@@ -85,6 +85,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
change_word
),
cmd!(
query "text.spelling.editDictionary",
"Edit Custom Dictionary",
[],
None,
"{remove?: [word…], add?: [word…] (each one word, at most 64 characters; at most 10000 of each)} deletes the words of remove that are there and adds those of add, then saves once: all of it, or none of it when the save fails (Edit Custom Dictionary's Done) → {removed: [word…], added: [word…]}",
always,
edit_dictionary
),
cmd!(
query "text.spelling.ignoreAll",
"Ignore All",
@@ -223,6 +232,12 @@ fn suggest(_s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "suggestions": suggestions }))
}
/// `w` as the custom dictionary keeps it (trimmed, lower case), or None when it isn't one word of at
/// most `craft_proof::MAX_CUSTOM_WORD_CHARS` characters: what `text.spelling.addWord` takes.
pub fn custom_word(w: &str) -> Option<String> {
craft_proof::custom_word(w)
}
/// `p`'s `key` as a custom-dictionary word (trimmed, lower case), or an error naming what it must be.
fn word_param(p: &Value, key: &str, command: &str) -> Result<String> {
let w = str_param(p, key).ok_or_else(|| bad(command, format!("missing `{key}`")))?;
@@ -342,6 +357,62 @@ fn change_word(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "word": to }))
}
/// Most words `text.spelling.editDictionary` takes in each of its lists.
const MAX_EDIT_WORDS: usize = 10_000;
/// `p`'s `key`, a list of custom-dictionary words (none when it is missing), deduplicated.
fn word_list(p: &Value, key: &str, command: &str) -> Result<BTreeSet<String>> {
let Some(v) = p.get(key) else { return Ok(BTreeSet::new()) };
let words = v.as_array().ok_or_else(|| bad(command, format!("`{key}` must be a list of words")))?;
if words.len() > MAX_EDIT_WORDS {
return Err(bad(command, format!("`{key}` holds more than {MAX_EDIT_WORDS} words")));
}
words
.iter()
.map(|w| {
w.as_str()
.and_then(craft_proof::custom_word)
.ok_or_else(|| bad(command, format!("`{key}` must hold single words of at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS)))
})
.collect()
}
/// `text.spelling.editDictionary`: Edit Custom Dictionary's Done. Every delete and add is made,
/// then saved once; when the save fails, all of them are taken back, so a failed Done leaves the
/// dictionary as it was rather than half changed.
fn edit_dictionary(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.editDictionary";
let (remove, add) = (word_list(p, "remove", C)?, word_list(p, "add", C)?);
let before: BTreeSet<String> = craft_proof::user_dictionary().into_iter().collect();
// A word in both lists stays; a word to delete that another window deleted already is skipped.
let removed: Vec<String> = remove.iter().filter(|w| !add.contains(*w) && before.contains(*w)).cloned().collect();
let added: Vec<String> = add.iter().filter(|w| !before.contains(*w)).cloned().collect();
if removed.is_empty() && added.is_empty() {
return Ok(json!({ "removed": removed, "added": added }));
}
for w in &removed {
craft_proof::remove_word(w);
}
for w in &added {
craft_proof::add_word(w);
}
let saved = save_dictionary(s, &removed, C);
dictionary_changed();
saved.inspect_err(|_| {
for w in &added {
craft_proof::remove_word(w);
}
for w in &removed {
craft_proof::add_word(w);
}
// Words the save took in from the file stay, as for one word.
if !added.is_empty() {
retake_file_words(s);
}
})?;
Ok(json!({ "removed": removed, "added": added }))
}
/// `text.spelling.ignoreAll`: Ignore All, for the active document.
fn ignore_all(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.ignoreAll";
+37
View File
@@ -260,6 +260,43 @@ fn a_change_that_cant_be_saved_is_taken_back() {
run(&mut s, "text.spelling.removeWord", json!({"word": "wkqzvx"}));
}
/// Edit Custom Dictionary's Done: its deletes and adds are saved together, or, when the save
/// fails, none of them is kept (a delete saved before an add that failed would stay otherwise).
#[test]
fn a_dictionary_edit_is_saved_whole_or_not_at_all() {
let _one_at_a_time = dictionary_lock();
const EDIT: &str = "text.spelling.editDictionary";
let dir = vectorcraft_testkit::temp_dir("spelling-edit");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "pqzvka\npqzvkb\n").unwrap();
let mut s = session();
s.attach_custom_dictionary(file.clone());
// A word already gone and a word already there are skipped; added words are kept in lower case.
let r = run(&mut s, EDIT, json!({"remove": ["pqzvka", "pqzvkz"], "add": ["PqzvkC", "pqzvkb"]}));
assert_eq!(r, json!({"removed": ["pqzvka"], "added": ["pqzvkc"]}));
let saved = std::fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = saved.lines().collect();
assert!(lines.contains(&"pqzvkb") && lines.contains(&"pqzvkc") && !lines.contains(&"pqzvka"), "{saved}");
// A save that fails takes back every delete and add.
let folder = dir.join("folder");
std::fs::create_dir_all(&folder).unwrap();
let mut failing = session();
failing.attach_custom_dictionary(folder);
assert!(failing.execute(EDIT, &json!({"remove": ["pqzvkb", "pqzvkc"], "add": ["pqzvkd"]})).is_err());
assert!(in_dictionary(&mut s, "pqzvkb") && in_dictionary(&mut s, "pqzvkc"), "the deletes are taken back");
assert!(!in_dictionary(&mut s, "pqzvkd"), "the add is taken back");
// A list that isn't one of words is refused before anything changes.
let many: Vec<String> = (0..10_001).map(|i| format!("pqzv{i}")).collect();
for p in [json!({"remove": "pqzvkb"}), json!({"add": ["a b"]}), json!({"add": [5]}), json!({"remove": ["pqzvkb"], "add": many})] {
assert!(s.execute(EDIT, &p).is_err(), "{EDIT} refuses a bad list");
assert!(in_dictionary(&mut s, "pqzvkb"), "{EDIT} changed nothing");
}
let e = s.execute(EDIT, &json!({"add": ["a b"]})).unwrap_err().to_string();
assert!(e.contains("must hold single words of at most 64 characters"), "{e}");
run(&mut s, EDIT, json!({"remove": ["pqzvkb", "pqzvkc"]}));
assert!(!in_dictionary(&mut s, "pqzvkb") && !in_dictionary(&mut s, "pqzvkc"));
}
#[cfg(unix)]
#[test]
fn a_word_another_window_saved_stays_when_this_save_fails() {