mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 00:42:33 +00:00
Merge pull request #6425 from Hassan1305/feat-4936-edit-custom-dictionary
#4936: Edit › Edit Custom Dictionary… dialog
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user