Files
wordcraft/crates/engine/src/cmd/file.rs
T

550 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File tab (Backstage): new, open, save, export, print, properties; document inspection.
use serde_json::{Value, json};
use wordcraft_doc::{Block, Document, StoryRef};
use super::{pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Longest user name kept, in characters; longer names are cut, not refused.
/// `format` of the save commands for an `.xml` file: Word 2003 XML Document (WordprocessingML 2003).
pub const FORMAT_WORD_2003: &str = "wordml2003";
/// `format` for an `.xml` file as a Word XML Document (Flat OPC), the default.
pub const FORMAT_FLAT_OPC: &str = "flatopc";
pub const MAX_AUTHOR_CHARS: usize = 255;
/// The user name as it is stored: trimmed, stripped of control characters (they can't go into
/// the saved XML) and cut to [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always
/// carry an author.
pub fn normalize_author(name: &str) -> Result<String, CmdError> {
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect();
let name = name.trim_end();
if name.is_empty() {
return Err(CmdError::Params("`name` must not be blank".into()));
}
Ok(name.to_string())
}
/// Sets the user name that new tracked changes and comments are recorded under. The name is
/// normalised by [`normalize_author`].
/// A protected document refuses it: the name is the identity its exceptions are checked against.
pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
super::protect::check_user_name(s)?;
s.author = normalize_author(p::req_str(v, "name")?)?;
let mut r = sel_result(s)?;
if let Some(o) = r.as_object_mut() {
o.insert("name".into(), json!(s.author));
}
Ok(r)
}
/// Rename the current file in its directory, or name a document before its first save.
fn rename(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?.trim();
if name.is_empty()
|| name.chars().count() > 200
|| name.ends_with(['.', ' '])
|| name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
|| matches!(name, "." | "..")
{
return Err(CmdError::Params("Enter a valid document name (up to 200 characters, without path separators).".into()));
}
if let Some(path) = &s.path {
let mut destination = path.with_file_name(name);
// The field edits the stem; retain the original format, including macro/template files.
// A name that already ends in that extension is taken as the whole file name, since a
// script or an agent passes one ("renamed.docx" became "renamed.docx.docx", #2983). The
// extension alone (".docx") is a stem, not a file name, so it still gets one.
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy();
// Split at the name's own last dot rather than counting bytes back from its end: a
// name whose characters are more than one byte each ("résumé.docm") would be cut
// inside one of them, and slicing there panics (#3128 review).
let given = name.rsplit_once('.').is_some_and(|(stem, suffix)| !stem.is_empty() && suffix.eq_ignore_ascii_case(ext.as_ref()));
destination.set_file_name(if given { name.to_string() } else { format!("{name}.{ext}") });
}
if destination != *path {
#[cfg(not(target_arch = "wasm32"))]
{
crate::io::rename_file(path, &destination).map_err(CmdError::Failed)?;
}
s.path = Some(destination);
s.relayout();
}
} else {
s.file_name = Some(name.to_string());
s.relayout();
}
Ok(json!({"name": name, "path": s.path}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name; its extension is kept, so the name may be given with or without it)}"#).pure(),
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
CommandSpec::new("file.open", "Open", "File", open)
.key("Mod+O / Mod+F12 / Mod+Alt+F2")
.params(r#"{"path": string, "data"?: base64 (the file's bytes; `path` then only names it), "password"?: string (for a password-protected Word document)}"#)
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12 / Alt+Shift+F2")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
.params(r#"{"password": string (Word documents are saved encrypted with it) | null or "" (saved unencrypted)}"#)
.pure(),
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
// No path (the command search, say): the app asks where to save, as File › Export
// does, instead of failing on the required parameter (#2313).
let Some(path) = p::str(v, "path") else {
s.push_ui_request(json!({"open": "exportPdf"}));
return Ok(json!({"exported": false}));
};
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
let opts = export_options(s, v);
crate::io::save_path_with(std::path::Path::new(&path), &s.doc, None, &opts).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "markup": opts.markup}))
})
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup)}"#)
.pure(),
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup)}"#)
.pure(),
// A print setting, like Word's: not part of the document, so no undo step.
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
s.prefs.print_markup = p::bool(v, "value").unwrap_or(!s.prefs.print_markup);
Ok(json!({"value": s.prefs.print_markup}))
})
.params(r#"{"value"?: bool (omit to toggle)} — PDFs, prints and copies show comments (balloons beside each page; a list at the end of HTML, Markdown and text) and tracked changes"#)
.pure(),
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
.params(
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
)
.pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
s.push_ui_request(json!({"open": "print"}));
sel_result(s)
})
.key("Mod+P / Mod+Shift+F12 / Mod+F2 / Mod+Alt+I")
.pure(),
CommandSpec::new("file.close", "Close", "File", |s, _| {
s.push_ui_request(json!({"close": true}));
sel_result(s)
})
.key("Mod+W / Mod+F4")
.params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask)}"#)
.pure(),
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#),
CommandSpec::new("file.info", "Info", "File", info).pure(),
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
.pure(),
// Trust Center › Privacy Options: the document's `w:removePersonalInformation` and
// `w:removeDateAndTime` settings, honoured when it is saved as .docx (#1062).
// Changing them is a document change (undoable, refused on a protected document); reading
// them is `file.privacy`, which changes nothing.
CommandSpec::new("file.privacyOptions", "Privacy Options", "File › Options › Save", |s, v| {
let personal = p::bool(v, "removePersonalInfo");
let date_time = p::bool(v, "removeDateTime");
if personal.is_none() && date_time.is_none() {
return Err(CmdError::Params("give removePersonalInfo or removeDateTime (file.privacy reads them)".into()));
}
if let Some(on) = personal {
s.doc.settings.remove_personal_info = on;
}
if let Some(on) = date_time {
s.doc.settings.remove_date_time = on;
}
Ok(privacy(s))
})
.params(r#"{"removePersonalInfo"?: bool (save comment and revision authors as "Author", no creator or last-modified-by), "removeDateTime"?: bool (save comments and tracked changes without dates)} — at least one; the open document keeps its real names and dates"#),
CommandSpec::new("file.privacy", "Privacy Settings", "File › Options › Save", |s, _| Ok(privacy(s)))
.params(r#"{} → {"removePersonalInfo": bool, "removeDateTime": bool} (file.privacyOptions changes them)"#)
.pure(),
CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(),
CommandSpec::new("document.text", "Document Text", "Agents", |s, v| {
let story = super::story_param(s, v);
Ok(json!({"text": s.doc.final_text(story)}))
})
.pure(),
CommandSpec::new("document.paragraph", "Paragraph Details", "Agents", paragraph).params(r#"{"path": [n], "story"?: Story}"#).pure(),
CommandSpec::new("document.selection", "Selection", "Agents", |s, _| {
let mut r = sel_result(s)?;
if let Some(o) = r.as_object_mut() {
o.insert("text".into(), json!(s.selected_text()));
o.insert("format".into(), super::format::state(s));
}
Ok(r)
})
.pure(),
CommandSpec::new("document.layout", "Layout Summary", "Agents", layout_summary).pure(),
CommandSpec::new("document.setText", "Replace Document Text", "Agents", |s, v| {
let text = p::req_str(v, "text")?;
let d = Document::from_text(text);
s.doc.body = d.body;
s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
})
.params(r#"{"text": string}"#),
]
}
/// The document's privacy settings, as `file.privacy` and `file.privacyOptions` return them.
fn privacy(s: &Session) -> Value {
json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time})
}
/// Create a blank document in the saved proofing language, or use the requested template.
fn new(s: &mut Session, v: &Value) -> CmdResult {
let doc = match p::str(v, "template").unwrap_or("blank") {
"sample" => crate::sample::sample_document(),
"letter" => crate::sample::letter(),
"resume" => crate::sample::resume(),
"report" => crate::sample::report(),
_ => normal_document(s),
};
s.set_document(doc);
s.path = None;
sel_result(s)
}
/// A blank document on the Normal template: the region's paper (#838), written in the editing
/// language (the templates' text is English).
fn normal_document(s: &Session) -> Document {
let mut d = s.blank_document();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
d.styles.default_chr.lang = Some(lang.clone());
}
d
}
/// Open a document, using the saved proofing default only for plain text and Markdown.
fn open(s: &mut Session, v: &Value) -> CmdResult {
let Some(path) = p::str(v, "path") else {
s.push_ui_request(json!({"open": "openFile"}));
return sel_result(s);
};
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
// An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new
// blank document that saves back to it (#1384).
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
doc.styles.default_chr.lang = Some(lang.clone());
}
s.set_document(doc);
if let Some(z) = s.doc.settings.zoom {
super::view::restore_zoom(s, z);
}
s.path = Some(path.into());
// Like Word, a document opened with a password is saved with it again.
if encrypted {
s.password = password.map(crate::io::Password::new);
}
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted}))
}
/// File › Info › Protect Document › Encrypt with Password: the password Word documents are saved
/// with from now on, or none.
fn encrypt(s: &mut Session, v: &Value) -> CmdResult {
let password = match v.get("password") {
Some(Value::Null) => None,
Some(Value::String(pw)) if pw.is_empty() => None,
Some(Value::String(pw)) => {
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
Some(crate::io::Password::new(pw))
}
_ => 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;
}
Ok(json!({"encrypted": s.password.is_some()}))
}
fn save(s: &mut Session, v: &Value) -> CmdResult {
let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) {
Some(p) => p,
None => {
s.push_ui_request(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
};
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
};
if !crate::io::can_encrypt(&path.to_string_lossy()) {
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();
let mut opts = export_options(s, v);
match v.get("format").filter(|f| !f.is_null()) {
None => {
// Saving over a Word 2003 XML file (one opened as such, say) keeps its format.
opts.word2003 = ext == "xml" && is_word_2003_file(&path);
}
Some(f) if matches!(f.as_str(), Some(FORMAT_WORD_2003 | FORMAT_FLAT_OPC)) && ext == "xml" => {}
Some(_) => {
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
}
}
// Undone below if the write fails: a failed save (no permission, disk full, a bad path)
// must not leave the document's own save stamps — zoom, modified/created, author, revision —
// changed, or a later save would persist them for a write that never actually happened.
let before_stamp =
(s.doc.settings.zoom, s.doc.core.last_modified_by.clone(), s.doc.core.created.clone(), s.doc.core.modified.clone(), s.doc.core.revision);
s.stamp_save();
let password = s.password.as_ref().map(crate::io::Password::as_str);
// A header/footer just made and left empty isn't saved (#960).
let copy = super::insert::saved_copy(s);
if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) {
(s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp;
return Err(CmdError::Failed(e));
}
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
let own_file = s.path.as_deref() == Some(path.as_path());
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
s.relayout();
}
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
}
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
#[cfg(not(target_arch = "wasm32"))]
fn is_word_2003_file(path: &std::path::Path) -> bool {
// The root element and its namespaces come first.
let Ok(head) = crate::io::read_file_head(&path.to_string_lossy(), 64 * 1024) else { return false };
wordcraft_docx::sniff_xml(&head) == wordcraft_docx::XmlKind::WordMl2003
}
#[cfg(target_arch = "wasm32")]
fn is_word_2003_file(_path: &std::path::Path) -> bool {
false
}
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "path").is_none() {
s.push_ui_request(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
save(s, v)
}
/// How a copy is written: Print Markup from the command's `markup`, else the File › Print
/// setting (`file.printMarkup`); comments as Track Changes Options show them; digits as the
/// Numeral setting shows them on screen.
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
crate::io::ExportOptions {
markup,
comments: markup && s.prefs.markup.comments,
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
numeral: s.view.numeral.resolve(crate::session::system_locale()),
}
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// No path: the app asks where to save, as file.exportPdf does (#2313).
let Some(path) = p::str(v, "path") else {
s.push_ui_request(json!({"open": "exportPng"}));
return Ok(json!({"exported": false}));
};
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
// A page image is the printed page: headers and footers at full strength. Dimming them is
// the on-screen cue while editing the body, not part of the page.
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
if !path.to_ascii_lowercase().ends_with(".png") {
return Err(CmdError::Params("path must end in .png".into()));
}
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
let png = img.to_png();
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let c = &mut s.doc.core;
let set = |dst: &mut String, k: &str| {
if let Some(x) = p::str(v, k) {
*dst = x.to_string();
}
};
set(&mut c.title, "title");
set(&mut c.subject, "subject");
set(&mut c.creator, "author");
set(&mut c.keywords, "keywords");
set(&mut c.description, "comments");
set(&mut c.category, "category");
if let Some(custom) = v.get("custom") {
let Some(m) = custom.as_object() else {
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
};
for (name, val) in m {
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
match val {
Value::Null => {
s.doc.remove_custom_prop(name);
}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => s.doc.set_custom_prop(name, x),
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
}
}
}
let mut out = serde_json::to_value(&s.doc.core).unwrap_or(Value::Null);
if let Some(o) = out.as_object_mut() {
o.insert("custom".into(), serde_json::to_value(&s.doc.custom_props).unwrap_or(Value::Null));
}
Ok(out)
}
/// Longest custom property name (Word's limit).
const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command.
const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
fn info(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
Ok(json!({
"path": s.path.as_ref().map(|p| p.to_string_lossy().to_string()),
"dirty": s.dirty,
"pages": l.pages.len(),
"words": s.doc.word_count(),
"paragraphs": s.doc.paragraph_count(),
"properties": s.doc.core,
"trackChanges": s.doc.settings.track_changes,
"comments": s.doc.comments.len(),
"sections": s.doc.sections().len(),
"encrypted": s.password.is_some(),
"layoutMs": l.ms,
}))
}
/// Structure for agents: blocks with text and formatting summary, selection, stats.
fn inspect(s: &mut Session, v: &Value) -> CmdResult {
let with_text = p::bool(v, "text").unwrap_or(true);
let mut blocks = Vec::new();
for (i, b) in s.doc.body.iter().enumerate().take(2000) {
match &**b {
Block::Para(p) => {
let style = p.props.style.clone().unwrap_or_else(|| "Normal".into());
let mut o = json!({"index": i, "type": "paragraph", "style": style, "len": p.len()});
if with_text {
o["text"] = json!(p.plain_text());
}
if let Some(n) = p.props.numbering.filter(|n| n.num != 0) {
o["list"] = json!({"num": n.num, "level": n.level});
}
if !p.props.is_empty() {
o["props"] = serde_json::to_value(&p.props).unwrap_or(Value::Null);
}
let runs: Vec<Value> = p
.run_ranges()
.filter(|(_, c)| !c.is_empty())
.map(|(r, c)| json!({"start": r.start, "end": r.end, "props": c}))
.take(50)
.collect();
if !runs.is_empty() {
o["runs"] = json!(runs);
}
if !p.objects.is_empty() {
o["objects"] = serde_json::to_value(&p.objects).unwrap_or(Value::Null);
}
if p.section.is_some() {
o["sectionBreak"] = json!(true);
}
blocks.push(o);
}
Block::Table(t) => {
let cells: Vec<Vec<String>> = t
.rows
.iter()
.map(|r| {
r.cells
.iter()
.map(|c| c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n"))
.collect()
})
.collect();
blocks.push(json!({"index": i, "type": "table", "rows": t.rows.len(), "cols": t.cols(), "style": t.props.style, "cells": cells}));
}
}
}
let l = s.layout();
let parts: Vec<Value> =
s.doc.parts.iter().map(|(id, p)| json!({"id": id, "kind": p.kind, "text": s.doc.plain_text(StoryRef::Part(*id))})).collect();
Ok(json!({
"blocks": blocks,
"parts": parts,
"selection": {"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus), "text": s.selected_text()},
"pages": l.pages.len(),
"words": s.doc.word_count(),
"sections": s.doc.sections().iter().map(|(end, sp)| json!({"endBlock": end, "props": sp})).collect::<Vec<_>>(),
"comments": s.doc.comments.len(),
"trackChanges": s.doc.settings.track_changes,
"properties": s.doc.core,
"canUndo": s.can_undo(),
"canRedo": s.can_redo(),
}))
}
fn paragraph(s: &mut Session, v: &Value) -> CmdResult {
let path: Vec<u32> = serde_json::from_value(v.get("path").cloned().unwrap_or(json!([0]))).map_err(|e| CmdError::Params(e.to_string()))?;
let story = super::story_param(s, v);
let p = s.doc.para(story, &wordcraft_doc::Path(path)).ok_or_else(|| CmdError::Params("no paragraph there".into()))?;
let resolved = s.doc.styles.resolve_para(&p.props);
Ok(json!({"paragraph": p, "resolved": resolved}))
}
fn layout_summary(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
let pages: Vec<Value> = l
.pages
.iter()
.map(|p| {
let lines: usize = p.items.iter().map(|it| if let wordcraft_layout::Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 }).sum();
json!({"number": p.number, "w": p.w, "h": p.h, "section": p.section, "lines": lines, "firstBlock": p.first_block, "header": p.header_story, "footer": p.footer_story})
})
.collect();
let caret = l.caret_on(&s.sel.focus, s.page_hint).map(|c| json!({"page": c.page, "x": c.x, "y": c.top, "h": c.height}));
Ok(json!({"pages": pages, "caret": caret, "ms": l.ms}))
}