From 3283203e9f87d21af0fe6dd1f6aaa18866cbc25e Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 10 Sep 2026 23:05:38 -0700 Subject: [PATCH] feat: publish edited page models Add PreparedEdit::page: diff an edited page::Page against the stored page and lower the difference onto the typed writers (text replacement, character formatting, paragraph insertion, split, join, move and deletion, outline insertion, deletion, position, width and saved collapse state), then squash their transactions into one revision per changed space while renaming the writer-allocated identities to the ones the model carries. Content the model cannot express (tables, images, lists, tags, fields, definitions, unsupported objects) must be unchanged and is otherwise untouched. A split at the end of a paragraph is distinguished from an appended paragraph by the copied paragraph style; a join needs a nonempty right side. Validation: a twelve-case oracle compares every active revision the typed writers produce with the page writer's result for the same edit, ignoring only writer-allocated styles, authors and reorder attribution; composed edits publish one transaction; direct model edits round-trip and leave unrelated objects byte-identical; unsupported edits fail before writing. Clippy and fmt pass. Assisted-by: claude-fable-5.1 --- crates/onestore/README.md | 1 + crates/onestore/src/commit.rs | 15 + crates/onestore/src/document.rs | 8 +- crates/onestore/src/page/mod.rs | 8 +- crates/onestore/src/page/text.rs | 21 + crates/onestore/src/page/write.rs | 1053 ++++++++++++++++++++++++++ crates/onestore/tests/page_writer.rs | 629 +++++++++++++++ 7 files changed, 1730 insertions(+), 5 deletions(-) create mode 100644 crates/onestore/src/page/write.rs create mode 100644 crates/onestore/tests/page_writer.rs diff --git a/crates/onestore/README.md b/crates/onestore/README.md index ef93bef9d338e686ca7db07adc29dfa8ad815902..b650d80518cf468679e985b4e6cc7b94c41bb86e 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -58,6 +58,7 @@ harness also accepts `--client-profile release`. | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | +| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character formatting, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | `read_file` | Read a snapshot under whole-file exclusion | diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index 9d5e474b19a9bc60522d1e049ab473445d506135..d0022820e849a4cd374b269fb254855bf9db6fc1 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -205,6 +205,21 @@ impl<'a> PreparedEdit<'a> { }) } + /// Publishes an edited page model as one revision per changed space. + /// The model must come from this snapshot; new paragraphs and outlines carry the identities + /// the model assigned, and content outside the model is untouched. + pub fn page( + source: &'a [u8], + space: ExGuid, + page: &crate::page::Page, + author: &str, + ) -> Result { + Ok(Self { + source, + written: crate::page::write::write_page(source, space, page, author)?, + }) + } + /// Creates a page and its section entry in one transaction, retaining the intent's identities. pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result { Ok(Self { diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs index ae527785cf256e3aed9d79e98ab8ebffc72c4b96..a6256a77e689ec578affe9e305eee136dddf784f 100644 --- a/crates/onestore/src/document.rs +++ b/crates/onestore/src/document.rs @@ -123,7 +123,7 @@ format_fields! { list_spacing: f32, } -#[derive(Debug, Serialize)] +#[derive(Debug, PartialEq, Serialize)] pub struct TextRun { pub start: u32, pub end: u32, @@ -266,7 +266,7 @@ pub struct Tag { pub extra_set: usize, } -#[derive(Debug, Serialize)] +#[derive(Debug, PartialEq, Serialize)] #[serde(tag = "type")] pub enum Kind<'a> { Section { @@ -401,13 +401,13 @@ pub enum Kind<'a> { Unknown, } -#[derive(Debug, Serialize)] +#[derive(Debug, PartialEq, Serialize)] pub struct Field<'a> { pub id: u32, pub value: FieldValue<'a>, } -#[derive(Debug, Serialize)] +#[derive(Debug, PartialEq, Serialize)] pub enum FieldValue<'a> { NoData, Bytes(#[serde(serialize_with = "hex")] &'a [u8]), diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index b2987c9bc23355b9258b02613a9979a1a55e857f..407aceb5c205283f10827f69495dc0732532d825 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -11,6 +11,7 @@ use std::{ }; pub mod text; +pub(crate) mod write; pub use text::Paragraph; /// The role of a title-outline paragraph that displays the page's creation date or time. @@ -20,6 +21,7 @@ pub enum DateField { Time = 1, } +#[derive(Debug, PartialEq)] pub struct Page { pub title: String, /// FILETIME ticks from the page's TopologyCreationTimeStamp. @@ -29,11 +31,13 @@ pub struct Page { pub definitions: BTreeMap, } +#[derive(Debug, PartialEq)] pub struct Definition { pub kind: Kind<'static>, pub format: Format, } +#[derive(Debug, PartialEq)] pub enum PageObject { Outline(Outline), Title(Title), @@ -69,6 +73,7 @@ impl PageObject { } } +#[derive(Debug, PartialEq)] pub struct Title { pub id: ExGuid, pub date: Option, @@ -76,7 +81,7 @@ pub struct Title { pub outlines: Vec, } -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq)] pub struct Outline { pub id: ExGuid, pub title: bool, @@ -147,6 +152,7 @@ pub struct TextObject { pub tags: Vec, } +#[derive(Debug, PartialEq)] pub struct Image { pub id: ExGuid, pub layout: Layout, diff --git a/crates/onestore/src/page/text.rs b/crates/onestore/src/page/text.rs index 1703e4b6806b65d398551f2ac7bbbc92cf2c730e..3a02b6e954fd553cf77d2d9c25c1d38dddf648d2 100644 --- a/crates/onestore/src/page/text.rs +++ b/crates/onestore/src/page/text.rs @@ -132,6 +132,27 @@ impl fmt::Display for EditError { impl std::error::Error for EditError {} +impl From for crate::Error { + fn from(error: EditError) -> Self { + Self { + offset: 0, + message: match error { + EditError::InvalidRange => { + "Text range is outside the paragraph or splits a surrogate pair" + } + EditError::TextTooLong => "Text exceeds the UTF-16 offset range", + EditError::InvalidStructure => { + "This outline has duplicate objects or broken paragraph links" + } + EditError::UnsupportedContent => { + "This paragraph contains content the text editor cannot edit" + } + EditError::Identity => "System random source failed", + }, + } + } +} + /// A fresh random identity for a new paragraph or text object. pub fn new_id() -> Result { Ok(ExGuid { diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs new file mode 100644 index 0000000000000000000000000000000000000000..974e78547c8d0c914781c1237cb1adb016889462 --- /dev/null +++ b/crates/onestore/src/page/write.rs @@ -0,0 +1,1053 @@ +//! Publishes an edited page model by lowering the difference from the stored page onto +//! the typed writers, then squashing their transactions into one revision per space. + +use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent}; +use crate::{ + Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, + RevisionIndex, Store, TextAttribute, TreeEdit, Value, + document::{Document, Format}, + write::{PropertyObject, RevisionEdit, write_revisions}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + ops::Range, +}; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +pub(crate) fn write_page( + source: &[u8], + space: ExGuid, + after: &Page, + author: &str, +) -> Result, Error> { + if author.contains('\0') { + return Err(invalid("Choose an author name without NUL")); + } + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + index.validate_current()?; + let document = Document::parse(&index)?; + let pages = document.pages_in(space)?; + let [page] = pages.as_slice() else { + return Err(invalid("Choose an object space containing one active page")); + }; + let before = Page::from_space(&document, space)?; + let raw = index.resolve_active(space)?; + let mut lowering = Lowering { + image: source.to_vec(), + space, + page: *page, + author, + alias: BTreeMap::new(), + }; + lowering.run(&before, after, &raw.objects.keys().copied().collect())?; + if lowering.image == source { + return Ok(lowering.image); + } + squash(source, &lowering.image, &lowering.alias) +} + +/// Direct children of every container, in model order, plus lookups by identity. +struct View<'a> { + page: &'a Page, + outlines: BTreeMap, + /// Outlines owned by a title object rather than the page. + title_outlines: BTreeSet, + paragraphs: BTreeMap, + children: BTreeMap>, + container: BTreeMap, + page_children: Vec, +} + +impl<'a> View<'a> { + fn new(page: &'a Page) -> Result { + let mut view = Self { + page, + outlines: BTreeMap::new(), + title_outlines: BTreeSet::new(), + paragraphs: BTreeMap::new(), + children: BTreeMap::new(), + container: BTreeMap::new(), + page_children: Vec::new(), + }; + for object in &page.objects { + view.page_children.push(object.id()); + match object { + PageObject::Outline(outline) => view.outline(outline)?, + PageObject::Title(title) => { + for outline in &title.outlines { + view.title_outlines.insert(outline.id); + view.outline(outline)?; + } + } + PageObject::Image(_) | PageObject::Unsupported(_) => {} + } + } + Ok(view) + } + + fn outline(&mut self, outline: &'a Outline) -> Result<(), Error> { + if self.outlines.insert(outline.id, outline).is_some() { + return Err(invalid("The page model repeats an outline identity")); + } + self.children.entry(outline.id).or_default(); + self.paragraphs_of(outline.id, &outline.paragraphs) + } + + fn paragraphs_of(&mut self, root: ExGuid, list: &'a [PageParagraph]) -> Result<(), Error> { + for paragraph in list { + let container = paragraph.parent.unwrap_or(root); + if paragraph + .parent + .is_some_and(|parent| !self.paragraphs.contains_key(&parent)) + { + return Err(invalid( + "A paragraph's parent must precede it in its container", + )); + } + if self.paragraphs.insert(paragraph.id, paragraph).is_some() { + return Err(invalid("The page model repeats a paragraph identity")); + } + self.children + .entry(container) + .or_default() + .push(paragraph.id); + self.children.entry(paragraph.id).or_default(); + self.container.insert(paragraph.id, container); + if let ParagraphContent::Table(table) = ¶graph.content { + for row in &table.rows { + for cell in &row.cells { + if self.children.contains_key(&cell.id) { + return Err(invalid("The page model repeats a cell identity")); + } + self.children.entry(cell.id).or_default(); + self.paragraphs_of(cell.id, &cell.paragraphs)?; + } + } + } + } + Ok(()) + } + + fn text(&self, paragraph: ExGuid) -> Option<&'a super::TextObject> { + self.paragraphs.get(¶graph).and_then(|p| p.text()) + } +} + +struct Lowering<'a> { + image: Vec, + space: ExGuid, + page: ExGuid, + author: &'a str, + /// Model identities of new objects mapped to the identities the typed writers allocated. + alias: BTreeMap, +} + +impl Lowering<'_> { + fn id(&self, model: ExGuid) -> ExGuid { + self.alias.get(&model).copied().unwrap_or(model) + } + + fn apply(&mut self, edit: impl FnOnce(&[u8]) -> Result, Error>) -> Result<(), Error> { + self.image = edit(&self.image)?; + Ok(()) + } + + fn current(&self) -> Result { + let store = Store::parse(&self.image)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + Page::from_space(&document, self.space) + } + + fn run( + &mut self, + before: &Page, + after: &Page, + existing: &BTreeSet, + ) -> Result<(), Error> { + if after.created != before.created || after.margin_origin != before.margin_origin { + return Err(invalid("Page creation time and margins cannot be edited")); + } + for (id, definition) in &after.definitions { + if before.definitions.get(id) != Some(definition) { + return Err(invalid("List, tag and style definitions cannot be edited")); + } + } + let old = View::new(before)?; + let new = View::new(after)?; + self.check_fixed_objects(before, after, &old, &new)?; + for id in new.outlines.keys().chain(new.paragraphs.keys()) { + if !old.outlines.contains_key(id) + && !old.paragraphs.contains_key(id) + && existing.contains(id) + { + return Err(invalid( + "A new model identity already exists in the section", + )); + } + } + let mut placed: BTreeMap> = BTreeMap::new(); + for (container, children) in &old.children { + placed.insert(*container, children.clone()); + } + let mut page_order: Vec = old + .page_children + .iter() + .copied() + .filter(|id| !old.outlines.contains_key(id) || new.outlines.contains_key(id)) + .collect(); + self.insert_outlines(&old, &new, &mut placed, &mut page_order)?; + let mut consumed = BTreeSet::new(); + self.split_and_join(&old, &new, &mut placed, &mut consumed)?; + self.place(&old, &new, &placed, &page_order)?; + self.delete(&old, &new, &consumed)?; + self.edit_text(&new)?; + self.edit_formatting(&new)?; + self.edit_layout(&old, &new)?; + Ok(()) + } + + fn check_fixed_objects( + &self, + before: &Page, + after: &Page, + old: &View<'_>, + new: &View<'_>, + ) -> Result<(), Error> { + let fixed = |page: &Page| -> Vec { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Image(image) => Some(format!("{image:?}")), + PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")), + PageObject::Title(title) => Some(format!( + "{:?} {:?} {:?} {:?}", + title.id, + title.date, + title.layout, + title.outlines.iter().map(|o| o.id).collect::>() + )), + PageObject::Outline(_) => None, + }) + .collect() + }; + let mut before_fixed = fixed(before); + let mut after_fixed = fixed(after); + before_fixed.sort(); + after_fixed.sort(); + if before_fixed != after_fixed { + return Err(invalid( + "Images, titles and unsupported objects cannot be edited through the page model", + )); + } + for (id, outline) in &new.outlines { + if outline.paragraphs.is_empty() { + return Err(invalid( + "An outline needs a paragraph; remove the outline instead", + )); + } + let Some(previous) = old.outlines.get(id) else { + continue; + }; + let same = outline.title == previous.title + && outline.min_width == previous.min_width + && outline.indents == previous.indents + && outline.unsupported == previous.unsupported + && (!new.title_outlines.contains(id) || outline.layout == previous.layout); + if !same { + return Err(invalid( + "Outline roles, indentation tables and title geometry cannot be edited", + )); + } + } + for (id, paragraph) in &new.paragraphs { + let Some(previous) = old.paragraphs.get(id) else { + continue; + }; + let same = paragraph.lists == previous.lists + && paragraph.tags == previous.tags + && paragraph.style == previous.style + && paragraph.format == previous.format; + if !same { + return Err(invalid( + "Paragraph lists, tags, styles and paragraph formatting cannot be edited", + )); + } + match (¶graph.content, &previous.content) { + (ParagraphContent::Text(text), ParagraphContent::Text(previous)) => { + if text.date_field != previous.date_field || text.tags != previous.tags { + return Err(invalid("Text fields and tags cannot be edited")); + } + } + (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => { + let same = table.id == previous.id + && table.columns == previous.columns + && table.borders == previous.borders + && table.layout == previous.layout + && table.tags == previous.tags + && table.rows.len() == previous.rows.len() + && table + .rows + .iter() + .zip(&previous.rows) + .all(|(row, previous)| { + row.id == previous.id + && row.cells.len() == previous.cells.len() + && row.cells.iter().zip(&previous.cells).all( + |(cell, previous)| { + cell.id == previous.id + && cell.layout == previous.layout + && cell.indents == previous.indents + && cell.shading == previous.shading + && cell.unsupported == previous.unsupported + }, + ) + }); + if !same { + return Err(invalid("Table structure cannot be edited")); + } + } + (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {} + _ => return Err(invalid("Paragraph content type cannot change")), + } + } + Ok(()) + } + + fn insert_outlines( + &mut self, + old: &View<'_>, + new: &View<'_>, + placed: &mut BTreeMap>, + page_order: &mut Vec, + ) -> Result<(), Error> { + for id in &new.page_children { + let Some(outline) = new.outlines.get(id) else { + continue; + }; + if old.outlines.contains_key(id) { + continue; + } + if new.title_outlines.contains(id) { + return Err(invalid("Title outlines cannot be added")); + } + let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else { + return Err(invalid("A new outline needs a position")); + }; + let Some(first) = new.children[id].first().copied() else { + return Err(invalid("A new outline needs a paragraph")); + }; + let text = new + .text(first) + .ok_or_else(|| invalid("A new outline starts with a text paragraph"))?; + let insertion = Insertion::outline(self.page, x, y, text.text.text(), self.author)?; + let space = self.space; + self.apply(|image| insertion.apply(image, space))?; + let outline_id = insertion.object(); + self.alias.insert(*id, outline_id); + self.alias.insert( + first, + ExGuid { + guid: outline_id.guid, + n: 3, + }, + ); + self.alias.insert(text.id, insertion.text_object()); + placed.insert(*id, vec![first]); + placed.insert(first, Vec::new()); + page_order.push(*id); + } + Ok(()) + } + + fn split_and_join( + &mut self, + old: &View<'_>, + new: &View<'_>, + placed: &mut BTreeMap>, + consumed: &mut BTreeSet, + ) -> Result<(), Error> { + for (container, children) in &new.children { + if !old.children.contains_key(container) { + continue; + } + for pair in children.windows(2) { + let [left, right] = [pair[0], pair[1]]; + let (Some(previous), Some(after), Some(next)) = + (old.text(left), new.text(left), new.text(right)) + else { + continue; + }; + if old.paragraphs.contains_key(&right) + || old.container.get(&left) != Some(container) + || !new.children[&left].is_empty() + || new.children[&right] != old.children[&left] + || after.id != previous.id + || new.paragraphs[&right].style != old.paragraphs[&left].style + { + continue; + } + let Ok(offset) = after.text.utf16_offset(after.text.text().len()) else { + continue; + }; + // Splitting at the end only differs from appending a paragraph by the copied + // paragraph style; without one, an appended empty paragraph is an insertion. + if offset == previous.text.utf16_offset(previous.text.text().len())? + && new.paragraphs[&right].style.is_none() + { + continue; + } + let Ok(head) = previous.text.slice(0..offset) else { + continue; + }; + let Ok(tail) = previous.text.slice( + offset + ..previous + .text + .utf16_offset(previous.text.text().len()) + .unwrap_or(0), + ) else { + continue; + }; + // An emptied side takes the writer's insertion style, so only its text must agree. + let same = |expected: &super::Paragraph, actual: &super::Paragraph| { + expected.text() == actual.text() + && (expected.text().is_empty() || expected == actual) + }; + if !same(&head, &after.text) || !same(&tail, &next.text) { + continue; + } + let split = ParagraphSplit::new(previous.id, offset, self.author)?; + let space = self.space; + self.apply(|image| split.apply(image, space))?; + self.alias.insert(right, split.object()); + self.alias.insert(next.id, split.text_object()); + let list = placed.get_mut(container).unwrap(); + let at = list.iter().position(|id| *id == left).unwrap(); + list.insert(at + 1, right); + placed.insert(right, placed[&left].clone()); + placed.insert(left, Vec::new()); + } + } + for (container, children) in &old.children { + if !new.children.contains_key(container) { + continue; + } + for pair in children.windows(2) { + let [left, right] = [pair[0], pair[1]]; + let (Some(previous), Some(after), Some(removed)) = + (old.text(left), new.text(left), old.text(right)) + else { + continue; + }; + if new.paragraphs.contains_key(&right) + || !old.children[&left].is_empty() + || !old.children[&right].is_empty() + || removed.text.text().is_empty() + { + continue; + } + let mut joined = previous.text.clone(); + if joined.append(removed.text.clone()).is_err() || joined != after.text { + continue; + } + let adopts_right = previous.text.text().is_empty(); + if after.id + != if adopts_right { + removed.id + } else { + previous.id + } + { + return Err(invalid( + "A joined paragraph keeps the left text identity unless the left text was empty", + )); + } + let join = ParagraphJoin::new(previous.id, removed.id, self.author)?; + let space = self.space; + self.apply(|image| join.apply(image, space))?; + consumed.insert(right); + placed.get_mut(container).unwrap().retain(|id| *id != right); + placed.remove(&right); + } + } + Ok(()) + } + + fn place( + &mut self, + old: &View<'_>, + new: &View<'_>, + placed: &BTreeMap>, + page_order: &[ExGuid], + ) -> Result<(), Error> { + let title = |page: &Page, id: ExGuid| { + page.objects + .iter() + .any(|object| matches!(object, PageObject::Title(title) if title.id == id)) + }; + let survivors: Vec = page_order + .iter() + .copied() + .filter(|id| { + (new.outlines.contains_key(id) || !old.outlines.contains_key(id)) + && !title(old.page, *id) + }) + .collect(); + let after: Vec = new + .page_children + .iter() + .copied() + .filter(|id| !title(new.page, *id)) + .collect(); + let kept = kept_set(&survivors, &after, |id| { + new.outlines.contains_key(&id) && !new.title_outlines.contains(&id) + })?; + let mut next = None; + for id in after.iter().rev() { + if !kept.contains(id) { + let edit = TreeEdit::move_to( + self.id(*id), + self.page, + next.map(|n| self.id(n)), + self.author, + )?; + let space = self.space; + self.apply(|image| edit.apply(image, space))?; + } + next = Some(*id); + } + let mut containers: Vec = Vec::new(); + for object in &new.page.objects { + let outlines: Vec<&Outline> = match object { + PageObject::Outline(outline) => vec![outline], + PageObject::Title(title) => title.outlines.iter().collect(), + PageObject::Image(_) | PageObject::Unsupported(_) => Vec::new(), + }; + for outline in outlines { + containers.push(outline.id); + collect_containers(&outline.paragraphs, &mut containers); + } + } + for container in containers { + let after = &new.children[&container]; + let current: Vec = placed + .get(&container) + .map(|list| { + list.iter() + .copied() + .filter(|id| new.paragraphs.contains_key(id)) + .collect() + }) + .unwrap_or_default(); + let kept = kept_set(¤t, after, |_| true)?; + let mut next = None; + for id in after.iter().rev() { + let anchor = next.map(|n| self.id(n)); + if !old.paragraphs.contains_key(id) && !self.alias.contains_key(id) { + let paragraph = new.paragraphs[id]; + let text = paragraph + .text() + .filter(|text| text.date_field.is_none() && text.tags.is_empty()) + .filter(|_| { + paragraph.lists.is_empty() + && paragraph.tags.is_empty() + && paragraph.style.is_none() + }) + .ok_or_else(|| { + invalid("New paragraphs contain plain text without lists, tags, fields or styles") + })?; + let insertion = Insertion::paragraph( + self.id(container), + anchor, + text.text.text(), + self.author, + )?; + let space = self.space; + self.apply(|image| insertion.apply(image, space))?; + self.alias.insert(*id, insertion.object()); + self.alias.insert(text.id, insertion.text_object()); + } else if !kept.contains(id) { + let edit = + TreeEdit::move_to(self.id(*id), self.id(container), anchor, self.author)?; + let space = self.space; + self.apply(|image| edit.apply(image, space))?; + } + next = Some(*id); + } + } + Ok(()) + } + + fn delete( + &mut self, + old: &View<'_>, + new: &View<'_>, + consumed: &BTreeSet, + ) -> Result<(), Error> { + let removed_outline = + |id: ExGuid| old.outlines.contains_key(&id) && !new.outlines.contains_key(&id); + for id in old.paragraphs.keys() { + if new.paragraphs.contains_key(id) || consumed.contains(id) { + continue; + } + let mut ancestor = old.container[id]; + let mut covered = false; + loop { + if removed_outline(ancestor) + || (old.paragraphs.contains_key(&ancestor) + && !new.paragraphs.contains_key(&ancestor) + && !consumed.contains(&ancestor)) + { + covered = true; + break; + } + match old.container.get(&ancestor) { + Some(parent) => ancestor = *parent, + None => break, + } + } + if covered { + continue; + } + let edit = TreeEdit::delete(*id, self.author)?; + let space = self.space; + self.apply(|image| edit.apply(image, space))?; + } + for id in old.outlines.keys() { + if removed_outline(*id) { + if old.title_outlines.contains(id) { + return Err(invalid("Title outlines cannot be removed")); + } + let edit = TreeEdit::delete(*id, self.author)?; + let space = self.space; + self.apply(|image| edit.apply(image, space))?; + } + } + Ok(()) + } + + fn edit_text(&mut self, new: &View<'_>) -> Result<(), Error> { + let current = self.current()?; + let current = View::new(¤t)?; + for (id, paragraph) in &new.paragraphs { + let Some(text) = paragraph.text() else { + continue; + }; + let image_id = self.id(*id); + let Some(stored) = current.text(image_id) else { + return Err(invalid("A paragraph is missing after structural edits")); + }; + if stored.id != self.id(text.id) { + return Err(invalid("Text object identities cannot change")); + } + if stored.text.text() == text.text.text() { + continue; + } + let (range, replacement) = text_edit(stored.text.text(), text.text.text())?; + let (space, object) = (self.space, stored.id); + self.apply(|image| crate::replace_text(image, space, object, range, &replacement))?; + } + Ok(()) + } + + fn edit_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { + let current = self.current()?; + let current = View::new(¤t)?; + for (id, paragraph) in &new.paragraphs { + let Some(text) = paragraph.text() else { + continue; + }; + let stored = current + .text(self.id(*id)) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; + if stored.text.text() != text.text.text() { + return Err(invalid("Text edits did not converge on the model")); + } + let mut boundaries = BTreeSet::new(); + for paragraph in [&stored.text, &text.text] { + for span in paragraph.spans() { + boundaries.insert(paragraph.utf16_offset(span.end)?); + } + } + boundaries.insert(0); + let boundaries: Vec = boundaries.into_iter().collect(); + let mut pending: Option<(Range, Vec)> = None; + let mut edits = Vec::new(); + for window in boundaries.windows(2) { + let (start, end) = (window[0], window[1]); + if start == end { + continue; + } + let attributes = attributes( + format_in(&stored.text, start)?, + format_in(&text.text, start)?, + )?; + match &mut pending { + Some((range, previous)) if *previous == attributes && range.end == start => { + range.end = end; + } + _ => { + if let Some(edit) = pending.take() { + edits.push(edit); + } + pending = Some((start..end, attributes)); + } + } + } + edits.extend(pending); + if text.text.text().is_empty() { + let attributes = + attributes(format_in(&stored.text, 0)?, format_in(&text.text, 0)?)?; + if !attributes.is_empty() { + edits.push((0..0, attributes)); + } + } + for (range, attributes) in edits { + if attributes.is_empty() { + continue; + } + let (space, object) = (self.space, stored.id); + self.apply(|image| { + crate::formatting::format_text(image, space, object, range, &attributes) + })?; + } + } + Ok(()) + } + + fn edit_layout(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { + let current = self.current()?; + let current = View::new(¤t)?; + for (id, paragraph) in &new.paragraphs { + let stored = current + .paragraphs + .get(&self.id(*id)) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; + if stored.collapsed != paragraph.collapsed { + let (space, object) = (self.space, self.id(*id)); + let edit = OutlineEdit::Collapsed(paragraph.collapsed); + self.apply(|image| edit.apply(image, space, object))?; + } + } + for (id, outline) in &new.outlines { + if new.title_outlines.contains(id) { + continue; + } + let stored = current + .outlines + .get(&self.id(*id)) + .ok_or_else(|| invalid("An outline is missing after text edits"))?; + let (space, object) = (self.space, self.id(*id)); + if (outline.layout.x, outline.layout.y) != (stored.layout.x, stored.layout.y) { + let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else { + return Err(invalid("An outline position needs both coordinates")); + }; + let edit = OutlineEdit::Position { x, y }; + self.apply(|image| edit.apply(image, space, object))?; + } + if (outline.layout.max_width, outline.layout.width_set_by_user) + != (stored.layout.max_width, stored.layout.width_set_by_user) + && (old.outlines.contains_key(id) || outline.layout.max_width.is_some()) + { + let Some(points) = outline.layout.max_width else { + return Err(invalid("An outline width cannot be removed")); + }; + let edit = OutlineEdit::Width { + points, + user_set: outline.layout.width_set_by_user == Some(true), + }; + self.apply(|image| edit.apply(image, space, object))?; + } + } + Ok(()) + } +} + +fn collect_containers(list: &[PageParagraph], out: &mut Vec) { + for paragraph in list { + out.push(paragraph.id); + if let ParagraphContent::Table(table) = ¶graph.content { + for row in &table.rows { + for cell in &row.cells { + out.push(cell.id); + collect_containers(&cell.paragraphs, out); + } + } + } + } +} + +/// Identities that keep their stored position: a longest increasing run of survivors, +/// always including immovable ones. `after` may contain identities absent from `before`. +fn kept_set( + before: &[ExGuid], + after: &[ExGuid], + movable: impl Fn(ExGuid) -> bool, +) -> Result, Error> { + let position: BTreeMap = + after.iter().enumerate().map(|(i, id)| (*id, i)).collect(); + let sequence: Vec<(ExGuid, usize)> = before + .iter() + .filter_map(|id| position.get(id).map(|at| (*id, *at))) + .collect(); + let fixed: Vec = sequence + .iter() + .filter(|(id, _)| !movable(*id)) + .map(|(_, at)| *at) + .collect(); + if fixed.windows(2).any(|w| w[0] > w[1]) { + return Err(invalid( + "Images and unsupported objects cannot be reordered", + )); + } + // Longest increasing subsequence over `after` positions, forced through immovable items. + let n = sequence.len(); + let mut best = vec![1usize; n]; + let mut previous = vec![usize::MAX; n]; + for i in 0..n { + let (id, at) = sequence[i]; + let mandatory_before = sequence[..i] + .iter() + .filter(|(other, _)| !movable(*other)) + .map(|(_, at)| *at) + .max(); + if movable(id) && mandatory_before.is_some_and(|m| m > at) { + best[i] = 0; + continue; + } + let mandatory_after = sequence[i + 1..] + .iter() + .filter(|(other, _)| !movable(*other)) + .map(|(_, at)| *at) + .min(); + if movable(id) && mandatory_after.is_some_and(|m| m < at) { + best[i] = 0; + continue; + } + for j in 0..i { + if best[j] > 0 && sequence[j].1 < at && best[j] + 1 > best[i] { + best[i] = best[j] + 1; + previous[i] = j; + } + } + } + let mut kept = BTreeSet::new(); + if let Some((mut i, _)) = best + .iter() + .enumerate() + .max_by_key(|(i, b)| (**b, usize::MAX - i)) + && best[i] > 0 + { + loop { + kept.insert(sequence[i].0); + if previous[i] == usize::MAX { + break; + } + i = previous[i]; + } + } + for (id, _) in &sequence { + if !movable(*id) && !kept.contains(id) { + return Err(invalid( + "Images and unsupported objects cannot be reordered", + )); + } + } + Ok(kept) +} + +/// The format of the span containing the UTF-16 position `at` (the last span at the end). +fn format_in(paragraph: &super::Paragraph, at: u32) -> Result<&Format, Error> { + let byte = paragraph.byte_offset(at)?; + let spans = paragraph.spans(); + let index = spans + .partition_point(|span| span.end <= byte) + .min(spans.len() - 1); + Ok(&spans[index].format) +} + +/// The smallest UTF-16 range whose replacement turns `before` into `after`. +fn text_edit(before: &str, after: &str) -> Result<(Range, String), Error> { + let prefix = before + .char_indices() + .zip(after.chars()) + .take_while(|((_, a), b)| a == b) + .map(|((i, a), _)| i + a.len_utf8()) + .last() + .unwrap_or(0); + let suffix = before[prefix..] + .chars() + .rev() + .zip(after[prefix..].chars().rev()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.len_utf8()) + .sum::(); + let units = |s: &str| -> Result { + u32::try_from(s.encode_utf16().count()) + .map_err(|_| invalid("Text exceeds UTF-16 offset range")) + }; + let start = units(&before[..prefix])?; + let end = start + units(&before[prefix..before.len() - suffix])?; + Ok((start..end, after[prefix..after.len() - suffix].to_owned())) +} + +/// Explicit attributes turning `current` into `target`; unsupported differences are errors. +/// Language tags are retained as stored because the model has no way to author them. +fn attributes(current: &Format, target: &Format) -> Result, Error> { + let mut out = Vec::new(); + macro_rules! boolean { + ($field:ident, $variant:ident) => { + if current.$field != target.$field { + match target.$field { + Some(value) => out.push(TextAttribute::$variant(value)), + None => { + return Err(invalid("Inherited character formatting cannot be restored")); + } + } + } + }; + } + boolean!(bold, Bold); + boolean!(italic, Italic); + boolean!(underline, Underline); + boolean!(strike, Strike); + boolean!(superscript, Superscript); + boolean!(subscript, Subscript); + if current.font != target.font { + match &target.font { + Some(font) => out.push(TextAttribute::Font(font.clone())), + None => return Err(invalid("Inherited character formatting cannot be restored")), + } + } + if current.font_size != target.font_size { + match target.font_size { + Some(size) => out.push(TextAttribute::FontSize(size)), + None => return Err(invalid("Inherited character formatting cannot be restored")), + } + } + let color = + |value: u32| (value != 0xff000000).then(|| value.to_le_bytes()[..3].try_into().unwrap()); + if current.color != target.color { + match target.color { + Some(value) => out.push(TextAttribute::Color(color(value))), + None => return Err(invalid("Inherited character formatting cannot be restored")), + } + } + if current.highlight != target.highlight { + match target.highlight { + Some(value) => out.push(TextAttribute::Highlight(color(value))), + None => return Err(invalid("Inherited character formatting cannot be restored")), + } + } + // An absent value and its stored default are the same formatting. + let flag = |a: Option, b: Option| a.unwrap_or(false) == b.unwrap_or(false); + let points = |a: Option, b: Option| a.unwrap_or(0.0) == b.unwrap_or(0.0); + let same_rest = flag(current.hidden, target.hidden) + && flag(current.hyperlink, target.hyperlink) + && flag(current.hyperlink_label, target.hyperlink_label) + && flag(current.math, target.math) + && flag(current.embedded_object, target.embedded_object) + && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0) + && flag(current.rtl, target.rtl) + && points(current.space_before, target.space_before) + && points(current.space_after, target.space_after) + && points(current.line_spacing, target.line_spacing) + && points(current.list_spacing, target.list_spacing); + if !same_rest { + return Err(invalid( + "Fields, links, language and paragraph spacing cannot be edited through the page model", + )); + } + Ok(out) +} + +/// Rewrites every revision the typed writers appended as one transaction on `source`, +/// renaming writer-allocated identities to the model's. +fn squash( + source: &[u8], + applied: &[u8], + alias: &BTreeMap, +) -> Result, Error> { + let rename: BTreeMap = alias + .iter() + .map(|(model, image)| (*image, *model)) + .collect(); + let applied_store = Store::parse(applied)?; + let applied_index = RevisionIndex::parse(&applied_store)?; + write_revisions(source, |index| { + let mut changes = BTreeMap::new(); + for sid in applied_index.spaces.keys() { + let Some(space) = index.spaces.get(sid) else { + return Err(invalid("Page edits cannot create object spaces")); + }; + let after_rid = applied_index.active(*sid)?; + if space.labels.get(&(ExGuid::default(), 1)) == Some(&after_rid) { + continue; + } + let before = index.resolve_active(*sid)?; + let after = applied_index.resolve(*sid, after_rid)?; + if before.roots != after.roots { + return Err(invalid("Page edits cannot change revision roots")); + } + // Retired styles and deleted content stay in history; only live objects are written. + let live = after.reachable()?; + let mut changed = BTreeMap::new(); + for (id, object) in &after.objects { + if !live.contains(id) { + continue; + } + if before.objects.get(id).is_some_and(|previous| { + previous.jcid == object.jcid + && previous.data == object.data + && previous.global_ids == object.global_ids + }) { + continue; + } + let ObjectData::Properties(_) = object.data else { + return Err(invalid("Page edits only produce property objects")); + }; + let mut replacement = PropertyObject::from_object(object)?; + remap(&mut replacement, &rename)?; + let id = rename.get(id).copied().unwrap_or(*id); + replacement.reference(id)?; + if before.objects.contains_key(&id) && rename.values().any(|model| *model == id) { + return Err(invalid( + "A new model identity already exists in the section", + )); + } + changed.insert(id, replacement); + } + changes.insert(*sid, RevisionEdit::Update(changed)); + } + Ok(changes) + }) +} + +fn remap(object: &mut PropertyObject, rename: &BTreeMap) -> Result<(), Error> { + let mut remapped = Vec::new(); + for property in PropertySets::parse(&object.bytes)?.sets.iter().flatten() { + if let Value::References { + stream: crate::IdStream::Objects, + compact_ids, + } = property.value + { + for bytes in compact_ids.chunks_exact(4) { + let offset = bytes.as_ptr().addr() - object.bytes.as_ptr().addr(); + let id = crate::bytes::Cursor { bytes, offset }.compact(&object.global_ids)?; + if let Some(model) = rename.get(&id) { + remapped.push((offset, *model)); + } + } + } + } + for (offset, id) in remapped { + let reference = object.reference(id)?; + object.bytes[offset..offset + 4].copy_from_slice(&reference); + } + Ok(()) +} diff --git a/crates/onestore/tests/page_writer.rs b/crates/onestore/tests/page_writer.rs new file mode 100644 index 0000000000000000000000000000000000000000..73db735cbf78605dba8e8fa75756200f4eaad8fa --- /dev/null +++ b/crates/onestore/tests/page_writer.rs @@ -0,0 +1,629 @@ +//! The page writer must publish the same graph the typed writers publish for the same edit, +//! in one transaction, and must round-trip the model it was given. + +use onestore::{ + ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, + Store, TextAttribute, TreeEdit, + document::{Document, Element, Kind, Revision}, + page::{Page, PageObject, ParagraphContent, text::new_id}, +}; +use std::collections::BTreeSet; + +const AUTHOR: &str = "Page author"; +const PARAGRAPHS: &[u8] = + include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"); +const OUTLINES: &[u8] = + include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); +const TREES: &[u8] = + include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"); + +fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut found = None; + for (space, _) in document.pages().unwrap() { + let page = Page::from_space(&document, space).unwrap(); + if page.title == title { + assert!(found.is_none(), "duplicate title {title}"); + found = Some((space, page)); + } + } + found.unwrap_or_else(|| panic!("no page titled {title}")) +} + +fn page_in(bytes: &[u8], space: ExGuid) -> Page { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + Page::from_space(&document, space).unwrap() +} + +fn page_id(bytes: &[u8], space: ExGuid) -> ExGuid { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document.pages_in(space).unwrap()[0] +} + +/// The body outlines in stacking order (not the title's outlines). +fn body(page: &Page) -> Vec<&onestore::page::Outline> { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .collect() +} + +fn text_ids(outline: &onestore::page::Outline) -> Vec<(ExGuid, ExGuid, String)> { + outline + .paragraphs + .iter() + .filter_map(|p| p.text().map(|t| (p.id, t.id, t.text.text().to_owned()))) + .collect() +} + +fn author_name<'a>(revision: &'a Revision<'_>, id: Option) -> Option<&'a str> { + match &revision.nodes.get(&id?)?.kind { + Kind::Author { name } => name.as_deref(), + _ => None, + } +} + +fn close(a: Option, b: Option) -> bool { + match (a, b) { + (Some(a), Some(b)) => a.abs_diff(b) <= 5, + (None, None) => true, + _ => false, + } +} + +fn same_element(revision_a: &Revision<'_>, revision_b: &Revision<'_>, id: ExGuid) { + let a: &Element = &revision_a.nodes[&id]; + let b: &Element = &revision_b.nodes[&id]; + assert_eq!(a.jcid, b.jcid, "{id}"); + assert_eq!(a.children, b.children, "children of {id}"); + assert_eq!(a.content, b.content, "content of {id}"); + assert_eq!(a.structure, b.structure, "structure of {id}"); + assert_eq!(a.spaces, b.spaces, "spaces of {id}"); + assert_eq!(a.child_level, b.child_level, "child level of {id}"); + assert_eq!(a.layout, b.layout, "layout of {id}"); + assert_eq!(a.format, b.format, "format of {id}"); + assert_eq!(a.tags, b.tags, "tags of {id}"); + assert_eq!(a.media_ids, b.media_ids, "media of {id}"); + assert!( + close(a.created, b.created), + "created {id}: {:?} {:?}", + a.created, + b.created + ); + assert_eq!( + author_name(revision_a, a.original_author), + author_name(revision_b, b.original_author), + "original author of {id}" + ); + // A reorder can be attributed to either sibling, so attribution may differ by this edit's author. + let latest_a = author_name(revision_a, a.latest_author); + let latest_b = author_name(revision_b, b.latest_author); + assert!( + latest_a == latest_b || latest_a == Some(AUTHOR) || latest_b == Some(AUTHOR), + "latest author of {id}: {latest_a:?} {latest_b:?}" + ); + assert_eq!(a.extra, b.extra, "retained fields of {id}"); + match (&a.kind, &b.kind) { + ( + Kind::RichText { + text: ta, + boilerplate: ba, + paragraph_style: pa, + .. + }, + Kind::RichText { + text: tb, + boilerplate: bb, + paragraph_style: pb, + .. + }, + ) => { + assert_eq!(ta, tb, "text of {id}"); + assert_eq!((ba, pa), (bb, pb), "text style of {id}"); + let runs_a = revision_a.text_runs(id).unwrap(); + let runs_b = revision_b.text_runs(id).unwrap(); + assert_eq!(runs_a.len(), runs_b.len(), "run count of {id}"); + for (ra, rb) in runs_a.iter().zip(&runs_b) { + assert_eq!( + (ra.text, &ra.format, ra.link), + (rb.text, &rb.format, rb.link), + "runs of {id}" + ); + } + } + (ka, kb) => assert_eq!(ka, kb, "kind of {id}"), + } +} + +/// Every active revision the typed writers produced is reproduced by the page writer, +/// ignoring writer-allocated immutable styles and author records. Modification times are +/// not compared: a reorder can be attributed to either sibling, and the model cannot say which. +fn equivalent(reference: &[u8], written: &[u8]) { + let store_a = Store::parse(reference).unwrap(); + let store_b = Store::parse(written).unwrap(); + let index_a = RevisionIndex::parse(&store_a).unwrap(); + let index_b = RevisionIndex::parse(&store_b).unwrap(); + index_b.validate_current().unwrap(); + let document_a = Document::parse(&index_a).unwrap(); + let document_b = Document::parse(&index_b).unwrap(); + assert_eq!( + document_a.spaces.keys().collect::>(), + document_b.spaces.keys().collect::>() + ); + for sid in document_a.spaces.keys() { + let a = document_a.active(*sid).unwrap(); + let b = document_b.active(*sid).unwrap(); + assert_eq!(a.roots, b.roots, "roots of {sid}"); + let roots: Vec = a.roots.values().copied().collect(); + let reachable = |revision: &Revision<'_>| -> BTreeSet { + revision + .parents(&roots) + .unwrap() + .into_keys() + .chain(roots.iter().copied()) + .filter(|id| !matches!(revision.nodes[id].jcid, 0x12004d | 0x120001)) + .collect() + }; + let ids_a = reachable(a); + let ids_b = reachable(b); + assert_eq!(ids_a, ids_b, "reachable objects of {sid}"); + for id in ids_a { + same_element(a, b, id); + } + } +} + +/// Runs the page writer on the model the typed writers produced and checks both contracts. +fn oracle(source: &[u8], space: ExGuid, reference: &[u8]) -> Vec { + let after = page_in(reference, space); + let prepared = PreparedEdit::page(source, space, &after, AUTHOR).unwrap(); + let written = prepared.as_bytes().to_vec(); + assert_eq!(page_in(&written, space), after, "model round trip"); + equivalent(reference, &written); + let transactions = |bytes: &[u8]| Store::parse(bytes).unwrap().header.transaction_count; + assert_eq!( + transactions(&written), + transactions(source) + 1, + "one transaction" + ); + written +} + +#[test] +fn text_replacement_matches_replace_text() { + let (space, page) = page_by_title(PARAGRAPHS, "Split middle"); + let (_, text, content) = text_ids(body(&page)[0])[0].clone(); + let end = u32::try_from(content.encode_utf16().count()).unwrap(); + for (range, replacement) in [ + (0..0, "🦀 "), + (1..end.min(3), "é"), + (end..end, " end"), + (0..end, ""), + ] { + let reference = + onestore::replace_text(PARAGRAPHS, space, text, range, replacement).unwrap(); + oracle(PARAGRAPHS, space, &reference); + } +} + +#[test] +fn formatting_matches_format_text() { + let (space, page) = page_by_title(PARAGRAPHS, "Split style boundary"); + let (_, text, content) = text_ids(body(&page)[0])[0].clone(); + let end = u32::try_from(content.encode_utf16().count()).unwrap(); + let reference = PreparedEdit::format( + PARAGRAPHS, + space, + text, + 1..end.min(4), + &[ + TextAttribute::Bold(true), + TextAttribute::Color(Some([255, 0, 0])), + TextAttribute::FontSize(14.0), + ], + ) + .unwrap(); + let reference = PreparedEdit::format( + reference.as_bytes(), + space, + text, + 0..1, + &[ + TextAttribute::Italic(true), + TextAttribute::Font("Consolas".into()), + ], + ) + .unwrap(); + oracle(PARAGRAPHS, space, reference.as_bytes()); +} + +#[test] +fn paragraph_insertions_match_insertion() { + let (space, page) = page_by_title(OUTLINES, "Move subtree down"); + let outline = body(&page)[0]; + let paragraphs = text_ids(outline); + let parent = outline + .paragraphs + .iter() + .find(|p| p.parent.is_none()) + .unwrap() + .id; + for intent in [ + Insertion::paragraph(outline.id, None, "Appended 🦋 é", AUTHOR).unwrap(), + Insertion::paragraph(outline.id, Some(paragraphs[0].0), "First", AUTHOR).unwrap(), + Insertion::paragraph(parent, None, "Nested child", AUTHOR).unwrap(), + Insertion::paragraph(outline.id, None, "", AUTHOR).unwrap(), + ] { + let reference = PreparedEdit::insert(OUTLINES, space, &intent).unwrap(); + oracle(OUTLINES, space, reference.as_bytes()); + } +} + +#[test] +fn cell_insertion_and_deletion_match() { + let (space, page) = page_by_title(TREES, "Delete cell subtree"); + let outline = body(&page)[0]; + let table = outline + .paragraphs + .iter() + .find_map(|p| match &p.content { + ParagraphContent::Table(table) => Some(table), + _ => None, + }) + .unwrap(); + let cell = &table.rows[0].cells[0]; + let intent = Insertion::paragraph(cell.id, None, "Cell text", AUTHOR).unwrap(); + let reference = PreparedEdit::insert(TREES, space, &intent).unwrap(); + oracle(TREES, space, reference.as_bytes()); + let victim = cell + .paragraphs + .iter() + .find(|p| p.parent.is_none()) + .unwrap() + .id; + let reference = + PreparedEdit::tree(TREES, space, &TreeEdit::delete(victim, AUTHOR).unwrap()).unwrap(); + oracle(TREES, space, reference.as_bytes()); +} + +#[test] +fn outline_insertion_matches_insertion() { + let (space, _) = page_by_title(OUTLINES, "Move leaf down"); + let page = page_id(OUTLINES, space); + let intent = Insertion::outline(page, 144.0, 200.0, "New outline 東京", AUTHOR).unwrap(); + let reference = PreparedEdit::insert(OUTLINES, space, &intent).unwrap(); + oracle(OUTLINES, space, reference.as_bytes()); +} + +#[test] +fn splits_and_joins_match_their_writers() { + for title in ["Split middle", "Split start", "Split end", "Split parent"] { + let (space, page) = page_by_title(PARAGRAPHS, title); + let paragraphs = text_ids(body(&page)[0]); + let (_, text, content) = paragraphs[0].clone(); + let units = u32::try_from(content.encode_utf16().count()).unwrap(); + let offset = match title { + "Split start" => 0, + "Split end" => units, + _ => units / 2, + }; + let offset = (0..=offset) + .rev() + .find(|at| { + content + .encode_utf16() + .nth(at.wrapping_sub(1) as usize) + .is_none_or(|u| !(0xd800..=0xdbff).contains(&u)) + }) + .unwrap(); + let split = ParagraphSplit::new(text, offset, AUTHOR).unwrap(); + let reference = PreparedEdit::split(PARAGRAPHS, space, &split).unwrap(); + oracle(PARAGRAPHS, space, reference.as_bytes()); + } + for title in ["Split middle", "Split empty"] { + let (space, page) = page_by_title(PARAGRAPHS, title); + let paragraphs = text_ids(body(&page)[0]); + let join = ParagraphJoin::new(paragraphs[0].1, paragraphs[1].1, AUTHOR).unwrap(); + let reference = PreparedEdit::join(PARAGRAPHS, space, &join).unwrap(); + oracle(PARAGRAPHS, space, reference.as_bytes()); + } +} + +#[test] +fn subtree_moves_and_deletions_match_tree_edits() { + for (title, build) in [ + ("Move leaf down", 0usize), + ("Move subtree down", 1), + ("Delete leaf", 2), + ("Delete subtree", 3), + ("Move outline", 4), + ("Delete outline", 5), + ] { + let (space, page) = page_by_title(OUTLINES, title); + let outlines = body(&page); + let outline = outlines[0]; + let top: Vec = outline + .paragraphs + .iter() + .filter(|p| p.parent.is_none()) + .map(|p| p.id) + .collect(); + let edit = match build { + 0 | 1 => TreeEdit::move_to(top[0], outline.id, top.get(2).copied(), AUTHOR).unwrap(), + 2 | 3 => TreeEdit::delete(top[0], AUTHOR).unwrap(), + 4 => TreeEdit::move_to(outline.id, page_id(OUTLINES, space), None, AUTHOR).unwrap(), + _ => TreeEdit::delete(outline.id, AUTHOR).unwrap(), + }; + let reference = PreparedEdit::tree(OUTLINES, space, &edit).unwrap(); + oracle(OUTLINES, space, reference.as_bytes()); + if outlines.len() > 1 && build == 0 { + let across = TreeEdit::move_to(top[1], outlines[1].id, None, AUTHOR).unwrap(); + let reference = PreparedEdit::tree(OUTLINES, space, &across).unwrap(); + oracle(OUTLINES, space, reference.as_bytes()); + } + } +} + +#[test] +fn outline_layout_and_collapse_match_outline_edits() { + let (space, page) = page_by_title(OUTLINES, "Collapse subtree"); + let outline = body(&page)[0]; + let parent = outline + .paragraphs + .iter() + .find(|p| !outline.paragraphs.iter().all(|q| q.parent != Some(p.id))) + .unwrap() + .id; + for (object, edit) in [ + (outline.id, OutlineEdit::Position { x: 90.0, y: 250.5 }), + ( + outline.id, + OutlineEdit::Width { + points: 300.0, + user_set: true, + }, + ), + ( + outline.id, + OutlineEdit::Width { + points: 200.0, + user_set: false, + }, + ), + (parent, OutlineEdit::Collapsed(true)), + ] { + let reference = PreparedEdit::outline(OUTLINES, space, object, edit).unwrap(); + oracle(OUTLINES, space, reference.as_bytes()); + } +} + +#[test] +fn composed_edits_publish_one_transaction() { + let (space, page) = page_by_title(OUTLINES, "Move subtree up"); + let outline = body(&page)[0]; + let paragraphs = text_ids(outline); + let mut reference = + onestore::replace_text(OUTLINES, space, paragraphs[0].1, 0..0, "Lead ").unwrap(); + let insertion = + Insertion::paragraph(outline.id, Some(paragraphs[1].0), "Inserted", AUTHOR).unwrap(); + reference = PreparedEdit::insert(&reference, space, &insertion) + .unwrap() + .as_bytes() + .to_vec(); + reference = PreparedEdit::format( + &reference, + space, + insertion.text_object(), + 0..3, + &[TextAttribute::Bold(true)], + ) + .unwrap() + .as_bytes() + .to_vec(); + let last = paragraphs.last().unwrap().0; + reference = PreparedEdit::tree(&reference, space, &TreeEdit::delete(last, AUTHOR).unwrap()) + .unwrap() + .as_bytes() + .to_vec(); + reference = PreparedEdit::outline( + &reference, + space, + outline.id, + OutlineEdit::Position { x: 60.0, y: 60.0 }, + ) + .unwrap() + .as_bytes() + .to_vec(); + let written = oracle(OUTLINES, space, &reference); + assert_eq!( + Store::parse(&reference).unwrap().header.transaction_count, + Store::parse(OUTLINES).unwrap().header.transaction_count + 5 + ); + assert!(written.len() < reference.len()); +} + +#[test] +fn model_edits_round_trip_and_leave_other_objects_untouched() { + let (space, mut after) = page_by_title(TREES, "Move numbered subtree down"); + let before = page_in(TREES, space); + let page = page_id(TREES, space); + let mut new_paragraph = None; + for object in &mut after.objects { + let PageObject::Outline(outline) = object else { + continue; + }; + if outline.title || new_paragraph.is_some() { + continue; + } + let first = outline + .paragraphs + .iter() + .position(|p| p.text().is_some()) + .unwrap(); + let text = outline.paragraphs[first].text_mut().unwrap(); + let end = text.text.utf16_offset(text.text.text().len()).unwrap(); + text.text + .apply(onestore::page::text::Edit { + range: end..end, + replacement: onestore::page::Paragraph::new( + " appended 🦀".into(), + text.text.format_at(end).unwrap().clone(), + ), + }) + .unwrap(); + let mut fresh = outline.paragraphs[first].clone(); + fresh.id = new_id().unwrap(); + fresh.parent = None; + fresh.level = 1; + fresh.lists.clear(); + fresh.tags.clear(); + fresh.style = None; + fresh.collapsed = false; + let fresh_text = fresh.text_mut().unwrap(); + fresh_text.id = new_id().unwrap(); + fresh_text.tags.clear(); + fresh_text.text = onestore::page::Paragraph::new( + "Brand new".into(), + fresh_text.text.format_at(0).unwrap().clone(), + ); + new_paragraph = Some(fresh.id); + outline.paragraphs.push(fresh); + outline.layout.x = Some(outline.layout.x.unwrap_or(0.0) + 18.0); + } + let mut outline = onestore::page::Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: onestore::document::Layout { + x: Some(300.0), + y: Some(400.0), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: Vec::new(), + unsupported: Vec::new(), + }; + let template = body(&before)[0] + .paragraphs + .iter() + .find(|p| p.text().is_some()) + .unwrap() + .clone(); + let mut paragraph = template.clone(); + paragraph.id = new_id().unwrap(); + paragraph.parent = None; + paragraph.level = 1; + paragraph.lists.clear(); + paragraph.tags.clear(); + paragraph.style = None; + paragraph.collapsed = false; + let text = paragraph.text_mut().unwrap(); + text.id = new_id().unwrap(); + text.tags.clear(); + text.text = onestore::page::Paragraph::new( + "Outline text".into(), + text.text.format_at(0).unwrap().clone(), + ); + outline.paragraphs.push(paragraph); + after.objects.push(PageObject::Outline(outline)); + let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let normalize = |page: &Page| -> Vec<(ExGuid, Vec<(ExGuid, String)>)> { + body(page) + .iter() + .map(|o| { + ( + o.id, + o.paragraphs + .iter() + .filter_map(|p| p.text().map(|t| (p.id, t.text.text().to_owned()))) + .collect(), + ) + }) + .collect() + }; + assert_eq!(normalize(&stored), normalize(&after)); + assert_eq!( + body(&stored) + .iter() + .map(|o| (o.layout.x, o.layout.y)) + .collect::>(), + body(&after) + .iter() + .map(|o| (o.layout.x, o.layout.y)) + .collect::>() + ); + let store = Store::parse(TREES).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let raw_before = index.resolve_active(space).unwrap(); + let store = Store::parse(written.as_bytes()).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let raw_after = index.resolve_active(space).unwrap(); + let touched: BTreeSet = body(&after) + .iter() + .flat_map(|o| { + std::iter::once(o.id).chain( + o.paragraphs + .iter() + .flat_map(|p| [p.id, p.text().map(|t| t.id).unwrap_or(p.id)]), + ) + }) + .chain([page]) + .collect(); + let mut untouched = 0; + for (id, object) in &raw_before.objects { + if touched.contains(id) || matches!(object.jcid, 0x12004d | 0x120001) { + continue; + } + let after = &raw_after.objects[id]; + assert_eq!((object.jcid, object.data), (after.jcid, after.data), "{id}"); + untouched += 1; + } + assert!(untouched > 5, "{untouched}"); +} + +#[test] +fn unsupported_model_edits_are_rejected_before_writing() { + let (space, page) = page_by_title(TREES, "Delete cell subtree"); + let mut widened = page_in(TREES, space); + for object in &mut widened.objects { + if let PageObject::Outline(outline) = object { + for paragraph in &mut outline.paragraphs { + if let ParagraphContent::Table(table) = &mut paragraph.content { + table.columns[0].width += 10.0; + } + } + } + } + assert!(PreparedEdit::page(TREES, space, &widened, AUTHOR).is_err()); + let mut relisted = page_in(TREES, space); + let outline = body(&page)[0]; + for object in &mut relisted.objects { + if let PageObject::Outline(o) = object + && o.id == outline.id + { + o.paragraphs[0].lists.push(new_id().unwrap()); + } + } + assert!(PreparedEdit::page(TREES, space, &relisted, AUTHOR).is_err()); + let mut renamed = page_in(TREES, space); + renamed.created = Some(1); + assert!(PreparedEdit::page(TREES, space, &renamed, AUTHOR).is_err()); + assert!(PreparedEdit::page(TREES, space, &page, "a\0b").is_err()); +} + +#[test] +fn an_unchanged_model_publishes_nothing() { + let (space, page) = page_by_title(OUTLINES, "Resize outline"); + let prepared = PreparedEdit::page(OUTLINES, space, &page, AUTHOR).unwrap(); + assert_eq!(prepared.as_bytes(), OUTLINES); +} -- 2.54.0