| author | |
| committer | |
| log | 59f5163e0c2a80a4addceedf0e3f4bc3b6c235c3 |
| tree | 71e646ce3ab8c16345981d6d10be7b7dbcfdc7e5 |
| parent | 09ee1d6454d9862cd2b895fea88eee8541e6caaf |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.110 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 - 6 | 139 | *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; |
| 8 | use onestore::{ | 8 | use 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 | }; |
| 12 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; | 13 | use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params}; |
| 13 | use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration}; | 14 | use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration}; |
| 14 | 15 | ||
| 15 | mod assets; | 16 | mod 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 | } |
| 69 | 70 | ||
| 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)] | ||
| 75 | pub 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)] |
| 72 | pub enum Operation { | 83 | pub 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 | } |
| 240 | 252 | ||
| 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 | } |
| 333 | 433 | ||
| 434 | fn 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 | |||
| 334 | fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> { | 441 | fn 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 | } |
| 510 | 526 | ||
| 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; |
| 7 | 7 | ||
| 8 | #[path = "../../onestore/tests/support/disk.rs"] | 8 | #[path = "../../onestore/tests/support/disk.rs"] |
| 9 | mod disk; | 9 | mod disk; |
| 10 | #[path = "sync/model.rs"] | ||
| 11 | mod model; | ||
| 10 | #[path = "sync/outline.rs"] | 12 | #[path = "sync/outline.rs"] |
| 11 | mod outline; | 13 | mod 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 | |||
| 4 | use super::*; | ||
| 5 | use notebook::{Operation, PageIntent}; | ||
| 6 | use onestore::page::{Page, PageObject}; | ||
| 7 | |||
| 8 | const OUTLINES: &[u8] = | ||
| 9 | include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 10 | |||
| 11 | fn 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 | |||
| 18 | fn 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. | ||
| 34 | fn 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 | |||
| 54 | fn 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] | ||
| 67 | fn 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] | ||
| 108 | fn 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] | ||
| 151 | fn 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] | ||
| 187 | fn 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] | ||
| 230 | fn 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] | ||
| 252 | fn 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 | }; |
| 10 | use serde::Serialize; | 10 | use serde::{Deserialize, Serialize}; |
| 11 | use std::collections::{BTreeMap, BTreeSet}; | 11 | use std::collections::{BTreeMap, BTreeSet}; |
| 12 | 12 | ||
| 13 | type Result<T> = std::result::Result<T, Error>; | 13 | type Result<T> = std::result::Result<T, Error>; |
| 14 | 14 | ||
| 15 | fn no_ciphertext<'a>() -> &'a [u8] { | ||
| 16 | &[] | ||
| 17 | } | ||
| 18 | |||
| 15 | fn invalid(message: &'static str) -> Error { | 19 | fn 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 | } |
| 75 | 79 | ||
| 76 | #[derive(Debug, Default, Clone, PartialEq, Serialize)] | 80 | #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] |
| 77 | pub struct Layout { | 81 | pub 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 { |
| 86 | 90 | ||
| 87 | macro_rules! format_fields { | 91 | macro_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>),* } |
| 91 | 95 | ||
| 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 | } |
| 125 | 129 | ||
| 126 | #[derive(Debug, PartialEq, Serialize)] | 130 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 127 | pub struct TextRun { | 131 | pub 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 | } |
| 253 | 257 | ||
| 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. |
| 256 | pub struct Tag { | 260 | pub 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 | } |
| 268 | 272 | ||
| 269 | #[derive(Debug, PartialEq, Serialize)] | 273 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 270 | #[serde(tag = "type")] | 274 | #[serde(tag = "type")] |
| 271 | pub enum Kind<'a> { | 275 | pub 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 @@ |
| 1 | use crate::{Error, Object, ObjectData, Reference, Store, bytes::Cursor}; | 1 | use crate::{Error, Object, ObjectData, Reference, Store, bytes::Cursor}; |
| 2 | 2 | ||
| 3 | #[derive(Debug, PartialEq, Eq, serde::Serialize)] | 3 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 4 | pub enum FileDataReference { | 4 | pub 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; |
| 15 | pub use text::Paragraph; | 15 | pub use text::Paragraph; |
| 16 | 16 | ||
| 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)] |
| 19 | pub enum DateField { | 19 | pub enum DateField { |
| 20 | Date = 0, | 20 | Date = 0, |
| 21 | Time = 1, | 21 | Time = 1, |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | #[derive(Debug, PartialEq)] | 24 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 25 | pub struct Page { | 25 | pub 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 | } |
| 33 | 33 | ||
| 34 | #[derive(Debug, PartialEq)] | 34 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 35 | pub struct Definition { | 35 | pub struct Definition { |
| 36 | pub kind: Kind<'static>, | 36 | pub kind: Kind<'static>, |
| 37 | pub format: Format, | 37 | pub format: Format, |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | #[derive(Debug, PartialEq)] | 40 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 41 | pub enum PageObject { | 41 | pub 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 | } |
| 75 | 75 | ||
| 76 | #[derive(Debug, PartialEq)] | 76 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 77 | pub struct Title { | 77 | pub 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 | } |
| 83 | 83 | ||
| 84 | #[derive(Clone, Debug, PartialEq)] | 84 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 85 | pub struct Outline { | 85 | pub 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 | } |
| 94 | 94 | ||
| 95 | #[derive(Clone, Debug, PartialEq)] | 95 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 96 | pub struct PageParagraph { | 96 | pub 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 | } |
| 107 | 107 | ||
| 108 | #[derive(Clone, Debug, PartialEq)] | 108 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 109 | pub enum ParagraphContent { | 109 | pub enum ParagraphContent { |
| 110 | Text(TextObject), | 110 | Text(TextObject), |
| 111 | Table(Table), | 111 | Table(Table), |
| 112 | Unsupported(Unsupported), | 112 | Unsupported(Unsupported), |
| 113 | } | 113 | } |
| 114 | 114 | ||
| 115 | #[derive(Clone, Debug, PartialEq)] | 115 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 116 | pub struct Table { | 116 | pub 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 | } |
| 124 | 124 | ||
| 125 | #[derive(Clone, Debug, PartialEq)] | 125 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 126 | pub struct TableColumn { | 126 | pub struct TableColumn { |
| 127 | pub width: f32, | 127 | pub width: f32, |
| 128 | pub locked: bool, | 128 | pub locked: bool, |
| 129 | } | 129 | } |
| 130 | 130 | ||
| 131 | #[derive(Clone, Debug, PartialEq)] | 131 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 132 | pub struct TableRow { | 132 | pub struct TableRow { |
| 133 | pub id: ExGuid, | 133 | pub id: ExGuid, |
| 134 | pub cells: Vec<TableCell>, | 134 | pub cells: Vec<TableCell>, |
| 135 | } | 135 | } |
| 136 | 136 | ||
| 137 | #[derive(Clone, Debug, PartialEq)] | 137 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 138 | pub struct TableCell { | 138 | pub 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 | } |
| 146 | 146 | ||
| 147 | #[derive(Clone, Debug, PartialEq)] | 147 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 148 | pub struct TextObject { | 148 | pub 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 | } |
| 154 | 154 | ||
| 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)] | ||
| 156 | pub struct Image { | 157 | pub 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 | } |
| 163 | 165 | ||
| 164 | #[derive(Clone, Debug, PartialEq)] | 166 | impl 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)] | ||
| 165 | pub struct Unsupported { | 176 | pub 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 @@ |
| 1 | use crate::{ExGuid, document::Format}; | 1 | use crate::{ExGuid, document::Format}; |
| 2 | use std::{fmt, ops::Range}; | 2 | use std::{fmt, ops::Range}; |
| 3 | 3 | ||
| 4 | #[derive(Clone, Debug, PartialEq)] | 4 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] |
| 5 | pub struct Span { | 5 | pub 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 | } |
| 10 | 10 | ||
| 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. |
| 13 | pub struct Paragraph { | 13 | pub 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.objects | 222 | 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 | "{:?} {:?} {:?} {:?}", |