Files
wordcraft/crates/engine/src/cmd/review/changes.rs
T

638 lines
28 KiB
Rust

use super::{CmdError, CmdResult, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, fmt_revisions, json, p, pos_json, sel_result};
/// Accept or reject the tracked formatting and text of `from..to` in one paragraph (not its mark).
fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, props: bool, accept: bool) -> Result<(), CmdError> {
if props && (para.props.fmt_change.is_some() || para.props.num_change.is_some()) {
fmt_revisions::resolve_para(&mut para.props, accept);
para.touch();
}
let ranges: Vec<(usize, usize, bool, bool, bool)> = para
.run_ranges()
.filter(|(r, _)| r.end > from && r.start < to)
.map(|(r, c)| (r.start.max(from), r.end.min(to), c.ins.is_some(), c.del.is_some(), c.fmt_change.is_some()))
.collect();
for (a, b, ins, del, fmt) in ranges.into_iter().rev() {
if (del && accept) || (ins && !accept) {
para.delete(a, b)?;
} else if ins || del || fmt {
para.format(a, b, &|c| {
c.ins = None;
c.del = None;
fmt_revisions::resolve_char(c, accept);
})?;
}
}
Ok(())
}
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
/// the section break on it), `props` the paragraph's own formatting change.
fn resolve_para(
s: &mut Session,
story: StoryRef,
path: &wordcraft_doc::Path,
(from, to): (usize, usize),
(mark, props): (bool, bool),
accept: bool,
) -> Result<(), CmdError> {
let para = s.doc.para_mut(story, path)?;
resolve_runs(para, from, to, props, accept)?;
if !mark {
return Ok(());
}
if para.mark.fmt_change.is_some() || para.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) {
fmt_revisions::resolve_char(&mut para.mark, accept);
if let Some(sec) = para.section.as_deref_mut() {
fmt_revisions::resolve_section(sec, accept);
}
para.touch();
}
let (ins, del) = (para.mark.ins, para.mark.del);
if ins.is_none() && del.is_none() {
return Ok(());
}
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
// the next paragraph joins this one (the split is undone). A paragraph left empty goes away
// whole, so the next one keeps its own style and numbering, as in Word. The mark at the end
// of a story or cell can't go, so it just stops being a revision.
if ((del.is_some() && accept) || (ins.is_some() && !accept))
&& (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?)
{
return Ok(());
}
let para = s.doc.para_mut(story, path)?;
para.mark.ins = None;
para.mark.del = None;
para.touch();
Ok(())
}
/// Accept or reject the changes of the table cell `path` is in: the table's, its row's and the
/// cell's own. Returns whether there were any.
fn resolve_cell(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path, accept: bool) -> Result<bool, CmdError> {
let Some((tp, r, c)) = path.cell() else { return Ok(false) };
let has = s.doc.table(story, &tp).is_some_and(|t| {
let row = t.rows.get(r);
t.props.fmt_change.is_some()
|| row.is_some_and(|x| x.props.fmt_change.is_some())
|| row.and_then(|x| x.cells.get(c)).is_some_and(|x| x.props.fmt_change.is_some())
});
if !has {
return Ok(false);
}
let t = s.doc.table_mut(story, &tp)?;
fmt_revisions::resolve_table(t, accept);
if let Some(row) = t.rows.get_mut(r) {
fmt_revisions::resolve_row(row, accept);
if let Some(cell) = row.cells.get_mut(c) {
fmt_revisions::resolve_cell(cell, accept);
}
}
Ok(true)
}
/// A tracked change, for navigation and the Reviewing Pane: its range, kind (`insert`,
/// `delete`, `format`), revision, and for a formatting change the properties it set.
struct Change {
a: Pos,
b: Pos,
kind: &'static str,
rev: Option<u32>,
props: Vec<Value>,
}
impl Change {
fn format(a: Pos, b: Pos, rev: u32, props: Vec<Value>) -> Change {
Change { a, b, kind: "format", rev: Some(rev), props }
}
}
/// The tracked paragraph mark at the end of the paragraph at `path`, as a change range: from
/// the paragraph's end to the start of the next paragraph (or the end itself when none follows).
fn mark_change(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Option<Change> {
let p = s.doc.para(story, path)?;
let kind = run_kind(&s.doc, p.mark.ins, p.mark.del);
if kind.is_empty() {
return None;
}
let a = Pos { story, path: path.clone(), off: p.len() };
let b = if crate::cmd::has_next_para(s, story, path) {
Pos { story, path: path.with_last(path.last().saturating_add(1)), off: 0 }
} else {
a.clone()
};
Some(Change { a, b, kind, rev: p.mark.ins.or(p.mark.del), props: Vec::new() })
}
/// The kind of an insertion / deletion change: `insert`, `delete`, or for one end of a move
/// `moveTo` / `moveFrom`; empty when neither.
fn run_kind(doc: &wordcraft_doc::Document, ins: Option<u32>, del: Option<u32>) -> &'static str {
match (ins, del) {
(Some(_), _) if doc.move_name(ins).is_some() => "moveTo",
(Some(_), _) => "insert",
(None, Some(_)) if doc.move_name(del).is_some() => "moveFrom",
(None, Some(_)) => "delete",
_ => "",
}
}
/// The moves in the body, for balloons: where each end starts, its author, and whether it is
/// the source (`true`) or the destination.
pub fn move_changes(s: &Session) -> Vec<(Pos, String, bool)> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for c in changes(s) {
let from = match c.kind {
"moveFrom" => true,
"moveTo" => false,
_ => continue,
};
let rev = c.rev.and_then(|r| s.doc.revisions.get(r as usize));
let name = rev.and_then(|r| r.move_name.clone()).unwrap_or_default();
// One balloon per end (an end runs on across paragraphs).
if seen.insert((name, from)) {
out.push((c.a, rev.map(|r| r.author.clone()).unwrap_or_default(), from));
}
}
out
}
/// Accept or reject the tracked text and paragraph marks of the top-level paragraphs in a block
/// list that is not a story (a note separator).
fn resolve_loose_blocks(blocks: &mut wordcraft_doc::Blocks, accept: bool) -> Result<(), CmdError> {
for b in blocks.iter_mut() {
let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) else { continue };
let len = p.len();
resolve_runs(p, 0, len, true, accept)?;
if p.mark.fmt_change.is_some() || p.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) {
fmt_revisions::resolve_char(&mut p.mark, accept);
if let Some(sec) = p.section.as_deref_mut() {
fmt_revisions::resolve_section(sec, accept);
}
}
// A mark can't be removed here (no neighbour to join), so it just stops being a revision.
p.mark.ins = None;
p.mark.del = None;
p.touch();
}
fmt_revisions::resolve_containers(blocks, accept, 0);
Ok(())
}
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for st in stories {
for path in s.doc.para_paths(st).into_iter().rev() {
let len = s.doc.para(st, &path).map(|p| p.len()).unwrap_or(0);
resolve_para(s, st, &path, (0, len), (true, true), accept)?;
}
}
// Tables, rows, cells and sections.
fmt_revisions::resolve_containers(&mut s.doc.body, accept, 0);
for part in s.doc.parts.values_mut() {
fmt_revisions::resolve_containers(&mut part.blocks, accept, 0);
}
fmt_revisions::resolve_section(&mut s.doc.last_section, accept);
// The note separators are block lists of their own, not stories; they must not keep ids of
// revisions that are about to be dropped.
for sep in [&mut s.doc.footnote_separators, &mut s.doc.endnote_separators] {
for blocks in [&mut sep.separator, &mut sep.continuation_separator, &mut sep.continuation_notice].into_iter().flatten() {
resolve_loose_blocks(blocks, accept)?;
}
}
s.doc.revisions.clear();
s.clamp_selection();
sel_result(s)
}
/// Accept or Reject All; with `stopTracking`, Track Changes is turned off as well.
pub(super) fn resolve_all_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
let out = resolve_all(s, accept)?;
if p::bool(v, "stopTracking") == Some(true) {
s.doc.settings.track_changes = false;
}
Ok(out)
}
/// Accept or Reject at the selection; with `next`, the next change is selected afterwards.
pub(super) fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
resolve_sel(s, accept, p::bool(v, "next") == Some(true))
}
/// The span of the tracked change around `off` in `p`: its run, joined across the comment anchors
/// that split one revision's text into several runs.
fn revision_span(p: &Paragraph, off: usize) -> Option<std::ops::Range<usize>> {
let runs: Vec<_> = p.run_ranges().collect();
let i = runs.iter().position(|(r, c)| r.start <= off && off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
let key = (runs[i].1.ins, runs[i].1.del);
if key == (None, None) {
return Some(start..end);
}
// Comment anchors split a revision's text into several runs; the revision goes on past them.
let is_anchor = |k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
let mut k = i;
while k > 0 {
if same(k - 1) {
k -= 1;
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
k -= 2;
} else {
break;
}
start = runs[k].0.start;
}
let mut k = i;
while k + 1 < runs.len() {
if same(k + 1) {
k += 1;
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
k += 2;
} else {
break;
}
end = runs[k].0.end;
}
Some(start..end)
}
fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
let (a, b) = s.sel.ordered();
// A caret right before a tracked paragraph mark (and no tracked text around it): that mark.
let mut caret_mark = None;
// A caret in a paragraph whose own formatting changed (and no tracked text around it).
let mut caret_props = None;
let ranged = a != b;
let (a, b) = if a == b {
// The change at the caret: the run around it, else the paragraph mark right after it,
// else the paragraph's formatting, else the table cell's.
let found = s.doc.para_at(&a).and_then(|p| revision_span(p, a.off));
let para_fmt = s.doc.para_at(&a).is_some_and(|p| p.props.fmt_change.is_some() || p.props.num_change.is_some());
let mark_fmt = s.doc.para_at(&a).is_some_and(|p| a.off == p.len() && p.mark.fmt_change.is_some());
match found {
Some(r) => (Pos { off: r.start, ..a.clone() }, Pos { off: r.end, ..a }),
None if mark_fmt || mark_change(s, a.story, &a.path).is_some_and(|m| m.a == a) => {
caret_mark = Some(a.path.clone());
(a.clone(), a)
}
None if para_fmt => {
caret_props = Some(a.path.clone());
(a.clone(), a)
}
None => {
if resolve_cell(s, a.story, &a.path, accept)? {
return if next { select_next_unresolved(s) } else { sel_result(s) };
}
return nav_change(s, 1);
}
}
} else {
(a, b)
};
// Moves with an end in the range: their other ends go with them.
let moves = crate::cmd::moves::names_in(s, &a, &b, caret_mark.as_ref());
for path in s.doc.paths_between(&a, &b).into_iter().rev() {
let len = s.doc.para(a.story, &path).map(|p| p.len()).unwrap_or(0);
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { len };
// A paragraph's mark is in the selection when the selection goes on past it.
let mark = path != b.path || caret_mark.as_ref() == Some(&path);
let props = (ranged && (path != b.path || to > from)) || caret_props.as_ref() == Some(&path);
resolve_para(s, a.story, &path, (from, to), (mark, props), accept)?;
if ranged {
resolve_cell(s, a.story, &path, accept)?;
}
}
crate::cmd::moves::resolve(s, &moves, accept)?;
s.sel = Selection::caret(a);
s.clamp_selection();
if next { select_next_unresolved(s) } else { sel_result(s) }
}
fn changes(s: &Session) -> Vec<Change> {
let mut out: Vec<Change> = Vec::new();
let mk = |path: &wordcraft_doc::Path, off| Pos { story: StoryRef::Body, path: path.clone(), off };
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
// A table's, row's and cell's changes sit at the start of the cell's first paragraph.
if path.last() == 0
&& let Some((tp, r, c)) = path.cell()
&& let Some(t) = s.doc.table(StoryRef::Body, &tp)
{
let row = t.rows.get(r);
let cell = row.and_then(|x| x.cells.get(c));
let found = [
(r == 0 && c == 0).then(|| t.props.fmt_change.as_ref().map(|ch| (ch.rev, fmt_revisions::table_diff(&t.props)))).flatten(),
(c == 0).then(|| row.and_then(|x| x.props.fmt_change.as_ref().map(|ch| (ch.rev, fmt_revisions::row_diff(&x.props))))).flatten(),
cell.and_then(|x| x.props.fmt_change.as_ref().map(|ch| (ch.rev, fmt_revisions::cell_diff(&x.props)))),
];
for (rev, props) in found.into_iter().flatten() {
out.push(Change::format(mk(&path, 0), mk(&path, 0), rev, props));
}
}
if let Some(ch) = &p.props.fmt_change {
out.push(Change::format(mk(&path, 0), mk(&path, p.len()), ch.rev, fmt_revisions::para_diff(&p.props)));
}
for (r, c) in p.run_ranges() {
let kind = run_kind(&s.doc, c.ins, c.del);
if !kind.is_empty() {
out.push(Change { a: mk(&path, r.start), b: mk(&path, r.end), kind, rev: c.ins.or(c.del), props: Vec::new() });
}
let Some(ch) = &c.fmt_change else { continue };
let props = fmt_revisions::char_diff(c);
// Neighbouring runs changed the same way by the same revision are one change.
if let Some(last) =
out.last_mut().filter(|l| l.kind == "format" && l.rev == Some(ch.rev) && l.b == mk(&path, r.start) && l.props == props)
{
last.b = mk(&path, r.end);
} else {
out.push(Change::format(mk(&path, r.start), mk(&path, r.end), ch.rev, props));
}
}
if let Some(ch) = &p.mark.fmt_change {
let end = mk(&path, p.len());
out.push(Change::format(end.clone(), end, ch.rev, fmt_revisions::char_diff(&p.mark)));
}
out.extend(mark_change(s, StoryRef::Body, &path));
if let Some(ch) = p.section.as_ref().and_then(|x| x.fmt_change.as_ref().map(|ch| (ch.rev, x))) {
let end = mk(&path, p.len());
out.push(Change::format(end.clone(), end, ch.0, fmt_revisions::section_diff(ch.1)));
}
}
if let Some(ch) = &s.doc.last_section.fmt_change {
let end = s.doc.end_of(StoryRef::Body);
out.push(Change::format(end.clone(), end, ch.rev, fmt_revisions::section_diff(&s.doc.last_section)));
}
out
}
/// The formatting changes in the body, for balloons: (position, author, properties set).
pub fn format_changes(s: &Session) -> Vec<(Pos, String, Vec<Value>)> {
changes(s)
.into_iter()
.filter(|c| c.kind == "format")
.map(|c| {
let author = c.rev.and_then(|r| s.doc.revisions.get(r as usize)).map(|r| r.author.clone()).unwrap_or_default();
(c.a, author, c.props)
})
.collect()
}
/// Whether the document has any tracked formatting change (cheap: stops at the first).
pub fn has_format_changes(doc: &wordcraft_doc::Document) -> bool {
if doc.last_section.fmt_change.is_some() {
return true;
}
doc.para_paths(StoryRef::Body).iter().any(|path| {
doc.para(StoryRef::Body, path).is_some_and(|p| {
p.props.fmt_change.is_some()
|| p.mark.fmt_change.is_some()
|| p.runs.iter().any(|r| r.props.fmt_change.is_some())
|| p.section.as_ref().is_some_and(|x| x.fmt_change.is_some())
|| path.cell().and_then(|(tp, r, c)| doc.table(StoryRef::Body, &tp).map(|t| (t, r, c))).is_some_and(|(t, r, c)| {
t.props.fmt_change.is_some()
|| t.rows.get(r).is_some_and(|x| x.props.fmt_change.is_some() || x.cells.get(c).is_some_and(|x| x.props.fmt_change.is_some()))
})
})
})
}
/// A tracked deletion in the body shown in a balloon (Show revisions in balloons).
#[derive(Clone, Debug, PartialEq)]
pub struct DeletionBalloon {
/// Where the deleted text was: the balloon's anchor.
pub at: Pos,
pub author: String,
/// The deleted text, a deleted paragraph mark as ¶, shortened to [`DELETION_TEXT_CHARS`].
pub text: String,
}
/// At most this many characters of deleted text show in a balloon (a deleted page shows its start).
pub const DELETION_TEXT_CHARS: usize = 200;
/// At most this many deletion balloons (a document of many small deletions can't flood the area).
const MAX_DELETION_BALLOONS: usize = 500;
/// The tracked deletions in the body, for balloons, in document order. Deleted text running on
/// across runs, revisions and paragraph marks by the same author is one balloon. The source of a
/// move is left out: it has its "Moved from" balloon ([`move_changes`]).
pub fn deletion_balloons(s: &Session) -> Vec<DeletionBalloon> {
let doc = &s.doc;
let author = |rev: u32| doc.revisions.get(rev as usize).map(|r| r.author.clone()).unwrap_or_default();
let mk = |path: &wordcraft_doc::Path, off| Pos { story: StoryRef::Body, path: path.clone(), off };
let mut out = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (r, c) in p.run_ranges() {
// Inserted and then deleted text is a deletion (it's drawn and left out as one).
let Some(rev) = plain_deletion(doc, c.del) else { continue };
push_deletion(&mut out, mk(&path, r.start), mk(&path, r.end), author(rev), p.text.get(r).unwrap_or(""));
}
if let Some(rev) = plain_deletion(doc, p.mark.del) {
let at = mk(&path, p.len());
let next = path.with_last(path.last().saturating_add(1));
let end = if crate::cmd::has_next_para(s, StoryRef::Body, &path) { mk(&next, 0) } else { at.clone() };
push_deletion(&mut out, at, end, author(rev), "¶");
}
}
out.into_iter().map(|(d, _, _)| d).collect()
}
/// Add `author`'s deleted `text` (from `at` to `end`) to `out`: to the last balloon when that
/// deletion ends at `at` and is theirs, else as a new one. Each balloon carries where its deletion
/// ends and how many characters its text has.
fn push_deletion(out: &mut Vec<(DeletionBalloon, Pos, usize)>, at: Pos, end: Pos, author: String, text: &str) {
let joins = out.last().is_some_and(|(d, e, _)| *e == at && d.author == author);
if !joins {
if out.len() >= MAX_DELETION_BALLOONS {
return;
}
out.push((DeletionBalloon { at, author, text: String::new() }, end.clone(), 0));
}
let Some((d, e, chars)) = out.last_mut() else { return };
*e = end;
for c in text.chars() {
if *chars > DELETION_TEXT_CHARS {
break;
}
let c = match c {
// Pictures, fields and other objects show no text of their own.
wordcraft_doc::para::OBJ => continue,
'\t' | wordcraft_doc::para::LINE_BREAK | wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK => ' ',
c if c.is_control() => continue,
c => c,
};
*chars += 1;
d.text.push(if *chars > DELETION_TEXT_CHARS { '…' } else { c });
}
}
/// A deletion revision that is not the source of a move (`w:moveFrom` is a deletion revision
/// with a move name).
fn plain_deletion(doc: &wordcraft_doc::Document, del: Option<u32>) -> Option<u32> {
del.filter(|_| doc.move_name(del).is_none())
}
/// Whether the body has any tracked deletion other than a move's source (cheap: stops at the
/// first).
pub fn has_deletions(doc: &wordcraft_doc::Document) -> bool {
doc.para_paths(StoryRef::Body).iter().any(|path| {
doc.para(StoryRef::Body, path)
.is_some_and(|p| plain_deletion(doc, p.mark.del).is_some() || p.runs.iter().any(|r| plain_deletion(doc, r.props.del).is_some()))
})
}
pub(super) fn nav_change(s: &mut Session, dir: i32) -> CmdResult {
let list = changes(s);
let caret = s.sel.ordered();
let t = if dir > 0 {
list.iter().find(|c| c.a >= caret.1 && (c.a > caret.0 || c.b > caret.1)).or(list.first())
} else {
list.iter().rev().find(|c| c.b <= caret.0 && (c.b < caret.1 || c.a < caret.0)).or(list.last())
};
if let Some(c) = t {
s.sel = Selection { anchor: c.a.clone(), focus: c.b.clone() };
}
sel_result(s)
}
/// After a change at the caret is resolved: select the first change from the caret on, including
/// one that starts and ends there (an empty paragraph's, a paragraph mark's or a table's
/// formatting), else the first change in the document.
fn select_next_unresolved(s: &mut Session) -> CmdResult {
let list = changes(s);
let caret = s.sel.ordered().0;
if let Some(c) = list.iter().find(|c| c.a >= caret).or(list.first()) {
s.sel = Selection { anchor: c.a.clone(), focus: c.b.clone() };
}
sel_result(s)
}
pub(super) fn list_changes(s: &mut Session, _: &Value) -> CmdResult {
let list = changes(s);
Ok(Value::Array(
list.iter()
.map(|c| {
let rev = c.rev.and_then(|r| s.doc.revisions.get(r as usize));
let mut v = json!({
"kind": c.kind, "start": pos_json(&c.a), "end": pos_json(&c.b),
"text": s.doc.copy_range(&c.a, &c.b).plain_text(),
"author": rev.map(|r| r.author.clone()), "date": rev.map(|r| r.date.clone()),
});
if c.kind == "format" {
v["description"] = json!(fmt_revisions::describe(&c.props));
v["props"] = json!(c.props);
}
// One end of a move: its name links it with the other end.
if let Some(name) = rev.and_then(|r| r.move_name.as_deref()).filter(|_| c.kind.starts_with("move")) {
v["move"] = json!(name);
v["description"] = json!(if c.kind == "moveFrom" { "Moved from" } else { "Moved to" });
}
v
})
.collect(),
))
}
/// Track Changes Options: change the fields given, return them all. A per-user preference.
pub(super) fn tracking_options(s: &mut Session, v: &Value) -> CmdResult {
use wordcraft_doc::Rgb;
use wordcraft_layout::display::{BalloonMode, ChangeBar, CommentDisplay, DeleteMark, InsertMark};
let mut m = s.prefs.markup.clone();
let color = |k: &str, auto: &str, cur: Option<Rgb>| -> Result<Option<Rgb>, CmdError> {
match p::str(v, k) {
None => Ok(cur),
Some(c) if c == auto => Ok(None),
Some(c) => Rgb::parse(c).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: \"{auto}\" or RRGGBB"))),
}
};
let pick = |k: &str| p::str(v, k);
for (k, f) in [
("comments", &mut m.comments),
("ink", &mut m.ink),
("insertionsDeletions", &mut m.insertions_deletions),
("formatting", &mut m.formatting),
("trackFormatting", &mut m.track_formatting),
] {
if let Some(b) = p::bool(v, k) {
*f = b;
}
}
if let Some(x) = pick("balloons") {
m.balloons = BalloonMode::parse(x).ok_or_else(|| CmdError::Params("`balloons`: revisions|inline|commentsAndFormatting".into()))?;
}
if let Some(x) = pick("commentDisplay") {
m.comment_display = CommentDisplay::parse(x).ok_or_else(|| CmdError::Params("`commentDisplay`: margin|selected".into()))?;
}
if let Some(x) = pick("insertMark") {
m.insert_mark = InsertMark::parse(x).ok_or_else(|| CmdError::Params("unknown `insertMark`".into()))?;
}
if let Some(x) = pick("deleteMark") {
m.delete_mark = DeleteMark::parse(x).ok_or_else(|| CmdError::Params("unknown `deleteMark`".into()))?;
}
if let Some(x) = pick("moveFromMark") {
m.move_from_mark = DeleteMark::parse(x).ok_or_else(|| CmdError::Params("unknown `moveFromMark`".into()))?;
}
if let Some(x) = pick("moveToMark") {
m.move_to_mark = InsertMark::parse(x).ok_or_else(|| CmdError::Params("unknown `moveToMark`".into()))?;
}
if let Some(x) = pick("changedLines") {
m.changed_lines = ChangeBar::parse(x).ok_or_else(|| CmdError::Params("unknown `changedLines`".into()))?;
}
m.insert_color = color("insertColor", "byAuthor", m.insert_color)?;
m.delete_color = color("deleteColor", "byAuthor", m.delete_color)?;
m.changed_lines_color = color("changedLinesColor", "auto", m.changed_lines_color)?;
m.move_from_color = color("moveFromColor", "byAuthor", m.move_from_color)?;
m.move_to_color = color("moveToColor", "byAuthor", m.move_to_color)?;
// Track moves is the document's (`w:doNotTrackMoves`), so changing it is an undo step; the
// command is pure, so it records the step itself, once every parameter has been accepted.
// The other options are the user's own, so the command stays available under Restrict
// Editing; this one edits the document, so it passes the same check as an edit and isn't
// limited to an exception.
if let Some(b) = p::bool(v, "trackMoves")
&& b != s.doc.settings.track_moves
{
if crate::cmd::protect::check_before(s, "review.trackingOptions", &json!({}))? {
return Err(CmdError::Disabled(
"review.trackingOptions: the document is protected; stop protection to change whether moves are tracked".into(),
));
}
s.checkpoint("Track Changes Options");
s.doc.settings.track_moves = b;
s.touch();
}
if m != s.prefs.markup {
s.prefs.markup = m;
// Hidden deletions leave the layout.
s.relayout();
}
let mut out = tracking_json(&s.prefs.markup);
out["trackMoves"] = json!(s.doc.settings.track_moves);
Ok(out)
}
/// The Track Changes Options as `review.trackingOptions` takes them.
pub fn tracking_json(m: &wordcraft_layout::display::MarkupOptions) -> Value {
let hex = |c: Option<wordcraft_doc::Rgb>, auto: &str| c.map(|c| c.hex()).unwrap_or_else(|| auto.to_string());
json!({
"comments": m.comments,
"ink": m.ink,
"insertionsDeletions": m.insertions_deletions,
"formatting": m.formatting,
"balloons": m.balloons.name(),
"commentDisplay": m.comment_display.name(),
"insertMark": m.insert_mark.name(),
"insertColor": hex(m.insert_color, "byAuthor"),
"deleteMark": m.delete_mark.name(),
"deleteColor": hex(m.delete_color, "byAuthor"),
"changedLines": m.changed_lines.name(),
"changedLinesColor": hex(m.changed_lines_color, "auto"),
"trackFormatting": m.track_formatting,
"moveFromMark": m.move_from_mark.name(),
"moveFromColor": hex(m.move_from_color, "byAuthor"),
"moveToMark": m.move_to_mark.name(),
"moveToColor": hex(m.move_to_color, "byAuthor"),
})
}