mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-02 06:47:11 -06:00
The Info page edited a copy of the document properties that was re-read from the document every frame and only written back when a field lost focus, so each keystroke vanished on the next frame and nothing was ever committed. The fields now commit through file.properties as you type (undoable, one step per visit to a field, marks the document dirty). The web build's Save writes the bytes itself instead of running file.save, so it never stamped the modified time, last-modified-by and revision. Both paths now share Session::stamp_save. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
14 KiB
Rust
312 lines
14 KiB
Rust
//! 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.
|
||
pub const MAX_AUTHOR_CHARS: usize = 255;
|
||
|
||
/// Sets the user name that new tracked changes and comments are recorded under. The name is
|
||
/// 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.
|
||
fn set_author(s: &mut Session, v: &Value) -> CmdResult {
|
||
let name: String = p::req_str(v, "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()));
|
||
}
|
||
s.author = name.to_string();
|
||
let mut r = sel_result(s)?;
|
||
if let Some(o) = r.as_object_mut() {
|
||
o.insert("name".into(), json!(s.author));
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
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").params(r#"{"path": string}"#).pure(),
|
||
CommandSpec::new("file.save", "Save", "File", save).key("Mod+S").params(r#"{"path"?: string}"#).pure(),
|
||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string}"#).pure(),
|
||
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
|
||
let path = p::req_str(v, "path")?;
|
||
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
|
||
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
|
||
Ok(json!({"path": path}))
|
||
})
|
||
.params(r#"{"path": string}"#)
|
||
.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}"#)
|
||
.pure(),
|
||
CommandSpec::new("file.print", "Print", "File", |s, _| {
|
||
s.ui_requests.push(json!({"open": "print"}));
|
||
sel_result(s)
|
||
})
|
||
.key("Mod+P")
|
||
.pure(),
|
||
CommandSpec::new("file.close", "Close", "File", |s, _| {
|
||
s.ui_requests.push(json!({"close": true}));
|
||
sel_result(s)
|
||
})
|
||
.key("Mod+W")
|
||
.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.options", "Options", "File", |s, _| {
|
||
s.ui_requests.push(json!({"open": "options"}));
|
||
sel_result(s)
|
||
})
|
||
.pure(),
|
||
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
|
||
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
|
||
.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.plain_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}"#),
|
||
]
|
||
}
|
||
|
||
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(),
|
||
_ => Document::new(),
|
||
};
|
||
s.set_document(doc);
|
||
s.path = None;
|
||
sel_result(s)
|
||
}
|
||
|
||
fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||
let Some(path) = p::str(v, "path") else {
|
||
s.ui_requests.push(json!({"open": "openFile"}));
|
||
return sel_result(s);
|
||
};
|
||
let doc = if let Some(data) = p::str(v, "data") {
|
||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||
crate::io::open_bytes(path, &bytes).map_err(CmdError::Failed)?
|
||
} else {
|
||
crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?
|
||
};
|
||
s.set_document(doc);
|
||
s.path = Some(path.into());
|
||
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count()}))
|
||
}
|
||
|
||
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.ui_requests.push(json!({"open": "saveAs"}));
|
||
return Ok(json!({"saved": false}));
|
||
}
|
||
};
|
||
s.stamp_save();
|
||
crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?;
|
||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) {
|
||
s.path = Some(path.clone());
|
||
s.dirty = false;
|
||
}
|
||
Ok(json!({"saved": true, "path": path.to_string_lossy()}))
|
||
}
|
||
|
||
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||
if p::str(v, "path").is_none() {
|
||
s.ui_requests.push(json!({"open": "saveAs"}));
|
||
return Ok(json!({"saved": false}));
|
||
}
|
||
save(s, v)
|
||
}
|
||
|
||
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||
let path = p::req_str(v, "path")?;
|
||
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();
|
||
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
|
||
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
|
||
let png = img.to_png();
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
std::fs::write(path, &png).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
|
||
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(),
|
||
"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}))
|
||
}
|