From 3d8f8144d9ea0b9c47c31773a75a27051663d56b Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:21:57 +0200 Subject: [PATCH] File: recover unsaved work after a crash (#518) (#586) * M10: retain local crash recovery on current main * i18n: Backstage Recover in every catalog Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + ROADMAP.md | 2 + apps/wordcraft/src/main.rs | 8 + crates/control-key/src/lib.rs | 4 + crates/doc/src/graphic.rs | 6 +- crates/doc/src/lib.rs | 4 +- crates/doc/src/para.rs | 2 +- crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/file.rs | 4 + crates/engine/src/cmd/tools.rs | 5 +- crates/engine/src/file_access.rs | 3 + crates/engine/src/lib.rs | 1 + crates/engine/src/recovery.rs | 135 ++++ crates/engine/src/recovery/desktop.rs | 879 +++++++++++++++++++++++++ crates/engine/src/session.rs | 6 + crates/engine/tests/recovery_format.rs | 69 ++ crates/ui-egui/src/backstage.rs | 209 +++++- crates/ui-egui/src/dialogs.rs | 33 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/et.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr-latn.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 55 ++ docs/control-protocol.md | 3 +- docs/file-format-parity.md | 1 + docs/gaps.md | 5 + docs/recovery.md | 85 +++ docs/roadmap.md | 4 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 3 +- 45 files changed, 1560 insertions(+), 30 deletions(-) create mode 100644 crates/engine/src/recovery.rs create mode 100644 crates/engine/src/recovery/desktop.rs create mode 100644 crates/engine/tests/recovery_format.rs create mode 100644 docs/recovery.md diff --git a/Cargo.lock b/Cargo.lock index 0c2061810..60708446f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5238,6 +5238,7 @@ version = "0.7.0" dependencies = [ "cfb", "encoding_rs", + "getrandom 0.3.4", "image", "js-sys", "log", diff --git a/ROADMAP.md b/ROADMAP.md index a4554ae74..5f3ec26ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -320,6 +320,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources | | 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing | | 2026-10-12 | List-label line height: automatic spacing scales body text before adding label ascent overflow; AtLeast spacing keeps text descent. Original Word for Mac probes and deterministic layout tests cover symbol/decimal labels, wrapped lines, fixed/minimum spacing and inline pictures. Overall readiness unchanged; [evidence](docs/layout-parity.md#list-label-line-spacing) | +| 2026-10-12 | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. | | 2026-10-11 | Contextual paragraph spacing: same-style first-only 12/8 pt and 10/12 pt gaps corrected to 0 pt and 2 pt in body and header layout, with per-side controls; measured against Word 16.113.3. Ordinary overlap remains #108 | | 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) | | 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents | @@ -462,6 +463,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages | | 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing | | 2026-10-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged | +| 2026-10-12 | minor | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. | | 2026-10-11 | minor | Progress log: contextual paragraph spacing in body paragraphs and headers | | 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) | | 2026-10-14 | trivial | Progress log: ODT and `.doc` pictures read once, within their byte budget (#1334) | diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index ab3d2ea2e..fe4ff9380 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -349,6 +349,14 @@ fn main() -> eframe::Result { app.session.set_document(doc); } app.watch_desktop_theme(&cc.egui_ctx); + if prefs_enabled() + && let Some(dir) = wordcraft_control_key::settings_dir() + { + let ctx = cc.egui_ctx.clone(); + if let Err(e) = app.session.enable_recovery(dir.join("recovery"), std::sync::Arc::new(move || ctx.request_repaint())) { + log::warn!("local recovery unavailable: {e}"); + } + } app.ui.window = restored; app.integrated_titlebar = cfg!(target_os = "macos"); #[cfg(target_os = "macos")] diff --git a/crates/control-key/src/lib.rs b/crates/control-key/src/lib.rs index 1cb7a1e5a..1c562f40a 100644 --- a/crates/control-key/src/lib.rs +++ b/crates/control-key/src/lib.rs @@ -6,7 +6,11 @@ use std::path::{Path, PathBuf}; /// The app's settings folder (preferences, logs, key files): macOS /// `~/Library/Application Support/WordCraft`, Windows `%APPDATA%\WordCraft`, otherwise /// `$XDG_CONFIG_HOME/wordcraft` or `~/.config/wordcraft`. +/// `WORDCRAFT_PROFILE_DIR` overrides the entire folder for isolated runs. pub fn settings_dir() -> Option { + if let Some(path) = std::env::var_os("WORDCRAFT_PROFILE_DIR").filter(|p| !p.is_empty()) { + return Some(PathBuf::from(path)); + } if cfg!(target_os = "macos") { std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/WordCraft")) } else if cfg!(windows) { diff --git a/crates/doc/src/graphic.rs b/crates/doc/src/graphic.rs index c6e448f67..0a4b21a35 100644 --- a/crates/doc/src/graphic.rs +++ b/crates/doc/src/graphic.rs @@ -24,7 +24,7 @@ pub struct Graphic { pub h: f32, /// Where the graphic was read from, so saving writes the original drawing itself back (not /// only these items). `None` for graphics made some other way. - #[serde(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option>, /// A SmartArt graphic WordCraft can edit (one it made, or one read back exactly as it wrote /// it): saving writes the diagram parts from it, and the items are drawn from it. See @@ -41,7 +41,7 @@ pub struct Graphic { /// source format and the package parts that markup refers to. Never edited; WordCraft only moves /// and resizes the object around it. The parts themselves (bytes, content type and their own /// relationships) are kept in [`crate::Document::passthrough`] under their part names. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Embedded { /// The object's markup as a standalone XML element with its namespace declarations (DOCX: /// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`, @@ -52,7 +52,7 @@ pub struct Embedded { } /// One relationship an [`Embedded`] object uses. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct EmbeddedRef { /// The relationship id the markup uses. pub id: String, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 919d66ef2..babe0451d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -642,10 +642,10 @@ pub struct Document { #[serde(skip_serializing_if = "NoteSeparators::is_empty")] pub endnote_separators: NoteSeparators, /// Embedded media (images) by key. - #[serde(skip)] + #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub media: BTreeMap>>, /// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name. - #[serde(skip)] + #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub passthrough: BTreeMap>>, } diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 601eaa55d..ed5a6793c 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -380,7 +380,7 @@ pub enum InlineObject { crop: [f32; 4], /// The OLE object (embedded or linked file) this picture shows, as read from a file: /// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`]. - #[serde(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] ole: Option>, }, /// A chart or SmartArt diagram (see [`crate::graphic`]). diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 5247a9de0..17dac28de 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -32,6 +32,9 @@ zeroize = { workspace = true } # The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32). js-sys = "0.3" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +getrandom = { workspace = true } + [dev-dependencies] encoding_rs = { workspace = true } zip = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 78d3d45df..c8a2753e5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -241,6 +241,9 @@ fn encrypt(s: &mut Session, v: &Value) -> CmdResult { _ => return Err(CmdError::Params("`password` must be a string, or null to remove the password".into())), }; if password != s.password { + if password.is_some() { + s.clear_recovery_before_password()?; + } s.password = password; s.dirty = true; } @@ -263,6 +266,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into())); } wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?; + s.clear_recovery_before_password()?; s.password = Some(crate::io::Password::new(pw)); } let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 595b36b9a..21b8addcc 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -411,9 +411,8 @@ pub fn specs() -> Vec { }) .params(r#"{"save"?: label, "restore"?: index}"#) .pure(), - CommandSpec::new("file.recover", "Recover Unsaved Documents", "File › Info", |s, _| { - Ok(Value::Array(s.versions.iter().enumerate().map(|(i, (l, d, _))| json!({"index": i, "label": l, "date": d})).collect())) - }) + CommandSpec::new("file.recover", "Recover Unsaved Documents", "File › Info", |s, v| s.recover(v)) + .params(r#"{"restore"?: recoveryId, "discard"?: recoveryId, "refresh"?: true}; desktop only; returns {enabled,busy,documents,error}; restore loads asynchronously without a file path"#) .pure(), CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| { let path = p::req_str(v, "path")?; diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 6744e7b46..3d1e80880 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -439,6 +439,8 @@ mod tests { /// reaches for `std::fs` itself would bypass them, so none may: every file under `src/` /// (commands, `print.rs`, `session.rs`…) except `io.rs` with its `io/` helpers (`safe_save`, /// reached only after `io` checked the roots), this module, and the test files. + /// The host-enabled recovery store owns its profile files; commands address known snapshot + /// IDs, never paths. Only that storage module is exempt, not recovery commands in general. #[test] fn commands_do_not_touch_the_file_system_themselves() { fn walk(dir: &Path, files: &mut Vec) { @@ -461,6 +463,7 @@ mod tests { // `io` and this module are the checked gateway; `tests*.rs` are test-only modules. if path.parent() == Some(src.as_path()) && (name == "io.rs" || name == "file_access.rs" || name.starts_with("tests")) || path.starts_with(src.join("io")) + || path == src.join("recovery/desktop.rs") { continue; } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 55e825ad2..0158a6054 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ mod io_ext; pub mod keymap; pub mod math_gallery; pub mod print; +pub mod recovery; pub mod ribbon; pub mod sample; mod session; diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs new file mode 100644 index 000000000..cf03a45a4 --- /dev/null +++ b/crates/engine/src/recovery.rs @@ -0,0 +1,135 @@ +//! Local desktop recovery. Encoding and file I/O run on a worker, outside typing. +use crate::{CmdError, CmdResult, Session}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecoveryInfo { + pub id: String, + pub name: String, + pub saved_at: String, +} + +#[cfg(not(target_arch = "wasm32"))] +mod desktop; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use desktop::Recovery; + +impl Session { + /// The desktop host supplies the profile folder and a thread-safe wakeup. + /// Headless sessions and the browser never write recovery files implicitly. + #[cfg(not(target_arch = "wasm32"))] + pub fn enable_recovery(&mut self, root: std::path::PathBuf, wake: std::sync::Arc) -> Result<(), String> { + self.recovery = Some(Recovery::start(root, wake)?); + Ok(()) + } + pub fn recovery_documents(&self) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.documents.clone(); + } + Vec::new() + } + pub fn recovery_ready(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.ready; + } + false + } + pub fn recovery_busy(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.busy; + } + false + } + pub fn recovery_error(&self) -> Option<&str> { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.error.as_deref(); + } + None + } + /// A delayed restore can't replace edits made while it was loading. Restores have no path. + pub fn recovery_tick(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + let Some(mut r) = self.recovery.take() else { return }; + if let Some((snapshot, expected)) = r.poll() { + if expected == (self.document_id(), self.rev()) && self.password.is_none() { + let id = snapshot.info.id; + self.set_document(snapshot.document); + self.path = None; + self.dirty = true; + r.adopt(id, self.document_id(), self.rev()); + } else { + r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into()); + } + } + if let Err(e) = r.tick(self, std::time::Instant::now()) { + r.error = Some(e); + } + if let Some(e) = &r.error { + self.status = e.clone(); + } + self.recovery = Some(r); + } + } + pub fn recovery_wait(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery + && self.dirty + && self.password.is_none() + && !r.busy + { + return r.wait(self.document_id(), self.rev()); + } + None + } + /// Only an explicit protect/save action waits for the worker and removes plaintext copies. + /// A failure prevents applying the password; typing never waits. + pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> { + #[cfg(not(target_arch = "wasm32"))] + { + let document = self.document_id(); + if let Some(r) = &mut self.recovery + && r.document_is(document) + { + r.purge().map_err(CmdError::Failed)?; + } + } + Ok(()) + } + pub(crate) fn recover(&mut self, v: &Value) -> CmdResult { + let restore = v.get("restore"); + let discard = v.get("discard"); + if restore.is_some() && discard.is_some() { + return Err(CmdError::Params("choose restore or discard".into())); + } + if restore.is_some() || discard.is_some() || v.get("refresh").is_some() { + #[cfg(not(target_arch = "wasm32"))] + { + let expected = (self.document_id(), self.rev()); + let r = self.recovery.as_mut().ok_or_else(|| CmdError::Disabled("local recovery requires the desktop host".into()))?; + if let Some(id) = restore { + r.restore(id.as_str().ok_or_else(|| CmdError::Params("restore must be a recovery id".into()))?, expected) + .map_err(CmdError::Failed)?; + } else if let Some(id) = discard { + r.discard(id.as_str().ok_or_else(|| CmdError::Params("discard must be a recovery id".into()))?, expected) + .map_err(CmdError::Failed)?; + } else if v.get("refresh").and_then(Value::as_bool) == Some(true) { + r.refresh().map_err(CmdError::Failed)?; + } else { + return Err(CmdError::Params("refresh must be true".into())); + } + } + #[cfg(target_arch = "wasm32")] + return Err(CmdError::Disabled("local recovery is available on desktop only".into())); + } + Ok( + json!({"enabled": self.recovery_ready(), "busy": self.recovery_busy(), "documents": self.recovery_documents(), "error": self.recovery_error()}), + ) + } +} diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs new file mode 100644 index 000000000..f9f1cd89a --- /dev/null +++ b/crates/engine/src/recovery/desktop.rs @@ -0,0 +1,879 @@ +use super::RecoveryInfo; +use crate::Session; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, mpsc}; +use std::time::{Duration, Instant}; +use wordcraft_doc::Document; + +const INTERVAL: Duration = Duration::from_secs(10); +const MAX_BYTES: u64 = 256 << 20; +const MAX_RECORDS: usize = 128; +const MAX_FILES: usize = 16; +const DISK_BUDGET: u64 = 2 << 30; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Snapshot { + version: u32, + pub info: RecoveryInfo, + generation: u64, + pub document: Document, +} +struct Entry { + info: RecoveryInfo, + path: PathBuf, +} +struct Store { + root: PathBuf, + entries: BTreeMap, + // Advisory locks prevent another running instance from restoring/discarding a live copy. + locks: BTreeMap, +} +fn private_dir(path: &Path) -> io::Result<()> { + let mut b = fs::DirBuilder::new(); + b.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + b.mode(0o700); + } + b.create(path) +} +fn private_file(path: &Path, exclusive: bool) -> io::Result { + let mut o = OpenOptions::new(); + o.read(true).write(true); + if exclusive { + o.create_new(true); + } else { + o.create(true).truncate(false); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + o.open(path) +} +fn sync_dir(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + File::open(path)?.sync_all()?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} +fn id_ok(id: &str) -> bool { + id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) +} +fn new_id() -> Result { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).map_err(|e| e.to_string())?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} +fn generation(path: &Path) -> Option { + path.file_name()?.to_str()?.strip_prefix("snapshot-")?.strip_suffix(".json")?.parse().ok() +} +fn read_snapshot(path: &Path) -> Result { + let meta = fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() || meta.len() > MAX_BYTES { + return Err("not a regular recovery file, or it exceeds 256 MiB".into()); + } + let file = File::open(path).map_err(|e| e.to_string())?; + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("not a regular recovery file".into()); + } + let s: Snapshot = serde_json::from_reader(BufReader::new(file.take(MAX_BYTES + 1))).map_err(|e| e.to_string())?; + if s.version != 1 || !id_ok(&s.info.id) { + return Err("unsupported recovery file".into()); + } + Ok(s) +} +impl Store { + fn new(root: PathBuf) -> Result { + private_dir(&root).map_err(|e| e.to_string())?; + Ok(Self { root, entries: BTreeMap::new(), locks: BTreeMap::new() }) + } + fn directories(&self) -> Result, String> { + let mut dirs = Vec::new(); + for e in fs::read_dir(&self.root).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + if e.file_type().map_err(|e| e.to_string())?.is_dir() && e.file_name().to_str().is_some_and(id_ok) { + if dirs.len() >= MAX_RECORDS { + return Err("recovery folder has too many records".into()); + } + dirs.push(e.path()); + } + } + Ok(dirs) + } + fn files(dir: &Path) -> Result, String> { + let mut paths = Vec::new(); + for e in fs::read_dir(dir).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + if e.file_type().map_err(|e| e.to_string())?.is_file() && generation(&e.path()).is_some() { + if paths.len() >= MAX_FILES { + return Err("recovery record has too many generations".into()); + } + paths.push(e.path()); + } + } + paths.sort_by_key(|p| std::cmp::Reverse(generation(p))); + Ok(paths) + } + fn lock(&self, id: &str) -> Result { + if !id_ok(id) { + return Err("invalid recovery id".into()); + } + let file = private_file(&self.root.join(id).join("owner.lock"), false).map_err(|e| e.to_string())?; + file.try_lock().map_err(|e| format!("recovery copy is in use or cannot be locked: {e}"))?; + Ok(file) + } + fn acquire(&mut self, id: &str) -> Result<(), String> { + if !self.locks.contains_key(id) { + let lock = self.lock(id)?; + self.locks.insert(id.to_string(), lock); + } + Ok(()) + } + fn scan(&mut self) -> Result<(), String> { + let mut entries = BTreeMap::new(); + let mut unreadable = false; + for dir in self.directories()? { + let Some(id) = dir.file_name().and_then(|s| s.to_str()) else { continue }; + let guard = if self.locks.contains_key(id) { + None + } else { + match self.lock(id) { + Ok(f) => Some(f), + Err(e) => { + log::debug!("recovery: {e}"); + continue; + } + } + }; + // A crashed writer can leave an incomplete temporary file. Only clean records + // we have locked, so another instance's write is never interrupted. + for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if entry.file_type().map_err(|e| e.to_string())?.is_file() + && entry.file_name().to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) + { + fs::remove_file(entry.path()).map_err(|e| e.to_string())?; + } + } + for path in Self::files(&dir)? { + match read_snapshot(&path) { + Ok(s) if s.info.id == id && Some(s.generation) == generation(&path) => { + entries.insert(id.to_string(), Entry { info: s.info, path }); + break; + } + Ok(_) => { + unreadable = true; + log::warn!("recovery: mismatched record {}", path.display()); + } + Err(e) => { + unreadable = true; + log::warn!("recovery: unreadable {}: {e}", path.display()); + } + } + } + drop(guard); + // A crash before the first commit leaves no document to recover. Reclaim + // that slot only under a fresh lock and the discard path's entry checks. + if Self::files(&dir)?.is_empty() + && let Err(e) = self.discard(id) + { + log::warn!("recovery: cannot remove empty record: {e}"); + } + } + self.entries = entries; + if unreadable { Err("Some recovery files could not be read. Earlier valid copies are listed when available.".into()) } else { Ok(()) } + } + fn inventory(&self) -> Vec { + let mut v: Vec<_> = self.entries.values().map(|e| e.info.clone()).collect(); + v.sort_by(|a, b| b.saved_at.cmp(&a.saved_at).then(a.id.cmp(&b.id))); + v + } + fn disk_used(&self) -> Result { + let mut used = 0u64; + for dir in self.directories()? { + for p in Self::files(&dir)? { + used = used.saturating_add(fs::metadata(p).map_err(|e| e.to_string())?.len()); + } + } + Ok(used) + } + fn save(&mut self, s: Snapshot) -> Result<(), String> { + let id = s.info.id.clone(); + let result = self.write_snapshot(s); + // Include failures before temp-file creation (quota checks, locking, etc.). + // A record containing any committed generation is never removed here. + if result.is_err() + && Self::files(&self.root.join(&id)).is_ok_and(|files| files.is_empty()) + && let Err(e) = self.discard(&id) + { + log::warn!("recovery: cannot remove failed empty record: {e}"); + } + result + } + fn write_snapshot(&mut self, mut s: Snapshot) -> Result<(), String> { + let dir = self.root.join(&s.info.id); + if !dir.exists() { + if self.directories()?.len() >= MAX_RECORDS { + return Err("recovery folder is full; discard an older copy".into()); + } + private_dir(&dir).map_err(|e| e.to_string())?; + } + self.acquire(&s.info.id)?; + let files = Self::files(&dir)?; + if files.len() >= MAX_FILES { + return Err("older recovery generations could not be pruned; keeping the last valid copy".into()); + } + s.generation = files.first().and_then(|p| generation(p)).unwrap_or(0).checked_add(1).ok_or("recovery generation overflow")?; + let previous = self.entries.get(&s.info.id).map(|e| e.path.clone()); + let limit = MAX_BYTES.min(DISK_BUDGET.saturating_sub(self.disk_used()?)); + let tmp = dir.join(format!("pending-{}.tmp", new_id()?)); + let final_path = dir.join(format!("snapshot-{:020}.json", s.generation)); + let result = (|| { + let file = private_file(&tmp, true).map_err(|e| e.to_string())?; + let mut writer = Limited { inner: BufWriter::new(file), remaining: limit }; + serde_json::to_writer(&mut writer, &s).map_err(|e| e.to_string())?; + writer.flush().map_err(|e| e.to_string())?; + writer.inner.get_ref().sync_all().map_err(|e| e.to_string())?; + drop(writer); + let check = read_snapshot(&tmp)?; + if check.document != s.document { + return Err("recovery format did not preserve the full document".into()); + } + // A new generation is never written over the last valid file. + fs::rename(&tmp, &final_path).map_err(|e| e.to_string())?; + sync_dir(&dir).map_err(|e| e.to_string())?; + self.entries.insert(s.info.id.clone(), Entry { info: s.info, path: final_path.clone() }); + for old in Self::files(&dir)?.into_iter().filter(|p| p != &final_path && Some(p) != previous.as_ref()) { + if let Err(e) = fs::remove_file(old) { + log::warn!("recovery: cannot prune old generation: {e}"); + } + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result + } + fn load(&mut self, id: &str) -> Result { + if !self.entries.contains_key(id) { + return Err("no such recovery copy".into()); + } + self.acquire(id)?; + read_snapshot(&self.entries.get(id).ok_or("no such recovery copy")?.path) + } + fn discard(&mut self, id: &str) -> Result<(), String> { + if !id_ok(id) { + return Err("invalid recovery id".into()); + } + let dir = self.root.join(id); + if !dir.exists() { + self.entries.remove(id); + return Ok(()); + } + self.acquire(id)?; + for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + let known = name == "owner.lock" + || generation(&entry.path()).is_some() + || name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)); + if !known || !entry.file_type().map_err(|e| e.to_string())?.is_file() { + return Err("recovery folder contains an unexpected entry".into()); + } + } + for p in Self::files(&dir)? { + fs::remove_file(p).map_err(|e| e.to_string())?; + } + for e in fs::read_dir(&dir).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + let name = e.file_name(); + if name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) { + fs::remove_file(e.path()).map_err(|e| e.to_string())?; + } + } + sync_dir(&dir).map_err(|e| e.to_string())?; + self.entries.remove(id); + self.locks.remove(id); + fs::remove_file(dir.join("owner.lock")).map_err(|e| e.to_string())?; + fs::remove_dir(&dir).map_err(|e| e.to_string())?; + sync_dir(&self.root).map_err(|e| e.to_string())?; + Ok(()) + } +} +struct Limited { + inner: W, + remaining: u64, +} +impl Write for Limited { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() as u64 > self.remaining { + return Err(io::Error::other("recovery snapshot or folder size limit exceeded")); + } + let n = self.inner.write(bytes)?; + self.remaining -= n as u64; + Ok(n) + } + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} +enum Job { + Save(Box), + Scan, + Load(String, (u64, u64)), + Discard(String), +} +enum Outcome { + Done, + Discarded(String), + Loaded(Box, (u64, u64)), +} +struct Event { + documents: Vec, + outcome: Result, +} +pub(crate) struct Recovery { + tx: mpsc::SyncSender, + rx: mpsc::Receiver, + pub documents: Vec, + pub ready: bool, + pub busy: bool, + pub error: Option, + document: Option, + id: Option, + revision: Option, + due: Instant, + alive: bool, +} +impl Recovery { + pub fn start(root: PathBuf, wake: Arc) -> Result { + let (tx, jobs) = mpsc::sync_channel(1); + let (events, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("wordcraft-recovery".into()) + .spawn(move || { + let mut store = match Store::new(root) { + Ok(s) => s, + Err(e) => { + let _ = events.send(Event { documents: Vec::new(), outcome: Err(e) }); + wake(); + return; + } + }; + let initial = store.scan().map(|_| Outcome::Done); + if events.send(Event { documents: store.inventory(), outcome: initial }).is_err() { + return; + } + wake(); + while let Ok(job) = jobs.recv() { + let result = match job { + Job::Save(s) => store.save(*s).map(|_| Outcome::Done), + Job::Scan => store.scan().map(|_| Outcome::Done), + Job::Load(id, expected) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), expected)), + Job::Discard(id) => store.discard(&id).map(|_| Outcome::Discarded(id)), + }; + if events.send(Event { documents: store.inventory(), outcome: result }).is_err() { + break; + } + wake(); + } + }) + .map_err(|e| e.to_string())?; + Ok(Self { + tx, + rx, + documents: Vec::new(), + ready: false, + busy: true, + error: None, + document: None, + id: None, + revision: None, + due: Instant::now() + INTERVAL, + alive: true, + }) + } + fn queue(&mut self, job: Job) -> Result<(), String> { + if !self.alive { + return Err("recovery worker is unavailable".into()); + } + if self.busy { + return Err("recovery is busy; try again when it finishes".into()); + } + self.tx.try_send(job).map_err(|e| e.to_string())?; + self.busy = true; + self.error = None; + Ok(()) + } + pub fn poll(&mut self) -> Option<(Snapshot, (u64, u64))> { + match self.rx.try_recv() { + Ok(e) => { + self.busy = false; + self.ready = true; + self.documents = e.documents; + match e.outcome { + Ok(Outcome::Done) => None, + Ok(Outcome::Discarded(id)) => { + if self.id.as_deref() == Some(&id) { + self.id = None; + } + None + } + Ok(Outcome::Loaded(s, expected)) => Some((*s, expected)), + Err(e) => { + self.error = Some(e); + self.revision = None; + self.due = Instant::now() + INTERVAL; + None + } + } + } + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => { + self.busy = false; + self.alive = false; + if self.error.is_none() { + self.error = Some("recovery worker is unavailable".into()); + } + None + } + } + } + pub fn adopt(&mut self, id: String, document: u64, revision: u64) { + self.id = Some(id); + self.document = Some(document); + self.revision = Some(revision); + self.due = Instant::now() + INTERVAL; + } + pub fn tick(&mut self, s: &Session, now: Instant) -> Result<(), String> { + if self.document != Some(s.document_id()) { + self.document = Some(s.document_id()); + self.id = None; + self.revision = None; + } + if self.busy || !self.ready || !self.alive { + return Ok(()); + } + if !s.dirty || s.password.is_some() { + if let Some(id) = self.id.clone() { + self.queue(Job::Discard(id))?; + } + return Ok(()); + } + if self.revision == Some(s.rev()) || now < self.due { + return Ok(()); + } + let id = match &self.id { + Some(id) => id.clone(), + None => new_id()?, + }; + let name = s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into()); + let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() }; + self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: s.doc.clone() })))?; + self.id = Some(id); + self.revision = Some(s.rev()); + self.due = now + INTERVAL; + Ok(()) + } + pub fn document_is(&self, document: u64) -> bool { + self.document == Some(document) + } + pub fn wait(&self, document: u64, revision: u64) -> Option { + if !self.alive || !self.ready || self.busy || (self.document == Some(document) && self.revision == Some(revision)) { + return None; + } + Some(self.due.saturating_duration_since(Instant::now()).max(Duration::from_millis(100))) + } + pub fn restore(&mut self, id: &str, expected: (u64, u64)) -> Result<(), String> { + if !self.documents.iter().any(|d| d.id == id) { + return Err("no such recovery copy".into()); + } + self.queue(Job::Load(id.into(), expected)) + } + pub fn discard(&mut self, id: &str, current: (u64, u64)) -> Result<(), String> { + if !self.documents.iter().any(|d| d.id == id) { + return Err("no such recovery copy".into()); + } + self.queue(Job::Discard(id.into()))?; + if self.id.as_deref() == Some(id) { + self.revision = Some(current.1); + } + Ok(()) + } + pub fn refresh(&mut self) -> Result<(), String> { + self.queue(Job::Scan) + } + pub fn purge(&mut self) -> Result<(), String> { + let Some(id) = self.id.clone() else { return Ok(()) }; + if self.busy { + let e = self.rx.recv().map_err(|e| e.to_string())?; + self.documents = e.documents; + self.busy = false; + self.ready = true; + if matches!(e.outcome, Ok(Outcome::Loaded(..))) { + return Err("finish recovery before protecting this document".into()); + } + } + self.queue(Job::Discard(id))?; + let e = self.rx.recv().map_err(|e| e.to_string())?; + self.busy = false; + self.documents = e.documents; + e.outcome?; + self.id = None; + self.revision = None; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wordcraft_doc::StoryRef; + + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("wordcraft-recovery-test-{}", new_id().unwrap())); + private_dir(&path).unwrap(); + Self(path) + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fn snapshot(id: &str, text: &str) -> Snapshot { + Snapshot { + version: 1, + info: RecoveryInfo { id: id.into(), name: "Synthetic draft".into(), saved_at: crate::cmd::now_iso() }, + generation: 0, + document: Document::from_text(text), + } + } + fn settle(s: &mut Session) { + let end = Instant::now() + Duration::from_secs(5); + loop { + s.recovery_tick(); + if s.recovery_ready() && !s.recovery_busy() { + break; + } + assert!(Instant::now() < end, "recovery did not finish"); + std::thread::sleep(Duration::from_millis(2)); + } + } + fn session(root: &Path) -> Session { + let mut s = Session::new(Document::new()); + s.enable_recovery(root.to_owned(), Arc::new(|| {})).unwrap(); + settle(&mut s); + s + } + fn save_now(s: &mut Session) { + s.recovery.as_mut().unwrap().due = Instant::now(); + s.recovery_tick(); + settle(s); + } + + #[test] + fn recovery_commands_only_address_known_profile_records() { + use crate::file_access::{AutomationRoots, confine}; + let profile = Temp::new(); + let files = Temp::new(); + let original = files.0.join("original.txt"); + fs::write(&original, b"External synthetic original").unwrap(); + let roots = AutomationRoots::new(Some(&files.0), Some(&files.0)).unwrap().unwrap(); + let mut s = session(&profile.0); + s.run("document.setText", &json!({"text": "Recoverable synthetic draft"})).unwrap(); + save_now(&mut s); + let id = s.recovery_documents()[0].id.clone(); + confine(Some(&roots), || { + // A command cannot use a file path or choose a different storage folder. + for bad in [original.to_string_lossy().into_owned(), "../original.txt".into()] { + for action in ["restore", "discard"] { + let err = s.run("file.recover", &json!({action: bad, "root": files.0})).unwrap_err(); + assert!(err.to_string().contains("no such recovery copy"), "{err}"); + } + } + assert_eq!(s.recovery_documents().len(), 1); + s.run("file.recover", &json!({"restore": id})).unwrap(); + settle(&mut s); + assert!(s.path.is_none()); + assert!(s.dirty); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Recoverable synthetic draft"); + s.run("file.recover", &json!({"discard": id})).unwrap(); + settle(&mut s); + assert!(s.recovery_documents().is_empty()); + }); + assert_eq!(fs::read(&original).unwrap(), b"External synthetic original"); + assert_eq!(fs::read_dir(&files.0).unwrap().count(), 1); + } + + #[test] + fn failed_write_keeps_last_valid_generation_and_discovery_falls_back() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "First valid copy")).unwrap(); + let first = store.entries[&id].path.clone(); + let bytes = fs::read(&first).unwrap(); + // Simulate a final rename failure without relying on permissions (root can bypass them). + let obstruction = t.0.join(&id).join("snapshot-00000000000000000002.json"); + fs::create_dir(&obstruction).unwrap(); + assert!(store.save(snapshot(&id, "Failed copy")).is_err()); + assert_eq!(fs::read(&first).unwrap(), bytes); + fs::remove_dir(obstruction).unwrap(); + store.save(snapshot(&id, "Second valid copy")).unwrap(); + let second = store.entries[&id].path.clone(); + fs::write(second, b"{truncated").unwrap(); + drop(store); + let mut reopened = Store::new(t.0.clone()).unwrap(); + assert!(reopened.scan().is_err()); + assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "First valid copy"); + // Never reuse the corrupt generation's name or prune the valid fallback. + reopened.save(snapshot(&id, "Third valid copy")).unwrap(); + assert_eq!(fs::read(first).unwrap(), bytes); + assert_eq!(Store::files(&t.0.join(&id)).unwrap().len(), 2); + } + + #[test] + fn size_limit_and_lossy_values_fail_without_committing() { + let mut out = Limited { inner: Vec::new(), remaining: 3 }; + assert!(out.write_all(b"four").is_err()); + assert!(out.inner.is_empty()); + let t = Temp::new(); + let id = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "valid")).unwrap(); + let first = store.entries[&id].path.clone(); + let bytes = fs::read(&first).unwrap(); + let mut invalid = snapshot(&id, "invalid"); + invalid.document.last_section.page_w = f32::NAN; + assert!(store.save(invalid).is_err()); + assert_eq!(fs::read(first).unwrap(), bytes); + assert_eq!(Store::files(&t.0.join(id)).unwrap().len(), 1); + } + + #[test] + fn failed_pruning_cannot_push_a_record_beyond_the_discovery_limit() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "Last valid copy")).unwrap(); + let first = store.entries[&id].path.clone(); + let bytes = fs::read(&first).unwrap(); + // A failed cleanup must not allow the next write to exceed the reader's bound. + for n in 2..=MAX_FILES { + fs::write(t.0.join(&id).join(format!("snapshot-{n:020}.json")), b"{truncated").unwrap(); + } + assert!(store.save(snapshot(&id, "New copy")).is_err()); + assert_eq!(fs::read(first).unwrap(), bytes); + drop(store); + let mut reopened = Store::new(t.0.clone()).unwrap(); + assert!(reopened.scan().is_err()); + assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "Last valid copy"); + } + + #[test] + fn failed_first_write_does_not_consume_a_record_slot() { + let t = Temp::new(); + let mut store = Store::new(t.0.clone()).unwrap(); + let mut invalid = snapshot(&new_id().unwrap(), "Synthetic draft"); + invalid.document.last_section.page_w = f32::NAN; + assert!(store.save(invalid).is_err()); + assert!(store.directories().unwrap().is_empty()); + } + + #[test] + fn discovery_reclaims_a_crashed_first_write_but_keeps_valid_records() { + let t = Temp::new(); + let id = new_id().unwrap(); + let empty = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "Valid synthetic draft")).unwrap(); + drop(store); + let dir = t.0.join(&empty); + private_dir(&dir).unwrap(); + fs::write(dir.join(format!("pending-{}.tmp", new_id().unwrap())), b"{unfinished").unwrap(); + let mut reopened = Store::new(t.0.clone()).unwrap(); + reopened.scan().unwrap(); + assert!(!dir.exists()); + assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "Valid synthetic draft"); + } + + #[test] + fn live_records_are_locked_and_discard_releases_storage() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut owner = Store::new(t.0.clone()).unwrap(); + owner.save(snapshot(&id, "live")).unwrap(); + let mut other = Store::new(t.0.clone()).unwrap(); + other.scan().unwrap(); + assert!(other.inventory().is_empty()); + assert!(other.discard(&id).is_err()); + assert!(other.discard("../outside").is_err()); + drop(owner); + other.scan().unwrap(); + assert_eq!(other.inventory().len(), 1); + other.discard(&id).unwrap(); + assert!(!t.0.join(id).exists(), "discard must release the record slot"); + } + + #[test] + fn new_draft_restarts_restores_unsaved_and_explicitly_discards() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Unsaved synthetic draft"})).unwrap(); + save_now(&mut s); + assert!(s.recovery_wait().is_none(), "unchanged documents must not schedule idle work"); + let expected = s.doc.clone(); + drop(s); + let mut s = session(&t.0); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + settle(&mut s); + assert_eq!(s.doc, expected); + assert!(s.dirty && s.path.is_none()); + s.run("file.recover", &json!({"discard": id})).unwrap(); + settle(&mut s); + save_now(&mut s); + assert!(s.recovery_documents().is_empty(), "discard isn't undone by the next timer"); + assert_eq!(s.doc, expected, "discard deletes the copy, not the editing document"); + } + + #[test] + fn existing_file_is_unchanged_and_successful_save_removes_copy() { + let t = Temp::new(); + let file = t.0.join("original.docx"); + let root = t.0.join("profile"); + crate::io::save_path(&file, &Document::from_text("Original business note")).unwrap(); + let original = fs::read(&file).unwrap(); + let mut s = session(&root); + s.run("file.open", &json!({"path": file})).unwrap(); + s.run("text.insert", &json!({"text": "Unsaved edit. "})).unwrap(); + save_now(&mut s); + let expected = s.doc.clone(); + drop(s); + let mut s = session(&root); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + settle(&mut s); + assert_eq!(s.doc, expected); + assert!(s.path.is_none() && s.dirty); + assert_eq!(fs::read(&file).unwrap(), original); + let saved = t.0.join("recovered.json"); + s.run("file.save", &json!({"path": saved})).unwrap(); + settle(&mut s); + assert!(s.recovery_documents().is_empty()); + assert_eq!(fs::read(file).unwrap(), original); + } + + #[test] + fn password_barrier_clears_in_flight_plaintext_and_blocks_new_copies() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Confidential synthetic draft"})).unwrap(); + s.recovery.as_mut().unwrap().due = Instant::now(); + s.recovery_tick(); + assert!(s.recovery_busy()); + s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); + assert!(s.recovery_documents().is_empty()); + s.run("text.insert", &json!({"text": "More confidential edits"})).unwrap(); + save_now(&mut s); + assert!(fs::read_dir(&t.0).unwrap().next().is_none()); + // Removing protection makes future recovery possible again. + s.run("file.encrypt", &json!({"password": null})).unwrap(); + save_now(&mut s); + assert_eq!(s.recovery_documents().len(), 1); + } + + #[test] + fn failed_password_cleanup_rejects_protection_and_preserves_snapshot() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Synthetic text"})).unwrap(); + save_now(&mut s); + let id = s.recovery_documents()[0].id.clone(); + let dir = t.0.join(id); + // An unknown file prevents removal: don't report that plaintext was purged. + let first = Store::files(&dir).unwrap()[0].clone(); + let bytes = fs::read(&first).unwrap(); + fs::write(dir.join("unexpected"), b"unexpected").unwrap(); + assert!(s.run("file.encrypt", &json!({"password": "synthetic-password"})).is_err()); + assert!(s.password.is_none()); + assert_eq!(fs::read(first).unwrap(), bytes); + } + + #[test] + fn delayed_restore_cannot_replace_new_edits() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); + save_now(&mut s); + drop(s); + let mut s = session(&t.0); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + s.run("text.insert", &json!({"text": "New work"})).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "New work"); + assert!(s.recovery_error().unwrap().contains("document changed")); + } + + #[test] + fn opening_protected_file_and_password_save_never_leave_plaintext_copies() { + let t = Temp::new(); + let original = t.0.join("protected.docx"); + let root = t.0.join("profile"); + crate::io::save_path_with( + &original, + &Document::from_text("Synthetic confidential note"), + Some("synthetic-password"), + &crate::io::ExportOptions::default(), + ) + .unwrap(); + let bytes = fs::read(&original).unwrap(); + let mut s = session(&root); + s.run("file.open", &json!({"path": original, "password": "synthetic-password"})).unwrap(); + s.run("text.insert", &json!({"text": "Confidential edit"})).unwrap(); + save_now(&mut s); + assert!(fs::read_dir(&root).unwrap().next().is_none()); + assert_eq!(fs::read(&original).unwrap(), bytes); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "New confidential draft"})).unwrap(); + save_now(&mut s); + assert_eq!(s.recovery_documents().len(), 1); + let saved = t.0.join("saved.docx"); + s.run("file.save", &json!({"path": saved, "password": "synthetic-password"})).unwrap(); + settle(&mut s); + assert!(fs::read_dir(root).unwrap().next().is_none()); + assert_eq!(fs::read(original).unwrap(), bytes); + } + + #[test] + fn protection_after_document_switch_does_not_discard_another_draft() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + save_now(&mut s); + s.run("file.new", &json!({})).unwrap(); + // Protection can be commanded before the host's next recovery tick. + s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); + settle(&mut s); + assert_eq!(s.recovery_documents().len(), 1); + } +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 5d6aacaae..fc1f4af47 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -449,6 +449,8 @@ pub struct Session { /// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected /// file that was opened, cleared when the document is replaced. pub password: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) recovery: Option, /// More pictures and shapes selected along with the one the selection holds (Shift+click), /// for Group. Cleared by any edit or selection change other than adding to it. pub also_selected: Vec, @@ -641,6 +643,8 @@ impl Session { editing_lang: None, read_aloud: Default::default(), password: None, + #[cfg(not(target_arch = "wasm32"))] + recovery: None, column: None, column_mode: false, widened: None, @@ -1088,6 +1092,8 @@ impl Session { math_normal_text: _, // The save password, like the file path: not an edit to the document. password: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, also_selected: _, // Column selection and a widened selection's ends, like `sel`'s shape: valid only while // `sel` matches them. diff --git a/crates/engine/tests/recovery_format.rs b/crates/engine/tests/recovery_format.rs new file mode 100644 index 000000000..38f372ac7 --- /dev/null +++ b/crates/engine/tests/recovery_format.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use wordcraft_doc::graphic::{Embedded, EmbeddedRef, Graphic}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Block, Document, InlineObject}; + +/// Original synthetic model data: sample formatting/tables/footer plus picture and source parts. +fn complete_document() -> Document { + let mut doc = wordcraft_engine::sample::sample_document(); + let picture = Arc::new(vec![0, 1, 127, 128, 254, 255]); + doc.media.insert("synthetic.png".into(), picture); + doc.passthrough.insert("word/embeddings/synthetic.bin".into(), Arc::new(vec![9, 0, 255, 7])); + let source = Arc::new(Embedded { + xml: "".into(), + refs: vec![EmbeddedRef { + id: "r1".into(), + kind: "urn:synthetic:embedding".into(), + target: "word/embeddings/synthetic.bin".into(), + external: false, + }], + }); + let Block::Para(p) = Arc::make_mut(doc.body.first_mut().unwrap()) else { panic!("sample starts with a paragraph") }; + p.insert_object( + 0, + InlineObject::Image { + media: "synthetic.png".into(), + w: 12.0, + h: 12.0, + alt: "Original synthetic picture".into(), + float: Default::default(), + crop: Default::default(), + ole: Some(source.clone()), + }, + &CharProps::default(), + ) + .unwrap(); + p.insert_object( + 0, + InlineObject::Graphic { + w: 24.0, + h: 18.0, + alt: "Original synthetic chart".into(), + float: Default::default(), + graphic: Arc::new(Graphic { source: Some(source), ..Default::default() }), + }, + &CharProps::default(), + ) + .unwrap(); + doc +} + +#[test] +fn native_json_preserves_the_entire_document() { + let doc = complete_document(); + let bytes = wordcraft_engine::io::save_bytes("recovery.json", &doc).unwrap(); + let restored = wordcraft_engine::io::open_bytes("recovery.json", &bytes).unwrap(); + assert_eq!(restored.media, doc.media, "picture bytes must survive"); + assert_eq!(restored.passthrough, doc.passthrough, "opaque parts must survive"); + assert_eq!(restored, doc, "object markup and relationships, stories and formatting must survive"); +} + +#[test] +fn existing_native_json_without_byte_stores_still_opens() { + let mut value = serde_json::to_value(Document::from_text("Old native document")).unwrap(); + value.as_object_mut().unwrap().remove("media"); + value.as_object_mut().unwrap().remove("passthrough"); + let doc = wordcraft_engine::io::open_bytes("old.json", &serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body), "Old native document"); +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index c13e9dd32..16991759b 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -6,11 +6,12 @@ use serde_json::json; use crate::theme::{APP_COLOR, Tokens, medium, regular, semibold}; use crate::{WordApp, icons}; -const PAGES: [(&str, &str); 10] = [ +const PAGES: [(&str, &str); 11] = [ ("home", "Home"), ("new", "New"), ("open", "Open"), ("info", "Info"), + ("recover", "Recover"), ("save", "Save"), ("saveAs", "Save As"), ("print", "Print"), @@ -21,6 +22,7 @@ const PAGES: [(&str, &str); 10] = [ pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + let recovery = app.ui.backstage_page == "recover"; // No document open (#1129): only the pages that open or create one, and Options. let has_document = app.has_document(); if !has_document && !works_without_document(&app.ui.backstage_page) { @@ -31,7 +33,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .exact_size(200.0) // Fixed width, as in Word: without this egui shows a resize handle that does nothing (#502). .resizable(false) - .frame(egui::Frame::NONE.fill(APP_COLOR).inner_margin(egui::Margin { left: 0, right: 0, top: if mac { 0 } else { 12 }, bottom: 12 })) + .frame(egui::Frame::NONE.fill(if recovery { egui::Color32::BLACK } else { APP_COLOR }).inner_margin(egui::Margin { + left: 0, + right: 0, + top: if mac { 0 } else { 12 }, + bottom: 12, + })) .show(ui, |ui| { let back = if mac { // macOS (#222): the Backstage covers the title bar, so its top band is the title @@ -128,22 +135,79 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } }); }); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| { - // The Print page fills the pane and scrolls its settings and its preview itself (#1156). - if app.ui.backstage_page == "print" { - export_page(app, ui); - return; - } - egui::ScrollArea::vertical().show(ui, |ui| match app.ui.backstage_page.as_str() { - "home" => home_page(app, ui), - "new" => new_page(app, ui), - "open" => open_page(app, ui), - "saveAs" => save_as_page(app, ui), - "info" => info_page(app, ui), - "print" | "export" => export_page(app, ui), - "options" => crate::options::page(app, ui), - _ => home_page(app, ui), + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(if recovery { egui::Color32::BLACK } else { t.ribbon }).inner_margin(egui::Margin::symmetric(40, 30))) + .show(ui, |ui| { + // The Print page fills the pane and scrolls its settings and its preview itself (#1156). + if app.ui.backstage_page == "print" { + export_page(app, ui); + return; + } + egui::ScrollArea::vertical().show(ui, |ui| match app.ui.backstage_page.as_str() { + "home" => home_page(app, ui), + "new" => new_page(app, ui), + "open" => open_page(app, ui), + "saveAs" => save_as_page(app, ui), + "info" => info_page(app, ui), + "recover" => recovery_page(app, ui), + "print" | "export" => export_page(app, ui), + "options" => crate::options::page(app, ui), + _ => home_page(app, ui), + }); }); +} + +/// Recovery is a separate copy. Leaving this page never discards it. +fn recovery_page(app: &mut WordApp, ui: &mut Ui) { + ui.visuals_mut().override_text_color = Some(egui::Color32::WHITE); + heading(ui, "Recover documents"); + ui.label(tl!("Restore opens an unsaved copy. Your original file stays unchanged.")); + ui.add_space(20.0); + if app.session.password.is_some() { + ui.label(tl!("Recovery is disabled while this document has a password.")); + } + if let Some(error) = app.session.recovery_error() { + ui.label(error); + } + if !app.session.recovery_ready() { + ui.label(tl!("Local recovery is unavailable or still loading.")); + } + let documents = app.session.recovery_documents(); + if documents.is_empty() && app.session.recovery_ready() { + ui.label(tl!("No recovery copies.")); + } + let busy = app.session.recovery_busy(); + egui::Grid::new("recovery_documents").min_col_width(180.0).spacing(vec2(24.0, 16.0)).show(ui, |ui| { + if !documents.is_empty() { + ui.strong(tl!("Document")); + ui.strong(tl!("Recovery copy")); + ui.end_row(); + } + for doc in documents { + ui.label(&doc.name); + ui.label(&doc.saved_at); + ui.add_enabled_ui(!busy, |ui| { + ui.horizontal(|ui| { + if ui.button(tl!("Restore")).clicked() { + let _ = app.run("file.recover", json!({"restore": doc.id})); + } + if ui.button(tl!("Discard…")).clicked() { + let _ = app.run("ui.recoveryDiscard", json!({"id": doc.id})); + } + }); + }); + ui.end_row(); + } + }); + ui.add_space(20.0); + ui.horizontal(|ui| { + if ui.button(tl!("Continue without restoring")).clicked() { + let _ = app.run("ui.backstage", json!({"value": false})); + app.canvas.want_focus = true; + } + if ui.add_enabled(!busy && app.session.recovery_ready(), egui::Button::new(tl!("Refresh"))).clicked() { + let _ = app.run("file.recover", json!({"refresh": true})); + } }); } @@ -421,6 +485,117 @@ mod tests { use super::matching_templates; use crate::{Services, WordApp}; + #[cfg(not(target_arch = "wasm32"))] + fn recovery_app() -> (WordApp, std::path::PathBuf) { + let root = std::env::temp_dir().join(format!( + "wordcraft-recovery-ui-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let id = "0123456789abcdef0123456789abcdef"; + let dir = root.join(id); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("snapshot-00000000000000000001.json"), + serde_json::to_vec(&json!({ + "version": 1, "generation": 1, "info": {"id": id, "name": "Synthetic draft", "savedAt": "2026-10-11T10:42:00Z"}, + "document": wordcraft_doc::Document::from_text("Recovered synthetic work") + })) + .unwrap(), + ) + .unwrap(); + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + app.session.enable_recovery(root.clone(), std::sync::Arc::new(|| {})).unwrap(); + (app, root) + } + + #[cfg(not(target_arch = "wasm32"))] + fn recovery_harness(app: WordApp) -> egui_kittest::Harness<'static, WordApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ) + } + + #[cfg(not(target_arch = "wasm32"))] + fn settle_recovery(h: &mut egui_kittest::Harness<'_, WordApp>) { + let end = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + h.run_steps(2); + if h.state().session.recovery_ready() && !h.state().session.recovery_busy() { + break; + } + assert!(std::time::Instant::now() < end); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + // Worker completion is a state boundary; egui needs subsequent frames to finish + // font installation/layout and expose the resulting buttons to AccessKit. + h.run_steps(4); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn recovery_page_keeps_dismissed_copies_and_confirms_discard() { + let (app, root) = recovery_app(); + let mut h = recovery_harness(app); + settle_recovery(&mut h); + assert!(h.state().ui.backstage && h.state().ui.backstage_page == "recover"); + h.get_by_label("Continue without restoring").click(); + h.run_steps(4); + assert!(!h.state().ui.backstage); + assert_eq!(h.state().session.recovery_documents().len(), 1); + h.state_mut().run("file.recover", json!({})).unwrap(); + h.run_steps(4); + h.get_by_label("Discard…").click(); + h.run_steps(4); + assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::DiscardRecovery { .. }))); + h.get_by_label("Cancel").click(); + h.run_steps(4); + assert_eq!(h.state().session.recovery_documents().len(), 1); + h.get_by_label("Discard…").click(); + h.run_steps(4); + h.get_by_label("Discard").click(); + settle_recovery(&mut h); + assert!(h.state().session.recovery_documents().is_empty()); + assert!(h.query_by_label("No recovery copies.").is_some()); + drop(h); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn recovery_restore_uses_save_changes_and_resets_autosave_permission() { + let (app, root) = recovery_app(); + let mut h = recovery_harness(app); + settle_recovery(&mut h); + h.state_mut().execute("text.insert", json!({"text": "Current unsaved work"})).unwrap(); + h.state_mut().autosave_path = Some(root.join("must-not-write.docx")); + h.state_mut().run("file.recover", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().session.recovery_documents().len(), 1); + h.get_by_label("Restore").click(); + h.run_steps(4); + assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); + h.get_by_label("Cancel").click(); + h.run_steps(4); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Current unsaved work"); + h.get_by_label("Restore").click(); + h.run_steps(4); + h.get_by_label("Don't Save").click(); + settle_recovery(&mut h); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Recovered synthetic work"); + assert!(h.state().session.dirty && h.state().session.path.is_none()); + assert!(h.state().autosave_path.is_none()); + assert!(!h.state().ui.backstage); + assert!(!root.join("must-not-write.docx").exists()); + drop(h); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn template_search_matches_labels_case_insensitively() { let ids = |q: &str| matching_templates(q).into_iter().map(|(_, id)| id).collect::>(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 965f79a94..5854d4eaa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -366,6 +366,11 @@ pub enum Dialog { About { tab: u8, }, + /// Confirm before deleting a recovery copy. + DiscardRecovery { + id: String, + name: String, + }, /// "Do you want to save changes?" before a user's New, Open, Close, Envelopes, Labels or /// Finish & Merge replaces the document (see [`WordApp::run`]); `then` runs once it is /// answered (`ui.saveChanges`), and only while the document it asked about @@ -976,6 +981,7 @@ impl Dialog { Dialog::PasteSpecial { .. } => "pasteSpecial", Dialog::About { .. } => "about", Dialog::SaveChanges { .. } => "saveChanges", + Dialog::DiscardRecovery { .. } => "discardRecovery", Dialog::RecipientList { .. } => "recipientList", Dialog::SelectTable { .. } => "selectTable", Dialog::InsertMergeField { .. } => "insertMergeField", @@ -1438,6 +1444,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::PasteSpecial { .. } => "Paste Special", Dialog::About { .. } => "About WordCraft", Dialog::SaveChanges { .. } => "WordCraft", + Dialog::DiscardRecovery { .. } => "Discard recovery copy", Dialog::RecipientList { .. } => "Recipient List", Dialog::SelectTable { .. } => "Select Table", Dialog::InsertMergeField { .. } => "Insert Merge Field", @@ -2596,6 +2603,32 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, tl!("Close")); ok || cancel } + Dialog::DiscardRecovery { name, .. } => { + ui.label(crate::i18n::fmt(tl!("Discard the recovery copy of {name}?"), &[("name", name)])); + ui.label(tl!("This cannot be undone. Your original file stays unchanged.")); + let answer = ui + .horizontal(|ui| { + let cancel = ui.button(tl!("Cancel")); + let discard = ui.button(tl!("Discard")); + if cancel.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + Some("cancel") + } else if discard.clicked() { + Some("discard") + } else { + None + } + }) + .inner; + if let Some(answer) = answer { + app.dialog = Some(d.clone()); + let result = app.run("ui.recoveryDiscard", json!({"answer": answer})); + if let Err(e) = &result { + app.status(e.clone()); + } + return result.is_ok(); + } + false + } Dialog::SaveChanges { name, message, .. } => { ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Do you want to save changes to {name}?"), &[("name", name)])).font(semibold(15.0))); ui.label(tl!("Your changes will be lost if you don't save them.")); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 919b6c140..8be7cc6d2 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2560,3 +2560,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Файловете .aff и .dic на Hunspell от тази папка (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) се използват преди системните. {language}: {file} {language}: {file} {language}: no dictionary found {language}: не е намерен речник + +# Backstage › Recover (#586) + Recover Възстановяване diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9c0a4d3a..4bedf1496 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2564,3 +2564,6 @@ preview Title Ukázka Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Soubory Hunspell .aff a .dic z této složky (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) mají přednost před systémovými. {language}: {file} {language}: {file} {language}: no dictionary found {language}: slovník nebyl nalezen + +# Backstage › Recover (#586) + Recover Obnovit diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e9e89628b..edf9028c4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2172,3 +2172,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Hunspell-Dateien (.aff und .dic) in diesem Ordner (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) haben Vorrang vor denen des Systems. {language}: {file} {language}: {file} {language}: no dictionary found {language}: kein Wörterbuch gefunden + +# Backstage › Recover (#586) + Recover Wiederherstellen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 75ea9f1a7..53179f99f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2539,3 +2539,6 @@ preview Title Título Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Los archivos .aff y .dic de Hunspell de esta carpeta (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) se usan antes que los del sistema. {language}: {file} {language}: {file} {language}: no dictionary found {language}: no se encontró ningún diccionario + +# Backstage › Recover (#586) + Recover Recuperar diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 48c4012bd..8bf2e0940 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2207,3 +2207,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Selle kausta Hunspelli .aff- ja .dic-failid (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) on eelistatud süsteemi omadele. {language}: {file} {language}: {file} {language}: no dictionary found {language}: sõnastikku ei leitud + +# Backstage › Recover (#586) + Recover Taasta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4179c60a1..532e760d8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2180,3 +2180,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Les fichiers Hunspell .aff et .dic de ce dossier (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) sont utilisés avant ceux du système. {language}: {file} {language} : {file} {language}: no dictionary found {language} : aucun dictionnaire trouvé + +# Backstage › Recover (#586) + Recover Récupérer diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e24ceb52d..76f551e85 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2568,3 +2568,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Az ebben a mappában lévő Hunspell .aff és .dic fájlok (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) elsőbbséget élveznek a rendszer fájljaival szemben. {language}: {file} {language}: {file} {language}: no dictionary found {language}: nem található szótár + +# Backstage › Recover (#586) + Recover Helyreállítás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 58de2c67b..9bd74bcb0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2555,3 +2555,6 @@ preview Title Esempio Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. I file Hunspell .aff e .dic in questa cartella (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) hanno la precedenza su quelli di sistema. {language}: {file} {language}: {file} {language}: no dictionary found {language}: nessun dizionario trovato + +# Backstage › Recover (#586) + Recover Ripristina diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1f8644159..73431e748 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2173,3 +2173,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. このフォルダーの Hunspell .aff と .dic ファイル (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) は、システムのものより優先して使われます。 {language}: {file} {language}: {file} {language}: no dictionary found {language}: 辞書が見つかりません + +# Backstage › Recover (#586) + Recover 回復 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f88ecfeac..88b461046 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2562,3 +2562,6 @@ preview Title Tytuł Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Pliki Hunspell .aff i .dic z tego folderu (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) mają pierwszeństwo przed systemowymi. {language}: {file} {language}: {file} {language}: no dictionary found {language}: nie znaleziono słownika + +# Backstage › Recover (#586) + Recover Odzyskaj diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index adaf10cae..34532909d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2185,3 +2185,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Os arquivos .aff e .dic do Hunspell nesta pasta (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) são usados antes dos do sistema. {language}: {file} {language}: {file} {language}: no dictionary found {language}: nenhum dicionário encontrado + +# Backstage › Recover (#586) + Recover Recuperar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e1b4e21ef..eb2db0d9d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2570,3 +2570,6 @@ preview Title Пример Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Файлы Hunspell .aff и .dic из этой папки (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) используются раньше системных. {language}: {file} {language}: {file} {language}: no dictionary found {language}: словарь не найден + +# Backstage › Recover (#586) + Recover Восстановить diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 285c816ce..291e74168 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2183,3 +2183,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Hunspell .aff i .dic datoteke iz ove fascikle (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) koriste se pre sistemskih. {language}: {file} {language}: {file} {language}: no dictionary found {language}: rečnik nije pronađen + +# Backstage › Recover (#586) + Recover Oporavi diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 18cc9c012..31b48531f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2187,3 +2187,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Hunspell .aff и .dic датотеке из ове фасцикле (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) користе се пре системских. {language}: {file} {language}: {file} {language}: no dictionary found {language}: речник није пронађен + +# Backstage › Recover (#586) + Recover Опорави diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 28074a26d..fc7a8ffef 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2569,3 +2569,6 @@ preview Title Örnek Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Bu klasördeki Hunspell .aff ve .dic dosyaları (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) sistemdekilerden önce kullanılır. {language}: {file} {language}: {file} {language}: no dictionary found {language}: sözlük bulunamadı + +# Backstage › Recover (#586) + Recover Kurtar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f9acf8cf6..728845711 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2185,3 +2185,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Файли Hunspell .aff і .dic із цієї папки (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) використовуються раніше за системні. {language}: {file} {language}: {file} {language}: no dictionary found {language}: словник не знайдено + +# Backstage › Recover (#586) + Recover Відновити diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c5fd6e2cd..21cdc863e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2596,3 +2596,6 @@ Sort by Sắp xếp theo Then by Sau đó theo {count} of {total} recipients will be merged. Sẽ phối {count} trên {total} người nhận. + +# Backstage › Recover (#586) + Recover Khôi phục diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 32dcfc08f..18f36717f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2185,3 +2185,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. 此文件夹中的 Hunspell .aff 和 .dic 文件 (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) 优先于系统中的文件。 {language}: {file} {language}:{file} {language}: no dictionary found {language}:未找到词典 + +# Backstage › Recover (#586) + Recover 恢复 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5d6417261..aa8c95734 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2174,3 +2174,6 @@ Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. 此資料夾中的 Hunspell .aff 與 .dic 檔案 (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) 優先於系統中的檔案。 {language}: {file} {language}:{file} {language}: no dictionary found {language}:找不到字典 + +# Backstage › Recover (#586) + Recover 復原 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 49657a210..a3608f4f4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -521,6 +521,7 @@ pub struct WordApp { /// keystroke, so one visit's keystrokes make a single undo step (and anything else in /// between, such as an Undo, starts a new one). pub(crate) info_editing: Option<(&'static str, u64)>, + recovery_seen: bool, /// Started on the Start screen with nothing to open (#1129): the id of the placeholder /// blank document the session holds until the user creates or opens one. While it is still /// the session's document, no document is open: the window shows no page and only the File @@ -618,6 +619,7 @@ impl WordApp { #[cfg(not(target_arch = "wasm32"))] file_browser: file_browser::State::default(), info_editing: None, + recovery_seen: false, no_document: None, } } @@ -713,6 +715,10 @@ impl WordApp { /// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask /// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`). pub fn run(&mut self, id: &str, params: Value) -> Result { + if id == "file.recover" && params.as_object().is_none_or(|p| p.is_empty()) { + self.ui.backstage = true; + self.ui.backstage_page = "recover".into(); + } // A button whose command needs input the user hasn't given yet opens its dialog (#240). if let Some(name) = input_dialog(id, ¶ms) && widgets::enabled(self, id) @@ -1363,6 +1369,36 @@ impl WordApp { let lang = i18n::Lang::from_pref(&self.ui.language); json!({"language": self.ui.language, "effective": lang.code(), "available": i18n::Lang::all().map(|l| json!({"code": l.code(), "name": l.name()})).collect::>()}) } + "ui.recoveryDiscard" => { + if let Some(answer) = s("answer") { + let Some(dialogs::Dialog::DiscardRecovery { id, .. }) = self.dialog.clone() else { + return Some(Err("no recovery discard is pending".into())); + }; + match answer { + "cancel" => { + self.dialog = None; + json!({"discarded": false}) + } + "discard" => { + let result = self.execute("file.recover", json!({"discard": id})); + if result.is_ok() { + self.dialog = None; + } + return Some(result); + } + _ => return Some(Err("`answer` must be discard or cancel".into())), + } + } else { + let Some(id) = s("id") else { + return Some(Err("`id` is required".into())); + }; + let Some(info) = self.session.recovery_documents().into_iter().find(|d| d.id == id) else { + return Some(Err("no such recovery copy".into())); + }; + self.dialog = Some(dialogs::Dialog::DiscardRecovery { id: info.id, name: info.name }); + json!({"pending": "discardRecovery"}) + } + } "ui.saveChanges" => { let Some(choice) = s("answer").and_then(SaveChoice::parse) else { return Some(Err("`answer` must be save, dontSave or cancel".into())); @@ -1673,6 +1709,24 @@ impl WordApp { self.drain_inbox(); self.drain_pasted(); self.watch_fonts(ctx); + let document = self.session.document_id(); + self.session.recovery_tick(); + if self.session.document_id() != document { + self.autosave_path = None; + self.dialog = None; + self.ui.backstage = false; + self.canvas.want_focus = true; + } + if !self.recovery_seen && self.session.recovery_ready() { + self.recovery_seen = true; + if !self.session.recovery_documents().is_empty() && !self.session.dirty { + self.ui.backstage = true; + self.ui.backstage_page = "recover".into(); + } + } + if let Some(wait) = self.session.recovery_wait() { + ctx.request_repaint_after(wait); + } self.autosave_tick(now_ms()); if self.autosaves() && self.session.dirty { ctx.request_repaint_after(std::time::Duration::from_millis(1000)); @@ -2218,6 +2272,7 @@ fn discards_document(id: &str, params: &Value) -> bool { "file.new" | "file.close" | "mailings.labels" => true, "mailings.envelopes" => params.get("add").and_then(Value::as_bool) != Some(true), "file.open" => params.get("path").is_some(), + "file.recover" => params.get("restore").is_some(), "mailings.finish" => params.get("path").and_then(Value::as_str).is_none(), _ => false, } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da84d8c82..c1f56112d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -18,7 +18,8 @@ connection (so an HTTP request sent to the port can't run a command); reconnect Any local program (and possibly a web page) can reach a loopback port, so every request needs the window's key. Once the port is bound, the app writes a random key (256 bits, as hex) to `/control-key.` and removes the file when the window closes; a new start -writes a new key. `` is the folder that holds the preferences: on Linux +writes a new key. `WORDCRAFT_PROFILE_DIR` overrides the whole profile folder for isolated runs, +including recovery copies. See [local recovery](recovery.md). `` is the folder that holds the preferences: on Linux `$XDG_CONFIG_HOME/wordcraft` (by default `~/.config/wordcraft`), on macOS `~/Library/Application Support/WordCraft`, on Windows `%APPDATA%\WordCraft`. `` is the Flatpak instance id inside Flatpak, and `local-` otherwise. On Unix the file has mode 0600. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 16db3cf16..f86b3b930 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -255,6 +255,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` | | 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) | | 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | +| 2026-10-12 | minor | Native JSON now preserves media, opaque parts and embedded source markup/relationships for lossless recovery of the document model. Older JSON still opens; [recovery limits](recovery.md). | | 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) | | 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | | 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed | diff --git a/docs/gaps.md b/docs/gaps.md index e4b4aa74d..0f4125a7e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -11,6 +11,10 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees). +Local desktop recovery now covers unsaved drafts and edits to existing files; see +[recovery.md](recovery.md). This narrows gap #3 but does not resolve the crash/hang reports or +establish cross-platform reliability. + Document renaming: desktop renaming requires a filesystem that supports hard links (exFAT/FAT drives and some network shares refuse it). Behaviour: [UI parity](ui-parity.md). ## Ranked @@ -141,6 +145,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | | 2026-10-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged | +| 2026-10-12 | minor | Gap #3 narrowed: local desktop recovery snapshots, discovery, unsaved restore and confirmed discard are implemented. Password sessions skip plaintext copies. Crash prevention, Windows field testing and soak testing remain; readiness estimates unchanged. | | 2026-10-11 | minor | #4: body/header contextual spacing corrected; ordinary overlap remains #108 and pagination remains a beta gap | | 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth | | 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h | diff --git a/docs/recovery.md b/docs/recovery.md new file mode 100644 index 000000000..00ce84506 --- /dev/null +++ b/docs/recovery.md @@ -0,0 +1,85 @@ +# Local document recovery + +Desktop WordCraft periodically keeps a local copy of unsaved work, including new drafts. +File › Recover lists available copies. The page opens at startup when copies are found. +Restore opens an unsaved document with no file path. Save As chooses where to keep it. +Recovery never writes to the original document. AutoSave's permission to write to an earlier +file is cleared on restore. + +Continue without restoring leaves copies available. Discard asks for confirmation and deletes +the selected copy, including its retained generation. It does not delete the editing document. +After discarding that document's copy, another copy is made only after a further edit. +A successful save removes that document's recovery copy on the next worker cycle while it +remains open. Copies left after switching documents can still be explicitly discarded. + +Documents with a password do not get plaintext recovery copies. Applying a password waits for +any pending snapshot, removes the current document's recovery generations, and fails if cleanup +fails. Encrypted recovery is not implemented. Documents opened with a password keep recovery +disabled for that session. Removing the password allows future recovery again. + +## Storage and timing + +Copies live in `recovery/` under the app's profile folder. `WORDCRAFT_PROFILE_DIR` selects a +separate profile for isolated runs, including preferences, logs and control keys. +`WORDCRAFT_NO_PREFS` disables recovery as well as preferences. Headless engine/CLI sessions +and the browser do not create recovery files implicitly. + +The desktop host checks for a dirty document every frame but snapshots at most once per ten +seconds, and only when its revision changes. It clones the shared document model for a bounded +worker queue. Encoding, file I/O, full-document validation and discovery run on the worker. +An unchanged document does not schedule repeated recovery repaints. The most recent edits can +be lost if a crash happens before the next snapshot or while its write is still pending. + +Version 1 snapshots wrap the native JSON document with an id, document name, timestamp and +monotonic generation. Native JSON now includes media bytes, opaque preserved parts, and +chart/SmartArt/OLE source markup and relationships. Older JSON without these fields still opens. +Paragraph cache revisions are transient and regenerated on load; undo history and selection are +not part of the document snapshot. Recovery preserves the current in-memory model. It cannot +recover content already lost during import or make later export more faithful than the exporter. + +Each generation is written exclusively to a temporary file, flushed and synchronized, decoded +and compared with the source document, then renamed to a new generation. The last valid +snapshot is retained until its successor is valid. Discovery falls back to an older valid copy +if the newest is corrupt and reports unreadable files. Abandoned temporary files are cleaned +only after acquiring that record's lock. Native file locks prevent other running instances from +restoring or deleting a live record. Unix folders/files use modes 0700/0600; Windows uses the +profile's inherited permissions. Directory synchronization runs on Unix; Windows synchronizes +the file but does not yet explicitly synchronize directory metadata. Power-loss durability on +Windows is therefore unverified. + +Limits are 256 MiB of encoded JSON per snapshot, 2 GiB of snapshot files per profile, 128 records +and 16 generations per record during discovery. Ordinarily only two valid generations remain. +Copies are retained until saved or explicitly discarded; there is no automatic age-based deletion. +Over-limit writes report an error and retain the previous valid copy. JSON stores binary parts +as byte arrays, so it can be substantially larger than DOCX. This first version has a fixed +interval, English fallback for new labels, and no cloud, version-history or browser recovery. + +## Commands and verification + +`file.recover={}` reads cached `{enabled,busy,documents,error}`. Each document has `id`, `name` +and `savedAt`. `{"refresh":true}`, `{"restore":"id"}` and `{"discard":"id"}` queue worker +operations. Poll until `busy` is false; inspect `error` for failure. A restore is cancelled if the +document id or revision changes while it loads. Programmatic restores do not open dialogs; +user restores over dirty work use the existing Save Changes prompt. +`ui.recoveryDiscard={"id":"id"}` opens confirmation; `{"answer":"discard"}` or +`{"answer":"cancel"}` answers it. + +Focused tests are `cargo test -p wordcraft-engine recovery`, +`cargo test -p wordcraft-engine --test recovery_format`, and +`cargo test -p wordcraft-ui-egui recovery`. They cover lifecycle, preservation, original-file +protection, failed writes, corruption, locks, password cleanup and the actual recovery buttons. +The contribution also uses a native SIGKILL/restart replay in temporary profiles for both new +drafts and edited existing files. It uses synthetic inputs and hashes the original before and +after. No Microsoft Word operation is needed for this reliability test. + +## Maintenance validation + +The 2026-10-12 rebase retains main’s editable graphic models, native font setup, current +options module, fixed-width Backstage and filename-aware Save As. The SIGKILL/restart +observations above belong to the original contribution, not a rerun on this branch. Current +validation executes the focused lifecycle and preservation tests in temporary profiles and +all repository CI gates; no daily-driver recovery folder or native app is operated. + +The desktop host chooses the private recovery profile. Recovery commands accept known +snapshot IDs, never file paths or a profile override. This internal store is separate from +the automation roots that constrain opening and saving user documents. diff --git a/docs/roadmap.md b/docs/roadmap.md index eb3810e70..fa109655a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,7 +18,8 @@ Opus 5.5 agent wall-clock. graphics survive a round trip. Drawing-level native ink now keeps its InkML and optional picture fallback alongside chart, SmartArt, OLE and drawing-canvas support. 4. **Stability sweep** (gap #3, 20–30 h): the open crash, hang and write-failure issues, plus an - autosave/recovery soak test. + autosave/recovery soak test. Local desktop recovery is implemented; cross-platform field + validation and longer soak testing remain. See [recovery.md](recovery.md). 5. **Pagination fidelity** (gap #4, 30–50 h): column balancing, footnote continuation, compat options, a local page-by-page comparison harness; an owner decision on an Aptos-metric font. 6. **Formatting revisions and content controls** (gap #5, 12–18 h), **native printing** (gap #6, @@ -78,6 +79,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | 2026-10-11 | trivial | M3: icons landed (#309) | | 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) | | 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | +| 2026-10-12 | minor | Stability sweep: local desktop recovery implemented with original-file protection and focused lifecycle tests. Cross-platform field validation and longer soak testing remain. | | 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) | | 2026-10-11 | trivial | M7: formatting revisions landed (#41) | | 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 31466a5fc..8b6d9a08a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -193,7 +193,7 @@ Word. Their weighted sum is the **full ready for real work** number. | File formats (DOCX fidelity first) | 20% | 60% | 185–300 | [file-format-parity.md](file-format-parity.md) | DOCX opens in Word and round-trips our own tests, but no real-world corpus test yet; charts, SmartArt, OLE and encrypted files are lost or refused (content controls kept since #381) | | Layout and pagination fidelity | 15% | 55% | 90–145 | [layout-parity.md](layout-parity.md) | Aptos has no metric-matched substitute so lines break differently from Word; no column balancing, footnote continuation or document grid; compatibility modes beyond 15 partial | | UI/UX fidelity | 10% | 65% | 65–100 | [ui-parity.md](ui-parity.md) | Ribbon, keytips, mini-toolbar, Backstage, 125 shortcuts; 16 modal dialogs against Word's ~100; no ribbon/keyboard customization | -| Stability | 10% | 50% | 25–45 | [gaps.md](gaps.md) | Never-crash standard and hostile-param fuzzing in place, but open reports of a startup crash on Intel UHD (#170), freezes (#77, #59, #31) and Windows write failure without admin (#218); no soak test; 5 days of field history | +| Stability | 10% | 50% | 25–45 | [gaps.md](gaps.md) | Never-crash standard and hostile-param fuzzing in place, but open reports of a startup crash on Intel UHD (#170), freezes (#77, #59, #31) and Windows write failure without admin (#218); local desktop recovery with focused lifecycle tests, but no longer soak test; 5 days of field history | | Performance | 5% | 75% | 10–20 | [layout-parity.md](layout-parity.md) | 61 ms cold layout and 1.4 ms relayout on a 188-page document (measured 2026-10-06, not re-run); font picker freezes fixed in #233; no large real-world documents measured | | Localization | 4% | 27% | 75–120 + native review | [localization-parity.md](localization-parity.md) | 13 UI languages (Word 30); 4 of the 12 key languages partly done; English and Polish proofing | | Platforms | 3% | 95% | 5–10 | [ROADMAP.md](../ROADMAP.md) | macOS (universal, signed), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/Flatpak), FreeBSD, web. Beyond Word on Linux, BSD and the web; Word's iPad/iPhone/Android apps are out of scope | @@ -284,6 +284,7 @@ can be used without committing Word output. | 2026-10-11 | trivial | Shortcut count 101 → 125 (#330) | | 2026-10-11 | trivial | Equation fixes: removing numbers keeps every row (#444), caret moves keep imported OMML (#446), build-up on a caret move is an undoable edit (#447), LaTeX `\text{}` keeps its spaces (#448); no percentage change | | 2026-10-11 | trivial | English spelling (#520): common modern words ("desktop", "download", "email"…) recognised via our supplement list; Proofing row updated, no percentage change | +| 2026-10-12 | minor | Local desktop recovery implemented; original files are protected, password sessions skip plaintext recovery. Crash prevention and broader field/soak validation remain. No change to stability or readiness estimates. | | 2026-10-11 | trivial | References: all 12 of Word's bibliography styles (eight more: Harvard, ISO 690 ×2, Turabian, GB/T 7714, GOST ×2, SIST02) (#383); no percentage change | | 2026-10-11 | trivial | Mail merge fixes (#427–#430): SKIPIF drops matching records, NEXT advances later fields, IF previews, header/footer fields merge; Mailings row updated, no percentage change | | 2026-10-11 | trivial | Formatting revisions (#41): Review row updated; no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9b14de544..7481a34f2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -19,7 +19,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Not shown below the ribbon | customizable | 90% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | -| Modal dialogs | **55** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | +| Modal dialogs | **56** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | @@ -131,6 +131,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 2026-10-11 | minor | Keyboard shortcuts 101 → 125 commands (#330): Reset Paragraph/Character Formatting, symbols, DATE/TIME/PAGE fields, view switches, Word Count, mail merge, F4 Repeat now reachable | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | trivial | Dialogs keep their top edge in place when switching tabs (Paragraph, Font, Borders and Shading…): only the bottom edge moves (#507) | +| 2026-10-12 | minor | File › Recover page: startup discovery, unsaved restore through Save Changes, confirmed discard and keep-for-later dismissal. Desktop only; [details](recovery.md). No readiness percentage change. | | 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) | | 2026-10-11 | trivial | Font list ends with Add Font… (TTF, OTF, TTC, WOFF, WOFF2; also by dropping the file); added fonts are listed at once and kept between runs (#582) | | 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) |