collective

Browse / github.com/storytold/gridcraft / patch

feat(web): expose native workbook snapshots to embedding hosts

feat(web): expose native workbook snapshots to embedding hosts Same-origin embeddings can open native JSON workbooks, but Save only hands the host XLSX. Add correlated whole-document native JSON export and engine validation through the existing file codec. Export settles pending drafts, reports the resulting document/revision, and retains the dirty baseline. Revision notifications include further edits while already dirty. The existing same-origin parent checks and ordinary Save behavior remain in place. Durable managed-save acknowledgement is a separate follow-up. Validation: - `cargo xtask ci` passed all 10 steps with the same pinned craft-fonts inpu

Status
open
Score
0.00
Adopters
0 other operators
Author
Srbino (imported from pull request #2736, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+225 −2 in 4 files
Review
pending
Shared
2026-10-28
Id
665cff946e6a7126f51b636f

How it went

No other operator has reported using it yet.

Diff

raw · README.md apps/gridcraft-web/src/host.rs apps/gridcraft-web/src/main.rs apps/gridcraft-web/src/native_document.rs

diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -129,6 +129,18 @@ cd apps/gridcraft-web && trunk serve --release     # http://127.0.0.1:8771  (?sa You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the browser's file picker (dropping files works too) and Save downloads the workbook. +A same-origin parent can embed the editor with `?host=parent` and open complete native JSON+with `gridcraft:open` (`name: "workbook.json"`, `bytes: ArrayBuffer`). After `gridcraft:ready`,+send `gridcraft:export-native` with an opaque string `requestId`; `gridcraft:native-exported`+returns that ID, the exported `document` and `revision`, and complete native JSON `bytes`, or `error`.+The revision identifies the state after any pending draft was committed. Export commits pending drafts+through the engine and leaves the unsaved state intact: the host remains responsible for durable+storage. `gridcraft:changed` reports every active document revision, including further edits while+already dirty. `gridcraft:validate-native` (`requestId`, `bytes`) returns+`gridcraft:native-validated` (`requestId`, `valid`, `error`) without changing the document.+These messages are accepted only from the same-origin parent. They do not grant permission to+store a document or turn the ordinary XLSX Save action into a durable acknowledgement.+ ### Ribbon access keys  Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,diff --git a/apps/gridcraft-web/src/host.rs b/apps/gridcraft-web/src/host.rs--- a/apps/gridcraft-web/src/host.rs+++ b/apps/gridcraft-web/src/host.rs@@ -8,7 +8,15 @@ //!   file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export //!   as PDF. The parent stores it and reports failures itself. //! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.+//! - `gridcraft:changed` (to the parent): `{ document, revision, dirty }` when the active+//!   workbook changes. Document and revision are opaque strings; edits while already dirty+//!   still notify the host, so it can debounce a fresh native export. //! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.+//! - `gridcraft:export-native` (from the parent): `{ requestId }`; replies with+//!   `gridcraft:native-exported` `{ requestId, document, revision, bytes }` (native JSON ArrayBuffer), or `{ requestId, error }`.+//!   Pending drafts are committed first. Export does not claim a durable save or clear dirty state.+//! - `gridcraft:validate-native` (from the parent): `{ requestId, bytes }`; replies with+//!   `gridcraft:native-validated` `{ requestId, valid, error? }` without changing the document. //! //! Only the parent window on the page's own origin is heard or answered. In host mode the page //! starts with an empty workbook instead of a sample, and the parent guards leaving the page.@@ -28,6 +36,11 @@ const WORKBOOK_EXTS: &[&str] = &["xlsx"]; /// The longest user name taken from `?author=`. const MAX_AUTHOR_CHARS: usize = 200; +enum NativeRequest {+    Export(String),+    Validate(String, Vec<u8>),+}+ #[derive(Clone)] pub struct Bridge {     window: web_sys::Window,@@ -37,6 +50,8 @@ pub struct Bridge {     saved: Rc<RefCell<Vec<String>>>,     /// The unsaved-changes state last reported to the parent.     reported: Rc<Cell<Option<bool>>>,+    native: Rc<RefCell<Vec<NativeRequest>>>,+    reported_revision: Rc<Cell<Option<(u64, u64)>>>, }  impl Bridge {@@ -48,7 +63,7 @@ impl Bridge {             return None;         }         let origin = window.location().origin().ok()?;-        Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })+        Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default(), native: Rc::default(), reported_revision: Rc::default() })     }      /// Send saved and exported files to the parent instead of downloading them, and hear files@@ -82,6 +97,42 @@ impl Bridge {     /// After each frame: a workbook handed to the parent this frame counts as saved under the     /// name it was saved as, and the parent hears when unsaved changes appear or go away.     pub fn sync(&self, app: &mut SheetApp) {+        let requests = std::mem::take(&mut *self.native.borrow_mut());+        for request in requests {+            match request {+                NativeRequest::Export(id) => match crate::native_document::export(app) {+                    Ok(snapshot) => {+                        let buffer = js_sys::Uint8Array::from(snapshot.bytes.as_slice()).buffer();+                        if let Some(message) = message(&[+                            ("type", "gridcraft:native-exported".into()),+                            ("requestId", id.as_str().into()),+                            ("document", snapshot.document.as_str().into()),+                            ("revision", snapshot.revision.as_str().into()),+                            ("bytes", buffer.clone().into()),+                        ]) {+                            let _ = self.parent.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer));+                        }+                    }+                    Err(error) => {+                        self.send(&[+                            ("type", "gridcraft:native-exported".into()),+                            ("requestId", id.as_str().into()),+                            ("error", error.as_str().into()),+                        ]);+                    }+                },+                NativeRequest::Validate(id, bytes) => {+                    let result = crate::native_document::validate(&bytes);+                    let error = result.as_ref().err().map(String::as_str).unwrap_or("");+                    self.send(&[+                        ("type", "gridcraft:native-validated".into()),+                        ("requestId", id.as_str().into()),+                        ("valid", result.is_ok().into()),+                        ("error", error.into()),+                    ]);+                }+            }+        }         let saved = std::mem::take(&mut *self.saved.borrow_mut());         if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))             && let Some(doc) = app.session.active_mut()@@ -95,6 +146,18 @@ impl Bridge {         if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {             self.reported.set(Some(dirty));         }+        let revision = app.session.active().map(|doc| (doc.uid, doc.revision));+        if revision != self.reported_revision.get() {+            if let Some((document, revision)) = revision {+                self.send(&[+                    ("type", "gridcraft:changed".into()),+                    ("document", document.to_string().into()),+                    ("revision", revision.to_string().into()),+                    ("dirty", dirty.into()),+                ]);+            }+            self.reported_revision.set(revision);+        }     }      fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {@@ -112,12 +175,44 @@ impl Bridge {     fn listen(&self, inbox: Inbox, ctx: egui::Context) {         let parent = self.parent.clone();         let origin = self.origin.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("gridcraft:open") {+            let kind = string(&data, "type");+            if matches!(kind.as_deref(), Some("gridcraft:export-native" | "gridcraft:validate-native")) {+                let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };+                let response =+                    if kind.as_deref() == Some("gridcraft:export-native") { "gridcraft:native-exported" } else { "gridcraft:native-validated" };+                if bridge.native.borrow().len() >= 8 {+                    bridge.send(&[+                        ("type", response.into()),+                        ("requestId", id.as_str().into()),+                        ("valid", false.into()),+                        ("error", "The native document request queue is full.".into()),+                    ]);+                    return;+                }+                let request = if kind.as_deref() == Some("gridcraft:export-native") {+                    NativeRequest::Export(id)+                } else if let Some(bytes) = bytes(&data) {+                    NativeRequest::Validate(id, bytes)+                } else {+                    bridge.send(&[+                        ("type", response.into()),+                        ("requestId", id.as_str().into()),+                        ("valid", false.into()),+                        ("error", "Native workbook bytes are required.".into()),+                    ]);+                    return;+                };+                bridge.native.borrow_mut().push(request);+                ctx.request_repaint();+                return;+            }+            if kind.as_deref() != Some("gridcraft:open") {                 return;             }             let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());diff --git a/apps/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs--- a/apps/gridcraft-web/src/main.rs+++ b/apps/gridcraft-web/src/main.rs@@ -19,6 +19,8 @@ #[cfg(target_arch = "wasm32")] mod host; #[cfg(any(target_arch = "wasm32", test))]+mod native_document;+#[cfg(any(target_arch = "wasm32", test))] mod prefs; #[cfg(target_arch = "wasm32")] mod web;diff --git a/apps/gridcraft-web/src/native_document.rs b/apps/gridcraft-web/src/native_document.rsnew file mode 100644--- /dev/null+++ b/apps/gridcraft-web/src/native_document.rs@@ -0,0 +1,114 @@+//! Whole native documents for same-origin embedding hosts.++use gridcraft_ui_egui::SheetApp;+use serde_json::json;++pub struct Snapshot {+    pub bytes: Vec<u8>,+    pub document: String,+    pub revision: String,+}++/// Encode the complete native workbook through the same command as desktop/CLI.+/// `SheetApp::run` settles pending cell and text-box drafts before file commands.+/// Export is not a durable save and deliberately leaves the dirty baseline alone.+pub fn export(app: &mut SheetApp) -> Result<Snapshot, String> {+    let encoded = app.run("file.saveBytes", json!({"format": "json"}))?;+    let bytes = encoded+        .get("base64")+        .and_then(serde_json::Value::as_str)+        .and_then(gridcraft_engine::io::base64_decode)+        .ok_or_else(|| "The engine did not return a native workbook.".to_string())?;+    validate(&bytes)?;+    // A pending draft may have advanced the revision during the file command.+    let doc = app.session.active().ok_or_else(|| "No workbook is open.".to_string())?;+    Ok(Snapshot { bytes, document: doc.uid.to_string(), revision: doc.revision.to_string() })+}++/// Decode using the application's engine without changing the open workbook.+pub fn validate(bytes: &[u8]) -> Result<(), String> {+    // The file reader also sniffs XLSX irrespective of the supplied extension.+    // This endpoint promises native JSON, not an arbitrary import.+    serde_json::from_slice::<serde_json::Value>(bytes).map_err(|e| e.to_string())?;+    let (_, warnings) = gridcraft_engine::io::open_bytes("snapshot.json", bytes).map_err(|e| e.to_string())?;+    if !warnings.is_empty() {+        return Err(warnings.join("\n"));+    }+    Ok(())+}++#[cfg(test)]+mod tests {+    use gridcraft_engine::Session;+    use gridcraft_ui_egui::{Services, SheetApp};+    use serde_json::json;++    fn app() -> SheetApp {+        let mut session = Session::new();+        session.new_workbook();+        SheetApp::new(session, Services::default())+    }++    #[test]+    fn native_export_commits_the_cell_draft_without_claiming_durable_save() {+        let mut app = app();+        app.run("cell.set", json!({"cell":"A1", "input":"21"})).unwrap();+        app.run("cell.set", json!({"cell":"B1", "input":"=A1*2"})).unwrap();+        app.run("home.bold", json!({"range":"A1", "on":true})).unwrap();+        app.run("home.numberFormat", json!({"range":"B1", "code":"0.00"})).unwrap();+        app.run("home.mergeCells", json!({"range":"A4:C4"})).unwrap();+        app.run("home.rowHeight", json!({"rows":"5:5", "height":42})).unwrap();+        app.run("home.insertSheet", json!({"name":"Second"})).unwrap();+        app.run("cell.set", json!({"cell":"A1", "input":"Second sheet"})).unwrap();+        app.run("sheet.activate", json!({"sheet":"Sheet1"})).unwrap();+        app.run("selection.set", json!({"cell":"A1"})).unwrap();+        let before = app.session.active().unwrap().revision;+        let document = app.session.active().unwrap().uid.to_string();+        app.begin_edit(Some("22".into()), false);+        let snapshot = super::export(&mut app).unwrap();+        assert_eq!(snapshot.document, document);+        assert!(app.session.active().unwrap().revision > before);+        assert_eq!(snapshot.revision, app.session.active().unwrap().revision.to_string());+        let bytes = snapshot.bytes;+        assert!(app.editor.is_none());+        assert!(app.session.active().unwrap().is_dirty());+        let (wb, _) = gridcraft_engine::io::open_bytes("snapshot.json", &bytes).unwrap();+        // Formula AST caches and rebuilt style indices are intentionally not persisted.+        assert_eq!(serde_json::to_value(&wb).unwrap(), serde_json::to_value(app.session.active().unwrap().wb.as_ref()).unwrap());+        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();+        assert!(json.get("sheets").is_some());+    }++    #[test]+    fn native_validation_rejects_invalid_documents() {+        assert!(super::validate(b"not JSON").is_err());+        assert!(super::validate(br#"{"sheets":"broken"}"#).is_err());+    }++    #[test]+    fn rejected_draft_is_retained_instead_of_exporting_an_older_cell() {+        let mut app = app();+        app.begin_edit(Some("=1+".into()), false);+        assert!(super::export(&mut app).is_err());+        assert!(app.editor.is_some());+    }++    #[test]+    fn native_validation_does_not_accept_an_xlsx_disguised_as_json() {+        let mut session = Session::new();+        session.new_workbook();+        let encoded = session.execute("file.saveBytes", json!({"format":"xlsx"})).unwrap();+        let bytes = gridcraft_engine::io::base64_decode(encoded["base64"].as_str().unwrap()).unwrap();+        assert!(super::validate(&bytes).is_err());+    }++    #[test]+    fn native_export_has_no_four_mib_checkpoint_limit() {+        let mut app = app();+        let values: Vec<Vec<String>> = (0..130).map(|_| vec!["x".repeat(32767)]).collect();+        app.run("range.setValues", json!({"range":"A1", "values":values})).unwrap();+        let bytes = super::export(&mut app).unwrap().bytes;+        assert!(bytes.len() > 4 * 1024 * 1024);+        super::validate(&bytes).unwrap();+    }+}