collective

Browse / github.com/storytold/wordcraft / patch

feat(web): expose correlated native snapshots

Web host: request complete native snapshots without acknowledging Save Same-origin iframe hosts can open native JSON, but the existing save bridge follows Save/Save As semantics, including save metadata and clearing dirty on delivery, and only reports boolean dirty transitions. A host cannot request a full native snapshot without acknowledging Save or detect another edit while dirty is already true. Add correlated native snapshot requests to the existing opt-in host bridge and an opaque revision change notification. A snapshot should include pending comment/reply text through the existing Save preparation, validate its serialization with the native Document pars

Status
open
Score
0.00
Adopters
0 other operators
Author
Srbino (imported from pull request #4573, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+280 −10 in 7 files
Review
pending
Shared
2026-10-28
Id
0b00f4a2fecafe6c61da85a6

How it went

No other operator has reported using it yet.

Diff

raw · apps/wordcraft-web/src/host.rs apps/wordcraft-web/src/main.rs apps/wordcraft-web/src/snapshot.rs apps/wordcraft-web/src/web.rs crates/engine/src/tests.rs crates/ui-egui/src/app/frame.rs crates/ui-egui/src/panes/comments.rs

diff --git a/apps/wordcraft-web/src/host.rs b/apps/wordcraft-web/src/host.rs--- a/apps/wordcraft-web/src/host.rs+++ b/apps/wordcraft-web/src/host.rs@@ -18,12 +18,23 @@ //!   - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and //!     Export Page as PNG: `false`. They are copies; the document's file stays the same. //! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.+//! - `wordcraft:changed` (to the parent): `{ revision }` after every native document revision,+//!   including further edits while `dirty` stays true. Revision is an opaque string that also+//!   distinguishes replacing the document.+//! - `wordcraft:snapshot` (from the parent): `{ requestId }`, a nonempty string of at most 128+//!   UTF-8 bytes. Requests the complete native JSON. Pending comment/reply text is committed as+//!   for File Save; dirty state is never cleared and storage is not acknowledged.+//! - `wordcraft:snapshot-result` (to the parent): `{ requestId, revision, bytes }` (a transferred+//!   `ArrayBuffer` of native JSON), or `{ requestId, error }`. At most one request is pending;+//!   another receives an error. The native Document parser checks the serialized snapshot.+//!   Neither snapshot nor changed grants durable Saved. Reopen native bytes with `wordcraft:open`+//!   and a `.json` name. The host guards unsaved work before replacing a document. //! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start. //! //! Only the parent window on the page's own origin is heard or answered. In host mode the parent //! guards leaving the page, and `?author=` names the user for comments and tracked changes. -use std::cell::Cell;+use std::cell::{Cell, RefCell}; use std::rc::Rc;  use wasm_bindgen::JsCast as _;@@ -39,6 +50,8 @@ pub struct Bridge {     window: web_sys::Window,     parent: web_sys::Window,     origin: String,+    snapshot: Rc<RefCell<Option<String>>>,+    observed: Rc<RefCell<crate::snapshot::RevisionObserver>>, }  impl Bridge {@@ -60,7 +73,7 @@ impl Bridge {         if parent.location().origin().ok()? != origin {             return None;         }-        Some(Self { window, parent, origin })+        Some(Self { window, parent, origin, snapshot: Rc::default(), observed: Rc::default() })     }      /// Hand saved and exported files to the parent instead of downloading them, report unsaved@@ -119,11 +132,27 @@ impl Bridge {     fn listen(&self, inbox: Inbox, ctx: egui::Context) {         let parent = self.parent.clone();         let origin = self.origin.clone();+        let snapshot = self.snapshot.clone();+        let bridge = self.clone();         let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {             if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {                 return;             }             let data = event.data();+            if string(&data, "type").as_deref() == Some("wordcraft:snapshot") {+                let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };+                if snapshot.borrow().is_some() {+                    bridge.send(&[+                        ("type", "wordcraft:snapshot-result".into()),+                        ("requestId", id.into()),+                        ("error", "snapshot already pending".into()),+                    ]);+                    return;+                }+                *snapshot.borrow_mut() = Some(id);+                ctx.request_repaint();+                return;+            }             if string(&data, "type").as_deref() != Some("wordcraft:open") {                 return;             }@@ -138,6 +167,38 @@ impl Bridge {         }     } +    /// Report native revisions independently of dirty state, then answer a pending native+    /// snapshot request after the app has processed its inbox and commands. Pending comment/reply+    /// drafts are committed through the same commands as Save. Neither is a save+    /// acknowledgement; the host must confirm its own durable storage separately.+    pub fn observe(&self, app: &mut WordApp) {+        let pending = self.snapshot.borrow_mut().take();+        let prepared = if pending.is_some() { app.prepare_native_snapshot() } else { Ok(()) };+        let session = &app.session;+        let draft_revision = app.native_draft_revision();+        let revision = crate::snapshot::revision(session, draft_revision);+        if self.observed.borrow_mut().changed(session, draft_revision) {+            self.send(&[("type", "wordcraft:changed".into()), ("revision", revision.as_str().into())]);+        }+        let Some(id) = pending else { return };+        let result = prepared.and_then(|()| crate::snapshot::native_snapshot(session)).and_then(|bytes| {+            let buffer = js_sys::Uint8Array::from(bytes.as_slice()).buffer();+            let value = message(&[+                ("type", "wordcraft:snapshot-result".into()),+                ("requestId", id.as_str().into()),+                ("revision", revision.as_str().into()),+                ("bytes", buffer.clone().into()),+            ])+            .ok_or_else(|| "couldn't build the snapshot message".to_string())?;+            self.parent+                .post_message_with_transfer(&value, &self.origin, &js_sys::Array::of1(&buffer))+                .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))+        });+        if let Err(error) = result {+            self.send(&[("type", "wordcraft:snapshot-result".into()), ("requestId", id.as_str().into()), ("error", error.as_str().into())]);+        }+    }+     /// Post a message to the parent; false if it couldn't be sent.     fn send(&self, fields: &[(&str, JsValue)]) -> bool {         message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs--- a/apps/wordcraft-web/src/main.rs+++ b/apps/wordcraft-web/src/main.rs@@ -18,6 +18,9 @@ mod prefs; #[cfg(any(target_arch = "wasm32", test))] mod save_fallback; +#[cfg(any(target_arch = "wasm32", test))]+mod snapshot;+ #[cfg(target_arch = "wasm32")] mod host; #[cfg(target_arch = "wasm32")]diff --git a/apps/wordcraft-web/src/snapshot.rs b/apps/wordcraft-web/src/snapshot.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft-web/src/snapshot.rs@@ -0,0 +1,106 @@+//! Native JSON snapshots for a host that stores complete editor versions.++use wordcraft_engine::Session;++/// Serialize only the native model, without changing dirty state, undo or file identity.+/// Parsing our own output also rejects values (such as a non-finite required float) that+/// serialize but cannot be opened as a native Document.+pub fn native_snapshot(session: &Session) -> Result<Vec<u8>, String> {+    let bytes = wordcraft_engine::io::save_bytes("snapshot.json", &session.doc)?;+    let _: wordcraft_doc::Document = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;+    Ok(bytes)+}++/// An opaque token: include document identity, and keep u64 values out of JS numbers.+pub fn revision(session: &Session, draft_revision: u64) -> String {+    format!("{}:{}:{}", session.document_id(), session.rev(), draft_revision)+}++#[derive(Default)]+pub struct RevisionObserver(Option<(u64, u64, u64)>);++impl RevisionObserver {+    pub fn changed(&mut self, session: &Session, draft_revision: u64) -> bool {+        let current = (session.document_id(), session.rev(), draft_revision);+        if self.0 == Some(current) {+            return false;+        }+        self.0 = Some(current);+        true+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use wordcraft_doc::{InlineObject, Part, PartKind};+    use wordcraft_engine::Session;++    #[test]+    fn native_snapshot_preserves_complete_model_and_does_not_mark_saved() {+        let mut session = Session::new(wordcraft_engine::sample::sample_document());+        session.doc.parts.insert(+            600,+            Part {+                kind: PartKind::Comment,+                blocks: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Snapshot comment", Default::default()))],+                ..Default::default()+            },+        );+        session.doc.comments.insert(1, wordcraft_doc::Comment { author: "Tester".into(), part: 600, ..Default::default() });+        let image = session.doc.add_media(vec![71; 3_200_000], "png");+        let mut para = wordcraft_doc::Paragraph::with_text("\u{fffc}", Default::default());+        para.objects.push(InlineObject::Image {+            media: image,+            w: 10.,+            h: 10.,+            alt: "Large image".into(),+            float: Default::default(),+            crop: [0.; 4],+            ole: None,+        });+        session.doc.body.push(wordcraft_doc::para_block(para));+        session.run("text.insert", &serde_json::json!({"text":"Edited "})).unwrap();+        let document_id = session.document_id();+        let revision = session.rev();+        let undo_depth = session.undo_depth();+        let before = serde_json::to_value(&session.doc).unwrap();+        let bytes = native_snapshot(&session).unwrap();+        assert!(bytes.len() > 4 * 1024 * 1024);+        let reopened = wordcraft_engine::io::open_bytes("version.json", &bytes).unwrap();+        assert_eq!(serde_json::to_value(&reopened).unwrap(), before);+        assert_eq!(serde_json::to_value(&session.doc).unwrap(), before);+        assert!(session.dirty);+        assert_eq!(session.document_id(), document_id);+        assert_eq!(session.rev(), revision);+        assert_eq!(session.undo_depth(), undo_depth);+    }+    #[test]+    fn changes_report_every_revision_even_while_the_document_stays_dirty() {+        let mut session = Session::new(wordcraft_doc::Document::new());+        let mut observer = RevisionObserver::default();+        assert!(observer.changed(&session, 0));+        assert!(!observer.changed(&session, 0));+        session.run("text.insert", &serde_json::json!({"text":"first"})).unwrap();+        assert!(session.dirty);+        assert!(observer.changed(&session, 0));+        let first = revision(&session, 0);+        session.run("text.insert", &serde_json::json!({"text":"second"})).unwrap();+        assert!(session.dirty);+        assert!(observer.changed(&session, 0));+        assert_ne!(revision(&session, 0), first);+        assert!(!observer.changed(&session, 0));+        session.set_document(wordcraft_doc::Document::new());+        assert!(observer.changed(&session, 0));+    }+    #[test]+    fn pending_draft_changes_are_reported_without_serializing_the_document() {+        let session = Session::new(wordcraft_doc::Document::new());+        let mut observer = RevisionObserver::default();+        assert!(observer.changed(&session, 0));+        assert!(observer.changed(&session, 1));+        assert!(observer.changed(&session, 2));+        assert!(!observer.changed(&session, 2));+        assert_ne!(revision(&session, 1), revision(&session, 2));+    }+}diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs--- a/apps/wordcraft-web/src/web.rs+++ b/apps/wordcraft-web/src/web.rs@@ -99,7 +99,7 @@ pub fn start() {                         crate::host::Bridge::set_author(&mut app, &author);                         prefs.keep_own_author(&app.session.author, &own);                     }-                    Ok(Box::new(WebShell { app, inbox, messages, prefs }))+                    Ok(Box::new(WebShell { app, inbox, messages, prefs, bridge: app_bridge }))                 }),             )             .await;@@ -134,6 +134,7 @@ struct WebShell {     inbox: Inbox,     messages: Rc<RefCell<Vec<String>>>,     prefs: crate::prefs::PrefsSync,+    bridge: Option<crate::host::Bridge>, }  fn local_storage() -> Option<web_sys::Storage> {@@ -155,6 +156,9 @@ impl eframe::App for WebShell {             self.app.status(msg);         }         self.app.logic(ctx);+        if let Some(bridge) = &self.bridge {+            bridge.observe(&mut self.app);+        }         // Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).         if let Some(json) = self.prefs.changed(&self.app) {             match local_storage() {diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -10865,10 +10865,11 @@ fn ime_body_epoch_marks_body_replacements_not_edits() {     run(&mut s, "text.insert", json!({"text": " two"}));     assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement"); +    let document_id = s.document_id();     run(&mut s, "document.setText", json!({"text": "same text"}));     let replaced = s.ime_body_epoch();     assert!(replaced > first, "setText replaces the body under the same document id");-    assert_eq!(s.document_id(), 1, "the document id itself is unchanged");+    assert_eq!(s.document_id(), document_id, "the document id itself is unchanged");      // The same text again still replaces the body: a composition must not survive on content     // equality.diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs--- a/crates/ui-egui/src/app/frame.rs+++ b/crates/ui-egui/src/app/frame.rs@@ -20,6 +20,17 @@ impl WordApp {         }     } +    /// Commit pending comment/reply text as a normal save does, without acknowledging any+    /// storage or clearing dirty state. A refused draft stays local and returns an error.+    pub fn prepare_native_snapshot(&mut self) -> Result<(), String> {+        panes::flush_comment_drafts(self)+    }++    /// Changes to pending comment/reply text, independent of the document's dirty flag.+    pub fn native_draft_revision(&self) -> u64 {+        self.comment_drafts.revision_for(self.session.document_id())+    }+     /// Tell the host whether the document has unsaved changes ([`Services::on_dirty`]).     pub(super) fn report_dirty(&self) {         if let Some(f) = &self.services.on_dirty {diff --git a/crates/ui-egui/src/panes/comments.rs b/crates/ui-egui/src/panes/comments.rs--- a/crates/ui-egui/src/panes/comments.rs+++ b/crates/ui-egui/src/panes/comments.rs@@ -72,6 +72,7 @@ pub(crate) struct CommentDrafts {     document: u64,     edits: BTreeMap<u32, String>,     replies: BTreeMap<u32, String>,+    revision: u64, }  impl CommentDrafts {@@ -85,6 +86,37 @@ impl CommentDrafts {         self     } +    /// A cheap change token for unsaved text that is not in the native model yet.+    pub(crate) fn revision_for(&self, document: u64) -> u64 {+        if self.document == document { self.revision } else { 0 }+    }++    fn record_edit(&mut self, id: u32, text: String) {+        if self.edits.get(&id) != Some(&text) {+            self.edits.insert(id, text);+            self.revision = self.revision.wrapping_add(1);+        }+    }++    fn record_reply(&mut self, id: u32, text: String) {+        if self.replies.get(&id) != Some(&text) {+            self.replies.insert(id, text);+            self.revision = self.revision.wrapping_add(1);+        }+    }++    fn forget_edit(&mut self, id: u32) {+        if self.edits.remove(&id).is_some() {+            self.revision = self.revision.wrapping_add(1);+        }+    }++    fn forget_reply(&mut self, id: u32) {+        if self.replies.remove(&id).is_some() {+            self.revision = self.revision.wrapping_add(1);+        }+    }+     /// Nothing typed and not yet written.     pub(crate) fn is_empty(&self) -> bool {         self.edits.is_empty() && self.replies.is_empty()@@ -133,12 +165,12 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti         r.request_focus();     }     if r.changed() {-        drafts(app).edits.insert(id, text);+        drafts(app).record_edit(id, text);     }     if r.lost_focus() {         if ui.input(|i| i.key_pressed(egui::Key::Escape)) {             // Escape throws the draft away: the field shows the comment's text again.-            drafts(app).edits.remove(&id);+            drafts(app).forget_edit(id);         } else {             // A refusal is on the status line, and the draft stays in the field.             let _ = commit_comment(app, id);@@ -161,7 +193,7 @@ pub(crate) fn commit_comment(app: &mut WordApp, id: u32) -> Result<(), String> {     {         app.run("review.editComment", json!({"id": id, "text": text}))?;     }-    drafts(app).edits.remove(&id);+    drafts(app).forget_edit(id);     Ok(()) } @@ -193,11 +225,11 @@ pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option         r.request_focus();     }     if r.changed() {-        drafts(app).replies.insert(id, text);+        drafts(app).record_reply(id, text);     }     if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {         // Escape throws the reply away and closes the field.-        drafts(app).replies.remove(&id);+        drafts(app).forget_reply(id);         return true;     }     r.lost_focus() && commit_reply(app, id).is_ok()@@ -211,7 +243,7 @@ pub(crate) fn commit_reply(app: &mut WordApp, id: u32) -> Result<(), String> {         let root = thread_root(app, id);         app.run("review.reply", json!({"id": root, "text": text.trim_end()}))?;     }-    drafts(app).replies.remove(&id);+    drafts(app).forget_reply(id);     Ok(()) } @@ -238,3 +270,55 @@ pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> {     }     first }++#[cfg(test)]+mod native_snapshot_tests {+    use super::*;+    use crate::Services;+    use wordcraft_engine::Session;++    #[test]+    fn native_snapshot_commits_pending_comment_and_keeps_it_unsaved() {+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+        app.run("review.newComment", json!({"text":"original"})).unwrap();+        let id = *app.session.doc.comments.keys().next().unwrap();+        let part = app.session.doc.comments[&id].part;+        let before = app.session.rev();+        drafts(&mut app).record_edit(id, "pending native comment".into());+        assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "original");+        app.prepare_native_snapshot().unwrap();+        assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "pending native comment");+        assert!(app.session.dirty, "export is not a storage acknowledgement");+        assert!(app.session.rev() > before);+        assert!(!has_comment_drafts(&app));+    }++    #[test]+    fn every_pending_comment_and_reply_edit_changes_the_native_draft_token() {+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+        app.session.dirty = true;+        let first = app.native_draft_revision();+        drafts(&mut app).record_edit(0, "first".into());+        let second = app.native_draft_revision();+        drafts(&mut app).record_edit(0, "second".into());+        let third = app.native_draft_revision();+        drafts(&mut app).record_reply(0, "reply draft".into());+        let fourth = app.native_draft_revision();+        assert_ne!(first, second);+        assert_ne!(second, third);+        assert_ne!(third, fourth);+        assert!(app.session.dirty);+    }+    #[test]+    fn refused_pending_comment_stays_local_and_snapshot_preparation_fails() {+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+        app.run("review.newComment", json!({"text":"original"})).unwrap();+        let id = *app.session.doc.comments.keys().next().unwrap();+        drafts(&mut app).record_edit(id, "pending retained".into());+        app.session.run("review.restrict", &json!({"mode":"readOnly"})).unwrap();+        let before = serde_json::to_value(&app.session.doc).unwrap();+        assert!(app.prepare_native_snapshot().is_err());+        assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);+        assert_eq!(comment_draft(&app, id).as_deref(), Some("pending retained"));+    }+}