authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-10 23:05:38-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-10 23:05:38-07:00
log3283203e9f87d21af0fe6dd1f6aaa18866cbc25e
treeea2ac48d786e596031fae641ff159da69baafe90
parentc5acf4f0209f9e28a4496e04a421a11cb40b2984
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

7 files changed, 1730 insertions(+), 5 deletions(-)

crates/onestore/README.md+1
...@@ -58,6 +58,7 @@ harness also accepts `--client-profile release`....@@ -58,6 +58,7 @@ harness also accepts `--client-profile release`.
58| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |58| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
59| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |59| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
60| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |60| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
61| `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 |
61| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |62| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
62| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |63| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
63| `read_file` | Read a snapshot under whole-file exclusion |64| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/commit.rs+15
...@@ -205,6 +205,21 @@ impl<'a> PreparedEdit<'a> {...@@ -205,6 +205,21 @@ impl<'a> PreparedEdit<'a> {
205 })205 })
206 }206 }
207207
208 /// Publishes an edited page model as one revision per changed space.
209 /// The model must come from this snapshot; new paragraphs and outlines carry the identities
210 /// the model assigned, and content outside the model is untouched.
211 pub fn page(
212 source: &'a [u8],
213 space: ExGuid,
214 page: &crate::page::Page,
215 author: &str,
216 ) -> Result<Self, crate::Error> {
217 Ok(Self {
218 source,
219 written: crate::page::write::write_page(source, space, page, author)?,
220 })
221 }
222
208 /// Creates a page and its section entry in one transaction, retaining the intent's identities.223 /// Creates a page and its section entry in one transaction, retaining the intent's identities.
209 pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result<Self, crate::Error> {224 pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result<Self, crate::Error> {
210 Ok(Self {225 Ok(Self {
crates/onestore/src/document.rs+4-4
...@@ -123,7 +123,7 @@ format_fields! {...@@ -123,7 +123,7 @@ format_fields! {
123 list_spacing: f32,123 list_spacing: f32,
124}124}
125125
126#[derive(Debug, Serialize)]126#[derive(Debug, PartialEq, Serialize)]
127pub struct TextRun {127pub struct TextRun {
128 pub start: u32,128 pub start: u32,
129 pub end: u32,129 pub end: u32,
...@@ -266,7 +266,7 @@ pub struct Tag {...@@ -266,7 +266,7 @@ pub struct Tag {
266 pub extra_set: usize,266 pub extra_set: usize,
267}267}
268268
269#[derive(Debug, Serialize)]269#[derive(Debug, PartialEq, Serialize)]
270#[serde(tag = "type")]270#[serde(tag = "type")]
271pub enum Kind<'a> {271pub enum Kind<'a> {
272 Section {272 Section {
...@@ -401,13 +401,13 @@ pub enum Kind<'a> {...@@ -401,13 +401,13 @@ pub enum Kind<'a> {
401 Unknown,401 Unknown,
402}402}
403403
404#[derive(Debug, Serialize)]404#[derive(Debug, PartialEq, Serialize)]
405pub struct Field<'a> {405pub struct Field<'a> {
406 pub id: u32,406 pub id: u32,
407 pub value: FieldValue<'a>,407 pub value: FieldValue<'a>,
408}408}
409409
410#[derive(Debug, Serialize)]410#[derive(Debug, PartialEq, Serialize)]
411pub enum FieldValue<'a> {411pub enum FieldValue<'a> {
412 NoData,412 NoData,
413 Bytes(#[serde(serialize_with = "hex")] &'a [u8]),413 Bytes(#[serde(serialize_with = "hex")] &'a [u8]),
crates/onestore/src/page/mod.rs+7-1
...@@ -11,6 +11,7 @@ use std::{...@@ -11,6 +11,7 @@ use std::{
11};11};
1212
13pub mod text;13pub mod text;
14pub(crate) mod write;
14pub use text::Paragraph;15pub use text::Paragraph;
1516
16/// The role of a title-outline paragraph that displays the page's creation date or time.17/// The role of a title-outline paragraph that displays the page's creation date or time.
...@@ -20,6 +21,7 @@ pub enum DateField {...@@ -20,6 +21,7 @@ pub enum DateField {
20 Time = 1,21 Time = 1,
21}22}
2223
24#[derive(Debug, PartialEq)]
23pub struct Page {25pub struct Page {
24 pub title: String,26 pub title: String,
25 /// FILETIME ticks from the page's TopologyCreationTimeStamp.27 /// FILETIME ticks from the page's TopologyCreationTimeStamp.
...@@ -29,11 +31,13 @@ pub struct Page {...@@ -29,11 +31,13 @@ pub struct Page {
29 pub definitions: BTreeMap<ExGuid, Definition>,31 pub definitions: BTreeMap<ExGuid, Definition>,
30}32}
3133
34#[derive(Debug, PartialEq)]
32pub struct Definition {35pub struct Definition {
33 pub kind: Kind<'static>,36 pub kind: Kind<'static>,
34 pub format: Format,37 pub format: Format,
35}38}
3639
40#[derive(Debug, PartialEq)]
37pub enum PageObject {41pub enum PageObject {
38 Outline(Outline),42 Outline(Outline),
39 Title(Title),43 Title(Title),
...@@ -69,6 +73,7 @@ impl PageObject {...@@ -69,6 +73,7 @@ impl PageObject {
69 }73 }
70}74}
7175
76#[derive(Debug, PartialEq)]
72pub struct Title {77pub struct Title {
73 pub id: ExGuid,78 pub id: ExGuid,
74 pub date: Option<ExGuid>,79 pub date: Option<ExGuid>,
...@@ -76,7 +81,7 @@ pub struct Title {...@@ -76,7 +81,7 @@ pub struct Title {
76 pub outlines: Vec<Outline>,81 pub outlines: Vec<Outline>,
77}82}
7883
79#[derive(Clone)]84#[derive(Clone, Debug, PartialEq)]
80pub struct Outline {85pub struct Outline {
81 pub id: ExGuid,86 pub id: ExGuid,
82 pub title: bool,87 pub title: bool,
...@@ -147,6 +152,7 @@ pub struct TextObject {...@@ -147,6 +152,7 @@ pub struct TextObject {
147 pub tags: Vec<Tag>,152 pub tags: Vec<Tag>,
148}153}
149154
155#[derive(Debug, PartialEq)]
150pub struct Image {156pub struct Image {
151 pub id: ExGuid,157 pub id: ExGuid,
152 pub layout: Layout,158 pub layout: Layout,
crates/onestore/src/page/text.rs+21
...@@ -132,6 +132,27 @@ impl fmt::Display for EditError {...@@ -132,6 +132,27 @@ impl fmt::Display for EditError {
132132
133impl std::error::Error for EditError {}133impl std::error::Error for EditError {}
134134
135impl From<EditError> for crate::Error {
136 fn from(error: EditError) -> Self {
137 Self {
138 offset: 0,
139 message: match error {
140 EditError::InvalidRange => {
141 "Text range is outside the paragraph or splits a surrogate pair"
142 }
143 EditError::TextTooLong => "Text exceeds the UTF-16 offset range",
144 EditError::InvalidStructure => {
145 "This outline has duplicate objects or broken paragraph links"
146 }
147 EditError::UnsupportedContent => {
148 "This paragraph contains content the text editor cannot edit"
149 }
150 EditError::Identity => "System random source failed",
151 },
152 }
153 }
154}
155
135/// A fresh random identity for a new paragraph or text object.156/// A fresh random identity for a new paragraph or text object.
136pub fn new_id() -> Result<ExGuid, EditError> {157pub fn new_id() -> Result<ExGuid, EditError> {
137 Ok(ExGuid {158 Ok(ExGuid {
crates/onestore/src/page/write.rs created+1053
...@@ -0,0 +1,1053 @@
1//! Publishes an edited page model by lowering the difference from the stored page onto
2//! the typed writers, then squashing their transactions into one revision per space.
3
4use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent};
5use crate::{
6 Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,
7 RevisionIndex, Store, TextAttribute, TreeEdit, Value,
8 document::{Document, Format},
9 write::{PropertyObject, RevisionEdit, write_revisions},
10};
11use std::{
12 collections::{BTreeMap, BTreeSet},
13 ops::Range,
14};
15
16fn invalid(message: &'static str) -> Error {
17 Error { offset: 0, message }
18}
19
20pub(crate) fn write_page(
21 source: &[u8],
22 space: ExGuid,
23 after: &Page,
24 author: &str,
25) -> Result<Vec<u8>, Error> {
26 if author.contains('\0') {
27 return Err(invalid("Choose an author name without NUL"));
28 }
29 let store = Store::parse(source)?;
30 let index = RevisionIndex::parse(&store)?;
31 index.validate_current()?;
32 let document = Document::parse(&index)?;
33 let pages = document.pages_in(space)?;
34 let [page] = pages.as_slice() else {
35 return Err(invalid("Choose an object space containing one active page"));
36 };
37 let before = Page::from_space(&document, space)?;
38 let raw = index.resolve_active(space)?;
39 let mut lowering = Lowering {
40 image: source.to_vec(),
41 space,
42 page: *page,
43 author,
44 alias: BTreeMap::new(),
45 };
46 lowering.run(&before, after, &raw.objects.keys().copied().collect())?;
47 if lowering.image == source {
48 return Ok(lowering.image);
49 }
50 squash(source, &lowering.image, &lowering.alias)
51}
52
53/// Direct children of every container, in model order, plus lookups by identity.
54struct View<'a> {
55 page: &'a Page,
56 outlines: BTreeMap<ExGuid, &'a Outline>,
57 /// Outlines owned by a title object rather than the page.
58 title_outlines: BTreeSet<ExGuid>,
59 paragraphs: BTreeMap<ExGuid, &'a PageParagraph>,
60 children: BTreeMap<ExGuid, Vec<ExGuid>>,
61 container: BTreeMap<ExGuid, ExGuid>,
62 page_children: Vec<ExGuid>,
63}
64
65impl<'a> View<'a> {
66 fn new(page: &'a Page) -> Result<Self, Error> {
67 let mut view = Self {
68 page,
69 outlines: BTreeMap::new(),
70 title_outlines: BTreeSet::new(),
71 paragraphs: BTreeMap::new(),
72 children: BTreeMap::new(),
73 container: BTreeMap::new(),
74 page_children: Vec::new(),
75 };
76 for object in &page.objects {
77 view.page_children.push(object.id());
78 match object {
79 PageObject::Outline(outline) => view.outline(outline)?,
80 PageObject::Title(title) => {
81 for outline in &title.outlines {
82 view.title_outlines.insert(outline.id);
83 view.outline(outline)?;
84 }
85 }
86 PageObject::Image(_) | PageObject::Unsupported(_) => {}
87 }
88 }
89 Ok(view)
90 }
91
92 fn outline(&mut self, outline: &'a Outline) -> Result<(), Error> {
93 if self.outlines.insert(outline.id, outline).is_some() {
94 return Err(invalid("The page model repeats an outline identity"));
95 }
96 self.children.entry(outline.id).or_default();
97 self.paragraphs_of(outline.id, &outline.paragraphs)
98 }
99
100 fn paragraphs_of(&mut self, root: ExGuid, list: &'a [PageParagraph]) -> Result<(), Error> {
101 for paragraph in list {
102 let container = paragraph.parent.unwrap_or(root);
103 if paragraph
104 .parent
105 .is_some_and(|parent| !self.paragraphs.contains_key(&parent))
106 {
107 return Err(invalid(
108 "A paragraph's parent must precede it in its container",
109 ));
110 }
111 if self.paragraphs.insert(paragraph.id, paragraph).is_some() {
112 return Err(invalid("The page model repeats a paragraph identity"));
113 }
114 self.children
115 .entry(container)
116 .or_default()
117 .push(paragraph.id);
118 self.children.entry(paragraph.id).or_default();
119 self.container.insert(paragraph.id, container);
120 if let ParagraphContent::Table(table) = &paragraph.content {
121 for row in &table.rows {
122 for cell in &row.cells {
123 if self.children.contains_key(&cell.id) {
124 return Err(invalid("The page model repeats a cell identity"));
125 }
126 self.children.entry(cell.id).or_default();
127 self.paragraphs_of(cell.id, &cell.paragraphs)?;
128 }
129 }
130 }
131 }
132 Ok(())
133 }
134
135 fn text(&self, paragraph: ExGuid) -> Option<&'a super::TextObject> {
136 self.paragraphs.get(&paragraph).and_then(|p| p.text())
137 }
138}
139
140struct Lowering<'a> {
141 image: Vec<u8>,
142 space: ExGuid,
143 page: ExGuid,
144 author: &'a str,
145 /// Model identities of new objects mapped to the identities the typed writers allocated.
146 alias: BTreeMap<ExGuid, ExGuid>,
147}
148
149impl Lowering<'_> {
150 fn id(&self, model: ExGuid) -> ExGuid {
151 self.alias.get(&model).copied().unwrap_or(model)
152 }
153
154 fn apply(&mut self, edit: impl FnOnce(&[u8]) -> Result<Vec<u8>, Error>) -> Result<(), Error> {
155 self.image = edit(&self.image)?;
156 Ok(())
157 }
158
159 fn current(&self) -> Result<Page, Error> {
160 let store = Store::parse(&self.image)?;
161 let index = RevisionIndex::parse(&store)?;
162 let document = Document::parse(&index)?;
163 Page::from_space(&document, self.space)
164 }
165
166 fn run(
167 &mut self,
168 before: &Page,
169 after: &Page,
170 existing: &BTreeSet<ExGuid>,
171 ) -> Result<(), Error> {
172 if after.created != before.created || after.margin_origin != before.margin_origin {
173 return Err(invalid("Page creation time and margins cannot be edited"));
174 }
175 for (id, definition) in &after.definitions {
176 if before.definitions.get(id) != Some(definition) {
177 return Err(invalid("List, tag and style definitions cannot be edited"));
178 }
179 }
180 let old = View::new(before)?;
181 let new = View::new(after)?;
182 self.check_fixed_objects(before, after, &old, &new)?;
183 for id in new.outlines.keys().chain(new.paragraphs.keys()) {
184 if !old.outlines.contains_key(id)
185 && !old.paragraphs.contains_key(id)
186 && existing.contains(id)
187 {
188 return Err(invalid(
189 "A new model identity already exists in the section",
190 ));
191 }
192 }
193 let mut placed: BTreeMap<ExGuid, Vec<ExGuid>> = BTreeMap::new();
194 for (container, children) in &old.children {
195 placed.insert(*container, children.clone());
196 }
197 let mut page_order: Vec<ExGuid> = old
198 .page_children
199 .iter()
200 .copied()
201 .filter(|id| !old.outlines.contains_key(id) || new.outlines.contains_key(id))
202 .collect();
203 self.insert_outlines(&old, &new, &mut placed, &mut page_order)?;
204 let mut consumed = BTreeSet::new();
205 self.split_and_join(&old, &new, &mut placed, &mut consumed)?;
206 self.place(&old, &new, &placed, &page_order)?;
207 self.delete(&old, &new, &consumed)?;
208 self.edit_text(&new)?;
209 self.edit_formatting(&new)?;
210 self.edit_layout(&old, &new)?;
211 Ok(())
212 }
213
214 fn check_fixed_objects(
215 &self,
216 before: &Page,
217 after: &Page,
218 old: &View<'_>,
219 new: &View<'_>,
220 ) -> Result<(), Error> {
221 let fixed = |page: &Page| -> Vec<String> {
222 page.objects
223 .iter()
224 .filter_map(|object| match object {
225 PageObject::Image(image) => Some(format!("{image:?}")),
226 PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")),
227 PageObject::Title(title) => Some(format!(
228 "{:?} {:?} {:?} {:?}",
229 title.id,
230 title.date,
231 title.layout,
232 title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()
233 )),
234 PageObject::Outline(_) => None,
235 })
236 .collect()
237 };
238 let mut before_fixed = fixed(before);
239 let mut after_fixed = fixed(after);
240 before_fixed.sort();
241 after_fixed.sort();
242 if before_fixed != after_fixed {
243 return Err(invalid(
244 "Images, titles and unsupported objects cannot be edited through the page model",
245 ));
246 }
247 for (id, outline) in &new.outlines {
248 if outline.paragraphs.is_empty() {
249 return Err(invalid(
250 "An outline needs a paragraph; remove the outline instead",
251 ));
252 }
253 let Some(previous) = old.outlines.get(id) else {
254 continue;
255 };
256 let same = outline.title == previous.title
257 && outline.min_width == previous.min_width
258 && outline.indents == previous.indents
259 && outline.unsupported == previous.unsupported
260 && (!new.title_outlines.contains(id) || outline.layout == previous.layout);
261 if !same {
262 return Err(invalid(
263 "Outline roles, indentation tables and title geometry cannot be edited",
264 ));
265 }
266 }
267 for (id, paragraph) in &new.paragraphs {
268 let Some(previous) = old.paragraphs.get(id) else {
269 continue;
270 };
271 let same = paragraph.lists == previous.lists
272 && paragraph.tags == previous.tags
273 && paragraph.style == previous.style
274 && paragraph.format == previous.format;
275 if !same {
276 return Err(invalid(
277 "Paragraph lists, tags, styles and paragraph formatting cannot be edited",
278 ));
279 }
280 match (&paragraph.content, &previous.content) {
281 (ParagraphContent::Text(text), ParagraphContent::Text(previous)) => {
282 if text.date_field != previous.date_field || text.tags != previous.tags {
283 return Err(invalid("Text fields and tags cannot be edited"));
284 }
285 }
286 (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => {
287 let same = table.id == previous.id
288 && table.columns == previous.columns
289 && table.borders == previous.borders
290 && table.layout == previous.layout
291 && table.tags == previous.tags
292 && table.rows.len() == previous.rows.len()
293 && table
294 .rows
295 .iter()
296 .zip(&previous.rows)
297 .all(|(row, previous)| {
298 row.id == previous.id
299 && row.cells.len() == previous.cells.len()
300 && row.cells.iter().zip(&previous.cells).all(
301 |(cell, previous)| {
302 cell.id == previous.id
303 && cell.layout == previous.layout
304 && cell.indents == previous.indents
305 && cell.shading == previous.shading
306 && cell.unsupported == previous.unsupported
307 },
308 )
309 });
310 if !same {
311 return Err(invalid("Table structure cannot be edited"));
312 }
313 }
314 (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {}
315 _ => return Err(invalid("Paragraph content type cannot change")),
316 }
317 }
318 Ok(())
319 }
320
321 fn insert_outlines(
322 &mut self,
323 old: &View<'_>,
324 new: &View<'_>,
325 placed: &mut BTreeMap<ExGuid, Vec<ExGuid>>,
326 page_order: &mut Vec<ExGuid>,
327 ) -> Result<(), Error> {
328 for id in &new.page_children {
329 let Some(outline) = new.outlines.get(id) else {
330 continue;
331 };
332 if old.outlines.contains_key(id) {
333 continue;
334 }
335 if new.title_outlines.contains(id) {
336 return Err(invalid("Title outlines cannot be added"));
337 }
338 let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else {
339 return Err(invalid("A new outline needs a position"));
340 };
341 let Some(first) = new.children[id].first().copied() else {
342 return Err(invalid("A new outline needs a paragraph"));
343 };
344 let text = new
345 .text(first)
346 .ok_or_else(|| invalid("A new outline starts with a text paragraph"))?;
347 let insertion = Insertion::outline(self.page, x, y, text.text.text(), self.author)?;
348 let space = self.space;
349 self.apply(|image| insertion.apply(image, space))?;
350 let outline_id = insertion.object();
351 self.alias.insert(*id, outline_id);
352 self.alias.insert(
353 first,
354 ExGuid {
355 guid: outline_id.guid,
356 n: 3,
357 },
358 );
359 self.alias.insert(text.id, insertion.text_object());
360 placed.insert(*id, vec![first]);
361 placed.insert(first, Vec::new());
362 page_order.push(*id);
363 }
364 Ok(())
365 }
366
367 fn split_and_join(
368 &mut self,
369 old: &View<'_>,
370 new: &View<'_>,
371 placed: &mut BTreeMap<ExGuid, Vec<ExGuid>>,
372 consumed: &mut BTreeSet<ExGuid>,
373 ) -> Result<(), Error> {
374 for (container, children) in &new.children {
375 if !old.children.contains_key(container) {
376 continue;
377 }
378 for pair in children.windows(2) {
379 let [left, right] = [pair[0], pair[1]];
380 let (Some(previous), Some(after), Some(next)) =
381 (old.text(left), new.text(left), new.text(right))
382 else {
383 continue;
384 };
385 if old.paragraphs.contains_key(&right)
386 || old.container.get(&left) != Some(container)
387 || !new.children[&left].is_empty()
388 || new.children[&right] != old.children[&left]
389 || after.id != previous.id
390 || new.paragraphs[&right].style != old.paragraphs[&left].style
391 {
392 continue;
393 }
394 let Ok(offset) = after.text.utf16_offset(after.text.text().len()) else {
395 continue;
396 };
397 // Splitting at the end only differs from appending a paragraph by the copied
398 // paragraph style; without one, an appended empty paragraph is an insertion.
399 if offset == previous.text.utf16_offset(previous.text.text().len())?
400 && new.paragraphs[&right].style.is_none()
401 {
402 continue;
403 }
404 let Ok(head) = previous.text.slice(0..offset) else {
405 continue;
406 };
407 let Ok(tail) = previous.text.slice(
408 offset
409 ..previous
410 .text
411 .utf16_offset(previous.text.text().len())
412 .unwrap_or(0),
413 ) else {
414 continue;
415 };
416 // An emptied side takes the writer's insertion style, so only its text must agree.
417 let same = |expected: &super::Paragraph, actual: &super::Paragraph| {
418 expected.text() == actual.text()
419 && (expected.text().is_empty() || expected == actual)
420 };
421 if !same(&head, &after.text) || !same(&tail, &next.text) {
422 continue;
423 }
424 let split = ParagraphSplit::new(previous.id, offset, self.author)?;
425 let space = self.space;
426 self.apply(|image| split.apply(image, space))?;
427 self.alias.insert(right, split.object());
428 self.alias.insert(next.id, split.text_object());
429 let list = placed.get_mut(container).unwrap();
430 let at = list.iter().position(|id| *id == left).unwrap();
431 list.insert(at + 1, right);
432 placed.insert(right, placed[&left].clone());
433 placed.insert(left, Vec::new());
434 }
435 }
436 for (container, children) in &old.children {
437 if !new.children.contains_key(container) {
438 continue;
439 }
440 for pair in children.windows(2) {
441 let [left, right] = [pair[0], pair[1]];
442 let (Some(previous), Some(after), Some(removed)) =
443 (old.text(left), new.text(left), old.text(right))
444 else {
445 continue;
446 };
447 if new.paragraphs.contains_key(&right)
448 || !old.children[&left].is_empty()
449 || !old.children[&right].is_empty()
450 || removed.text.text().is_empty()
451 {
452 continue;
453 }
454 let mut joined = previous.text.clone();
455 if joined.append(removed.text.clone()).is_err() || joined != after.text {
456 continue;
457 }
458 let adopts_right = previous.text.text().is_empty();
459 if after.id
460 != if adopts_right {
461 removed.id
462 } else {
463 previous.id
464 }
465 {
466 return Err(invalid(
467 "A joined paragraph keeps the left text identity unless the left text was empty",
468 ));
469 }
470 let join = ParagraphJoin::new(previous.id, removed.id, self.author)?;
471 let space = self.space;
472 self.apply(|image| join.apply(image, space))?;
473 consumed.insert(right);
474 placed.get_mut(container).unwrap().retain(|id| *id != right);
475 placed.remove(&right);
476 }
477 }
478 Ok(())
479 }
480
481 fn place(
482 &mut self,
483 old: &View<'_>,
484 new: &View<'_>,
485 placed: &BTreeMap<ExGuid, Vec<ExGuid>>,
486 page_order: &[ExGuid],
487 ) -> Result<(), Error> {
488 let title = |page: &Page, id: ExGuid| {
489 page.objects
490 .iter()
491 .any(|object| matches!(object, PageObject::Title(title) if title.id == id))
492 };
493 let survivors: Vec<ExGuid> = page_order
494 .iter()
495 .copied()
496 .filter(|id| {
497 (new.outlines.contains_key(id) || !old.outlines.contains_key(id))
498 && !title(old.page, *id)
499 })
500 .collect();
501 let after: Vec<ExGuid> = new
502 .page_children
503 .iter()
504 .copied()
505 .filter(|id| !title(new.page, *id))
506 .collect();
507 let kept = kept_set(&survivors, &after, |id| {
508 new.outlines.contains_key(&id) && !new.title_outlines.contains(&id)
509 })?;
510 let mut next = None;
511 for id in after.iter().rev() {
512 if !kept.contains(id) {
513 let edit = TreeEdit::move_to(
514 self.id(*id),
515 self.page,
516 next.map(|n| self.id(n)),
517 self.author,
518 )?;
519 let space = self.space;
520 self.apply(|image| edit.apply(image, space))?;
521 }
522 next = Some(*id);
523 }
524 let mut containers: Vec<ExGuid> = Vec::new();
525 for object in &new.page.objects {
526 let outlines: Vec<&Outline> = match object {
527 PageObject::Outline(outline) => vec![outline],
528 PageObject::Title(title) => title.outlines.iter().collect(),
529 PageObject::Image(_) | PageObject::Unsupported(_) => Vec::new(),
530 };
531 for outline in outlines {
532 containers.push(outline.id);
533 collect_containers(&outline.paragraphs, &mut containers);
534 }
535 }
536 for container in containers {
537 let after = &new.children[&container];
538 let current: Vec<ExGuid> = placed
539 .get(&container)
540 .map(|list| {
541 list.iter()
542 .copied()
543 .filter(|id| new.paragraphs.contains_key(id))
544 .collect()
545 })
546 .unwrap_or_default();
547 let kept = kept_set(&current, after, |_| true)?;
548 let mut next = None;
549 for id in after.iter().rev() {
550 let anchor = next.map(|n| self.id(n));
551 if !old.paragraphs.contains_key(id) && !self.alias.contains_key(id) {
552 let paragraph = new.paragraphs[id];
553 let text = paragraph
554 .text()
555 .filter(|text| text.date_field.is_none() && text.tags.is_empty())
556 .filter(|_| {
557 paragraph.lists.is_empty()
558 && paragraph.tags.is_empty()
559 && paragraph.style.is_none()
560 })
561 .ok_or_else(|| {
562 invalid("New paragraphs contain plain text without lists, tags, fields or styles")
563 })?;
564 let insertion = Insertion::paragraph(
565 self.id(container),
566 anchor,
567 text.text.text(),
568 self.author,
569 )?;
570 let space = self.space;
571 self.apply(|image| insertion.apply(image, space))?;
572 self.alias.insert(*id, insertion.object());
573 self.alias.insert(text.id, insertion.text_object());
574 } else if !kept.contains(id) {
575 let edit =
576 TreeEdit::move_to(self.id(*id), self.id(container), anchor, self.author)?;
577 let space = self.space;
578 self.apply(|image| edit.apply(image, space))?;
579 }
580 next = Some(*id);
581 }
582 }
583 Ok(())
584 }
585
586 fn delete(
587 &mut self,
588 old: &View<'_>,
589 new: &View<'_>,
590 consumed: &BTreeSet<ExGuid>,
591 ) -> Result<(), Error> {
592 let removed_outline =
593 |id: ExGuid| old.outlines.contains_key(&id) && !new.outlines.contains_key(&id);
594 for id in old.paragraphs.keys() {
595 if new.paragraphs.contains_key(id) || consumed.contains(id) {
596 continue;
597 }
598 let mut ancestor = old.container[id];
599 let mut covered = false;
600 loop {
601 if removed_outline(ancestor)
602 || (old.paragraphs.contains_key(&ancestor)
603 && !new.paragraphs.contains_key(&ancestor)
604 && !consumed.contains(&ancestor))
605 {
606 covered = true;
607 break;
608 }
609 match old.container.get(&ancestor) {
610 Some(parent) => ancestor = *parent,
611 None => break,
612 }
613 }
614 if covered {
615 continue;
616 }
617 let edit = TreeEdit::delete(*id, self.author)?;
618 let space = self.space;
619 self.apply(|image| edit.apply(image, space))?;
620 }
621 for id in old.outlines.keys() {
622 if removed_outline(*id) {
623 if old.title_outlines.contains(id) {
624 return Err(invalid("Title outlines cannot be removed"));
625 }
626 let edit = TreeEdit::delete(*id, self.author)?;
627 let space = self.space;
628 self.apply(|image| edit.apply(image, space))?;
629 }
630 }
631 Ok(())
632 }
633
634 fn edit_text(&mut self, new: &View<'_>) -> Result<(), Error> {
635 let current = self.current()?;
636 let current = View::new(&current)?;
637 for (id, paragraph) in &new.paragraphs {
638 let Some(text) = paragraph.text() else {
639 continue;
640 };
641 let image_id = self.id(*id);
642 let Some(stored) = current.text(image_id) else {
643 return Err(invalid("A paragraph is missing after structural edits"));
644 };
645 if stored.id != self.id(text.id) {
646 return Err(invalid("Text object identities cannot change"));
647 }
648 if stored.text.text() == text.text.text() {
649 continue;
650 }
651 let (range, replacement) = text_edit(stored.text.text(), text.text.text())?;
652 let (space, object) = (self.space, stored.id);
653 self.apply(|image| crate::replace_text(image, space, object, range, &replacement))?;
654 }
655 Ok(())
656 }
657
658 fn edit_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {
659 let current = self.current()?;
660 let current = View::new(&current)?;
661 for (id, paragraph) in &new.paragraphs {
662 let Some(text) = paragraph.text() else {
663 continue;
664 };
665 let stored = current
666 .text(self.id(*id))
667 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
668 if stored.text.text() != text.text.text() {
669 return Err(invalid("Text edits did not converge on the model"));
670 }
671 let mut boundaries = BTreeSet::new();
672 for paragraph in [&stored.text, &text.text] {
673 for span in paragraph.spans() {
674 boundaries.insert(paragraph.utf16_offset(span.end)?);
675 }
676 }
677 boundaries.insert(0);
678 let boundaries: Vec<u32> = boundaries.into_iter().collect();
679 let mut pending: Option<(Range<u32>, Vec<TextAttribute>)> = None;
680 let mut edits = Vec::new();
681 for window in boundaries.windows(2) {
682 let (start, end) = (window[0], window[1]);
683 if start == end {
684 continue;
685 }
686 let attributes = attributes(
687 format_in(&stored.text, start)?,
688 format_in(&text.text, start)?,
689 )?;
690 match &mut pending {
691 Some((range, previous)) if *previous == attributes && range.end == start => {
692 range.end = end;
693 }
694 _ => {
695 if let Some(edit) = pending.take() {
696 edits.push(edit);
697 }
698 pending = Some((start..end, attributes));
699 }
700 }
701 }
702 edits.extend(pending);
703 if text.text.text().is_empty() {
704 let attributes =
705 attributes(format_in(&stored.text, 0)?, format_in(&text.text, 0)?)?;
706 if !attributes.is_empty() {
707 edits.push((0..0, attributes));
708 }
709 }
710 for (range, attributes) in edits {
711 if attributes.is_empty() {
712 continue;
713 }
714 let (space, object) = (self.space, stored.id);
715 self.apply(|image| {
716 crate::formatting::format_text(image, space, object, range, &attributes)
717 })?;
718 }
719 }
720 Ok(())
721 }
722
723 fn edit_layout(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> {
724 let current = self.current()?;
725 let current = View::new(&current)?;
726 for (id, paragraph) in &new.paragraphs {
727 let stored = current
728 .paragraphs
729 .get(&self.id(*id))
730 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
731 if stored.collapsed != paragraph.collapsed {
732 let (space, object) = (self.space, self.id(*id));
733 let edit = OutlineEdit::Collapsed(paragraph.collapsed);
734 self.apply(|image| edit.apply(image, space, object))?;
735 }
736 }
737 for (id, outline) in &new.outlines {
738 if new.title_outlines.contains(id) {
739 continue;
740 }
741 let stored = current
742 .outlines
743 .get(&self.id(*id))
744 .ok_or_else(|| invalid("An outline is missing after text edits"))?;
745 let (space, object) = (self.space, self.id(*id));
746 if (outline.layout.x, outline.layout.y) != (stored.layout.x, stored.layout.y) {
747 let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else {
748 return Err(invalid("An outline position needs both coordinates"));
749 };
750 let edit = OutlineEdit::Position { x, y };
751 self.apply(|image| edit.apply(image, space, object))?;
752 }
753 if (outline.layout.max_width, outline.layout.width_set_by_user)
754 != (stored.layout.max_width, stored.layout.width_set_by_user)
755 && (old.outlines.contains_key(id) || outline.layout.max_width.is_some())
756 {
757 let Some(points) = outline.layout.max_width else {
758 return Err(invalid("An outline width cannot be removed"));
759 };
760 let edit = OutlineEdit::Width {
761 points,
762 user_set: outline.layout.width_set_by_user == Some(true),
763 };
764 self.apply(|image| edit.apply(image, space, object))?;
765 }
766 }
767 Ok(())
768 }
769}
770
771fn collect_containers(list: &[PageParagraph], out: &mut Vec<ExGuid>) {
772 for paragraph in list {
773 out.push(paragraph.id);
774 if let ParagraphContent::Table(table) = &paragraph.content {
775 for row in &table.rows {
776 for cell in &row.cells {
777 out.push(cell.id);
778 collect_containers(&cell.paragraphs, out);
779 }
780 }
781 }
782 }
783}
784
785/// Identities that keep their stored position: a longest increasing run of survivors,
786/// always including immovable ones. `after` may contain identities absent from `before`.
787fn kept_set(
788 before: &[ExGuid],
789 after: &[ExGuid],
790 movable: impl Fn(ExGuid) -> bool,
791) -> Result<BTreeSet<ExGuid>, Error> {
792 let position: BTreeMap<ExGuid, usize> =
793 after.iter().enumerate().map(|(i, id)| (*id, i)).collect();
794 let sequence: Vec<(ExGuid, usize)> = before
795 .iter()
796 .filter_map(|id| position.get(id).map(|at| (*id, *at)))
797 .collect();
798 let fixed: Vec<usize> = sequence
799 .iter()
800 .filter(|(id, _)| !movable(*id))
801 .map(|(_, at)| *at)
802 .collect();
803 if fixed.windows(2).any(|w| w[0] > w[1]) {
804 return Err(invalid(
805 "Images and unsupported objects cannot be reordered",
806 ));
807 }
808 // Longest increasing subsequence over `after` positions, forced through immovable items.
809 let n = sequence.len();
810 let mut best = vec![1usize; n];
811 let mut previous = vec![usize::MAX; n];
812 for i in 0..n {
813 let (id, at) = sequence[i];
814 let mandatory_before = sequence[..i]
815 .iter()
816 .filter(|(other, _)| !movable(*other))
817 .map(|(_, at)| *at)
818 .max();
819 if movable(id) && mandatory_before.is_some_and(|m| m > at) {
820 best[i] = 0;
821 continue;
822 }
823 let mandatory_after = sequence[i + 1..]
824 .iter()
825 .filter(|(other, _)| !movable(*other))
826 .map(|(_, at)| *at)
827 .min();
828 if movable(id) && mandatory_after.is_some_and(|m| m < at) {
829 best[i] = 0;
830 continue;
831 }
832 for j in 0..i {
833 if best[j] > 0 && sequence[j].1 < at && best[j] + 1 > best[i] {
834 best[i] = best[j] + 1;
835 previous[i] = j;
836 }
837 }
838 }
839 let mut kept = BTreeSet::new();
840 if let Some((mut i, _)) = best
841 .iter()
842 .enumerate()
843 .max_by_key(|(i, b)| (**b, usize::MAX - i))
844 && best[i] > 0
845 {
846 loop {
847 kept.insert(sequence[i].0);
848 if previous[i] == usize::MAX {
849 break;
850 }
851 i = previous[i];
852 }
853 }
854 for (id, _) in &sequence {
855 if !movable(*id) && !kept.contains(id) {
856 return Err(invalid(
857 "Images and unsupported objects cannot be reordered",
858 ));
859 }
860 }
861 Ok(kept)
862}
863
864/// The format of the span containing the UTF-16 position `at` (the last span at the end).
865fn format_in(paragraph: &super::Paragraph, at: u32) -> Result<&Format, Error> {
866 let byte = paragraph.byte_offset(at)?;
867 let spans = paragraph.spans();
868 let index = spans
869 .partition_point(|span| span.end <= byte)
870 .min(spans.len() - 1);
871 Ok(&spans[index].format)
872}
873
874/// The smallest UTF-16 range whose replacement turns `before` into `after`.
875fn text_edit(before: &str, after: &str) -> Result<(Range<u32>, String), Error> {
876 let prefix = before
877 .char_indices()
878 .zip(after.chars())
879 .take_while(|((_, a), b)| a == b)
880 .map(|((i, a), _)| i + a.len_utf8())
881 .last()
882 .unwrap_or(0);
883 let suffix = before[prefix..]
884 .chars()
885 .rev()
886 .zip(after[prefix..].chars().rev())
887 .take_while(|(a, b)| a == b)
888 .map(|(a, _)| a.len_utf8())
889 .sum::<usize>();
890 let units = |s: &str| -> Result<u32, Error> {
891 u32::try_from(s.encode_utf16().count())
892 .map_err(|_| invalid("Text exceeds UTF-16 offset range"))
893 };
894 let start = units(&before[..prefix])?;
895 let end = start + units(&before[prefix..before.len() - suffix])?;
896 Ok((start..end, after[prefix..after.len() - suffix].to_owned()))
897}
898
899/// Explicit attributes turning `current` into `target`; unsupported differences are errors.
900/// Language tags are retained as stored because the model has no way to author them.
901fn attributes(current: &Format, target: &Format) -> Result<Vec<TextAttribute>, Error> {
902 let mut out = Vec::new();
903 macro_rules! boolean {
904 ($field:ident, $variant:ident) => {
905 if current.$field != target.$field {
906 match target.$field {
907 Some(value) => out.push(TextAttribute::$variant(value)),
908 None => {
909 return Err(invalid("Inherited character formatting cannot be restored"));
910 }
911 }
912 }
913 };
914 }
915 boolean!(bold, Bold);
916 boolean!(italic, Italic);
917 boolean!(underline, Underline);
918 boolean!(strike, Strike);
919 boolean!(superscript, Superscript);
920 boolean!(subscript, Subscript);
921 if current.font != target.font {
922 match &target.font {
923 Some(font) => out.push(TextAttribute::Font(font.clone())),
924 None => return Err(invalid("Inherited character formatting cannot be restored")),
925 }
926 }
927 if current.font_size != target.font_size {
928 match target.font_size {
929 Some(size) => out.push(TextAttribute::FontSize(size)),
930 None => return Err(invalid("Inherited character formatting cannot be restored")),
931 }
932 }
933 let color =
934 |value: u32| (value != 0xff000000).then(|| value.to_le_bytes()[..3].try_into().unwrap());
935 if current.color != target.color {
936 match target.color {
937 Some(value) => out.push(TextAttribute::Color(color(value))),
938 None => return Err(invalid("Inherited character formatting cannot be restored")),
939 }
940 }
941 if current.highlight != target.highlight {
942 match target.highlight {
943 Some(value) => out.push(TextAttribute::Highlight(color(value))),
944 None => return Err(invalid("Inherited character formatting cannot be restored")),
945 }
946 }
947 // An absent value and its stored default are the same formatting.
948 let flag = |a: Option<bool>, b: Option<bool>| a.unwrap_or(false) == b.unwrap_or(false);
949 let points = |a: Option<f32>, b: Option<f32>| a.unwrap_or(0.0) == b.unwrap_or(0.0);
950 let same_rest = flag(current.hidden, target.hidden)
951 && flag(current.hyperlink, target.hyperlink)
952 && flag(current.hyperlink_label, target.hyperlink_label)
953 && flag(current.math, target.math)
954 && flag(current.embedded_object, target.embedded_object)
955 && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0)
956 && flag(current.rtl, target.rtl)
957 && points(current.space_before, target.space_before)
958 && points(current.space_after, target.space_after)
959 && points(current.line_spacing, target.line_spacing)
960 && points(current.list_spacing, target.list_spacing);
961 if !same_rest {
962 return Err(invalid(
963 "Fields, links, language and paragraph spacing cannot be edited through the page model",
964 ));
965 }
966 Ok(out)
967}
968
969/// Rewrites every revision the typed writers appended as one transaction on `source`,
970/// renaming writer-allocated identities to the model's.
971fn squash(
972 source: &[u8],
973 applied: &[u8],
974 alias: &BTreeMap<ExGuid, ExGuid>,
975) -> Result<Vec<u8>, Error> {
976 let rename: BTreeMap<ExGuid, ExGuid> = alias
977 .iter()
978 .map(|(model, image)| (*image, *model))
979 .collect();
980 let applied_store = Store::parse(applied)?;
981 let applied_index = RevisionIndex::parse(&applied_store)?;
982 write_revisions(source, |index| {
983 let mut changes = BTreeMap::new();
984 for sid in applied_index.spaces.keys() {
985 let Some(space) = index.spaces.get(sid) else {
986 return Err(invalid("Page edits cannot create object spaces"));
987 };
988 let after_rid = applied_index.active(*sid)?;
989 if space.labels.get(&(ExGuid::default(), 1)) == Some(&after_rid) {
990 continue;
991 }
992 let before = index.resolve_active(*sid)?;
993 let after = applied_index.resolve(*sid, after_rid)?;
994 if before.roots != after.roots {
995 return Err(invalid("Page edits cannot change revision roots"));
996 }
997 // Retired styles and deleted content stay in history; only live objects are written.
998 let live = after.reachable()?;
999 let mut changed = BTreeMap::new();
1000 for (id, object) in &after.objects {
1001 if !live.contains(id) {
1002 continue;
1003 }
1004 if before.objects.get(id).is_some_and(|previous| {
1005 previous.jcid == object.jcid
1006 && previous.data == object.data
1007 && previous.global_ids == object.global_ids
1008 }) {
1009 continue;
1010 }
1011 let ObjectData::Properties(_) = object.data else {
1012 return Err(invalid("Page edits only produce property objects"));
1013 };
1014 let mut replacement = PropertyObject::from_object(object)?;
1015 remap(&mut replacement, &rename)?;
1016 let id = rename.get(id).copied().unwrap_or(*id);
1017 replacement.reference(id)?;
1018 if before.objects.contains_key(&id) && rename.values().any(|model| *model == id) {
1019 return Err(invalid(
1020 "A new model identity already exists in the section",
1021 ));
1022 }
1023 changed.insert(id, replacement);
1024 }
1025 changes.insert(*sid, RevisionEdit::Update(changed));
1026 }
1027 Ok(changes)
1028 })
1029}
1030
1031fn remap(object: &mut PropertyObject, rename: &BTreeMap<ExGuid, ExGuid>) -> Result<(), Error> {
1032 let mut remapped = Vec::new();
1033 for property in PropertySets::parse(&object.bytes)?.sets.iter().flatten() {
1034 if let Value::References {
1035 stream: crate::IdStream::Objects,
1036 compact_ids,
1037 } = property.value
1038 {
1039 for bytes in compact_ids.chunks_exact(4) {
1040 let offset = bytes.as_ptr().addr() - object.bytes.as_ptr().addr();
1041 let id = crate::bytes::Cursor { bytes, offset }.compact(&object.global_ids)?;
1042 if let Some(model) = rename.get(&id) {
1043 remapped.push((offset, *model));
1044 }
1045 }
1046 }
1047 }
1048 for (offset, id) in remapped {
1049 let reference = object.reference(id)?;
1050 object.bytes[offset..offset + 4].copy_from_slice(&reference);
1051 }
1052 Ok(())
1053}
crates/onestore/tests/page_writer.rs created+629
...@@ -0,0 +1,629 @@
1//! The page writer must publish the same graph the typed writers publish for the same edit,
2//! in one transaction, and must round-trip the model it was given.
3
4use onestore::{
5 ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex,
6 Store, TextAttribute, TreeEdit,
7 document::{Document, Element, Kind, Revision},
8 page::{Page, PageObject, ParagraphContent, text::new_id},
9};
10use std::collections::BTreeSet;
11
12const AUTHOR: &str = "Page author";
13const PARAGRAPHS: &[u8] =
14 include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one");
15const OUTLINES: &[u8] =
16 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
17const TREES: &[u8] =
18 include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one");
19
20fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) {
21 let store = Store::parse(bytes).unwrap();
22 let index = RevisionIndex::parse(&store).unwrap();
23 let document = Document::parse(&index).unwrap();
24 let mut found = None;
25 for (space, _) in document.pages().unwrap() {
26 let page = Page::from_space(&document, space).unwrap();
27 if page.title == title {
28 assert!(found.is_none(), "duplicate title {title}");
29 found = Some((space, page));
30 }
31 }
32 found.unwrap_or_else(|| panic!("no page titled {title}"))
33}
34
35fn page_in(bytes: &[u8], space: ExGuid) -> Page {
36 let store = Store::parse(bytes).unwrap();
37 let index = RevisionIndex::parse(&store).unwrap();
38 let document = Document::parse(&index).unwrap();
39 Page::from_space(&document, space).unwrap()
40}
41
42fn page_id(bytes: &[u8], space: ExGuid) -> ExGuid {
43 let store = Store::parse(bytes).unwrap();
44 let index = RevisionIndex::parse(&store).unwrap();
45 let document = Document::parse(&index).unwrap();
46 document.pages_in(space).unwrap()[0]
47}
48
49/// The body outlines in stacking order (not the title's outlines).
50fn body(page: &Page) -> Vec<&onestore::page::Outline> {
51 page.objects
52 .iter()
53 .filter_map(|object| match object {
54 PageObject::Outline(outline) => Some(outline),
55 _ => None,
56 })
57 .collect()
58}
59
60fn text_ids(outline: &onestore::page::Outline) -> Vec<(ExGuid, ExGuid, String)> {
61 outline
62 .paragraphs
63 .iter()
64 .filter_map(|p| p.text().map(|t| (p.id, t.id, t.text.text().to_owned())))
65 .collect()
66}
67
68fn author_name<'a>(revision: &'a Revision<'_>, id: Option<ExGuid>) -> Option<&'a str> {
69 match &revision.nodes.get(&id?)?.kind {
70 Kind::Author { name } => name.as_deref(),
71 _ => None,
72 }
73}
74
75fn close(a: Option<u32>, b: Option<u32>) -> bool {
76 match (a, b) {
77 (Some(a), Some(b)) => a.abs_diff(b) <= 5,
78 (None, None) => true,
79 _ => false,
80 }
81}
82
83fn same_element(revision_a: &Revision<'_>, revision_b: &Revision<'_>, id: ExGuid) {
84 let a: &Element = &revision_a.nodes[&id];
85 let b: &Element = &revision_b.nodes[&id];
86 assert_eq!(a.jcid, b.jcid, "{id}");
87 assert_eq!(a.children, b.children, "children of {id}");
88 assert_eq!(a.content, b.content, "content of {id}");
89 assert_eq!(a.structure, b.structure, "structure of {id}");
90 assert_eq!(a.spaces, b.spaces, "spaces of {id}");
91 assert_eq!(a.child_level, b.child_level, "child level of {id}");
92 assert_eq!(a.layout, b.layout, "layout of {id}");
93 assert_eq!(a.format, b.format, "format of {id}");
94 assert_eq!(a.tags, b.tags, "tags of {id}");
95 assert_eq!(a.media_ids, b.media_ids, "media of {id}");
96 assert!(
97 close(a.created, b.created),
98 "created {id}: {:?} {:?}",
99 a.created,
100 b.created
101 );
102 assert_eq!(
103 author_name(revision_a, a.original_author),
104 author_name(revision_b, b.original_author),
105 "original author of {id}"
106 );
107 // A reorder can be attributed to either sibling, so attribution may differ by this edit's author.
108 let latest_a = author_name(revision_a, a.latest_author);
109 let latest_b = author_name(revision_b, b.latest_author);
110 assert!(
111 latest_a == latest_b || latest_a == Some(AUTHOR) || latest_b == Some(AUTHOR),
112 "latest author of {id}: {latest_a:?} {latest_b:?}"
113 );
114 assert_eq!(a.extra, b.extra, "retained fields of {id}");
115 match (&a.kind, &b.kind) {
116 (
117 Kind::RichText {
118 text: ta,
119 boilerplate: ba,
120 paragraph_style: pa,
121 ..
122 },
123 Kind::RichText {
124 text: tb,
125 boilerplate: bb,
126 paragraph_style: pb,
127 ..
128 },
129 ) => {
130 assert_eq!(ta, tb, "text of {id}");
131 assert_eq!((ba, pa), (bb, pb), "text style of {id}");
132 let runs_a = revision_a.text_runs(id).unwrap();
133 let runs_b = revision_b.text_runs(id).unwrap();
134 assert_eq!(runs_a.len(), runs_b.len(), "run count of {id}");
135 for (ra, rb) in runs_a.iter().zip(&runs_b) {
136 assert_eq!(
137 (ra.text, &ra.format, ra.link),
138 (rb.text, &rb.format, rb.link),
139 "runs of {id}"
140 );
141 }
142 }
143 (ka, kb) => assert_eq!(ka, kb, "kind of {id}"),
144 }
145}
146
147/// Every active revision the typed writers produced is reproduced by the page writer,
148/// ignoring writer-allocated immutable styles and author records. Modification times are
149/// not compared: a reorder can be attributed to either sibling, and the model cannot say which.
150fn equivalent(reference: &[u8], written: &[u8]) {
151 let store_a = Store::parse(reference).unwrap();
152 let store_b = Store::parse(written).unwrap();
153 let index_a = RevisionIndex::parse(&store_a).unwrap();
154 let index_b = RevisionIndex::parse(&store_b).unwrap();
155 index_b.validate_current().unwrap();
156 let document_a = Document::parse(&index_a).unwrap();
157 let document_b = Document::parse(&index_b).unwrap();
158 assert_eq!(
159 document_a.spaces.keys().collect::<Vec<_>>(),
160 document_b.spaces.keys().collect::<Vec<_>>()
161 );
162 for sid in document_a.spaces.keys() {
163 let a = document_a.active(*sid).unwrap();
164 let b = document_b.active(*sid).unwrap();
165 assert_eq!(a.roots, b.roots, "roots of {sid}");
166 let roots: Vec<ExGuid> = a.roots.values().copied().collect();
167 let reachable = |revision: &Revision<'_>| -> BTreeSet<ExGuid> {
168 revision
169 .parents(&roots)
170 .unwrap()
171 .into_keys()
172 .chain(roots.iter().copied())
173 .filter(|id| !matches!(revision.nodes[id].jcid, 0x12004d | 0x120001))
174 .collect()
175 };
176 let ids_a = reachable(a);
177 let ids_b = reachable(b);
178 assert_eq!(ids_a, ids_b, "reachable objects of {sid}");
179 for id in ids_a {
180 same_element(a, b, id);
181 }
182 }
183}
184
185/// Runs the page writer on the model the typed writers produced and checks both contracts.
186fn oracle(source: &[u8], space: ExGuid, reference: &[u8]) -> Vec<u8> {
187 let after = page_in(reference, space);
188 let prepared = PreparedEdit::page(source, space, &after, AUTHOR).unwrap();
189 let written = prepared.as_bytes().to_vec();
190 assert_eq!(page_in(&written, space), after, "model round trip");
191 equivalent(reference, &written);
192 let transactions = |bytes: &[u8]| Store::parse(bytes).unwrap().header.transaction_count;
193 assert_eq!(
194 transactions(&written),
195 transactions(source) + 1,
196 "one transaction"
197 );
198 written
199}
200
201#[test]
202fn text_replacement_matches_replace_text() {
203 let (space, page) = page_by_title(PARAGRAPHS, "Split middle");
204 let (_, text, content) = text_ids(body(&page)[0])[0].clone();
205 let end = u32::try_from(content.encode_utf16().count()).unwrap();
206 for (range, replacement) in [
207 (0..0, "πŸ¦€ "),
208 (1..end.min(3), "é"),
209 (end..end, " end"),
210 (0..end, ""),
211 ] {
212 let reference =
213 onestore::replace_text(PARAGRAPHS, space, text, range, replacement).unwrap();
214 oracle(PARAGRAPHS, space, &reference);
215 }
216}
217
218#[test]
219fn formatting_matches_format_text() {
220 let (space, page) = page_by_title(PARAGRAPHS, "Split style boundary");
221 let (_, text, content) = text_ids(body(&page)[0])[0].clone();
222 let end = u32::try_from(content.encode_utf16().count()).unwrap();
223 let reference = PreparedEdit::format(
224 PARAGRAPHS,
225 space,
226 text,
227 1..end.min(4),
228 &[
229 TextAttribute::Bold(true),
230 TextAttribute::Color(Some([255, 0, 0])),
231 TextAttribute::FontSize(14.0),
232 ],
233 )
234 .unwrap();
235 let reference = PreparedEdit::format(
236 reference.as_bytes(),
237 space,
238 text,
239 0..1,
240 &[
241 TextAttribute::Italic(true),
242 TextAttribute::Font("Consolas".into()),
243 ],
244 )
245 .unwrap();
246 oracle(PARAGRAPHS, space, reference.as_bytes());
247}
248
249#[test]
250fn paragraph_insertions_match_insertion() {
251 let (space, page) = page_by_title(OUTLINES, "Move subtree down");
252 let outline = body(&page)[0];
253 let paragraphs = text_ids(outline);
254 let parent = outline
255 .paragraphs
256 .iter()
257 .find(|p| p.parent.is_none())
258 .unwrap()
259 .id;
260 for intent in [
261 Insertion::paragraph(outline.id, None, "Appended πŸ¦‹ é", AUTHOR).unwrap(),
262 Insertion::paragraph(outline.id, Some(paragraphs[0].0), "First", AUTHOR).unwrap(),
263 Insertion::paragraph(parent, None, "Nested child", AUTHOR).unwrap(),
264 Insertion::paragraph(outline.id, None, "", AUTHOR).unwrap(),
265 ] {
266 let reference = PreparedEdit::insert(OUTLINES, space, &intent).unwrap();
267 oracle(OUTLINES, space, reference.as_bytes());
268 }
269}
270
271#[test]
272fn cell_insertion_and_deletion_match() {
273 let (space, page) = page_by_title(TREES, "Delete cell subtree");
274 let outline = body(&page)[0];
275 let table = outline
276 .paragraphs
277 .iter()
278 .find_map(|p| match &p.content {
279 ParagraphContent::Table(table) => Some(table),
280 _ => None,
281 })
282 .unwrap();
283 let cell = &table.rows[0].cells[0];
284 let intent = Insertion::paragraph(cell.id, None, "Cell text", AUTHOR).unwrap();
285 let reference = PreparedEdit::insert(TREES, space, &intent).unwrap();
286 oracle(TREES, space, reference.as_bytes());
287 let victim = cell
288 .paragraphs
289 .iter()
290 .find(|p| p.parent.is_none())
291 .unwrap()
292 .id;
293 let reference =
294 PreparedEdit::tree(TREES, space, &TreeEdit::delete(victim, AUTHOR).unwrap()).unwrap();
295 oracle(TREES, space, reference.as_bytes());
296}
297
298#[test]
299fn outline_insertion_matches_insertion() {
300 let (space, _) = page_by_title(OUTLINES, "Move leaf down");
301 let page = page_id(OUTLINES, space);
302 let intent = Insertion::outline(page, 144.0, 200.0, "New outline 東京", AUTHOR).unwrap();
303 let reference = PreparedEdit::insert(OUTLINES, space, &intent).unwrap();
304 oracle(OUTLINES, space, reference.as_bytes());
305}
306
307#[test]
308fn splits_and_joins_match_their_writers() {
309 for title in ["Split middle", "Split start", "Split end", "Split parent"] {
310 let (space, page) = page_by_title(PARAGRAPHS, title);
311 let paragraphs = text_ids(body(&page)[0]);
312 let (_, text, content) = paragraphs[0].clone();
313 let units = u32::try_from(content.encode_utf16().count()).unwrap();
314 let offset = match title {
315 "Split start" => 0,
316 "Split end" => units,
317 _ => units / 2,
318 };
319 let offset = (0..=offset)
320 .rev()
321 .find(|at| {
322 content
323 .encode_utf16()
324 .nth(at.wrapping_sub(1) as usize)
325 .is_none_or(|u| !(0xd800..=0xdbff).contains(&u))
326 })
327 .unwrap();
328 let split = ParagraphSplit::new(text, offset, AUTHOR).unwrap();
329 let reference = PreparedEdit::split(PARAGRAPHS, space, &split).unwrap();
330 oracle(PARAGRAPHS, space, reference.as_bytes());
331 }
332 for title in ["Split middle", "Split empty"] {
333 let (space, page) = page_by_title(PARAGRAPHS, title);
334 let paragraphs = text_ids(body(&page)[0]);
335 let join = ParagraphJoin::new(paragraphs[0].1, paragraphs[1].1, AUTHOR).unwrap();
336 let reference = PreparedEdit::join(PARAGRAPHS, space, &join).unwrap();
337 oracle(PARAGRAPHS, space, reference.as_bytes());
338 }
339}
340
341#[test]
342fn subtree_moves_and_deletions_match_tree_edits() {
343 for (title, build) in [
344 ("Move leaf down", 0usize),
345 ("Move subtree down", 1),
346 ("Delete leaf", 2),
347 ("Delete subtree", 3),
348 ("Move outline", 4),
349 ("Delete outline", 5),
350 ] {
351 let (space, page) = page_by_title(OUTLINES, title);
352 let outlines = body(&page);
353 let outline = outlines[0];
354 let top: Vec<ExGuid> = outline
355 .paragraphs
356 .iter()
357 .filter(|p| p.parent.is_none())
358 .map(|p| p.id)
359 .collect();
360 let edit = match build {
361 0 | 1 => TreeEdit::move_to(top[0], outline.id, top.get(2).copied(), AUTHOR).unwrap(),
362 2 | 3 => TreeEdit::delete(top[0], AUTHOR).unwrap(),
363 4 => TreeEdit::move_to(outline.id, page_id(OUTLINES, space), None, AUTHOR).unwrap(),
364 _ => TreeEdit::delete(outline.id, AUTHOR).unwrap(),
365 };
366 let reference = PreparedEdit::tree(OUTLINES, space, &edit).unwrap();
367 oracle(OUTLINES, space, reference.as_bytes());
368 if outlines.len() > 1 && build == 0 {
369 let across = TreeEdit::move_to(top[1], outlines[1].id, None, AUTHOR).unwrap();
370 let reference = PreparedEdit::tree(OUTLINES, space, &across).unwrap();
371 oracle(OUTLINES, space, reference.as_bytes());
372 }
373 }
374}
375
376#[test]
377fn outline_layout_and_collapse_match_outline_edits() {
378 let (space, page) = page_by_title(OUTLINES, "Collapse subtree");
379 let outline = body(&page)[0];
380 let parent = outline
381 .paragraphs
382 .iter()
383 .find(|p| !outline.paragraphs.iter().all(|q| q.parent != Some(p.id)))
384 .unwrap()
385 .id;
386 for (object, edit) in [
387 (outline.id, OutlineEdit::Position { x: 90.0, y: 250.5 }),
388 (
389 outline.id,
390 OutlineEdit::Width {
391 points: 300.0,
392 user_set: true,
393 },
394 ),
395 (
396 outline.id,
397 OutlineEdit::Width {
398 points: 200.0,
399 user_set: false,
400 },
401 ),
402 (parent, OutlineEdit::Collapsed(true)),
403 ] {
404 let reference = PreparedEdit::outline(OUTLINES, space, object, edit).unwrap();
405 oracle(OUTLINES, space, reference.as_bytes());
406 }
407}
408
409#[test]
410fn composed_edits_publish_one_transaction() {
411 let (space, page) = page_by_title(OUTLINES, "Move subtree up");
412 let outline = body(&page)[0];
413 let paragraphs = text_ids(outline);
414 let mut reference =
415 onestore::replace_text(OUTLINES, space, paragraphs[0].1, 0..0, "Lead ").unwrap();
416 let insertion =
417 Insertion::paragraph(outline.id, Some(paragraphs[1].0), "Inserted", AUTHOR).unwrap();
418 reference = PreparedEdit::insert(&reference, space, &insertion)
419 .unwrap()
420 .as_bytes()
421 .to_vec();
422 reference = PreparedEdit::format(
423 &reference,
424 space,
425 insertion.text_object(),
426 0..3,
427 &[TextAttribute::Bold(true)],
428 )
429 .unwrap()
430 .as_bytes()
431 .to_vec();
432 let last = paragraphs.last().unwrap().0;
433 reference = PreparedEdit::tree(&reference, space, &TreeEdit::delete(last, AUTHOR).unwrap())
434 .unwrap()
435 .as_bytes()
436 .to_vec();
437 reference = PreparedEdit::outline(
438 &reference,
439 space,
440 outline.id,
441 OutlineEdit::Position { x: 60.0, y: 60.0 },
442 )
443 .unwrap()
444 .as_bytes()
445 .to_vec();
446 let written = oracle(OUTLINES, space, &reference);
447 assert_eq!(
448 Store::parse(&reference).unwrap().header.transaction_count,
449 Store::parse(OUTLINES).unwrap().header.transaction_count + 5
450 );
451 assert!(written.len() < reference.len());
452}
453
454#[test]
455fn model_edits_round_trip_and_leave_other_objects_untouched() {
456 let (space, mut after) = page_by_title(TREES, "Move numbered subtree down");
457 let before = page_in(TREES, space);
458 let page = page_id(TREES, space);
459 let mut new_paragraph = None;
460 for object in &mut after.objects {
461 let PageObject::Outline(outline) = object else {
462 continue;
463 };
464 if outline.title || new_paragraph.is_some() {
465 continue;
466 }
467 let first = outline
468 .paragraphs
469 .iter()
470 .position(|p| p.text().is_some())
471 .unwrap();
472 let text = outline.paragraphs[first].text_mut().unwrap();
473 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
474 text.text
475 .apply(onestore::page::text::Edit {
476 range: end..end,
477 replacement: onestore::page::Paragraph::new(
478 " appended πŸ¦€".into(),
479 text.text.format_at(end).unwrap().clone(),
480 ),
481 })
482 .unwrap();
483 let mut fresh = outline.paragraphs[first].clone();
484 fresh.id = new_id().unwrap();
485 fresh.parent = None;
486 fresh.level = 1;
487 fresh.lists.clear();
488 fresh.tags.clear();
489 fresh.style = None;
490 fresh.collapsed = false;
491 let fresh_text = fresh.text_mut().unwrap();
492 fresh_text.id = new_id().unwrap();
493 fresh_text.tags.clear();
494 fresh_text.text = onestore::page::Paragraph::new(
495 "Brand new".into(),
496 fresh_text.text.format_at(0).unwrap().clone(),
497 );
498 new_paragraph = Some(fresh.id);
499 outline.paragraphs.push(fresh);
500 outline.layout.x = Some(outline.layout.x.unwrap_or(0.0) + 18.0);
501 }
502 let mut outline = onestore::page::Outline {
503 id: new_id().unwrap(),
504 title: false,
505 min_width: None,
506 layout: onestore::document::Layout {
507 x: Some(300.0),
508 y: Some(400.0),
509 ..Default::default()
510 },
511 indents: Vec::new(),
512 paragraphs: Vec::new(),
513 unsupported: Vec::new(),
514 };
515 let template = body(&before)[0]
516 .paragraphs
517 .iter()
518 .find(|p| p.text().is_some())
519 .unwrap()
520 .clone();
521 let mut paragraph = template.clone();
522 paragraph.id = new_id().unwrap();
523 paragraph.parent = None;
524 paragraph.level = 1;
525 paragraph.lists.clear();
526 paragraph.tags.clear();
527 paragraph.style = None;
528 paragraph.collapsed = false;
529 let text = paragraph.text_mut().unwrap();
530 text.id = new_id().unwrap();
531 text.tags.clear();
532 text.text = onestore::page::Paragraph::new(
533 "Outline text".into(),
534 text.text.format_at(0).unwrap().clone(),
535 );
536 outline.paragraphs.push(paragraph);
537 after.objects.push(PageObject::Outline(outline));
538 let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap();
539 let stored = page_in(written.as_bytes(), space);
540 let normalize = |page: &Page| -> Vec<(ExGuid, Vec<(ExGuid, String)>)> {
541 body(page)
542 .iter()
543 .map(|o| {
544 (
545 o.id,
546 o.paragraphs
547 .iter()
548 .filter_map(|p| p.text().map(|t| (p.id, t.text.text().to_owned())))
549 .collect(),
550 )
551 })
552 .collect()
553 };
554 assert_eq!(normalize(&stored), normalize(&after));
555 assert_eq!(
556 body(&stored)
557 .iter()
558 .map(|o| (o.layout.x, o.layout.y))
559 .collect::<Vec<_>>(),
560 body(&after)
561 .iter()
562 .map(|o| (o.layout.x, o.layout.y))
563 .collect::<Vec<_>>()
564 );
565 let store = Store::parse(TREES).unwrap();
566 let index = RevisionIndex::parse(&store).unwrap();
567 let raw_before = index.resolve_active(space).unwrap();
568 let store = Store::parse(written.as_bytes()).unwrap();
569 let index = RevisionIndex::parse(&store).unwrap();
570 let raw_after = index.resolve_active(space).unwrap();
571 let touched: BTreeSet<ExGuid> = body(&after)
572 .iter()
573 .flat_map(|o| {
574 std::iter::once(o.id).chain(
575 o.paragraphs
576 .iter()
577 .flat_map(|p| [p.id, p.text().map(|t| t.id).unwrap_or(p.id)]),
578 )
579 })
580 .chain([page])
581 .collect();
582 let mut untouched = 0;
583 for (id, object) in &raw_before.objects {
584 if touched.contains(id) || matches!(object.jcid, 0x12004d | 0x120001) {
585 continue;
586 }
587 let after = &raw_after.objects[id];
588 assert_eq!((object.jcid, object.data), (after.jcid, after.data), "{id}");
589 untouched += 1;
590 }
591 assert!(untouched > 5, "{untouched}");
592}
593
594#[test]
595fn unsupported_model_edits_are_rejected_before_writing() {
596 let (space, page) = page_by_title(TREES, "Delete cell subtree");
597 let mut widened = page_in(TREES, space);
598 for object in &mut widened.objects {
599 if let PageObject::Outline(outline) = object {
600 for paragraph in &mut outline.paragraphs {
601 if let ParagraphContent::Table(table) = &mut paragraph.content {
602 table.columns[0].width += 10.0;
603 }
604 }
605 }
606 }
607 assert!(PreparedEdit::page(TREES, space, &widened, AUTHOR).is_err());
608 let mut relisted = page_in(TREES, space);
609 let outline = body(&page)[0];
610 for object in &mut relisted.objects {
611 if let PageObject::Outline(o) = object
612 && o.id == outline.id
613 {
614 o.paragraphs[0].lists.push(new_id().unwrap());
615 }
616 }
617 assert!(PreparedEdit::page(TREES, space, &relisted, AUTHOR).is_err());
618 let mut renamed = page_in(TREES, space);
619 renamed.created = Some(1);
620 assert!(PreparedEdit::page(TREES, space, &renamed, AUTHOR).is_err());
621 assert!(PreparedEdit::page(TREES, space, &page, "a\0b").is_err());
622}
623
624#[test]
625fn an_unchanged_model_publishes_nothing() {
626 let (space, page) = page_by_title(OUTLINES, "Resize outline");
627 let prepared = PreparedEdit::page(OUTLINES, space, &page, AUTHOR).unwrap();
628 assert_eq!(prepared.as_bytes(), OUTLINES);
629}