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>> {...@@ -139,6 +139,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
139 *value as u64 - 6139 *value as u64 - 6
140 }140 }
141 notebook::Operation::Split(_)141 notebook::Operation::Split(_)
142 | notebook::Operation::Page(_)
142 | notebook::Operation::CreatePage(_)143 | notebook::Operation::CreatePage(_)
143 | notebook::Operation::Pages(_)144 | notebook::Operation::Pages(_)
144 | notebook::Operation::Join(_)145 | notebook::Operation::Join(_)
crates/notebook/src/lib.rs+108-1
...@@ -8,8 +8,9 @@ pub mod smb;...@@ -8,8 +8,9 @@ pub mod smb;
8use onestore::{8use onestore::{
9 ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store,9 ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store,
10 document::{Document, Kind},10 document::{Document, Kind},
11 page::Page,
11};12};
12use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};13use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params};
13use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration};14use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration};
1415
15mod assets;16mod assets;
...@@ -67,9 +68,20 @@ pub struct TextEdit {...@@ -67,9 +68,20 @@ pub struct TextEdit {
67 pub replacement: String,68 pub replacement: String,
68}69}
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
70#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]81#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
71#[serde(deny_unknown_fields)]82#[serde(deny_unknown_fields)]
72pub enum Operation {83pub enum Operation {
84 Page(PageIntent),
73 CreatePage(PageCreation),85 CreatePage(PageCreation),
74 Pages(PageEdits),86 Pages(PageEdits),
75 Text(TextEdit),87 Text(TextEdit),
...@@ -238,6 +250,94 @@ impl Replica {...@@ -238,6 +250,94 @@ impl Replica {
238 self.record(source, space, Operation::Insert(insertion.clone()), &edit)250 self.record(source, space, Operation::Insert(insertion.clone()), &edit)
239 }251 }
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
241 /// Queues a new page and its section entry with stable identities for dependent edits.341 /// Queues a new page and its section entry with stable identities for dependent edits.
242 pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> {342 pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> {
243 let edit = PreparedEdit::create_page(source, page)?;343 let edit = PreparedEdit::create_page(source, page)?;
...@@ -331,6 +431,13 @@ fn active_paths(...@@ -331,6 +431,13 @@ fn active_paths(
331 .collect()431 .collect()
332}432}
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
334fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> {441fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> {
335 let store = Store::parse(source)?;442 let store = Store::parse(source)?;
336 let index = RevisionIndex::parse(&store)?;443 let index = RevisionIndex::parse(&store)?;
crates/notebook/src/sync.rs+43
...@@ -139,6 +139,15 @@ impl Replica {...@@ -139,6 +139,15 @@ impl Replica {
139 return Ok(Some((intent.id, EditStatus::Published { revision })));139 return Ok(Some((intent.id, EditStatus::Published { revision })));
140 }140 }
141 let candidate = match &intent.operation {141 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 }),
142 Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page)151 Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page)
143 .map_err(|_| ConflictKind::StructureChanged),152 .map_err(|_| ConflictKind::StructureChanged),
144 Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?,153 Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?,
...@@ -327,6 +336,13 @@ impl Replica {...@@ -327,6 +336,13 @@ impl Replica {
327 )336 )
328 .into());337 .into());
329 }338 }
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 }
330 Operation::Outline(_) => {346 Operation::Outline(_) => {
331 return Err(io::Error::new(347 return Err(io::Error::new(
332 io::ErrorKind::InvalidInput,348 io::ErrorKind::InvalidInput,
...@@ -508,6 +524,33 @@ impl Replica {...@@ -508,6 +524,33 @@ impl Replica {
508 })524 })
509 }525 }
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
511 fn resolve_conflict(554 fn resolve_conflict(
512 &self,555 &self,
513 id: u64,556 id: u64,
crates/notebook/tests/sync.rs+2
...@@ -7,6 +7,8 @@ use std::io;...@@ -7,6 +7,8 @@ use std::io;
77
8#[path = "../../onestore/tests/support/disk.rs"]8#[path = "../../onestore/tests/support/disk.rs"]
9mod disk;9mod disk;
10#[path = "sync/model.rs"]
11mod model;
10#[path = "sync/outline.rs"]12#[path = "sync/outline.rs"]
11mod outline;13mod outline;
12#[path = "sync/page.rs"]14#[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::{...@@ -7,11 +7,15 @@ use crate::{
7 Error, ExGuid, FileDataReference, IdStream, Object, ObjectData, Property, PropertySets,7 Error, ExGuid, FileDataReference, IdStream, Object, ObjectData, Property, PropertySets,
8 RevisionIndex, Store, Value, bytes::Cursor,8 RevisionIndex, Store, Value, bytes::Cursor,
9};9};
10use serde::Serialize;10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};11use std::collections::{BTreeMap, BTreeSet};
1212
13type Result<T> = std::result::Result<T, Error>;13type Result<T> = std::result::Result<T, Error>;
1414
15fn no_ciphertext<'a>() -> &'a [u8] {
16 &[]
17}
18
15fn invalid(message: &'static str) -> Error {19fn invalid(message: &'static str) -> Error {
16 Error { offset: 0, message }20 Error { offset: 0, message }
17}21}
...@@ -73,7 +77,7 @@ pub struct Element<'a> {...@@ -73,7 +77,7 @@ pub struct Element<'a> {
73 pub extra: Vec<Vec<Field<'a>>>,77 pub extra: Vec<Vec<Field<'a>>>,
74}78}
7579
76#[derive(Debug, Default, Clone, PartialEq, Serialize)]80#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
77pub struct Layout {81pub struct Layout {
78 pub x: Option<f32>,82 pub x: Option<f32>,
79 pub y: Option<f32>,83 pub y: Option<f32>,
...@@ -86,7 +90,7 @@ pub struct Layout {...@@ -86,7 +90,7 @@ pub struct Layout {
8690
87macro_rules! format_fields {91macro_rules! format_fields {
88 ($($field:ident: $value:ty),* $(,)?) => {92 ($($field:ident: $value:ty),* $(,)?) => {
89 #[derive(Debug, Default, Clone, PartialEq, Serialize)]93 #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
90 pub struct Format { $(pub $field: Option<$value>),* }94 pub struct Format { $(pub $field: Option<$value>),* }
9195
92 impl Format {96 impl Format {
...@@ -123,7 +127,7 @@ format_fields! {...@@ -123,7 +127,7 @@ format_fields! {
123 list_spacing: f32,127 list_spacing: f32,
124}128}
125129
126#[derive(Debug, PartialEq, Serialize)]130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct TextRun {131pub struct TextRun {
128 pub start: u32,132 pub start: u32,
129 pub end: u32,133 pub end: u32,
...@@ -251,7 +255,7 @@ impl Revision<'_> {...@@ -251,7 +255,7 @@ impl Revision<'_> {
251 }255 }
252}256}
253257
254#[derive(Debug, Clone, PartialEq, Serialize)]258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255/// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits.259/// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits.
256pub struct Tag {260pub struct Tag {
257 pub definition: Option<ExGuid>,261 pub definition: Option<ExGuid>,
...@@ -266,7 +270,7 @@ pub struct Tag {...@@ -266,7 +270,7 @@ pub struct Tag {
266 pub extra_set: usize,270 pub extra_set: usize,
267}271}
268272
269#[derive(Debug, PartialEq, Serialize)]273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270#[serde(tag = "type")]274#[serde(tag = "type")]
271pub enum Kind<'a> {275pub enum Kind<'a> {
272 Section {276 Section {
...@@ -395,7 +399,7 @@ pub enum Kind<'a> {...@@ -395,7 +399,7 @@ pub enum Kind<'a> {
395 color: Option<u32>,399 color: Option<u32>,
396 },400 },
397 Encrypted {401 Encrypted {
398 #[serde(serialize_with = "hex")]402 #[serde(serialize_with = "hex", skip_deserializing, default = "no_ciphertext")]
399 ciphertext: &'a [u8],403 ciphertext: &'a [u8],
400 },404 },
401 Unknown,405 Unknown,
crates/onestore/src/files.rs+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1use crate::{Error, Object, ObjectData, Reference, Store, bytes::Cursor};1use 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)]
4pub enum FileDataReference {4pub enum FileDataReference {
5 Internal([u8; 16]),5 Internal([u8; 16]),
6 External(String),6 External(String),
crates/onestore/src/page/mod.rs+26-15
...@@ -15,13 +15,13 @@ pub(crate) mod write;...@@ -15,13 +15,13 @@ pub(crate) mod write;
15pub use text::Paragraph;15pub use text::Paragraph;
1616
17/// The role of a title-outline paragraph that displays the page's creation date or time.17/// The role of a title-outline paragraph that displays the page's creation date or time.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]18#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
19pub enum DateField {19pub enum DateField {
20 Date = 0,20 Date = 0,
21 Time = 1,21 Time = 1,
22}22}
2323
24#[derive(Debug, PartialEq)]24#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
25pub struct Page {25pub struct Page {
26 pub title: String,26 pub title: String,
27 /// FILETIME ticks from the page's TopologyCreationTimeStamp.27 /// FILETIME ticks from the page's TopologyCreationTimeStamp.
...@@ -31,13 +31,13 @@ pub struct Page {...@@ -31,13 +31,13 @@ pub struct Page {
31 pub definitions: BTreeMap<ExGuid, Definition>,31 pub definitions: BTreeMap<ExGuid, Definition>,
32}32}
3333
34#[derive(Debug, PartialEq)]34#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
35pub struct Definition {35pub struct Definition {
36 pub kind: Kind<'static>,36 pub kind: Kind<'static>,
37 pub format: Format,37 pub format: Format,
38}38}
3939
40#[derive(Debug, PartialEq)]40#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
41pub enum PageObject {41pub enum PageObject {
42 Outline(Outline),42 Outline(Outline),
43 Title(Title),43 Title(Title),
...@@ -73,7 +73,7 @@ impl PageObject {...@@ -73,7 +73,7 @@ impl PageObject {
73 }73 }
74}74}
7575
76#[derive(Debug, PartialEq)]76#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
77pub struct Title {77pub struct Title {
78 pub id: ExGuid,78 pub id: ExGuid,
79 pub date: Option<ExGuid>,79 pub date: Option<ExGuid>,
...@@ -81,7 +81,7 @@ pub struct Title {...@@ -81,7 +81,7 @@ pub struct Title {
81 pub outlines: Vec<Outline>,81 pub outlines: Vec<Outline>,
82}82}
8383
84#[derive(Clone, Debug, PartialEq)]84#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
85pub struct Outline {85pub struct Outline {
86 pub id: ExGuid,86 pub id: ExGuid,
87 pub title: bool,87 pub title: bool,
...@@ -92,7 +92,7 @@ pub struct Outline {...@@ -92,7 +92,7 @@ pub struct Outline {
92 pub unsupported: Vec<Unsupported>,92 pub unsupported: Vec<Unsupported>,
93}93}
9494
95#[derive(Clone, Debug, PartialEq)]95#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
96pub struct PageParagraph {96pub struct PageParagraph {
97 pub id: ExGuid,97 pub id: ExGuid,
98 pub parent: Option<ExGuid>,98 pub parent: Option<ExGuid>,
...@@ -105,14 +105,14 @@ pub struct PageParagraph {...@@ -105,14 +105,14 @@ pub struct PageParagraph {
105 pub collapsed: bool,105 pub collapsed: bool,
106}106}
107107
108#[derive(Clone, Debug, PartialEq)]108#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
109pub enum ParagraphContent {109pub enum ParagraphContent {
110 Text(TextObject),110 Text(TextObject),
111 Table(Table),111 Table(Table),
112 Unsupported(Unsupported),112 Unsupported(Unsupported),
113}113}
114114
115#[derive(Clone, Debug, PartialEq)]115#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
116pub struct Table {116pub struct Table {
117 pub id: ExGuid,117 pub id: ExGuid,
118 pub columns: Vec<TableColumn>,118 pub columns: Vec<TableColumn>,
...@@ -122,19 +122,19 @@ pub struct Table {...@@ -122,19 +122,19 @@ pub struct Table {
122 pub tags: Vec<Tag>,122 pub tags: Vec<Tag>,
123}123}
124124
125#[derive(Clone, Debug, PartialEq)]125#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
126pub struct TableColumn {126pub struct TableColumn {
127 pub width: f32,127 pub width: f32,
128 pub locked: bool,128 pub locked: bool,
129}129}
130130
131#[derive(Clone, Debug, PartialEq)]131#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
132pub struct TableRow {132pub struct TableRow {
133 pub id: ExGuid,133 pub id: ExGuid,
134 pub cells: Vec<TableCell>,134 pub cells: Vec<TableCell>,
135}135}
136136
137#[derive(Clone, Debug, PartialEq)]137#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
138pub struct TableCell {138pub struct TableCell {
139 pub id: ExGuid,139 pub id: ExGuid,
140 pub layout: Layout,140 pub layout: Layout,
...@@ -144,7 +144,7 @@ pub struct TableCell {...@@ -144,7 +144,7 @@ pub struct TableCell {
144 pub unsupported: Vec<Unsupported>,144 pub unsupported: Vec<Unsupported>,
145}145}
146146
147#[derive(Clone, Debug, PartialEq)]147#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
148pub struct TextObject {148pub struct TextObject {
149 pub id: ExGuid,149 pub id: ExGuid,
150 pub date_field: Option<DateField>,150 pub date_field: Option<DateField>,
...@@ -152,16 +152,27 @@ pub struct TextObject {...@@ -152,16 +152,27 @@ pub struct TextObject {
152 pub tags: Vec<Tag>,152 pub tags: Vec<Tag>,
153}153}
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)]
156pub struct Image {157pub struct Image {
157 pub id: ExGuid,158 pub id: ExGuid,
158 pub layout: Layout,159 pub layout: Layout,
160 #[serde(skip)]
159 pub bytes: Option<Arc<[u8]>>,161 pub bytes: Option<Arc<[u8]>>,
160 pub alt: Option<String>,162 pub alt: Option<String>,
161 pub background: bool,163 pub background: bool,
162}164}
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)]
165pub struct Unsupported {176pub struct Unsupported {
166 pub id: ExGuid,177 pub id: ExGuid,
167 pub jcid: u32,178 pub jcid: u32,
crates/onestore/src/page/text.rs+2-2
...@@ -1,14 +1,14 @@...@@ -1,14 +1,14 @@
1use crate::{ExGuid, document::Format};1use crate::{ExGuid, document::Format};
2use std::{fmt, ops::Range};2use std::{fmt, ops::Range};
33
4#[derive(Clone, Debug, PartialEq)]4#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
5pub struct Span {5pub struct Span {
6 /// Exclusive UTF-8 boundary; the start is the preceding span's end.6 /// Exclusive UTF-8 boundary; the start is the preceding span's end.
7 pub end: usize,7 pub end: usize,
8 pub format: Format,8 pub format: Format,
9}9}
1010
11#[derive(Clone, Debug, PartialEq)]11#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
12/// Editable text styles are coalesced independently of serialized run boundaries.12/// Editable text styles are coalesced independently of serialized run boundaries.
13pub struct Paragraph {13pub struct Paragraph {
14 text: String,14 text: String,
crates/onestore/src/page/write.rs+4-1
...@@ -222,7 +222,10 @@ impl Lowering<'_> {...@@ -222,7 +222,10 @@ impl Lowering<'_> {
222 page.objects222 page.objects
223 .iter()223 .iter()
224 .filter_map(|object| match object {224 .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 )),
226 PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")),229 PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")),
227 PageObject::Title(title) => Some(format!(230 PageObject::Title(title) => Some(format!(
228 "{:?} {:?} {:?} {:?}",231 "{:?} {:?} {:?} {:?}",