* Persist the custom dictionary across sessions (#40) * Review fixes for #796: session-only Ignore All, desktop-only persistence, capped atomic I/O - review.ignoreAll no longer adds to (and saves) the custom dictionary: ignored words go to a separate session-only list, as Ignore All does in Word. - Only the desktop app wires the dictionary file. Headless MCP and `wordcraft-cli run` keep words in memory, so an agent never reads or changes the person's dictionary (and nothing writes outside an automation write root, #769). - Loading reads through `take(1 MB)`, so a huge file is never read whole; lines that aren't a single word (spaces, control characters, invalid UTF-8, over 64 characters) are skipped. - review.addToDictionary applies the same word rule (proof::custom_word), so a word added is never dropped on the next load. - Saving merges the words on disk first (another process's additions survive; words just removed stay out), creates the folder, and replaces the file atomically (temp + rename). The file I/O lives in the proof crate, so the engine doesn't touch std::fs itself. - Tests for each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
64319e0039
commit
b30cda1535
|
|
@ -5020,6 +5020,7 @@ dependencies = [
|
|||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-proof",
|
||||
"wordcraft-ui-egui",
|
||||
"zbus",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ workspace = true
|
|||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-proof = { workspace = true }
|
||||
# Fonts the user added (#582) load from the settings folder at start-up.
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -123,6 +123,21 @@ fn log_dir() -> Option<std::path::PathBuf> {
|
|||
Some(prefs_path()?.parent()?.join("logs"))
|
||||
}
|
||||
|
||||
/// `custom-dictionary.txt` in the settings folder: the words Add to Dictionary kept (#40).
|
||||
fn user_dict_path() -> Option<std::path::PathBuf> {
|
||||
wordcraft_control_key::settings_dir().map(|b| b.join(wordcraft_engine::Session::USER_DICT_FILE))
|
||||
}
|
||||
|
||||
fn load_user_dictionary(app: &mut WordApp) {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = user_dict_path() {
|
||||
wordcraft_proof::load_user_dictionary(&p);
|
||||
app.session.user_dict_path = Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs without preferences (`WORDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("WORDCRAFT_NO_PREFS").is_none()
|
||||
|
|
@ -301,6 +316,7 @@ fn main() -> eframe::Result {
|
|||
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
|
||||
let mut app = WordApp::new(Session::new(doc), services(file_dialog, &cc.egui_ctx));
|
||||
load_prefs(&mut app);
|
||||
load_user_dictionary(&mut app);
|
||||
app.watch_desktop_theme(&cc.egui_ctx);
|
||||
app.ui.window = restored;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
|
|
|
|||
|
|
@ -154,6 +154,9 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
|||
for w in &remove {
|
||||
wordcraft_proof::remove_word(w);
|
||||
}
|
||||
if !add.is_empty() || !remove.is_empty() {
|
||||
s.persist_user_dictionary(&remove);
|
||||
}
|
||||
if setting {
|
||||
// Marks, hidden text and proofing change what is laid out.
|
||||
s.relayout();
|
||||
|
|
|
|||
|
|
@ -65,7 +65,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
if w.is_empty() {
|
||||
return Err(CmdError::Params("no word".into()));
|
||||
}
|
||||
wordcraft_proof::add_word(&w);
|
||||
// The same rule as loading the dictionary file, so a word added is never dropped later.
|
||||
if wordcraft_proof::custom_word(&w).is_none() {
|
||||
let max = wordcraft_proof::MAX_CUSTOM_WORD_CHARS;
|
||||
return Err(CmdError::Params(format!("{w:?} isn't a single word of at most {max} characters")));
|
||||
}
|
||||
wordcraft_proof::add_word(w.trim());
|
||||
s.persist_user_dictionary(&[]);
|
||||
s.relayout();
|
||||
Ok(json!({"added": w}))
|
||||
})
|
||||
|
|
@ -78,7 +84,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.map(|(a, b, _)| s.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off).map(str::to_string)).unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
wordcraft_proof::add_word(&w);
|
||||
// For this session only, as in Word: not added to (or saved with) the dictionary.
|
||||
if !w.trim().is_empty() {
|
||||
wordcraft_proof::ignore_word(w.trim());
|
||||
}
|
||||
s.relayout();
|
||||
Ok(json!({"ignored": w}))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -275,6 +275,11 @@ pub struct Session {
|
|||
pub pending: Option<CharProps>,
|
||||
pub path: Option<std::path::PathBuf>,
|
||||
pub dirty: bool,
|
||||
/// Where the custom dictionary is kept on disk, if the host wired one up. Only the desktop
|
||||
/// app does: headless sessions (MCP, `wordcraft-cli run`), tests and the web build leave
|
||||
/// `None`, so an agent's words stay in memory and never change the person's dictionary.
|
||||
/// Add to Dictionary and File › Options › Proofing save through it (#40).
|
||||
pub user_dict_path: Option<std::path::PathBuf>,
|
||||
pub clipboard: Option<Fragment>,
|
||||
/// A tracked cut the next paste can turn into a move (see `cmd::moves`).
|
||||
pub pending_move: Option<crate::cmd::moves::PendingMove>,
|
||||
|
|
@ -471,6 +476,20 @@ fn doc_bib_style(doc: &Document) -> String {
|
|||
const MAX_UNDO: usize = 500;
|
||||
|
||||
impl Session {
|
||||
/// File name of the persisted custom dictionary inside the settings folder.
|
||||
pub const USER_DICT_FILE: &str = "custom-dictionary.txt";
|
||||
|
||||
/// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up,
|
||||
/// with `removed` (words just taken out) left out of the merge with the file. A failed save
|
||||
/// keeps the words in memory and never fails the command (#40).
|
||||
pub(crate) fn persist_user_dictionary(&self, removed: &[String]) {
|
||||
if let Some(path) = self.user_dict_path.as_deref()
|
||||
&& let Err(e) = wordcraft_proof::save_user_dictionary(path, removed)
|
||||
{
|
||||
log::warn!("custom dictionary: save failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(doc: Document) -> Self {
|
||||
let start = doc.start_of(StoryRef::Body);
|
||||
let bib_style = doc_bib_style(&doc);
|
||||
|
|
@ -480,6 +499,7 @@ impl Session {
|
|||
view: ViewState::default(),
|
||||
pending: None,
|
||||
path: None,
|
||||
user_dict_path: None,
|
||||
dirty: false,
|
||||
clipboard: None,
|
||||
pending_move: None,
|
||||
|
|
@ -844,6 +864,8 @@ impl Session {
|
|||
view: _,
|
||||
pending: _,
|
||||
path: _,
|
||||
// The custom dictionary file, like the file path: host wiring, not an edit.
|
||||
user_dict_path: _,
|
||||
dirty,
|
||||
clipboard: _,
|
||||
// Like the clipboard: what the next paste can make a move of.
|
||||
|
|
|
|||
|
|
@ -125,6 +125,45 @@ fn reset_shortcuts_remove_direct_formatting_but_keep_styles() {
|
|||
assert_eq!(text(&s), "Hello world—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_dictionary_words_persist_through_their_file() {
|
||||
// #40: adding a word through a command writes it to the session's file, and loading
|
||||
// that file makes the word known (only the desktop app wires this up).
|
||||
let dir = std::env::temp_dir().join(format!("wc-engdict-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("custom-dictionary.txt");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut s = s();
|
||||
s.user_dict_path = Some(path.clone());
|
||||
run(&mut s, "review.addToDictionary", json!({"word": "zxqengpersist"}));
|
||||
let saved = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert!(saved.lines().any(|l| l == "zxqengpersist"), "the word reaches the file: {saved:?}");
|
||||
run(&mut s, "file.options", json!({"removeWords": ["zxqengpersist"]}));
|
||||
let saved = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert!(!saved.lines().any(|l| l == "zxqengpersist"), "removal reaches the file too: {saved:?}");
|
||||
|
||||
// Ignore All is for this session only, as in Word: the word is accepted, never saved.
|
||||
assert!(!wordcraft_proof::is_correct("zxqengignored"));
|
||||
run(&mut s, "review.ignoreAll", json!({"word": "zxqengignored"}));
|
||||
assert!(wordcraft_proof::is_correct("zxqengignored"));
|
||||
assert!(!wordcraft_proof::user_dictionary().contains(&"zxqengignored".to_string()));
|
||||
let saved = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert!(!saved.contains("zxqengignored"), "Ignore All doesn't reach the file: {saved:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_dictionary_takes_only_words_the_file_keeps() {
|
||||
// #40: the same rule as loading the dictionary file, so nothing added is dropped on the next load.
|
||||
let mut s = s();
|
||||
for bad in ["two words", "tab\tword", "bell\u{7}", &"x".repeat(65)] {
|
||||
assert!(s.run("review.addToDictionary", &json!({"word": bad})).is_err(), "{bad:?}");
|
||||
assert!(!wordcraft_proof::user_dictionary().iter().any(|w| w == &bad.to_lowercase()));
|
||||
}
|
||||
run(&mut s, "review.addToDictionary", json!({"word": " zxqengtrimmed "}));
|
||||
assert!(wordcraft_proof::user_dictionary().contains(&"zxqengtrimmed".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_english_runs_are_not_proofed() {
|
||||
// #100: runs tagged with a proofing language that has no dictionary are skipped
|
||||
|
|
|
|||
|
|
@ -107,3 +107,15 @@ fn render_and_screenshot_through_the_app_use_no_temp_file() {
|
|||
assert!(params.get("path").is_none(), "{tool} asked for a file: {params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #40: a headless session never reads or writes the person's custom dictionary file: an agent's
|
||||
/// Add to Dictionary stays in memory (only the desktop app saves the dictionary).
|
||||
#[test]
|
||||
fn headless_sessions_keep_the_dictionary_in_memory() {
|
||||
let mut h = Headless::default();
|
||||
assert!(h.session.user_dict_path.is_none());
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "execute", json!({"command": "review.addToDictionary", "params": {"word": "zxqmcpword"}}));
|
||||
h.session.run("review.addToDictionary", &json!({"word": "zxqmcpword2"})).expect("added");
|
||||
assert!(h.session.user_dict_path.is_none());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,19 @@ pub fn remove_word(w: &str) -> bool {
|
|||
removed
|
||||
}
|
||||
|
||||
fn ignored_words() -> &'static RwLock<HashSet<String>> {
|
||||
static I: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
|
||||
I.get_or_init(|| RwLock::new(HashSet::new()))
|
||||
}
|
||||
|
||||
/// Ignore a word everywhere until the app closes ("Ignore All"). Unlike [`add_word`] it isn't
|
||||
/// part of the user dictionary: it is never listed, saved or loaded, as in Word.
|
||||
pub fn ignore_word(w: &str) {
|
||||
if ignored_words().write().unwrap_or_else(|e| e.into_inner()).insert(w.to_lowercase()) {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A number that changes whenever a word is added to or removed from the user dictionary.
|
||||
pub fn dictionary_generation() -> u64 {
|
||||
DICTIONARY_GENERATION.load(Ordering::Relaxed)
|
||||
|
|
@ -112,6 +125,109 @@ pub fn user_dictionary() -> Vec<String> {
|
|||
v
|
||||
}
|
||||
|
||||
/// Longest custom-dictionary word kept, in characters (mirrors `file.options` validation).
|
||||
pub const MAX_CUSTOM_WORD_CHARS: usize = 64;
|
||||
/// Most custom-dictionary words read from one file (caps a hostile file).
|
||||
const MAX_CUSTOM_WORDS: usize = 100_000;
|
||||
/// Largest custom-dictionary file read, in bytes (caps a hostile file).
|
||||
const MAX_CUSTOM_FILE_BYTES: u64 = 1_000_000;
|
||||
|
||||
/// `w` as a custom-dictionary word: trimmed and lower-cased, or `None` when it isn't a single
|
||||
/// word of at most [`MAX_CUSTOM_WORD_CHARS`] characters (empty, spaces, control characters, or
|
||||
/// bytes that weren't UTF-8).
|
||||
/// Adding and loading both use it, so a word that can be added is never dropped on the next load.
|
||||
pub fn custom_word(w: &str) -> Option<String> {
|
||||
let w = w.trim();
|
||||
if w.is_empty()
|
||||
|| w.chars().count() > MAX_CUSTOM_WORD_CHARS
|
||||
|| w.chars().any(|c| c.is_whitespace() || c.is_control() || c == char::REPLACEMENT_CHARACTER)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(w.to_lowercase())
|
||||
}
|
||||
|
||||
/// The valid words of the custom-dictionary file at `path`, reading at most
|
||||
/// [`MAX_CUSTOM_FILE_BYTES`] of it and keeping at most [`MAX_CUSTOM_WORDS`]. A missing or
|
||||
/// unreadable file reads as no words.
|
||||
fn read_dictionary_file(path: &std::path::Path) -> Vec<String> {
|
||||
use std::io::Read;
|
||||
let mut bytes = Vec::new();
|
||||
let Ok(file) = std::fs::File::open(path) else { return Vec::new() };
|
||||
// `take` bounds the read itself, so a huge file is never read whole.
|
||||
if file.take(MAX_CUSTOM_FILE_BYTES).read_to_end(&mut bytes).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Lossy: a cut multi-byte tail becomes U+FFFD and is skipped by validation below,
|
||||
// instead of discarding the whole file.
|
||||
String::from_utf8_lossy(&bytes).lines().filter_map(custom_word).take(MAX_CUSTOM_WORDS).collect()
|
||||
}
|
||||
|
||||
/// Read custom-dictionary words from a file into the user dictionary (one word per line;
|
||||
/// blank lines are skipped). A missing file reads as empty; a hostile file is capped,
|
||||
/// never a panic and never an error that stops startup. Returns the words added.
|
||||
pub fn load_user_dictionary(path: &std::path::Path) -> Vec<String> {
|
||||
let words = read_dictionary_file(path);
|
||||
let mut set = user_words().write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut added = Vec::new();
|
||||
for w in words {
|
||||
if set.len() >= MAX_CUSTOM_WORDS {
|
||||
break;
|
||||
}
|
||||
if set.insert(w.clone()) {
|
||||
added.push(w);
|
||||
}
|
||||
}
|
||||
if !added.is_empty() {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Write the user dictionary to a file (one word per line, sorted), creating its folder.
|
||||
///
|
||||
/// Words another process (a second window, the MCP bridge) saved to the file since it was loaded
|
||||
/// are merged in first, so neither loses the other's words; `removed` (words just taken out) stay
|
||||
/// out. The file is replaced atomically: written beside it, then renamed over it. Errors (a
|
||||
/// read-only folder, a full disk) go back to the caller; the in-memory words are kept.
|
||||
pub fn save_user_dictionary(path: &std::path::Path, removed: &[String]) -> std::io::Result<()> {
|
||||
let removed: HashSet<String> = removed.iter().map(|w| w.to_lowercase()).collect();
|
||||
let on_disk = read_dictionary_file(path);
|
||||
{
|
||||
let mut set = user_words().write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut changed = false;
|
||||
for w in on_disk {
|
||||
if set.len() >= MAX_CUSTOM_WORDS {
|
||||
break;
|
||||
}
|
||||
if !removed.contains(&w) && set.insert(w) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for w in &removed {
|
||||
changed |= set.remove(w);
|
||||
}
|
||||
if changed {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
for w in user_dictionary() {
|
||||
out.push_str(&w);
|
||||
out.push('\n');
|
||||
}
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "custom-dictionary.txt".into());
|
||||
let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id()));
|
||||
let written = std::fs::write(&tmp, out).and_then(|()| std::fs::rename(&tmp, path));
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// Common words the source list lacks (modern vocabulary, contractions).
|
||||
const COMMON: &str = "a an the and or but if of to in on at by for with from as is am are was were be been being it its we us our you your he him his she her they them their i me my mine this that these those there here what which who whom whose when where why how all any both each few more most other some such no nor not only own same so than too very can will just should now do does did done has have had having may might must shall would could up down out over under again further then once off into onto upon about above below between through during before after until while because per via vs etc ok yes yeah oh ah hi hey bye one two three four five six seven eight nine ten first last next new old get got go goes went gone see saw seen say said make made take took come came give gave know knew think thought look want need use used find found tell told ask asked feel felt try left keep let put mean set run show turn move live play pay hear hold bring begin seem help talk start might";
|
||||
|
||||
|
|
@ -134,7 +250,7 @@ fn known_core(w: &str) -> bool {
|
|||
if base_known(d, w) || COMMON.split(' ').any(|c| c == w) {
|
||||
return true;
|
||||
}
|
||||
if user_words().read().unwrap_or_else(|e| e.into_inner()).contains(w) {
|
||||
if user_words().read().unwrap_or_else(|e| e.into_inner()).contains(w) || ignored_words().read().unwrap_or_else(|e| e.into_inner()).contains(w) {
|
||||
return true;
|
||||
}
|
||||
// Inflections.
|
||||
|
|
@ -701,6 +817,93 @@ mod tests {
|
|||
assert!(is_correct("zxqwordcrafty"));
|
||||
}
|
||||
|
||||
/// #40: the user dictionary survives a save and a fresh load.
|
||||
#[test]
|
||||
fn user_dictionary_persists_through_a_file() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-dict-{}-{}", std::process::id(), "persist"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// The folder is created on the first save.
|
||||
let path = dir.join("sub/custom-dictionary.txt");
|
||||
add_word("zxqpersistone");
|
||||
add_word("zxqpersisttwo");
|
||||
save_user_dictionary(&path, &[]).expect("save works");
|
||||
let saved = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(saved.lines().any(|w| w == "zxqpersistone") && saved.lines().any(|w| w == "zxqpersisttwo"), "{saved}");
|
||||
// Written atomically: no temporary file is left beside it.
|
||||
let names: Vec<_> = std::fs::read_dir(dir.join("sub")).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(names, ["custom-dictionary.txt"]);
|
||||
remove_word("zxqpersistone");
|
||||
assert!(!is_correct("zxqpersistone"));
|
||||
load_user_dictionary(&path);
|
||||
assert!(is_correct("zxqpersistone"), "loading brings the word back");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #40: a hostile file is capped: invalid lines are skipped, and only the first megabyte is read.
|
||||
#[test]
|
||||
fn hostile_dictionary_files_are_capped() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-dict-{}-{}", std::process::id(), "hostile"));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("custom-dictionary.txt");
|
||||
let mut hostile = vec![0xff, 0xfe, b'\n'];
|
||||
hostile.extend(b"zxqhostileok\n zxqhostiletrim \nzxq hostile space\nzxq\x07bell\n");
|
||||
hostile.extend("zxq".repeat(30).bytes()); // 90 characters: too long
|
||||
hostile.push(b'\n');
|
||||
// Pad past the 1 MB cap with blank lines; the word after it is never read.
|
||||
hostile.resize(1_000_100, b'\n');
|
||||
hostile.extend(b"zxqhostilepastcap\n");
|
||||
std::fs::write(&path, &hostile).unwrap();
|
||||
let added = load_user_dictionary(&path);
|
||||
assert!(added.contains(&"zxqhostileok".to_string()) && added.contains(&"zxqhostiletrim".to_string()), "{added:?}");
|
||||
assert!(!added.iter().any(|w| w.contains("space") || w.contains("bell") || w.chars().count() > MAX_CUSTOM_WORD_CHARS), "{added:?}");
|
||||
assert!(!is_correct("zxqhostilepastcap"), "the file is read only up to the cap");
|
||||
assert_eq!(read_dictionary_file(&path).len(), 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #40: saving merges in words another process saved, and leaves out the ones just removed.
|
||||
#[test]
|
||||
fn saving_merges_with_the_file() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-dict-{}-{}", std::process::id(), "merge"));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("custom-dictionary.txt");
|
||||
std::fs::write(&path, "zxqmergeother\nzxqmergegone\n").unwrap();
|
||||
add_word("zxqmergemine");
|
||||
save_user_dictionary(&path, &["zxqmergegone".to_string()]).unwrap();
|
||||
let saved = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(saved.lines().any(|w| w == "zxqmergeother"), "the other process's word is kept: {saved}");
|
||||
assert!(saved.lines().any(|w| w == "zxqmergemine"), "{saved}");
|
||||
assert!(!saved.lines().any(|w| w == "zxqmergegone"), "a removed word stays out: {saved}");
|
||||
assert!(is_correct("zxqmergeother") && !is_correct("zxqmergegone"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Ignore All lasts for the session: the word is accepted but isn't in the dictionary.
|
||||
#[test]
|
||||
fn ignored_words_are_not_dictionary_words() {
|
||||
assert!(!is_correct("zxqignoredword"));
|
||||
ignore_word("ZxqIgnoredWord");
|
||||
assert!(is_correct("zxqignoredword"));
|
||||
assert!(!user_dictionary().contains(&"zxqignoredword".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_words_are_single_short_words() {
|
||||
assert_eq!(custom_word(" Hello "), Some("hello".into()));
|
||||
for bad in ["", " ", "two words", "tab\tbed", "bell\u{7}", "bad\u{FFFD}", &"x".repeat(MAX_CUSTOM_WORD_CHARS + 1)] {
|
||||
assert_eq!(custom_word(bad), None, "{bad:?}");
|
||||
}
|
||||
assert!(custom_word(&"é".repeat(MAX_CUSTOM_WORD_CHARS)).is_some(), "the cap counts characters, not bytes");
|
||||
}
|
||||
|
||||
/// #40: a missing file loads as empty, never an error.
|
||||
#[test]
|
||||
fn missing_dictionary_file_loads_empty() {
|
||||
let path = std::env::temp_dir().join(format!("wc-dict-{}-missing.txt", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(load_user_dictionary(&path).is_empty());
|
||||
}
|
||||
|
||||
/// #100: runs are proofed when their language has a dictionary (the default language too)
|
||||
/// and skipped otherwise.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Custom dictionary persists across sessions (`custom-dictionary.txt` in the settings folder; #40) |
|
||||
| 2026-10-11 | trivial | #8 narrowed: runs in a proofing language without a dictionary are skipped instead of flagged against the English dictionary (#100); dictionaries still English-only |
|
||||
| 2026-10-11 | trivial | #16: DOCX open/save preserves independent complex-script formatting inheritance; RTL sections, tables and typography remain open |
|
||||
| 2026-10-11 | trivial | `.doc` pictures, shapes and text boxes read (#551); gap #14 narrowed to the writer and Word 6/95 files |
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ full parity for the area.
|
|||
| Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). |
|
||||
| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources not yet in DOCX (#169 open). |
|
||||
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), Compare / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (insertions stay inline, as in Word; deletions in headers, footers, notes and text boxes, and in print and PDF, stay inline). Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 40% | (in Review) | 20–40 | English and Polish, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10). Other languages are left unmarked. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 40% | (in Review) | 20–40 | English and Polish, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10). Other languages are left unmarked. The custom dictionary persists across sessions (#40). Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
|
||||
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. |
|
||||
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. |
|
||||
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. |
|
||||
|
|
@ -251,6 +251,7 @@ can be used without committing Word output.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Proofing: custom dictionary persists across sessions (#40); no percentage change |
|
||||
| 2026-10-11 | trivial | Proofing: runs in a language without a dictionary are skipped instead of flagged against the English dictionary (#100); no percentage change |
|
||||
| 2026-10-11 | minor | Polish interface (13 UI languages) and Polish proofing with per-run languages: Proofing 35% → 40% (25–45 h → 20–40 h), Localization 25% → 27%; feature depth and readiness unchanged at this precision |
|
||||
| 2026-10-11 | trivial | Tables: column and row borders and the resize handle drag on the page (#217); Tables row and interaction-fidelity evidence updated, no percentage change |
|
||||
|
|
|
|||
Loading…
Reference in New Issue