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); +}