diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 0b014e81d2af402f96bf9b02bb1adac08b9a07c9..ef93bef9d338e686ca7db07adc29dfa8ad815902 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -43,6 +43,8 @@ harness also accepts `--client-profile release`. | `PropertySets`, `Object::references` | Decode properties and ID streams while retaining raw values | | `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes | | `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities | +| `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups | +| `page::Page`, `page::Paragraph` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images) with stored identities; content outside the model is retained as `Unsupported` | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | | `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries | diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index c5f41bec49f49f0557d494e02073b84e0c80c92f..9d5e474b19a9bc60522d1e049ab473445d506135 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -188,7 +188,7 @@ impl<'a> PreparedEdit<'a> { pub fn pages(source: &'a [u8], edits: &[crate::PageEdit]) -> Result { Ok(Self { source, - written: crate::page::edit_pages(source, edits, &[])?, + written: crate::pages::edit_pages(source, edits, &[])?, }) } @@ -201,7 +201,7 @@ impl<'a> PreparedEdit<'a> { ) -> Result { Ok(Self { source, - written: crate::page::edit_pages(source, &[], pages)?, + written: crate::pages::edit_pages(source, &[], pages)?, }) } diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index 5cf64bcba45d63834e44ce062111cbdea98775aa..9a4deb50081cd1c1bf9f4fe581e78727ca5e1ac8 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -12,7 +12,8 @@ mod formatting; mod insertion; mod objects; mod outline; -mod page; +pub mod page; +mod pages; mod paragraph; mod properties; #[cfg(feature = "protected")] @@ -36,7 +37,7 @@ pub use formatting::TextAttribute; pub use insertion::Insertion; pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; pub use outline::OutlineEdit; -pub use page::{PageCreation, PageEdit, PagePosition}; +pub use pages::{PageCreation, PageEdit, PagePosition}; pub use paragraph::{ParagraphJoin, ParagraphSplit}; pub use properties::{IdStream, Property, PropertySets, Value}; pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; diff --git a/crates/onestore/src/page.rs b/crates/onestore/src/page.rs deleted file mode 100644 index 0337065751d4c363d2af759084a129be8322aec2..0000000000000000000000000000000000000000 --- a/crates/onestore/src/page.rs +++ /dev/null @@ -1,883 +0,0 @@ -use crate::{ - Error, ExGuid, - create::{current_timestamps, properties, string}, - document::{Document, FieldValue, Kind}, - write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions}, -}; -use serde::{Deserialize, Serialize}; -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, -}; - -fn invalid(message: &'static str) -> Error { - Error { offset: 0, message } -} - -fn metadata_id(space: ExGuid) -> ExGuid { - let mut id = ExGuid { - guid: space.guid, - n: 1, - }; - for (byte, salt) in id.guid.iter_mut().zip([ - 0x31, 0xc0, 0xa8, 0x22, 0, 0x36, 0xee, 0x42, 0xb7, 0x14, 0xd7, 0xac, 0xda, 0x24, 0x35, 0xe8, - ]) { - *byte ^= salt; - } - id -} - -fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[u8; 16], Error> { - metadata.extra[0] - .iter() - .find_map(|field| match field.value { - FieldValue::Bytes(bytes) if field.id == 0x1c001c30 => bytes.try_into().ok(), - _ => None, - }) - .ok_or_else(|| invalid("Page metadata needs its page identifier")) -} - -fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> { - let mut references = Vec::new(); - for id in ids { - references.extend_from_slice(&object.reference(*id)?); - } - let source = PropertyObject { - jcid: object.jcid, - bytes: properties(&[(property, references)])?, - global_ids: Arc::clone(&object.global_ids), - }; - object.copy_property(&source, property) -} - -/// Whether an edit preserves position or moves before a page (None appends). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub enum PagePosition { - Keep, - Before(Option), -} - -/// A page's explicit position and indentation, with a stable identity for a new series. -/// Moving a page does not implicitly select its following subpages. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PageEdit { - guid: [u8; 16], - space: ExGuid, - level: u32, - position: PagePosition, -} - -impl PageEdit { - /// Changes one page's indentation without changing its position. - pub fn set_level(space: ExGuid, level: u32) -> Result { - Self::new(space, level, PagePosition::Keep) - } - - /// Moves before an existing page space, or appends when `before` is None. - pub fn move_to(space: ExGuid, before: Option, level: u32) -> Result { - Self::new(space, level, PagePosition::Before(before)) - } - - pub fn space(&self) -> ExGuid { - self.space - } - - pub fn level(&self) -> u32 { - self.level - } - - pub fn position(&self) -> PagePosition { - self.position - } - - /// Revises placement while retaining the selected page and allocated series identity. - pub fn reposition(&self, position: PagePosition, level: u32) -> Result { - let edit = Self { - position, - level, - ..self.clone() - }; - edit.validate()?; - Ok(edit) - } - - fn new(space: ExGuid, level: u32, position: PagePosition) -> Result { - let edit = Self { - guid: fresh_guid()?, - space, - level, - position, - }; - edit.validate()?; - Ok(edit) - } - - fn validate(&self) -> Result<(), Error> { - if self.guid == [0; 16] || self.space.guid == [0; 16] || !(1..=3).contains(&self.level) { - return Err(invalid( - "Choose an existing page space and indentation level 1 through 3", - )); - } - if let PagePosition::Before(Some(before)) = self.position - && (before.guid == [0; 16] || before == self.space) - { - return Err(invalid("Choose a different page as the movement anchor")); - } - Ok(()) - } -} - -pub(crate) fn edit_pages( - source: &[u8], - edits: &[PageEdit], - removals: &[ExGuid], -) -> Result, Error> { - if !edits.is_empty() && !removals.is_empty() { - return Err(invalid( - "Choose page placement or page removal for one operation", - )); - } - let removed: BTreeSet<_> = removals.iter().copied().collect(); - if removed.len() != removals.len() || removed.iter().any(|sid| sid.guid == [0; 16]) { - return Err(invalid("Choose distinct existing page spaces for removal")); - } - let mut selected = BTreeMap::new(); - let mut guids = BTreeSet::new(); - for edit in edits { - edit.validate()?; - if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) { - return Err(invalid( - "Use distinct page and edit identities within a batch", - )); - } - } - write_revisions(source, |index| { - let document = Document::parse(index)?; - let pages = document.pages()?; - let section = &document.spaces[&document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; - let section_id = view.roots[&1]; - let section_node = &view.nodes[§ion_id]; - if !matches!(section_node.kind, Kind::Section { .. }) - || section_node.extra[0] - .iter() - .any(|field| field.id == 0x88001cde) - { - return Err(invalid("Choose an editable section for page editing")); - } - let mut order = Vec::new(); - let mut levels = BTreeMap::new(); - let mut page_guids = BTreeMap::new(); - for (sid, _) in &pages { - let page = &document.spaces[sid]; - let page = &page.revisions[&page.contexts[&ExGuid::default()]]; - let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else { - return Err(invalid("Page metadata is unavailable")); - }; - let Kind::Metadata { level, .. } = metadata.kind else { - return Err(invalid("Choose ordinary pages with page metadata")); - }; - let level = level.unwrap_or(1); - if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() { - return Err(invalid( - "Each page space must contain one page with a valid level", - )); - } - if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() { - return Err(invalid("Pages must have distinct metadata identifiers")); - } - order.push(*sid); - } - let original_order = order.clone(); - let original_levels = levels.clone(); - let mut series_by_head = BTreeMap::new(); - let mut copies = BTreeMap::new(); - let mut copy_ids = BTreeSet::new(); - for oid in §ion_node.children { - let series = &view.nodes[oid]; - let Some(head) = series.spaces.first() else { - return Err(invalid("A page series must contain at least one page")); - }; - if levels.get(head) != Some(&1) - || series.spaces[1..] - .iter() - .any(|sid| levels.get(sid).is_none_or(|level| *level == 1)) - { - return Err(invalid( - "A page series must start with its only top-level page", - )); - } - if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) { - series_by_head.insert(*retained, *oid); - } - if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { - let FieldValue::Objects(ids) = &field.value else { - return Err(invalid("Page metadata copies must be object references")); - }; - if ids.len() != series.spaces.len() { - return Err(invalid("Page metadata copies must match their series")); - } - for id in ids { - if !copy_ids.insert(*id) - || !matches!( - view.nodes.get(id).map(|n| &n.kind), - Some(Kind::Metadata { .. }) - ) - { - return Err(invalid("Each page needs its own ordinary metadata copy")); - } - let sid = page_guids - .get(&metadata_guid(&view.nodes[id])?) - .filter(|sid| series.spaces.contains(sid)) - .ok_or_else(|| { - invalid("Metadata copy must identify a page in its series") - })?; - if copies.insert(*sid, *id).is_some() { - return Err(invalid("Each page needs its own ordinary metadata copy")); - } - } - } - } - for sid in &removed { - if !levels.contains_key(sid) { - return Err(invalid("The selected page is no longer in the section")); - } - } - order.retain(|sid| !removed.contains(sid)); - if !removed.is_empty() - && let Some(first) = order.first() - { - levels.insert(*first, 1); - } - for edit in edits { - let Some(at) = order.iter().position(|sid| *sid == edit.space) else { - return Err(invalid("The selected page is no longer in the section")); - }; - levels.insert(edit.space, edit.level); - if let PagePosition::Before(before) = edit.position { - order.remove(at); - let position = match before { - None => order.len(), - Some(before) => { - order.iter().position(|sid| *sid == before).ok_or_else(|| { - invalid("The movement anchor is no longer in the section") - })? - } - }; - order.insert(position, edit.space); - } - } - if order.first().is_some_and(|sid| levels[sid] != 1) { - return Err(invalid( - "The section's first page must have indentation level 1", - )); - } - if order == original_order && levels == original_levels { - return Ok(BTreeMap::new()); - } - let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?; - let mut groups: Vec> = Vec::new(); - for sid in order { - if levels[&sid] == 1 { - groups.push(Vec::new()); - } - groups.last_mut().unwrap().push(sid); - } - let mut changes = BTreeMap::new(); - for sid in &removed { - let page = index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; - let manifest_id = page.roots[&1]; - let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?; - if manifest.jcid != 0x60037 { - return Err(invalid("Choose ordinary pages for removal")); - } - manifest.bytes = properties(&[])?; - let metadata_id = page.roots[&2]; - let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?; - metadata.set(&[(0x88001de9, &[])])?; - changes.insert( - *sid, - RevisionEdit::Update(BTreeMap::from([ - (manifest_id, manifest), - (metadata_id, metadata), - ])), - ); - } - let mut replacements = BTreeMap::new(); - let mut children = Vec::new(); - for spaces in groups { - let head = spaces[0]; - let old_id = series_by_head.get(&head).copied(); - let (id, mut series) = if let Some(id) = old_id { - (id, PropertyObject::from_object(&raw.objects[&id])?) - } else { - let edit = selected - .get(&head) - .ok_or_else(|| invalid("A new series must start at an edited page"))?; - let id = ExGuid { - guid: edit.guid, - n: 1, - }; - if raw.objects.contains_key(&id) { - return Err(invalid("The new page-series identity already exists")); - } - ( - id, - PropertyObject { - jcid: 0x60008, - bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?, - global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])), - }, - ) - }; - children.push(id); - let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces); - if !membership_changed - && !spaces.iter().any(|sid| { - levels[sid] != original_levels[sid] - || copies.get(sid).is_some_and(|id| { - matches!(view.nodes[id].kind, Kind::Metadata { level, .. } - if level.unwrap_or(1) != levels[sid]) - }) - }) - { - continue; - } - let mut metadata_ids = Vec::new(); - for sid in &spaces { - let page = - index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; - let page_id = page.roots[&2]; - let original = PropertyObject::from_object(&page.objects[&page_id])?; - if old_id.is_none() && *sid == head { - series.copy_property(&original, 0x18001c65)?; - } - if levels[sid] != original_levels[sid] { - let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?; - metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; - changes.insert( - *sid, - RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])), - ); - } - let copy_id = copies - .get(sid) - .copied() - .unwrap_or_else(|| metadata_id(*sid)); - let mut copy = match raw.objects.get(©_id) { - Some(object) => { - if object.jcid != 0x20030 - || (!copies.contains_key(sid) && copy_ids.contains(©_id)) - { - return Err(invalid("Page metadata identities overlap")); - } - PropertyObject::from_object(object)? - } - None => original, - }; - copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; - copy.reference(copy_id)?; - if replacements.insert(copy_id, copy).is_some() { - return Err(invalid("Page metadata identities overlap")); - } - metadata_ids.push(copy_id); - } - if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { - set_references(&mut series, 0x2c001d63, &spaces)?; - set_references(&mut series, 0x24003442, &metadata_ids)?; - if replacements.insert(id, series).is_some() { - return Err(invalid("Page-series and metadata identities overlap")); - } - } - } - if children != section_node.children { - let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?; - set_references(&mut parent, 0x24001c20, &children)?; - replacements.insert(section_id, parent); - } - changes.insert(document.root, RevisionEdit::Update(replacements)); - Ok(changes) - }) -} - -/// An empty top-level page with stable identities and creation time. -/// Retain the intent across retries; an existing page identity rejects duplicate creation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PageCreation { - guid: [u8; 16], - series_guid: [u8; 16], - before: Option, - title: Option, - author: String, - created: u32, -} - -impl PageCreation { - /// Appends a page, or inserts before the first page space of an existing series. - /// `Some("")` creates an empty title field; `None` creates a page without a title node. - /// The page has no body outlines or generated date/time text and does not apply a template. - pub fn new(before: Option, title: Option<&str>, author: &str) -> Result { - let page = Self { - guid: fresh_guid()?, - series_guid: fresh_guid()?, - before, - title: title.map(str::to_owned), - author: author.to_owned(), - created: current_timestamps()?.0, - }; - page.validate()?; - Ok(page) - } - - pub fn space(&self) -> ExGuid { - ExGuid { - guid: self.guid, - n: 1, - } - } - - pub fn object(&self) -> ExGuid { - ExGuid { - guid: self.guid, - n: 12, - } - } - - pub fn title_object(&self) -> Option { - self.title.as_ref().map(|_| ExGuid { - guid: self.guid, - n: 16, - }) - } - - /// Changes the insertion anchor while retaining all identities and creation metadata. - pub fn reposition(&self, before: Option) -> Result { - let mut page = self.clone(); - page.before = before; - page.validate()?; - Ok(page) - } - - fn validate(&self) -> Result<(), Error> { - if self.guid == [0; 16] - || self.series_guid == [0; 16] - || self.guid == self.series_guid - || self.author.contains('\0') - || self.before.is_some_and(|id| id.guid == [0; 16]) - || self - .title - .as_ref() - .is_some_and(|title| title.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) - { - return Err(invalid( - "Use a new page identity, an existing page anchor, and ordinary single-line title text", - )); - } - Ok(()) - } - - pub(crate) fn apply(&self, source: &[u8]) -> Result, Error> { - self.validate()?; - write_revisions(source, |index| { - if index.spaces.contains_key(&self.space()) { - return Err(invalid( - "This page identity already exists; reconcile the original creation", - )); - } - let document = Document::parse(index)?; - document.pages()?; - let section = &document.spaces[&document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; - let section_id = view.roots[&1]; - let section_node = &view.nodes[§ion_id]; - if !matches!(section_node.kind, Kind::Section { .. }) - || section_node.extra[0] - .iter() - .any(|field| field.id == 0x88001cde) - { - return Err(invalid("Choose an editable section for the new page")); - } - let position = match self.before { - None => section_node.children.len(), - Some(before) => section_node - .children - .iter() - .position(|id| view.nodes[id].spaces.first() == Some(&before)) - .ok_or_else(|| { - invalid("Insert before the first page of an existing series, or append") - })?, - }; - let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?; - let id = |n| ExGuid { guid: self.guid, n }; - let reference = |n: u32| n.to_le_bytes().to_vec(); - let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000) - .to_le_bytes() - .to_vec(); - let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec()); - let mut table = BTreeMap::from([(0, self.guid)]); - let metadata_id = metadata_id(self.space()); - table.insert(1, metadata_id.guid); - let table = Arc::new(table); - let object = |jcid, values: Vec<_>| -> Result<_, Error> { - Ok(PropertyObject { - jcid, - bytes: properties(&values)?, - global_ids: Arc::clone(&table), - }) - }; - let title = self.title.as_deref().unwrap_or_default().trim_start(); - let metadata = vec![ - (0x1c001c30, self.guid.to_vec()), - (0x1c001cf3, string(title)), - (0x14001d82, reference(40)), - (0x1400348b, reference(40)), - (0x14001dff, reference(1)), - (0x18001c65, timestamp.clone()), - ]; - let mut page = vec![ - modified(), - (0x1c001d75, string(&self.author)), - (0x1c001d3c, string("")), - ]; - if self.title.is_some() { - page.push((0x24001d5f, reference(13))); - } - let mut objects = BTreeMap::from([ - (id(10), object(0x60037, vec![(0x24001c1f, reference(12))])?), - (id(11), object(0x20030, metadata.clone())?), - (id(12), object(0x6000b, page)?), - ]); - if let Some(title) = &self.title { - for (n, jcid, values) in [ - ( - 13, - 0x6002c, - vec![ - modified(), - (0x24001c20, reference(14)), - (0x14001c14, 0_f32.to_le_bytes().to_vec()), - (0x14001c15, 0_f32.to_le_bytes().to_vec()), - ], - ), - ( - 14, - 0x6000c, - vec![ - modified(), - (0x24001c20, reference(15)), - (0x0c001c03, vec![1]), - ], - ), - ( - 15, - 0x6000d, - vec![ - modified(), - (0x24001c1f, reference(16)), - (0x0c001c03, vec![1]), - (0x20001d78, reference(17)), - (0x20001d79, reference(17)), - (0x14001d09, self.created.to_le_bytes().to_vec()), - (0x88001cb4, vec![]), - ], - ), - ( - 16, - 0x6000e, - vec![ - modified(), - (0x1c001c22, string(title)), - (0x24001e13, reference(19)), - (0x2000342c, reference(18)), - (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()), - (0x88001cb4, vec![]), - ], - ), - (17, 0x120001, vec![(0x1c001d75, string(&self.author))]), - ( - 18, - 0x12004d, - vec![ - (0x1c00345a, string("PageTitle")), - (0x1c001c0a, string("Calibri")), - (0x10001c0b, 34_u16.to_le_bytes().to_vec()), - ], - ), - (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]), - ] { - objects.insert(id(n), object(jcid, values)?); - } - } - let series = object( - 0x60008, - vec![ - (0x1c001c30, self.series_guid.to_vec()), - (0x18001c65, timestamp), - (0x2c001d63, reference(1)), - (0x24003442, reference(257)), - ], - )?; - if raw.objects.contains_key(&id(2)) || raw.objects.contains_key(&metadata_id) { - return Err(invalid("The new page's section identities already exist")); - } - let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?; - let mut children = section_node.children.clone(); - children.insert(position, id(2)); - let mut references = Vec::new(); - for child in children { - references.extend_from_slice(&parent.reference(child)?); - } - parent.set(&[(0x24001c20, &references)])?; - Ok(BTreeMap::from([ - ( - document.root, - RevisionEdit::Update(BTreeMap::from([ - (section_id, parent), - (id(2), series), - (metadata_id, object(0x20030, metadata)?), - ])), - ), - ( - self.space(), - RevisionEdit::Create { - roots: BTreeMap::from([(1, id(10)), (2, id(11))]), - objects, - }, - ), - ])) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{RevisionIndex, Store}; - - #[test] - fn page_movement_restores_an_optional_metadata_cache_from_history() { - let source = - include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let sid = document.pages().unwrap()[3].0; - let section = &document.spaces[&document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; - let series_id = view.nodes[&view.roots[&1]].children[3]; - let copy_id = metadata_id(sid); - for unknown in [false, true] { - let source = if unknown { - write_revisions(source, |index| { - let raw = index.resolve( - index.root, - index.spaces[&index.root].labels[&(ExGuid::default(), 1)], - )?; - let mut copy = PropertyObject::from_object(&raw.objects[©_id])?; - copy.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?; - Ok(BTreeMap::from([( - index.root, - RevisionEdit::Update(BTreeMap::from([(copy_id, copy)])), - )])) - }) - .unwrap() - } else { - source.to_vec() - }; - let source = write_revisions(&source, |index| { - let raw = index.resolve( - index.root, - index.spaces[&index.root].labels[&(ExGuid::default(), 1)], - )?; - let mut series = PropertyObject::from_object(&raw.objects[&series_id])?; - series.remove(&[0x24003442])?; - Ok(BTreeMap::from([( - index.root, - RevisionEdit::Update(BTreeMap::from([(series_id, series)])), - )])) - }) - .unwrap(); - for changed in [false, true] { - for unrelated_edit in [false, true] { - let result = write_revisions(&source, |index| { - let raw = index.resolve( - index.root, - index.spaces[&index.root].labels[&(ExGuid::default(), 1)], - )?; - assert!(!raw.reachable()?.contains(©_id)); - let mut copy = PropertyObject::from_object(&raw.objects[©_id])?; - if changed { - copy.set(&[(0x14001dff, &2_u32.to_le_bytes())])?; - } - let mut objects = BTreeMap::from([(copy_id, copy)]); - if unrelated_edit { - let mut series = PropertyObject::from_object(&raw.objects[&series_id])?; - series.set(&[(0x1400abcd, &88_u32.to_le_bytes())])?; - objects.insert(series_id, series); - } - Ok(BTreeMap::from([( - index.root, - RevisionEdit::Update(objects), - )])) - }); - assert_eq!( - result.unwrap_err().message, - "Edited object is not reachable in the resulting revision" - ); - } - } - let written = - edit_pages(&source, &[PageEdit::set_level(sid, 2).unwrap()], &[]).unwrap(); - let store = Store::parse(&written).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - index.validate_current().unwrap(); - assert_eq!(Document::parse(&index).unwrap().pages().unwrap().len(), 9); - let current = index - .resolve( - index.root, - index.spaces[&index.root].labels[&(ExGuid::default(), 1)], - ) - .unwrap(); - assert!(current.reachable().unwrap().contains(©_id)); - let copy = PropertyObject::from_object(¤t.objects[©_id]).unwrap(); - let properties = crate::PropertySets::parse(©.bytes).unwrap(); - assert_eq!(unknown, properties.sets[0].iter().any(|field| field.id == 0x1400abcd - && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes()))); - let old_store = Store::parse(&source).unwrap(); - let old_index = RevisionIndex::parse(&old_store).unwrap(); - for (space, revisions) in &old_index.spaces { - for revision in revisions.revisions.keys() { - assert_eq!( - format!("{:?}", old_index.resolve(*space, *revision).unwrap()), - format!("{:?}", index.resolve(*space, *revision).unwrap()) - ); - } - } - if !unknown && let Some(output) = std::env::var_os("ONESTORE_PAGE_MOVEMENT_OUTPUT") { - let output = std::path::Path::new(&output); - assert!(output.is_absolute()); - for (name, bytes) in [ - ("optional-cache-source", source), - ("optional-cache", written), - ] { - let path = output.join(name); - std::fs::create_dir_all(&path).unwrap(); - std::fs::write(path.join("Lifecycle.one"), bytes).unwrap(); - } - } - } - } - - #[test] - fn page_movement_rejects_empty_manifests_and_series() { - let source = - include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let sid = document.pages().unwrap()[3].0; - for empty_series in [false, true] { - let invalid_source = write_revisions(source, |index| { - let target = if empty_series { index.root } else { sid }; - let raw = index.resolve( - target, - index.spaces[&target].labels[&(ExGuid::default(), 1)], - )?; - let id = if empty_series { - let space = &document.spaces[&document.root]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; - view.nodes[&view.roots[&1]].children[3] - } else { - raw.roots[&1] - }; - let mut object = PropertyObject::from_object(&raw.objects[&id])?; - set_references( - &mut object, - if empty_series { 0x2c001d63 } else { 0x24001c1f }, - &[], - )?; - Ok(BTreeMap::from([( - target, - RevisionEdit::Update(BTreeMap::from([(id, object)])), - )])) - }) - .unwrap(); - assert!( - edit_pages( - &invalid_source, - &[PageEdit::set_level(sid, 2).unwrap()], - &[] - ) - .is_err() - ); - } - } - - #[test] - fn page_moves_retain_unknown_series_and_metadata_properties() { - let source = - include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); - let store = Store::parse(source).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - let pages = document.pages().unwrap(); - let section = &document.spaces[&document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; - let series_id = view.nodes[&view.roots[&1]].children[3]; - let field = view.nodes[&series_id].extra[0] - .iter() - .find(|f| f.id == 0x24003442) - .unwrap(); - let FieldValue::Objects(copies) = &field.value else { - panic!() - }; - let copy_id = copies[0]; - let source = write_revisions(source, |index| { - let raw = index.resolve( - index.root, - index.spaces[&index.root].labels[&(ExGuid::default(), 1)], - )?; - let mut objects = BTreeMap::new(); - for id in [series_id, copy_id] { - let mut object = PropertyObject::from_object(&raw.objects[&id])?; - object.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?; - objects.insert(id, object); - } - Ok(BTreeMap::from([( - index.root, - RevisionEdit::Update(objects), - )])) - }) - .unwrap(); - for edits in [ - vec![PageEdit::set_level(pages[3].0, 2).unwrap()], - pages[3..6] - .iter() - .enumerate() - .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap()) - .collect(), - ] { - let written = edit_pages(&source, &edits, &[]).unwrap(); - let old_store = Store::parse(&source).unwrap(); - let new_store = Store::parse(&written).unwrap(); - let old = RevisionIndex::parse(&old_store).unwrap(); - let new = RevisionIndex::parse(&new_store).unwrap(); - let old_id = old.spaces[&old.root].labels[&(ExGuid::default(), 1)]; - let new_id = new.spaces[&new.root].labels[&(ExGuid::default(), 1)]; - assert_eq!( - format!("{:?}", old.resolve(old.root, old_id).unwrap()), - format!("{:?}", new.resolve(new.root, old_id).unwrap()) - ); - let current = new.resolve(new.root, new_id).unwrap(); - for id in [series_id, copy_id] { - let object = PropertyObject::from_object(¤t.objects[&id]).unwrap(); - let properties = crate::PropertySets::parse(&object.bytes).unwrap(); - assert!(properties.sets[0].iter().any(|field| field.id == 0x1400abcd - && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes()))); - } - } - } -} diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..b2987c9bc23355b9258b02613a9979a1a55e857f --- /dev/null +++ b/crates/onestore/src/page/mod.rs @@ -0,0 +1,1276 @@ +//! An editable page model: outlines, paragraphs, tables and images with their stored +//! identities, plus everything else on the page retained as `Unsupported`. + +use crate::{ + Error, ExGuid, + document::{Document, Element, FieldValue, Format, Kind, Layout, Revision, Tag}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +pub mod text; +pub use text::Paragraph; + +/// The role of a title-outline paragraph that displays the page's creation date or time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DateField { + Date = 0, + Time = 1, +} + +pub struct Page { + pub title: String, + /// FILETIME ticks from the page's TopologyCreationTimeStamp. + pub created: Option, + pub margin_origin: [f32; 2], + pub objects: Vec, + pub definitions: BTreeMap, +} + +pub struct Definition { + pub kind: Kind<'static>, + pub format: Format, +} + +pub enum PageObject { + Outline(Outline), + Title(Title), + Image(Image), + Unsupported(Unsupported), +} + +impl PageObject { + pub fn id(&self) -> ExGuid { + match self { + Self::Outline(value) => value.id, + Self::Title(value) => value.id, + Self::Image(value) => value.id, + Self::Unsupported(value) => value.id, + } + } + pub fn layout(&self) -> &Layout { + match self { + Self::Outline(value) => &value.layout, + Self::Title(value) => &value.layout, + Self::Image(value) => &value.layout, + Self::Unsupported(value) => &value.layout, + } + } + + pub fn layout_mut(&mut self) -> &mut Layout { + match self { + Self::Outline(value) => &mut value.layout, + Self::Title(value) => &mut value.layout, + Self::Image(value) => &mut value.layout, + Self::Unsupported(value) => &mut value.layout, + } + } +} + +pub struct Title { + pub id: ExGuid, + pub date: Option, + pub layout: Layout, + pub outlines: Vec, +} + +#[derive(Clone)] +pub struct Outline { + pub id: ExGuid, + pub title: bool, + pub min_width: Option, + pub layout: Layout, + pub indents: Vec, + pub paragraphs: Vec, + pub unsupported: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PageParagraph { + pub id: ExGuid, + pub parent: Option, + pub level: u32, + pub style: Option, + pub format: Format, + pub content: ParagraphContent, + pub lists: Vec, + pub tags: Vec, + pub collapsed: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ParagraphContent { + Text(TextObject), + Table(Table), + Unsupported(Unsupported), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub id: ExGuid, + pub columns: Vec, + pub rows: Vec, + pub borders: Option, + pub layout: Layout, + pub tags: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TableColumn { + pub width: f32, + pub locked: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TableRow { + pub id: ExGuid, + pub cells: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TableCell { + pub id: ExGuid, + pub layout: Layout, + pub indents: Vec, + pub shading: Option, + pub paragraphs: Vec, + pub unsupported: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextObject { + pub id: ExGuid, + pub date_field: Option, + pub text: Paragraph, + pub tags: Vec, +} + +pub struct Image { + pub id: ExGuid, + pub layout: Layout, + pub bytes: Option>, + pub alt: Option, + pub background: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Unsupported { + pub id: ExGuid, + pub jcid: u32, + pub layout: Layout, +} + +impl Page { + pub fn from_document(document: &Document<'_>, title: &str) -> Result { + let mut selected = None; + for (space, id) in document.pages()? { + let revision = document.active(space)?; + if page_title(revision, id) == Some(title) { + if selected.is_some() { + return Err(Error { + offset: 0, + message: "More than one active page has the requested title", + }); + } + selected = Some((revision, id)); + } + } + let (revision, id) = selected.ok_or(Error { + offset: 0, + message: "No active page has the requested title", + })?; + Self::from_revision(revision, id) + } + + /// The single active page declared in one page object space. + pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result { + let pages = document.pages_in(space)?; + let [id] = pages.as_slice() else { + return Err(Error { + offset: 0, + message: "Choose an object space containing one active page", + }); + }; + Self::from_revision(document.active(space)?, *id) + } + + pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result { + let invalid = |message| Error { offset: 0, message }; + let root = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas page"))?; + let Kind::Page { + margin_origin_x, + margin_origin_y, + .. + } = &root.kind + else { + return Err(invalid("Canvas root is not a page")); + }; + let mut page = Self { + title: page_title(revision, id).unwrap_or_default().to_owned(), + created: revision + .roots + .get(&2) + .and_then(|id| revision.nodes.get(id)) + .filter(|node| matches!(node.kind, Kind::Metadata { .. })) + .and_then(|node| node.extra.first()) + .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65)) + .map(|field| { + let FieldValue::Bytes(bytes) = field.value else { + return Err(invalid("The page creation date is damaged")); + }; + Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| { + invalid("The page creation date is damaged") + })?)) + }) + .transpose()?, + margin_origin: [ + margin_origin_x.unwrap_or(0.0), + margin_origin_y.unwrap_or(0.0), + ], + objects: Vec::new(), + definitions: BTreeMap::new(), + }; + let mut roots: Vec<_> = root + .children + .iter() + .chain(&root.structure) + .rev() + .map(|id| (*id, None)) + .collect(); + let mut seen = BTreeSet::new(); + while let Some((id, title_index)) = roots.pop() { + if !seen.insert(id) { + return Err(invalid("Repeated canvas page object")); + } + let node = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas page object"))?; + if title_index.is_some() && !matches!(node.kind, Kind::Outline { .. }) { + return Err(invalid("Canvas title child is not an outline")); + } + match &node.kind { + Kind::Title => { + let index = page.objects.len(); + page.objects.push(PageObject::Title(Title { + id, + date: None, + layout: node.layout.clone(), + outlines: Vec::new(), + })); + roots.extend(node.children.iter().rev().map(|id| (*id, Some(index)))); + } + Kind::Outline { indents } => { + let fields = node.extra.first().map(Vec::as_slice).unwrap_or_default(); + if let Some(index) = title_index + && fields.iter().any(|field| { + field.id == 0x88001cb5 && matches!(field.value, FieldValue::NoData) + }) + { + let PageObject::Title(title) = &mut page.objects[index] else { + unreachable!() + }; + if title.date.replace(id).is_some() { + return Err(invalid("The page has more than one date field")); + } + } + let min_width = fields + .iter() + .find(|field| field.id == 0x14001cec) + .map(|field| { + let FieldValue::Bytes(bytes) = field.value else { + return Err(invalid("Invalid canvas minimum outline width")); + }; + let width = + f32::from_le_bytes(bytes.try_into().map_err(|_| { + invalid("Invalid canvas minimum outline width") + })?) * 36.0; + if !width.is_finite() || width <= 0.0 { + return Err(invalid("Invalid canvas minimum outline width")); + } + Ok(width) + }) + .transpose()?; + let mut outline = Outline { + id, + title: fields.iter().any(|field| { + field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData) + }), + min_width, + layout: node.layout.clone(), + indents: indents.clone(), + paragraphs: Vec::new(), + unsupported: Vec::new(), + }; + (outline.paragraphs, outline.unsupported) = read_paragraphs( + revision, + node, + &node.format, + &mut page.definitions, + &mut seen, + 0, + )?; + if let Some(index) = title_index { + let PageObject::Title(title) = &mut page.objects[index] else { + unreachable!() + }; + title.outlines.push(outline); + } else { + page.objects.push(PageObject::Outline(outline)); + } + } + Kind::Image { + container, + alt, + background, + .. + } => { + let bytes = if let Some(container) = container { + let data = revision + .nodes + .get(container) + .ok_or_else(|| invalid("Missing canvas image data"))?; + match &data.kind { + Kind::File { payload, .. } => payload.map(Arc::from), + _ => return Err(invalid("Canvas image data has the wrong type")), + } + } else { + None + }; + page.objects.push(PageObject::Image(Image { + id, + layout: node.layout.clone(), + bytes, + alt: alt.clone(), + background: background.unwrap_or(false), + })); + } + _ => page.objects.push(PageObject::Unsupported(Unsupported { + id, + jcid: node.jcid, + layout: node.layout.clone(), + })), + } + } + Ok(page) + } +} + +fn read_paragraphs( + revision: &Revision<'_>, + container: &Element<'_>, + inherited: &Format, + definitions: &mut BTreeMap, + seen: &mut BTreeSet, + depth: usize, +) -> Result<(Vec, Vec), Error> { + let invalid = |message| Error { offset: 0, message }; + let default_format = Format::default(); + let style = |id: Option| -> Result<&Format, Error> { + let Some(id) = id else { + return Ok(&default_format); + }; + let node = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas paragraph style"))?; + if !matches!(node.kind, Kind::Style { .. }) { + return Err(invalid("Canvas paragraph style has the wrong type")); + } + Ok(&node.format) + }; + + let mut paragraphs = Vec::new(); + let mut unsupported = Vec::new(); + let mut pending: Vec<_> = container + .children + .iter() + .rev() + .map(|id| { + ( + *id, + None, + u32::from(container.child_level.unwrap_or(0)), + inherited.clone(), + ) + }) + .collect(); + while let Some((id, parent, level, inherited)) = pending.pop() { + if !seen.insert(id) { + return Err(invalid("Repeated canvas outline object")); + } + let node = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas outline object"))?; + let next_level = level + .checked_add(u32::from(node.child_level.unwrap_or(0))) + .ok_or_else(|| invalid("Canvas outline level overflow"))?; + match &node.kind { + Kind::Paragraph { + lists, + paragraph_style, + collapse_state, + } => { + let format = node + .format + .inherit(style(*paragraph_style)?) + .inherit(&inherited); + let mut base_style = *paragraph_style; + let [content_id] = node.content.as_slice() else { + return Err(invalid("A paragraph must contain one content object")); + }; + if !seen.insert(*content_id) { + return Err(invalid("Repeated canvas paragraph content")); + } + let content = revision + .nodes + .get(content_id) + .ok_or_else(|| invalid("Missing canvas paragraph content"))?; + let content = if let Kind::RichText { + paragraph_style, .. + } = &content.kind + { + base_style = paragraph_style.or(base_style); + let runs = revision.text_runs(*content_id)?; + let text_content = if runs.is_empty() { + Paragraph::new( + String::new(), + content + .format + .inherit(style(*paragraph_style)?) + .inherit(&format), + ) + } else { + Paragraph::from_runs( + runs.into_iter() + .map(|run| (run.text.to_owned(), run.format.inherit(&format))), + ) + }; + ParagraphContent::Text(TextObject { + id: *content_id, + date_field: content + .extra + .first() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter(|field| matches!(field.value, FieldValue::NoData)) + .filter_map(|field| match field.id { + 0x88001cb5 => Some(DateField::Date), + 0x88001c87 => Some(DateField::Time), + _ => None, + }) + .try_fold(None, |previous, field| { + if previous.is_some() { + Err(invalid("The page date field has conflicting roles")) + } else { + Ok(Some(field)) + } + })?, + text: text_content, + tags: content.tags.clone(), + }) + } else if matches!(content.kind, Kind::Table { .. }) { + ParagraphContent::Table(read_table( + revision, + *content_id, + &content.format.inherit(&format), + definitions, + seen, + depth + 1, + )?) + } else { + ParagraphContent::Unsupported(Unsupported { + id: *content_id, + jcid: content.jcid, + layout: content.layout.clone(), + }) + }; + for (id, is_list) in lists.iter().map(|id| (id, true)).chain( + node.tags + .iter() + .chain(match &content { + ParagraphContent::Text(text) => text.tags.as_slice(), + ParagraphContent::Table(table) => table.tags.as_slice(), + ParagraphContent::Unsupported(_) => &[], + }) + .filter_map(|tag| tag.definition.as_ref()) + .map(|id| (id, false)), + ) { + let definition = revision + .nodes + .get(id) + .ok_or_else(|| invalid("Missing canvas list or tag definition"))?; + let kind = match &definition.kind { + Kind::List { + font, + format, + restart, + bullet, + } if is_list => Kind::List { + font: font.clone(), + format: format.clone(), + restart: *restart, + bullet: *bullet, + }, + Kind::TagDefinition { + label, + action_type, + shape, + color, + highlight, + } if !is_list => Kind::TagDefinition { + label: label.clone(), + action_type: *action_type, + shape: *shape, + color: *color, + highlight: *highlight, + }, + _ => { + return Err(invalid( + "Canvas list or tag definition has the wrong type", + )); + } + }; + definitions.insert( + *id, + Definition { + kind, + format: definition.format.clone(), + }, + ); + } + if let Some(id) = base_style { + let definition = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas paragraph style"))?; + let Kind::Style { name } = &definition.kind else { + return Err(invalid("Canvas paragraph style has the wrong type")); + }; + definitions.entry(id).or_insert_with(|| Definition { + kind: Kind::Style { name: name.clone() }, + format: definition.format.clone(), + }); + } + paragraphs.push(PageParagraph { + id, + parent, + level, + style: base_style, + format: format.clone(), + content, + lists: lists.clone(), + tags: node.tags.clone(), + collapsed: *collapse_state == Some(1), + }); + pending.extend( + node.children + .iter() + .rev() + .map(|child| (*child, Some(id), next_level, format.clone())), + ); + } + Kind::OutlineGroup => pending.extend( + node.children + .iter() + .rev() + .map(|child| (*child, parent, next_level, node.format.inherit(&inherited))), + ), + _ => unsupported.push(Unsupported { + id, + jcid: node.jcid, + layout: node.layout.clone(), + }), + } + } + Ok((paragraphs, unsupported)) +} + +fn read_table( + revision: &Revision<'_>, + id: ExGuid, + inherited: &Format, + definitions: &mut BTreeMap, + seen: &mut BTreeSet, + depth: usize, +) -> Result { + let invalid = |message| Error { offset: 0, message }; + if depth > 64 { + return Err(invalid("Tables are nested too deeply")); + } + let node = &revision.nodes[&id]; + let Kind::Table { + rows, + columns, + widths, + locked, + borders, + } = &node.kind + else { + unreachable!() + }; + if rows.map(|n| n as usize) != Some(node.children.len()) + || node.children.is_empty() + || columns.map(|n| n as usize) != Some(widths.len()) + || widths.is_empty() + || (!locked.is_empty() && locked.len() != widths.len()) + || widths.iter().any(|w| !w.is_finite() || *w < 36.0) + { + return Err(invalid("Table dimensions are inconsistent")); + } + let mut table_rows = Vec::new(); + for row_id in &node.children { + if !seen.insert(*row_id) { + return Err(invalid("Repeated canvas table row")); + } + let row = revision + .nodes + .get(row_id) + .ok_or_else(|| invalid("Missing canvas table row"))?; + if !matches!(row.kind, Kind::Row) || row.children.len() != widths.len() { + return Err(invalid("Table row dimensions are inconsistent")); + } + let mut cells = Vec::new(); + for cell_id in &row.children { + if !seen.insert(*cell_id) { + return Err(invalid("Repeated canvas table cell")); + } + let cell = revision + .nodes + .get(cell_id) + .ok_or_else(|| invalid("Missing canvas table cell"))?; + if cell.children.is_empty() { + return Err(invalid("A table cell has no paragraphs")); + } + let Kind::Cell { shading, indents } = &cell.kind else { + return Err(invalid("A table row contains an invalid cell")); + }; + let (paragraphs, unsupported) = read_paragraphs( + revision, + cell, + &cell.format.inherit(inherited), + definitions, + seen, + depth, + )?; + cells.push(TableCell { + id: *cell_id, + layout: cell.layout.clone(), + indents: indents.clone(), + shading: *shading, + paragraphs, + unsupported, + }); + } + table_rows.push(TableRow { id: *row_id, cells }); + } + Ok(Table { + id, + columns: widths + .iter() + .enumerate() + .map(|(index, &width)| TableColumn { + width, + locked: locked.get(index).copied().unwrap_or(false), + }) + .collect(), + rows: table_rows, + borders: *borders, + layout: node.layout.clone(), + tags: node.tags.clone(), + }) +} + +fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> { + revision + .roots + .get(&2) + .and_then(|id| revision.nodes.get(id)) + .and_then(|node| { + if let Kind::Metadata { title, .. } = &node.kind { + title.as_deref() + } else { + None + } + }) + .or_else(|| { + if let Kind::Page { + alternate_title, .. + } = &revision.nodes.get(&id)?.kind + { + alternate_title.as_deref() + } else { + None + } + }) +} + +impl PageParagraph { + pub fn text(&self) -> Option<&TextObject> { + match &self.content { + ParagraphContent::Text(text) => Some(text), + _ => None, + } + } + pub fn text_mut(&mut self) -> Option<&mut TextObject> { + match &mut self.content { + ParagraphContent::Text(text) => Some(text), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::{Element, TextRun}; + + fn id(n: u32) -> ExGuid { + ExGuid { + n, + ..ExGuid::default() + } + } + + fn element(kind: Kind<'_>) -> Element<'_> { + Element { + jcid: 0, + children: Vec::new(), + content: Vec::new(), + structure: Vec::new(), + spaces: Vec::new(), + child_level: None, + layout: Layout::default(), + format: Format::default(), + created: None, + modified: None, + original_author: None, + latest_author: None, + media_ids: Vec::new(), + media_time_ms: None, + tags: Vec::new(), + kind, + extra: Vec::new(), + } + } + + fn revision() -> Revision<'static> { + let mut page = element(Kind::Page { + alternate_title: Some("Fallback".into()), + level: None, + width: None, + height: None, + margin_origin_x: Some(36.0), + margin_origin_y: Some(12.0), + rtl: None, + }); + page.children.push(id(2)); + let mut outline = element(Kind::Outline { + indents: vec![18.0, 0.0, 36.0], + }); + outline.children.push(id(3)); + outline.child_level = Some(1); + outline.format.font_size = Some(9.0); + outline.format.bold = Some(true); + let mut paragraph = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: Some(id(5)), + collapse_state: Some(1), + }); + paragraph.content.push(id(4)); + paragraph.format.bold = Some(false); + let text = element(Kind::RichText { + text: "ab".into(), + runs: vec![TextRun { + start: 0, + end: 2, + format: None, + extra_set: None, + }], + paragraph_style: None, + boilerplate: false, + }); + let mut style = element(Kind::Style { + name: Some("Body".into()), + }); + style.format.font_size = Some(12.0); + let metadata = element(Kind::Metadata { + title: Some("Page title".into()), + level: None, + }); + Revision { + roots: BTreeMap::from([(2, id(6))]), + nodes: BTreeMap::from([ + (id(1), page), + (id(2), outline), + (id(3), paragraph), + (id(4), text), + (id(5), style), + (id(6), metadata), + ]), + } + } + + fn table_revision() -> Revision<'static> { + let mut source = revision(); + source.nodes.get_mut(&id(3)).unwrap().content = vec![id(7)]; + let mut table = element(Kind::Table { + rows: Some(1), + columns: Some(2), + widths: vec![37.11, 99.0], + locked: vec![false, true], + borders: Some(false), + }); + table.children = vec![id(8)]; + let mut row = element(Kind::Row); + row.children = vec![id(9), id(10)]; + source.nodes.insert(id(7), table); + source.nodes.insert(id(8), row); + for (cell, paragraph, text) in [(9, 11, 4), (10, 12, 13)] { + let mut node = element(Kind::Cell { + shading: Some(0x00ffff), + indents: vec![18.0, 0.0, 27.0, 27.0], + }); + node.child_level = Some(1); + node.layout.max_width = Some(268.8); + node.children = vec![id(paragraph)]; + if cell == 10 { + node.format.font_size = Some(13.0); + } + source.nodes.insert(id(cell), node); + let mut node = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: None, + collapse_state: None, + }); + node.content = vec![id(text)]; + source.nodes.insert(id(paragraph), node); + } + source.nodes.insert( + id(13), + element(Kind::RichText { + text: "cd".into(), + runs: vec![TextRun { + start: 0, + end: 2, + format: None, + extra_set: None, + }], + paragraph_style: None, + boilerplate: false, + }), + ); + source + } + + #[test] + fn table_import_owns_cells_and_inherits_format_across_containers() { + let page = { + let source = table_revision(); + let before = serde_json::to_vec(&source).unwrap(); + let page = Page::from_revision(&source, id(1)).unwrap(); + assert_eq!(serde_json::to_vec(&source).unwrap(), before); + page + }; + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + let ParagraphContent::Table(table) = &outline.paragraphs[0].content else { + panic!() + }; + assert_eq!(table.id, id(7)); + assert_eq!(table.borders, Some(false)); + assert_eq!( + table.columns, + [ + TableColumn { + width: 37.11, + locked: false + }, + TableColumn { + width: 99.0, + locked: true + } + ] + ); + assert_eq!(table.rows[0].id, id(8)); + for (index, cell) in table.rows[0].cells.iter().enumerate() { + assert_eq!(cell.id, id(9 + index as u32)); + assert_eq!(cell.layout.max_width, Some(268.8)); + assert_eq!(cell.indents, [18.0, 0.0, 27.0, 27.0]); + assert_eq!(cell.shading, Some(0x00ffff)); + let paragraph = &cell.paragraphs[0]; + assert_eq!(paragraph.parent, None); + assert_eq!(paragraph.level, 1); + assert_eq!(paragraph.id, id(11 + index as u32)); + let text = paragraph.text().unwrap(); + assert_eq!(text.id, if index == 0 { id(4) } else { id(13) }); + assert_eq!(text.text.text(), if index == 0 { "ab" } else { "cd" }); + assert_eq!( + text.text.spans()[0].format.font_size, + Some(if index == 0 { 12.0 } else { 13.0 }) + ); + assert_eq!(text.text.spans()[0].format.bold, Some(false)); + } + } + + #[test] + fn absent_column_locks_use_the_specified_unlocked_default() { + let mut source = table_revision(); + let Kind::Table { locked, .. } = &mut source.nodes.get_mut(&id(7)).unwrap().kind else { + panic!() + }; + locked.clear(); + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + let ParagraphContent::Table(table) = &outline.paragraphs[0].content else { + panic!() + }; + assert!(table.columns.iter().all(|column| !column.locked)); + } + + #[test] + fn table_import_rejects_aliases_cycles_and_inconsistent_dimensions() { + for case in 0..7 { + let mut source = table_revision(); + match case { + 0 => source.nodes.get_mut(&id(8)).unwrap().children[1] = id(9), + 1 => source.nodes.get_mut(&id(11)).unwrap().content = vec![id(7)], + 2 => source.nodes.get_mut(&id(12)).unwrap().content = vec![id(4)], + 3 => source.nodes.get_mut(&id(3)).unwrap().content.push(id(4)), + 4 => source.nodes.get_mut(&id(9)).unwrap().children.clear(), + 5 => { + let Kind::Table { columns, .. } = + &mut source.nodes.get_mut(&id(7)).unwrap().kind + else { + panic!() + }; + *columns = Some(3); + } + _ => { + let Kind::Table { widths, .. } = + &mut source.nodes.get_mut(&id(7)).unwrap().kind + else { + panic!() + }; + widths[0] = f32::INFINITY; + } + } + assert!(Page::from_revision(&source, id(1)).is_err(), "case {case}"); + } + } + + #[test] + fn nested_tables_import_with_a_bounded_depth() { + for levels in [1, 62, 65] { + let mut source = table_revision(); + let mut paragraph = id(11); + for depth in 0..levels { + let base = 20 + depth * 4; + source.nodes.get_mut(¶graph).unwrap().content = vec![id(base)]; + let mut table = element(Kind::Table { + rows: Some(1), + columns: Some(1), + widths: vec![72.0], + locked: vec![true], + borders: Some(true), + }); + table.children = vec![id(base + 1)]; + let mut row = element(Kind::Row); + row.children = vec![id(base + 2)]; + let mut cell = element(Kind::Cell { + shading: None, + indents: vec![18.0, 0.0, 27.0, 27.0], + }); + cell.child_level = Some(1); + cell.children = vec![id(base + 3)]; + let mut child = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: None, + collapse_state: None, + }); + child.content = vec![id(4)]; + source.nodes.extend([ + (id(base), table), + (id(base + 1), row), + (id(base + 2), cell), + (id(base + 3), child), + ]); + paragraph = id(base + 3); + } + assert_eq!(Page::from_revision(&source, id(1)).is_ok(), levels < 64); + } + } + + #[test] + fn owns_page_content_and_resolves_style_before_ancestor_defaults() { + let page = { + let mut source = revision(); + source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag { + definition: Some(id(7)), + action_type: None, + status: 0, + created: Some(123), + completed: None, + start: None, + due: None, + task_id: None, + extra_set: 0, + }); + source.nodes.insert( + id(7), + element(Kind::TagDefinition { + label: Some("To Do".into()), + action_type: Some(0), + shape: Some(3), + color: None, + highlight: None, + }), + ); + let before = serde_json::to_vec(&source).unwrap(); + let page = Page::from_revision(&source, id(1)).unwrap(); + assert_eq!(before, serde_json::to_vec(&source).unwrap()); + page + }; + assert_eq!(page.title, "Page title"); + assert_eq!(page.margin_origin, [36.0, 12.0]); + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + assert_eq!(outline.paragraphs.len(), 1); + let paragraph = &outline.paragraphs[0]; + assert!(paragraph.collapsed); + assert_eq!(paragraph.level, 1); + assert_eq!(paragraph.parent, None); + assert_eq!(paragraph.style, Some(id(5))); + assert!(matches!(&page.definitions[&id(5)].kind, + Kind::Style { name: Some(name) } if name == "Body")); + assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0)); + let text = ¶graph.text().unwrap().text; + assert_eq!(text.text(), "ab"); + assert_eq!(text.spans()[0].format.font_size, Some(12.0)); + assert_eq!(text.spans()[0].format.bold, Some(false)); + assert_eq!(paragraph.text().unwrap().tags[0].created, Some(123)); + assert!(matches!(&page.definitions[&id(7)].kind, + Kind::TagDefinition { label: Some(label), shape: Some(3), .. } if label == "To Do")); + } + + #[test] + fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() { + use crate::document::{Field, FieldValue}; + let mut source = revision(); + source.nodes.get_mut(&id(1)).unwrap().created = Some(7); + let bytes = 134_333_468_649_123_456_u64.to_le_bytes(); + source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field { + id: 0x18001c65, + value: FieldValue::Bytes(&bytes), + }]]; + source.nodes.get_mut(&id(1)).unwrap().children.clear(); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); + let mut title = element(Kind::Title); + title.children.push(id(2)); + source.nodes.insert(id(7), title); + source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![Field { + id: 0x88001cb5, + value: FieldValue::NoData, + }]]; + source.nodes.get_mut(&id(4)).unwrap().extra = vec![vec![Field { + id: 0x88001c87, + value: FieldValue::NoData, + }]]; + let page = Page::from_revision(&source, id(1)).unwrap(); + assert_eq!(page.created, Some(134_333_468_649_123_456)); + let PageObject::Title(title) = &page.objects[0] else { + panic!() + }; + assert_eq!(title.date, Some(id(2))); + assert_eq!( + title.outlines[0].paragraphs[0].text().unwrap().date_field, + Some(DateField::Time) + ); + source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]); + assert!(Page::from_revision(&source, id(1)).is_err()); + source.nodes.get_mut(&id(6)).unwrap().extra.clear(); + assert_eq!(Page::from_revision(&source, id(1)).unwrap().created, None); + } + + #[test] + fn title_role_and_minimum_width_come_from_root_properties() { + use crate::document::{Field, FieldValue}; + let mut source = revision(); + source.nodes.get_mut(&id(1)).unwrap().children.clear(); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); + let mut title = element(Kind::Title); + title.children.push(id(2)); + source.nodes.insert(id(7), title); + source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![ + Field { + id: 0x88001cb4, + value: FieldValue::NoData, + }, + Field { + id: 0x14001cec, + value: FieldValue::Bytes(&[0, 0, 0x90, 0x40]), + }, + ]]; + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Title(title) = &page.objects[0] else { + panic!() + }; + assert!(title.outlines[0].title); + assert_eq!(title.outlines[0].min_width, Some(162.0)); + source.nodes.get_mut(&id(2)).unwrap().extra[0][1].value = + FieldValue::Bytes(&[0, 0, 0x80, 0x7f]); + assert!(Page::from_revision(&source, id(1)).is_err()); + source.nodes.get_mut(&id(2)).unwrap().extra = vec![ + vec![Field { + id: 0x08001cb4, + value: FieldValue::NoData, + }], + vec![Field { + id: 0x88001cb4, + value: FieldValue::NoData, + }], + ]; + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Title(title) = &page.objects[0] else { + panic!() + }; + assert!(!title.outlines[0].title); + assert_eq!(title.outlines[0].min_width, None); + } + + #[test] + fn preserves_paint_order_nested_parents_and_owned_image_payloads() { + let bytes = vec![1, 2, 3, 4]; + let mut source = revision(); + let image = element(Kind::Image { + container: Some(id(8)), + filename: None, + alt: Some("Image".into()), + picture_width: None, + picture_height: None, + background: Some(true), + printout: None, + link: None, + }); + let file = element(Kind::File { + reference: crate::FileDataReference::Internal([0; 16]), + extension: "png".into(), + payload: Some(bytes.as_slice()), + }); + let mut title = element(Kind::Title); + title.layout.x = Some(12.0); + title.layout.y = Some(24.0); + title.children.push(id(10)); + let title_outline = element(Kind::Outline { + indents: Vec::new(), + }); + let mut group = element(Kind::OutlineGroup); + group.children.push(id(12)); + group.child_level = Some(1); + let mut child = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: None, + collapse_state: None, + }); + child.content.push(id(13)); + source.nodes.insert( + id(13), + element(Kind::RichText { + text: "".into(), + runs: Vec::new(), + paragraph_style: None, + boilerplate: false, + }), + ); + source.nodes.extend([ + (id(7), image), + (id(8), file), + (id(9), title), + (id(10), title_outline), + (id(11), group), + (id(12), child), + ]); + source + .nodes + .get_mut(&id(1)) + .unwrap() + .children + .insert(0, id(7)); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(9)); + source.nodes.get_mut(&id(3)).unwrap().children.push(id(11)); + source.nodes.get_mut(&id(3)).unwrap().child_level = Some(1); + let page = Page::from_revision(&source, id(1)).unwrap(); + drop(source); + drop(bytes); + assert_eq!(page.objects.len(), 3); + let PageObject::Image(image) = &page.objects[0] else { + panic!() + }; + assert_eq!(image.bytes.as_deref(), Some([1, 2, 3, 4].as_slice())); + let PageObject::Outline(outline) = &page.objects[1] else { + panic!() + }; + assert_eq!(outline.paragraphs[1].id, id(12)); + assert_eq!(outline.paragraphs[1].parent, Some(id(3))); + assert_eq!(outline.paragraphs[1].level, 3); + let PageObject::Title(title) = &page.objects[2] else { + panic!() + }; + assert_eq!(title.id, id(9)); + assert_eq!([title.layout.x, title.layout.y], [Some(12.0), Some(24.0)]); + assert_eq!(title.outlines[0].id, id(10)); + assert_eq!(title.outlines[0].layout.x, None); + } + + #[test] + fn rejects_title_children_that_are_not_outlines() { + let mut source = revision(); + let mut title = element(Kind::Title); + title.children.push(id(8)); + source.nodes.insert(id(7), title); + source.nodes.insert(id(8), element(Kind::Title)); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); + assert_eq!( + Page::from_revision(&source, id(1)).err().unwrap().message, + "Canvas title child is not an outline" + ); + } + + #[test] + fn validates_empty_text_styles_missing_references_and_cycles() { + let mut source = revision(); + source.nodes.get_mut(&id(4)).unwrap().kind = Kind::RichText { + text: String::new(), + runs: Vec::new(), + paragraph_style: Some(id(5)), + boilerplate: false, + }; + source.nodes.get_mut(&id(3)).unwrap().format.font_size = Some(10.0); + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + assert_eq!( + outline.paragraphs[0].text().unwrap().text.spans()[0] + .format + .font_size, + Some(12.0) + ); + source.nodes.get_mut(&id(3)).unwrap().children.push(id(2)); + assert!(Page::from_revision(&source, id(1)).is_err()); + source.nodes.get_mut(&id(3)).unwrap().children.clear(); + source.nodes.remove(&id(5)); + assert!(Page::from_revision(&source, id(1)).is_err()); + } +} diff --git a/crates/onestore/src/page/text.rs b/crates/onestore/src/page/text.rs new file mode 100644 index 0000000000000000000000000000000000000000..1703e4b6806b65d398551f2ac7bbbc92cf2c730e --- /dev/null +++ b/crates/onestore/src/page/text.rs @@ -0,0 +1,571 @@ +use crate::{ExGuid, document::Format}; +use std::{fmt, ops::Range}; + +#[derive(Clone, Debug, PartialEq)] +pub struct Span { + /// Exclusive UTF-8 boundary; the start is the preceding span's end. + pub end: usize, + pub format: Format, +} + +#[derive(Clone, Debug, PartialEq)] +/// Editable text styles are coalesced independently of serialized run boundaries. +pub struct Paragraph { + text: String, + spans: Vec, +} + +/// Which side of a hidden field a visible boundary maps to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Affinity { + Upstream, + Downstream, +} + +#[derive(Clone)] +pub struct TextProjection { + text: Paragraph, + spans: Vec, + source_len: u32, +} + +#[derive(Clone)] +struct ProjectedSpan { + visible: Range, + source_start: u32, +} + +impl TextProjection { + pub fn text(&self) -> &Paragraph { + &self.text + } + + /// Maps visible UTF-8 boundaries to downstream source UTF-16 positions in one pass. + pub fn source_boundaries(&self) -> impl Iterator + '_ { + let mut spans = self.spans.iter().peekable(); + let mut visible = 0; + self.text + .text + .char_indices() + .map(move |(byte, character)| { + while spans.peek().is_some_and(|span| span.visible.end <= visible) { + spans.next(); + } + let span = spans.peek().unwrap(); + let source = span.source_start + (visible - span.visible.start); + visible += character.len_utf16() as u32; + (byte, source) + }) + .chain(std::iter::once((self.text.text.len(), self.source_len))) + } + + pub fn source_offset(&self, visible: u32, affinity: Affinity) -> Result { + self.text.byte_offset(visible)?; + Ok(match affinity { + Affinity::Downstream => self + .spans + .iter() + .find(|span| span.visible.end > visible) + .map(|span| span.source_start + (visible - span.visible.start)) + .unwrap_or(self.source_len), + Affinity::Upstream => self + .spans + .iter() + .rev() + .find(|span| span.visible.start < visible) + .map(|span| span.source_start + (visible - span.visible.start)) + .unwrap_or(0), + }) + } + + /// Hidden source positions collapse to their visible boundary. + pub fn visible_offset(&self, source: u32) -> Result { + if source > self.source_len { + return Err(EditError::InvalidRange); + } + for span in &self.spans { + if source < span.source_start { + return Ok(span.visible.start); + } + let length = span.visible.end - span.visible.start; + if source < span.source_start + length { + let visible = span.visible.start + source - span.source_start; + self.text.byte_offset(visible)?; + return Ok(visible); + } + } + self.text.utf16_offset(self.text.text.len()) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Edit { + pub range: Range, + pub replacement: Paragraph, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditError { + InvalidRange, + TextTooLong, + InvalidStructure, + UnsupportedContent, + /// The system random source failed while allocating a new identity. + Identity, +} + +impl fmt::Display for EditError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Identity => "System random source failed", + Self::UnsupportedContent => { + "This paragraph contains content the text editor cannot edit" + } + Self::InvalidRange => "Text range is outside the paragraph or splits a surrogate pair", + Self::TextTooLong => "Text exceeds the UTF-16 offset range", + Self::InvalidStructure => { + "This outline has duplicate objects or broken paragraph links" + } + }) + } +} + +impl std::error::Error for EditError {} + +/// A fresh random identity for a new paragraph or text object. +pub fn new_id() -> Result { + Ok(ExGuid { + guid: crate::write::fresh_guid().map_err(|_| EditError::Identity)?, + n: 0, + }) +} + +impl Paragraph { + pub fn project(&self) -> Result { + let mut runs = Vec::new(); + let mut spans: Vec = Vec::new(); + let mut byte = 0; + let mut source = 0_u32; + let mut visible = 0_u32; + for span in &self.spans { + let fragment = &self.text[byte..span.end]; + let length: u32 = fragment + .encode_utf16() + .count() + .try_into() + .map_err(|_| EditError::TextTooLong)?; + let source_end = source.checked_add(length).ok_or(EditError::TextTooLong)?; + if span.format.hidden != Some(true) { + runs.push((fragment.replace('\u{000b}', "\n"), span.format.clone())); + if length > 0 { + if let Some(last) = spans.last_mut() + && last.source_start + (last.visible.end - last.visible.start) == source + { + last.visible.end += length; + } else { + spans.push(ProjectedSpan { + visible: visible..visible + length, + source_start: source, + }); + } + visible += length; + } + } + byte = span.end; + source = source_end; + } + if runs.is_empty() { + let mut format = self.spans[0].format.clone(); + format.hidden = Some(false); + runs.push((String::new(), format)); + } + Ok(TextProjection { + text: Self::from_runs(runs), + spans, + source_len: source, + }) + } + + pub fn new(text: String, format: Format) -> Self { + let end = text.len(); + Self { + text, + spans: vec![Span { end, format }], + } + } + + pub fn from_runs(runs: impl IntoIterator) -> Self { + let mut text = String::new(); + let mut spans: Vec = Vec::new(); + for (fragment, format) in runs { + text.push_str(&fragment); + if let Some(last) = spans.last_mut() { + if last.format == format { + last.end = text.len(); + continue; + } + if fragment.is_empty() && !text.is_empty() { + continue; + } + if last.end == 0 { + spans.clear(); + } + } + spans.push(Span { + end: text.len(), + format, + }); + } + if spans.is_empty() { + spans.push(Span { + end: 0, + format: Format::default(), + }); + } + Self { text, spans } + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn spans(&self) -> &[Span] { + &self.spans + } + + pub fn byte_offset(&self, utf16: u32) -> Result { + let mut units = 0_u64; + for (byte, character) in self.text.char_indices() { + if units == u64::from(utf16) { + return Ok(byte); + } + units += character.len_utf16() as u64; + if units > u64::from(utf16) { + return Err(EditError::InvalidRange); + } + } + if units == u64::from(utf16) { + Ok(self.text.len()) + } else { + Err(EditError::InvalidRange) + } + } + + pub fn utf16_offset(&self, byte: usize) -> Result { + self.text + .get(..byte) + .ok_or(EditError::InvalidRange)? + .encode_utf16() + .count() + .try_into() + .map_err(|_| EditError::TextTooLong) + } + + pub fn format_at(&self, utf16: u32) -> Result<&Format, EditError> { + let byte = self.byte_offset(utf16)?; + let index = self.spans.partition_point(|span| span.end < byte); + Ok(&self.spans[index].format) + } + + pub fn slice(&self, range: Range) -> Result { + if range.start > range.end { + return Err(EditError::InvalidRange); + } + let start = self.byte_offset(range.start)?; + let end = self.byte_offset(range.end)?; + if start == end { + return Ok(Self::new( + String::new(), + self.format_at(range.start)?.clone(), + )); + } + let mut previous = 0; + let mut spans = Vec::new(); + for span in &self.spans { + if previous < end && span.end > start { + spans.push(Span { + end: span.end.min(end) - start, + format: span.format.clone(), + }); + } + previous = span.end; + } + Ok(Self { + text: self.text[start..end].to_owned(), + spans, + }) + } + + /// Returns the inverse edit; applying that inverse returns a redo operation. + pub fn apply(&mut self, edit: Edit) -> Result { + let end = self.utf16_offset(self.text.len())?; + let removed = self.slice(edit.range.clone())?; + let inserted = edit.replacement.utf16_offset(edit.replacement.text.len())?; + let new_end = edit + .range + .start + .checked_add(inserted) + .ok_or(EditError::TextTooLong)?; + let resulting_len = end + .checked_sub(edit.range.end - edit.range.start) + .and_then(|n| n.checked_add(inserted)) + .ok_or(EditError::TextTooLong)?; + if resulting_len == 0 { + *self = edit.replacement; + return Ok(Edit { + range: edit.range.start..new_end, + replacement: removed, + }); + } + let prefix = self.slice(0..edit.range.start)?; + let suffix = self.slice(edit.range.end..end)?; + let mut runs = Vec::new(); + for part in [prefix, edit.replacement, suffix] { + let mut start = 0; + for span in part.spans { + if span.end > start { + runs.push((part.text[start..span.end].to_owned(), span.format)); + } + start = span.end; + } + } + *self = Self::from_runs(runs); + Ok(Edit { + range: edit.range.start..new_end, + replacement: removed, + }) + } + + pub fn append(&mut self, other: Self) -> Result { + let end = self.utf16_offset(self.text.len())?; + self.apply(Edit { + range: end..end, + replacement: other, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn regular() -> Format { + Format { + font: Some("Arial".into()), + font_size: Some(11.0), + ..Format::default() + } + } + + fn bold() -> Format { + Format { + bold: Some(true), + ..regular() + } + } + #[test] + fn soft_breaks_keep_source_offsets_and_formatting() { + let source = Paragraph::from_runs([("A\u{000b}".into(), regular()), ("🌳".into(), bold())]); + let projection = source.project().unwrap(); + assert_eq!(source.text(), "A\u{000b}🌳"); + assert_eq!(projection.text().text(), "A\n🌳"); + assert_eq!(projection.text().spans(), source.spans()); + assert_eq!( + projection.source_boundaries().collect::>(), + [(0, 0), (1, 1), (2, 2), (6, 4)] + ); + for offset in [0, 1, 2, 4] { + assert_eq!(projection.visible_offset(offset), Ok(offset)); + assert_eq!( + projection.source_offset(offset, Affinity::Downstream), + Ok(offset) + ); + } + assert!(projection.visible_offset(3).is_err()); + } + + #[test] + fn hidden_fields_keep_source_offsets_at_both_sides_of_a_gap() { + let hidden = Format { + hidden: Some(true), + ..regular() + }; + let source = Paragraph::from_runs([ + ("A".into(), regular()), + ("🌳".into(), hidden.clone()), + ("e\u{301}".into(), bold()), + ("X".into(), hidden), + ]); + let original = source.clone(); + let projection = source.project().unwrap(); + assert_eq!(projection.text().text(), "Ae\u{301}"); + assert_eq!( + projection.source_boundaries().collect::>(), + [(0, 0), (1, 3), (2, 4), (4, 6)] + ); + for (visible, upstream, downstream) in [(0, 0, 0), (1, 1, 3), (2, 4, 4), (3, 5, 6)] { + assert_eq!( + projection.source_offset(visible, Affinity::Upstream), + Ok(upstream) + ); + assert_eq!( + projection.source_offset(visible, Affinity::Downstream), + Ok(downstream) + ); + } + for (source, visible) in [(0, 0), (1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3)] { + assert_eq!(projection.visible_offset(source), Ok(visible)); + } + assert_eq!(source, original); + } + + #[test] + fn projection_validates_visible_surrogates_and_preserves_empty_field_boundaries() { + let visible = Paragraph::new("a🌳z".into(), regular()).project().unwrap(); + assert_eq!( + visible.source_offset(2, Affinity::Downstream), + Err(EditError::InvalidRange) + ); + assert_eq!(visible.visible_offset(2), Err(EditError::InvalidRange)); + let hidden = Paragraph::new( + "🌳".into(), + Format { + hidden: Some(true), + ..regular() + }, + ) + .project() + .unwrap(); + assert!(hidden.text().text().is_empty()); + assert_eq!(hidden.source_boundaries().collect::>(), [(0, 2)]); + assert_eq!( + visible.source_boundaries().collect::>(), + [(0, 0), (1, 1), (5, 3), (6, 4)] + ); + assert_eq!(hidden.source_offset(0, Affinity::Upstream), Ok(0)); + assert_eq!(hidden.source_offset(0, Affinity::Downstream), Ok(2)); + assert_eq!(hidden.visible_offset(1), Ok(0)); + assert_eq!(hidden.visible_offset(3), Err(EditError::InvalidRange)); + } + + #[test] + fn offsets_distinguish_bytes_utf16_and_scalars() { + let text = Paragraph::new("a🌳e\u{301}".into(), regular()); + for (utf16, byte) in [(0, 0), (1, 1), (3, 5), (4, 6), (5, 8)] { + assert_eq!(text.byte_offset(utf16), Ok(byte)); + assert_eq!(text.utf16_offset(byte), Ok(utf16)); + } + assert_eq!(text.byte_offset(2), Err(EditError::InvalidRange)); + assert_eq!(text.utf16_offset(2), Err(EditError::InvalidRange)); + assert_eq!(text.byte_offset(6), Err(EditError::InvalidRange)); + assert_eq!(text.utf16_offset(9), Err(EditError::InvalidRange)); + } + + #[test] + fn replacement_preserves_styles_outside_selection() { + let mut text = Paragraph::from_runs([ + ("plain ".into(), regular()), + ("bold".into(), bold()), + (" end".into(), regular()), + ]); + let original = text.clone(); + let edit = Edit { + range: 4..8, + replacement: Paragraph::new("🌳".into(), bold()), + }; + let undo = text.apply(edit).unwrap(); + assert_eq!(text.text(), "plai🌳ld end"); + assert_eq!( + text.spans() + .iter() + .map(|s| (s.end, s.format.bold)) + .collect::>(), + [(4, None), (10, Some(true)), (14, None)] + ); + let after = text.clone(); + let redo = text.apply(undo).unwrap(); + assert_eq!(text, original); + text.apply(redo).unwrap(); + assert_eq!(text, after); + } + + #[test] + fn invalid_edits_leave_text_and_styles_unchanged() { + let original = Paragraph::new("a🌳b".into(), regular()); + for (start, end) in [(2, 3), (0, 2), (3, 2), (0, 5)] { + let mut text = original.clone(); + assert_eq!( + text.apply(Edit { + range: start..end, + replacement: Paragraph::new("x".into(), bold()) + }), + Err(EditError::InvalidRange) + ); + assert_eq!(text, original); + } + } + + #[test] + fn undo_restores_empty_paragraph_format() { + let mut text = Paragraph::new(String::new(), regular()); + let original = text.clone(); + let undo = text + .apply(Edit { + range: 0..0, + replacement: Paragraph::new("bold".into(), bold()), + }) + .unwrap(); + text.apply(undo).unwrap(); + assert_eq!(text, original); + } + + #[test] + fn every_scalar_range_round_trips_styled_unicode_edits() { + let original = Paragraph::from_runs([ + ("a🌳".into(), regular()), + ("e\u{301}Χ©ΧœΧ•Χ".into(), bold()), + ("Z".into(), regular()), + ]); + let boundaries: Vec<_> = original + .text() + .char_indices() + .map(|(n, _)| n) + .chain([original.text().len()]) + .collect(); + for &start in &boundaries { + for &end in boundaries.iter().filter(|&&end| end >= start) { + let range = + original.utf16_offset(start).unwrap()..original.utf16_offset(end).unwrap(); + for replacement in ["", "πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘¦", "xyz", "\u{301}"] { + let mut edited = original.clone(); + let undo = edited + .apply(Edit { + range: range.clone(), + replacement: Paragraph::new(replacement.into(), bold()), + }) + .unwrap(); + assert_eq!( + edited.text(), + format!( + "{}{replacement}{}", + &original.text()[..start], + &original.text()[end..] + ) + ); + let after = edited.clone(); + let redo = edited.apply(undo).unwrap(); + assert_eq!(edited, original); + edited.apply(redo).unwrap(); + assert_eq!(edited, after); + } + } + let at = original.utf16_offset(start).unwrap(); + let mut left = original.slice(0..at).unwrap(); + let right = original + .slice(at..original.utf16_offset(original.text.len()).unwrap()) + .unwrap(); + left.append(right).unwrap(); + assert_eq!(left, original); + } + } +} diff --git a/crates/onestore/src/pages.rs b/crates/onestore/src/pages.rs new file mode 100644 index 0000000000000000000000000000000000000000..c007c99357976a1389251c94f053376c530e53bd --- /dev/null +++ b/crates/onestore/src/pages.rs @@ -0,0 +1,861 @@ +use crate::{ + Error, ExGuid, + create::{current_timestamps, properties, string}, + document::{Document, FieldValue, Kind}, + write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +fn metadata_id(space: ExGuid) -> ExGuid { + let mut id = ExGuid { + guid: space.guid, + n: 1, + }; + for (byte, salt) in id.guid.iter_mut().zip([ + 0x31, 0xc0, 0xa8, 0x22, 0, 0x36, 0xee, 0x42, 0xb7, 0x14, 0xd7, 0xac, 0xda, 0x24, 0x35, 0xe8, + ]) { + *byte ^= salt; + } + id +} + +fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[u8; 16], Error> { + metadata.extra[0] + .iter() + .find_map(|field| match field.value { + FieldValue::Bytes(bytes) if field.id == 0x1c001c30 => bytes.try_into().ok(), + _ => None, + }) + .ok_or_else(|| invalid("Page metadata needs its page identifier")) +} + +fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> { + let mut references = Vec::new(); + for id in ids { + references.extend_from_slice(&object.reference(*id)?); + } + let source = PropertyObject { + jcid: object.jcid, + bytes: properties(&[(property, references)])?, + global_ids: Arc::clone(&object.global_ids), + }; + object.copy_property(&source, property) +} + +/// Whether an edit preserves position or moves before a page (None appends). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum PagePosition { + Keep, + Before(Option), +} + +/// A page's explicit position and indentation, with a stable identity for a new series. +/// Moving a page does not implicitly select its following subpages. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PageEdit { + guid: [u8; 16], + space: ExGuid, + level: u32, + position: PagePosition, +} + +impl PageEdit { + /// Changes one page's indentation without changing its position. + pub fn set_level(space: ExGuid, level: u32) -> Result { + Self::new(space, level, PagePosition::Keep) + } + + /// Moves before an existing page space, or appends when `before` is None. + pub fn move_to(space: ExGuid, before: Option, level: u32) -> Result { + Self::new(space, level, PagePosition::Before(before)) + } + + pub fn space(&self) -> ExGuid { + self.space + } + + pub fn level(&self) -> u32 { + self.level + } + + pub fn position(&self) -> PagePosition { + self.position + } + + /// Revises placement while retaining the selected page and allocated series identity. + pub fn reposition(&self, position: PagePosition, level: u32) -> Result { + let edit = Self { + position, + level, + ..self.clone() + }; + edit.validate()?; + Ok(edit) + } + + fn new(space: ExGuid, level: u32, position: PagePosition) -> Result { + let edit = Self { + guid: fresh_guid()?, + space, + level, + position, + }; + edit.validate()?; + Ok(edit) + } + + fn validate(&self) -> Result<(), Error> { + if self.guid == [0; 16] || self.space.guid == [0; 16] || !(1..=3).contains(&self.level) { + return Err(invalid( + "Choose an existing page space and indentation level 1 through 3", + )); + } + if let PagePosition::Before(Some(before)) = self.position + && (before.guid == [0; 16] || before == self.space) + { + return Err(invalid("Choose a different page as the movement anchor")); + } + Ok(()) + } +} + +pub(crate) fn edit_pages( + source: &[u8], + edits: &[PageEdit], + removals: &[ExGuid], +) -> Result, Error> { + if !edits.is_empty() && !removals.is_empty() { + return Err(invalid( + "Choose page placement or page removal for one operation", + )); + } + let removed: BTreeSet<_> = removals.iter().copied().collect(); + if removed.len() != removals.len() || removed.iter().any(|sid| sid.guid == [0; 16]) { + return Err(invalid("Choose distinct existing page spaces for removal")); + } + let mut selected = BTreeMap::new(); + let mut guids = BTreeSet::new(); + for edit in edits { + edit.validate()?; + if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) { + return Err(invalid( + "Use distinct page and edit identities within a batch", + )); + } + } + write_revisions(source, |index| { + let document = Document::parse(index)?; + let pages = document.pages()?; + let view = document.active(document.root)?; + let section_id = view.roots[&1]; + let section_node = &view.nodes[§ion_id]; + if !matches!(section_node.kind, Kind::Section { .. }) + || section_node.extra[0] + .iter() + .any(|field| field.id == 0x88001cde) + { + return Err(invalid("Choose an editable section for page editing")); + } + let mut order = Vec::new(); + let mut levels = BTreeMap::new(); + let mut page_guids = BTreeMap::new(); + for (sid, _) in &pages { + let page = document.active(*sid)?; + let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else { + return Err(invalid("Page metadata is unavailable")); + }; + let Kind::Metadata { level, .. } = metadata.kind else { + return Err(invalid("Choose ordinary pages with page metadata")); + }; + let level = level.unwrap_or(1); + if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() { + return Err(invalid( + "Each page space must contain one page with a valid level", + )); + } + if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() { + return Err(invalid("Pages must have distinct metadata identifiers")); + } + order.push(*sid); + } + let original_order = order.clone(); + let original_levels = levels.clone(); + let mut series_by_head = BTreeMap::new(); + let mut copies = BTreeMap::new(); + let mut copy_ids = BTreeSet::new(); + for oid in §ion_node.children { + let series = &view.nodes[oid]; + let Some(head) = series.spaces.first() else { + return Err(invalid("A page series must contain at least one page")); + }; + if levels.get(head) != Some(&1) + || series.spaces[1..] + .iter() + .any(|sid| levels.get(sid).is_none_or(|level| *level == 1)) + { + return Err(invalid( + "A page series must start with its only top-level page", + )); + } + if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) { + series_by_head.insert(*retained, *oid); + } + if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { + let FieldValue::Objects(ids) = &field.value else { + return Err(invalid("Page metadata copies must be object references")); + }; + if ids.len() != series.spaces.len() { + return Err(invalid("Page metadata copies must match their series")); + } + for id in ids { + if !copy_ids.insert(*id) + || !matches!( + view.nodes.get(id).map(|n| &n.kind), + Some(Kind::Metadata { .. }) + ) + { + return Err(invalid("Each page needs its own ordinary metadata copy")); + } + let sid = page_guids + .get(&metadata_guid(&view.nodes[id])?) + .filter(|sid| series.spaces.contains(sid)) + .ok_or_else(|| { + invalid("Metadata copy must identify a page in its series") + })?; + if copies.insert(*sid, *id).is_some() { + return Err(invalid("Each page needs its own ordinary metadata copy")); + } + } + } + } + for sid in &removed { + if !levels.contains_key(sid) { + return Err(invalid("The selected page is no longer in the section")); + } + } + order.retain(|sid| !removed.contains(sid)); + if !removed.is_empty() + && let Some(first) = order.first() + { + levels.insert(*first, 1); + } + for edit in edits { + let Some(at) = order.iter().position(|sid| *sid == edit.space) else { + return Err(invalid("The selected page is no longer in the section")); + }; + levels.insert(edit.space, edit.level); + if let PagePosition::Before(before) = edit.position { + order.remove(at); + let position = match before { + None => order.len(), + Some(before) => { + order.iter().position(|sid| *sid == before).ok_or_else(|| { + invalid("The movement anchor is no longer in the section") + })? + } + }; + order.insert(position, edit.space); + } + } + if order.first().is_some_and(|sid| levels[sid] != 1) { + return Err(invalid( + "The section's first page must have indentation level 1", + )); + } + if order == original_order && levels == original_levels { + return Ok(BTreeMap::new()); + } + let raw = index.resolve_active(document.root)?; + let mut groups: Vec> = Vec::new(); + for sid in order { + if levels[&sid] == 1 { + groups.push(Vec::new()); + } + groups.last_mut().unwrap().push(sid); + } + let mut changes = BTreeMap::new(); + for sid in &removed { + let page = index.resolve_active(*sid)?; + let manifest_id = page.roots[&1]; + let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?; + if manifest.jcid != 0x60037 { + return Err(invalid("Choose ordinary pages for removal")); + } + manifest.bytes = properties(&[])?; + let metadata_id = page.roots[&2]; + let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?; + metadata.set(&[(0x88001de9, &[])])?; + changes.insert( + *sid, + RevisionEdit::Update(BTreeMap::from([ + (manifest_id, manifest), + (metadata_id, metadata), + ])), + ); + } + let mut replacements = BTreeMap::new(); + let mut children = Vec::new(); + for spaces in groups { + let head = spaces[0]; + let old_id = series_by_head.get(&head).copied(); + let (id, mut series) = if let Some(id) = old_id { + (id, PropertyObject::from_object(&raw.objects[&id])?) + } else { + let edit = selected + .get(&head) + .ok_or_else(|| invalid("A new series must start at an edited page"))?; + let id = ExGuid { + guid: edit.guid, + n: 1, + }; + if raw.objects.contains_key(&id) { + return Err(invalid("The new page-series identity already exists")); + } + ( + id, + PropertyObject { + jcid: 0x60008, + bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?, + global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])), + }, + ) + }; + children.push(id); + let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces); + if !membership_changed + && !spaces.iter().any(|sid| { + levels[sid] != original_levels[sid] + || copies.get(sid).is_some_and(|id| { + matches!(view.nodes[id].kind, Kind::Metadata { level, .. } + if level.unwrap_or(1) != levels[sid]) + }) + }) + { + continue; + } + let mut metadata_ids = Vec::new(); + for sid in &spaces { + let page = index.resolve_active(*sid)?; + let page_id = page.roots[&2]; + let original = PropertyObject::from_object(&page.objects[&page_id])?; + if old_id.is_none() && *sid == head { + series.copy_property(&original, 0x18001c65)?; + } + if levels[sid] != original_levels[sid] { + let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?; + metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; + changes.insert( + *sid, + RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])), + ); + } + let copy_id = copies + .get(sid) + .copied() + .unwrap_or_else(|| metadata_id(*sid)); + let mut copy = match raw.objects.get(©_id) { + Some(object) => { + if object.jcid != 0x20030 + || (!copies.contains_key(sid) && copy_ids.contains(©_id)) + { + return Err(invalid("Page metadata identities overlap")); + } + PropertyObject::from_object(object)? + } + None => original, + }; + copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; + copy.reference(copy_id)?; + if replacements.insert(copy_id, copy).is_some() { + return Err(invalid("Page metadata identities overlap")); + } + metadata_ids.push(copy_id); + } + if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { + set_references(&mut series, 0x2c001d63, &spaces)?; + set_references(&mut series, 0x24003442, &metadata_ids)?; + if replacements.insert(id, series).is_some() { + return Err(invalid("Page-series and metadata identities overlap")); + } + } + } + if children != section_node.children { + let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?; + set_references(&mut parent, 0x24001c20, &children)?; + replacements.insert(section_id, parent); + } + changes.insert(document.root, RevisionEdit::Update(replacements)); + Ok(changes) + }) +} + +/// An empty top-level page with stable identities and creation time. +/// Retain the intent across retries; an existing page identity rejects duplicate creation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PageCreation { + guid: [u8; 16], + series_guid: [u8; 16], + before: Option, + title: Option, + author: String, + created: u32, +} + +impl PageCreation { + /// Appends a page, or inserts before the first page space of an existing series. + /// `Some("")` creates an empty title field; `None` creates a page without a title node. + /// The page has no body outlines or generated date/time text and does not apply a template. + pub fn new(before: Option, title: Option<&str>, author: &str) -> Result { + let page = Self { + guid: fresh_guid()?, + series_guid: fresh_guid()?, + before, + title: title.map(str::to_owned), + author: author.to_owned(), + created: current_timestamps()?.0, + }; + page.validate()?; + Ok(page) + } + + pub fn space(&self) -> ExGuid { + ExGuid { + guid: self.guid, + n: 1, + } + } + + pub fn object(&self) -> ExGuid { + ExGuid { + guid: self.guid, + n: 12, + } + } + + pub fn title_object(&self) -> Option { + self.title.as_ref().map(|_| ExGuid { + guid: self.guid, + n: 16, + }) + } + + /// Changes the insertion anchor while retaining all identities and creation metadata. + pub fn reposition(&self, before: Option) -> Result { + let mut page = self.clone(); + page.before = before; + page.validate()?; + Ok(page) + } + + fn validate(&self) -> Result<(), Error> { + if self.guid == [0; 16] + || self.series_guid == [0; 16] + || self.guid == self.series_guid + || self.author.contains('\0') + || self.before.is_some_and(|id| id.guid == [0; 16]) + || self + .title + .as_ref() + .is_some_and(|title| title.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) + { + return Err(invalid( + "Use a new page identity, an existing page anchor, and ordinary single-line title text", + )); + } + Ok(()) + } + + pub(crate) fn apply(&self, source: &[u8]) -> Result, Error> { + self.validate()?; + write_revisions(source, |index| { + if index.spaces.contains_key(&self.space()) { + return Err(invalid( + "This page identity already exists; reconcile the original creation", + )); + } + let document = Document::parse(index)?; + document.pages()?; + let view = document.active(document.root)?; + let section_id = view.roots[&1]; + let section_node = &view.nodes[§ion_id]; + if !matches!(section_node.kind, Kind::Section { .. }) + || section_node.extra[0] + .iter() + .any(|field| field.id == 0x88001cde) + { + return Err(invalid("Choose an editable section for the new page")); + } + let position = match self.before { + None => section_node.children.len(), + Some(before) => section_node + .children + .iter() + .position(|id| view.nodes[id].spaces.first() == Some(&before)) + .ok_or_else(|| { + invalid("Insert before the first page of an existing series, or append") + })?, + }; + let raw = index.resolve_active(document.root)?; + let id = |n| ExGuid { guid: self.guid, n }; + let reference = |n: u32| n.to_le_bytes().to_vec(); + let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000) + .to_le_bytes() + .to_vec(); + let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec()); + let mut table = BTreeMap::from([(0, self.guid)]); + let metadata_id = metadata_id(self.space()); + table.insert(1, metadata_id.guid); + let table = Arc::new(table); + let object = |jcid, values: Vec<_>| -> Result<_, Error> { + Ok(PropertyObject { + jcid, + bytes: properties(&values)?, + global_ids: Arc::clone(&table), + }) + }; + let title = self.title.as_deref().unwrap_or_default().trim_start(); + let metadata = vec![ + (0x1c001c30, self.guid.to_vec()), + (0x1c001cf3, string(title)), + (0x14001d82, reference(40)), + (0x1400348b, reference(40)), + (0x14001dff, reference(1)), + (0x18001c65, timestamp.clone()), + ]; + let mut page = vec![ + modified(), + (0x1c001d75, string(&self.author)), + (0x1c001d3c, string("")), + ]; + if self.title.is_some() { + page.push((0x24001d5f, reference(13))); + } + let mut objects = BTreeMap::from([ + (id(10), object(0x60037, vec![(0x24001c1f, reference(12))])?), + (id(11), object(0x20030, metadata.clone())?), + (id(12), object(0x6000b, page)?), + ]); + if let Some(title) = &self.title { + for (n, jcid, values) in [ + ( + 13, + 0x6002c, + vec![ + modified(), + (0x24001c20, reference(14)), + (0x14001c14, 0_f32.to_le_bytes().to_vec()), + (0x14001c15, 0_f32.to_le_bytes().to_vec()), + ], + ), + ( + 14, + 0x6000c, + vec![ + modified(), + (0x24001c20, reference(15)), + (0x0c001c03, vec![1]), + ], + ), + ( + 15, + 0x6000d, + vec![ + modified(), + (0x24001c1f, reference(16)), + (0x0c001c03, vec![1]), + (0x20001d78, reference(17)), + (0x20001d79, reference(17)), + (0x14001d09, self.created.to_le_bytes().to_vec()), + (0x88001cb4, vec![]), + ], + ), + ( + 16, + 0x6000e, + vec![ + modified(), + (0x1c001c22, string(title)), + (0x24001e13, reference(19)), + (0x2000342c, reference(18)), + (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()), + (0x88001cb4, vec![]), + ], + ), + (17, 0x120001, vec![(0x1c001d75, string(&self.author))]), + ( + 18, + 0x12004d, + vec![ + (0x1c00345a, string("PageTitle")), + (0x1c001c0a, string("Calibri")), + (0x10001c0b, 34_u16.to_le_bytes().to_vec()), + ], + ), + (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]), + ] { + objects.insert(id(n), object(jcid, values)?); + } + } + let series = object( + 0x60008, + vec![ + (0x1c001c30, self.series_guid.to_vec()), + (0x18001c65, timestamp), + (0x2c001d63, reference(1)), + (0x24003442, reference(257)), + ], + )?; + if raw.objects.contains_key(&id(2)) || raw.objects.contains_key(&metadata_id) { + return Err(invalid("The new page's section identities already exist")); + } + let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?; + let mut children = section_node.children.clone(); + children.insert(position, id(2)); + let mut references = Vec::new(); + for child in children { + references.extend_from_slice(&parent.reference(child)?); + } + parent.set(&[(0x24001c20, &references)])?; + Ok(BTreeMap::from([ + ( + document.root, + RevisionEdit::Update(BTreeMap::from([ + (section_id, parent), + (id(2), series), + (metadata_id, object(0x20030, metadata)?), + ])), + ), + ( + self.space(), + RevisionEdit::Create { + roots: BTreeMap::from([(1, id(10)), (2, id(11))]), + objects, + }, + ), + ])) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RevisionIndex, Store}; + + #[test] + fn page_movement_restores_an_optional_metadata_cache_from_history() { + let source = + include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let sid = document.pages().unwrap()[3].0; + let view = document.active(document.root).unwrap(); + let series_id = view.nodes[&view.roots[&1]].children[3]; + let copy_id = metadata_id(sid); + for unknown in [false, true] { + let source = if unknown { + write_revisions(source, |index| { + let raw = index.resolve_active(index.root)?; + let mut copy = PropertyObject::from_object(&raw.objects[©_id])?; + copy.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?; + Ok(BTreeMap::from([( + index.root, + RevisionEdit::Update(BTreeMap::from([(copy_id, copy)])), + )])) + }) + .unwrap() + } else { + source.to_vec() + }; + let source = write_revisions(&source, |index| { + let raw = index.resolve_active(index.root)?; + let mut series = PropertyObject::from_object(&raw.objects[&series_id])?; + series.remove(&[0x24003442])?; + Ok(BTreeMap::from([( + index.root, + RevisionEdit::Update(BTreeMap::from([(series_id, series)])), + )])) + }) + .unwrap(); + for changed in [false, true] { + for unrelated_edit in [false, true] { + let result = write_revisions(&source, |index| { + let raw = index.resolve_active(index.root)?; + assert!(!raw.reachable()?.contains(©_id)); + let mut copy = PropertyObject::from_object(&raw.objects[©_id])?; + if changed { + copy.set(&[(0x14001dff, &2_u32.to_le_bytes())])?; + } + let mut objects = BTreeMap::from([(copy_id, copy)]); + if unrelated_edit { + let mut series = PropertyObject::from_object(&raw.objects[&series_id])?; + series.set(&[(0x1400abcd, &88_u32.to_le_bytes())])?; + objects.insert(series_id, series); + } + Ok(BTreeMap::from([( + index.root, + RevisionEdit::Update(objects), + )])) + }); + assert_eq!( + result.unwrap_err().message, + "Edited object is not reachable in the resulting revision" + ); + } + } + let written = + edit_pages(&source, &[PageEdit::set_level(sid, 2).unwrap()], &[]).unwrap(); + let store = Store::parse(&written).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + assert_eq!(Document::parse(&index).unwrap().pages().unwrap().len(), 9); + let current = index + .resolve( + index.root, + index.spaces[&index.root].labels[&(ExGuid::default(), 1)], + ) + .unwrap(); + assert!(current.reachable().unwrap().contains(©_id)); + let copy = PropertyObject::from_object(¤t.objects[©_id]).unwrap(); + let properties = crate::PropertySets::parse(©.bytes).unwrap(); + assert_eq!(unknown, properties.sets[0].iter().any(|field| field.id == 0x1400abcd + && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes()))); + let old_store = Store::parse(&source).unwrap(); + let old_index = RevisionIndex::parse(&old_store).unwrap(); + for (space, revisions) in &old_index.spaces { + for revision in revisions.revisions.keys() { + assert_eq!( + format!("{:?}", old_index.resolve(*space, *revision).unwrap()), + format!("{:?}", index.resolve(*space, *revision).unwrap()) + ); + } + } + if !unknown && let Some(output) = std::env::var_os("ONESTORE_PAGE_MOVEMENT_OUTPUT") { + let output = std::path::Path::new(&output); + assert!(output.is_absolute()); + for (name, bytes) in [ + ("optional-cache-source", source), + ("optional-cache", written), + ] { + let path = output.join(name); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("Lifecycle.one"), bytes).unwrap(); + } + } + } + } + + #[test] + fn page_movement_rejects_empty_manifests_and_series() { + let source = + include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let sid = document.pages().unwrap()[3].0; + for empty_series in [false, true] { + let invalid_source = write_revisions(source, |index| { + let target = if empty_series { index.root } else { sid }; + let raw = index.resolve_active(target)?; + let id = if empty_series { + let view = document.active(document.root).unwrap(); + view.nodes[&view.roots[&1]].children[3] + } else { + raw.roots[&1] + }; + let mut object = PropertyObject::from_object(&raw.objects[&id])?; + set_references( + &mut object, + if empty_series { 0x2c001d63 } else { 0x24001c1f }, + &[], + )?; + Ok(BTreeMap::from([( + target, + RevisionEdit::Update(BTreeMap::from([(id, object)])), + )])) + }) + .unwrap(); + assert!( + edit_pages( + &invalid_source, + &[PageEdit::set_level(sid, 2).unwrap()], + &[] + ) + .is_err() + ); + } + } + + #[test] + fn page_moves_retain_unknown_series_and_metadata_properties() { + let source = + include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let pages = document.pages().unwrap(); + let view = document.active(document.root).unwrap(); + let series_id = view.nodes[&view.roots[&1]].children[3]; + let field = view.nodes[&series_id].extra[0] + .iter() + .find(|f| f.id == 0x24003442) + .unwrap(); + let FieldValue::Objects(copies) = &field.value else { + panic!() + }; + let copy_id = copies[0]; + let source = write_revisions(source, |index| { + let raw = index.resolve_active(index.root)?; + let mut objects = BTreeMap::new(); + for id in [series_id, copy_id] { + let mut object = PropertyObject::from_object(&raw.objects[&id])?; + object.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?; + objects.insert(id, object); + } + Ok(BTreeMap::from([( + index.root, + RevisionEdit::Update(objects), + )])) + }) + .unwrap(); + for edits in [ + vec![PageEdit::set_level(pages[3].0, 2).unwrap()], + pages[3..6] + .iter() + .enumerate() + .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap()) + .collect(), + ] { + let written = edit_pages(&source, &edits, &[]).unwrap(); + let old_store = Store::parse(&source).unwrap(); + let new_store = Store::parse(&written).unwrap(); + let old = RevisionIndex::parse(&old_store).unwrap(); + let new = RevisionIndex::parse(&new_store).unwrap(); + let old_id = old.spaces[&old.root].labels[&(ExGuid::default(), 1)]; + let new_id = new.spaces[&new.root].labels[&(ExGuid::default(), 1)]; + assert_eq!( + format!("{:?}", old.resolve(old.root, old_id).unwrap()), + format!("{:?}", new.resolve(new.root, old_id).unwrap()) + ); + let current = new.resolve(new.root, new_id).unwrap(); + for id in [series_id, copy_id] { + let object = PropertyObject::from_object(¤t.objects[&id]).unwrap(); + let properties = crate::PropertySets::parse(&object.bytes).unwrap(); + assert!(properties.sets[0].iter().any(|field| field.id == 0x1400abcd + && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes()))); + } + } + } +} diff --git a/crates/onestore/src/store.rs b/crates/onestore/src/store.rs index d8cf334be8f076d613f4abd1f4768cd61957343c..bcbdefbe89dfaf4fbabeb3a570005ff68764c62f 100644 --- a/crates/onestore/src/store.rs +++ b/crates/onestore/src/store.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::ops::Range; -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub struct Error { /// Parser byte offset; zero also represents errors without a byte location. pub offset: usize, diff --git a/crates/onestore/tests/page_model.rs b/crates/onestore/tests/page_model.rs new file mode 100644 index 0000000000000000000000000000000000000000..2541b51c07785ad12012d5f874e5182cb66242a6 --- /dev/null +++ b/crates/onestore/tests/page_model.rs @@ -0,0 +1,112 @@ +use onestore::{ + RevisionIndex, Store, + document::Document, + page::{Page, PageObject, ParagraphContent}, +}; +use std::{collections::BTreeSet, fs, path::Path}; + +fn sections(directory: &Path, out: &mut Vec) { + for entry in fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + if !matches!( + path.file_name().and_then(|n| n.to_str()), + Some("private" | "recovered") + ) { + sections(&path, out); + } + } else if path.extension().is_some_and(|e| e == "one") { + out.push(path); + } + } +} + +fn identities(page: &Page) -> Vec { + fn paragraphs(list: &[onestore::page::PageParagraph], ids: &mut Vec) { + for paragraph in list { + ids.push(paragraph.id); + match ¶graph.content { + ParagraphContent::Text(text) => ids.push(text.id), + ParagraphContent::Table(table) => { + ids.push(table.id); + for row in &table.rows { + ids.push(row.id); + for cell in &row.cells { + ids.push(cell.id); + paragraphs(&cell.paragraphs, ids); + ids.extend(cell.unsupported.iter().map(|u| u.id)); + } + } + } + ParagraphContent::Unsupported(unsupported) => ids.push(unsupported.id), + } + } + } + let mut ids = Vec::new(); + for object in &page.objects { + ids.push(object.id()); + match object { + PageObject::Outline(outline) => { + paragraphs(&outline.paragraphs, &mut ids); + ids.extend(outline.unsupported.iter().map(|u| u.id)); + } + PageObject::Title(title) => { + for outline in &title.outlines { + ids.push(outline.id); + paragraphs(&outline.paragraphs, &mut ids); + ids.extend(outline.unsupported.iter().map(|u| u.id)); + } + } + PageObject::Image(_) | PageObject::Unsupported(_) => {} + } + } + ids +} + +#[test] +fn every_corpus_page_builds_a_model_with_distinct_identities() { + let mut files = Vec::new(); + sections( + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")), + &mut files, + ); + files.sort(); + assert!(files.len() > 300, "{} sections", files.len()); + let mut pages = 0; + let mut failures = Vec::new(); + for path in &files { + let bytes = fs::read(path).unwrap(); + let Ok(store) = Store::parse(&bytes) else { + continue; + }; + let Ok(index) = RevisionIndex::parse(&store) else { + continue; + }; + let Ok(document) = Document::parse(&index) else { + continue; + }; + let Ok(listed) = document.pages() else { + continue; + }; + for (space, _) in listed { + pages += 1; + match Page::from_space(&document, space) { + Ok(page) => { + let ids = identities(&page); + let distinct: BTreeSet<_> = ids.iter().collect(); + if distinct.len() != ids.len() { + failures.push(format!("{}: repeated identity", path.display())); + } + } + Err(error) => failures.push(format!("{}: {}", path.display(), error.message)), + } + } + } + assert!(pages > 1000, "{pages} pages"); + assert!( + failures.is_empty(), + "{}\n{} failures", + failures.join("\n"), + failures.len() + ); +}