Browse / github.com/storytold/wordcraft / patch
Web host bridge: control-protocol requests from the parent page, and an author on engine.execute
Web host bridge: control-protocol requests from the parent page, and an author on engine.execute A page that frames WordCraft with `?host=parent` can open a document and store what it saves, but it cannot read or change the open document. An agent working beside the person (a drafting assistant in the host application) has to edit a copy instead, and the person sees its work only after reloading. And when an agent does edit the same document, its tracked changes carry the person's name, so Review cannot tell them apart. This adds two things. **`engine.execute` takes `author`** (first commit). It names who signs that one command's comments and tracked changes, with initials drawn from th
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- theohmwoa (imported from pull request #4559, open)
- Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750- Size
- +226 −18 in 13 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4b86c4d2abcda6237205f241
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/web.rs crates/engine/src/lib.rs crates/engine/src/session.rs crates/engine/src/tests.rs crates/engine/src/tests/request_author.rs crates/mcp/src/lib.rs crates/mcp/src/tests.rs crates/mcp/src/tools.rs crates/ui-egui/src/control/handler.rs docs/control-protocol.md docs/mcp.md
@@ -19,11 +19,15 @@ //! 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:failed` (to the parent): `{ error }` when the app couldn't start.+//! - `wordcraft:request` (from the parent): `{ id, method, params }`, one of [`HOST_METHODS`] of+//! the control protocol (`docs/control-protocol.md`), run on the open document as an agent's+//! request is. `wordcraft:response` (to the parent) answers it: `{ id, reply }`, where `reply`+//! is the protocol's `{ ok, result }` or `{ ok: false, error }`. //! //! 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 _;@@ -34,6 +38,13 @@ use wordcraft_ui_egui::{Inbox, Services, WordApp}; /// The longest user name taken from `?author=`. const MAX_AUTHOR_CHARS: usize = 200; +/// The control-protocol methods a host page may call: the document's commands and state, not+/// the window's input, screenshots or quitting.+pub const HOST_METHODS: &[&str] = &["engine.execute", "engine.commands", "document.inspect", "ui.inspect"];++/// A host request waiting for the next frame: its id, method and parameters.+pub type Requests = Rc<RefCell<Vec<(JsValue, String, serde_json::Value)>>>;+ #[derive(Clone)] pub struct Bridge { window: web_sys::Window,@@ -65,8 +76,8 @@ impl Bridge { /// Hand saved and exported files to the parent instead of downloading them, report unsaved /// changes to it, and hear the files it sends.- pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {- self.listen(inbox, ctx);+ pub fn connect(&self, services: &mut Services, inbox: Inbox, requests: Requests, ctx: egui::Context) {+ self.listen(inbox, requests, ctx); let bridge = self.clone(); services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as))); // Anything else that would download goes to the parent too.@@ -115,8 +126,15 @@ impl Bridge { .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}"))) } - /// Hear `wordcraft:open` from the parent and queue the file like a dropped one.- fn listen(&self, inbox: Inbox, ctx: egui::Context) {+ /// Answer a `wordcraft:request` with the control protocol's reply.+ pub fn post_response(&self, id: &JsValue, reply: &serde_json::Value) {+ let reply = js_sys::JSON::parse(&reply.to_string()).unwrap_or(JsValue::NULL);+ self.send(&[("type", "wordcraft:response".into()), ("id", id.clone()), ("reply", reply)]);+ }++ /// Hear `wordcraft:open` from the parent and queue the file like a dropped one, and queue+ /// `wordcraft:request` for the next frame, which runs it on the app.+ fn listen(&self, inbox: Inbox, requests: Requests, ctx: egui::Context) { let parent = self.parent.clone(); let origin = self.origin.clone(); let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {@@ -124,6 +142,19 @@ impl Bridge { return; } let data = event.data();+ if string(&data, "type").as_deref() == Some("wordcraft:request") {+ let id = js_sys::Reflect::get(&data, &JsValue::from_str("id")).unwrap_or(JsValue::NULL);+ let method = string(&data, "method").unwrap_or_default();+ let params = js_sys::Reflect::get(&data, &JsValue::from_str("params"))+ .ok()+ .and_then(|p| js_sys::JSON::stringify(&p).ok())+ .and_then(|s| s.as_string())+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or(serde_json::Value::Null);+ requests.borrow_mut().push((id, method, params));+ ctx.request_repaint();+ return;+ } if string(&data, "type").as_deref() != Some("wordcraft:open") { return; }@@ -10,7 +10,8 @@ //! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document. //! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the //! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for-//! comments and tracked changes.+//! comments and tracked changes. The parent page can also run control-protocol requests on the+//! open document (commands, inspection), so an agent edits it while the person does. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #[cfg(any(target_arch = "wasm32", test))]@@ -69,8 +69,9 @@ pub fn start() { session.system_region = region; let messages = Rc::new(RefCell::new(Vec::new())); let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());+ let requests = crate::host::Requests::default(); if let Some(bridge) = &app_bridge {- bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());+ bridge.connect(&mut services, inbox.clone(), requests.clone(), cc.egui_ctx.clone()); } let mut app = WordApp::new(session, services); // Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).@@ -99,7 +100,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.clone(), requests })) }), ) .await;@@ -134,6 +135,9 @@ struct WebShell { inbox: Inbox, messages: Rc<RefCell<Vec<String>>>, prefs: crate::prefs::PrefsSync,+ /// The host page in `?host=parent` mode, and its requests waiting for this frame.+ bridge: Option<crate::host::Bridge>,+ requests: crate::host::Requests, } fn local_storage() -> Option<web_sys::Storage> {@@ -154,6 +158,27 @@ impl eframe::App for WebShell { for msg in msgs { self.app.status(msg); }+ // The host's requests run between frames, as the desktop app's control channel does.+ let requests = std::mem::take(&mut *self.requests.borrow_mut());+ if !requests.is_empty()+ && let Some(bridge) = &self.bridge+ {+ for (id, method, params) in requests {+ let reply = if crate::host::HOST_METHODS.contains(&method.as_str()) {+ let (request, _) = wordcraft_ui_egui::ControlRequest::new(method, params);+ match wordcraft_ui_egui::control::handle(&mut self.app, ctx, &request) {+ wordcraft_ui_egui::control::Outcome::Done(reply) => reply,+ wordcraft_ui_egui::control::Outcome::Screenshot { .. } => {+ serde_json::json!({"ok": false, "error": "screenshots are not available to the host page"})+ }+ }+ } else {+ serde_json::json!({"ok": false, "error": format!("the host page cannot call `{method}`")})+ };+ bridge.post_response(&id, &reply);+ }+ ctx.request_repaint();+ } self.app.logic(ctx); // Store changed preferences straight away: a reload or closed tab runs no exit code (#2132). if let Some(json) = self.prefs.changed(&self.app) {@@ -34,8 +34,8 @@ pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; pub use session::{- CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX,- ViewState, WindowView, blank_document, next_math_session, set_system_locale,+ CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, Signature, StylesPaneDock,+ UNDO_LIST_MAX, ViewState, WindowView, blank_document, next_math_session, set_system_locale, }; /// The outcome a command reports for IME receipt classification. ///@@ -239,4 +239,50 @@ pub struct Session { impl Session { /// File name of the persisted custom dictionary inside the settings folder. pub const USER_DICT_FILE: &str = "custom-dictionary.txt";++ /// The longest author name a command request may give; a longer one is cut.+ pub const MAX_REQUEST_AUTHOR_CHARS: usize = 200;++ /// The author a command request names in `author`, trimmed and cut to+ /// [`Self::MAX_REQUEST_AUTHOR_CHARS`]: `None` when it's absent, null or blank.+ pub fn request_author(params: &Value) -> Result<Option<String>, String> {+ match params.get("author") {+ None | Some(Value::Null) => Ok(None),+ Some(Value::String(name)) => {+ let name: String = name.trim().chars().take(Self::MAX_REQUEST_AUTHOR_CHARS).collect();+ Ok((!name.is_empty()).then_some(name))+ }+ Some(_) => Err("`author` must be a string".to_string()),+ }+ }++ /// Signs what follows with `author`: comments and tracked changes carry that name, and the+ /// initials drawn from it. Returns the session's own signature for [`Self::restore_signature`].+ pub fn sign_as(&mut self, author: String) -> Signature {+ Signature { author: std::mem::replace(&mut self.author, author), initials: std::mem::take(&mut self.initials) }+ }++ /// Puts back the signature [`Self::sign_as`] replaced.+ pub fn restore_signature(&mut self, own: Signature) {+ self.author = own.author;+ self.initials = own.initials;+ }++ /// Runs `f` signed by `author` (see [`Self::sign_as`]), then restores the session's own+ /// signature whatever `f` returns; `None` runs `f` as the session's author.+ pub fn as_author<T>(&mut self, author: Option<String>, f: impl FnOnce(&mut Self) -> T) -> T {+ let Some(author) = author else { return f(self) };+ let own = self.sign_as(author);+ let result = f(self);+ self.restore_signature(own);+ result+ }+}++/// The author name and comment initials a session signs with, kept aside while a request signs+/// as someone else.+#[derive(Debug, Clone)]+pub struct Signature {+ author: String,+ initials: String, }@@ -13,6 +13,7 @@ mod bidi; mod documents; mod fonts; mod ime_noop;+mod request_author; mod revision_checks; mod typing; mod wordart;new file mode 100644@@ -0,0 +1,50 @@+//! A command request's `author` signs that command's tracked changes and comments, and the session+//! signs with its own name again afterwards.++use serde_json::{Value, json};++use crate::Session;++fn run(s: &mut Session, id: &str, v: Value) -> Value {+ s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))+}++fn change_authors(s: &mut Session) -> Vec<String> {+ let list = run(s, "review.changes", json!({}));+ list.as_array().into_iter().flatten().filter_map(|c| c["author"].as_str().map(str::to_string)).collect()+}++#[test]+fn request_author_is_trimmed_and_cut_and_blank_means_none() {+ assert_eq!(Session::request_author(&json!({})), Ok(None));+ assert_eq!(Session::request_author(&json!({"author": null})), Ok(None));+ assert_eq!(Session::request_author(&json!({"author": " "})), Ok(None));+ assert_eq!(Session::request_author(&json!({"author": " Drafting assistant "})), Ok(Some("Drafting assistant".to_string())));+ let long = "é".repeat(Session::MAX_REQUEST_AUTHOR_CHARS + 50);+ let cut = Session::request_author(&json!({"author": long})).ok().flatten().unwrap_or_default();+ assert_eq!(cut.chars().count(), Session::MAX_REQUEST_AUTHOR_CHARS);+ assert!(Session::request_author(&json!({"author": 42})).is_err());+}++#[test]+fn as_author_signs_the_tracked_change_then_restores_the_session_signature() {+ let mut s = Session::new(wordcraft_doc::Document::new());+ s.author = "Alice Smith".into();+ s.initials = "AS".into();+ run(&mut s, "review.trackChanges", json!({"value": true}));+ run(&mut s, "text.insert", json!({"text": "mine "}));+ let r = s.as_author(Some("Drafting assistant".into()), |s| s.run("text.insert", &json!({"text": "agent"})));+ assert!(r.is_ok(), "{r:?}");+ assert_eq!(change_authors(&mut s), ["Alice Smith", "Drafting assistant"]);+ assert_eq!(s.author, "Alice Smith");+ assert_eq!(s.initials, "AS");+}++#[test]+fn as_author_restores_the_signature_when_the_command_fails() {+ let mut s = Session::new(wordcraft_doc::Document::new());+ s.author = "Alice Smith".into();+ let r = s.as_author(Some("Drafting assistant".into()), |s| s.run("no.such.command", &json!({})));+ assert!(r.is_err());+ assert_eq!(s.author, "Alice Smith");+}@@ -71,7 +71,8 @@ fn answer_on(session: &mut Session, method: &str, params: Value) -> Result<Value "engine.execute" | "command" => { let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string(); let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));- session.run(&id, &p).map_err(|e| e.to_string())+ let author = Session::request_author(¶ms)?;+ session.as_author(author, |s| s.run(&id, &p)).map_err(|e| e.to_string()) } "engine.commands" => Ok(session.registry.describe()), "document.inspect" => session.run("document.inspect", ¶ms).map_err(|e| e.to_string()),@@ -355,3 +355,20 @@ fn tools_address_an_open_document_by_id() { assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second"); assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again"); }++#[test]+fn an_agent_signs_its_tracked_changes_with_author() {+ let mut s = Server::new(Box::new(Headless::default()));+ tool(&mut s, "new_document", json!({}));+ tool(&mut s, "execute", json!({"command": "review.trackChanges", "params": {"value": true}}));+ tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": "person "}}));+ tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": "agent"}, "author": "Drafting assistant"}));+ tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": " person again"}}));+ let changes = tool(&mut s, "execute", json!({"command": "review.changes"}));+ let authors: Vec<&str> = changes.as_array().unwrap().iter().filter_map(|c| c["author"].as_str()).collect();+ assert!(authors.contains(&"Drafting assistant"), "{changes}");+ assert_eq!(authors.iter().filter(|a| **a == "Drafting assistant").count(), 1, "only the signed command: {changes}");+ let bad =+ call(&mut s, 14, "tools/call", json!({"name": "execute", "arguments": {"command": "text.insert", "params": {"text": "x"}, "author": 7}}));+ assert_eq!(bad["result"]["isError"], true, "{bad}");+}@@ -75,8 +75,11 @@ pub fn tool_definitions() -> Value { tool( "execute", "Run a command",- "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.",- with_doc(obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"])),+ "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one. `author` signs this command's comments and tracked changes, so an agent's edits are told apart from the person's.",+ with_doc(obj(+ json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}, "author": s("Name that signs this command's comments and tracked changes (the document's author when absent)")}),+ &["command"]+ )), false ), tool(@@ -235,7 +238,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } } "execute" => match st("command") {- Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})), doc)),+ Some(c) => {+ let mut call = json!({"command": c, "params": a.get("params").cloned().unwrap_or(json!({}))});+ if let (Some(author), Some(o)) = (a.get("author"), call.as_object_mut()) {+ o.insert("author".into(), author.clone());+ }+ wrap(b.call("engine.execute", addressed(call, doc)))+ } None => ToolResult::error("missing `command`"), }, "batch" => {@@ -158,7 +158,11 @@ fn handle_in(app: &mut WordApp, doc: u64, method: &str, p: &Value) -> Outcome { Err(e) => err(e), }; }- let r = app.session.with_document(doc, |s| s.run(id, ¶ms));+ let author = match wordcraft_engine::Session::request_author(p) {+ Ok(author) => author,+ Err(e) => return err(e),+ };+ let r = app.session.with_document(doc, |s| s.as_author(author, |s| s.run(id, ¶ms))); // UI requests the command made carry `document`, so the window can tell they aren't // about the document it shows. for req in std::mem::take(&mut app.session.ui_requests) {@@ -190,7 +194,17 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O if let Some(e) = close_refused(app, id, ¶ms) { return err(e); }- match app.execute(id, params) {+ let author = match wordcraft_engine::Session::request_author(p) {+ Ok(author) => author,+ Err(e) => return err(e),+ };+ // `author` signs only this command: the app signs with its own name again after it.+ let own = author.map(|author| app.session.sign_as(author));+ let r = app.execute(id, params);+ if let Some(own) = own {+ app.session.restore_signature(own);+ }+ match r { Ok(v) => ok(v), Err(e) => err(e), }@@ -54,7 +54,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---|-| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |+| `engine.execute` | `command`, `params`, `doc?`, `author?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |@@ -103,6 +103,19 @@ window); in the browser, the one the window shows. Commands work on it unless to that isn't a whole number from 0 up, is an error and nothing runs. Window commands (`ui.*`) work on the active window only: with the id of another document they are an error. +### Signing a command++`engine.execute` takes `author`, a name that signs that one command's comments and tracked changes+instead of the document's author (File › Options › General), with initials drawn from the name. An+agent editing beside a person sets it, so Review shows its changes apart from theirs and they can+be accepted or rejected as a group. The name is trimmed and cut to 200 characters; left out, `null`+or blank, the command signs as usual, and a value that isn't a string is an error. The app signs+with its own name again once the command has run.++```json+{"id": 3, "key": "…", "method": "engine.execute", "params": {"command": "edit.replaceAll", "params": {"text": "30 days", "with": "45 days"}, "author": "Drafting assistant"}}+```+ ### The active window Input and window methods go to the active window, the one focused last: `ui.click`, `ui.key`,@@ -115,7 +115,7 @@ files it was given rather than guarding against other software on the computer. | Tool | What it does | |---|---| | `list_commands` | every command (filter with `query`) — over 270 of them |-| `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` |+| `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}`; `author` signs its comments and tracked changes (see [Signing a command](control-protocol.md#signing-a-command)) | | `batch` | run several commands in order | | `new_document` | blank, sample, letter, resume, report; opens beside the open documents | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png |