From 59f5163e0c2a80a4addceedf0e3f4bc3b6c235c3 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 11 Sep 2026 01:48:23 -0700 Subject: [PATCH] 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 --- crates/notebook/examples/cache_probe.rs | 1 + crates/notebook/src/lib.rs | 109 +++++++++- crates/notebook/src/sync.rs | 43 ++++ crates/notebook/tests/sync.rs | 2 + crates/notebook/tests/sync/model.rs | 268 ++++++++++++++++++++++++ crates/onestore/src/document.rs | 18 +- crates/onestore/src/files.rs | 2 +- crates/onestore/src/page/mod.rs | 41 ++-- crates/onestore/src/page/text.rs | 4 +- crates/onestore/src/page/write.rs | 5 +- 10 files changed, 466 insertions(+), 27 deletions(-) create mode 100644 crates/notebook/tests/sync/model.rs diff --git a/crates/notebook/examples/cache_probe.rs b/crates/notebook/examples/cache_probe.rs index aef2d6e75b0d81db0965cedf76778955b6d73208..12d39ea202fe3da496478ee10bf3c114fab4d9b6 100644 --- a/crates/notebook/examples/cache_probe.rs +++ b/crates/notebook/examples/cache_probe.rs @@ -139,6 +139,7 @@ fn main() -> Result<(), Box> { *value as u64 - 6 } notebook::Operation::Split(_) + | notebook::Operation::Page(_) | notebook::Operation::CreatePage(_) | notebook::Operation::Pages(_) | notebook::Operation::Join(_) diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index a768226d5ff9fba55f5408b8bd7d3953be5c804f..bf3166939e2ef79d4eb648d552609269fca4c066 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -8,8 +8,9 @@ pub mod smb; use onestore::{ ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, document::{Document, Kind}, + page::Page, }; -use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; +use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params}; use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration}; mod assets; @@ -67,9 +68,20 @@ pub struct TextEdit { pub replacement: String, } +/// An edited page model together with the stored model it was edited from. +/// `before` is the precondition reconciliation checks against the remote page. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PageIntent { + pub before: Page, + pub after: Page, + pub author: String, +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub enum Operation { + Page(PageIntent), CreatePage(PageCreation), Pages(PageEdits), Text(TextEdit), @@ -238,6 +250,94 @@ impl Replica { self.record(source, space, Operation::Insert(insertion.clone()), &edit) } + /// Durably queues an edited page model using the supplied local snapshot. + /// While the newest pending edit is an unattempted save of the same page, a new save + /// replaces its result instead of queueing another publication, as OneNote does + /// within its own save interval. An unchanged model returns `None`. + pub fn save( + &self, + source: &[u8], + space: ExGuid, + after: &Page, + author: &str, + ) -> Result> { + let prepared = PreparedEdit::page(source, space, after, author)?; + if prepared.as_bytes() == source { + return Ok(None); + } + let before = page_of(source, space)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "The saved page is not in the local image", + ) + })?; + { + let mut connection = self + .connection + .lock() + .map_err(|_| io::Error::other("Cache owner panicked"))?; + let transaction = + connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current: Vec = + transaction.query_row("SELECT working FROM replica WHERE id=1", [], |row| { + row.get(0) + })?; + if current != source { + return Err(io::Error::new( + io::ErrorKind::ResourceBusy, + "The local snapshot changed before this edit", + ) + .into()); + } + let newest: Option<(i64, String, String)> = transaction + .query_row( + "SELECT id, space, operation FROM edits WHERE id=(SELECT max(id) FROM edits)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + if let Some((id, sid, operation)) = newest + && sid == space.to_string() + && let Ok(Operation::Page(mut head)) = serde_json::from_str::(&operation) + { + let attempted: bool = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM attempt WHERE edit_id=?1) OR EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1)", + [id], + |row| row.get(0), + )?; + if !attempted { + head.after = after.clone(); + transaction.execute( + "UPDATE edits SET operation=?1 WHERE id=?2", + params![ + serde_json::to_string(&Operation::Page(head)) + .map_err(io::Error::other)?, + id + ], + )?; + transaction.execute( + "UPDATE replica SET working=?1 WHERE id=1", + [prepared.as_bytes()], + )?; + transaction.commit()?; + drop(connection); + self.wake_sync(); + return Ok(Some(u64::try_from(id).map_err(io::Error::other)?)); + } + } + } + self.record( + source, + space, + Operation::Page(PageIntent { + before, + after: after.clone(), + author: author.to_owned(), + }), + &prepared, + ) + } + /// Queues a new page and its section entry with stable identities for dependent edits. pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result> { let edit = PreparedEdit::create_page(source, page)?; @@ -331,6 +431,13 @@ fn active_paths( .collect() } +fn page_of(source: &[u8], space: ExGuid) -> Result> { + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + Ok(Page::from_space(&document, space).ok()) +} + fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result> { let store = Store::parse(source)?; let index = RevisionIndex::parse(&store)?; diff --git a/crates/notebook/src/sync.rs b/crates/notebook/src/sync.rs index 199845ff7184d5d3ca9d7b468fcde94d8d599468..1a5513501b22157b9b6883862b956f23b4b72616 100644 --- a/crates/notebook/src/sync.rs +++ b/crates/notebook/src/sync.rs @@ -139,6 +139,15 @@ impl Replica { return Ok(Some((intent.id, EditStatus::Published { revision }))); } let candidate = match &intent.operation { + Operation::Page(edit) => page_of(&snapshot, intent.space)? + .ok_or(ConflictKind::TargetUnavailable) + .and_then(|current| { + if current != edit.before { + return Err(ConflictKind::ContentChanged); + } + PreparedEdit::page(&snapshot, intent.space, &edit.after, &edit.author) + .map_err(|_| ConflictKind::UnsupportedEdit) + }), Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) .map_err(|_| ConflictKind::StructureChanged), Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?, @@ -327,6 +336,13 @@ impl Replica { ) .into()); } + Operation::Page(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Review a page model conflict using review_page", + ) + .into()); + } Operation::Outline(_) => { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -508,6 +524,33 @@ impl Replica { }) } + /// Replaces a conflicting page save with a model reviewed against the remote page. + /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed + /// model must publish against the remote image, and its author is retained. + pub fn review_page(&self, id: u64, local: &[u8], remote: &[u8], after: &Page) -> Result<()> { + self.resolve_conflict(id, local, remote, |intent| { + let Operation::Page(edit) = intent.operation else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Select a page save conflict", + ) + .into()); + }; + let before = page_of(remote, intent.space)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "The page is no longer in the remote section", + ) + })?; + PreparedEdit::page(remote, intent.space, after, &edit.author)?; + Ok(Operation::Page(PageIntent { + before, + after: after.clone(), + author: edit.author, + })) + }) + } + fn resolve_conflict( &self, id: u64, diff --git a/crates/notebook/tests/sync.rs b/crates/notebook/tests/sync.rs index f03a39611443d7f5649768e4b993db46df84a3ac..0eb1799a2c23aa639cf28506d5cdfc2e72567df9 100644 --- a/crates/notebook/tests/sync.rs +++ b/crates/notebook/tests/sync.rs @@ -7,6 +7,8 @@ use std::io; #[path = "../../onestore/tests/support/disk.rs"] mod disk; +#[path = "sync/model.rs"] +mod model; #[path = "sync/outline.rs"] mod outline; #[path = "sync/page.rs"] diff --git a/crates/notebook/tests/sync/model.rs b/crates/notebook/tests/sync/model.rs new file mode 100644 index 0000000000000000000000000000000000000000..c75ce6aaa6e7a36201edd99d45e96953a52c7972 --- /dev/null +++ b/crates/notebook/tests/sync/model.rs @@ -0,0 +1,268 @@ +//! Page-model saves: one intent per page until it is attempted, reconciliation against the +//! remote page, reviewed conflicts, and independence between pages. + +use super::*; +use notebook::{Operation, PageIntent}; +use onestore::page::{Page, PageObject}; + +const OUTLINES: &[u8] = + include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); + +fn page_of(bytes: &[u8], space: ExGuid) -> Page { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + Page::from_space(&document, space).unwrap() +} + +fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .find_map(|(sid, _)| { + let page = Page::from_space(&document, sid).unwrap(); + (page.title == title).then_some((sid, page)) + }) + .unwrap() +} + +/// Appends `suffix` to the first body paragraph of the first body outline. +fn append(page: &mut Page, suffix: &str) { + let outline = page + .objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .unwrap(); + let text = outline.paragraphs[0].text_mut().unwrap(); + let end = text.text.utf16_offset(text.text.text().len()).unwrap(); + let format = text.text.format_at(end).unwrap().clone(); + text.text + .apply(onestore::page::text::Edit { + range: end..end, + replacement: onestore::page::Paragraph::new(suffix.into(), format), + }) + .unwrap(); +} + +fn first_text(page: &Page) -> String { + page.objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => { + Some(outline.paragraphs[0].text().unwrap().text.text().to_owned()) + } + _ => None, + }) + .unwrap() +} + +#[test] +fn a_saved_page_model_publishes_and_reads_back() { + let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); + append(&mut after, " saved 🦀"); + let id = cache + .save(OUTLINES, space, &after, "Model author") + .unwrap() + .unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + assert_eq!(page_of(&cache.snapshot().unwrap(), space), after); + let pending = cache.pending().unwrap(); + assert_eq!(pending.len(), 1); + let Operation::Page(PageIntent { + before, + after: stored, + author, + }) = &pending[0].operation + else { + panic!() + }; + assert_eq!( + (before, stored, author.as_str()), + (&page_of(OUTLINES, space), &after, "Model author") + ); + let mut server = Server::new(OUTLINES); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + assert_eq!(page_of(&server.durable, space), after); + assert_eq!(server.publications, 1); + assert!(cache.pending().unwrap().is_empty()); + assert_eq!( + cache + .save(&cache.snapshot().unwrap(), space, &after, "Model author") + .unwrap(), + None + ); +} + +#[test] +fn saves_before_an_attempt_coalesce_into_one_publication() { + let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); + append(&mut after, " one"); + let id = cache + .save(OUTLINES, space, &after, "Model author") + .unwrap() + .unwrap(); + let mut later = page_of(&cache.snapshot().unwrap(), space); + append(&mut later, " two"); + assert_eq!( + cache + .save(&cache.snapshot().unwrap(), space, &later, "Model author") + .unwrap(), + Some(id) + ); + let pending = cache.pending().unwrap(); + assert_eq!(pending.len(), 1); + let Operation::Page(intent) = &pending[0].operation else { + panic!() + }; + assert_eq!(intent.before, page_of(OUTLINES, space)); + assert_eq!(intent.after, later); + let mut server = Server::new(OUTLINES); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + assert_eq!(server.publications, 1); + assert_eq!( + first_text(&page_of(&server.durable, space)), + first_text(&later) + ); + let mut third = page_of(&cache.snapshot().unwrap(), space); + append(&mut third, " three"); + let next = cache + .save(&cache.snapshot().unwrap(), space, &third, "Model author") + .unwrap() + .unwrap(); + assert!(next > id); +} + +#[test] +fn an_attempted_save_is_never_rewritten() { + let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); + append(&mut after, " uncertain"); + let id = cache + .save(OUTLINES, space, &after, "Model author") + .unwrap() + .unwrap(); + let mut server = Server::new(OUTLINES); + server.fault = Fault::UnknownAfter; + assert!(cache.sync_once(&mut server).is_err()); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + )); + let mut more = page_of(&cache.snapshot().unwrap(), space); + append(&mut more, " more"); + let next = cache + .save(&cache.snapshot().unwrap(), space, &more, "Model author") + .unwrap() + .unwrap(); + assert!(next > id); + let pending = cache.pending().unwrap(); + assert_eq!(pending.iter().map(|p| p.id).collect::>(), [id, next]); + let Operation::Page(first) = &pending[0].operation else { + panic!() + }; + assert_eq!(first.after, after); + let Operation::Page(second) = &pending[1].operation else { + panic!() + }; + assert_eq!((&second.before, &second.after), (&after, &more)); +} + +#[test] +fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() { + let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); + append(&mut after, " local"); + let id = cache + .save(OUTLINES, space, &after, "Model author") + .unwrap() + .unwrap(); + let mut remote_page = page_of(OUTLINES, space); + append(&mut remote_page, " remote"); + let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap(); + let mut server = Server::new(remote.as_bytes()); + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + assert_eq!(server.publications, 0); + assert!(cache.pending().unwrap().len() == 1); + let local = cache.snapshot().unwrap(); + let observed = cache.remote_snapshot().unwrap(); + assert_eq!(observed, remote.as_bytes()); + let mut reviewed = page_of(&observed, space); + append(&mut reviewed, " local"); + assert!( + cache + .review_page(id + 1, &local, &observed, &reviewed) + .is_err() + ); + assert!(cache.review_page(id, &local, OUTLINES, &reviewed).is_err()); + cache.review_page(id, &local, &observed, &reviewed).unwrap(); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + assert_eq!( + first_text(&page_of(&server.durable, space)), + first_text(&reviewed) + ); + assert!(first_text(&page_of(&server.durable, space)).ends_with(" remote local")); +} + +#[test] +fn remote_changes_to_other_pages_do_not_conflict() { + let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); + let (other, mut other_page) = page_titled(OUTLINES, "Delete leaf"); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); + append(&mut after, " local"); + let id = cache + .save(OUTLINES, space, &after, "Model author") + .unwrap() + .unwrap(); + append(&mut other_page, " elsewhere"); + let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap(); + let mut server = Server::new(remote.as_bytes()); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + assert_eq!(page_of(&server.durable, space), after); + assert_eq!(page_of(&server.durable, other), other_page); + assert_eq!(page_of(&cache.snapshot().unwrap(), other), other_page); +} + +#[test] +fn pending_saves_survive_reopening_the_cache() { + let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, OUTLINES).unwrap(); + append(&mut after, " durable"); + let id = cache + .save(OUTLINES, space, &after, "Model author") + .unwrap() + .unwrap(); + let pending = cache.pending().unwrap(); + drop(cache); + let reopened = Replica::open(&path).unwrap(); + assert_eq!(reopened.pending().unwrap(), pending); + assert_eq!(reopened.status(id).unwrap(), Some(EditStatus::Pending)); + assert_eq!(page_of(&reopened.snapshot().unwrap(), space), after); +} diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs index a6256a77e689ec578affe9e305eee136dddf784f..a640f59089200170ccbb8c09eaa1f35dcfbf9356 100644 --- a/crates/onestore/src/document.rs +++ b/crates/onestore/src/document.rs @@ -7,11 +7,15 @@ use crate::{ Error, ExGuid, FileDataReference, IdStream, Object, ObjectData, Property, PropertySets, RevisionIndex, Store, Value, bytes::Cursor, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; type Result = std::result::Result; +fn no_ciphertext<'a>() -> &'a [u8] { + &[] +} + fn invalid(message: &'static str) -> Error { Error { offset: 0, message } } @@ -73,7 +77,7 @@ pub struct Element<'a> { pub extra: Vec>>, } -#[derive(Debug, Default, Clone, PartialEq, Serialize)] +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub struct Layout { pub x: Option, pub y: Option, @@ -86,7 +90,7 @@ pub struct Layout { macro_rules! format_fields { ($($field:ident: $value:ty),* $(,)?) => { - #[derive(Debug, Default, Clone, PartialEq, Serialize)] + #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub struct Format { $(pub $field: Option<$value>),* } impl Format { @@ -123,7 +127,7 @@ format_fields! { list_spacing: f32, } -#[derive(Debug, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TextRun { pub start: u32, pub end: u32, @@ -251,7 +255,7 @@ impl Revision<'_> { } } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] /// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits. pub struct Tag { pub definition: Option, @@ -266,7 +270,7 @@ pub struct Tag { pub extra_set: usize, } -#[derive(Debug, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum Kind<'a> { Section { @@ -395,7 +399,7 @@ pub enum Kind<'a> { color: Option, }, Encrypted { - #[serde(serialize_with = "hex")] + #[serde(serialize_with = "hex", skip_deserializing, default = "no_ciphertext")] ciphertext: &'a [u8], }, Unknown, diff --git a/crates/onestore/src/files.rs b/crates/onestore/src/files.rs index e481dffb48a8343ec32be05099f6703d04fb3d5e..fdfe6a9a858aa8a251b9c0e3db60b9f891c9b773 100644 --- a/crates/onestore/src/files.rs +++ b/crates/onestore/src/files.rs @@ -1,6 +1,6 @@ use crate::{Error, Object, ObjectData, Reference, Store, bytes::Cursor}; -#[derive(Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum FileDataReference { Internal([u8; 16]), External(String), diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 407aceb5c205283f10827f69495dc0732532d825..ec2d744bbc0825eb4b8e498357f3e99610d48bb9 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -15,13 +15,13 @@ pub(crate) mod write; pub use text::Paragraph; /// The role of a title-outline paragraph that displays the page's creation date or time. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum DateField { Date = 0, Time = 1, } -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Page { pub title: String, /// FILETIME ticks from the page's TopologyCreationTimeStamp. @@ -31,13 +31,13 @@ pub struct Page { pub definitions: BTreeMap, } -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Definition { pub kind: Kind<'static>, pub format: Format, } -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum PageObject { Outline(Outline), Title(Title), @@ -73,7 +73,7 @@ impl PageObject { } } -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Title { pub id: ExGuid, pub date: Option, @@ -81,7 +81,7 @@ pub struct Title { pub outlines: Vec, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Outline { pub id: ExGuid, pub title: bool, @@ -92,7 +92,7 @@ pub struct Outline { pub unsupported: Vec, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PageParagraph { pub id: ExGuid, pub parent: Option, @@ -105,14 +105,14 @@ pub struct PageParagraph { pub collapsed: bool, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum ParagraphContent { Text(TextObject), Table(Table), Unsupported(Unsupported), } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Table { pub id: ExGuid, pub columns: Vec, @@ -122,19 +122,19 @@ pub struct Table { pub tags: Vec, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TableColumn { pub width: f32, pub locked: bool, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TableRow { pub id: ExGuid, pub cells: Vec, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TableCell { pub id: ExGuid, pub layout: Layout, @@ -144,7 +144,7 @@ pub struct TableCell { pub unsupported: Vec, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TextObject { pub id: ExGuid, pub date_field: Option, @@ -152,16 +152,27 @@ pub struct TextObject { pub tags: Vec, } -#[derive(Debug, PartialEq)] +/// Payload bytes are identified by `id`, so equality and serialization leave them out. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Image { pub id: ExGuid, pub layout: Layout, + #[serde(skip)] pub bytes: Option>, pub alt: Option, pub background: bool, } -#[derive(Clone, Debug, PartialEq)] +impl PartialEq for Image { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.layout == other.layout + && self.alt == other.alt + && self.background == other.background + } +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Unsupported { pub id: ExGuid, pub jcid: u32, diff --git a/crates/onestore/src/page/text.rs b/crates/onestore/src/page/text.rs index 3a02b6e954fd553cf77d2d9c25c1d38dddf648d2..e195c578a4717b0bd436842329d1bb215cb95ebd 100644 --- a/crates/onestore/src/page/text.rs +++ b/crates/onestore/src/page/text.rs @@ -1,14 +1,14 @@ use crate::{ExGuid, document::Format}; use std::{fmt, ops::Range}; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Span { /// Exclusive UTF-8 boundary; the start is the preceding span's end. pub end: usize, pub format: Format, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] /// Editable text styles are coalesced independently of serialized run boundaries. pub struct Paragraph { text: String, diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index 974e78547c8d0c914781c1237cb1adb016889462..6b0c60e2a1065ba09c76ee8506392396d9faee4c 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -222,7 +222,10 @@ impl Lowering<'_> { page.objects .iter() .filter_map(|object| match object { - PageObject::Image(image) => Some(format!("{image:?}")), + PageObject::Image(image) => Some(format!( + "{:?} {:?} {:?} {:?}", + image.id, image.layout, image.alt, image.background + )), PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")), PageObject::Title(title) => Some(format!( "{:?} {:?} {:?} {:?}", -- 2.54.0