mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 04:47:10 -06:00
396 lines
16 KiB
Rust
396 lines
16 KiB
Rust
//! Tracked formatting changes (#41): recording them while Track Changes is on, describing them
|
|
//! ("Formatted: Bold, Size: 14 pt") and accepting or rejecting them.
|
|
//!
|
|
//! Recording isn't done by each formatting command: after any command that edits the document,
|
|
//! [`record`] compares the paragraphs it changed with the document before it. Where a paragraph
|
|
//! kept its text but its runs, mark or properties were formatted differently, the change is kept
|
|
//! as a formatting revision holding the formatting before it (`w:rPrChange` / `w:pPrChange`).
|
|
//! Like Word, an author's later changes to the same text keep the formatting before their first
|
|
//! one, and formatting changed back to what it was stops being a change.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use serde::Serialize;
|
|
use serde_json::{Value, json};
|
|
use wordcraft_doc::props::VMerge;
|
|
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
|
|
use wordcraft_doc::section::SectionProps;
|
|
use wordcraft_doc::table::{Cell, Row, Table};
|
|
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Revision, RevisionKind};
|
|
|
|
use crate::Session;
|
|
|
|
/// Commands whose formatting edits aren't the author's own changes: accepting or rejecting
|
|
/// changes, comparing documents, undo and redo, and opening or replacing the document.
|
|
fn records(id: &str) -> bool {
|
|
!(id.starts_with("review.accept")
|
|
|| id.starts_with("review.reject")
|
|
|| matches!(id, "review.compare" | "review.combine" | "edit.undo" | "edit.redo")
|
|
|| id.starts_with("file."))
|
|
}
|
|
|
|
/// Tables nested deeper than this aren't compared (hostile documents).
|
|
const MAX_DEPTH: usize = 16;
|
|
|
|
/// A paragraph's, table's or cell's tracked formatting change, if it carries one.
|
|
type Slot<T> = Option<Box<PropChange<T>>>;
|
|
|
|
/// What a [`Slot`] should become (which may be no change at all).
|
|
struct Replacement<T>(Slot<T>);
|
|
|
|
struct Recorder<'a> {
|
|
author: String,
|
|
date: String,
|
|
before: &'a [Revision],
|
|
revisions: &'a mut Vec<Revision>,
|
|
rid: Option<u32>,
|
|
}
|
|
|
|
impl Recorder<'_> {
|
|
/// The author's formatting revision for this command (made on first use).
|
|
fn rid(&mut self) -> u32 {
|
|
if let Some(r) = self.rid {
|
|
return r;
|
|
}
|
|
let found = self.revisions.iter().rposition(|r| r.kind == RevisionKind::Format && r.author == self.author && r.date == self.date);
|
|
let r = match found {
|
|
Some(i) => i as u32,
|
|
None => {
|
|
self.revisions.push(Revision { kind: RevisionKind::Format, author: self.author.clone(), date: self.date.clone(), move_name: None });
|
|
self.revisions.len().saturating_sub(1) as u32
|
|
}
|
|
};
|
|
self.rid = Some(r);
|
|
r
|
|
}
|
|
|
|
fn own(&self, rev: u32) -> bool {
|
|
self.before.get(rev as usize).is_some_and(|r| r.author == self.author)
|
|
}
|
|
|
|
/// The tracked change `after` should carry, given the props `before` the command (`f` strips
|
|
/// everything that isn't formatting); `None` when it already carries the right one.
|
|
fn change<T: Clone + PartialEq>(&mut self, before: &T, after: &T, f: fn(&T) -> T, get: fn(&T) -> &Slot<T>) -> Option<Replacement<T>> {
|
|
let (fb, fa) = (f(before), f(after));
|
|
let (had, has) = (get(before), get(after));
|
|
let want = if fb == fa {
|
|
// Not formatted by this command: keep the change it carries (or carried).
|
|
if has.is_some() {
|
|
return None;
|
|
}
|
|
had.clone()
|
|
} else {
|
|
// The author's own earlier change keeps the formatting before it; another author's
|
|
// is superseded by this one.
|
|
let own = had.as_ref().filter(|c| self.own(c.rev));
|
|
let old = own.map(|c| c.old.clone()).unwrap_or(fb);
|
|
if old == fa {
|
|
None
|
|
} else {
|
|
let rev = own.map(|c| c.rev).unwrap_or_else(|| self.rid());
|
|
PropChange::boxed(rev, old)
|
|
}
|
|
};
|
|
(want != *has).then_some(Replacement(want))
|
|
}
|
|
|
|
/// Record the formatting changes between `b` and `a` (the same text) into `a`.
|
|
fn para(&mut self, b: &Paragraph, a: &mut Paragraph) {
|
|
let mut runs = Vec::new();
|
|
let (rb, ra): (Vec<_>, Vec<_>) = (b.run_ranges().collect(), a.run_ranges().collect());
|
|
let (mut i, mut j) = (0, 0);
|
|
while let (Some((x, pb)), Some((y, pa))) = (rb.get(i), ra.get(j)) {
|
|
let (from, to) = (x.start.max(y.start), x.end.min(y.end));
|
|
// Formatting text the author is inserting themselves changes the insertion, as in Word.
|
|
let own_insert = pa.ins.and_then(|i| self.revisions.get(i as usize)).is_some_and(|r| r.author == self.author);
|
|
if from < to
|
|
&& !own_insert
|
|
&& let Some(want) = self.change(*pb, *pa, CharProps::formatting, |c| &c.fmt_change)
|
|
{
|
|
runs.push((from, to, want.0));
|
|
}
|
|
if x.end <= y.end {
|
|
i += 1;
|
|
}
|
|
if y.end <= x.end {
|
|
j += 1;
|
|
}
|
|
}
|
|
let mark = self.change(&b.mark, &a.mark, CharProps::formatting, |c| &c.fmt_change);
|
|
let props = self.change(&b.props, &a.props, ParaProps::formatting, |p| &p.fmt_change);
|
|
if runs.is_empty() && mark.is_none() && props.is_none() {
|
|
return;
|
|
}
|
|
for (from, to, want) in runs {
|
|
let _ = a.format(from, to, &|c| c.fmt_change = want.clone());
|
|
}
|
|
if let Some(Replacement(m)) = mark {
|
|
a.mark.fmt_change = m;
|
|
}
|
|
if let Some(Replacement(p)) = props {
|
|
a.props.fmt_change = p;
|
|
}
|
|
a.normalize();
|
|
a.touch();
|
|
}
|
|
|
|
fn blocks(&mut self, before: &Blocks, after: &mut Blocks, depth: usize) {
|
|
if before.len() != after.len() || depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
for (b, a) in before.iter().zip(after.iter_mut()) {
|
|
if Arc::ptr_eq(b, a) {
|
|
continue;
|
|
}
|
|
match (&**b, &**a) {
|
|
(Block::Para(pb), Block::Para(pa)) if pb.text == pa.text => {
|
|
if let Block::Para(pa) = Arc::make_mut(a) {
|
|
self.para(pb, pa);
|
|
// The section's properties on the paragraph (`w:sectPrChange`).
|
|
let (Some(b), Some(x)) = (pb.section.as_deref(), pa.section.as_deref_mut()) else { continue };
|
|
if let Some(Replacement(w)) = self.change(b, x, strip_section, |s| &s.fmt_change) {
|
|
x.fmt_change = w;
|
|
}
|
|
}
|
|
}
|
|
(Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() && tb.grid == ta.grid => {
|
|
let Block::Table(ta) = Arc::make_mut(a) else { continue };
|
|
if let Some(Replacement(w)) = self.change(&tb.props, &ta.props, strip_table, |t| &t.fmt_change) {
|
|
ta.props.fmt_change = w;
|
|
}
|
|
for (rb, ra) in tb.rows.iter().zip(ta.rows.iter_mut()) {
|
|
if let Some(Replacement(w)) = self.change(&rb.props, &ra.props, strip_row, |r| &r.fmt_change) {
|
|
ra.props.fmt_change = w;
|
|
}
|
|
if rb.cells.len() != ra.cells.len() {
|
|
continue;
|
|
}
|
|
for (cb, ca) in rb.cells.iter().zip(ra.cells.iter_mut()) {
|
|
// Merges are structure, not formatting: span and vertical merge stay out.
|
|
if let Some(Replacement(w)) = self.change(&cb.props, &ca.props, strip_cell, |c| &c.fmt_change) {
|
|
ca.props.fmt_change = w;
|
|
}
|
|
self.blocks(&cb.blocks, &mut ca.blocks, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The formatting-comparison strips: what isn't formatting in these properties. A cell's merge
|
|
/// is structure, a section's headers and footers are content.
|
|
fn strip_table(t: &TableProps) -> TableProps {
|
|
TableProps { fmt_change: None, ..t.clone() }
|
|
}
|
|
fn strip_row(r: &RowProps) -> RowProps {
|
|
RowProps { fmt_change: None, ..r.clone() }
|
|
}
|
|
fn strip_cell(c: &CellProps) -> CellProps {
|
|
CellProps { fmt_change: None, span: 0, vmerge: VMerge::None, ..c.clone() }
|
|
}
|
|
fn strip_section(s: &SectionProps) -> SectionProps {
|
|
SectionProps { fmt_change: None, headers: Default::default(), footers: Default::default(), ..s.clone() }
|
|
}
|
|
|
|
/// Keep the formatting changes the command `id` made to `s.doc` (compared with `before`) as
|
|
/// tracked formatting changes, when Track Changes and Track Formatting are on.
|
|
pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
|
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting || !records(id) {
|
|
return;
|
|
}
|
|
let author = s.author.clone();
|
|
let doc = &mut s.doc;
|
|
let mut r = Recorder { author, date: super::now_iso(), before: &before.revisions, revisions: &mut doc.revisions, rid: None };
|
|
r.blocks(&before.body, &mut doc.body, 0);
|
|
for (k, pb) in &before.parts {
|
|
if let Some(pa) = doc.parts.get_mut(k) {
|
|
r.blocks(&pb.blocks, &mut pa.blocks, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compare Documents: record how paragraph `new` (the revised version, same text) is formatted
|
|
/// differently from `old` as tracked formatting changes by `author`.
|
|
pub(crate) fn compare_formatting(old: &Paragraph, new: &mut Paragraph, revisions: &mut Vec<Revision>, author: &str, date: &str) {
|
|
let mut r = Recorder { author: author.to_string(), date: date.to_string(), before: &[], revisions, rid: None };
|
|
r.para(old, new);
|
|
}
|
|
|
|
/// The properties a change sets, as the Style Inspector lists them: `[{"prop", "value"}]` in
|
|
/// field order; a switch turned off is `false`, a value removed is `null`.
|
|
pub fn diff<T: Serialize>(old: &T, new: &T) -> Vec<Value> {
|
|
let (Ok(Value::Object(o)), Ok(Value::Object(n))) = (serde_json::to_value(old), serde_json::to_value(new)) else { return Vec::new() };
|
|
let mut out = Vec::new();
|
|
for (k, v) in &n {
|
|
if o.get(k) != Some(v) {
|
|
out.push(json!({"prop": k, "value": v}));
|
|
}
|
|
}
|
|
for (k, v) in &o {
|
|
if !n.contains_key(k) {
|
|
out.push(json!({"prop": k, "value": if v.is_boolean() { json!(false) } else { Value::Null }}));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The properties a run's (or paragraph mark's) change set.
|
|
pub fn char_diff(c: &CharProps) -> Vec<Value> {
|
|
c.fmt_change.as_ref().map(|ch| diff(&ch.old.formatting(), &c.formatting())).unwrap_or_default()
|
|
}
|
|
|
|
/// The properties a paragraph's change set.
|
|
pub fn para_diff(p: &ParaProps) -> Vec<Value> {
|
|
p.fmt_change.as_ref().map(|ch| diff(&ch.old.formatting(), &p.formatting())).unwrap_or_default()
|
|
}
|
|
|
|
/// A section's change: the properties it set.
|
|
pub fn section_diff(s: &SectionProps) -> Vec<Value> {
|
|
let strip = |s: &SectionProps| SectionProps { fmt_change: None, headers: Default::default(), footers: Default::default(), ..s.clone() };
|
|
s.fmt_change.as_ref().map(|ch| diff(&strip(&ch.old), &strip(s))).unwrap_or_default()
|
|
}
|
|
|
|
/// "Formatted: Bold, Not italic, Size: 14" — the change in plain English, for agents.
|
|
pub fn describe(props: &[Value]) -> String {
|
|
let items: Vec<String> = props
|
|
.iter()
|
|
.map(|d| {
|
|
let label = words(d["prop"].as_str().unwrap_or(""));
|
|
match &d["value"] {
|
|
Value::Bool(true) => label,
|
|
Value::Bool(false) => format!("Not {}", label.to_lowercase()),
|
|
Value::Null => format!("{label}: (default)"),
|
|
Value::String(s) => format!("{label}: {s}"),
|
|
Value::Number(n) => {
|
|
let x = n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0);
|
|
let t = format!("{x:.2}");
|
|
format!("{label}: {}", t.trim_end_matches('0').trim_end_matches('.'))
|
|
}
|
|
v => format!("{label}: {v}"),
|
|
}
|
|
})
|
|
.collect();
|
|
if items.is_empty() { "Formatted".into() } else { format!("Formatted: {}", items.join(", ")) }
|
|
}
|
|
|
|
/// `indentLeft` → `Indent left`.
|
|
fn words(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len() + 4);
|
|
for (i, c) in s.chars().take(64).enumerate() {
|
|
if i == 0 {
|
|
out.extend(c.to_uppercase());
|
|
} else if c.is_uppercase() {
|
|
out.push(' ');
|
|
out.extend(c.to_lowercase());
|
|
} else {
|
|
out.push(c);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Accept (drop the record) or reject (restore the old formatting) a run's change.
|
|
pub fn resolve_char(c: &mut CharProps, accept: bool) {
|
|
let Some(ch) = c.fmt_change.take() else { return };
|
|
if !accept {
|
|
*c = CharProps { link: c.link.take(), ins: c.ins, del: c.del, ..ch.old.formatting() };
|
|
}
|
|
}
|
|
|
|
/// Accept or reject a paragraph's change (its list numbering change goes with it).
|
|
pub fn resolve_para(p: &mut ParaProps, accept: bool) {
|
|
p.num_change = None;
|
|
let Some(ch) = p.fmt_change.take() else { return };
|
|
if !accept {
|
|
*p = ch.old.formatting();
|
|
}
|
|
}
|
|
|
|
/// Accept or reject every table, row, cell and section change in `blocks` (paragraph changes
|
|
/// are resolved with their text).
|
|
pub fn resolve_containers(blocks: &mut Blocks, accept: bool, depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
for b in blocks.iter_mut() {
|
|
let needs = match &**b {
|
|
Block::Para(p) => p.section.as_ref().is_some_and(|s| s.fmt_change.is_some()),
|
|
Block::Table(_) => true,
|
|
};
|
|
if !needs {
|
|
continue;
|
|
}
|
|
match Arc::make_mut(b) {
|
|
Block::Para(p) => {
|
|
if let Some(s) = p.section.as_deref_mut() {
|
|
resolve_section(s, accept);
|
|
p.touch();
|
|
}
|
|
}
|
|
Block::Table(t) => {
|
|
resolve_table(t, accept);
|
|
for r in &mut t.rows {
|
|
resolve_row(r, accept);
|
|
for c in &mut r.cells {
|
|
resolve_cell(c, accept);
|
|
resolve_containers(&mut c.blocks, accept, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Accept or reject a table's own change (not its rows' or cells').
|
|
pub fn resolve_table(t: &mut Table, accept: bool) {
|
|
if let Some(ch) = t.props.fmt_change.take().filter(|_| !accept) {
|
|
t.props = TableProps { fmt_change: None, ..ch.old };
|
|
}
|
|
}
|
|
|
|
/// Accept or reject a row's change.
|
|
pub fn resolve_row(r: &mut Row, accept: bool) {
|
|
if let Some(ch) = r.props.fmt_change.take().filter(|_| !accept) {
|
|
r.props = RowProps { fmt_change: None, ..ch.old };
|
|
}
|
|
}
|
|
|
|
/// Accept or reject a cell's change. Merges are the table's structure, not formatting: they stay.
|
|
pub fn resolve_cell(c: &mut Cell, accept: bool) {
|
|
if let Some(ch) = c.props.fmt_change.take().filter(|_| !accept) {
|
|
c.props = CellProps { fmt_change: None, span: c.props.span, vmerge: c.props.vmerge, ..ch.old };
|
|
}
|
|
}
|
|
|
|
/// Accept or reject a section's change (its headers and footers stay).
|
|
pub fn resolve_section(s: &mut SectionProps, accept: bool) {
|
|
let Some(ch) = s.fmt_change.take() else { return };
|
|
if !accept {
|
|
let (headers, footers) = (std::mem::take(&mut s.headers), std::mem::take(&mut s.footers));
|
|
*s = SectionProps { headers, footers, fmt_change: None, ..ch.old };
|
|
}
|
|
}
|
|
|
|
/// A table's, row's or cell's change: the properties it set.
|
|
pub fn table_diff(t: &TableProps) -> Vec<Value> {
|
|
t.fmt_change
|
|
.as_ref()
|
|
.map(|ch| diff(&TableProps { fmt_change: None, ..ch.old.clone() }, &TableProps { fmt_change: None, ..t.clone() }))
|
|
.unwrap_or_default()
|
|
}
|
|
pub fn row_diff(r: &RowProps) -> Vec<Value> {
|
|
r.fmt_change
|
|
.as_ref()
|
|
.map(|ch| diff(&RowProps { fmt_change: None, ..ch.old.clone() }, &RowProps { fmt_change: None, ..r.clone() }))
|
|
.unwrap_or_default()
|
|
}
|
|
pub fn cell_diff(c: &CellProps) -> Vec<Value> {
|
|
c.fmt_change
|
|
.as_ref()
|
|
.map(|ch| diff(&CellProps { fmt_change: None, ..ch.old.clone() }, &CellProps { fmt_change: None, ..c.clone() }))
|
|
.unwrap_or_default()
|
|
}
|