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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rees
2026-10-14 17:21:57 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 92c0036d72
commit 3d8f8144d9
45 changed files with 1560 additions and 30 deletions
Generated
+1
View File
@@ -5238,6 +5238,7 @@ version = "0.7.0"
dependencies = [
"cfb",
"encoding_rs",
"getrandom 0.3.4",
"image",
"js-sys",
"log",
+2
View File
@@ -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) |
+8
View File
@@ -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")]
+4
View File
@@ -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<PathBuf> {
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) {
+3 -3
View File
@@ -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<std::sync::Arc<Embedded>>,
/// 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,
+2 -2
View File
@@ -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<String, Arc<Vec<u8>>>,
/// 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<String, Arc<Vec<u8>>>,
}
+1 -1
View File
@@ -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<std::sync::Arc<crate::graphic::Embedded>>,
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
+3
View File
@@ -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 }
+4
View File
@@ -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();
+2 -3
View File
@@ -411,9 +411,8 @@ pub fn specs() -> Vec<CommandSpec> {
})
.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")?;
+3
View File
@@ -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<PathBuf>) {
@@ -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;
}
+1
View File
@@ -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;
+135
View File
@@ -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<dyn Fn() + Send + Sync>) -> Result<(), String> {
self.recovery = Some(Recovery::start(root, wake)?);
Ok(())
}
pub fn recovery_documents(&self) -> Vec<RecoveryInfo> {
#[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<std::time::Duration> {
#[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()}),
)
}
}
+879
View File
@@ -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<String, Entry>,
// Advisory locks prevent another running instance from restoring/discarding a live copy.
locks: BTreeMap<String, File>,
}
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<File> {
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<String, String> {
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<u64> {
path.file_name()?.to_str()?.strip_prefix("snapshot-")?.strip_suffix(".json")?.parse().ok()
}
fn read_snapshot(path: &Path) -> Result<Snapshot, String> {
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<Self, String> {
private_dir(&root).map_err(|e| e.to_string())?;
Ok(Self { root, entries: BTreeMap::new(), locks: BTreeMap::new() })
}
fn directories(&self) -> Result<Vec<PathBuf>, 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<Vec<PathBuf>, 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<File, String> {
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<RecoveryInfo> {
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<u64, String> {
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<Snapshot, String> {
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<W> {
inner: W,
remaining: u64,
}
impl<W: Write> Write for Limited<W> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
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<Snapshot>),
Scan,
Load(String, (u64, u64)),
Discard(String),
}
enum Outcome {
Done,
Discarded(String),
Loaded(Box<Snapshot>, (u64, u64)),
}
struct Event {
documents: Vec<RecoveryInfo>,
outcome: Result<Outcome, String>,
}
pub(crate) struct Recovery {
tx: mpsc::SyncSender<Job>,
rx: mpsc::Receiver<Event>,
pub documents: Vec<RecoveryInfo>,
pub ready: bool,
pub busy: bool,
pub error: Option<String>,
document: Option<u64>,
id: Option<String>,
revision: Option<u64>,
due: Instant,
alive: bool,
}
impl Recovery {
pub fn start(root: PathBuf, wake: Arc<dyn Fn() + Send + Sync>) -> Result<Self, String> {
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<Duration> {
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);
}
}
+6
View File
@@ -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<crate::io::Password>,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) recovery: Option<crate::recovery::Recovery>,
/// 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<Pos>,
@@ -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.
+69
View File
@@ -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: "<synthetic r:id=\"r1\"/>".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");
}
+192 -17
View File
@@ -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::<Vec<_>>();
+33
View File
@@ -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."));
+3
View File
@@ -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 Възстановяване
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 460 and column 13.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -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 回復
Can't render this file because it contains an unexpected character in line 568 and column 13.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -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 Восстановить
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -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 Опорави
Can't render this file because it contains an unexpected character in line 11 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 441 and column 13.
+3
View File
@@ -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 Відновити
Can't render this file because it contains an unexpected character in line 6 and column 31.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -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 恢复
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -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 復原
Can't render this file because it contains an unexpected character in line 569 and column 13.
+55
View File
@@ -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<Value, String> {
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, &params)
&& 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::<Vec<_>>()})
}
"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,
}
+2 -1
View File
@@ -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
`<settings>/control-key.<instance>` and removes the file when the window closes; a new start
writes a new key. `<settings>` 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). `<settings>` 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`. `<instance>` is the
Flatpak instance id inside Flatpak, and `local-<port>` otherwise. On Unix the file has mode 0600.
+1
View File
@@ -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 |
+5
View File
@@ -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 |
+85
View File
@@ -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.
+3 -1
View File
@@ -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 |
+2 -1
View File
@@ -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 |
+2 -1
View File
@@ -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) |