authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 01:48:23-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 01:40:12-07:00
log59f5163e0c2a80a4addceedf0e3f4bc3b6c235c3
tree71e646ce3ab8c16345981d6d10be7b7dbcfdc7e5
parent09ee1d6454d9862cd2b895fea88eee8541e6caaf
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: save edited page models to the local replica

Add Operation::Page: a PageIntent records the page model the editor started from, the model it produced and the author. Replica::save publishes the difference locally through PreparedEdit::page and, while the newest pending edit is an unattempted save of the same page, replaces that save instead of queueing another publication, matching OneNote's own save interval (evidence/m10/NATIVE-SAVE-CADENCE.md). Reconciliation compares the remote page with the recorded model: an unchanged page publishes, a changed one is a ContentChanged conflict that review_page resolves with a model reviewed against the remote image. The page model and the document types it embeds gain Clone and serde support; image payloads are identified by their id and left out of equality and serialization. Validation: six new sync tests (publication, coalescing, immutability of an attempted save, conflict and review, independence between pages, reopen); 329 notebook and onestore tests, Clippy and fmt. Assisted-by: claude-fable-5.1

10 files changed, 466 insertions(+), 27 deletions(-)

crates/notebook/examples/cache_probe.rs+1
......@@ -139,6 +139,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
139139 *value as u64 - 6
140140 }
141141 notebook::Operation::Split(_)
142 | notebook::Operation::Page(_)
142143 | notebook::Operation::CreatePage(_)
143144 | notebook::Operation::Pages(_)
144145 | notebook::Operation::Join(_)
crates/notebook/src/lib.rs+108-1
......@@ -8,8 +8,9 @@ pub mod smb;
88use onestore::{
99 ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store,
1010 document::{Document, Kind},
11 page::Page,
1112};
12use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
13use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params};
1314use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration};
1415
1516mod assets;
......@@ -67,9 +68,20 @@ pub struct TextEdit {
6768 pub replacement: String,
6869}
6970
71/// An edited page model together with the stored model it was edited from.
72/// `before` is the precondition reconciliation checks against the remote page.
73#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct PageIntent {
76 pub before: Page,
77 pub after: Page,
78 pub author: String,
79}
80
7081#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
7182#[serde(deny_unknown_fields)]
7283pub enum Operation {
84 Page(PageIntent),
7385 CreatePage(PageCreation),
7486 Pages(PageEdits),
7587 Text(TextEdit),
......@@ -238,6 +250,94 @@ impl Replica {
238250 self.record(source, space, Operation::Insert(insertion.clone()), &edit)
239251 }
240252
253 /// Durably queues an edited page model using the supplied local snapshot.
254 /// While the newest pending edit is an unattempted save of the same page, a new save
255 /// replaces its result instead of queueing another publication, as OneNote does
256 /// within its own save interval. An unchanged model returns `None`.
257 pub fn save(
258 &self,
259 source: &[u8],
260 space: ExGuid,
261 after: &Page,
262 author: &str,
263 ) -> Result<Option<u64>> {
264 let prepared = PreparedEdit::page(source, space, after, author)?;
265 if prepared.as_bytes() == source {
266 return Ok(None);
267 }
268 let before = page_of(source, space)?.ok_or_else(|| {
269 io::Error::new(
270 io::ErrorKind::InvalidData,
271 "The saved page is not in the local image",
272 )
273 })?;
274 {
275 let mut connection = self
276 .connection
277 .lock()
278 .map_err(|_| io::Error::other("Cache owner panicked"))?;
279 let transaction =
280 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
281 let current: Vec<u8> =
282 transaction.query_row("SELECT working FROM replica WHERE id=1", [], |row| {
283 row.get(0)
284 })?;
285 if current != source {
286 return Err(io::Error::new(
287 io::ErrorKind::ResourceBusy,
288 "The local snapshot changed before this edit",
289 )
290 .into());
291 }
292 let newest: Option<(i64, String, String)> = transaction
293 .query_row(
294 "SELECT id, space, operation FROM edits WHERE id=(SELECT max(id) FROM edits)",
295 [],
296 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
297 )
298 .optional()?;
299 if let Some((id, sid, operation)) = newest
300 && sid == space.to_string()
301 && let Ok(Operation::Page(mut head)) = serde_json::from_str::<Operation>(&operation)
302 {
303 let attempted: bool = transaction.query_row(
304 "SELECT EXISTS(SELECT 1 FROM attempt WHERE edit_id=?1) OR EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1)",
305 [id],
306 |row| row.get(0),
307 )?;
308 if !attempted {
309 head.after = after.clone();
310 transaction.execute(
311 "UPDATE edits SET operation=?1 WHERE id=?2",
312 params![
313 serde_json::to_string(&Operation::Page(head))
314 .map_err(io::Error::other)?,
315 id
316 ],
317 )?;
318 transaction.execute(
319 "UPDATE replica SET working=?1 WHERE id=1",
320 [prepared.as_bytes()],
321 )?;
322 transaction.commit()?;
323 drop(connection);
324 self.wake_sync();
325 return Ok(Some(u64::try_from(id).map_err(io::Error::other)?));
326 }
327 }
328 }
329 self.record(
330 source,
331 space,
332 Operation::Page(PageIntent {
333 before,
334 after: after.clone(),
335 author: author.to_owned(),
336 }),
337 &prepared,
338 )
339 }
340
241341 /// Queues a new page and its section entry with stable identities for dependent edits.
242342 pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> {
243343 let edit = PreparedEdit::create_page(source, page)?;
......@@ -331,6 +431,13 @@ fn active_paths(
331431 .collect()
332432}
333433
434fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> {
435 let store = Store::parse(source)?;
436 let index = RevisionIndex::parse(&store)?;
437 let document = Document::parse(&index)?;
438 Ok(Page::from_space(&document, space).ok())
439}
440
334441fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> {
335442 let store = Store::parse(source)?;
336443 let index = RevisionIndex::parse(&store)?;
crates/notebook/src/sync.rs+43
......@@ -139,6 +139,15 @@ impl Replica {
139139 return Ok(Some((intent.id, EditStatus::Published { revision })));
140140 }
141141 let candidate = match &intent.operation {
142 Operation::Page(edit) => page_of(&snapshot, intent.space)?
143 .ok_or(ConflictKind::TargetUnavailable)
144 .and_then(|current| {
145 if current != edit.before {
146 return Err(ConflictKind::ContentChanged);
147 }
148 PreparedEdit::page(&snapshot, intent.space, &edit.after, &edit.author)
149 .map_err(|_| ConflictKind::UnsupportedEdit)
150 }),
142151 Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page)
143152 .map_err(|_| ConflictKind::StructureChanged),
144153 Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?,
......@@ -327,6 +336,13 @@ impl Replica {
327336 )
328337 .into());
329338 }
339 Operation::Page(_) => {
340 return Err(io::Error::new(
341 io::ErrorKind::InvalidInput,
342 "Review a page model conflict using review_page",
343 )
344 .into());
345 }
330346 Operation::Outline(_) => {
331347 return Err(io::Error::new(
332348 io::ErrorKind::InvalidInput,
......@@ -508,6 +524,33 @@ impl Replica {
508524 })
509525 }
510526
527 /// Replaces a conflicting page save with a model reviewed against the remote page.
528 /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed
529 /// model must publish against the remote image, and its author is retained.
530 pub fn review_page(&self, id: u64, local: &[u8], remote: &[u8], after: &Page) -> Result<()> {
531 self.resolve_conflict(id, local, remote, |intent| {
532 let Operation::Page(edit) = intent.operation else {
533 return Err(io::Error::new(
534 io::ErrorKind::InvalidInput,
535 "Select a page save conflict",
536 )
537 .into());
538 };
539 let before = page_of(remote, intent.space)?.ok_or_else(|| {
540 io::Error::new(
541 io::ErrorKind::InvalidInput,
542 "The page is no longer in the remote section",
543 )
544 })?;
545 PreparedEdit::page(remote, intent.space, after, &edit.author)?;
546 Ok(Operation::Page(PageIntent {
547 before,
548 after: after.clone(),
549 author: edit.author,
550 }))
551 })
552 }
553
511554 fn resolve_conflict(
512555 &self,
513556 id: u64,
crates/notebook/tests/sync.rs+2
......@@ -7,6 +7,8 @@ use std::io;
77
88#[path = "../../onestore/tests/support/disk.rs"]
99mod disk;
10#[path = "sync/model.rs"]
11mod model;
1012#[path = "sync/outline.rs"]
1113mod outline;
1214#[path = "sync/page.rs"]
crates/notebook/tests/sync/model.rs created+268
......@@ -0,0 +1,268 @@
1//! Page-model saves: one intent per page until it is attempted, reconciliation against the
2//! remote page, reviewed conflicts, and independence between pages.
3
4use super::*;
5use notebook::{Operation, PageIntent};
6use onestore::page::{Page, PageObject};
7
8const OUTLINES: &[u8] =
9 include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one");
10
11fn page_of(bytes: &[u8], space: ExGuid) -> Page {
12 let store = Store::parse(bytes).unwrap();
13 let index = RevisionIndex::parse(&store).unwrap();
14 let document = Document::parse(&index).unwrap();
15 Page::from_space(&document, space).unwrap()
16}
17
18fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) {
19 let store = Store::parse(bytes).unwrap();
20 let index = RevisionIndex::parse(&store).unwrap();
21 let document = Document::parse(&index).unwrap();
22 document
23 .pages()
24 .unwrap()
25 .into_iter()
26 .find_map(|(sid, _)| {
27 let page = Page::from_space(&document, sid).unwrap();
28 (page.title == title).then_some((sid, page))
29 })
30 .unwrap()
31}
32
33/// Appends `suffix` to the first body paragraph of the first body outline.
34fn append(page: &mut Page, suffix: &str) {
35 let outline = page
36 .objects
37 .iter_mut()
38 .find_map(|object| match object {
39 PageObject::Outline(outline) => Some(outline),
40 _ => None,
41 })
42 .unwrap();
43 let text = outline.paragraphs[0].text_mut().unwrap();
44 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
45 let format = text.text.format_at(end).unwrap().clone();
46 text.text
47 .apply(onestore::page::text::Edit {
48 range: end..end,
49 replacement: onestore::page::Paragraph::new(suffix.into(), format),
50 })
51 .unwrap();
52}
53
54fn first_text(page: &Page) -> String {
55 page.objects
56 .iter()
57 .find_map(|object| match object {
58 PageObject::Outline(outline) => {
59 Some(outline.paragraphs[0].text().unwrap().text.text().to_owned())
60 }
61 _ => None,
62 })
63 .unwrap()
64}
65
66#[test]
67fn a_saved_page_model_publishes_and_reads_back() {
68 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
69 let directory = tempfile::tempdir().unwrap();
70 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
71 append(&mut after, " saved 🦀");
72 let id = cache
73 .save(OUTLINES, space, &after, "Model author")
74 .unwrap()
75 .unwrap();
76 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
77 assert_eq!(page_of(&cache.snapshot().unwrap(), space), after);
78 let pending = cache.pending().unwrap();
79 assert_eq!(pending.len(), 1);
80 let Operation::Page(PageIntent {
81 before,
82 after: stored,
83 author,
84 }) = &pending[0].operation
85 else {
86 panic!()
87 };
88 assert_eq!(
89 (before, stored, author.as_str()),
90 (&page_of(OUTLINES, space), &after, "Model author")
91 );
92 let mut server = Server::new(OUTLINES);
93 assert!(
94 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
95 );
96 assert_eq!(page_of(&server.durable, space), after);
97 assert_eq!(server.publications, 1);
98 assert!(cache.pending().unwrap().is_empty());
99 assert_eq!(
100 cache
101 .save(&cache.snapshot().unwrap(), space, &after, "Model author")
102 .unwrap(),
103 None
104 );
105}
106
107#[test]
108fn saves_before_an_attempt_coalesce_into_one_publication() {
109 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
110 let directory = tempfile::tempdir().unwrap();
111 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
112 append(&mut after, " one");
113 let id = cache
114 .save(OUTLINES, space, &after, "Model author")
115 .unwrap()
116 .unwrap();
117 let mut later = page_of(&cache.snapshot().unwrap(), space);
118 append(&mut later, " two");
119 assert_eq!(
120 cache
121 .save(&cache.snapshot().unwrap(), space, &later, "Model author")
122 .unwrap(),
123 Some(id)
124 );
125 let pending = cache.pending().unwrap();
126 assert_eq!(pending.len(), 1);
127 let Operation::Page(intent) = &pending[0].operation else {
128 panic!()
129 };
130 assert_eq!(intent.before, page_of(OUTLINES, space));
131 assert_eq!(intent.after, later);
132 let mut server = Server::new(OUTLINES);
133 assert!(
134 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
135 );
136 assert_eq!(server.publications, 1);
137 assert_eq!(
138 first_text(&page_of(&server.durable, space)),
139 first_text(&later)
140 );
141 let mut third = page_of(&cache.snapshot().unwrap(), space);
142 append(&mut third, " three");
143 let next = cache
144 .save(&cache.snapshot().unwrap(), space, &third, "Model author")
145 .unwrap()
146 .unwrap();
147 assert!(next > id);
148}
149
150#[test]
151fn an_attempted_save_is_never_rewritten() {
152 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
153 let directory = tempfile::tempdir().unwrap();
154 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
155 append(&mut after, " uncertain");
156 let id = cache
157 .save(OUTLINES, space, &after, "Model author")
158 .unwrap()
159 .unwrap();
160 let mut server = Server::new(OUTLINES);
161 server.fault = Fault::UnknownAfter;
162 assert!(cache.sync_once(&mut server).is_err());
163 assert!(matches!(
164 cache.status(id).unwrap(),
165 Some(EditStatus::AwaitingConfirmation { .. })
166 ));
167 let mut more = page_of(&cache.snapshot().unwrap(), space);
168 append(&mut more, " more");
169 let next = cache
170 .save(&cache.snapshot().unwrap(), space, &more, "Model author")
171 .unwrap()
172 .unwrap();
173 assert!(next > id);
174 let pending = cache.pending().unwrap();
175 assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]);
176 let Operation::Page(first) = &pending[0].operation else {
177 panic!()
178 };
179 assert_eq!(first.after, after);
180 let Operation::Page(second) = &pending[1].operation else {
181 panic!()
182 };
183 assert_eq!((&second.before, &second.after), (&after, &more));
184}
185
186#[test]
187fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() {
188 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
189 let directory = tempfile::tempdir().unwrap();
190 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
191 append(&mut after, " local");
192 let id = cache
193 .save(OUTLINES, space, &after, "Model author")
194 .unwrap()
195 .unwrap();
196 let mut remote_page = page_of(OUTLINES, space);
197 append(&mut remote_page, " remote");
198 let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap();
199 let mut server = Server::new(remote.as_bytes());
200 assert_eq!(
201 cache.sync_once(&mut server).unwrap(),
202 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
203 );
204 assert_eq!(server.publications, 0);
205 assert!(cache.pending().unwrap().len() == 1);
206 let local = cache.snapshot().unwrap();
207 let observed = cache.remote_snapshot().unwrap();
208 assert_eq!(observed, remote.as_bytes());
209 let mut reviewed = page_of(&observed, space);
210 append(&mut reviewed, " local");
211 assert!(
212 cache
213 .review_page(id + 1, &local, &observed, &reviewed)
214 .is_err()
215 );
216 assert!(cache.review_page(id, &local, OUTLINES, &reviewed).is_err());
217 cache.review_page(id, &local, &observed, &reviewed).unwrap();
218 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
219 assert!(
220 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
221 );
222 assert_eq!(
223 first_text(&page_of(&server.durable, space)),
224 first_text(&reviewed)
225 );
226 assert!(first_text(&page_of(&server.durable, space)).ends_with(" remote local"));
227}
228
229#[test]
230fn remote_changes_to_other_pages_do_not_conflict() {
231 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
232 let (other, mut other_page) = page_titled(OUTLINES, "Delete leaf");
233 let directory = tempfile::tempdir().unwrap();
234 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
235 append(&mut after, " local");
236 let id = cache
237 .save(OUTLINES, space, &after, "Model author")
238 .unwrap()
239 .unwrap();
240 append(&mut other_page, " elsewhere");
241 let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap();
242 let mut server = Server::new(remote.as_bytes());
243 assert!(
244 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
245 );
246 assert_eq!(page_of(&server.durable, space), after);
247 assert_eq!(page_of(&server.durable, other), other_page);
248 assert_eq!(page_of(&cache.snapshot().unwrap(), other), other_page);
249}
250
251#[test]
252fn pending_saves_survive_reopening_the_cache() {
253 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
254 let directory = tempfile::tempdir().unwrap();
255 let path = directory.path().join("cache.sqlite");
256 let cache = Replica::create(&path, OUTLINES).unwrap();
257 append(&mut after, " durable");
258 let id = cache
259 .save(OUTLINES, space, &after, "Model author")
260 .unwrap()
261 .unwrap();
262 let pending = cache.pending().unwrap();
263 drop(cache);
264 let reopened = Replica::open(&path).unwrap();
265 assert_eq!(reopened.pending().unwrap(), pending);
266 assert_eq!(reopened.status(id).unwrap(), Some(EditStatus::Pending));
267 assert_eq!(page_of(&reopened.snapshot().unwrap(), space), after);
268}
crates/onestore/src/document.rs+11-7
......@@ -7,11 +7,15 @@ use crate::{
77 Error, ExGuid, FileDataReference, IdStream, Object, ObjectData, Property, PropertySets,
88 RevisionIndex, Store, Value, bytes::Cursor,
99};
10use serde::Serialize;
10use serde::{Deserialize, Serialize};
1111use std::collections::{BTreeMap, BTreeSet};
1212
1313type Result<T> = std::result::Result<T, Error>;
1414
15fn no_ciphertext<'a>() -> &'a [u8] {
16 &[]
17}
18
1519fn invalid(message: &'static str) -> Error {
1620 Error { offset: 0, message }
1721}
......@@ -73,7 +77,7 @@ pub struct Element<'a> {
7377 pub extra: Vec<Vec<Field<'a>>>,
7478}
7579
76#[derive(Debug, Default, Clone, PartialEq, Serialize)]
80#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
7781pub struct Layout {
7882 pub x: Option<f32>,
7983 pub y: Option<f32>,
......@@ -86,7 +90,7 @@ pub struct Layout {
8690
8791macro_rules! format_fields {
8892 ($($field:ident: $value:ty),* $(,)?) => {
89 #[derive(Debug, Default, Clone, PartialEq, Serialize)]
93 #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
9094 pub struct Format { $(pub $field: Option<$value>),* }
9195
9296 impl Format {
......@@ -123,7 +127,7 @@ format_fields! {
123127 list_spacing: f32,
124128}
125129
126#[derive(Debug, PartialEq, Serialize)]
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127131pub struct TextRun {
128132 pub start: u32,
129133 pub end: u32,
......@@ -251,7 +255,7 @@ impl Revision<'_> {
251255 }
252256}
253257
254#[derive(Debug, Clone, PartialEq, Serialize)]
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255259/// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits.
256260pub struct Tag {
257261 pub definition: Option<ExGuid>,
......@@ -266,7 +270,7 @@ pub struct Tag {
266270 pub extra_set: usize,
267271}
268272
269#[derive(Debug, PartialEq, Serialize)]
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270274#[serde(tag = "type")]
271275pub enum Kind<'a> {
272276 Section {
......@@ -395,7 +399,7 @@ pub enum Kind<'a> {
395399 color: Option<u32>,
396400 },
397401 Encrypted {
398 #[serde(serialize_with = "hex")]
402 #[serde(serialize_with = "hex", skip_deserializing, default = "no_ciphertext")]
399403 ciphertext: &'a [u8],
400404 },
401405 Unknown,
crates/onestore/src/files.rs+1-1
......@@ -1,6 +1,6 @@
11use crate::{Error, Object, ObjectData, Reference, Store, bytes::Cursor};
22
3#[derive(Debug, PartialEq, Eq, serde::Serialize)]
3#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub enum FileDataReference {
55 Internal([u8; 16]),
66 External(String),
crates/onestore/src/page/mod.rs+26-15
......@@ -15,13 +15,13 @@ pub(crate) mod write;
1515pub use text::Paragraph;
1616
1717/// The role of a title-outline paragraph that displays the page's creation date or time.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
1919pub enum DateField {
2020 Date = 0,
2121 Time = 1,
2222}
2323
24#[derive(Debug, PartialEq)]
24#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
2525pub struct Page {
2626 pub title: String,
2727 /// FILETIME ticks from the page's TopologyCreationTimeStamp.
......@@ -31,13 +31,13 @@ pub struct Page {
3131 pub definitions: BTreeMap<ExGuid, Definition>,
3232}
3333
34#[derive(Debug, PartialEq)]
34#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
3535pub struct Definition {
3636 pub kind: Kind<'static>,
3737 pub format: Format,
3838}
3939
40#[derive(Debug, PartialEq)]
40#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
4141pub enum PageObject {
4242 Outline(Outline),
4343 Title(Title),
......@@ -73,7 +73,7 @@ impl PageObject {
7373 }
7474}
7575
76#[derive(Debug, PartialEq)]
76#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
7777pub struct Title {
7878 pub id: ExGuid,
7979 pub date: Option<ExGuid>,
......@@ -81,7 +81,7 @@ pub struct Title {
8181 pub outlines: Vec<Outline>,
8282}
8383
84#[derive(Clone, Debug, PartialEq)]
84#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
8585pub struct Outline {
8686 pub id: ExGuid,
8787 pub title: bool,
......@@ -92,7 +92,7 @@ pub struct Outline {
9292 pub unsupported: Vec<Unsupported>,
9393}
9494
95#[derive(Clone, Debug, PartialEq)]
95#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
9696pub struct PageParagraph {
9797 pub id: ExGuid,
9898 pub parent: Option<ExGuid>,
......@@ -105,14 +105,14 @@ pub struct PageParagraph {
105105 pub collapsed: bool,
106106}
107107
108#[derive(Clone, Debug, PartialEq)]
108#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
109109pub enum ParagraphContent {
110110 Text(TextObject),
111111 Table(Table),
112112 Unsupported(Unsupported),
113113}
114114
115#[derive(Clone, Debug, PartialEq)]
115#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
116116pub struct Table {
117117 pub id: ExGuid,
118118 pub columns: Vec<TableColumn>,
......@@ -122,19 +122,19 @@ pub struct Table {
122122 pub tags: Vec<Tag>,
123123}
124124
125#[derive(Clone, Debug, PartialEq)]
125#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
126126pub struct TableColumn {
127127 pub width: f32,
128128 pub locked: bool,
129129}
130130
131#[derive(Clone, Debug, PartialEq)]
131#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
132132pub struct TableRow {
133133 pub id: ExGuid,
134134 pub cells: Vec<TableCell>,
135135}
136136
137#[derive(Clone, Debug, PartialEq)]
137#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
138138pub struct TableCell {
139139 pub id: ExGuid,
140140 pub layout: Layout,
......@@ -144,7 +144,7 @@ pub struct TableCell {
144144 pub unsupported: Vec<Unsupported>,
145145}
146146
147#[derive(Clone, Debug, PartialEq)]
147#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
148148pub struct TextObject {
149149 pub id: ExGuid,
150150 pub date_field: Option<DateField>,
......@@ -152,16 +152,27 @@ pub struct TextObject {
152152 pub tags: Vec<Tag>,
153153}
154154
155#[derive(Debug, PartialEq)]
155/// Payload bytes are identified by `id`, so equality and serialization leave them out.
156#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
156157pub struct Image {
157158 pub id: ExGuid,
158159 pub layout: Layout,
160 #[serde(skip)]
159161 pub bytes: Option<Arc<[u8]>>,
160162 pub alt: Option<String>,
161163 pub background: bool,
162164}
163165
164#[derive(Clone, Debug, PartialEq)]
166impl PartialEq for Image {
167 fn eq(&self, other: &Self) -> bool {
168 self.id == other.id
169 && self.layout == other.layout
170 && self.alt == other.alt
171 && self.background == other.background
172 }
173}
174
175#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
165176pub struct Unsupported {
166177 pub id: ExGuid,
167178 pub jcid: u32,
crates/onestore/src/page/text.rs+2-2
......@@ -1,14 +1,14 @@
11use crate::{ExGuid, document::Format};
22use std::{fmt, ops::Range};
33
4#[derive(Clone, Debug, PartialEq)]
4#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
55pub struct Span {
66 /// Exclusive UTF-8 boundary; the start is the preceding span's end.
77 pub end: usize,
88 pub format: Format,
99}
1010
11#[derive(Clone, Debug, PartialEq)]
11#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
1212/// Editable text styles are coalesced independently of serialized run boundaries.
1313pub struct Paragraph {
1414 text: String,
crates/onestore/src/page/write.rs+4-1
......@@ -222,7 +222,10 @@ impl Lowering<'_> {
222222 page.objects
223223 .iter()
224224 .filter_map(|object| match object {
225 PageObject::Image(image) => Some(format!("{image:?}")),
225 PageObject::Image(image) => Some(format!(
226 "{:?} {:?} {:?} {:?}",
227 image.id, image.layout, image.alt, image.background
228 )),
226229 PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")),
227230 PageObject::Title(title) => Some(format!(
228231 "{:?} {:?} {:?} {:?}",