authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-10 21:08:05-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-10 21:08:05-07:00
loge00506af62d3a184a53b6dec1b40ac6bd6372c3c
tree9b01549bbe63d307a541267c5094aa507f929c1a
parentad10b477304448e0310a90215fdfab07b4dd337f
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: add the editable page model

Move the canvas page model into onestore::page: Page, Title, Outline, PageParagraph, Table, TextObject, Image and Unsupported keep stored identities, resolve inherited formatting, and retain everything else on the page as Unsupported. page::text::Paragraph coalesces rich text into spans with UTF-8/UTF-16 offset mapping, hidden-field projection, slicing and invertible edits. Page::from_space selects the single active page of an object space. The page writer module is renamed pages.rs; its public types are unchanged. Validation: 18 model unit tests ported from the canvas branch; every page in the 396-file public corpus builds a model with distinct identities; Clippy, fmt and doctests. Merge instructions for the UI branch are in evidence/M1-MERGE.md. Assisted-by: claude-fable-5.1

9 files changed, 2828 insertions(+), 888 deletions(-)

crates/onestore/README.md+2
......@@ -43,6 +43,8 @@ harness also accepts `--client-profile release`.
4343| `PropertySets`, `Object::references` | Decode properties and ID streams while retaining raw values |
4444| `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes |
4545| `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities |
46| `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 |
47| `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` |
4648| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
4749| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
4850| `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries |
crates/onestore/src/commit.rs+2-2
......@@ -188,7 +188,7 @@ impl<'a> PreparedEdit<'a> {
188188 pub fn pages(source: &'a [u8], edits: &[crate::PageEdit]) -> Result<Self, crate::Error> {
189189 Ok(Self {
190190 source,
191 written: crate::page::edit_pages(source, edits, &[])?,
191 written: crate::pages::edit_pages(source, edits, &[])?,
192192 })
193193 }
194194
......@@ -201,7 +201,7 @@ impl<'a> PreparedEdit<'a> {
201201 ) -> Result<Self, crate::Error> {
202202 Ok(Self {
203203 source,
204 written: crate::page::edit_pages(source, &[], pages)?,
204 written: crate::pages::edit_pages(source, &[], pages)?,
205205 })
206206 }
207207
crates/onestore/src/lib.rs+3-2
......@@ -12,7 +12,8 @@ mod formatting;
1212mod insertion;
1313mod objects;
1414mod outline;
15mod page;
15pub mod page;
16mod pages;
1617mod paragraph;
1718mod properties;
1819#[cfg(feature = "protected")]
......@@ -36,7 +37,7 @@ pub use formatting::TextAttribute;
3637pub use insertion::Insertion;
3738pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};
3839pub use outline::OutlineEdit;
39pub use page::{PageCreation, PageEdit, PagePosition};
40pub use pages::{PageCreation, PageEdit, PagePosition};
4041pub use paragraph::{ParagraphJoin, ParagraphSplit};
4142pub use properties::{IdStream, Property, PropertySets, Value};
4243pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};
crates/onestore/src/page.rs deleted-883
......@@ -1,883 +0,0 @@
1use crate::{
2 Error, ExGuid,
3 create::{current_timestamps, properties, string},
4 document::{Document, FieldValue, Kind},
5 write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions},
6};
7use serde::{Deserialize, Serialize};
8use std::{
9 collections::{BTreeMap, BTreeSet},
10 sync::Arc,
11};
12
13fn invalid(message: &'static str) -> Error {
14 Error { offset: 0, message }
15}
16
17fn metadata_id(space: ExGuid) -> ExGuid {
18 let mut id = ExGuid {
19 guid: space.guid,
20 n: 1,
21 };
22 for (byte, salt) in id.guid.iter_mut().zip([
23 0x31, 0xc0, 0xa8, 0x22, 0, 0x36, 0xee, 0x42, 0xb7, 0x14, 0xd7, 0xac, 0xda, 0x24, 0x35, 0xe8,
24 ]) {
25 *byte ^= salt;
26 }
27 id
28}
29
30fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[u8; 16], Error> {
31 metadata.extra[0]
32 .iter()
33 .find_map(|field| match field.value {
34 FieldValue::Bytes(bytes) if field.id == 0x1c001c30 => bytes.try_into().ok(),
35 _ => None,
36 })
37 .ok_or_else(|| invalid("Page metadata needs its page identifier"))
38}
39
40fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> {
41 let mut references = Vec::new();
42 for id in ids {
43 references.extend_from_slice(&object.reference(*id)?);
44 }
45 let source = PropertyObject {
46 jcid: object.jcid,
47 bytes: properties(&[(property, references)])?,
48 global_ids: Arc::clone(&object.global_ids),
49 };
50 object.copy_property(&source, property)
51}
52
53/// Whether an edit preserves position or moves before a page (None appends).
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub enum PagePosition {
57 Keep,
58 Before(Option<ExGuid>),
59}
60
61/// A page's explicit position and indentation, with a stable identity for a new series.
62/// Moving a page does not implicitly select its following subpages.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct PageEdit {
66 guid: [u8; 16],
67 space: ExGuid,
68 level: u32,
69 position: PagePosition,
70}
71
72impl PageEdit {
73 /// Changes one page's indentation without changing its position.
74 pub fn set_level(space: ExGuid, level: u32) -> Result<Self, Error> {
75 Self::new(space, level, PagePosition::Keep)
76 }
77
78 /// Moves before an existing page space, or appends when `before` is None.
79 pub fn move_to(space: ExGuid, before: Option<ExGuid>, level: u32) -> Result<Self, Error> {
80 Self::new(space, level, PagePosition::Before(before))
81 }
82
83 pub fn space(&self) -> ExGuid {
84 self.space
85 }
86
87 pub fn level(&self) -> u32 {
88 self.level
89 }
90
91 pub fn position(&self) -> PagePosition {
92 self.position
93 }
94
95 /// Revises placement while retaining the selected page and allocated series identity.
96 pub fn reposition(&self, position: PagePosition, level: u32) -> Result<Self, Error> {
97 let edit = Self {
98 position,
99 level,
100 ..self.clone()
101 };
102 edit.validate()?;
103 Ok(edit)
104 }
105
106 fn new(space: ExGuid, level: u32, position: PagePosition) -> Result<Self, Error> {
107 let edit = Self {
108 guid: fresh_guid()?,
109 space,
110 level,
111 position,
112 };
113 edit.validate()?;
114 Ok(edit)
115 }
116
117 fn validate(&self) -> Result<(), Error> {
118 if self.guid == [0; 16] || self.space.guid == [0; 16] || !(1..=3).contains(&self.level) {
119 return Err(invalid(
120 "Choose an existing page space and indentation level 1 through 3",
121 ));
122 }
123 if let PagePosition::Before(Some(before)) = self.position
124 && (before.guid == [0; 16] || before == self.space)
125 {
126 return Err(invalid("Choose a different page as the movement anchor"));
127 }
128 Ok(())
129 }
130}
131
132pub(crate) fn edit_pages(
133 source: &[u8],
134 edits: &[PageEdit],
135 removals: &[ExGuid],
136) -> Result<Vec<u8>, Error> {
137 if !edits.is_empty() && !removals.is_empty() {
138 return Err(invalid(
139 "Choose page placement or page removal for one operation",
140 ));
141 }
142 let removed: BTreeSet<_> = removals.iter().copied().collect();
143 if removed.len() != removals.len() || removed.iter().any(|sid| sid.guid == [0; 16]) {
144 return Err(invalid("Choose distinct existing page spaces for removal"));
145 }
146 let mut selected = BTreeMap::new();
147 let mut guids = BTreeSet::new();
148 for edit in edits {
149 edit.validate()?;
150 if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) {
151 return Err(invalid(
152 "Use distinct page and edit identities within a batch",
153 ));
154 }
155 }
156 write_revisions(source, |index| {
157 let document = Document::parse(index)?;
158 let pages = document.pages()?;
159 let section = &document.spaces[&document.root];
160 let view = &section.revisions[&section.contexts[&ExGuid::default()]];
161 let section_id = view.roots[&1];
162 let section_node = &view.nodes[&section_id];
163 if !matches!(section_node.kind, Kind::Section { .. })
164 || section_node.extra[0]
165 .iter()
166 .any(|field| field.id == 0x88001cde)
167 {
168 return Err(invalid("Choose an editable section for page editing"));
169 }
170 let mut order = Vec::new();
171 let mut levels = BTreeMap::new();
172 let mut page_guids = BTreeMap::new();
173 for (sid, _) in &pages {
174 let page = &document.spaces[sid];
175 let page = &page.revisions[&page.contexts[&ExGuid::default()]];
176 let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else {
177 return Err(invalid("Page metadata is unavailable"));
178 };
179 let Kind::Metadata { level, .. } = metadata.kind else {
180 return Err(invalid("Choose ordinary pages with page metadata"));
181 };
182 let level = level.unwrap_or(1);
183 if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() {
184 return Err(invalid(
185 "Each page space must contain one page with a valid level",
186 ));
187 }
188 if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() {
189 return Err(invalid("Pages must have distinct metadata identifiers"));
190 }
191 order.push(*sid);
192 }
193 let original_order = order.clone();
194 let original_levels = levels.clone();
195 let mut series_by_head = BTreeMap::new();
196 let mut copies = BTreeMap::new();
197 let mut copy_ids = BTreeSet::new();
198 for oid in &section_node.children {
199 let series = &view.nodes[oid];
200 let Some(head) = series.spaces.first() else {
201 return Err(invalid("A page series must contain at least one page"));
202 };
203 if levels.get(head) != Some(&1)
204 || series.spaces[1..]
205 .iter()
206 .any(|sid| levels.get(sid).is_none_or(|level| *level == 1))
207 {
208 return Err(invalid(
209 "A page series must start with its only top-level page",
210 ));
211 }
212 if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) {
213 series_by_head.insert(*retained, *oid);
214 }
215 if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) {
216 let FieldValue::Objects(ids) = &field.value else {
217 return Err(invalid("Page metadata copies must be object references"));
218 };
219 if ids.len() != series.spaces.len() {
220 return Err(invalid("Page metadata copies must match their series"));
221 }
222 for id in ids {
223 if !copy_ids.insert(*id)
224 || !matches!(
225 view.nodes.get(id).map(|n| &n.kind),
226 Some(Kind::Metadata { .. })
227 )
228 {
229 return Err(invalid("Each page needs its own ordinary metadata copy"));
230 }
231 let sid = page_guids
232 .get(&metadata_guid(&view.nodes[id])?)
233 .filter(|sid| series.spaces.contains(sid))
234 .ok_or_else(|| {
235 invalid("Metadata copy must identify a page in its series")
236 })?;
237 if copies.insert(*sid, *id).is_some() {
238 return Err(invalid("Each page needs its own ordinary metadata copy"));
239 }
240 }
241 }
242 }
243 for sid in &removed {
244 if !levels.contains_key(sid) {
245 return Err(invalid("The selected page is no longer in the section"));
246 }
247 }
248 order.retain(|sid| !removed.contains(sid));
249 if !removed.is_empty()
250 && let Some(first) = order.first()
251 {
252 levels.insert(*first, 1);
253 }
254 for edit in edits {
255 let Some(at) = order.iter().position(|sid| *sid == edit.space) else {
256 return Err(invalid("The selected page is no longer in the section"));
257 };
258 levels.insert(edit.space, edit.level);
259 if let PagePosition::Before(before) = edit.position {
260 order.remove(at);
261 let position = match before {
262 None => order.len(),
263 Some(before) => {
264 order.iter().position(|sid| *sid == before).ok_or_else(|| {
265 invalid("The movement anchor is no longer in the section")
266 })?
267 }
268 };
269 order.insert(position, edit.space);
270 }
271 }
272 if order.first().is_some_and(|sid| levels[sid] != 1) {
273 return Err(invalid(
274 "The section's first page must have indentation level 1",
275 ));
276 }
277 if order == original_order && levels == original_levels {
278 return Ok(BTreeMap::new());
279 }
280 let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?;
281 let mut groups: Vec<Vec<ExGuid>> = Vec::new();
282 for sid in order {
283 if levels[&sid] == 1 {
284 groups.push(Vec::new());
285 }
286 groups.last_mut().unwrap().push(sid);
287 }
288 let mut changes = BTreeMap::new();
289 for sid in &removed {
290 let page = index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?;
291 let manifest_id = page.roots[&1];
292 let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?;
293 if manifest.jcid != 0x60037 {
294 return Err(invalid("Choose ordinary pages for removal"));
295 }
296 manifest.bytes = properties(&[])?;
297 let metadata_id = page.roots[&2];
298 let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?;
299 metadata.set(&[(0x88001de9, &[])])?;
300 changes.insert(
301 *sid,
302 RevisionEdit::Update(BTreeMap::from([
303 (manifest_id, manifest),
304 (metadata_id, metadata),
305 ])),
306 );
307 }
308 let mut replacements = BTreeMap::new();
309 let mut children = Vec::new();
310 for spaces in groups {
311 let head = spaces[0];
312 let old_id = series_by_head.get(&head).copied();
313 let (id, mut series) = if let Some(id) = old_id {
314 (id, PropertyObject::from_object(&raw.objects[&id])?)
315 } else {
316 let edit = selected
317 .get(&head)
318 .ok_or_else(|| invalid("A new series must start at an edited page"))?;
319 let id = ExGuid {
320 guid: edit.guid,
321 n: 1,
322 };
323 if raw.objects.contains_key(&id) {
324 return Err(invalid("The new page-series identity already exists"));
325 }
326 (
327 id,
328 PropertyObject {
329 jcid: 0x60008,
330 bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?,
331 global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])),
332 },
333 )
334 };
335 children.push(id);
336 let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces);
337 if !membership_changed
338 && !spaces.iter().any(|sid| {
339 levels[sid] != original_levels[sid]
340 || copies.get(sid).is_some_and(|id| {
341 matches!(view.nodes[id].kind, Kind::Metadata { level, .. }
342 if level.unwrap_or(1) != levels[sid])
343 })
344 })
345 {
346 continue;
347 }
348 let mut metadata_ids = Vec::new();
349 for sid in &spaces {
350 let page =
351 index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?;
352 let page_id = page.roots[&2];
353 let original = PropertyObject::from_object(&page.objects[&page_id])?;
354 if old_id.is_none() && *sid == head {
355 series.copy_property(&original, 0x18001c65)?;
356 }
357 if levels[sid] != original_levels[sid] {
358 let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?;
359 metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?;
360 changes.insert(
361 *sid,
362 RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])),
363 );
364 }
365 let copy_id = copies
366 .get(sid)
367 .copied()
368 .unwrap_or_else(|| metadata_id(*sid));
369 let mut copy = match raw.objects.get(&copy_id) {
370 Some(object) => {
371 if object.jcid != 0x20030
372 || (!copies.contains_key(sid) && copy_ids.contains(&copy_id))
373 {
374 return Err(invalid("Page metadata identities overlap"));
375 }
376 PropertyObject::from_object(object)?
377 }
378 None => original,
379 };
380 copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?;
381 copy.reference(copy_id)?;
382 if replacements.insert(copy_id, copy).is_some() {
383 return Err(invalid("Page metadata identities overlap"));
384 }
385 metadata_ids.push(copy_id);
386 }
387 if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) {
388 set_references(&mut series, 0x2c001d63, &spaces)?;
389 set_references(&mut series, 0x24003442, &metadata_ids)?;
390 if replacements.insert(id, series).is_some() {
391 return Err(invalid("Page-series and metadata identities overlap"));
392 }
393 }
394 }
395 if children != section_node.children {
396 let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?;
397 set_references(&mut parent, 0x24001c20, &children)?;
398 replacements.insert(section_id, parent);
399 }
400 changes.insert(document.root, RevisionEdit::Update(replacements));
401 Ok(changes)
402 })
403}
404
405/// An empty top-level page with stable identities and creation time.
406/// Retain the intent across retries; an existing page identity rejects duplicate creation.
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct PageCreation {
410 guid: [u8; 16],
411 series_guid: [u8; 16],
412 before: Option<ExGuid>,
413 title: Option<String>,
414 author: String,
415 created: u32,
416}
417
418impl PageCreation {
419 /// Appends a page, or inserts before the first page space of an existing series.
420 /// `Some("")` creates an empty title field; `None` creates a page without a title node.
421 /// The page has no body outlines or generated date/time text and does not apply a template.
422 pub fn new(before: Option<ExGuid>, title: Option<&str>, author: &str) -> Result<Self, Error> {
423 let page = Self {
424 guid: fresh_guid()?,
425 series_guid: fresh_guid()?,
426 before,
427 title: title.map(str::to_owned),
428 author: author.to_owned(),
429 created: current_timestamps()?.0,
430 };
431 page.validate()?;
432 Ok(page)
433 }
434
435 pub fn space(&self) -> ExGuid {
436 ExGuid {
437 guid: self.guid,
438 n: 1,
439 }
440 }
441
442 pub fn object(&self) -> ExGuid {
443 ExGuid {
444 guid: self.guid,
445 n: 12,
446 }
447 }
448
449 pub fn title_object(&self) -> Option<ExGuid> {
450 self.title.as_ref().map(|_| ExGuid {
451 guid: self.guid,
452 n: 16,
453 })
454 }
455
456 /// Changes the insertion anchor while retaining all identities and creation metadata.
457 pub fn reposition(&self, before: Option<ExGuid>) -> Result<Self, Error> {
458 let mut page = self.clone();
459 page.before = before;
460 page.validate()?;
461 Ok(page)
462 }
463
464 fn validate(&self) -> Result<(), Error> {
465 if self.guid == [0; 16]
466 || self.series_guid == [0; 16]
467 || self.guid == self.series_guid
468 || self.author.contains('\0')
469 || self.before.is_some_and(|id| id.guid == [0; 16])
470 || self
471 .title
472 .as_ref()
473 .is_some_and(|title| title.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}']))
474 {
475 return Err(invalid(
476 "Use a new page identity, an existing page anchor, and ordinary single-line title text",
477 ));
478 }
479 Ok(())
480 }
481
482 pub(crate) fn apply(&self, source: &[u8]) -> Result<Vec<u8>, Error> {
483 self.validate()?;
484 write_revisions(source, |index| {
485 if index.spaces.contains_key(&self.space()) {
486 return Err(invalid(
487 "This page identity already exists; reconcile the original creation",
488 ));
489 }
490 let document = Document::parse(index)?;
491 document.pages()?;
492 let section = &document.spaces[&document.root];
493 let view = &section.revisions[&section.contexts[&ExGuid::default()]];
494 let section_id = view.roots[&1];
495 let section_node = &view.nodes[&section_id];
496 if !matches!(section_node.kind, Kind::Section { .. })
497 || section_node.extra[0]
498 .iter()
499 .any(|field| field.id == 0x88001cde)
500 {
501 return Err(invalid("Choose an editable section for the new page"));
502 }
503 let position = match self.before {
504 None => section_node.children.len(),
505 Some(before) => section_node
506 .children
507 .iter()
508 .position(|id| view.nodes[id].spaces.first() == Some(&before))
509 .ok_or_else(|| {
510 invalid("Insert before the first page of an existing series, or append")
511 })?,
512 };
513 let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?;
514 let id = |n| ExGuid { guid: self.guid, n };
515 let reference = |n: u32| n.to_le_bytes().to_vec();
516 let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000)
517 .to_le_bytes()
518 .to_vec();
519 let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec());
520 let mut table = BTreeMap::from([(0, self.guid)]);
521 let metadata_id = metadata_id(self.space());
522 table.insert(1, metadata_id.guid);
523 let table = Arc::new(table);
524 let object = |jcid, values: Vec<_>| -> Result<_, Error> {
525 Ok(PropertyObject {
526 jcid,
527 bytes: properties(&values)?,
528 global_ids: Arc::clone(&table),
529 })
530 };
531 let title = self.title.as_deref().unwrap_or_default().trim_start();
532 let metadata = vec![
533 (0x1c001c30, self.guid.to_vec()),
534 (0x1c001cf3, string(title)),
535 (0x14001d82, reference(40)),
536 (0x1400348b, reference(40)),
537 (0x14001dff, reference(1)),
538 (0x18001c65, timestamp.clone()),
539 ];
540 let mut page = vec![
541 modified(),
542 (0x1c001d75, string(&self.author)),
543 (0x1c001d3c, string("")),
544 ];
545 if self.title.is_some() {
546 page.push((0x24001d5f, reference(13)));
547 }
548 let mut objects = BTreeMap::from([
549 (id(10), object(0x60037, vec![(0x24001c1f, reference(12))])?),
550 (id(11), object(0x20030, metadata.clone())?),
551 (id(12), object(0x6000b, page)?),
552 ]);
553 if let Some(title) = &self.title {
554 for (n, jcid, values) in [
555 (
556 13,
557 0x6002c,
558 vec![
559 modified(),
560 (0x24001c20, reference(14)),
561 (0x14001c14, 0_f32.to_le_bytes().to_vec()),
562 (0x14001c15, 0_f32.to_le_bytes().to_vec()),
563 ],
564 ),
565 (
566 14,
567 0x6000c,
568 vec![
569 modified(),
570 (0x24001c20, reference(15)),
571 (0x0c001c03, vec![1]),
572 ],
573 ),
574 (
575 15,
576 0x6000d,
577 vec![
578 modified(),
579 (0x24001c1f, reference(16)),
580 (0x0c001c03, vec![1]),
581 (0x20001d78, reference(17)),
582 (0x20001d79, reference(17)),
583 (0x14001d09, self.created.to_le_bytes().to_vec()),
584 (0x88001cb4, vec![]),
585 ],
586 ),
587 (
588 16,
589 0x6000e,
590 vec![
591 modified(),
592 (0x1c001c22, string(title)),
593 (0x24001e13, reference(19)),
594 (0x2000342c, reference(18)),
595 (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()),
596 (0x88001cb4, vec![]),
597 ],
598 ),
599 (17, 0x120001, vec![(0x1c001d75, string(&self.author))]),
600 (
601 18,
602 0x12004d,
603 vec![
604 (0x1c00345a, string("PageTitle")),
605 (0x1c001c0a, string("Calibri")),
606 (0x10001c0b, 34_u16.to_le_bytes().to_vec()),
607 ],
608 ),
609 (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]),
610 ] {
611 objects.insert(id(n), object(jcid, values)?);
612 }
613 }
614 let series = object(
615 0x60008,
616 vec![
617 (0x1c001c30, self.series_guid.to_vec()),
618 (0x18001c65, timestamp),
619 (0x2c001d63, reference(1)),
620 (0x24003442, reference(257)),
621 ],
622 )?;
623 if raw.objects.contains_key(&id(2)) || raw.objects.contains_key(&metadata_id) {
624 return Err(invalid("The new page's section identities already exist"));
625 }
626 let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?;
627 let mut children = section_node.children.clone();
628 children.insert(position, id(2));
629 let mut references = Vec::new();
630 for child in children {
631 references.extend_from_slice(&parent.reference(child)?);
632 }
633 parent.set(&[(0x24001c20, &references)])?;
634 Ok(BTreeMap::from([
635 (
636 document.root,
637 RevisionEdit::Update(BTreeMap::from([
638 (section_id, parent),
639 (id(2), series),
640 (metadata_id, object(0x20030, metadata)?),
641 ])),
642 ),
643 (
644 self.space(),
645 RevisionEdit::Create {
646 roots: BTreeMap::from([(1, id(10)), (2, id(11))]),
647 objects,
648 },
649 ),
650 ]))
651 })
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use crate::{RevisionIndex, Store};
659
660 #[test]
661 fn page_movement_restores_an_optional_metadata_cache_from_history() {
662 let source =
663 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
664 let store = Store::parse(source).unwrap();
665 let index = RevisionIndex::parse(&store).unwrap();
666 let document = Document::parse(&index).unwrap();
667 let sid = document.pages().unwrap()[3].0;
668 let section = &document.spaces[&document.root];
669 let view = &section.revisions[&section.contexts[&ExGuid::default()]];
670 let series_id = view.nodes[&view.roots[&1]].children[3];
671 let copy_id = metadata_id(sid);
672 for unknown in [false, true] {
673 let source = if unknown {
674 write_revisions(source, |index| {
675 let raw = index.resolve(
676 index.root,
677 index.spaces[&index.root].labels[&(ExGuid::default(), 1)],
678 )?;
679 let mut copy = PropertyObject::from_object(&raw.objects[&copy_id])?;
680 copy.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?;
681 Ok(BTreeMap::from([(
682 index.root,
683 RevisionEdit::Update(BTreeMap::from([(copy_id, copy)])),
684 )]))
685 })
686 .unwrap()
687 } else {
688 source.to_vec()
689 };
690 let source = write_revisions(&source, |index| {
691 let raw = index.resolve(
692 index.root,
693 index.spaces[&index.root].labels[&(ExGuid::default(), 1)],
694 )?;
695 let mut series = PropertyObject::from_object(&raw.objects[&series_id])?;
696 series.remove(&[0x24003442])?;
697 Ok(BTreeMap::from([(
698 index.root,
699 RevisionEdit::Update(BTreeMap::from([(series_id, series)])),
700 )]))
701 })
702 .unwrap();
703 for changed in [false, true] {
704 for unrelated_edit in [false, true] {
705 let result = write_revisions(&source, |index| {
706 let raw = index.resolve(
707 index.root,
708 index.spaces[&index.root].labels[&(ExGuid::default(), 1)],
709 )?;
710 assert!(!raw.reachable()?.contains(&copy_id));
711 let mut copy = PropertyObject::from_object(&raw.objects[&copy_id])?;
712 if changed {
713 copy.set(&[(0x14001dff, &2_u32.to_le_bytes())])?;
714 }
715 let mut objects = BTreeMap::from([(copy_id, copy)]);
716 if unrelated_edit {
717 let mut series = PropertyObject::from_object(&raw.objects[&series_id])?;
718 series.set(&[(0x1400abcd, &88_u32.to_le_bytes())])?;
719 objects.insert(series_id, series);
720 }
721 Ok(BTreeMap::from([(
722 index.root,
723 RevisionEdit::Update(objects),
724 )]))
725 });
726 assert_eq!(
727 result.unwrap_err().message,
728 "Edited object is not reachable in the resulting revision"
729 );
730 }
731 }
732 let written =
733 edit_pages(&source, &[PageEdit::set_level(sid, 2).unwrap()], &[]).unwrap();
734 let store = Store::parse(&written).unwrap();
735 let index = RevisionIndex::parse(&store).unwrap();
736 index.validate_current().unwrap();
737 assert_eq!(Document::parse(&index).unwrap().pages().unwrap().len(), 9);
738 let current = index
739 .resolve(
740 index.root,
741 index.spaces[&index.root].labels[&(ExGuid::default(), 1)],
742 )
743 .unwrap();
744 assert!(current.reachable().unwrap().contains(&copy_id));
745 let copy = PropertyObject::from_object(&current.objects[&copy_id]).unwrap();
746 let properties = crate::PropertySets::parse(&copy.bytes).unwrap();
747 assert_eq!(unknown, properties.sets[0].iter().any(|field| field.id == 0x1400abcd
748 && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes())));
749 let old_store = Store::parse(&source).unwrap();
750 let old_index = RevisionIndex::parse(&old_store).unwrap();
751 for (space, revisions) in &old_index.spaces {
752 for revision in revisions.revisions.keys() {
753 assert_eq!(
754 format!("{:?}", old_index.resolve(*space, *revision).unwrap()),
755 format!("{:?}", index.resolve(*space, *revision).unwrap())
756 );
757 }
758 }
759 if !unknown && let Some(output) = std::env::var_os("ONESTORE_PAGE_MOVEMENT_OUTPUT") {
760 let output = std::path::Path::new(&output);
761 assert!(output.is_absolute());
762 for (name, bytes) in [
763 ("optional-cache-source", source),
764 ("optional-cache", written),
765 ] {
766 let path = output.join(name);
767 std::fs::create_dir_all(&path).unwrap();
768 std::fs::write(path.join("Lifecycle.one"), bytes).unwrap();
769 }
770 }
771 }
772 }
773
774 #[test]
775 fn page_movement_rejects_empty_manifests_and_series() {
776 let source =
777 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
778 let store = Store::parse(source).unwrap();
779 let index = RevisionIndex::parse(&store).unwrap();
780 let document = Document::parse(&index).unwrap();
781 let sid = document.pages().unwrap()[3].0;
782 for empty_series in [false, true] {
783 let invalid_source = write_revisions(source, |index| {
784 let target = if empty_series { index.root } else { sid };
785 let raw = index.resolve(
786 target,
787 index.spaces[&target].labels[&(ExGuid::default(), 1)],
788 )?;
789 let id = if empty_series {
790 let space = &document.spaces[&document.root];
791 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
792 view.nodes[&view.roots[&1]].children[3]
793 } else {
794 raw.roots[&1]
795 };
796 let mut object = PropertyObject::from_object(&raw.objects[&id])?;
797 set_references(
798 &mut object,
799 if empty_series { 0x2c001d63 } else { 0x24001c1f },
800 &[],
801 )?;
802 Ok(BTreeMap::from([(
803 target,
804 RevisionEdit::Update(BTreeMap::from([(id, object)])),
805 )]))
806 })
807 .unwrap();
808 assert!(
809 edit_pages(
810 &invalid_source,
811 &[PageEdit::set_level(sid, 2).unwrap()],
812 &[]
813 )
814 .is_err()
815 );
816 }
817 }
818
819 #[test]
820 fn page_moves_retain_unknown_series_and_metadata_properties() {
821 let source =
822 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
823 let store = Store::parse(source).unwrap();
824 let index = RevisionIndex::parse(&store).unwrap();
825 let document = Document::parse(&index).unwrap();
826 let pages = document.pages().unwrap();
827 let section = &document.spaces[&document.root];
828 let view = &section.revisions[&section.contexts[&ExGuid::default()]];
829 let series_id = view.nodes[&view.roots[&1]].children[3];
830 let field = view.nodes[&series_id].extra[0]
831 .iter()
832 .find(|f| f.id == 0x24003442)
833 .unwrap();
834 let FieldValue::Objects(copies) = &field.value else {
835 panic!()
836 };
837 let copy_id = copies[0];
838 let source = write_revisions(source, |index| {
839 let raw = index.resolve(
840 index.root,
841 index.spaces[&index.root].labels[&(ExGuid::default(), 1)],
842 )?;
843 let mut objects = BTreeMap::new();
844 for id in [series_id, copy_id] {
845 let mut object = PropertyObject::from_object(&raw.objects[&id])?;
846 object.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?;
847 objects.insert(id, object);
848 }
849 Ok(BTreeMap::from([(
850 index.root,
851 RevisionEdit::Update(objects),
852 )]))
853 })
854 .unwrap();
855 for edits in [
856 vec![PageEdit::set_level(pages[3].0, 2).unwrap()],
857 pages[3..6]
858 .iter()
859 .enumerate()
860 .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap())
861 .collect(),
862 ] {
863 let written = edit_pages(&source, &edits, &[]).unwrap();
864 let old_store = Store::parse(&source).unwrap();
865 let new_store = Store::parse(&written).unwrap();
866 let old = RevisionIndex::parse(&old_store).unwrap();
867 let new = RevisionIndex::parse(&new_store).unwrap();
868 let old_id = old.spaces[&old.root].labels[&(ExGuid::default(), 1)];
869 let new_id = new.spaces[&new.root].labels[&(ExGuid::default(), 1)];
870 assert_eq!(
871 format!("{:?}", old.resolve(old.root, old_id).unwrap()),
872 format!("{:?}", new.resolve(new.root, old_id).unwrap())
873 );
874 let current = new.resolve(new.root, new_id).unwrap();
875 for id in [series_id, copy_id] {
876 let object = PropertyObject::from_object(&current.objects[&id]).unwrap();
877 let properties = crate::PropertySets::parse(&object.bytes).unwrap();
878 assert!(properties.sets[0].iter().any(|field| field.id == 0x1400abcd
879 && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes())));
880 }
881 }
882 }
883}
crates/onestore/src/page/mod.rs created+1276
......@@ -0,0 +1,1276 @@
1//! An editable page model: outlines, paragraphs, tables and images with their stored
2//! identities, plus everything else on the page retained as `Unsupported`.
3
4use crate::{
5 Error, ExGuid,
6 document::{Document, Element, FieldValue, Format, Kind, Layout, Revision, Tag},
7};
8use std::{
9 collections::{BTreeMap, BTreeSet},
10 sync::Arc,
11};
12
13pub mod text;
14pub use text::Paragraph;
15
16/// The role of a title-outline paragraph that displays the page's creation date or time.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum DateField {
19 Date = 0,
20 Time = 1,
21}
22
23pub struct Page {
24 pub title: String,
25 /// FILETIME ticks from the page's TopologyCreationTimeStamp.
26 pub created: Option<u64>,
27 pub margin_origin: [f32; 2],
28 pub objects: Vec<PageObject>,
29 pub definitions: BTreeMap<ExGuid, Definition>,
30}
31
32pub struct Definition {
33 pub kind: Kind<'static>,
34 pub format: Format,
35}
36
37pub enum PageObject {
38 Outline(Outline),
39 Title(Title),
40 Image(Image),
41 Unsupported(Unsupported),
42}
43
44impl PageObject {
45 pub fn id(&self) -> ExGuid {
46 match self {
47 Self::Outline(value) => value.id,
48 Self::Title(value) => value.id,
49 Self::Image(value) => value.id,
50 Self::Unsupported(value) => value.id,
51 }
52 }
53 pub fn layout(&self) -> &Layout {
54 match self {
55 Self::Outline(value) => &value.layout,
56 Self::Title(value) => &value.layout,
57 Self::Image(value) => &value.layout,
58 Self::Unsupported(value) => &value.layout,
59 }
60 }
61
62 pub fn layout_mut(&mut self) -> &mut Layout {
63 match self {
64 Self::Outline(value) => &mut value.layout,
65 Self::Title(value) => &mut value.layout,
66 Self::Image(value) => &mut value.layout,
67 Self::Unsupported(value) => &mut value.layout,
68 }
69 }
70}
71
72pub struct Title {
73 pub id: ExGuid,
74 pub date: Option<ExGuid>,
75 pub layout: Layout,
76 pub outlines: Vec<Outline>,
77}
78
79#[derive(Clone)]
80pub struct Outline {
81 pub id: ExGuid,
82 pub title: bool,
83 pub min_width: Option<f32>,
84 pub layout: Layout,
85 pub indents: Vec<f32>,
86 pub paragraphs: Vec<PageParagraph>,
87 pub unsupported: Vec<Unsupported>,
88}
89
90#[derive(Clone, Debug, PartialEq)]
91pub struct PageParagraph {
92 pub id: ExGuid,
93 pub parent: Option<ExGuid>,
94 pub level: u32,
95 pub style: Option<ExGuid>,
96 pub format: Format,
97 pub content: ParagraphContent,
98 pub lists: Vec<ExGuid>,
99 pub tags: Vec<Tag>,
100 pub collapsed: bool,
101}
102
103#[derive(Clone, Debug, PartialEq)]
104pub enum ParagraphContent {
105 Text(TextObject),
106 Table(Table),
107 Unsupported(Unsupported),
108}
109
110#[derive(Clone, Debug, PartialEq)]
111pub struct Table {
112 pub id: ExGuid,
113 pub columns: Vec<TableColumn>,
114 pub rows: Vec<TableRow>,
115 pub borders: Option<bool>,
116 pub layout: Layout,
117 pub tags: Vec<Tag>,
118}
119
120#[derive(Clone, Debug, PartialEq)]
121pub struct TableColumn {
122 pub width: f32,
123 pub locked: bool,
124}
125
126#[derive(Clone, Debug, PartialEq)]
127pub struct TableRow {
128 pub id: ExGuid,
129 pub cells: Vec<TableCell>,
130}
131
132#[derive(Clone, Debug, PartialEq)]
133pub struct TableCell {
134 pub id: ExGuid,
135 pub layout: Layout,
136 pub indents: Vec<f32>,
137 pub shading: Option<u32>,
138 pub paragraphs: Vec<PageParagraph>,
139 pub unsupported: Vec<Unsupported>,
140}
141
142#[derive(Clone, Debug, PartialEq)]
143pub struct TextObject {
144 pub id: ExGuid,
145 pub date_field: Option<DateField>,
146 pub text: Paragraph,
147 pub tags: Vec<Tag>,
148}
149
150pub struct Image {
151 pub id: ExGuid,
152 pub layout: Layout,
153 pub bytes: Option<Arc<[u8]>>,
154 pub alt: Option<String>,
155 pub background: bool,
156}
157
158#[derive(Clone, Debug, PartialEq)]
159pub struct Unsupported {
160 pub id: ExGuid,
161 pub jcid: u32,
162 pub layout: Layout,
163}
164
165impl Page {
166 pub fn from_document(document: &Document<'_>, title: &str) -> Result<Self, Error> {
167 let mut selected = None;
168 for (space, id) in document.pages()? {
169 let revision = document.active(space)?;
170 if page_title(revision, id) == Some(title) {
171 if selected.is_some() {
172 return Err(Error {
173 offset: 0,
174 message: "More than one active page has the requested title",
175 });
176 }
177 selected = Some((revision, id));
178 }
179 }
180 let (revision, id) = selected.ok_or(Error {
181 offset: 0,
182 message: "No active page has the requested title",
183 })?;
184 Self::from_revision(revision, id)
185 }
186
187 /// The single active page declared in one page object space.
188 pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result<Self, Error> {
189 let pages = document.pages_in(space)?;
190 let [id] = pages.as_slice() else {
191 return Err(Error {
192 offset: 0,
193 message: "Choose an object space containing one active page",
194 });
195 };
196 Self::from_revision(document.active(space)?, *id)
197 }
198
199 pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result<Self, Error> {
200 let invalid = |message| Error { offset: 0, message };
201 let root = revision
202 .nodes
203 .get(&id)
204 .ok_or_else(|| invalid("Missing canvas page"))?;
205 let Kind::Page {
206 margin_origin_x,
207 margin_origin_y,
208 ..
209 } = &root.kind
210 else {
211 return Err(invalid("Canvas root is not a page"));
212 };
213 let mut page = Self {
214 title: page_title(revision, id).unwrap_or_default().to_owned(),
215 created: revision
216 .roots
217 .get(&2)
218 .and_then(|id| revision.nodes.get(id))
219 .filter(|node| matches!(node.kind, Kind::Metadata { .. }))
220 .and_then(|node| node.extra.first())
221 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))
222 .map(|field| {
223 let FieldValue::Bytes(bytes) = field.value else {
224 return Err(invalid("The page creation date is damaged"));
225 };
226 Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| {
227 invalid("The page creation date is damaged")
228 })?))
229 })
230 .transpose()?,
231 margin_origin: [
232 margin_origin_x.unwrap_or(0.0),
233 margin_origin_y.unwrap_or(0.0),
234 ],
235 objects: Vec::new(),
236 definitions: BTreeMap::new(),
237 };
238 let mut roots: Vec<_> = root
239 .children
240 .iter()
241 .chain(&root.structure)
242 .rev()
243 .map(|id| (*id, None))
244 .collect();
245 let mut seen = BTreeSet::new();
246 while let Some((id, title_index)) = roots.pop() {
247 if !seen.insert(id) {
248 return Err(invalid("Repeated canvas page object"));
249 }
250 let node = revision
251 .nodes
252 .get(&id)
253 .ok_or_else(|| invalid("Missing canvas page object"))?;
254 if title_index.is_some() && !matches!(node.kind, Kind::Outline { .. }) {
255 return Err(invalid("Canvas title child is not an outline"));
256 }
257 match &node.kind {
258 Kind::Title => {
259 let index = page.objects.len();
260 page.objects.push(PageObject::Title(Title {
261 id,
262 date: None,
263 layout: node.layout.clone(),
264 outlines: Vec::new(),
265 }));
266 roots.extend(node.children.iter().rev().map(|id| (*id, Some(index))));
267 }
268 Kind::Outline { indents } => {
269 let fields = node.extra.first().map(Vec::as_slice).unwrap_or_default();
270 if let Some(index) = title_index
271 && fields.iter().any(|field| {
272 field.id == 0x88001cb5 && matches!(field.value, FieldValue::NoData)
273 })
274 {
275 let PageObject::Title(title) = &mut page.objects[index] else {
276 unreachable!()
277 };
278 if title.date.replace(id).is_some() {
279 return Err(invalid("The page has more than one date field"));
280 }
281 }
282 let min_width = fields
283 .iter()
284 .find(|field| field.id == 0x14001cec)
285 .map(|field| {
286 let FieldValue::Bytes(bytes) = field.value else {
287 return Err(invalid("Invalid canvas minimum outline width"));
288 };
289 let width =
290 f32::from_le_bytes(bytes.try_into().map_err(|_| {
291 invalid("Invalid canvas minimum outline width")
292 })?) * 36.0;
293 if !width.is_finite() || width <= 0.0 {
294 return Err(invalid("Invalid canvas minimum outline width"));
295 }
296 Ok(width)
297 })
298 .transpose()?;
299 let mut outline = Outline {
300 id,
301 title: fields.iter().any(|field| {
302 field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData)
303 }),
304 min_width,
305 layout: node.layout.clone(),
306 indents: indents.clone(),
307 paragraphs: Vec::new(),
308 unsupported: Vec::new(),
309 };
310 (outline.paragraphs, outline.unsupported) = read_paragraphs(
311 revision,
312 node,
313 &node.format,
314 &mut page.definitions,
315 &mut seen,
316 0,
317 )?;
318 if let Some(index) = title_index {
319 let PageObject::Title(title) = &mut page.objects[index] else {
320 unreachable!()
321 };
322 title.outlines.push(outline);
323 } else {
324 page.objects.push(PageObject::Outline(outline));
325 }
326 }
327 Kind::Image {
328 container,
329 alt,
330 background,
331 ..
332 } => {
333 let bytes = if let Some(container) = container {
334 let data = revision
335 .nodes
336 .get(container)
337 .ok_or_else(|| invalid("Missing canvas image data"))?;
338 match &data.kind {
339 Kind::File { payload, .. } => payload.map(Arc::from),
340 _ => return Err(invalid("Canvas image data has the wrong type")),
341 }
342 } else {
343 None
344 };
345 page.objects.push(PageObject::Image(Image {
346 id,
347 layout: node.layout.clone(),
348 bytes,
349 alt: alt.clone(),
350 background: background.unwrap_or(false),
351 }));
352 }
353 _ => page.objects.push(PageObject::Unsupported(Unsupported {
354 id,
355 jcid: node.jcid,
356 layout: node.layout.clone(),
357 })),
358 }
359 }
360 Ok(page)
361 }
362}
363
364fn read_paragraphs(
365 revision: &Revision<'_>,
366 container: &Element<'_>,
367 inherited: &Format,
368 definitions: &mut BTreeMap<ExGuid, Definition>,
369 seen: &mut BTreeSet<ExGuid>,
370 depth: usize,
371) -> Result<(Vec<PageParagraph>, Vec<Unsupported>), Error> {
372 let invalid = |message| Error { offset: 0, message };
373 let default_format = Format::default();
374 let style = |id: Option<ExGuid>| -> Result<&Format, Error> {
375 let Some(id) = id else {
376 return Ok(&default_format);
377 };
378 let node = revision
379 .nodes
380 .get(&id)
381 .ok_or_else(|| invalid("Missing canvas paragraph style"))?;
382 if !matches!(node.kind, Kind::Style { .. }) {
383 return Err(invalid("Canvas paragraph style has the wrong type"));
384 }
385 Ok(&node.format)
386 };
387
388 let mut paragraphs = Vec::new();
389 let mut unsupported = Vec::new();
390 let mut pending: Vec<_> = container
391 .children
392 .iter()
393 .rev()
394 .map(|id| {
395 (
396 *id,
397 None,
398 u32::from(container.child_level.unwrap_or(0)),
399 inherited.clone(),
400 )
401 })
402 .collect();
403 while let Some((id, parent, level, inherited)) = pending.pop() {
404 if !seen.insert(id) {
405 return Err(invalid("Repeated canvas outline object"));
406 }
407 let node = revision
408 .nodes
409 .get(&id)
410 .ok_or_else(|| invalid("Missing canvas outline object"))?;
411 let next_level = level
412 .checked_add(u32::from(node.child_level.unwrap_or(0)))
413 .ok_or_else(|| invalid("Canvas outline level overflow"))?;
414 match &node.kind {
415 Kind::Paragraph {
416 lists,
417 paragraph_style,
418 collapse_state,
419 } => {
420 let format = node
421 .format
422 .inherit(style(*paragraph_style)?)
423 .inherit(&inherited);
424 let mut base_style = *paragraph_style;
425 let [content_id] = node.content.as_slice() else {
426 return Err(invalid("A paragraph must contain one content object"));
427 };
428 if !seen.insert(*content_id) {
429 return Err(invalid("Repeated canvas paragraph content"));
430 }
431 let content = revision
432 .nodes
433 .get(content_id)
434 .ok_or_else(|| invalid("Missing canvas paragraph content"))?;
435 let content = if let Kind::RichText {
436 paragraph_style, ..
437 } = &content.kind
438 {
439 base_style = paragraph_style.or(base_style);
440 let runs = revision.text_runs(*content_id)?;
441 let text_content = if runs.is_empty() {
442 Paragraph::new(
443 String::new(),
444 content
445 .format
446 .inherit(style(*paragraph_style)?)
447 .inherit(&format),
448 )
449 } else {
450 Paragraph::from_runs(
451 runs.into_iter()
452 .map(|run| (run.text.to_owned(), run.format.inherit(&format))),
453 )
454 };
455 ParagraphContent::Text(TextObject {
456 id: *content_id,
457 date_field: content
458 .extra
459 .first()
460 .map(Vec::as_slice)
461 .unwrap_or_default()
462 .iter()
463 .filter(|field| matches!(field.value, FieldValue::NoData))
464 .filter_map(|field| match field.id {
465 0x88001cb5 => Some(DateField::Date),
466 0x88001c87 => Some(DateField::Time),
467 _ => None,
468 })
469 .try_fold(None, |previous, field| {
470 if previous.is_some() {
471 Err(invalid("The page date field has conflicting roles"))
472 } else {
473 Ok(Some(field))
474 }
475 })?,
476 text: text_content,
477 tags: content.tags.clone(),
478 })
479 } else if matches!(content.kind, Kind::Table { .. }) {
480 ParagraphContent::Table(read_table(
481 revision,
482 *content_id,
483 &content.format.inherit(&format),
484 definitions,
485 seen,
486 depth + 1,
487 )?)
488 } else {
489 ParagraphContent::Unsupported(Unsupported {
490 id: *content_id,
491 jcid: content.jcid,
492 layout: content.layout.clone(),
493 })
494 };
495 for (id, is_list) in lists.iter().map(|id| (id, true)).chain(
496 node.tags
497 .iter()
498 .chain(match &content {
499 ParagraphContent::Text(text) => text.tags.as_slice(),
500 ParagraphContent::Table(table) => table.tags.as_slice(),
501 ParagraphContent::Unsupported(_) => &[],
502 })
503 .filter_map(|tag| tag.definition.as_ref())
504 .map(|id| (id, false)),
505 ) {
506 let definition = revision
507 .nodes
508 .get(id)
509 .ok_or_else(|| invalid("Missing canvas list or tag definition"))?;
510 let kind = match &definition.kind {
511 Kind::List {
512 font,
513 format,
514 restart,
515 bullet,
516 } if is_list => Kind::List {
517 font: font.clone(),
518 format: format.clone(),
519 restart: *restart,
520 bullet: *bullet,
521 },
522 Kind::TagDefinition {
523 label,
524 action_type,
525 shape,
526 color,
527 highlight,
528 } if !is_list => Kind::TagDefinition {
529 label: label.clone(),
530 action_type: *action_type,
531 shape: *shape,
532 color: *color,
533 highlight: *highlight,
534 },
535 _ => {
536 return Err(invalid(
537 "Canvas list or tag definition has the wrong type",
538 ));
539 }
540 };
541 definitions.insert(
542 *id,
543 Definition {
544 kind,
545 format: definition.format.clone(),
546 },
547 );
548 }
549 if let Some(id) = base_style {
550 let definition = revision
551 .nodes
552 .get(&id)
553 .ok_or_else(|| invalid("Missing canvas paragraph style"))?;
554 let Kind::Style { name } = &definition.kind else {
555 return Err(invalid("Canvas paragraph style has the wrong type"));
556 };
557 definitions.entry(id).or_insert_with(|| Definition {
558 kind: Kind::Style { name: name.clone() },
559 format: definition.format.clone(),
560 });
561 }
562 paragraphs.push(PageParagraph {
563 id,
564 parent,
565 level,
566 style: base_style,
567 format: format.clone(),
568 content,
569 lists: lists.clone(),
570 tags: node.tags.clone(),
571 collapsed: *collapse_state == Some(1),
572 });
573 pending.extend(
574 node.children
575 .iter()
576 .rev()
577 .map(|child| (*child, Some(id), next_level, format.clone())),
578 );
579 }
580 Kind::OutlineGroup => pending.extend(
581 node.children
582 .iter()
583 .rev()
584 .map(|child| (*child, parent, next_level, node.format.inherit(&inherited))),
585 ),
586 _ => unsupported.push(Unsupported {
587 id,
588 jcid: node.jcid,
589 layout: node.layout.clone(),
590 }),
591 }
592 }
593 Ok((paragraphs, unsupported))
594}
595
596fn read_table(
597 revision: &Revision<'_>,
598 id: ExGuid,
599 inherited: &Format,
600 definitions: &mut BTreeMap<ExGuid, Definition>,
601 seen: &mut BTreeSet<ExGuid>,
602 depth: usize,
603) -> Result<Table, Error> {
604 let invalid = |message| Error { offset: 0, message };
605 if depth > 64 {
606 return Err(invalid("Tables are nested too deeply"));
607 }
608 let node = &revision.nodes[&id];
609 let Kind::Table {
610 rows,
611 columns,
612 widths,
613 locked,
614 borders,
615 } = &node.kind
616 else {
617 unreachable!()
618 };
619 if rows.map(|n| n as usize) != Some(node.children.len())
620 || node.children.is_empty()
621 || columns.map(|n| n as usize) != Some(widths.len())
622 || widths.is_empty()
623 || (!locked.is_empty() && locked.len() != widths.len())
624 || widths.iter().any(|w| !w.is_finite() || *w < 36.0)
625 {
626 return Err(invalid("Table dimensions are inconsistent"));
627 }
628 let mut table_rows = Vec::new();
629 for row_id in &node.children {
630 if !seen.insert(*row_id) {
631 return Err(invalid("Repeated canvas table row"));
632 }
633 let row = revision
634 .nodes
635 .get(row_id)
636 .ok_or_else(|| invalid("Missing canvas table row"))?;
637 if !matches!(row.kind, Kind::Row) || row.children.len() != widths.len() {
638 return Err(invalid("Table row dimensions are inconsistent"));
639 }
640 let mut cells = Vec::new();
641 for cell_id in &row.children {
642 if !seen.insert(*cell_id) {
643 return Err(invalid("Repeated canvas table cell"));
644 }
645 let cell = revision
646 .nodes
647 .get(cell_id)
648 .ok_or_else(|| invalid("Missing canvas table cell"))?;
649 if cell.children.is_empty() {
650 return Err(invalid("A table cell has no paragraphs"));
651 }
652 let Kind::Cell { shading, indents } = &cell.kind else {
653 return Err(invalid("A table row contains an invalid cell"));
654 };
655 let (paragraphs, unsupported) = read_paragraphs(
656 revision,
657 cell,
658 &cell.format.inherit(inherited),
659 definitions,
660 seen,
661 depth,
662 )?;
663 cells.push(TableCell {
664 id: *cell_id,
665 layout: cell.layout.clone(),
666 indents: indents.clone(),
667 shading: *shading,
668 paragraphs,
669 unsupported,
670 });
671 }
672 table_rows.push(TableRow { id: *row_id, cells });
673 }
674 Ok(Table {
675 id,
676 columns: widths
677 .iter()
678 .enumerate()
679 .map(|(index, &width)| TableColumn {
680 width,
681 locked: locked.get(index).copied().unwrap_or(false),
682 })
683 .collect(),
684 rows: table_rows,
685 borders: *borders,
686 layout: node.layout.clone(),
687 tags: node.tags.clone(),
688 })
689}
690
691fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> {
692 revision
693 .roots
694 .get(&2)
695 .and_then(|id| revision.nodes.get(id))
696 .and_then(|node| {
697 if let Kind::Metadata { title, .. } = &node.kind {
698 title.as_deref()
699 } else {
700 None
701 }
702 })
703 .or_else(|| {
704 if let Kind::Page {
705 alternate_title, ..
706 } = &revision.nodes.get(&id)?.kind
707 {
708 alternate_title.as_deref()
709 } else {
710 None
711 }
712 })
713}
714
715impl PageParagraph {
716 pub fn text(&self) -> Option<&TextObject> {
717 match &self.content {
718 ParagraphContent::Text(text) => Some(text),
719 _ => None,
720 }
721 }
722 pub fn text_mut(&mut self) -> Option<&mut TextObject> {
723 match &mut self.content {
724 ParagraphContent::Text(text) => Some(text),
725 _ => None,
726 }
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use crate::document::{Element, TextRun};
734
735 fn id(n: u32) -> ExGuid {
736 ExGuid {
737 n,
738 ..ExGuid::default()
739 }
740 }
741
742 fn element(kind: Kind<'_>) -> Element<'_> {
743 Element {
744 jcid: 0,
745 children: Vec::new(),
746 content: Vec::new(),
747 structure: Vec::new(),
748 spaces: Vec::new(),
749 child_level: None,
750 layout: Layout::default(),
751 format: Format::default(),
752 created: None,
753 modified: None,
754 original_author: None,
755 latest_author: None,
756 media_ids: Vec::new(),
757 media_time_ms: None,
758 tags: Vec::new(),
759 kind,
760 extra: Vec::new(),
761 }
762 }
763
764 fn revision() -> Revision<'static> {
765 let mut page = element(Kind::Page {
766 alternate_title: Some("Fallback".into()),
767 level: None,
768 width: None,
769 height: None,
770 margin_origin_x: Some(36.0),
771 margin_origin_y: Some(12.0),
772 rtl: None,
773 });
774 page.children.push(id(2));
775 let mut outline = element(Kind::Outline {
776 indents: vec![18.0, 0.0, 36.0],
777 });
778 outline.children.push(id(3));
779 outline.child_level = Some(1);
780 outline.format.font_size = Some(9.0);
781 outline.format.bold = Some(true);
782 let mut paragraph = element(Kind::Paragraph {
783 lists: Vec::new(),
784 paragraph_style: Some(id(5)),
785 collapse_state: Some(1),
786 });
787 paragraph.content.push(id(4));
788 paragraph.format.bold = Some(false);
789 let text = element(Kind::RichText {
790 text: "ab".into(),
791 runs: vec![TextRun {
792 start: 0,
793 end: 2,
794 format: None,
795 extra_set: None,
796 }],
797 paragraph_style: None,
798 boilerplate: false,
799 });
800 let mut style = element(Kind::Style {
801 name: Some("Body".into()),
802 });
803 style.format.font_size = Some(12.0);
804 let metadata = element(Kind::Metadata {
805 title: Some("Page title".into()),
806 level: None,
807 });
808 Revision {
809 roots: BTreeMap::from([(2, id(6))]),
810 nodes: BTreeMap::from([
811 (id(1), page),
812 (id(2), outline),
813 (id(3), paragraph),
814 (id(4), text),
815 (id(5), style),
816 (id(6), metadata),
817 ]),
818 }
819 }
820
821 fn table_revision() -> Revision<'static> {
822 let mut source = revision();
823 source.nodes.get_mut(&id(3)).unwrap().content = vec![id(7)];
824 let mut table = element(Kind::Table {
825 rows: Some(1),
826 columns: Some(2),
827 widths: vec![37.11, 99.0],
828 locked: vec![false, true],
829 borders: Some(false),
830 });
831 table.children = vec![id(8)];
832 let mut row = element(Kind::Row);
833 row.children = vec![id(9), id(10)];
834 source.nodes.insert(id(7), table);
835 source.nodes.insert(id(8), row);
836 for (cell, paragraph, text) in [(9, 11, 4), (10, 12, 13)] {
837 let mut node = element(Kind::Cell {
838 shading: Some(0x00ffff),
839 indents: vec![18.0, 0.0, 27.0, 27.0],
840 });
841 node.child_level = Some(1);
842 node.layout.max_width = Some(268.8);
843 node.children = vec![id(paragraph)];
844 if cell == 10 {
845 node.format.font_size = Some(13.0);
846 }
847 source.nodes.insert(id(cell), node);
848 let mut node = element(Kind::Paragraph {
849 lists: Vec::new(),
850 paragraph_style: None,
851 collapse_state: None,
852 });
853 node.content = vec![id(text)];
854 source.nodes.insert(id(paragraph), node);
855 }
856 source.nodes.insert(
857 id(13),
858 element(Kind::RichText {
859 text: "cd".into(),
860 runs: vec![TextRun {
861 start: 0,
862 end: 2,
863 format: None,
864 extra_set: None,
865 }],
866 paragraph_style: None,
867 boilerplate: false,
868 }),
869 );
870 source
871 }
872
873 #[test]
874 fn table_import_owns_cells_and_inherits_format_across_containers() {
875 let page = {
876 let source = table_revision();
877 let before = serde_json::to_vec(&source).unwrap();
878 let page = Page::from_revision(&source, id(1)).unwrap();
879 assert_eq!(serde_json::to_vec(&source).unwrap(), before);
880 page
881 };
882 let PageObject::Outline(outline) = &page.objects[0] else {
883 panic!()
884 };
885 let ParagraphContent::Table(table) = &outline.paragraphs[0].content else {
886 panic!()
887 };
888 assert_eq!(table.id, id(7));
889 assert_eq!(table.borders, Some(false));
890 assert_eq!(
891 table.columns,
892 [
893 TableColumn {
894 width: 37.11,
895 locked: false
896 },
897 TableColumn {
898 width: 99.0,
899 locked: true
900 }
901 ]
902 );
903 assert_eq!(table.rows[0].id, id(8));
904 for (index, cell) in table.rows[0].cells.iter().enumerate() {
905 assert_eq!(cell.id, id(9 + index as u32));
906 assert_eq!(cell.layout.max_width, Some(268.8));
907 assert_eq!(cell.indents, [18.0, 0.0, 27.0, 27.0]);
908 assert_eq!(cell.shading, Some(0x00ffff));
909 let paragraph = &cell.paragraphs[0];
910 assert_eq!(paragraph.parent, None);
911 assert_eq!(paragraph.level, 1);
912 assert_eq!(paragraph.id, id(11 + index as u32));
913 let text = paragraph.text().unwrap();
914 assert_eq!(text.id, if index == 0 { id(4) } else { id(13) });
915 assert_eq!(text.text.text(), if index == 0 { "ab" } else { "cd" });
916 assert_eq!(
917 text.text.spans()[0].format.font_size,
918 Some(if index == 0 { 12.0 } else { 13.0 })
919 );
920 assert_eq!(text.text.spans()[0].format.bold, Some(false));
921 }
922 }
923
924 #[test]
925 fn absent_column_locks_use_the_specified_unlocked_default() {
926 let mut source = table_revision();
927 let Kind::Table { locked, .. } = &mut source.nodes.get_mut(&id(7)).unwrap().kind else {
928 panic!()
929 };
930 locked.clear();
931 let page = Page::from_revision(&source, id(1)).unwrap();
932 let PageObject::Outline(outline) = &page.objects[0] else {
933 panic!()
934 };
935 let ParagraphContent::Table(table) = &outline.paragraphs[0].content else {
936 panic!()
937 };
938 assert!(table.columns.iter().all(|column| !column.locked));
939 }
940
941 #[test]
942 fn table_import_rejects_aliases_cycles_and_inconsistent_dimensions() {
943 for case in 0..7 {
944 let mut source = table_revision();
945 match case {
946 0 => source.nodes.get_mut(&id(8)).unwrap().children[1] = id(9),
947 1 => source.nodes.get_mut(&id(11)).unwrap().content = vec![id(7)],
948 2 => source.nodes.get_mut(&id(12)).unwrap().content = vec![id(4)],
949 3 => source.nodes.get_mut(&id(3)).unwrap().content.push(id(4)),
950 4 => source.nodes.get_mut(&id(9)).unwrap().children.clear(),
951 5 => {
952 let Kind::Table { columns, .. } =
953 &mut source.nodes.get_mut(&id(7)).unwrap().kind
954 else {
955 panic!()
956 };
957 *columns = Some(3);
958 }
959 _ => {
960 let Kind::Table { widths, .. } =
961 &mut source.nodes.get_mut(&id(7)).unwrap().kind
962 else {
963 panic!()
964 };
965 widths[0] = f32::INFINITY;
966 }
967 }
968 assert!(Page::from_revision(&source, id(1)).is_err(), "case {case}");
969 }
970 }
971
972 #[test]
973 fn nested_tables_import_with_a_bounded_depth() {
974 for levels in [1, 62, 65] {
975 let mut source = table_revision();
976 let mut paragraph = id(11);
977 for depth in 0..levels {
978 let base = 20 + depth * 4;
979 source.nodes.get_mut(&paragraph).unwrap().content = vec![id(base)];
980 let mut table = element(Kind::Table {
981 rows: Some(1),
982 columns: Some(1),
983 widths: vec![72.0],
984 locked: vec![true],
985 borders: Some(true),
986 });
987 table.children = vec![id(base + 1)];
988 let mut row = element(Kind::Row);
989 row.children = vec![id(base + 2)];
990 let mut cell = element(Kind::Cell {
991 shading: None,
992 indents: vec![18.0, 0.0, 27.0, 27.0],
993 });
994 cell.child_level = Some(1);
995 cell.children = vec![id(base + 3)];
996 let mut child = element(Kind::Paragraph {
997 lists: Vec::new(),
998 paragraph_style: None,
999 collapse_state: None,
1000 });
1001 child.content = vec![id(4)];
1002 source.nodes.extend([
1003 (id(base), table),
1004 (id(base + 1), row),
1005 (id(base + 2), cell),
1006 (id(base + 3), child),
1007 ]);
1008 paragraph = id(base + 3);
1009 }
1010 assert_eq!(Page::from_revision(&source, id(1)).is_ok(), levels < 64);
1011 }
1012 }
1013
1014 #[test]
1015 fn owns_page_content_and_resolves_style_before_ancestor_defaults() {
1016 let page = {
1017 let mut source = revision();
1018 source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag {
1019 definition: Some(id(7)),
1020 action_type: None,
1021 status: 0,
1022 created: Some(123),
1023 completed: None,
1024 start: None,
1025 due: None,
1026 task_id: None,
1027 extra_set: 0,
1028 });
1029 source.nodes.insert(
1030 id(7),
1031 element(Kind::TagDefinition {
1032 label: Some("To Do".into()),
1033 action_type: Some(0),
1034 shape: Some(3),
1035 color: None,
1036 highlight: None,
1037 }),
1038 );
1039 let before = serde_json::to_vec(&source).unwrap();
1040 let page = Page::from_revision(&source, id(1)).unwrap();
1041 assert_eq!(before, serde_json::to_vec(&source).unwrap());
1042 page
1043 };
1044 assert_eq!(page.title, "Page title");
1045 assert_eq!(page.margin_origin, [36.0, 12.0]);
1046 let PageObject::Outline(outline) = &page.objects[0] else {
1047 panic!()
1048 };
1049 assert_eq!(outline.paragraphs.len(), 1);
1050 let paragraph = &outline.paragraphs[0];
1051 assert!(paragraph.collapsed);
1052 assert_eq!(paragraph.level, 1);
1053 assert_eq!(paragraph.parent, None);
1054 assert_eq!(paragraph.style, Some(id(5)));
1055 assert!(matches!(&page.definitions[&id(5)].kind,
1056 Kind::Style { name: Some(name) } if name == "Body"));
1057 assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0));
1058 let text = &paragraph.text().unwrap().text;
1059 assert_eq!(text.text(), "ab");
1060 assert_eq!(text.spans()[0].format.font_size, Some(12.0));
1061 assert_eq!(text.spans()[0].format.bold, Some(false));
1062 assert_eq!(paragraph.text().unwrap().tags[0].created, Some(123));
1063 assert!(matches!(&page.definitions[&id(7)].kind,
1064 Kind::TagDefinition { label: Some(label), shape: Some(3), .. } if label == "To Do"));
1065 }
1066
1067 #[test]
1068 fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() {
1069 use crate::document::{Field, FieldValue};
1070 let mut source = revision();
1071 source.nodes.get_mut(&id(1)).unwrap().created = Some(7);
1072 let bytes = 134_333_468_649_123_456_u64.to_le_bytes();
1073 source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field {
1074 id: 0x18001c65,
1075 value: FieldValue::Bytes(&bytes),
1076 }]];
1077 source.nodes.get_mut(&id(1)).unwrap().children.clear();
1078 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
1079 let mut title = element(Kind::Title);
1080 title.children.push(id(2));
1081 source.nodes.insert(id(7), title);
1082 source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![Field {
1083 id: 0x88001cb5,
1084 value: FieldValue::NoData,
1085 }]];
1086 source.nodes.get_mut(&id(4)).unwrap().extra = vec![vec![Field {
1087 id: 0x88001c87,
1088 value: FieldValue::NoData,
1089 }]];
1090 let page = Page::from_revision(&source, id(1)).unwrap();
1091 assert_eq!(page.created, Some(134_333_468_649_123_456));
1092 let PageObject::Title(title) = &page.objects[0] else {
1093 panic!()
1094 };
1095 assert_eq!(title.date, Some(id(2)));
1096 assert_eq!(
1097 title.outlines[0].paragraphs[0].text().unwrap().date_field,
1098 Some(DateField::Time)
1099 );
1100 source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]);
1101 assert!(Page::from_revision(&source, id(1)).is_err());
1102 source.nodes.get_mut(&id(6)).unwrap().extra.clear();
1103 assert_eq!(Page::from_revision(&source, id(1)).unwrap().created, None);
1104 }
1105
1106 #[test]
1107 fn title_role_and_minimum_width_come_from_root_properties() {
1108 use crate::document::{Field, FieldValue};
1109 let mut source = revision();
1110 source.nodes.get_mut(&id(1)).unwrap().children.clear();
1111 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
1112 let mut title = element(Kind::Title);
1113 title.children.push(id(2));
1114 source.nodes.insert(id(7), title);
1115 source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![
1116 Field {
1117 id: 0x88001cb4,
1118 value: FieldValue::NoData,
1119 },
1120 Field {
1121 id: 0x14001cec,
1122 value: FieldValue::Bytes(&[0, 0, 0x90, 0x40]),
1123 },
1124 ]];
1125 let page = Page::from_revision(&source, id(1)).unwrap();
1126 let PageObject::Title(title) = &page.objects[0] else {
1127 panic!()
1128 };
1129 assert!(title.outlines[0].title);
1130 assert_eq!(title.outlines[0].min_width, Some(162.0));
1131 source.nodes.get_mut(&id(2)).unwrap().extra[0][1].value =
1132 FieldValue::Bytes(&[0, 0, 0x80, 0x7f]);
1133 assert!(Page::from_revision(&source, id(1)).is_err());
1134 source.nodes.get_mut(&id(2)).unwrap().extra = vec![
1135 vec![Field {
1136 id: 0x08001cb4,
1137 value: FieldValue::NoData,
1138 }],
1139 vec![Field {
1140 id: 0x88001cb4,
1141 value: FieldValue::NoData,
1142 }],
1143 ];
1144 let page = Page::from_revision(&source, id(1)).unwrap();
1145 let PageObject::Title(title) = &page.objects[0] else {
1146 panic!()
1147 };
1148 assert!(!title.outlines[0].title);
1149 assert_eq!(title.outlines[0].min_width, None);
1150 }
1151
1152 #[test]
1153 fn preserves_paint_order_nested_parents_and_owned_image_payloads() {
1154 let bytes = vec![1, 2, 3, 4];
1155 let mut source = revision();
1156 let image = element(Kind::Image {
1157 container: Some(id(8)),
1158 filename: None,
1159 alt: Some("Image".into()),
1160 picture_width: None,
1161 picture_height: None,
1162 background: Some(true),
1163 printout: None,
1164 link: None,
1165 });
1166 let file = element(Kind::File {
1167 reference: crate::FileDataReference::Internal([0; 16]),
1168 extension: "png".into(),
1169 payload: Some(bytes.as_slice()),
1170 });
1171 let mut title = element(Kind::Title);
1172 title.layout.x = Some(12.0);
1173 title.layout.y = Some(24.0);
1174 title.children.push(id(10));
1175 let title_outline = element(Kind::Outline {
1176 indents: Vec::new(),
1177 });
1178 let mut group = element(Kind::OutlineGroup);
1179 group.children.push(id(12));
1180 group.child_level = Some(1);
1181 let mut child = element(Kind::Paragraph {
1182 lists: Vec::new(),
1183 paragraph_style: None,
1184 collapse_state: None,
1185 });
1186 child.content.push(id(13));
1187 source.nodes.insert(
1188 id(13),
1189 element(Kind::RichText {
1190 text: "".into(),
1191 runs: Vec::new(),
1192 paragraph_style: None,
1193 boilerplate: false,
1194 }),
1195 );
1196 source.nodes.extend([
1197 (id(7), image),
1198 (id(8), file),
1199 (id(9), title),
1200 (id(10), title_outline),
1201 (id(11), group),
1202 (id(12), child),
1203 ]);
1204 source
1205 .nodes
1206 .get_mut(&id(1))
1207 .unwrap()
1208 .children
1209 .insert(0, id(7));
1210 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(9));
1211 source.nodes.get_mut(&id(3)).unwrap().children.push(id(11));
1212 source.nodes.get_mut(&id(3)).unwrap().child_level = Some(1);
1213 let page = Page::from_revision(&source, id(1)).unwrap();
1214 drop(source);
1215 drop(bytes);
1216 assert_eq!(page.objects.len(), 3);
1217 let PageObject::Image(image) = &page.objects[0] else {
1218 panic!()
1219 };
1220 assert_eq!(image.bytes.as_deref(), Some([1, 2, 3, 4].as_slice()));
1221 let PageObject::Outline(outline) = &page.objects[1] else {
1222 panic!()
1223 };
1224 assert_eq!(outline.paragraphs[1].id, id(12));
1225 assert_eq!(outline.paragraphs[1].parent, Some(id(3)));
1226 assert_eq!(outline.paragraphs[1].level, 3);
1227 let PageObject::Title(title) = &page.objects[2] else {
1228 panic!()
1229 };
1230 assert_eq!(title.id, id(9));
1231 assert_eq!([title.layout.x, title.layout.y], [Some(12.0), Some(24.0)]);
1232 assert_eq!(title.outlines[0].id, id(10));
1233 assert_eq!(title.outlines[0].layout.x, None);
1234 }
1235
1236 #[test]
1237 fn rejects_title_children_that_are_not_outlines() {
1238 let mut source = revision();
1239 let mut title = element(Kind::Title);
1240 title.children.push(id(8));
1241 source.nodes.insert(id(7), title);
1242 source.nodes.insert(id(8), element(Kind::Title));
1243 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
1244 assert_eq!(
1245 Page::from_revision(&source, id(1)).err().unwrap().message,
1246 "Canvas title child is not an outline"
1247 );
1248 }
1249
1250 #[test]
1251 fn validates_empty_text_styles_missing_references_and_cycles() {
1252 let mut source = revision();
1253 source.nodes.get_mut(&id(4)).unwrap().kind = Kind::RichText {
1254 text: String::new(),
1255 runs: Vec::new(),
1256 paragraph_style: Some(id(5)),
1257 boilerplate: false,
1258 };
1259 source.nodes.get_mut(&id(3)).unwrap().format.font_size = Some(10.0);
1260 let page = Page::from_revision(&source, id(1)).unwrap();
1261 let PageObject::Outline(outline) = &page.objects[0] else {
1262 panic!()
1263 };
1264 assert_eq!(
1265 outline.paragraphs[0].text().unwrap().text.spans()[0]
1266 .format
1267 .font_size,
1268 Some(12.0)
1269 );
1270 source.nodes.get_mut(&id(3)).unwrap().children.push(id(2));
1271 assert!(Page::from_revision(&source, id(1)).is_err());
1272 source.nodes.get_mut(&id(3)).unwrap().children.clear();
1273 source.nodes.remove(&id(5));
1274 assert!(Page::from_revision(&source, id(1)).is_err());
1275 }
1276}
crates/onestore/src/page/text.rs created+571
......@@ -0,0 +1,571 @@
1use crate::{ExGuid, document::Format};
2use std::{fmt, ops::Range};
3
4#[derive(Clone, Debug, PartialEq)]
5pub struct Span {
6 /// Exclusive UTF-8 boundary; the start is the preceding span's end.
7 pub end: usize,
8 pub format: Format,
9}
10
11#[derive(Clone, Debug, PartialEq)]
12/// Editable text styles are coalesced independently of serialized run boundaries.
13pub struct Paragraph {
14 text: String,
15 spans: Vec<Span>,
16}
17
18/// Which side of a hidden field a visible boundary maps to.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Affinity {
21 Upstream,
22 Downstream,
23}
24
25#[derive(Clone)]
26pub struct TextProjection {
27 text: Paragraph,
28 spans: Vec<ProjectedSpan>,
29 source_len: u32,
30}
31
32#[derive(Clone)]
33struct ProjectedSpan {
34 visible: Range<u32>,
35 source_start: u32,
36}
37
38impl TextProjection {
39 pub fn text(&self) -> &Paragraph {
40 &self.text
41 }
42
43 /// Maps visible UTF-8 boundaries to downstream source UTF-16 positions in one pass.
44 pub fn source_boundaries(&self) -> impl Iterator<Item = (usize, u32)> + '_ {
45 let mut spans = self.spans.iter().peekable();
46 let mut visible = 0;
47 self.text
48 .text
49 .char_indices()
50 .map(move |(byte, character)| {
51 while spans.peek().is_some_and(|span| span.visible.end <= visible) {
52 spans.next();
53 }
54 let span = spans.peek().unwrap();
55 let source = span.source_start + (visible - span.visible.start);
56 visible += character.len_utf16() as u32;
57 (byte, source)
58 })
59 .chain(std::iter::once((self.text.text.len(), self.source_len)))
60 }
61
62 pub fn source_offset(&self, visible: u32, affinity: Affinity) -> Result<u32, EditError> {
63 self.text.byte_offset(visible)?;
64 Ok(match affinity {
65 Affinity::Downstream => self
66 .spans
67 .iter()
68 .find(|span| span.visible.end > visible)
69 .map(|span| span.source_start + (visible - span.visible.start))
70 .unwrap_or(self.source_len),
71 Affinity::Upstream => self
72 .spans
73 .iter()
74 .rev()
75 .find(|span| span.visible.start < visible)
76 .map(|span| span.source_start + (visible - span.visible.start))
77 .unwrap_or(0),
78 })
79 }
80
81 /// Hidden source positions collapse to their visible boundary.
82 pub fn visible_offset(&self, source: u32) -> Result<u32, EditError> {
83 if source > self.source_len {
84 return Err(EditError::InvalidRange);
85 }
86 for span in &self.spans {
87 if source < span.source_start {
88 return Ok(span.visible.start);
89 }
90 let length = span.visible.end - span.visible.start;
91 if source < span.source_start + length {
92 let visible = span.visible.start + source - span.source_start;
93 self.text.byte_offset(visible)?;
94 return Ok(visible);
95 }
96 }
97 self.text.utf16_offset(self.text.text.len())
98 }
99}
100
101#[derive(Clone, Debug, PartialEq)]
102pub struct Edit {
103 pub range: Range<u32>,
104 pub replacement: Paragraph,
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum EditError {
109 InvalidRange,
110 TextTooLong,
111 InvalidStructure,
112 UnsupportedContent,
113 /// The system random source failed while allocating a new identity.
114 Identity,
115}
116
117impl fmt::Display for EditError {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.write_str(match self {
120 Self::Identity => "System random source failed",
121 Self::UnsupportedContent => {
122 "This paragraph contains content the text editor cannot edit"
123 }
124 Self::InvalidRange => "Text range is outside the paragraph or splits a surrogate pair",
125 Self::TextTooLong => "Text exceeds the UTF-16 offset range",
126 Self::InvalidStructure => {
127 "This outline has duplicate objects or broken paragraph links"
128 }
129 })
130 }
131}
132
133impl std::error::Error for EditError {}
134
135/// A fresh random identity for a new paragraph or text object.
136pub fn new_id() -> Result<ExGuid, EditError> {
137 Ok(ExGuid {
138 guid: crate::write::fresh_guid().map_err(|_| EditError::Identity)?,
139 n: 0,
140 })
141}
142
143impl Paragraph {
144 pub fn project(&self) -> Result<TextProjection, EditError> {
145 let mut runs = Vec::new();
146 let mut spans: Vec<ProjectedSpan> = Vec::new();
147 let mut byte = 0;
148 let mut source = 0_u32;
149 let mut visible = 0_u32;
150 for span in &self.spans {
151 let fragment = &self.text[byte..span.end];
152 let length: u32 = fragment
153 .encode_utf16()
154 .count()
155 .try_into()
156 .map_err(|_| EditError::TextTooLong)?;
157 let source_end = source.checked_add(length).ok_or(EditError::TextTooLong)?;
158 if span.format.hidden != Some(true) {
159 runs.push((fragment.replace('\u{000b}', "\n"), span.format.clone()));
160 if length > 0 {
161 if let Some(last) = spans.last_mut()
162 && last.source_start + (last.visible.end - last.visible.start) == source
163 {
164 last.visible.end += length;
165 } else {
166 spans.push(ProjectedSpan {
167 visible: visible..visible + length,
168 source_start: source,
169 });
170 }
171 visible += length;
172 }
173 }
174 byte = span.end;
175 source = source_end;
176 }
177 if runs.is_empty() {
178 let mut format = self.spans[0].format.clone();
179 format.hidden = Some(false);
180 runs.push((String::new(), format));
181 }
182 Ok(TextProjection {
183 text: Self::from_runs(runs),
184 spans,
185 source_len: source,
186 })
187 }
188
189 pub fn new(text: String, format: Format) -> Self {
190 let end = text.len();
191 Self {
192 text,
193 spans: vec![Span { end, format }],
194 }
195 }
196
197 pub fn from_runs(runs: impl IntoIterator<Item = (String, Format)>) -> Self {
198 let mut text = String::new();
199 let mut spans: Vec<Span> = Vec::new();
200 for (fragment, format) in runs {
201 text.push_str(&fragment);
202 if let Some(last) = spans.last_mut() {
203 if last.format == format {
204 last.end = text.len();
205 continue;
206 }
207 if fragment.is_empty() && !text.is_empty() {
208 continue;
209 }
210 if last.end == 0 {
211 spans.clear();
212 }
213 }
214 spans.push(Span {
215 end: text.len(),
216 format,
217 });
218 }
219 if spans.is_empty() {
220 spans.push(Span {
221 end: 0,
222 format: Format::default(),
223 });
224 }
225 Self { text, spans }
226 }
227
228 pub fn text(&self) -> &str {
229 &self.text
230 }
231
232 pub fn spans(&self) -> &[Span] {
233 &self.spans
234 }
235
236 pub fn byte_offset(&self, utf16: u32) -> Result<usize, EditError> {
237 let mut units = 0_u64;
238 for (byte, character) in self.text.char_indices() {
239 if units == u64::from(utf16) {
240 return Ok(byte);
241 }
242 units += character.len_utf16() as u64;
243 if units > u64::from(utf16) {
244 return Err(EditError::InvalidRange);
245 }
246 }
247 if units == u64::from(utf16) {
248 Ok(self.text.len())
249 } else {
250 Err(EditError::InvalidRange)
251 }
252 }
253
254 pub fn utf16_offset(&self, byte: usize) -> Result<u32, EditError> {
255 self.text
256 .get(..byte)
257 .ok_or(EditError::InvalidRange)?
258 .encode_utf16()
259 .count()
260 .try_into()
261 .map_err(|_| EditError::TextTooLong)
262 }
263
264 pub fn format_at(&self, utf16: u32) -> Result<&Format, EditError> {
265 let byte = self.byte_offset(utf16)?;
266 let index = self.spans.partition_point(|span| span.end < byte);
267 Ok(&self.spans[index].format)
268 }
269
270 pub fn slice(&self, range: Range<u32>) -> Result<Self, EditError> {
271 if range.start > range.end {
272 return Err(EditError::InvalidRange);
273 }
274 let start = self.byte_offset(range.start)?;
275 let end = self.byte_offset(range.end)?;
276 if start == end {
277 return Ok(Self::new(
278 String::new(),
279 self.format_at(range.start)?.clone(),
280 ));
281 }
282 let mut previous = 0;
283 let mut spans = Vec::new();
284 for span in &self.spans {
285 if previous < end && span.end > start {
286 spans.push(Span {
287 end: span.end.min(end) - start,
288 format: span.format.clone(),
289 });
290 }
291 previous = span.end;
292 }
293 Ok(Self {
294 text: self.text[start..end].to_owned(),
295 spans,
296 })
297 }
298
299 /// Returns the inverse edit; applying that inverse returns a redo operation.
300 pub fn apply(&mut self, edit: Edit) -> Result<Edit, EditError> {
301 let end = self.utf16_offset(self.text.len())?;
302 let removed = self.slice(edit.range.clone())?;
303 let inserted = edit.replacement.utf16_offset(edit.replacement.text.len())?;
304 let new_end = edit
305 .range
306 .start
307 .checked_add(inserted)
308 .ok_or(EditError::TextTooLong)?;
309 let resulting_len = end
310 .checked_sub(edit.range.end - edit.range.start)
311 .and_then(|n| n.checked_add(inserted))
312 .ok_or(EditError::TextTooLong)?;
313 if resulting_len == 0 {
314 *self = edit.replacement;
315 return Ok(Edit {
316 range: edit.range.start..new_end,
317 replacement: removed,
318 });
319 }
320 let prefix = self.slice(0..edit.range.start)?;
321 let suffix = self.slice(edit.range.end..end)?;
322 let mut runs = Vec::new();
323 for part in [prefix, edit.replacement, suffix] {
324 let mut start = 0;
325 for span in part.spans {
326 if span.end > start {
327 runs.push((part.text[start..span.end].to_owned(), span.format));
328 }
329 start = span.end;
330 }
331 }
332 *self = Self::from_runs(runs);
333 Ok(Edit {
334 range: edit.range.start..new_end,
335 replacement: removed,
336 })
337 }
338
339 pub fn append(&mut self, other: Self) -> Result<Edit, EditError> {
340 let end = self.utf16_offset(self.text.len())?;
341 self.apply(Edit {
342 range: end..end,
343 replacement: other,
344 })
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 fn regular() -> Format {
353 Format {
354 font: Some("Arial".into()),
355 font_size: Some(11.0),
356 ..Format::default()
357 }
358 }
359
360 fn bold() -> Format {
361 Format {
362 bold: Some(true),
363 ..regular()
364 }
365 }
366 #[test]
367 fn soft_breaks_keep_source_offsets_and_formatting() {
368 let source = Paragraph::from_runs([("A\u{000b}".into(), regular()), ("🌳".into(), bold())]);
369 let projection = source.project().unwrap();
370 assert_eq!(source.text(), "A\u{000b}🌳");
371 assert_eq!(projection.text().text(), "A\n🌳");
372 assert_eq!(projection.text().spans(), source.spans());
373 assert_eq!(
374 projection.source_boundaries().collect::<Vec<_>>(),
375 [(0, 0), (1, 1), (2, 2), (6, 4)]
376 );
377 for offset in [0, 1, 2, 4] {
378 assert_eq!(projection.visible_offset(offset), Ok(offset));
379 assert_eq!(
380 projection.source_offset(offset, Affinity::Downstream),
381 Ok(offset)
382 );
383 }
384 assert!(projection.visible_offset(3).is_err());
385 }
386
387 #[test]
388 fn hidden_fields_keep_source_offsets_at_both_sides_of_a_gap() {
389 let hidden = Format {
390 hidden: Some(true),
391 ..regular()
392 };
393 let source = Paragraph::from_runs([
394 ("A".into(), regular()),
395 ("🌳".into(), hidden.clone()),
396 ("e\u{301}".into(), bold()),
397 ("X".into(), hidden),
398 ]);
399 let original = source.clone();
400 let projection = source.project().unwrap();
401 assert_eq!(projection.text().text(), "Ae\u{301}");
402 assert_eq!(
403 projection.source_boundaries().collect::<Vec<_>>(),
404 [(0, 0), (1, 3), (2, 4), (4, 6)]
405 );
406 for (visible, upstream, downstream) in [(0, 0, 0), (1, 1, 3), (2, 4, 4), (3, 5, 6)] {
407 assert_eq!(
408 projection.source_offset(visible, Affinity::Upstream),
409 Ok(upstream)
410 );
411 assert_eq!(
412 projection.source_offset(visible, Affinity::Downstream),
413 Ok(downstream)
414 );
415 }
416 for (source, visible) in [(0, 0), (1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3)] {
417 assert_eq!(projection.visible_offset(source), Ok(visible));
418 }
419 assert_eq!(source, original);
420 }
421
422 #[test]
423 fn projection_validates_visible_surrogates_and_preserves_empty_field_boundaries() {
424 let visible = Paragraph::new("a🌳z".into(), regular()).project().unwrap();
425 assert_eq!(
426 visible.source_offset(2, Affinity::Downstream),
427 Err(EditError::InvalidRange)
428 );
429 assert_eq!(visible.visible_offset(2), Err(EditError::InvalidRange));
430 let hidden = Paragraph::new(
431 "🌳".into(),
432 Format {
433 hidden: Some(true),
434 ..regular()
435 },
436 )
437 .project()
438 .unwrap();
439 assert!(hidden.text().text().is_empty());
440 assert_eq!(hidden.source_boundaries().collect::<Vec<_>>(), [(0, 2)]);
441 assert_eq!(
442 visible.source_boundaries().collect::<Vec<_>>(),
443 [(0, 0), (1, 1), (5, 3), (6, 4)]
444 );
445 assert_eq!(hidden.source_offset(0, Affinity::Upstream), Ok(0));
446 assert_eq!(hidden.source_offset(0, Affinity::Downstream), Ok(2));
447 assert_eq!(hidden.visible_offset(1), Ok(0));
448 assert_eq!(hidden.visible_offset(3), Err(EditError::InvalidRange));
449 }
450
451 #[test]
452 fn offsets_distinguish_bytes_utf16_and_scalars() {
453 let text = Paragraph::new("a🌳e\u{301}".into(), regular());
454 for (utf16, byte) in [(0, 0), (1, 1), (3, 5), (4, 6), (5, 8)] {
455 assert_eq!(text.byte_offset(utf16), Ok(byte));
456 assert_eq!(text.utf16_offset(byte), Ok(utf16));
457 }
458 assert_eq!(text.byte_offset(2), Err(EditError::InvalidRange));
459 assert_eq!(text.utf16_offset(2), Err(EditError::InvalidRange));
460 assert_eq!(text.byte_offset(6), Err(EditError::InvalidRange));
461 assert_eq!(text.utf16_offset(9), Err(EditError::InvalidRange));
462 }
463
464 #[test]
465 fn replacement_preserves_styles_outside_selection() {
466 let mut text = Paragraph::from_runs([
467 ("plain ".into(), regular()),
468 ("bold".into(), bold()),
469 (" end".into(), regular()),
470 ]);
471 let original = text.clone();
472 let edit = Edit {
473 range: 4..8,
474 replacement: Paragraph::new("🌳".into(), bold()),
475 };
476 let undo = text.apply(edit).unwrap();
477 assert_eq!(text.text(), "plai🌳ld end");
478 assert_eq!(
479 text.spans()
480 .iter()
481 .map(|s| (s.end, s.format.bold))
482 .collect::<Vec<_>>(),
483 [(4, None), (10, Some(true)), (14, None)]
484 );
485 let after = text.clone();
486 let redo = text.apply(undo).unwrap();
487 assert_eq!(text, original);
488 text.apply(redo).unwrap();
489 assert_eq!(text, after);
490 }
491
492 #[test]
493 fn invalid_edits_leave_text_and_styles_unchanged() {
494 let original = Paragraph::new("a🌳b".into(), regular());
495 for (start, end) in [(2, 3), (0, 2), (3, 2), (0, 5)] {
496 let mut text = original.clone();
497 assert_eq!(
498 text.apply(Edit {
499 range: start..end,
500 replacement: Paragraph::new("x".into(), bold())
501 }),
502 Err(EditError::InvalidRange)
503 );
504 assert_eq!(text, original);
505 }
506 }
507
508 #[test]
509 fn undo_restores_empty_paragraph_format() {
510 let mut text = Paragraph::new(String::new(), regular());
511 let original = text.clone();
512 let undo = text
513 .apply(Edit {
514 range: 0..0,
515 replacement: Paragraph::new("bold".into(), bold()),
516 })
517 .unwrap();
518 text.apply(undo).unwrap();
519 assert_eq!(text, original);
520 }
521
522 #[test]
523 fn every_scalar_range_round_trips_styled_unicode_edits() {
524 let original = Paragraph::from_runs([
525 ("a🌳".into(), regular()),
526 ("e\u{301}שלום".into(), bold()),
527 ("Z".into(), regular()),
528 ]);
529 let boundaries: Vec<_> = original
530 .text()
531 .char_indices()
532 .map(|(n, _)| n)
533 .chain([original.text().len()])
534 .collect();
535 for &start in &boundaries {
536 for &end in boundaries.iter().filter(|&&end| end >= start) {
537 let range =
538 original.utf16_offset(start).unwrap()..original.utf16_offset(end).unwrap();
539 for replacement in ["", "👩‍👩‍👧‍👦", "xyz", "\u{301}"] {
540 let mut edited = original.clone();
541 let undo = edited
542 .apply(Edit {
543 range: range.clone(),
544 replacement: Paragraph::new(replacement.into(), bold()),
545 })
546 .unwrap();
547 assert_eq!(
548 edited.text(),
549 format!(
550 "{}{replacement}{}",
551 &original.text()[..start],
552 &original.text()[end..]
553 )
554 );
555 let after = edited.clone();
556 let redo = edited.apply(undo).unwrap();
557 assert_eq!(edited, original);
558 edited.apply(redo).unwrap();
559 assert_eq!(edited, after);
560 }
561 }
562 let at = original.utf16_offset(start).unwrap();
563 let mut left = original.slice(0..at).unwrap();
564 let right = original
565 .slice(at..original.utf16_offset(original.text.len()).unwrap())
566 .unwrap();
567 left.append(right).unwrap();
568 assert_eq!(left, original);
569 }
570 }
571}
crates/onestore/src/pages.rs created+861
......@@ -0,0 +1,861 @@
1use crate::{
2 Error, ExGuid,
3 create::{current_timestamps, properties, string},
4 document::{Document, FieldValue, Kind},
5 write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions},
6};
7use serde::{Deserialize, Serialize};
8use std::{
9 collections::{BTreeMap, BTreeSet},
10 sync::Arc,
11};
12
13fn invalid(message: &'static str) -> Error {
14 Error { offset: 0, message }
15}
16
17fn metadata_id(space: ExGuid) -> ExGuid {
18 let mut id = ExGuid {
19 guid: space.guid,
20 n: 1,
21 };
22 for (byte, salt) in id.guid.iter_mut().zip([
23 0x31, 0xc0, 0xa8, 0x22, 0, 0x36, 0xee, 0x42, 0xb7, 0x14, 0xd7, 0xac, 0xda, 0x24, 0x35, 0xe8,
24 ]) {
25 *byte ^= salt;
26 }
27 id
28}
29
30fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[u8; 16], Error> {
31 metadata.extra[0]
32 .iter()
33 .find_map(|field| match field.value {
34 FieldValue::Bytes(bytes) if field.id == 0x1c001c30 => bytes.try_into().ok(),
35 _ => None,
36 })
37 .ok_or_else(|| invalid("Page metadata needs its page identifier"))
38}
39
40fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> {
41 let mut references = Vec::new();
42 for id in ids {
43 references.extend_from_slice(&object.reference(*id)?);
44 }
45 let source = PropertyObject {
46 jcid: object.jcid,
47 bytes: properties(&[(property, references)])?,
48 global_ids: Arc::clone(&object.global_ids),
49 };
50 object.copy_property(&source, property)
51}
52
53/// Whether an edit preserves position or moves before a page (None appends).
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub enum PagePosition {
57 Keep,
58 Before(Option<ExGuid>),
59}
60
61/// A page's explicit position and indentation, with a stable identity for a new series.
62/// Moving a page does not implicitly select its following subpages.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct PageEdit {
66 guid: [u8; 16],
67 space: ExGuid,
68 level: u32,
69 position: PagePosition,
70}
71
72impl PageEdit {
73 /// Changes one page's indentation without changing its position.
74 pub fn set_level(space: ExGuid, level: u32) -> Result<Self, Error> {
75 Self::new(space, level, PagePosition::Keep)
76 }
77
78 /// Moves before an existing page space, or appends when `before` is None.
79 pub fn move_to(space: ExGuid, before: Option<ExGuid>, level: u32) -> Result<Self, Error> {
80 Self::new(space, level, PagePosition::Before(before))
81 }
82
83 pub fn space(&self) -> ExGuid {
84 self.space
85 }
86
87 pub fn level(&self) -> u32 {
88 self.level
89 }
90
91 pub fn position(&self) -> PagePosition {
92 self.position
93 }
94
95 /// Revises placement while retaining the selected page and allocated series identity.
96 pub fn reposition(&self, position: PagePosition, level: u32) -> Result<Self, Error> {
97 let edit = Self {
98 position,
99 level,
100 ..self.clone()
101 };
102 edit.validate()?;
103 Ok(edit)
104 }
105
106 fn new(space: ExGuid, level: u32, position: PagePosition) -> Result<Self, Error> {
107 let edit = Self {
108 guid: fresh_guid()?,
109 space,
110 level,
111 position,
112 };
113 edit.validate()?;
114 Ok(edit)
115 }
116
117 fn validate(&self) -> Result<(), Error> {
118 if self.guid == [0; 16] || self.space.guid == [0; 16] || !(1..=3).contains(&self.level) {
119 return Err(invalid(
120 "Choose an existing page space and indentation level 1 through 3",
121 ));
122 }
123 if let PagePosition::Before(Some(before)) = self.position
124 && (before.guid == [0; 16] || before == self.space)
125 {
126 return Err(invalid("Choose a different page as the movement anchor"));
127 }
128 Ok(())
129 }
130}
131
132pub(crate) fn edit_pages(
133 source: &[u8],
134 edits: &[PageEdit],
135 removals: &[ExGuid],
136) -> Result<Vec<u8>, Error> {
137 if !edits.is_empty() && !removals.is_empty() {
138 return Err(invalid(
139 "Choose page placement or page removal for one operation",
140 ));
141 }
142 let removed: BTreeSet<_> = removals.iter().copied().collect();
143 if removed.len() != removals.len() || removed.iter().any(|sid| sid.guid == [0; 16]) {
144 return Err(invalid("Choose distinct existing page spaces for removal"));
145 }
146 let mut selected = BTreeMap::new();
147 let mut guids = BTreeSet::new();
148 for edit in edits {
149 edit.validate()?;
150 if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) {
151 return Err(invalid(
152 "Use distinct page and edit identities within a batch",
153 ));
154 }
155 }
156 write_revisions(source, |index| {
157 let document = Document::parse(index)?;
158 let pages = document.pages()?;
159 let view = document.active(document.root)?;
160 let section_id = view.roots[&1];
161 let section_node = &view.nodes[&section_id];
162 if !matches!(section_node.kind, Kind::Section { .. })
163 || section_node.extra[0]
164 .iter()
165 .any(|field| field.id == 0x88001cde)
166 {
167 return Err(invalid("Choose an editable section for page editing"));
168 }
169 let mut order = Vec::new();
170 let mut levels = BTreeMap::new();
171 let mut page_guids = BTreeMap::new();
172 for (sid, _) in &pages {
173 let page = document.active(*sid)?;
174 let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else {
175 return Err(invalid("Page metadata is unavailable"));
176 };
177 let Kind::Metadata { level, .. } = metadata.kind else {
178 return Err(invalid("Choose ordinary pages with page metadata"));
179 };
180 let level = level.unwrap_or(1);
181 if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() {
182 return Err(invalid(
183 "Each page space must contain one page with a valid level",
184 ));
185 }
186 if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() {
187 return Err(invalid("Pages must have distinct metadata identifiers"));
188 }
189 order.push(*sid);
190 }
191 let original_order = order.clone();
192 let original_levels = levels.clone();
193 let mut series_by_head = BTreeMap::new();
194 let mut copies = BTreeMap::new();
195 let mut copy_ids = BTreeSet::new();
196 for oid in &section_node.children {
197 let series = &view.nodes[oid];
198 let Some(head) = series.spaces.first() else {
199 return Err(invalid("A page series must contain at least one page"));
200 };
201 if levels.get(head) != Some(&1)
202 || series.spaces[1..]
203 .iter()
204 .any(|sid| levels.get(sid).is_none_or(|level| *level == 1))
205 {
206 return Err(invalid(
207 "A page series must start with its only top-level page",
208 ));
209 }
210 if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) {
211 series_by_head.insert(*retained, *oid);
212 }
213 if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) {
214 let FieldValue::Objects(ids) = &field.value else {
215 return Err(invalid("Page metadata copies must be object references"));
216 };
217 if ids.len() != series.spaces.len() {
218 return Err(invalid("Page metadata copies must match their series"));
219 }
220 for id in ids {
221 if !copy_ids.insert(*id)
222 || !matches!(
223 view.nodes.get(id).map(|n| &n.kind),
224 Some(Kind::Metadata { .. })
225 )
226 {
227 return Err(invalid("Each page needs its own ordinary metadata copy"));
228 }
229 let sid = page_guids
230 .get(&metadata_guid(&view.nodes[id])?)
231 .filter(|sid| series.spaces.contains(sid))
232 .ok_or_else(|| {
233 invalid("Metadata copy must identify a page in its series")
234 })?;
235 if copies.insert(*sid, *id).is_some() {
236 return Err(invalid("Each page needs its own ordinary metadata copy"));
237 }
238 }
239 }
240 }
241 for sid in &removed {
242 if !levels.contains_key(sid) {
243 return Err(invalid("The selected page is no longer in the section"));
244 }
245 }
246 order.retain(|sid| !removed.contains(sid));
247 if !removed.is_empty()
248 && let Some(first) = order.first()
249 {
250 levels.insert(*first, 1);
251 }
252 for edit in edits {
253 let Some(at) = order.iter().position(|sid| *sid == edit.space) else {
254 return Err(invalid("The selected page is no longer in the section"));
255 };
256 levels.insert(edit.space, edit.level);
257 if let PagePosition::Before(before) = edit.position {
258 order.remove(at);
259 let position = match before {
260 None => order.len(),
261 Some(before) => {
262 order.iter().position(|sid| *sid == before).ok_or_else(|| {
263 invalid("The movement anchor is no longer in the section")
264 })?
265 }
266 };
267 order.insert(position, edit.space);
268 }
269 }
270 if order.first().is_some_and(|sid| levels[sid] != 1) {
271 return Err(invalid(
272 "The section's first page must have indentation level 1",
273 ));
274 }
275 if order == original_order && levels == original_levels {
276 return Ok(BTreeMap::new());
277 }
278 let raw = index.resolve_active(document.root)?;
279 let mut groups: Vec<Vec<ExGuid>> = Vec::new();
280 for sid in order {
281 if levels[&sid] == 1 {
282 groups.push(Vec::new());
283 }
284 groups.last_mut().unwrap().push(sid);
285 }
286 let mut changes = BTreeMap::new();
287 for sid in &removed {
288 let page = index.resolve_active(*sid)?;
289 let manifest_id = page.roots[&1];
290 let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?;
291 if manifest.jcid != 0x60037 {
292 return Err(invalid("Choose ordinary pages for removal"));
293 }
294 manifest.bytes = properties(&[])?;
295 let metadata_id = page.roots[&2];
296 let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?;
297 metadata.set(&[(0x88001de9, &[])])?;
298 changes.insert(
299 *sid,
300 RevisionEdit::Update(BTreeMap::from([
301 (manifest_id, manifest),
302 (metadata_id, metadata),
303 ])),
304 );
305 }
306 let mut replacements = BTreeMap::new();
307 let mut children = Vec::new();
308 for spaces in groups {
309 let head = spaces[0];
310 let old_id = series_by_head.get(&head).copied();
311 let (id, mut series) = if let Some(id) = old_id {
312 (id, PropertyObject::from_object(&raw.objects[&id])?)
313 } else {
314 let edit = selected
315 .get(&head)
316 .ok_or_else(|| invalid("A new series must start at an edited page"))?;
317 let id = ExGuid {
318 guid: edit.guid,
319 n: 1,
320 };
321 if raw.objects.contains_key(&id) {
322 return Err(invalid("The new page-series identity already exists"));
323 }
324 (
325 id,
326 PropertyObject {
327 jcid: 0x60008,
328 bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?,
329 global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])),
330 },
331 )
332 };
333 children.push(id);
334 let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces);
335 if !membership_changed
336 && !spaces.iter().any(|sid| {
337 levels[sid] != original_levels[sid]
338 || copies.get(sid).is_some_and(|id| {
339 matches!(view.nodes[id].kind, Kind::Metadata { level, .. }
340 if level.unwrap_or(1) != levels[sid])
341 })
342 })
343 {
344 continue;
345 }
346 let mut metadata_ids = Vec::new();
347 for sid in &spaces {
348 let page = index.resolve_active(*sid)?;
349 let page_id = page.roots[&2];
350 let original = PropertyObject::from_object(&page.objects[&page_id])?;
351 if old_id.is_none() && *sid == head {
352 series.copy_property(&original, 0x18001c65)?;
353 }
354 if levels[sid] != original_levels[sid] {
355 let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?;
356 metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?;
357 changes.insert(
358 *sid,
359 RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])),
360 );
361 }
362 let copy_id = copies
363 .get(sid)
364 .copied()
365 .unwrap_or_else(|| metadata_id(*sid));
366 let mut copy = match raw.objects.get(&copy_id) {
367 Some(object) => {
368 if object.jcid != 0x20030
369 || (!copies.contains_key(sid) && copy_ids.contains(&copy_id))
370 {
371 return Err(invalid("Page metadata identities overlap"));
372 }
373 PropertyObject::from_object(object)?
374 }
375 None => original,
376 };
377 copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?;
378 copy.reference(copy_id)?;
379 if replacements.insert(copy_id, copy).is_some() {
380 return Err(invalid("Page metadata identities overlap"));
381 }
382 metadata_ids.push(copy_id);
383 }
384 if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) {
385 set_references(&mut series, 0x2c001d63, &spaces)?;
386 set_references(&mut series, 0x24003442, &metadata_ids)?;
387 if replacements.insert(id, series).is_some() {
388 return Err(invalid("Page-series and metadata identities overlap"));
389 }
390 }
391 }
392 if children != section_node.children {
393 let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?;
394 set_references(&mut parent, 0x24001c20, &children)?;
395 replacements.insert(section_id, parent);
396 }
397 changes.insert(document.root, RevisionEdit::Update(replacements));
398 Ok(changes)
399 })
400}
401
402/// An empty top-level page with stable identities and creation time.
403/// Retain the intent across retries; an existing page identity rejects duplicate creation.
404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
405#[serde(deny_unknown_fields)]
406pub struct PageCreation {
407 guid: [u8; 16],
408 series_guid: [u8; 16],
409 before: Option<ExGuid>,
410 title: Option<String>,
411 author: String,
412 created: u32,
413}
414
415impl PageCreation {
416 /// Appends a page, or inserts before the first page space of an existing series.
417 /// `Some("")` creates an empty title field; `None` creates a page without a title node.
418 /// The page has no body outlines or generated date/time text and does not apply a template.
419 pub fn new(before: Option<ExGuid>, title: Option<&str>, author: &str) -> Result<Self, Error> {
420 let page = Self {
421 guid: fresh_guid()?,
422 series_guid: fresh_guid()?,
423 before,
424 title: title.map(str::to_owned),
425 author: author.to_owned(),
426 created: current_timestamps()?.0,
427 };
428 page.validate()?;
429 Ok(page)
430 }
431
432 pub fn space(&self) -> ExGuid {
433 ExGuid {
434 guid: self.guid,
435 n: 1,
436 }
437 }
438
439 pub fn object(&self) -> ExGuid {
440 ExGuid {
441 guid: self.guid,
442 n: 12,
443 }
444 }
445
446 pub fn title_object(&self) -> Option<ExGuid> {
447 self.title.as_ref().map(|_| ExGuid {
448 guid: self.guid,
449 n: 16,
450 })
451 }
452
453 /// Changes the insertion anchor while retaining all identities and creation metadata.
454 pub fn reposition(&self, before: Option<ExGuid>) -> Result<Self, Error> {
455 let mut page = self.clone();
456 page.before = before;
457 page.validate()?;
458 Ok(page)
459 }
460
461 fn validate(&self) -> Result<(), Error> {
462 if self.guid == [0; 16]
463 || self.series_guid == [0; 16]
464 || self.guid == self.series_guid
465 || self.author.contains('\0')
466 || self.before.is_some_and(|id| id.guid == [0; 16])
467 || self
468 .title
469 .as_ref()
470 .is_some_and(|title| title.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}']))
471 {
472 return Err(invalid(
473 "Use a new page identity, an existing page anchor, and ordinary single-line title text",
474 ));
475 }
476 Ok(())
477 }
478
479 pub(crate) fn apply(&self, source: &[u8]) -> Result<Vec<u8>, Error> {
480 self.validate()?;
481 write_revisions(source, |index| {
482 if index.spaces.contains_key(&self.space()) {
483 return Err(invalid(
484 "This page identity already exists; reconcile the original creation",
485 ));
486 }
487 let document = Document::parse(index)?;
488 document.pages()?;
489 let view = document.active(document.root)?;
490 let section_id = view.roots[&1];
491 let section_node = &view.nodes[&section_id];
492 if !matches!(section_node.kind, Kind::Section { .. })
493 || section_node.extra[0]
494 .iter()
495 .any(|field| field.id == 0x88001cde)
496 {
497 return Err(invalid("Choose an editable section for the new page"));
498 }
499 let position = match self.before {
500 None => section_node.children.len(),
501 Some(before) => section_node
502 .children
503 .iter()
504 .position(|id| view.nodes[id].spaces.first() == Some(&before))
505 .ok_or_else(|| {
506 invalid("Insert before the first page of an existing series, or append")
507 })?,
508 };
509 let raw = index.resolve_active(document.root)?;
510 let id = |n| ExGuid { guid: self.guid, n };
511 let reference = |n: u32| n.to_le_bytes().to_vec();
512 let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000)
513 .to_le_bytes()
514 .to_vec();
515 let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec());
516 let mut table = BTreeMap::from([(0, self.guid)]);
517 let metadata_id = metadata_id(self.space());
518 table.insert(1, metadata_id.guid);
519 let table = Arc::new(table);
520 let object = |jcid, values: Vec<_>| -> Result<_, Error> {
521 Ok(PropertyObject {
522 jcid,
523 bytes: properties(&values)?,
524 global_ids: Arc::clone(&table),
525 })
526 };
527 let title = self.title.as_deref().unwrap_or_default().trim_start();
528 let metadata = vec![
529 (0x1c001c30, self.guid.to_vec()),
530 (0x1c001cf3, string(title)),
531 (0x14001d82, reference(40)),
532 (0x1400348b, reference(40)),
533 (0x14001dff, reference(1)),
534 (0x18001c65, timestamp.clone()),
535 ];
536 let mut page = vec![
537 modified(),
538 (0x1c001d75, string(&self.author)),
539 (0x1c001d3c, string("")),
540 ];
541 if self.title.is_some() {
542 page.push((0x24001d5f, reference(13)));
543 }
544 let mut objects = BTreeMap::from([
545 (id(10), object(0x60037, vec![(0x24001c1f, reference(12))])?),
546 (id(11), object(0x20030, metadata.clone())?),
547 (id(12), object(0x6000b, page)?),
548 ]);
549 if let Some(title) = &self.title {
550 for (n, jcid, values) in [
551 (
552 13,
553 0x6002c,
554 vec![
555 modified(),
556 (0x24001c20, reference(14)),
557 (0x14001c14, 0_f32.to_le_bytes().to_vec()),
558 (0x14001c15, 0_f32.to_le_bytes().to_vec()),
559 ],
560 ),
561 (
562 14,
563 0x6000c,
564 vec![
565 modified(),
566 (0x24001c20, reference(15)),
567 (0x0c001c03, vec![1]),
568 ],
569 ),
570 (
571 15,
572 0x6000d,
573 vec![
574 modified(),
575 (0x24001c1f, reference(16)),
576 (0x0c001c03, vec![1]),
577 (0x20001d78, reference(17)),
578 (0x20001d79, reference(17)),
579 (0x14001d09, self.created.to_le_bytes().to_vec()),
580 (0x88001cb4, vec![]),
581 ],
582 ),
583 (
584 16,
585 0x6000e,
586 vec![
587 modified(),
588 (0x1c001c22, string(title)),
589 (0x24001e13, reference(19)),
590 (0x2000342c, reference(18)),
591 (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()),
592 (0x88001cb4, vec![]),
593 ],
594 ),
595 (17, 0x120001, vec![(0x1c001d75, string(&self.author))]),
596 (
597 18,
598 0x12004d,
599 vec![
600 (0x1c00345a, string("PageTitle")),
601 (0x1c001c0a, string("Calibri")),
602 (0x10001c0b, 34_u16.to_le_bytes().to_vec()),
603 ],
604 ),
605 (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]),
606 ] {
607 objects.insert(id(n), object(jcid, values)?);
608 }
609 }
610 let series = object(
611 0x60008,
612 vec![
613 (0x1c001c30, self.series_guid.to_vec()),
614 (0x18001c65, timestamp),
615 (0x2c001d63, reference(1)),
616 (0x24003442, reference(257)),
617 ],
618 )?;
619 if raw.objects.contains_key(&id(2)) || raw.objects.contains_key(&metadata_id) {
620 return Err(invalid("The new page's section identities already exist"));
621 }
622 let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?;
623 let mut children = section_node.children.clone();
624 children.insert(position, id(2));
625 let mut references = Vec::new();
626 for child in children {
627 references.extend_from_slice(&parent.reference(child)?);
628 }
629 parent.set(&[(0x24001c20, &references)])?;
630 Ok(BTreeMap::from([
631 (
632 document.root,
633 RevisionEdit::Update(BTreeMap::from([
634 (section_id, parent),
635 (id(2), series),
636 (metadata_id, object(0x20030, metadata)?),
637 ])),
638 ),
639 (
640 self.space(),
641 RevisionEdit::Create {
642 roots: BTreeMap::from([(1, id(10)), (2, id(11))]),
643 objects,
644 },
645 ),
646 ]))
647 })
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use crate::{RevisionIndex, Store};
655
656 #[test]
657 fn page_movement_restores_an_optional_metadata_cache_from_history() {
658 let source =
659 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
660 let store = Store::parse(source).unwrap();
661 let index = RevisionIndex::parse(&store).unwrap();
662 let document = Document::parse(&index).unwrap();
663 let sid = document.pages().unwrap()[3].0;
664 let view = document.active(document.root).unwrap();
665 let series_id = view.nodes[&view.roots[&1]].children[3];
666 let copy_id = metadata_id(sid);
667 for unknown in [false, true] {
668 let source = if unknown {
669 write_revisions(source, |index| {
670 let raw = index.resolve_active(index.root)?;
671 let mut copy = PropertyObject::from_object(&raw.objects[&copy_id])?;
672 copy.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?;
673 Ok(BTreeMap::from([(
674 index.root,
675 RevisionEdit::Update(BTreeMap::from([(copy_id, copy)])),
676 )]))
677 })
678 .unwrap()
679 } else {
680 source.to_vec()
681 };
682 let source = write_revisions(&source, |index| {
683 let raw = index.resolve_active(index.root)?;
684 let mut series = PropertyObject::from_object(&raw.objects[&series_id])?;
685 series.remove(&[0x24003442])?;
686 Ok(BTreeMap::from([(
687 index.root,
688 RevisionEdit::Update(BTreeMap::from([(series_id, series)])),
689 )]))
690 })
691 .unwrap();
692 for changed in [false, true] {
693 for unrelated_edit in [false, true] {
694 let result = write_revisions(&source, |index| {
695 let raw = index.resolve_active(index.root)?;
696 assert!(!raw.reachable()?.contains(&copy_id));
697 let mut copy = PropertyObject::from_object(&raw.objects[&copy_id])?;
698 if changed {
699 copy.set(&[(0x14001dff, &2_u32.to_le_bytes())])?;
700 }
701 let mut objects = BTreeMap::from([(copy_id, copy)]);
702 if unrelated_edit {
703 let mut series = PropertyObject::from_object(&raw.objects[&series_id])?;
704 series.set(&[(0x1400abcd, &88_u32.to_le_bytes())])?;
705 objects.insert(series_id, series);
706 }
707 Ok(BTreeMap::from([(
708 index.root,
709 RevisionEdit::Update(objects),
710 )]))
711 });
712 assert_eq!(
713 result.unwrap_err().message,
714 "Edited object is not reachable in the resulting revision"
715 );
716 }
717 }
718 let written =
719 edit_pages(&source, &[PageEdit::set_level(sid, 2).unwrap()], &[]).unwrap();
720 let store = Store::parse(&written).unwrap();
721 let index = RevisionIndex::parse(&store).unwrap();
722 index.validate_current().unwrap();
723 assert_eq!(Document::parse(&index).unwrap().pages().unwrap().len(), 9);
724 let current = index
725 .resolve(
726 index.root,
727 index.spaces[&index.root].labels[&(ExGuid::default(), 1)],
728 )
729 .unwrap();
730 assert!(current.reachable().unwrap().contains(&copy_id));
731 let copy = PropertyObject::from_object(&current.objects[&copy_id]).unwrap();
732 let properties = crate::PropertySets::parse(&copy.bytes).unwrap();
733 assert_eq!(unknown, properties.sets[0].iter().any(|field| field.id == 0x1400abcd
734 && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes())));
735 let old_store = Store::parse(&source).unwrap();
736 let old_index = RevisionIndex::parse(&old_store).unwrap();
737 for (space, revisions) in &old_index.spaces {
738 for revision in revisions.revisions.keys() {
739 assert_eq!(
740 format!("{:?}", old_index.resolve(*space, *revision).unwrap()),
741 format!("{:?}", index.resolve(*space, *revision).unwrap())
742 );
743 }
744 }
745 if !unknown && let Some(output) = std::env::var_os("ONESTORE_PAGE_MOVEMENT_OUTPUT") {
746 let output = std::path::Path::new(&output);
747 assert!(output.is_absolute());
748 for (name, bytes) in [
749 ("optional-cache-source", source),
750 ("optional-cache", written),
751 ] {
752 let path = output.join(name);
753 std::fs::create_dir_all(&path).unwrap();
754 std::fs::write(path.join("Lifecycle.one"), bytes).unwrap();
755 }
756 }
757 }
758 }
759
760 #[test]
761 fn page_movement_rejects_empty_manifests_and_series() {
762 let source =
763 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
764 let store = Store::parse(source).unwrap();
765 let index = RevisionIndex::parse(&store).unwrap();
766 let document = Document::parse(&index).unwrap();
767 let sid = document.pages().unwrap()[3].0;
768 for empty_series in [false, true] {
769 let invalid_source = write_revisions(source, |index| {
770 let target = if empty_series { index.root } else { sid };
771 let raw = index.resolve_active(target)?;
772 let id = if empty_series {
773 let view = document.active(document.root).unwrap();
774 view.nodes[&view.roots[&1]].children[3]
775 } else {
776 raw.roots[&1]
777 };
778 let mut object = PropertyObject::from_object(&raw.objects[&id])?;
779 set_references(
780 &mut object,
781 if empty_series { 0x2c001d63 } else { 0x24001c1f },
782 &[],
783 )?;
784 Ok(BTreeMap::from([(
785 target,
786 RevisionEdit::Update(BTreeMap::from([(id, object)])),
787 )]))
788 })
789 .unwrap();
790 assert!(
791 edit_pages(
792 &invalid_source,
793 &[PageEdit::set_level(sid, 2).unwrap()],
794 &[]
795 )
796 .is_err()
797 );
798 }
799 }
800
801 #[test]
802 fn page_moves_retain_unknown_series_and_metadata_properties() {
803 let source =
804 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
805 let store = Store::parse(source).unwrap();
806 let index = RevisionIndex::parse(&store).unwrap();
807 let document = Document::parse(&index).unwrap();
808 let pages = document.pages().unwrap();
809 let view = document.active(document.root).unwrap();
810 let series_id = view.nodes[&view.roots[&1]].children[3];
811 let field = view.nodes[&series_id].extra[0]
812 .iter()
813 .find(|f| f.id == 0x24003442)
814 .unwrap();
815 let FieldValue::Objects(copies) = &field.value else {
816 panic!()
817 };
818 let copy_id = copies[0];
819 let source = write_revisions(source, |index| {
820 let raw = index.resolve_active(index.root)?;
821 let mut objects = BTreeMap::new();
822 for id in [series_id, copy_id] {
823 let mut object = PropertyObject::from_object(&raw.objects[&id])?;
824 object.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?;
825 objects.insert(id, object);
826 }
827 Ok(BTreeMap::from([(
828 index.root,
829 RevisionEdit::Update(objects),
830 )]))
831 })
832 .unwrap();
833 for edits in [
834 vec![PageEdit::set_level(pages[3].0, 2).unwrap()],
835 pages[3..6]
836 .iter()
837 .enumerate()
838 .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap())
839 .collect(),
840 ] {
841 let written = edit_pages(&source, &edits, &[]).unwrap();
842 let old_store = Store::parse(&source).unwrap();
843 let new_store = Store::parse(&written).unwrap();
844 let old = RevisionIndex::parse(&old_store).unwrap();
845 let new = RevisionIndex::parse(&new_store).unwrap();
846 let old_id = old.spaces[&old.root].labels[&(ExGuid::default(), 1)];
847 let new_id = new.spaces[&new.root].labels[&(ExGuid::default(), 1)];
848 assert_eq!(
849 format!("{:?}", old.resolve(old.root, old_id).unwrap()),
850 format!("{:?}", new.resolve(new.root, old_id).unwrap())
851 );
852 let current = new.resolve(new.root, new_id).unwrap();
853 for id in [series_id, copy_id] {
854 let object = PropertyObject::from_object(&current.objects[&id]).unwrap();
855 let properties = crate::PropertySets::parse(&object.bytes).unwrap();
856 assert!(properties.sets[0].iter().any(|field| field.id == 0x1400abcd
857 && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes())));
858 }
859 }
860 }
861}
crates/onestore/src/store.rs+1-1
......@@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet};
33use std::fmt;
44use std::ops::Range;
55
6#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
6#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
77pub struct Error {
88 /// Parser byte offset; zero also represents errors without a byte location.
99 pub offset: usize,
crates/onestore/tests/page_model.rs created+112
......@@ -0,0 +1,112 @@
1use onestore::{
2 RevisionIndex, Store,
3 document::Document,
4 page::{Page, PageObject, ParagraphContent},
5};
6use std::{collections::BTreeSet, fs, path::Path};
7
8fn sections(directory: &Path, out: &mut Vec<std::path::PathBuf>) {
9 for entry in fs::read_dir(directory).unwrap() {
10 let path = entry.unwrap().path();
11 if path.is_dir() {
12 if !matches!(
13 path.file_name().and_then(|n| n.to_str()),
14 Some("private" | "recovered")
15 ) {
16 sections(&path, out);
17 }
18 } else if path.extension().is_some_and(|e| e == "one") {
19 out.push(path);
20 }
21 }
22}
23
24fn identities(page: &Page) -> Vec<onestore::ExGuid> {
25 fn paragraphs(list: &[onestore::page::PageParagraph], ids: &mut Vec<onestore::ExGuid>) {
26 for paragraph in list {
27 ids.push(paragraph.id);
28 match &paragraph.content {
29 ParagraphContent::Text(text) => ids.push(text.id),
30 ParagraphContent::Table(table) => {
31 ids.push(table.id);
32 for row in &table.rows {
33 ids.push(row.id);
34 for cell in &row.cells {
35 ids.push(cell.id);
36 paragraphs(&cell.paragraphs, ids);
37 ids.extend(cell.unsupported.iter().map(|u| u.id));
38 }
39 }
40 }
41 ParagraphContent::Unsupported(unsupported) => ids.push(unsupported.id),
42 }
43 }
44 }
45 let mut ids = Vec::new();
46 for object in &page.objects {
47 ids.push(object.id());
48 match object {
49 PageObject::Outline(outline) => {
50 paragraphs(&outline.paragraphs, &mut ids);
51 ids.extend(outline.unsupported.iter().map(|u| u.id));
52 }
53 PageObject::Title(title) => {
54 for outline in &title.outlines {
55 ids.push(outline.id);
56 paragraphs(&outline.paragraphs, &mut ids);
57 ids.extend(outline.unsupported.iter().map(|u| u.id));
58 }
59 }
60 PageObject::Image(_) | PageObject::Unsupported(_) => {}
61 }
62 }
63 ids
64}
65
66#[test]
67fn every_corpus_page_builds_a_model_with_distinct_identities() {
68 let mut files = Vec::new();
69 sections(
70 Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")),
71 &mut files,
72 );
73 files.sort();
74 assert!(files.len() > 300, "{} sections", files.len());
75 let mut pages = 0;
76 let mut failures = Vec::new();
77 for path in &files {
78 let bytes = fs::read(path).unwrap();
79 let Ok(store) = Store::parse(&bytes) else {
80 continue;
81 };
82 let Ok(index) = RevisionIndex::parse(&store) else {
83 continue;
84 };
85 let Ok(document) = Document::parse(&index) else {
86 continue;
87 };
88 let Ok(listed) = document.pages() else {
89 continue;
90 };
91 for (space, _) in listed {
92 pages += 1;
93 match Page::from_space(&document, space) {
94 Ok(page) => {
95 let ids = identities(&page);
96 let distinct: BTreeSet<_> = ids.iter().collect();
97 if distinct.len() != ids.len() {
98 failures.push(format!("{}: repeated identity", path.display()));
99 }
100 }
101 Err(error) => failures.push(format!("{}: {}", path.display(), error.message)),
102 }
103 }
104 }
105 assert!(pages > 1000, "{pages} pages");
106 assert!(
107 failures.is_empty(),
108 "{}\n{} failures",
109 failures.join("\n"),
110 failures.len()
111 );
112}