From e0b4334a77134228b5f773ce7cb9227fc2dd027d Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 25 Sep 2026 22:14:25 -0700 Subject: [PATCH] perf: page writes in one revision, and saves off the main thread The page writer keeps its step-by-step object placement but simulates each step's write and read-back in memory, building one revision per write instead of one per step; output is byte-identical (505 corpus sections, 8052 page edits, generated pages up to 2000 paragraphs). A 250-paragraph import into a 4.6 MB section drops from 22 s to 0.15 s and 2000 paragraphs from 38 minutes to 0.7 s. Section::queue_save saves on a thread, chaining consecutive saves of a page, and saved() reports outcomes; Snowbound saves through it, so an edit no longer re-reads and rewrites the section on the main thread. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 --- crates/notebook/README.md | 11 +- crates/notebook/examples/save_probe.rs | 108 +++ crates/notebook/src/lib.rs | 21 +- crates/notebook/src/session.rs | 177 +++- crates/notebook/tests/session.rs | 80 ++ crates/onestore/Cargo.toml | 1 + crates/onestore/src/active.rs | 374 ++++++++ crates/onestore/src/document.rs | 37 +- crates/onestore/src/edit.rs | 144 +-- crates/onestore/src/formatting.rs | 184 ++-- crates/onestore/src/insertion.rs | 145 ++- crates/onestore/src/lib.rs | 1 + crates/onestore/src/objects.rs | 7 +- crates/onestore/src/outline.rs | 81 +- crates/onestore/src/page/write.rs | 1144 ++++++++++++------------ crates/onestore/src/paragraph.rs | 70 +- crates/onestore/src/protected/mod.rs | 35 +- crates/onestore/src/tree.rs | 47 +- crates/onestore/src/write.rs | 782 +++++++++++----- crates/snowbound/src/main.rs | 43 +- 20 files changed, 2297 insertions(+), 1195 deletions(-) create mode 100644 crates/notebook/examples/save_probe.rs create mode 100644 crates/onestore/src/active.rs diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 930e821b6c816c43a00c904400408fd3c1e30daf..3cfd958001ea8035da22eabbb9e80527537990af 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -67,10 +67,15 @@ author)` queues the edited model: `Save::Queued(id)` is durable locally, `Save::Unchanged` means the model equals the stored page, and `Save::Stale` means the stored page no longer matches `before` because the section changed underneath the editor, so the page must be reloaded before saving again. +`queue_save(space, before, after, author)` does the same on the section's save +thread and returns at once, so an editor's frame never waits on the write; +`saved()` drains the outcomes. A queued save whose `before` is the previous +one's `after` continues it, needing no reload in between, and a run of such +saves waiting together is written once. `events()` drains what the synchronization thread reported since the last -poll: refreshes, attempt outcomes and unreachable files; `notify` runs on that -thread whenever an event is available so the application can wake its event -loop. `close()` stops publication. +poll: refreshes, attempt outcomes and unreachable files; `notify` runs +whenever an event or save outcome is available so the application can wake its +event loop. `close()` finishes queued saves and stops publication. `Event::Unreachable` retains an `io::Error`: callers can distinguish permission denial, missing targets, timeouts, and connection failures through `kind()` without diff --git a/crates/notebook/examples/save_probe.rs b/crates/notebook/examples/save_probe.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e2af9eb89cb96d0fe96b23bf5c22000557ec5e2 --- /dev/null +++ b/crates/notebook/examples/save_probe.rs @@ -0,0 +1,108 @@ +//! Times importing a generated page into a copy of a section, then saving a one-character +//! edit of it: `save_probe SECTION PARAGRAPHS`. + +use notebook::session::Section; +use onestore::{ + document::{Format, Layout}, + page::{ + Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, + text::{Edit, new_id}, + }, +}; +use std::time::{Duration, Instant}; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let (Some(source), Some(count)) = (args.next(), args.next()) else { + return Err("usage: save_probe SECTION PARAGRAPHS".into()); + }; + let count: usize = count.parse()?; + let directory = tempfile::tempdir()?; + let file = directory.path().join("probe.one"); + std::fs::write(&file, std::fs::read(source)?)?; + let section = Section::open(&file, directory.path().join("cache"), || {})?; + let words = [ + "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog", + ]; + let paragraph = |i: usize| -> Result> { + let text: Vec<&str> = (0..1 + i % 17).map(|j| words[(i * 7 + j) % 8]).collect(); + Ok(PageParagraph { + id: new_id()?, + parent: None, + level: 1, + style: None, + format: Format::default(), + content: ParagraphContent::Text(TextObject { + id: new_id()?, + date_field: None, + text: Paragraph::new(text.join(" "), Format::default()), + tags: Vec::new(), + }), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + }) + }; + let imported = Page { + title: "Probe".into(), + identity: None, + created: None, + margin_origin: [0.0; 2], + objects: vec![PageObject::Outline(Outline { + id: new_id()?, + title: false, + min_width: None, + layout: Layout { + x: Some(36.0), + y: Some(86.4), + ..Layout::default() + }, + indents: Vec::new(), + paragraphs: (0..count).map(paragraph).collect::>()?, + unsupported: Vec::new(), + })], + definitions: Default::default(), + }; + let start = Instant::now(); + let space = section.import_page(&imported, "Probe")?; + println!("import {count}: {:?}", start.elapsed()); + let before = section.page(space)?; + let edit = |page: &Page, word: &str| -> Result> { + let mut after = page.clone(); + let text = after + .objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) if outline.paragraphs.len() == count => { + outline.paragraphs[count / 2].text_mut() + } + _ => None, + }) + .ok_or("the imported outline is missing")?; + let format = text.text.format_at(0)?.clone(); + text.text.apply(Edit { + range: 0..0, + replacement: Paragraph::new(word.into(), format), + })?; + Ok(after) + }; + let after = edit(&before, "x")?; + let start = Instant::now(); + section.save(space, &before, &after, "Probe")?; + println!("save: {:?}", start.elapsed()); + let next = edit(&after, "y")?; + let start = Instant::now(); + section.queue_save(space, after, next, "Probe")?; + println!("queue_save: {:?}", start.elapsed()); + let deadline = start + Duration::from_secs(600); + while Instant::now() < deadline { + if let Some((_, save)) = section.saved().pop() { + println!("queued save done: {:?} {save:?}", start.elapsed()); + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + section.close()?; + Ok(()) +} diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index c8dde05afef867990772847468788668854917d9..39c32211ee29438d1a296c585618f01dc657b910 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -274,9 +274,20 @@ impl Replica { after: &Page, author: &str, ) -> Result> { + Ok(self.save_image(source, space, after, author)?.0) + } + + /// `save`, with the working image it leaves. + pub(crate) fn save_image( + &self, + source: &[u8], + space: ExGuid, + after: &Page, + author: &str, + ) -> Result<(Option, Vec)> { let prepared = PreparedEdit::page(source, space, after, author)?; if prepared.as_bytes() == source { - return Ok(None); + return Ok((None, source.to_vec())); } let before = page_of(source, space)?.ok_or_else(|| { io::Error::new( @@ -332,11 +343,12 @@ impl Replica { transaction.commit()?; drop(connection); self.wake_sync(); - return Ok(Some(u64::try_from(id).map_err(io::Error::other)?)); + let id = u64::try_from(id).map_err(io::Error::other)?; + return Ok((Some(id), prepared.as_bytes().to_vec())); } } } - self.record( + let id = self.record( source, space, Operation::Page(PageIntent { @@ -345,7 +357,8 @@ impl Replica { author: author.to_owned(), }), &prepared, - ) + )?; + Ok((id, prepared.as_bytes().to_vec())) } /// Queues a new page and its section entry with stable identities for dependent edits. diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 05212683c1a323482664d5d70faa2db564d5101d..1d3f73ef0facec69bff3f416d2396a1f2647f4aa 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -9,14 +9,15 @@ use onestore::{ page::Page, }; use std::{ - collections::BTreeMap, + collections::{BTreeMap, VecDeque}, io, io::Write, path::{Path, PathBuf}, sync::{ - Arc, - mpsc::{self, Receiver}, + Arc, Mutex, + mpsc::{self, Receiver, Sender}, }, + thread::{self, JoinHandle}, time::Duration, }; @@ -751,12 +752,23 @@ pub struct QueuedEdit { pub status: EditStatus, } -/// A section file with its replica and background publication. +/// A section file with its replica, background saves and background publication. pub struct Section { file: PathBuf, replica: Arc, worker: Option, events: Receiver, + saves: Option>, + saved: Receiver<(ExGuid, std::result::Result)>, + saver: Option>, +} + +/// An edited page `Section::queue_save` hands to the save thread. +struct PageSave { + space: ExGuid, + before: Page, + after: Page, + author: String, } impl Section { @@ -830,6 +842,26 @@ impl Section { ) -> Result { let replica = Arc::new(replica); let (sender, events) = mpsc::channel(); + let (saved_sender, saved) = mpsc::channel(); + let notify = Arc::new(Mutex::new(notify)); + let signal = move || { + if let Ok(notify) = notify.lock() { + notify(); + } + }; + let (saves, requests) = mpsc::channel(); + let saver = { + let (replica, signal) = (Arc::clone(&replica), signal.clone()); + thread::Builder::new() + .name("onestore-save".into()) + .spawn(move || { + save_pages(&replica, &requests, |outcome| { + if saved_sender.send(outcome).is_ok() { + signal(); + } + }); + })? + }; let worker = replica.start_sync(Duration::from_secs(2), connect, move |result| { let event = match result { Ok(None) => Event::Refreshed, @@ -847,7 +879,7 @@ impl Section { Err(error) => Event::Failed(error.to_string()), }; if sender.send(event).is_ok() { - notify(); + signal(); } })?; Ok(Self { @@ -855,6 +887,9 @@ impl Section { replica, worker: Some(worker), events, + saves: Some(saves), + saved, + saver: Some(saver), }) } @@ -951,20 +986,23 @@ impl Section { /// Saves an edited page. `before` is the model the edit started from; a stored page /// that differs from it means the section changed underneath the editor. pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result { - loop { - let snapshot = self.replica.snapshot()?; - let store = Store::parse(&snapshot)?; - let index = RevisionIndex::parse(&store)?; - if Page::from_space(&Document::parse(&index)?, space)? != *before { - return Ok(Save::Stale); - } - match self.replica.save(&snapshot, space, after, author) { - Ok(Some(id)) => return Ok(Save::Queued(id)), - Ok(None) => return Ok(Save::Unchanged), - Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} - Err(error) => return Err(error), - } - } + Ok(save(&self.replica, space, before, None, after, author)?.0) + } + + /// `save` on the section's save thread, returning at once; `saved` reports the outcome. + /// Saves queue in order, and one whose `before` is the previous save's `after` continues + /// it: pass each save's `after` as the next one's `before`. + pub fn queue_save(&self, space: ExGuid, before: Page, after: Page, author: &str) -> Result<()> { + let save = PageSave { + space, + before, + after, + author: author.to_owned(), + }; + self.saves + .as_ref() + .and_then(|saves| saves.send(save).ok()) + .ok_or_else(|| io::Error::other("The save thread stopped").into()) } pub fn status(&self, id: u64) -> Result> { @@ -1043,6 +1081,12 @@ impl Section { self.events.try_iter().collect() } + /// Outcomes of queued saves since the last poll, oldest first; `notify` runs for each. + /// Consecutive saves continuing one another finish as one, reported once. + pub fn saved(&self) -> Vec<(ExGuid, std::result::Result)> { + self.saved.try_iter().collect() + } + /// Requests a synchronization attempt now. pub fn wake(&self) { if let Some(worker) = &self.worker { @@ -1058,6 +1102,12 @@ impl Section { /// Dropping instead requests cancellation without waiting; the worker retains /// cache ownership until that operation finishes. Remote calls must be bounded. pub fn close(mut self) -> Result<()> { + drop(self.saves.take()); + if let Some(saver) = self.saver.take() { + saver + .join() + .map_err(|_| io::Error::other("The save thread panicked"))?; + } match self.worker.take() { Some(worker) => worker.stop(), None => Ok(()), @@ -1065,6 +1115,95 @@ impl Section { } } +/// Saves `after` unless the stored page no longer matches `before`, returning the working +/// image the save leaves. `known` is the image a previous save of `before` left: while the +/// working image equals it the page is current unread, and otherwise the page `known` +/// stores stands in for `before`. +fn save( + replica: &Replica, + space: ExGuid, + before: &Page, + known: Option<&[u8]>, + after: &Page, + author: &str, +) -> Result<(Save, Vec)> { + let stored = |image: &[u8]| -> Result { + let store = Store::parse(image)?; + Ok(Page::from_space( + &Document::parse(&RevisionIndex::parse(&store)?)?, + space, + )?) + }; + loop { + let snapshot = replica.snapshot()?; + if known != Some(snapshot.as_slice()) { + let current = stored(&snapshot)?; + let expected = match known { + Some(image) => current == stored(image)?, + None => current == *before, + }; + if !expected { + return Ok((Save::Stale, snapshot)); + } + } + match replica.save_image(&snapshot, space, after, author) { + Ok((Some(id), image)) => return Ok((Save::Queued(id), image)), + Ok((None, image)) => return Ok((Save::Unchanged, image)), + Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} + Err(error) => return Err(error), + } + } +} + +/// Runs queued saves until the section drops its sender, folding each run of saves that +/// continue one another into one. +fn save_pages( + replica: &Replica, + requests: &Receiver, + report: impl Fn((ExGuid, std::result::Result)), +) { + let mut queue = VecDeque::new(); + // The last save's page and the working image it left. + let mut last: Option<(ExGuid, Page, Vec)> = None; + loop { + if queue.is_empty() { + match requests.recv() { + Ok(request) => queue.push_back(request), + Err(_) => return, + } + } + queue.extend(requests.try_iter()); + let mut request = queue.pop_front().unwrap(); + while let Some(next) = queue.front() + && (next.space, &next.before, &next.author) + == (request.space, &request.after, &request.author) + { + request.after = queue.pop_front().unwrap().after; + } + let known = last + .take() + .filter(|(space, after, _)| *space == request.space && *after == request.before); + let result = save( + replica, + request.space, + &request.before, + known.as_ref().map(|(_, _, image)| image.as_slice()), + &request.after, + &request.author, + ); + let save = match result { + Ok((save, image)) => { + if save != Save::Stale { + last = Some((request.space, request.after, image)); + } + Ok(save) + } + Err(error) => Err(error.to_string()), + }; + report((request.space, save)); + } +} + /// The section file itself as the publication target, under OneNote-compatible exclusion. struct FileRemote(PathBuf); diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 0905afde9bf075a8ab2957e03599e4c5e090c684..65e040dc66bff1cae582bed73105d5fe9a24fb19 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -232,6 +232,86 @@ fn an_external_change_refreshes_the_page_and_stales_a_save_from_the_old_model() section.close().unwrap(); } +#[test] +fn queued_saves_continue_each_other_detect_stale_pages_and_finish_before_close() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("notes.one"); + let cache = directory.path().join("cache"); + std::fs::write( + &file, + onestore::create_section("notes.one", "Original", "Author").unwrap(), + ) + .unwrap(); + let (section, _) = open(&file, &cache); + let space = section.pages().unwrap()[0].0; + let mut before = section.page(space).unwrap(); + for word in ["one ", "two ", "three ", "four ", "five "] { + let after = edited(&before, word); + section + .queue_save(space, before, after.clone(), "Editor") + .unwrap(); + before = after; + } + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let mut stored = section.page(space).unwrap(); + stored.title = before.title.clone(); + if stored == before { + break; + } + assert!(Instant::now() < deadline, "the saves did not arrive"); + for (_, save) in section.saved() { + assert!(matches!(save, Ok(Save::Queued(_))), "{save:?}"); + } + std::thread::sleep(Duration::from_millis(20)); + } + while !section.pending().unwrap().is_empty() { + assert!(Instant::now() < deadline, "the saves were not published"); + std::thread::sleep(Duration::from_millis(20)); + } + let native = edited(§ion.page(space).unwrap(), "Native "); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let bytes = onestore::read_file(&file).unwrap(); + match PreparedEdit::page(&bytes, space, &native, "Native") + .unwrap() + .commit_file(&file) + { + Ok(()) => break, + Err(error) if error.error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(20)); + } + Err(error) => panic!("{error:?}"), + } + } + section.wake(); + while !section.page(space).unwrap().objects.eq(&native.objects) { + assert!(Instant::now() < deadline, "the refresh did not arrive"); + wait(§ion, |event| matches!(event, Event::Refreshed)); + } + let stale = edited(&before, "Local "); + section.queue_save(space, before, stale, "Editor").unwrap(); + // Outcomes of the earlier saves may still be arriving. + while !section + .saved() + .into_iter() + .any(|saved| saved == (space, Ok(Save::Stale))) + { + assert!(Instant::now() < deadline, "the stale save was not reported"); + std::thread::sleep(Duration::from_millis(20)); + } + let native = section.page(space).unwrap(); + let after = edited(&native, "Local "); + section + .queue_save(space, native, after.clone(), "Editor") + .unwrap(); + section.close().unwrap(); + let (section, _) = open(&file, &cache); + assert_same(section.page(space).unwrap(), &after); + section.close().unwrap(); +} + #[test] fn a_notebook_directory_lists_its_sections_and_opens_them() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/onestore/Cargo.toml b/crates/onestore/Cargo.toml index ec6aee64f500852101e2052af696ab1a6f2c8cec..5b7027da55aba3b3df2bf558ddc6d1732d8853e8 100644 --- a/crates/onestore/Cargo.toml +++ b/crates/onestore/Cargo.toml @@ -9,6 +9,7 @@ protected = ["dep:aes", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "d [dependencies] base64 = "0.22.1" +bumpalo = "3.20.3" serde_json = "1.0.151" md5 = "0.8.1" getrandom = "0.4.3" diff --git a/crates/onestore/src/active.rs b/crates/onestore/src/active.rs new file mode 100644 index 0000000000000000000000000000000000000000..8b05379d4e1b50bb111238e39b464605640c11d2 --- /dev/null +++ b/crates/onestore/src/active.rs @@ -0,0 +1,374 @@ +use crate::{ + Error, ExGuid, Object, RevisionIndex, Store, + document::{Document, Element, Revision, Space}, + write::{Commit, LiveRevision, PropertyObject, chain_depth, declared, differing}, +}; +use bumpalo::Bump; +use std::collections::{BTreeMap, BTreeSet}; + +type Result = std::result::Result; + +/// Objects a writer stores in one revision of a page space. +pub(crate) type Changes = BTreeMap; + +/// A page space's active revision with the views its writers read, kept as appending +/// their revisions and parsing the result would leave it. +pub(crate) struct ActivePage<'a> { + store: &'a Store<'a>, + pub pages: Vec, + pub live: LiveRevision<'a>, + /// Elements of the reachable objects. + pub view: Revision<'a>, + /// `view.parents(&pages)`. + pub parents: BTreeMap>, + /// Elements of `view` carrying the page-title flag. + titles: BTreeSet, + /// Payloads embedded by revisions `write` applied, in order. + pub payloads: Vec<([u8; 16], &'a [u8])>, +} + +impl<'a> ActivePage<'a> { + pub(crate) fn parse(index: &'a RevisionIndex<'a>, space: ExGuid) -> Result { + index.validate_current()?; + let mut document = Document::parse(index)?; + let pages = document.pages_in(space)?; + let view = document + .spaces + .remove(&space) + .and_then(Space::into_active) + .ok_or(Error { + offset: 0, + message: "The active page is unavailable", + })?; + let rid = index.active(space)?; + let live = LiveRevision::new(index.resolve(space, rid)?, chain_depth(index, space, rid))?; + let parents = view.parents(&pages)?; + let titles = view + .nodes + .iter() + .filter(|(_, node)| is_title(node)) + .map(|(id, _)| *id) + .collect(); + Ok(Self { + store: index.store, + pages, + live, + view, + parents, + titles, + payloads: Vec::new(), + }) + } + + /// The element of an object, reading payloads `write` embedded as well as stored ones. + pub(crate) fn element<'o>(&self, object: &Object<'o>) -> Result> + where + 'a: 'o, + { + Element::parse_with(object, self.store, &mut |guid| match self + .payloads + .iter() + .find(|(id, _)| *id == guid) + { + Some((_, payload)) => Ok(*payload), + None => self.store.file_data(guid), + }) + } + + /// `parents`, after checking that `object` and its ancestors are editable. + pub(crate) fn editable_parents( + &self, + object: ExGuid, + ) -> Result<&BTreeMap>> { + crate::edit::check_editable(&self.view, &self.parents, &self.pages, object)?; + Ok(&self.parents) + } + + /// `edit::page_title` of the view with `overlay` replacing or adding elements that + /// neither carry the title flag nor detach any element that does. + pub(crate) fn title( + &self, + overlay: &BTreeMap>, + text_update: Option<(ExGuid, &str)>, + ) -> Result> { + crate::edit::title_of( + |id| overlay.get(&id).or_else(|| self.view.nodes.get(&id)), + &self.parents, + self.titles.iter().copied(), + &self.pages, + text_update, + ) + } + + /// Stores a writer's objects and payloads as the next revision; false when it stores + /// nothing, as appending it would leave the image unchanged. + pub(crate) fn write( + &mut self, + arena: &'a Bump, + payloads: &[([u8; 16], &[u8])], + changes: Changes, + ) -> Result { + let changes = self.live.prepare(changes, true)?; + for (guid, payload) in payloads { + self.payloads.push((*guid, arena.alloc_slice_copy(payload))); + } + if changes.is_empty() { + return Ok(!payloads.is_empty()); + } + let mut objects = Vec::new(); + for (id, change) in changes { + let bytes = arena.alloc_slice_copy(&change.bytes); + objects.push((id, declared(change.jcid, bytes, change.global_ids)?)); + } + let replaced: BTreeSet = objects.iter().map(|(id, _)| *id).collect(); + let Commit { + checkpoint, + changed, + touched, + } = self.live.commit(objects)?; + if checkpoint { + let all: Vec = self.live.revision.objects.keys().copied().collect(); + self.live.settle(all)?; + } else { + self.live.settle(changed)?; + } + self.refresh(&replaced, touched)?; + Ok(true) + } + + /// Brings the view, parents and titles up to date with the revision for the objects a + /// commit touched, which include those it replaced. + fn refresh(&mut self, replaced: &BTreeSet, touched: BTreeSet) -> Result<()> { + let mut attach = Vec::new(); + let mut detach = Vec::new(); + let mut gone = BTreeMap::new(); + for id in touched { + let reachable = self.live.is_reachable(id); + let viewed = self.view.nodes.contains_key(&id); + if reachable == viewed && !(reachable && replaced.contains(&id)) { + continue; + } + let fresh = if reachable { + Some(self.element(&self.live.revision.objects[&id])?) + } else { + None + }; + let old = self.view.nodes.remove(&id); + // An element leaving the view detaches with its last parent. + if let Some(node) = &fresh + && (self.parents.contains_key(&id) || self.pages.contains(&id)) + { + let before: Vec = old.iter().flat_map(edges).collect(); + let after: Vec = edges(node).collect(); + let (removed, added) = differing(&before, &after); + detach.extend(removed.iter().map(|child| (id, *child))); + attach.extend(added.iter().map(|child| (id, *child))); + } + self.titles.remove(&id); + if let Some(node) = fresh { + if is_title(&node) { + self.titles.insert(id); + } + self.view.nodes.insert(id, node); + } else if let Some(node) = old { + gone.insert(id, node); + } + } + let node = |nodes: &BTreeMap>, id: ExGuid| { + nodes + .get(&id) + .or_else(|| gone.get(&id)) + .map(|node| edges(node).collect::>()) + .ok_or(Error { + offset: 0, + message: "Page content is unavailable", + }) + }; + // Attaching before detaching keeps a moved subtree from being walked twice. + while let Some((parent, child)) = attach.pop() { + let parents = self.parents.entry(child).or_default(); + parents.push(parent); + if parents.len() == 1 && !self.pages.contains(&child) { + attach.extend( + node(&self.view.nodes, child)? + .into_iter() + .map(|grandchild| (child, grandchild)), + ); + } + } + while let Some((parent, child)) = detach.pop() { + let parents = self.parents.get_mut(&child).unwrap(); + let at = parents.iter().position(|id| *id == parent).unwrap(); + parents.remove(at); + if parents.is_empty() { + self.parents.remove(&child); + if !self.pages.contains(&child) { + detach.extend( + node(&self.view.nodes, child)? + .into_iter() + .map(|grandchild| (child, grandchild)), + ); + } + } + } + Ok(()) + } +} + +fn edges<'n>(node: &'n Element<'_>) -> impl Iterator + 'n { + node.children + .iter() + .chain(&node.content) + .chain(&node.structure) + .copied() +} + +fn is_title(node: &Element<'_>) -> bool { + node.extra + .first() + .is_some_and(|fields| fields.iter().any(|field| field.id == 0x88001cb4)) +} + +/// Writes one revision of a page space: `changes` computes its objects from the active page. +pub(crate) fn write( + source: &[u8], + space: ExGuid, + changes: impl FnOnce(&ActivePage<'_>) -> Result, +) -> Result> { + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + let changes = changes(&ActivePage::parse(&index, space)?)?; + if changes.is_empty() { + return Ok(source.to_vec()); + } + crate::write::write_revision_on(&index, space, |_| Ok(changes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Insertion, ParagraphSplit, TextAttribute, TreeEdit, document::Kind}; + + /// `active` equals the page `image` stores, views included. + fn assert_stores(active: &ActivePage<'_>, image: &[u8], space: ExGuid) { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let read = ActivePage::parse(&index, space).unwrap(); + assert_eq!(read.live.revision.roots, active.live.revision.roots); + let objects = |page: &ActivePage<'_>| -> Vec { + page.live + .revision + .objects + .iter() + .map(|(id, object)| format!("{id} {object:?}")) + .collect() + }; + assert_eq!(objects(&read), objects(active)); + assert_eq!(format!("{:?}", read.view), format!("{:?}", active.view)); + let parents = |page: &ActivePage<'_>| -> Vec<(ExGuid, Vec)> { + page.parents + .iter() + .map(|(child, parents)| { + let mut parents = parents.clone(); + parents.sort(); + (*child, parents) + }) + .collect() + }; + assert_eq!(parents(&read), parents(active)); + assert_eq!(read.titles, active.titles); + } + + /// Applies `steps` random writes to the first page of `source` both in memory and by + /// appending each revision, comparing the two as it goes. + fn check_writes(source: &[u8], steps: usize) { + let mut image = source.to_vec(); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let (space, _) = Document::parse(&index).unwrap().pages().unwrap()[0]; + let arena = Bump::new(); + let mut active = ActivePage::parse(&index, space).unwrap(); + let mut seed = 7_u64; + let mut pick = |count: usize| { + seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (seed >> 33) as usize % count.max(1) + }; + for step in 0..steps { + let nodes = &active.view.nodes; + let listed = |kind: fn(&Kind<'_>) -> bool| -> Vec { + nodes + .iter() + .filter(|(id, node)| kind(&node.kind) && active.parents.contains_key(id)) + .map(|(id, _)| *id) + .collect() + }; + let paragraphs = listed(|kind| matches!(kind, Kind::Paragraph { .. })); + let containers = listed(|kind| { + matches!( + kind, + Kind::Paragraph { .. } | Kind::Outline { .. } | Kind::Cell { .. } + ) + }); + let texts = listed(|kind| matches!(kind, Kind::RichText { .. })); + let paragraph = paragraphs[pick(paragraphs.len())]; + let container = containers[pick(containers.len())]; + let anchor = nodes[&container].children.get(pick(4)).copied(); + let text = texts[pick(texts.len())]; + let words = ["", "a", "Two words", "東京 🦀", "longer text here"]; + let word = words[pick(words.len())]; + let changes = match pick(8) { + 0..=3 => Insertion::paragraph(container, anchor, word, "Author") + .and_then(|insertion| insertion.changes(&active)), + 4 => TreeEdit::move_to(paragraph, container, anchor, "Author") + .and_then(|edit| edit.changes(&active)), + 5 => TreeEdit::delete(paragraph, "Author").and_then(|edit| edit.changes(&active)), + 6 => ParagraphSplit::new(text, pick(3) as u32, "Author") + .and_then(|split| split.changes(&active)), + _ => crate::formatting::format_changes( + &active, + text, + 0..pick(2) as u32, + &[TextAttribute::Bold(pick(2) == 0)], + ), + }; + let Ok(changes) = changes else { + continue; + }; + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let written = + crate::write::write_revision_on(&index, space, |_| Ok(changes.clone())).unwrap(); + assert_eq!( + active.write(&arena, &[], changes).unwrap(), + written != image, + "step {step}" + ); + image = written; + if step % 40 == 0 { + assert_stores(&active, &image, space); + } + } + assert_stores(&active, &image, space); + } + + #[test] + fn writes_leave_the_page_that_appending_and_reading_their_revisions_does() { + // Past 512 revisions, so the chain checkpoints once. + check_writes( + include_bytes!( + "../../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one" + ), + 720, + ); + check_writes( + include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"), + 160, + ); + check_writes( + &crate::create_section("model.one", "First", "Author").unwrap(), + 160, + ); + } +} diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs index d0590f7da2d8b145c496f728a84329d031f7c6f4..54d3fb603318f1a2998fec5575102192ed4f71d7 100644 --- a/crates/onestore/src/document.rs +++ b/crates/onestore/src/document.rs @@ -46,13 +46,13 @@ impl<'a> Space<'a> { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct Revision<'a> { pub roots: BTreeMap, pub nodes: BTreeMap>, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct Element<'a> { pub jcid: u32, pub children: Vec, @@ -490,13 +490,13 @@ pub enum Kind<'a> { Unknown, } -#[derive(Debug, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct Field<'a> { pub id: u32, pub value: FieldValue<'a>, } -#[derive(Debug, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub enum FieldValue<'a> { NoData, Bytes(#[serde(serialize_with = "hex")] &'a [u8]), @@ -648,6 +648,29 @@ impl<'a, 'o> Fields<'a, 'o> { } fn references(bytes: &[u8], ids: &BTreeMap) -> Result> { + // A long list, such as a large outline's children, reads a dense table as an array. + if bytes.len() >= 256 + && bytes.len().is_multiple_of(4) + && let Some((last, _)) = ids.last_key_value() + && *last as usize + 1 == ids.len() + { + let table: Vec<[u8; 16]> = ids.values().copied().collect(); + return bytes + .chunks_exact(4) + .enumerate() + .map(|(i, bytes)| { + let raw = u32::from_le_bytes(bytes.try_into().unwrap()); + let guid = table.get((raw >> 8) as usize).ok_or(Error { + offset: i * 4, + message: "Compact ID refers to a missing global ID", + })?; + Ok(ExGuid { + guid: *guid, + n: raw & 0xff, + }) + }) + .collect(); + } let mut cursor = Cursor { bytes, offset: 0 }; let mut result = Vec::new(); while !cursor.bytes.is_empty() { @@ -906,11 +929,7 @@ impl<'a> Document<'a> { } impl<'a> Element<'a> { - pub(crate) fn parse(object: &Object<'a>, store: &Store<'a>) -> Result { - Self::parse_with(object, store, &mut |id| store.file_data(id)) - } - - fn parse_with( + pub(crate) fn parse_with( object: &Object<'a>, store: &Store<'a>, file_data: &mut impl FnMut([u8; 16]) -> Result<&'a [u8]>, diff --git a/crates/onestore/src/edit.rs b/crates/onestore/src/edit.rs index 32ecf570a446a1bce57570dc99ec74f75158e23b..61be7cc4df0f8faf574b328f7131f39bed057223 100644 --- a/crates/onestore/src/edit.rs +++ b/crates/onestore/src/edit.rs @@ -1,7 +1,8 @@ use crate::{ - Error, ExGuid, Object, ObjectData, PropertySets, RevisionIndex, Store, + Error, ExGuid, Object, ObjectData, PropertySets, + active::{ActivePage, Changes}, create::string, - document::{Document, Element, Kind}, + document::{Element, Kind}, write::PropertyObject, }; use std::{collections::BTreeMap, ops::Range, sync::Arc}; @@ -35,28 +36,31 @@ pub fn replace_text( range: Range, replacement: &str, ) -> Result, Error> { - let invalid = |message| Error { offset: 0, message }; if range.start > range.end || replacement.contains(['\0', '\n', '\r', '\u{fffc}']) { - return Err(invalid( - "Use a valid text range and ordinary paragraph text", - )); + return Err(Error { + offset: 0, + message: "Use a valid text range and ordinary paragraph text", + }); } - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - let rid = index.active(space)?; - let raw = index.resolve(space, rid)?; - if !raw.reachable()?.contains(&object) { + crate::active::write(source, space, |active| { + text_changes(active, object, range, replacement) + }) +} + +/// `replace_text` on an active page. +pub(crate) fn text_changes( + active: &ActivePage<'_>, + object: ExGuid, + range: Range, + replacement: &str, +) -> Result { + let invalid = |message| Error { offset: 0, message }; + let raw = &active.live.revision; + if !active.live.is_reachable(object) { return Err(invalid("Object is not reachable in the active revision")); } - let document = Document::parse(&index)?; - let revision = document - .spaces - .get(&space) - .and_then(|s| s.revisions.get(&rid)) - .ok_or_else(|| invalid("The active document revision is unavailable"))?; - let pages = document.pages_in(space)?; - let parents = editable_parents(revision, &pages, object)?; + let revision = &active.view; + let parents = active.editable_parents(object)?; let node = revision .nodes .get(&object) @@ -129,7 +133,7 @@ pub fn replace_text( )); }; if &text[start..end] == replacement { - return Ok(source.to_vec()); + return Ok(Changes::new()); } let added = u32::try_from(replacement.encode_utf16().count()) .map_err(|_| invalid("Replacement text exceeds the UTF-16 offset range"))?; @@ -235,9 +239,9 @@ pub fn replace_text( pending.extend(parents.get(&id).into_iter().flatten().copied()); } let Some((page, automatic, title_text)) = - page_title(revision, &pages, Some((object, &changed)))? + active.title(&BTreeMap::new(), Some((object, &changed)))? else { - return crate::write::replace_objects(&index, space, &edits); + return crate::write::patched(raw, &edits); }; let Kind::Page { alternate_title, .. @@ -296,7 +300,7 @@ pub fn replace_text( &[] }, }); - crate::write::replace_objects(&index, space, &edits) + crate::write::patched(raw, &edits) } pub(crate) fn editable_parents( @@ -304,8 +308,19 @@ pub(crate) fn editable_parents( pages: &[ExGuid], object: ExGuid, ) -> Result>, Error> { - let invalid = |message| Error { offset: 0, message }; let parents = revision.parents(pages)?; + check_editable(revision, &parents, pages, object)?; + Ok(parents) +} + +/// Requires `object` on an active page with neither it nor an ancestor read-only. +pub(crate) fn check_editable( + revision: &crate::document::Revision<'_>, + parents: &BTreeMap>, + pages: &[ExGuid], + object: ExGuid, +) -> Result<(), Error> { + let invalid = |message| Error { offset: 0, message }; if !parents.contains_key(&object) && !pages.contains(&object) { return Err(invalid("Select content on an active editable page")); } @@ -323,22 +338,40 @@ pub(crate) fn editable_parents( } pending.extend(parents.get(&id).into_iter().flatten().copied()); } - Ok(parents) + Ok(()) } pub(crate) fn page_title( revision: &crate::document::Revision<'_>, pages: &[ExGuid], text_update: Option<(ExGuid, &str)>, +) -> Result, Error> { + title_of( + |id| revision.nodes.get(&id), + &revision.parents(pages)?, + revision.nodes.keys().copied(), + pages, + text_update, + ) +} + +/// `page_title` over the elements `node` finds, whose parents from `pages` are `parents`; +/// `candidates` lists, in order, every element that may be title text. +pub(crate) fn title_of<'n>( + node: impl Fn(ExGuid) -> Option<&'n Element<'n>>, + parents: &BTreeMap>, + candidates: impl Iterator, + pages: &[ExGuid], + text_update: Option<(ExGuid, &str)>, ) -> Result, Error> { let invalid = |message| Error { offset: 0, message }; - let parents = revision.parents(pages)?; - let seen: std::collections::BTreeSet<_> = parents.keys().chain(pages).copied().collect(); - let titles: Vec<_> = revision - .nodes - .iter() - .filter_map(|(id, node)| { - if !seen.contains(id) || !node.extra[0].iter().any(|field| field.id == 0x88001cb4) { + let titles: Vec<_> = candidates + .filter_map(|id| { + if !parents.contains_key(&id) && !pages.contains(&id) { + return None; + } + let node = node(id)?; + if !node.extra[0].iter().any(|field| field.id == 0x88001cb4) { return None; } match &node.kind { @@ -346,7 +379,7 @@ pub(crate) fn page_title( text, boilerplate: false, .. - } => Some((*id, text.as_str())), + } => Some((id, text.as_str())), _ => None, } }) @@ -369,15 +402,15 @@ pub(crate) fn page_title( let [page] = pages else { return Err(invalid("Title editing requires a single active page")); }; - let Kind::Page { rtl, .. } = &revision.nodes[page].kind else { + let Kind::Page { rtl, .. } = &node(*page).unwrap().kind else { unreachable!() }; let mut title_text = title_line(title_text); if automatic { - let mut roots = revision.nodes[page].children.clone(); + let mut roots = node(*page).unwrap().children.clone(); roots.sort_by(|a, b| { - let a = &revision.nodes[a].layout; - let b = &revision.nodes[b].layout; + let a = &node(*a).unwrap().layout; + let b = &node(*b).unwrap().layout; a.y.unwrap_or(0.0) .total_cmp(&b.y.unwrap_or(0.0)) .then_with(|| { @@ -394,12 +427,12 @@ pub(crate) fn page_title( if !seen.insert(id) { continue; } - let node = &revision.nodes[&id]; + let element = node(id).unwrap(); if let Kind::RichText { text, boilerplate: false, .. - } = &node.kind + } = &element.kind { title_text = automatic_title( if let Some((_, changed)) = text_update.filter(|(object, _)| *object == id) { @@ -412,40 +445,37 @@ pub(crate) fn page_title( break; } } - if *rtl == Some(true) && matches!(node.kind, Kind::Row) { - pending.extend(node.children.iter().copied()); + if *rtl == Some(true) && matches!(element.kind, Kind::Row) { + pending.extend(element.children.iter().copied()); } else { - pending.extend(node.children.iter().rev().copied()); + pending.extend(element.children.iter().rev().copied()); } - pending.extend(node.content.iter().rev().copied()); - pending.extend(node.structure.iter().rev().copied()); + pending.extend(element.content.iter().rev().copied()); + pending.extend(element.structure.iter().rev().copied()); } } Ok(Some((*page, automatic, title_text.to_owned()))) } +/// Updates the page title for `changed` objects made on `view`, a copy of the page's view. pub(crate) fn update_title( - store: &Store<'_>, - raw: &crate::ResolvedRevision<'_>, + active: &ActivePage<'_>, view: crate::document::Revision<'_>, - pages: &[ExGuid], changed: &mut BTreeMap, ) -> Result<(), Error> { let invalid = |message| Error { offset: 0, message }; + let (raw, pages) = (&active.live.revision, &active.pages); // Shorten the moved view's lifetime to the changed property buffers. let mut view = view; for (id, object) in changed.iter() { view.nodes.insert( *id, - Element::parse( - &Object { - jcid: object.jcid, - reference_count: 0, - data: ObjectData::Properties(&object.bytes), - global_ids: Arc::clone(&object.global_ids), - }, - store, - )?, + active.element(&Object { + jcid: object.jcid, + reference_count: 0, + data: ObjectData::Properties(&object.bytes), + global_ids: Arc::clone(&object.global_ids), + })?, ); } let title = page_title(&view, pages, None)?; diff --git a/crates/onestore/src/formatting.rs b/crates/onestore/src/formatting.rs index 428c22dbb23081b59fbd4e449b85441c985533f4..79f0ed7c8d1204418f581d22b141fe60fa690472 100644 --- a/crates/onestore/src/formatting.rs +++ b/crates/onestore/src/formatting.rs @@ -1,9 +1,9 @@ use crate::{ - Error, ExGuid, PropertySets, RevisionIndex, Store, + Error, ExGuid, PropertySets, + active::{ActivePage, Changes}, create::{current_timestamps, properties, string}, - document::{Document, Kind}, - edit::editable_parents, - write::{PropertyObject, fresh_guid, write_revision_on}, + document::Kind, + write::{PropertyObject, fresh_guid}, }; use serde::{Deserialize, Serialize}; use std::{ @@ -98,19 +98,26 @@ pub(crate) fn format_text( range: Range, attributes: &[TextAttribute], ) -> Result, Error> { + crate::active::write(source, space, |active| { + format_changes(active, object, range, attributes) + }) +} + +/// `format_text` on an active page. +pub(crate) fn format_changes( + active: &ActivePage<'_>, + object: ExGuid, + range: Range, + attributes: &[TextAttribute], +) -> Result { if attributes.is_empty() || range.start > range.end { return Err(invalid( "Select a text range and at least one formatting attribute", )); } let values = attribute_values(attributes)?; - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - let document = Document::parse(&index)?; - let view = document.active(space)?; - let pages = document.pages_in(space)?; - let parents = editable_parents(view, &pages, object)?; + let view = &active.view; + let parents = active.editable_parents(object)?; let node = &view.nodes[&object]; let Kind::RichText { text, @@ -172,87 +179,86 @@ pub(crate) fn format_text( } } let modified = current_timestamps()?.0.to_le_bytes(); - write_revision_on(&index, space, |raw| { - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - let fields = PropertySets::parse(&target.bytes)?; - if fields.sets[0].iter().any(|p| p.id == 0x24003458) { - return Err(invalid("This text object contains associated run objects")); - } - if segments.len() != runs.len() && fields.sets[0].iter().any(|p| p.id == 0x40003499) { - return Err(invalid( - "Formatting boundaries cannot split preserved run data", - )); - } - let mut styles = BTreeMap::new(); - let mut changed = BTreeMap::new(); - let mut references = Vec::new(); - let mut ends = Vec::new(); - let mut updated = false; - let changes: Vec<_> = values - .iter() - .map(|(id, value)| (*id, value.as_slice())) - .collect(); - for &(i, end, selected) in &segments { - let previous = runs[i].format; - let key = (previous, selected); - let id = if let Some(id) = styles.get(&key) { - *id - } else if let Some(id) = previous.filter(|_| !selected) { + let raw = &active.live.revision; + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let fields = PropertySets::parse(&target.bytes)?; + if fields.sets[0].iter().any(|p| p.id == 0x24003458) { + return Err(invalid("This text object contains associated run objects")); + } + if segments.len() != runs.len() && fields.sets[0].iter().any(|p| p.id == 0x40003499) { + return Err(invalid( + "Formatting boundaries cannot split preserved run data", + )); + } + let mut styles = BTreeMap::new(); + let mut changed = BTreeMap::new(); + let mut references = Vec::new(); + let mut ends = Vec::new(); + let mut updated = false; + let changes: Vec<_> = values + .iter() + .map(|(id, value)| (*id, value.as_slice())) + .collect(); + for &(i, end, selected) in &segments { + let previous = runs[i].format; + let key = (previous, selected); + let id = if let Some(id) = styles.get(&key) { + *id + } else if let Some(id) = previous.filter(|_| !selected) { + id + } else { + let mut style = match previous { + Some(id) => PropertyObject::from_object(&raw.objects[&id])?, + None => PropertyObject { + jcid: 0x12004d, + bytes: properties(&[])?, + global_ids: Arc::new(BTreeMap::new()), + }, + }; + if selected { + style.set(&changes)?; + } + if let Some(id) = previous + .filter(|id| raw.objects[id].data == crate::ObjectData::Properties(&style.bytes)) + { + styles.insert(key, id); id } else { - let mut style = match previous { - Some(id) => PropertyObject::from_object(&raw.objects[&id])?, - None => PropertyObject { - jcid: 0x12004d, - bytes: properties(&[])?, - global_ids: Arc::new(BTreeMap::new()), - }, + if !PropertySets::parse(&style.bytes)?.sets[0] + .iter() + .any(|p| p.id == 0x14001c3b) + { + style.set(&[( + 0x14001c3b, + &resolved[i].format.language.unwrap_or(0x409).to_le_bytes(), + )])?; + } + let id = ExGuid { + guid: fresh_guid()?, + n: 1, }; - if selected { - style.set(&changes)?; - } - if let Some(id) = previous.filter(|id| { - raw.objects[id].data == crate::ObjectData::Properties(&style.bytes) - }) { - styles.insert(key, id); - id - } else { - if !PropertySets::parse(&style.bytes)?.sets[0] - .iter() - .any(|p| p.id == 0x14001c3b) - { - style.set(&[( - 0x14001c3b, - &resolved[i].format.language.unwrap_or(0x409).to_le_bytes(), - )])?; - } - let id = ExGuid { - guid: fresh_guid()?, - n: 1, - }; - style.reference(id)?; - changed.insert(id, style); - styles.insert(key, id); - id - } - }; - updated |= selected && previous != Some(id); - references.extend_from_slice(&target.reference(id)?); - ends.extend_from_slice(&end.to_le_bytes()); - } - if !updated { - return Ok(BTreeMap::new()); - } - ends.truncate(ends.len() - 4); - target.set(&[ - (0x24001e13, &references), - (0x1c001e12, &ends), - (0x14001d7a, &modified), - ])?; - changed.insert(object, target); - touch_ancestors(raw, &parents, object, &modified, &mut changed)?; - Ok(changed) - }) + style.reference(id)?; + changed.insert(id, style); + styles.insert(key, id); + id + } + }; + updated |= selected && previous != Some(id); + references.extend_from_slice(&target.reference(id)?); + ends.extend_from_slice(&end.to_le_bytes()); + } + if !updated { + return Ok(BTreeMap::new()); + } + ends.truncate(ends.len() - 4); + target.set(&[ + (0x24001e13, &references), + (0x1c001e12, &ends), + (0x14001d7a, &modified), + ])?; + changed.insert(object, target); + touch_ancestors(raw, parents, object, &modified, &mut changed)?; + Ok(changed) } pub(crate) fn touch_ancestors( diff --git a/crates/onestore/src/insertion.rs b/crates/onestore/src/insertion.rs index b9a8081f7388f6fbffbfe423217a0d4754abbfa9..c6070391d6bb85ce7ad0653de83b88124ffe9d2a 100644 --- a/crates/onestore/src/insertion.rs +++ b/crates/onestore/src/insertion.rs @@ -1,9 +1,9 @@ use crate::{ - Error, ExGuid, Object, ObjectData, RevisionIndex, Store, + Error, ExGuid, Object, ObjectData, + active::{ActivePage, Changes}, create::{current_timestamps, default_text_style, properties, string}, - document::{Document, Element, Kind}, - edit::{editable_parents, page_title}, - write::{PropertyObject, fresh_guid, write_revision_on}, + document::Kind, + write::{PropertyObject, fresh_guid}, }; use serde::{Deserialize, Serialize}; use std::{ @@ -191,20 +191,15 @@ impl Insertion { pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> { self.validate()?; - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - let mut document = Document::parse(&index)?; - let pages = document.pages_in(space)?; - let [page] = pages.as_slice() else { + crate::active::write(source, space, |active| self.changes(active)) + } + + pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { + let [page] = active.pages.as_slice() else { return Err(invalid("Insertion requires a single active page")); }; - let mut view = document - .spaces - .remove(&space) - .and_then(crate::document::Space::into_active) - .ok_or_else(|| invalid("The active page is unavailable"))?; - let parents = editable_parents(&view, &pages, self.parent)?; + let view = &active.view; + let parents = active.editable_parents(self.parent)?; let parent = &view.nodes[&self.parent]; let position = match self.placement { Placement::Paragraph { before } => { @@ -363,82 +358,74 @@ impl Insertion { }, ); } - let raw = index.resolve_active(space)?; + let raw = &active.live.revision; if new.keys().any(|id| raw.objects.contains_key(id)) { return Err(invalid( "An insertion identity is already present; reconcile the existing edit", )); } - // Drop the semantic view before moving the property bytes it borrows. + // Drop the overlay before moving the property bytes it borrows. let title = { - view.nodes - .get_mut(&self.parent) - .unwrap() - .children - .insert(position, self.object()); + let mut overlay = BTreeMap::new(); + let mut parent = view.nodes[&self.parent].clone(); + parent.children.insert(position, self.object()); + overlay.insert(self.parent, parent); for (id, object) in &new { - view.nodes.insert( + overlay.insert( *id, - Element::parse( - &Object { - jcid: object.jcid, - reference_count: 0, - data: ObjectData::Properties(&object.bytes), - global_ids: Arc::clone(&object.global_ids), - }, - &store, - )?, + active.element(&Object { + jcid: object.jcid, + reference_count: 0, + data: ObjectData::Properties(&object.bytes), + global_ids: Arc::clone(&object.global_ids), + })?, ); } - let title = page_title(&view, &pages, None)?; - drop(view); - title + active.title(&overlay, None)? }; - write_revision_on(&index, space, |raw| { - let mut changed = new; - for id in &ancestors { - let mut object = PropertyObject::from_object(&raw.objects[id])?; - object.set(&[(0x14001d7a, &modified)])?; - changed.insert(*id, object); - } - let parent = changed.get_mut(&self.parent).unwrap(); + let mut changed = new; + for id in &ancestors { + let mut object = PropertyObject::from_object(&raw.objects[id])?; + object.set(&[(0x14001d7a, &modified)])?; + changed.insert(*id, object); + } + let parent = changed.get_mut(&self.parent).unwrap(); + let properties = crate::PropertySets::parse(&parent.bytes)?; + let existing = properties.sets[0].iter().find(|p| p.id == 0x24001c20); + let mut ids = match existing.map(|p| &p.value) { + Some(crate::Value::References { compact_ids, .. }) => compact_ids.to_vec(), + None => Vec::new(), + _ => return Err(invalid("The parent has an invalid child list")), + }; + let child = parent.reference(self.object())?; + if position > ids.len() / 4 { + return Err(invalid("The parent has an invalid child list")); + } + ids.splice(position * 4..position * 4, child); + parent.set(&[(0x24001c20, &ids)])?; + if matches!(self.placement, Placement::Paragraph { .. }) { let properties = crate::PropertySets::parse(&parent.bytes)?; - let existing = properties.sets[0].iter().find(|p| p.id == 0x24001c20); - let mut ids = match existing.map(|p| &p.value) { - Some(crate::Value::References { compact_ids, .. }) => compact_ids.to_vec(), - None => Vec::new(), - _ => return Err(invalid("The parent has an invalid child list")), - }; - let child = parent.reference(self.object())?; - if position > ids.len() / 4 { - return Err(invalid("The parent has an invalid child list")); + if !properties.sets[0].iter().any(|p| p.id == 0x0c001c03) { + parent.set(&[(0x0c001c03, &[1])])?; } - ids.splice(position * 4..position * 4, child); - parent.set(&[(0x24001c20, &ids)])?; - if matches!(self.placement, Placement::Paragraph { .. }) { - let properties = crate::PropertySets::parse(&parent.bytes)?; - if !properties.sets[0].iter().any(|p| p.id == 0x0c001c03) { - parent.set(&[(0x0c001c03, &[1])])?; - } + } + if let Some((page, automatic, title)) = title { + let metadata = raw + .roots + .get(&2) + .ok_or_else(|| invalid("Page title metadata is unavailable"))?; + if raw.objects[metadata].jcid != 0x20030 { + return Err(invalid("Page title metadata is unavailable")); } - if let Some((page, automatic, title)) = title { - let metadata = raw - .roots - .get(&2) - .ok_or_else(|| invalid("Page title metadata is unavailable"))?; - if raw.objects[metadata].jcid != 0x20030 { - return Err(invalid("Page title metadata is unavailable")); - } - let title = string(&title); - let mut metadata_object = PropertyObject::from_object(&raw.objects[metadata])?; - metadata_object.set(&[(0x1c001cf3, &title)])?; - changed.insert(*metadata, metadata_object); - changed - .get_mut(&page) - .unwrap() - .set(&[(0x1c001d3c, if automatic { &title } else { &[0, 0] })])?; - } - Ok(changed) - }) + let title = string(&title); + let mut metadata_object = PropertyObject::from_object(&raw.objects[metadata])?; + metadata_object.set(&[(0x1c001cf3, &title)])?; + changed.insert(*metadata, metadata_object); + changed + .get_mut(&page) + .unwrap() + .set(&[(0x1c001d3c, if automatic { &title } else { &[0, 0] })])?; + } + Ok(changed) } } diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index 9b29539f9ac3689afdc5c1a223759d724b3fd388..be75f348c7463b60e8c2f8d163a04d7fddc7b2ec 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] #![doc = include_str!("../README.md")] +mod active; mod bytes; mod commit; mod create; diff --git a/crates/onestore/src/objects.rs b/crates/onestore/src/objects.rs index d9e222669e66a16f1b9980ab60176c2174f0a6b2..dd548ae0a0ac98e54f4c220cdb838955cb1392a7 100644 --- a/crates/onestore/src/objects.rs +++ b/crates/onestore/src/objects.rs @@ -82,6 +82,11 @@ impl Object<'_> { impl ResolvedRevision<'_> { pub fn reachable(&self) -> Result> { + Ok(self.checked_counts()?.into_keys().collect()) + } + + /// Incoming reference counts of the reachable objects, which must equal those stored. + pub(crate) fn checked_counts(&self) -> Result> { let incoming = self.reference_counts()?; for (id, count) in &incoming { if self.objects[id].reference_count != *count { @@ -91,7 +96,7 @@ impl ResolvedRevision<'_> { }); } } - Ok(incoming.into_keys().collect()) + Ok(incoming) } pub(crate) fn reference_counts(&self) -> Result> { diff --git a/crates/onestore/src/outline.rs b/crates/onestore/src/outline.rs index f7207d209526aed9fb263ced2542ea7de1819a86..924a0a139e065d8a8cd95da742625e5ed65246c7 100644 --- a/crates/onestore/src/outline.rs +++ b/crates/onestore/src/outline.rs @@ -1,9 +1,10 @@ use crate::{ - Error, ExGuid, RevisionIndex, Store, + Error, ExGuid, + active::{ActivePage, Changes}, create::current_timestamps, - document::{Document, Kind}, - edit::{editable_parents, update_title}, - write::{PropertyObject, write_revision_on}, + document::Kind, + edit::update_title, + write::PropertyObject, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -30,6 +31,10 @@ impl OutlineEdit { space: ExGuid, object: ExGuid, ) -> Result, Error> { + crate::active::write(source, space, |active| self.changes(active, object)) + } + + pub(crate) fn changes(self, active: &ActivePage<'_>, object: ExGuid) -> Result { let invalid = |message| Error { offset: 0, message }; let values = match self { Self::Position { x, y } => { @@ -54,20 +59,12 @@ impl OutlineEdit { } Self::Collapsed(value) => vec![(0x0c001c11, vec![u8::from(value)])], }; - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - let mut document = Document::parse(&index)?; - let pages = document.pages_in(space)?; + let pages = &active.pages; let [page] = pages.as_slice() else { return Err(invalid("Outline editing requires a single active page")); }; - let view = document - .spaces - .remove(&space) - .and_then(crate::document::Space::into_active) - .unwrap(); - let parents = editable_parents(&view, &pages, object)?; + let view = &active.view; + let parents = active.editable_parents(object)?; let node = &view.nodes[&object]; match self { Self::Collapsed(_) => { @@ -105,34 +102,33 @@ impl OutlineEdit { pending.extend(parents.get(&id).into_iter().flatten().copied()); } let modified = current_timestamps()?.0.to_le_bytes(); - write_revision_on(&index, space, |raw| { - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - target.set( - &values - .iter() - .map(|(id, data)| (*id, data.as_slice())) - .collect::>(), - )?; - if matches!(self, Self::Width { .. }) { - target.remove(&[0x14001cdb])?; + let raw = &active.live.revision; + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + target.set( + &values + .iter() + .map(|(id, data)| (*id, data.as_slice())) + .collect::>(), + )?; + if matches!(self, Self::Width { .. }) { + target.remove(&[0x14001cdb])?; + } + if raw.objects[&object].data == crate::ObjectData::Properties(&target.bytes) { + return Ok(BTreeMap::new()); + } + target.set(&[(0x14001d7a, &modified)])?; + let mut changed = BTreeMap::from([(object, target)]); + for id in ancestors { + if id != object { + let mut ancestor = PropertyObject::from_object(&raw.objects[&id])?; + ancestor.set(&[(0x14001d7a, &modified)])?; + changed.insert(id, ancestor); } - if raw.objects[&object].data == crate::ObjectData::Properties(&target.bytes) { - return Ok(BTreeMap::new()); - } - target.set(&[(0x14001d7a, &modified)])?; - let mut changed = BTreeMap::from([(object, target)]); - for id in ancestors { - if id != object { - let mut ancestor = PropertyObject::from_object(&raw.objects[&id])?; - ancestor.set(&[(0x14001d7a, &modified)])?; - changed.insert(id, ancestor); - } - } - if matches!(self, Self::Position { .. }) { - update_title(&store, raw, view, &pages, &mut changed)?; - } - Ok(changed) - }) + } + if matches!(self, Self::Position { .. }) { + update_title(active, view.clone(), &mut changed)?; + } + Ok(changed) } } @@ -140,6 +136,7 @@ impl OutlineEdit { mod tests { use super::*; use crate::write::write_revision; + use crate::{RevisionIndex, Store, document::Document}; #[test] fn protection_on_the_target_or_ancestor_prevents_layout_edits() { diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index dccfc64e42300787552ceca13c1f40e507afa32c..f34701c2d00e8d4346ca6263e4edc34e78ebe47f 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -1,5 +1,6 @@ //! Publishes an edited page model by lowering the difference from the stored page onto -//! the typed writers, then squashing their transactions into one revision per space. +//! the typed writers, whose revisions accumulate in memory, then squashing them into one +//! revision per space. use super::{ Attachment, Image, Ink, Math, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table, @@ -7,9 +8,11 @@ use super::{ use crate::{ Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, RevisionIndex, Store, TextAttribute, TreeEdit, Value, - document::{Document, Format, Kind, Tag}, + active::{ActivePage, Changes}, + document::{Format, Kind, Tag}, write::{PropertyObject, RevisionEdit}, }; +use bumpalo::Bump; use std::{ collections::{BTreeMap, BTreeSet}, ops::Range, @@ -339,38 +342,36 @@ pub(crate) fn write_page( if author.contains('\0') { return Err(invalid("Choose an author name without NUL")); } - // The parsed source is released before the writers parse their own images. - let (page, before, existing) = { - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - let document = Document::parse(&index)?; - let pages = document.pages_in(space)?; - let [page] = pages.as_slice() else { - return Err(invalid("Choose an object space containing one active page")); - }; - let existing = index - .resolve_active(space)? - .objects - .keys() - .copied() - .collect(); - (*page, Page::from_space(&document, space)?, existing) + let store = Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + let arena = Bump::new(); + let active = ActivePage::parse(&index, space)?; + let [page] = active.pages[..] else { + return Err(invalid("Choose an object space containing one active page")); }; + let before = Page::from_revision(&active.view, page)?; let mut lowering = Lowering { - image: source.to_vec(), + active, + arena: &arena, current: Some(before.clone()), - space, + edited: false, page, author, alias: BTreeMap::new(), built: BTreeSet::new(), }; - lowering.run(&before, after, &existing)?; - if lowering.image == source { - return Ok(lowering.image); + lowering.run(&before, after)?; + if !lowering.edited { + return Ok(source.to_vec()); } - squash(source, &lowering.image, &lowering.alias, None) + let active = &lowering.active; + squash( + &index, + &[(space, &active.live.revision)], + &active.payloads, + &lowering.alias, + None, + ) } /// Direct children of every container, in model order, plus lookups by identity. @@ -461,10 +462,13 @@ impl<'a> View<'a> { } struct Lowering<'a> { - image: Vec, - /// The page as `image` stores it, until a writer changes the image. + active: ActivePage<'a>, + /// Holds the objects the typed writers store for as long as `active` reads them. + arena: &'a Bump, + /// The page as `active` stores it, until a writer changes it. current: Option, - space: ExGuid, + /// Whether a writer stored anything. + edited: bool, page: ExGuid, author: &'a str, /// Model identities of new objects mapped to the identities the typed writers allocated. @@ -473,7 +477,7 @@ struct Lowering<'a> { built: BTreeSet, } -impl Lowering<'_> { +impl<'a> Lowering<'a> { fn id(&self, model: ExGuid) -> ExGuid { self.alias.get(&model).copied().unwrap_or(model) } @@ -488,31 +492,35 @@ impl Lowering<'_> { Ok(written) } - fn apply(&mut self, edit: impl FnOnce(&[u8]) -> Result, Error>) -> Result<(), Error> { - let image = edit(&self.image)?; - if image != self.image { - self.image = image; + /// Stores the next revision a typed writer computes from the page as stored so far. + fn write( + &mut self, + changes: impl FnOnce(&ActivePage<'a>) -> Result, + ) -> Result<(), Error> { + self.write_with(&[], changes) + } + + fn write_with( + &mut self, + payloads: &[([u8; 16], &[u8])], + changes: impl FnOnce(&ActivePage<'a>) -> Result, + ) -> Result<(), Error> { + let changes = changes(&self.active)?; + if self.active.write(self.arena, payloads, changes)? { self.current = None; + self.edited = true; } Ok(()) } fn current(&mut self) -> Result { if self.current.is_none() { - let store = Store::parse(&self.image)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - self.current = Some(Page::from_space(&document, self.space)?); + self.current = Some(Page::from_revision(&self.active.view, self.page)?); } Ok(self.current.clone().unwrap()) } - fn run( - &mut self, - before: &Page, - after: &Page, - existing: &BTreeSet, - ) -> Result<(), Error> { + fn run(&mut self, before: &Page, after: &Page) -> Result<(), Error> { if after.created != before.created || after.margin_origin != before.margin_origin { return Err(invalid("Page creation time and margins cannot be edited")); } @@ -540,7 +548,7 @@ impl Lowering<'_> { for id in new.outlines.keys().chain(new.paragraphs.keys()) { if !old.outlines.contains_key(id) && !old.paragraphs.contains_key(id) - && existing.contains(id) + && self.active.live.revision.objects.contains_key(id) { return Err(invalid( "A new model identity already exists in the section", @@ -730,8 +738,7 @@ impl Lowering<'_> { } }; let insertion = Insertion::outline(self.page, x, y, text, self.author)?; - let space = self.space; - self.apply(|image| insertion.apply(image, space))?; + self.write(|active| insertion.changes(active))?; let outline_id = insertion.object(); self.alias.insert(*id, outline_id); self.alias.insert( @@ -809,8 +816,7 @@ impl Lowering<'_> { continue; } let split = ParagraphSplit::new(previous.id, offset, self.author)?; - let space = self.space; - self.apply(|image| split.apply(image, space))?; + self.write(|active| split.changes(active))?; self.alias.insert(right, split.object()); self.alias.insert(next.id, split.text_object()); let list = placed.get_mut(container).unwrap(); @@ -855,8 +861,7 @@ impl Lowering<'_> { )); } let join = ParagraphJoin::new(previous.id, removed.id, self.author)?; - let space = self.space; - self.apply(|image| join.apply(image, space))?; + self.write(|active| join.changes(active))?; consumed.insert(right); placed.get_mut(container).unwrap().retain(|id| *id != right); placed.remove(&right); @@ -905,8 +910,7 @@ impl Lowering<'_> { next.map(|n| self.id(n)), self.author, )?; - let space = self.space; - self.apply(|image| edit.apply(image, space))?; + self.write(|active| edit.changes(active))?; } next = Some(*id); } @@ -1037,7 +1041,6 @@ impl Lowering<'_> { values.push((0x1c001d9d, crate::create::string(path))); } let holder = self.id(*paragraph_id); - let space = self.space; let mut payloads: Vec<([u8; 16], &[u8])> = vec![(payload_guid, bytes)]; let mut preview = None; if let Some(icon) = &attachment.preview { @@ -1054,35 +1057,34 @@ impl Lowering<'_> { payload_reference(guid), )); } - self.apply(|current| { - crate::write::write_revision_with_payloads(current, space, &payloads, |raw| { - let mut changed = BTreeMap::new(); - let mut file = PropertyObject::file(file_id, &reference, extension)?; - file.jcid = 0x80036; - changed.insert(file_id, file); - let mut node = PropertyObject { - jcid: 0x60035, - bytes: crate::create::properties(&values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, attachment_id.guid)])), - }; - node.reference(attachment_id)?; - let container = node.reference(file_id)?; - node.set(&[(0x20001d9b, &container)])?; - if let Some((icon_id, icon_reference)) = &preview { - changed.insert( - *icon_id, - PropertyObject::file(*icon_id, icon_reference, ".png")?, - ); - let icon = node.reference(*icon_id)?; - node.set(&[(0x20001c3f, &icon)])?; - } - changed.insert(attachment_id, node); - let mut object = PropertyObject::from_object(&raw.objects[&holder])?; - let content = object.reference(attachment_id)?; - object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; - changed.insert(holder, object); - Ok(changed) - }) + self.write_with(&payloads, |active| { + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + let mut file = PropertyObject::file(file_id, &reference, extension)?; + file.jcid = 0x80036; + changed.insert(file_id, file); + let mut node = PropertyObject { + jcid: 0x60035, + bytes: crate::create::properties(&values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, attachment_id.guid)])), + }; + node.reference(attachment_id)?; + let container = node.reference(file_id)?; + node.set(&[(0x20001d9b, &container)])?; + if let Some((icon_id, icon_reference)) = &preview { + changed.insert( + *icon_id, + PropertyObject::file(*icon_id, icon_reference, ".png")?, + ); + let icon = node.reference(*icon_id)?; + node.set(&[(0x20001c3f, &icon)])?; + } + changed.insert(attachment_id, node); + let mut object = PropertyObject::from_object(&raw.objects[&holder])?; + let content = object.reference(attachment_id)?; + object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; + changed.insert(holder, object); + Ok(changed) })?; } Ok(()) @@ -1201,49 +1203,47 @@ impl Lowering<'_> { } let (modified, filetime) = crate::create::current_timestamps()?; let modified = modified.to_le_bytes(); - let (space, page) = (self.space, self.page); - self.apply(|current| { - crate::write::write_revision(current, space, |raw| { - let mut changed = BTreeMap::new(); - let mut data_object = PropertyObject { - jcid: 0x2003b, - bytes: crate::create::properties(&[])?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])), - }; - data_object.reference(data)?; - let references = - write_strokes(&mut changed, &mut data_object, &strokes, 0, filetime)?; - data_object.set(&[(0x24003416, &references)])?; - changed.insert(data, data_object); - let mut object = PropertyObject { - jcid: 0x60014, - bytes: crate::create::properties(&[ - (0x14001d7a, modified.to_vec()), - (0x14001d4e, 1u32.to_le_bytes().to_vec()), - ])?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, container.guid)])), - }; - object.reference(container)?; - let data_reference = object.reference(data)?; - object.set(&[(0x20003415, &data_reference)])?; - changed.insert(container, object); - match holder { - Some(holder) => { - let mut object = PropertyObject::from_object(&raw.objects[&holder])?; - let content = object.reference(container)?; - object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; - changed.insert(holder, object); - } - None => { - let mut object = PropertyObject::from_object(&raw.objects[&page])?; - let mut children = page_children(&object)?; - children.extend(object.reference(container)?); - object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?; - changed.insert(page, object); - } + let page = self.page; + self.write(|active| { + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + let mut data_object = PropertyObject { + jcid: 0x2003b, + bytes: crate::create::properties(&[])?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])), + }; + data_object.reference(data)?; + let references = write_strokes(&mut changed, &mut data_object, &strokes, 0, filetime)?; + data_object.set(&[(0x24003416, &references)])?; + changed.insert(data, data_object); + let mut object = PropertyObject { + jcid: 0x60014, + bytes: crate::create::properties(&[ + (0x14001d7a, modified.to_vec()), + (0x14001d4e, 1u32.to_le_bytes().to_vec()), + ])?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, container.guid)])), + }; + object.reference(container)?; + let data_reference = object.reference(data)?; + object.set(&[(0x20003415, &data_reference)])?; + changed.insert(container, object); + match holder { + Some(holder) => { + let mut object = PropertyObject::from_object(&raw.objects[&holder])?; + let content = object.reference(container)?; + object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; + changed.insert(holder, object); } - Ok(changed) - }) + None => { + let mut object = PropertyObject::from_object(&raw.objects[&page])?; + let mut children = page_children(&object)?; + children.extend(object.reference(container)?); + object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?; + changed.insert(page, object); + } + } + Ok(changed) }) } @@ -1266,47 +1266,41 @@ impl Lowering<'_> { } } let container = self.id(ink.id); - let data = { - let store = Store::parse(&self.image)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let revision = document - .spaces - .get(&self.space) - .and_then(crate::document::Space::active) - .ok_or_else(|| invalid("The page space is unavailable"))?; - match revision.nodes.get(&container).map(|node| &node.kind) { - Some(crate::document::Kind::Ink { - data: Some(data), .. - }) => *data, - _ => return Err(invalid("Stored ink has no stroke data to rewrite")), - } + let data = match self + .active + .view + .nodes + .get(&container) + .map(|node| &node.kind) + { + Some(crate::document::Kind::Ink { + data: Some(data), .. + }) => *data, + _ => return Err(invalid("Stored ink has no stroke data to rewrite")), }; let (modified, filetime) = crate::create::current_timestamps()?; let modified = modified.to_le_bytes(); - let space = self.space; - self.apply(|current| { - crate::write::write_revision(current, space, |raw| { - let mut changed = BTreeMap::new(); - let mut data_object = PropertyObject::from_object(&raw.objects[&data])?; - let mut references = Vec::new(); - for id in &kept { - references.extend(data_object.reference(*id)?); - } - references.extend(write_strokes( - &mut changed, - &mut data_object, - &added, - kept.len(), - filetime, - )?); - data_object.set(&[(0x24003416, &references)])?; - changed.insert(data, data_object); - let mut object = PropertyObject::from_object(&raw.objects[&container])?; - object.set(&[(0x14001d7a, &modified)])?; - changed.insert(container, object); - Ok(changed) - }) + self.write(|active| { + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + let mut data_object = PropertyObject::from_object(&raw.objects[&data])?; + let mut references = Vec::new(); + for id in &kept { + references.extend(data_object.reference(*id)?); + } + references.extend(write_strokes( + &mut changed, + &mut data_object, + &added, + kept.len(), + filetime, + )?); + data_object.set(&[(0x24003416, &references)])?; + changed.insert(data, data_object); + let mut object = PropertyObject::from_object(&raw.objects[&container])?; + object.set(&[(0x14001d7a, &modified)])?; + changed.insert(container, object); + Ok(changed) }) } @@ -1349,44 +1343,38 @@ impl Lowering<'_> { values.push((0x08001d13 | (1 << 31), Vec::new())); } values.push((0x08001d85, Vec::new())); - let (space, page) = (self.space, self.page); + let page = self.page; let payload: &[u8] = bytes; - self.apply(|current| { - crate::write::write_revision_with_payloads( - current, - space, - &[(payload_guid, payload)], - |raw| { - let mut changed = BTreeMap::new(); - let file = PropertyObject::file(file_id, &reference, extension)?; - changed.insert(file_id, file); - let mut picture = PropertyObject { - jcid: 0x60011, - bytes: crate::create::properties(&values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])), - }; - picture.reference(image_id)?; - let container = picture.reference(file_id)?; - picture.set(&[(0x20001c3f, &container)])?; - changed.insert(image_id, picture); - match holder { - Some(holder) => { - let mut object = PropertyObject::from_object(&raw.objects[&holder])?; - let content = object.reference(image_id)?; - object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; - changed.insert(holder, object); - } - None => { - let mut object = PropertyObject::from_object(&raw.objects[&page])?; - let mut children = page_children(&object)?; - children.extend(object.reference(image_id)?); - object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?; - changed.insert(page, object); - } - } - Ok(changed) - }, - ) + self.write_with(&[(payload_guid, payload)], |active| { + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + let file = PropertyObject::file(file_id, &reference, extension)?; + changed.insert(file_id, file); + let mut picture = PropertyObject { + jcid: 0x60011, + bytes: crate::create::properties(&values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])), + }; + picture.reference(image_id)?; + let container = picture.reference(file_id)?; + picture.set(&[(0x20001c3f, &container)])?; + changed.insert(image_id, picture); + match holder { + Some(holder) => { + let mut object = PropertyObject::from_object(&raw.objects[&holder])?; + let content = object.reference(image_id)?; + object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; + changed.insert(holder, object); + } + None => { + let mut object = PropertyObject::from_object(&raw.objects[&page])?; + let mut children = page_children(&object)?; + children.extend(object.reference(image_id)?); + object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?; + changed.insert(page, object); + } + } + Ok(changed) }) } @@ -1434,18 +1422,17 @@ impl Lowering<'_> { None => removed.extend([0x140034cd, 0x140034ce]), } } - let (space, object) = (self.space, self.id(attachment.id)); - self.apply(|current| { - crate::write::write_revision(current, space, |raw| { - let mut node = PropertyObject::from_object(&raw.objects[&object])?; - node.remove(&removed)?; - let values: Vec<(u32, &[u8])> = values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect(); - node.set(&values)?; - Ok(BTreeMap::from([(object, node)])) - }) + let object = self.id(attachment.id); + self.write(|active| { + let raw = &active.live.revision; + let mut node = PropertyObject::from_object(&raw.objects[&object])?; + node.remove(&removed)?; + let values: Vec<(u32, &[u8])> = values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect(); + node.set(&values)?; + Ok(BTreeMap::from([(object, node)])) }) } @@ -1466,18 +1453,17 @@ impl Lowering<'_> { None => removed.push(0x1c001e58), } } - let (space, object) = (self.space, self.id(image.id)); - self.apply(|current| { - crate::write::write_revision(current, space, |raw| { - let mut picture = PropertyObject::from_object(&raw.objects[&object])?; - picture.remove(&removed)?; - let values: Vec<(u32, &[u8])> = values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect(); - picture.set(&values)?; - Ok(BTreeMap::from([(object, picture)])) - }) + let object = self.id(image.id); + self.write(|active| { + let raw = &active.live.revision; + let mut picture = PropertyObject::from_object(&raw.objects[&object])?; + picture.remove(&removed)?; + let values: Vec<(u32, &[u8])> = values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect(); + picture.set(&values)?; + Ok(BTreeMap::from([(object, picture)])) }) } @@ -1540,8 +1526,7 @@ impl Lowering<'_> { }; let insertion = Insertion::paragraph(self.id(*container), anchor, text, self.author)?; - let space = self.space; - self.apply(|image| insertion.apply(image, space))?; + self.write(|active| insertion.changes(active))?; self.alias.insert(*id, insertion.object()); if let Some(text_id) = text_id { self.alias.insert(text_id, insertion.text_object()); @@ -1549,8 +1534,7 @@ impl Lowering<'_> { } else if !kept.contains(id) { let edit = TreeEdit::move_to(self.id(*id), self.id(*container), anchor, self.author)?; - let space = self.space; - self.apply(|image| edit.apply(image, space))?; + self.write(|active| edit.changes(active))?; } next = Some(*id); } @@ -1607,19 +1591,17 @@ impl Lowering<'_> { values.push((0x1c001c12, measurement_bytes(&cell.indents, 4)?)); } } - let (space, object) = (self.space, self.id(cell.id)); - self.apply(|current| { - crate::write::write_revision(current, space, |raw| { - let mut object_properties = - PropertyObject::from_object(&raw.objects[&object])?; - object_properties.remove(&removed)?; - let values: Vec<(u32, &[u8])> = values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect(); - object_properties.set(&values)?; - Ok(BTreeMap::from([(object, object_properties)])) - }) + let object = self.id(cell.id); + self.write(|active| { + let raw = &active.live.revision; + let mut object_properties = PropertyObject::from_object(&raw.objects[&object])?; + object_properties.remove(&removed)?; + let values: Vec<(u32, &[u8])> = values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect(); + object_properties.set(&values)?; + Ok(BTreeMap::from([(object, object_properties)])) })?; } } @@ -1755,56 +1737,54 @@ impl Lowering<'_> { } let holder = self.id(*paragraph_id); let is_new = previous.is_none(); - let space = self.space; - self.apply(|image| { - crate::write::write_revision(image, space, |raw| { - let mut changed: BTreeMap = BTreeMap::new(); - for (id, jcid, values) in &created { - let mut node = PropertyObject { - jcid: *jcid, - bytes: crate::create::properties(values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), - }; - node.reference(*id)?; - changed.insert(*id, node); - } - let mut row_refs = Vec::new(); - for (row_id, cells) in &rows { - let mut row = match changed.remove(row_id) { - Some(row) => row, - None => PropertyObject::from_object(&raw.objects[row_id])?, - }; - let mut cell_refs = Vec::new(); - for cell in cells { - cell_refs.extend_from_slice(&row.reference(*cell)?); - } - row.set(&[(0x24001c20, &cell_refs), (0x14001d7a, &modified)])?; - changed.insert(*row_id, row); - } - let mut table_object = match changed.remove(&table_id) { - Some(object) => object, - None => PropertyObject::from_object(&raw.objects[&table_id])?, + self.write(|active| { + let raw = &active.live.revision; + let mut changed: BTreeMap = BTreeMap::new(); + for (id, jcid, values) in &created { + let mut node = PropertyObject { + jcid: *jcid, + bytes: crate::create::properties(values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), }; - for (row_id, _) in &rows { - row_refs.extend_from_slice(&table_object.reference(*row_id)?); - } - table_object.remove(&[0x1c001d7d, 0x08001d5e])?; - table_object.set( - &table_values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect::>(), - )?; - table_object.set(&[(0x24001c20, &row_refs)])?; - changed.insert(table_id, table_object); - if is_new { - let mut object = PropertyObject::from_object(&raw.objects[&holder])?; - let reference = object.reference(table_id)?; - object.set(&[(0x24001c1f, &reference), (0x14001d7a, &modified)])?; - changed.insert(holder, object); + node.reference(*id)?; + changed.insert(*id, node); + } + let mut row_refs = Vec::new(); + for (row_id, cells) in &rows { + let mut row = match changed.remove(row_id) { + Some(row) => row, + None => PropertyObject::from_object(&raw.objects[row_id])?, + }; + let mut cell_refs = Vec::new(); + for cell in cells { + cell_refs.extend_from_slice(&row.reference(*cell)?); } - Ok(changed) - }) + row.set(&[(0x24001c20, &cell_refs), (0x14001d7a, &modified)])?; + changed.insert(*row_id, row); + } + let mut table_object = match changed.remove(&table_id) { + Some(object) => object, + None => PropertyObject::from_object(&raw.objects[&table_id])?, + }; + for (row_id, _) in &rows { + row_refs.extend_from_slice(&table_object.reference(*row_id)?); + } + table_object.remove(&[0x1c001d7d, 0x08001d5e])?; + table_object.set( + &table_values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect::>(), + )?; + table_object.set(&[(0x24001c20, &row_refs)])?; + changed.insert(table_id, table_object); + if is_new { + let mut object = PropertyObject::from_object(&raw.objects[&holder])?; + let reference = object.reference(table_id)?; + object.set(&[(0x24001c1f, &reference), (0x14001d7a, &modified)])?; + changed.insert(holder, object); + } + Ok(changed) })?; } Ok(()) @@ -1846,8 +1826,7 @@ impl Lowering<'_> { continue; } let edit = TreeEdit::delete(*id, self.author)?; - let space = self.space; - self.apply(|image| edit.apply(image, space))?; + self.write(|active| edit.changes(active))?; } let kept: BTreeSet = page_images(new.page) .map(|(id, _)| id) @@ -1859,8 +1838,7 @@ impl Lowering<'_> { { if !kept.contains(&id) { let edit = TreeEdit::delete(id, self.author)?; - let space = self.space; - self.apply(|image| edit.apply(image, space))?; + self.write(|active| edit.changes(active))?; } } for id in old.outlines.keys() { @@ -1869,8 +1847,7 @@ impl Lowering<'_> { return Err(invalid("Title outlines cannot be removed")); } let edit = TreeEdit::delete(*id, self.author)?; - let space = self.space; - self.apply(|image| edit.apply(image, space))?; + self.write(|active| edit.changes(active))?; } } Ok(()) @@ -1943,56 +1920,54 @@ impl Lowering<'_> { ends.truncate(ends.len() - 4); let has_objects = sets.iter().any(|set| !set.is_empty()); let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let space = self.space; - self.apply(|current| { - crate::write::write_revision(current, space, |raw| { - let mut changed = BTreeMap::new(); - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - let mut references = Vec::new(); - let mut created: Vec<(Values, ExGuid)> = Vec::new(); - for values in &styles { - let id = match created.iter().find(|(known, _)| known == values) { - Some((_, id)) => *id, - None => { - let id = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - let mut style = PropertyObject { - jcid: 0x12004d, - bytes: crate::create::properties(values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), - }; - style.reference(id)?; - changed.insert(id, style); - created.push((values.clone(), id)); - id - } - }; - references.extend(target.reference(id)?); - } - target.remove(&[0x1c003498, 0x40003499])?; + self.write(|active| { + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let mut references = Vec::new(); + let mut created: Vec<(Values, ExGuid)> = Vec::new(); + for values in &styles { + let id = match created.iter().find(|(known, _)| known == values) { + Some((_, id)) => *id, + None => { + let id = ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }; + let mut style = PropertyObject { + jcid: 0x12004d, + bytes: crate::create::properties(values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), + }; + style.reference(id)?; + changed.insert(id, style); + created.push((values.clone(), id)); + id + } + }; + references.extend(target.reference(id)?); + } + target.remove(&[0x1c003498, 0x40003499])?; + target.set(&[ + (0x1c001c22, &encoded), + (0x1c001e12, &ends), + (0x24001e13, &references), + (0x14001d7a, &modified), + ])?; + if has_objects { + target.set_sets(0x40003499, 0x44000811, &sets)?; + } + if all_math { + // The flags and language marker OneNote's equation editor leaves on + // every equation text object. target.set(&[ - (0x1c001c22, &encoded), - (0x1c001e12, &ends), - (0x24001e13, &references), - (0x14001d7a, &modified), + (0x10001cfe, &0x7f_u16.to_le_bytes()), + (0x14001c3e, &1u32.to_le_bytes()), + (0x14001c84, &1u32.to_le_bytes()), ])?; - if has_objects { - target.set_sets(0x40003499, 0x44000811, &sets)?; - } - if all_math { - // The flags and language marker OneNote's equation editor leaves on - // every equation text object. - target.set(&[ - (0x10001cfe, &0x7f_u16.to_le_bytes()), - (0x14001c3e, &1u32.to_le_bytes()), - (0x14001c84, &1u32.to_le_bytes()), - ])?; - } - changed.insert(object, target); - Ok(changed) - }) + } + changed.insert(object, target); + Ok(changed) })?; } Ok(()) @@ -2016,8 +1991,8 @@ impl Lowering<'_> { continue; } let (range, replacement) = text_edit(stored.text.text(), text.text.text())?; - let (space, object) = (self.space, stored.id); - self.apply(|image| crate::replace_text(image, space, object, range, &replacement))?; + let object = stored.id; + self.write(|active| crate::edit::text_changes(active, object, range, &replacement))?; } Ok(()) } @@ -2132,70 +2107,56 @@ impl Lowering<'_> { } nodes.push((node_id, values)); } - let (space, object) = (self.space, self.id(*id)); - self.apply(|image| { - let store = Store::parse(image)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let parents = crate::edit::editable_parents( - document.active(space)?, - &document.pages_in(space)?, - object, - )?; + let object = self.id(*id); + self.write(|active| { + let parents = active.editable_parents(object)?; let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - crate::write::write_revision(image, space, |raw| { - let mut changed = BTreeMap::new(); - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - let mut references = Vec::new(); - for (list, values) in &nodes { - let mut node = match raw.objects.get(list) { - Some(existing) => { - if existing.jcid != 0x60012 { - return Err(invalid( - "A list definition identity belongs to another object", - )); - } - let mut node = PropertyObject::from_object(existing)?; - node.remove(&[ - 0x1c001c1a, 0x1c001c52, 0x14001cb7, 0x10001d0e, 0x0c001cc0, - 0x1c001c0a, 0x10001c0b, 0x14001c0c, 0x14001c3b, 0x08001c04, - 0x08001c05, - ])?; - node + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let mut references = Vec::new(); + for (list, values) in &nodes { + let mut node = match raw.objects.get(list) { + Some(existing) => { + if existing.jcid != 0x60012 { + return Err(invalid( + "A list definition identity belongs to another object", + )); } - None => PropertyObject { - jcid: 0x60012, - bytes: crate::create::properties(&[])?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, list.guid)])), - }, - }; - node.set( - &values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect::>(), - )?; - node.set(&[(0x14001d7a, &modified)])?; - node.reference(*list)?; - references.extend_from_slice(&target.reference(*list)?); - changed.insert(*list, node); - } - if references.is_empty() { - target.remove(&[0x24001c26])?; - } else { - target.set(&[(0x24001c26, &references)])?; - } - target.set(&[(0x14001d7a, &modified)])?; - changed.insert(object, target); - crate::formatting::touch_ancestors( - raw, - &parents, - object, - &modified, - &mut changed, + let mut node = PropertyObject::from_object(existing)?; + node.remove(&[ + 0x1c001c1a, 0x1c001c52, 0x14001cb7, 0x10001d0e, 0x0c001cc0, + 0x1c001c0a, 0x10001c0b, 0x14001c0c, 0x14001c3b, 0x08001c04, + 0x08001c05, + ])?; + node + } + None => PropertyObject { + jcid: 0x60012, + bytes: crate::create::properties(&[])?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, list.guid)])), + }, + }; + node.set( + &values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect::>(), )?; - Ok(changed) - }) + node.set(&[(0x14001d7a, &modified)])?; + node.reference(*list)?; + references.extend_from_slice(&target.reference(*list)?); + changed.insert(*list, node); + } + if references.is_empty() { + target.remove(&[0x24001c26])?; + } else { + target.set(&[(0x24001c26, &references)])?; + } + target.set(&[(0x14001d7a, &modified)])?; + changed.insert(object, target); + crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; + Ok(changed) })?; } Ok(()) @@ -2330,51 +2291,42 @@ impl Lowering<'_> { } sets.push((index, fields)); } - let space = self.space; - self.apply(|image| { - let store = Store::parse(image)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let parents = crate::edit::editable_parents( - document.active(space)?, - &document.pages_in(space)?, - object, - )?; + self.write(|active| { + let parents = active.editable_parents(object)?; let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - crate::write::write_revision(image, space, |raw| { - let mut changed = BTreeMap::new(); - for (id, values) in &definitions { - let Some(values) = values else { - continue; - }; - let mut node = PropertyObject { - jcid: 0x120043, - bytes: crate::create::properties(values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), - }; - node.reference(*id)?; - changed.insert(*id, node); - } - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - let mut encoded = Vec::new(); - for (index, fields) in &sets { - let reference = target.reference(definitions[*index].0)?; - let mut set = vec![(0x20003488, reference.to_vec())]; - set.extend(fields.iter().cloned()); - encoded.push(set); - } - target.set_sets(0x40003489, 0x44000811, &encoded)?; - target.set(&[(0x14001d7a, &modified)])?; - changed.insert(object, target); - crate::formatting::touch_ancestors( - raw, - &parents, - object, - &modified, - &mut changed, - )?; - Ok(changed) - }) + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + for (id, values) in &definitions { + let Some(values) = values else { + continue; + }; + let mut node = PropertyObject { + jcid: 0x120043, + bytes: crate::create::properties(values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), + }; + node.reference(*id)?; + changed.insert(*id, node); + } + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let mut encoded = Vec::new(); + for (index, fields) in &sets { + let reference = target.reference(definitions[*index].0)?; + let mut set = vec![(0x20003488, reference.to_vec())]; + set.extend(fields.iter().cloned()); + encoded.push(set); + } + target.set_sets(0x40003489, 0x44000811, &encoded)?; + target.set(&[(0x14001d7a, &modified)])?; + changed.insert(object, target); + crate::formatting::touch_ancestors( + raw, + parents, + object, + &modified, + &mut changed, + )?; + Ok(changed) })?; } } @@ -2444,25 +2396,24 @@ impl Lowering<'_> { } Some(values) }; - let (space, object) = (self.space, self.id(text.id)); - self.apply(|image| { - crate::write::write_revision(image, space, |raw| { - let mut changed = BTreeMap::new(); - if let Some(values) = &values { - let mut node = PropertyObject { - jcid: 0x12004d, - bytes: crate::create::properties(values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, style_id.guid)])), - }; - node.reference(style_id)?; - changed.insert(style_id, node); - } - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - let reference = target.reference(style_id)?; - target.set(&[(0x2000342c, &reference)])?; - changed.insert(object, target); - Ok(changed) - }) + let object = self.id(text.id); + self.write(|active| { + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + if let Some(values) = &values { + let mut node = PropertyObject { + jcid: 0x12004d, + bytes: crate::create::properties(values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, style_id.guid)])), + }; + node.reference(style_id)?; + changed.insert(style_id, node); + } + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let reference = target.reference(style_id)?; + target.set(&[(0x2000342c, &reference)])?; + changed.insert(object, target); + Ok(changed) })?; } Ok(()) @@ -2531,55 +2482,41 @@ impl Lowering<'_> { "Generated title fields cannot be formatted as ordinary text", )); } - let (space, object) = (self.space, stored.id); - self.apply(|image| { - let store = Store::parse(image)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let parents = crate::edit::editable_parents( - document.active(space)?, - &document.pages_in(space)?, - object, - )?; + let object = stored.id; + self.write(|active| { + let parents = active.editable_parents(object)?; let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - crate::write::write_revision(image, space, |raw| { - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - target.set( - &values + let raw = &active.live.revision; + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + target.set( + &values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect::>(), + )?; + if let Some((_, alignment)) = values.iter().find(|(id, _)| *id == 0x0c003477) { + for property in [0x14001c3e, 0x14001c84] { + let fields = PropertySets::parse(&target.bytes)?; + let previous = fields.sets[0] .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect::>(), - )?; - if let Some((_, alignment)) = values.iter().find(|(id, _)| *id == 0x0c003477) { - for property in [0x14001c3e, 0x14001c84] { - let fields = PropertySets::parse(&target.bytes)?; - let previous = fields.sets[0] - .iter() - .find(|field| field.id == property) - .map(|field| match field.value { - Value::Bytes(bytes) => bytes - .try_into() - .map(u32::from_le_bytes) - .map_err(|_| invalid("Invalid paragraph layout alignment")), - _ => Err(invalid("Invalid paragraph layout alignment")), - }) - .transpose()? - .unwrap_or(0); - let value = (previous & !7) | (u32::from(alignment[0]) + 1); - target.set(&[(property, &value.to_le_bytes())])?; - } + .find(|field| field.id == property) + .map(|field| match field.value { + Value::Bytes(bytes) => bytes + .try_into() + .map(u32::from_le_bytes) + .map_err(|_| invalid("Invalid paragraph layout alignment")), + _ => Err(invalid("Invalid paragraph layout alignment")), + }) + .transpose()? + .unwrap_or(0); + let value = (previous & !7) | (u32::from(alignment[0]) + 1); + target.set(&[(property, &value.to_le_bytes())])?; } - target.set(&[(0x14001d7a, &modified)])?; - let mut changed = BTreeMap::from([(object, target)]); - crate::formatting::touch_ancestors( - raw, - &parents, - object, - &modified, - &mut changed, - )?; - Ok(changed) - }) + } + target.set(&[(0x14001d7a, &modified)])?; + let mut changed = BTreeMap::from([(object, target)]); + crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; + Ok(changed) })?; } Ok(()) @@ -2646,9 +2583,9 @@ impl Lowering<'_> { if attributes.is_empty() { continue; } - let (space, object) = (self.space, stored.id); - self.apply(|image| { - crate::formatting::format_text(image, space, object, range, &attributes) + let object = stored.id; + self.write(|active| { + crate::formatting::format_changes(active, object, range, &attributes) })?; } } @@ -2664,9 +2601,9 @@ impl Lowering<'_> { .get(&self.id(*id)) .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; if stored.collapsed != paragraph.collapsed { - let (space, object) = (self.space, self.id(*id)); + let object = self.id(*id); let edit = OutlineEdit::Collapsed(paragraph.collapsed); - self.apply(|image| edit.apply(image, space, object))?; + self.write(|active| edit.changes(active, object))?; } } for (id, outline) in &new.outlines { @@ -2677,7 +2614,7 @@ impl Lowering<'_> { .outlines .get(&self.id(*id)) .ok_or_else(|| invalid("An outline is missing after text edits"))?; - let (space, object) = (self.space, self.id(*id)); + let object = self.id(*id); // A new outline takes the model's indentation table (a copied outline keeps // its levels' offsets); a stored table stays as it is. if !old.outlines.contains_key(id) @@ -2685,12 +2622,11 @@ impl Lowering<'_> { && outline.indents != stored.indents { let indents = measurement_bytes(&outline.indents, 4)?; - self.apply(|current| { - crate::write::write_revision(current, space, |raw| { - let mut node = PropertyObject::from_object(&raw.objects[&object])?; - node.set(&[(0x1c001c12, &indents)])?; - Ok(BTreeMap::from([(object, node)])) - }) + self.write(|active| { + let raw = &active.live.revision; + let mut node = PropertyObject::from_object(&raw.objects[&object])?; + node.set(&[(0x1c001c12, &indents)])?; + Ok(BTreeMap::from([(object, node)])) })?; } if (outline.layout.x, outline.layout.y) != (stored.layout.x, stored.layout.y) { @@ -2698,7 +2634,7 @@ impl Lowering<'_> { return Err(invalid("An outline position needs both coordinates")); }; let edit = OutlineEdit::Position { x, y }; - self.apply(|image| edit.apply(image, space, object))?; + self.write(|active| edit.changes(active, object))?; } if (outline.layout.max_width, outline.layout.width_set_by_user) != (stored.layout.max_width, stored.layout.width_set_by_user) @@ -2711,7 +2647,7 @@ impl Lowering<'_> { points, user_set: outline.layout.width_set_by_user == Some(true), }; - self.apply(|image| edit.apply(image, space, object))?; + self.write(|active| edit.changes(active, object))?; } } Ok(()) @@ -2755,6 +2691,9 @@ fn kept_set( "Images and unsupported objects cannot be reordered", )); } + if sequence.windows(2).all(|pair| pair[0].1 < pair[1].1) { + return Ok(sequence.into_iter().map(|(id, _)| id).collect()); + } // Longest increasing subsequence over `after` positions, forced through immovable items. let n = sequence.len(); let mut best = vec![1usize; n]; @@ -2926,12 +2865,24 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result) -> Vec<[u8; 16]> { + store + .lists + .values() + .flat_map(|list| &list.nodes) + .filter(|node| node.id == 0x94) + .filter_map(|node| node.payload.get(..16).and_then(|g| g.try_into().ok())) + .collect() +} + +/// Writes the spaces the typed writers `edited` as one transaction on the validated +/// `source`, renaming writer-allocated identities to the model's and embedding the +/// `payloads` it lacks. A protected `source` takes the revisions its plaintext twin gained. pub(crate) fn squash( - source: &[u8], - applied: &[u8], + source: &RevisionIndex<'_>, + edited: &[(ExGuid, &crate::ResolvedRevision<'_>)], + payloads: &[([u8; 16], &[u8])], alias: &BTreeMap, protection: Option<&dyn crate::write::Protection>, ) -> Result, Error> { @@ -2939,44 +2890,19 @@ pub(crate) fn squash( .iter() .map(|(model, image)| (*image, *model)) .collect(); - let declared = |store: &Store<'_>| -> Vec<[u8; 16]> { - store - .lists - .values() - .flat_map(|list| &list.nodes) - .filter(|node| node.id == 0x94) - .filter_map(|node| node.payload.get(..16).and_then(|g| g.try_into().ok())) - .collect() - }; - let existing = declared(&Store::parse(source)?); - let applied_store = Store::parse(applied)?; - let applied_index = RevisionIndex::parse(&applied_store)?; - // Payloads the typed edits embedded travel into the squashed transaction as well. - let mut payloads = Vec::new(); - for guid in declared(&applied_store) { - if !existing.contains(&guid) { - payloads.push((guid, applied_store.file_data(guid)?)); - } - } + let existing = declared_payloads(source.store); + let payloads: Vec<_> = payloads + .iter() + .filter(|(guid, _)| !existing.contains(guid)) + .copied() + .collect(); let edit = |index: &RevisionIndex<'_>| { let mut changes = BTreeMap::new(); - for sid in applied_index.spaces.keys() { - let Some(space) = index.spaces.get(sid) else { - // The twin's scaffold spaces are not the section's. - if protection.is_some() { - continue; - } - return Err(invalid("Page edits cannot create object spaces")); - }; - let after_rid = applied_index.active(*sid)?; - if space.labels.get(&(ExGuid::default(), 1)) == Some(&after_rid) { - continue; - } + for (sid, after) in edited { let before = match protection { Some(protection) => protection.resolve(*sid, index.active(*sid)?)?, None => index.resolve_active(*sid)?, }; - let after = applied_index.resolve(*sid, after_rid)?; if before.roots != after.roots { return Err(invalid("Page edits cannot change revision roots")); } @@ -2994,7 +2920,7 @@ pub(crate) fn squash( { let entries = match object.data { ObjectData::Properties(bytes) => crate::write::table_entries(bytes)?, - _ => BTreeSet::new(), + _ => Vec::new(), }; if entries .iter() @@ -3044,12 +2970,8 @@ pub(crate) fn squash( } Ok(changes) }; - let validate = protection.is_none(); - let output = crate::write::build(source, &payloads, protection, validate, edit)?; - // The parsed images are released before the result is parsed. - drop(applied_index); - drop(applied_store); - crate::write::check(&output, validate)?; + let output = crate::write::build_on(source, &payloads, protection, edit)?; + crate::write::check(&output, protection.is_none())?; Ok(output) } @@ -3076,3 +2998,81 @@ fn remap(object: &mut PropertyObject, rename: &BTreeMap) -> Resu } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::page::{PageParagraph, Paragraph, TextObject, text::new_id}; + + /// A new page in a new section and the page with `count` paragraphs added in an outline. + fn generated(count: usize) -> (Vec, ExGuid, Page) { + let section = crate::create_section("big.one", "First", "Author").unwrap(); + let creation = crate::PageCreation::new(None, Some("Big"), "Author").unwrap(); + let source = crate::PreparedEdit::create_page(§ion, &creation) + .unwrap() + .as_bytes() + .to_vec(); + let space = creation.space(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let mut page = + Page::from_space(&crate::document::Document::parse(&index).unwrap(), space).unwrap(); + let paragraph = |i: usize| PageParagraph { + id: new_id().unwrap(), + parent: None, + level: 1, + style: None, + format: Format::default(), + content: ParagraphContent::Text(TextObject { + id: new_id().unwrap(), + date_field: None, + text: Paragraph::new(format!("Paragraph {i}"), Format::default()), + tags: Vec::new(), + }), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + }; + page.objects.push(PageObject::Outline(Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: crate::document::Layout { + x: Some(36.0), + y: Some(86.4), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: (0..count).map(paragraph).collect(), + unsupported: Vec::new(), + })); + (source, space, page) + } + + #[test] + fn a_page_write_builds_one_revision_however_many_paragraphs_it_adds() { + for count in [10, 40] { + let (source, space, after) = generated(count); + let builds = crate::write::BUILDS.with(std::cell::Cell::get); + let written = write_page(&source, space, &after, "Author").unwrap(); + assert_eq!(crate::write::BUILDS.with(std::cell::Cell::get) - builds, 1); + let store = Store::parse(&written).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let read = Page::from_space(&crate::document::Document::parse(&index).unwrap(), space) + .unwrap(); + let texts = |page: &Page| -> Vec { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .flat_map(|outline| &outline.paragraphs) + .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned())) + .collect() + }; + assert_eq!(texts(&read), texts(&after)); + } + } +} diff --git a/crates/onestore/src/paragraph.rs b/crates/onestore/src/paragraph.rs index 66105442433e16ef48dd085dfc4745a72d0de671..630727c118a3802a6f59a75c43e7ebb94f4babd1 100644 --- a/crates/onestore/src/paragraph.rs +++ b/crates/onestore/src/paragraph.rs @@ -1,9 +1,10 @@ use crate::{ - Error, ExGuid, Object, ObjectData, PropertySets, RevisionIndex, Store, + Error, ExGuid, Object, ObjectData, PropertySets, + active::{ActivePage, Changes}, create::{current_timestamps, properties, string}, - document::{Document, Element, Kind}, - edit::{editable_parents, update_title}, - write::{PropertyObject, fresh_guid, write_revision_on}, + document::{Element, Kind}, + edit::update_title, + write::{PropertyObject, fresh_guid}, }; use serde::{Deserialize, Serialize}; use std::{ @@ -80,20 +81,15 @@ impl ParagraphSplit { "Choose paragraph text and an author name without NUL", )); } - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - let mut document = Document::parse(&index)?; - let pages = document.pages_in(space)?; - let [_] = pages.as_slice() else { + crate::active::write(source, space, |active| self.changes(active)) + } + + pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { + let [_] = active.pages.as_slice() else { return Err(invalid("Splitting requires a single active page")); }; - let view = document - .spaces - .remove(&space) - .and_then(crate::document::Space::into_active) - .ok_or_else(|| invalid("The active page is unavailable"))?; - let parents = editable_parents(&view, &pages, self.text)?; + let view = &active.view; + let parents = active.editable_parents(self.text)?; let [paragraph] = parents .get(&self.text) .map(Vec::as_slice) @@ -138,8 +134,8 @@ impl ParagraphSplit { } pending.extend(parents.get(&id).into_iter().flatten().copied()); } - let raw = index.resolve_active(space)?; - let (text, runs) = ordinary_text(&view, &raw, self.text)?; + let raw = &active.live.revision; + let (text, runs) = ordinary_text(view, raw, self.text)?; let node = &view.nodes[&self.text]; let length = u32::try_from(text.encode_utf16().count()) .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?; @@ -251,8 +247,8 @@ impl ParagraphSplit { }; object.set(&[(0x14001d7a, &modified)])?; } - update_title(&store, &raw, view, &pages, &mut changed)?; - write_revision_on(&index, space, |_| Ok(changed)) + update_title(active, view.clone(), &mut changed)?; + Ok(changed) } } @@ -406,21 +402,17 @@ impl ParagraphJoin { "Choose two distinct text objects and an author name without NUL", )); } - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - let mut document = Document::parse(&index)?; - let pages = document.pages_in(space)?; + crate::active::write(source, space, |active| self.changes(active)) + } + + pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { + let pages = &active.pages; if pages.len() != 1 { return Err(invalid("Joining requires a single active page")); } - let view = document - .spaces - .remove(&space) - .and_then(crate::document::Space::into_active) - .ok_or_else(|| invalid("The active page is unavailable"))?; - let parents = editable_parents(&view, &pages, self.left)?; - editable_parents(&view, &pages, self.right)?; + let view = &active.view; + let parents = active.editable_parents(self.left)?; + active.editable_parents(self.right)?; let parent = |id| -> Result { let [parent] = parents.get(&id).map(Vec::as_slice).unwrap_or_default() else { return Err(invalid("Select text with a unique editable parent")); @@ -482,9 +474,9 @@ impl ParagraphJoin { "Joining these child indentation levels requires a hierarchy edit", )); } - let raw = index.resolve_active(space)?; - let (a, a_runs) = ordinary_text(&view, &raw, self.left)?; - let (b, b_runs) = ordinary_text(&view, &raw, self.right)?; + let raw = &active.live.revision; + let (a, a_runs) = ordinary_text(view, raw, self.left)?; + let (b, b_runs) = ordinary_text(view, raw, self.right)?; let length = u32::try_from(a.encode_utf16().count()) .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?; let right_length = u32::try_from(b.encode_utf16().count()) @@ -531,8 +523,8 @@ impl ParagraphJoin { references.extend_from_slice(&target.reference(id)?); ends.extend_from_slice(&run.end.to_le_bytes()); } - let left_base = character_properties(&view, &raw, self.left)?; - let right_base = character_properties(&view, &raw, self.right)?; + let left_base = character_properties(view, raw, self.left)?; + let right_base = character_properties(view, raw, self.right)?; for (i, run) in b_runs.iter().enumerate() { if run.start == run.end && i != b_runs.len() - 1 { continue; @@ -681,8 +673,8 @@ impl ParagraphJoin { object.set(&[(0x14001d7a, &modified)])?; pending.extend(parents.get(&id).into_iter().flatten().copied()); } - update_title(&store, &raw, view, &pages, &mut changed)?; - write_revision_on(&index, space, |_| Ok(changed)) + update_title(active, view.clone(), &mut changed)?; + Ok(changed) } } diff --git a/crates/onestore/src/protected/mod.rs b/crates/onestore/src/protected/mod.rs index 1c7a44ee47be7884bfa4db3477846f411d10b153..24dee8209d7be545a26fb7990c69d923d4c33503 100644 --- a/crates/onestore/src/protected/mod.rs +++ b/crates/onestore/src/protected/mod.rs @@ -407,15 +407,36 @@ pub(crate) fn write_page( } let twin = unlocked.twin()?; let applied = Zeroizing::new(crate::page::write::write_page(&twin, space, page, author)?); - // Squash skips the spaces the source lacks as the twin's scaffold; the writers added none. - let spaces = |image: &[u8]| -> Result> { - let store = crate::Store::parse(image)?; - Ok(RevisionIndex::parse(&store)?.spaces.into_keys().collect()) - }; - if spaces(&twin)? != spaces(&applied)? { + let applied_store = crate::Store::parse(&applied)?; + let applied_index = RevisionIndex::parse(&applied_store)?; + let twin_store = crate::Store::parse(&twin)?; + if RevisionIndex::parse(&twin_store)? + .spaces + .keys() + .ne(applied_index.spaces.keys()) + { return Err(invalid("Page edits cannot create object spaces")); } - let written = crate::page::write::squash(source, &applied, &BTreeMap::new(), Some(&unlocked))?; + // The twin's scaffold spaces are not the section's. + let mut edited = Vec::new(); + for (sid, space) in &index.spaces { + let rid = applied_index.active(*sid)?; + if space.labels.get(&(ExGuid::default(), 1)) != Some(&rid) { + edited.push((*sid, applied_index.resolve(*sid, rid)?)); + } + } + let edited: Vec<_> = edited.iter().map(|(sid, after)| (*sid, after)).collect(); + let payloads = crate::page::write::declared_payloads(&applied_store) + .into_iter() + .map(|guid| Ok((guid, applied_store.file_data(guid)?))) + .collect::, crate::Error>>()?; + let written = crate::page::write::squash( + &index, + &edited, + &payloads, + &BTreeMap::new(), + Some(&unlocked), + )?; let store = crate::Store::parse(&written)?; UnlockedSection::open(&RevisionIndex::parse(&store)?, password, Limits::default())?; Ok(written) diff --git a/crates/onestore/src/tree.rs b/crates/onestore/src/tree.rs index b717409fb3f5fa5a7fb01a9d8b47167b451aa69b..577dc9d74f9959421e2ff32b6ed61e1c320b87ad 100644 --- a/crates/onestore/src/tree.rs +++ b/crates/onestore/src/tree.rs @@ -1,9 +1,10 @@ use crate::{ - Error, ExGuid, RevisionIndex, Store, + Error, ExGuid, + active::{ActivePage, Changes}, create::{current_timestamps, properties, string}, - document::{Document, Kind, Revision}, + document::{Kind, Revision}, edit::{editable_parents, update_title}, - write::{PropertyObject, fresh_guid, write_revision_on}, + write::{PropertyObject, fresh_guid}, }; use serde::{Deserialize, Serialize}; use std::{ @@ -96,21 +97,17 @@ impl TreeEdit { pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> { self.validate()?; - let store = Store::parse(source)?; - let index = RevisionIndex::parse(&store)?; - index.validate_current()?; - let mut document = Document::parse(&index)?; - let pages = document.pages_in(space)?; + crate::active::write(source, space, |active| self.changes(active)) + } + + pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { + let pages = &active.pages; let [page] = pages.as_slice() else { return Err(invalid("Tree editing requires a single active page")); }; - let mut view = document - .spaces - .remove(&space) - .and_then(crate::document::Space::into_active) - .unwrap(); - let parents = editable_parents(&view, &pages, self.object)?; - let path = checked_path(&view, &parents, *page, self.object)?; + let mut view = active.view.clone(); + let parents = active.editable_parents(self.object)?; + let path = checked_path(&view, parents, *page, self.object)?; let source_parent = *path .get(1) .ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?; @@ -132,7 +129,7 @@ impl TreeEdit { let mut changed_ids = BTreeSet::from([source_parent]); let mut affected = BTreeSet::from([self.object]); if let Placement::Move { parent, before } = self.placement { - if checked_path(&view, &parents, *page, parent)?.contains(&self.object) { + if checked_path(&view, parents, *page, parent)?.contains(&self.object) { return Err(invalid("A subtree cannot move inside itself")); } let destination = &view.nodes[&parent]; @@ -143,7 +140,7 @@ impl TreeEdit { } let previous = destination.children.clone(); if parent == source_parent && before == Some(self.object) { - return Ok(source.to_vec()); + return Ok(Changes::new()); } view.nodes .get_mut(&source_parent) @@ -156,7 +153,7 @@ impl TreeEdit { .unwrap_or(children.len()); children.insert(position, self.object); if parent == source_parent && *children == previous { - return Ok(source.to_vec()); + return Ok(Changes::new()); } changed_ids.insert(parent); } else { @@ -187,7 +184,7 @@ impl TreeEdit { if node.extra[0].iter().any(|field| field.id == 0x08001d0c) { return Err(invalid("The emptied container cannot be deleted")); } - let path = checked_path(&view, &parents, *page, at)?; + let path = checked_path(&view, parents, *page, at)?; let parent = path[1]; view.nodes .get_mut(&parent) @@ -256,7 +253,7 @@ impl TreeEdit { if !checked.insert(id) { continue; } - checked_path(&view, &parents, *page, id)?; + checked_path(&view, parents, *page, id)?; let node = &view.nodes[&id]; if matches!(self.placement, Placement::Delete) && node.extra[0].iter().any(|field| field.id == 0x08001d0c) @@ -273,8 +270,8 @@ impl TreeEdit { .copied(), ); } - let raw = index.resolve_active(space)?; - let active_parents = editable_parents(&view, &pages, *page)?; + let raw = &active.live.revision; + let active_parents = editable_parents(&view, pages, *page)?; let modified = current_timestamps()?.0.to_le_bytes(); let mut changed = BTreeMap::new(); let author_id = ExGuid { @@ -370,8 +367,8 @@ impl TreeEdit { object.set(&[(0x20001d79, &author), (0x14001d7a, &modified)])?; changed.insert(self.object, object); } - update_title(&store, &raw, view, &pages, &mut changed)?; - write_revision_on(&index, space, |_| Ok(changed)) + update_title(active, view, &mut changed)?; + Ok(changed) } } @@ -417,7 +414,7 @@ fn checked_path( mod tests { use super::*; use crate::write::write_revision; - use crate::{Insertion, PreparedEdit}; + use crate::{Insertion, PreparedEdit, RevisionIndex, Store, document::Document}; #[test] fn a_retained_move_intent_can_reuse_its_immutable_author_after_another_move() { diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index e31daef641e874ed30ad2f87bbeee6f108d73fb1..41a55ba0d2289a49bb9e6578771b877c22c28bc8 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -105,8 +105,8 @@ fn compact(id: ExGuid, table: &BTreeMap) -> Result<[u8; 4]> { } /// The global id table entries a property object's references name. -pub(crate) fn table_entries(bytes: &[u8]) -> Result> { - let mut entries = BTreeSet::new(); +pub(crate) fn table_entries(bytes: &[u8]) -> Result> { + let mut entries = Vec::new(); for property in PropertySets::parse(bytes)?.sets.iter().flatten() { if let Value::References { compact_ids, .. } = property.value { entries.extend( @@ -340,36 +340,42 @@ pub(crate) fn replace_objects( space: ExGuid, edits: &[ObjectEdit<'_>], ) -> Result> { - write_revision_on(index, space, |revision| { - let mut changed = BTreeMap::new(); - for edit in edits { - if changed.contains_key(&edit.object) { - return Err(Error { - offset: 0, - message: "Duplicate object edit", - }); - } - let object = revision.objects.get(&edit.object).ok_or(Error { + write_revision_on(index, space, |revision| patched(revision, edits)) +} + +/// Objects of `revision` with `edits` applied. +pub(crate) fn patched( + revision: &crate::ResolvedRevision<'_>, + edits: &[ObjectEdit<'_>], +) -> Result> { + let mut changed = BTreeMap::new(); + for edit in edits { + if changed.contains_key(&edit.object) { + return Err(Error { offset: 0, - message: "Object is absent from the active revision", - })?; - let ObjectData::Properties(blob) = object.data else { - return Err(Error { - offset: 0, - message: "Object does not contain editable properties", - }); - }; - changed.insert( - edit.object, - PropertyObject { - jcid: object.jcid, - bytes: patch_properties(blob, edit.updates, edit.inserts, &[])?, - global_ids: Arc::clone(&object.global_ids), - }, - ); + message: "Duplicate object edit", + }); } - Ok(changed) - }) + let object = revision.objects.get(&edit.object).ok_or(Error { + offset: 0, + message: "Object is absent from the active revision", + })?; + let ObjectData::Properties(blob) = object.data else { + return Err(Error { + offset: 0, + message: "Object does not contain editable properties", + }); + }; + changed.insert( + edit.object, + PropertyObject { + jcid: object.jcid, + bytes: patch_properties(blob, edit.updates, edit.inserts, &[])?, + global_ids: Arc::clone(&object.global_ids), + }, + ); + } + Ok(changed) } /// The object type OneNote gives embedded picture payload declarations; embedded files @@ -377,10 +383,57 @@ pub(crate) fn replace_objects( pub(crate) const FILE_DATA_JCID: u32 = 0x80039; pub(crate) const EMBEDDED_FILE_JCID: u32 = 0x80036; +/// The reference and extension of a file-data declaration built by `PropertyObject::file`. +fn file_declaration(bytes: &[u8]) -> Result<(&[u8], &[u8])> { + let malformed = || Error { + offset: 0, + message: "Malformed file-data declaration", + }; + let mut rest = bytes; + let mut parts = Vec::new(); + for _ in 0..2 { + let length = usize::try_from(u32::from_le_bytes( + rest.get(..4).ok_or_else(malformed)?.try_into().unwrap(), + )) + .map_err(|_| malformed())?; + let bytes = rest.get(4..4 + length).ok_or_else(malformed)?; + parts.push(bytes); + rest = &rest[4 + length..]; + } + if !rest.is_empty() { + return Err(malformed()); + } + Ok((parts[0], parts[1])) +} + +/// An object of type `jcid` stored as `bytes`, as a revision declares it. +pub(crate) fn declared( + jcid: u32, + bytes: &[u8], + global_ids: Arc>, +) -> Result> { + let data = if is_file_declaration(jcid) { + let (reference, extension) = file_declaration(bytes)?; + ObjectData::File { + reference, + extension, + } + } else { + ObjectData::Properties(bytes) + }; + Ok(crate::Object { + jcid, + reference_count: 0, + data, + global_ids, + }) +} + fn is_file_declaration(jcid: u32) -> bool { jcid == FILE_DATA_JCID || jcid == EMBEDDED_FILE_JCID } +#[derive(Clone)] pub(crate) struct PropertyObject { pub jcid: u32, pub bytes: Vec, @@ -426,29 +479,6 @@ impl PropertyObject { }) } - /// The reference and extension of a file-data declaration built by `file`. - fn file_declaration(&self) -> Result<(&[u8], &[u8])> { - let malformed = || Error { - offset: 0, - message: "Malformed file-data declaration", - }; - let mut rest = self.bytes.as_slice(); - let mut parts = Vec::new(); - for _ in 0..2 { - let length = usize::try_from(u32::from_le_bytes( - rest.get(..4).ok_or_else(malformed)?.try_into().unwrap(), - )) - .map_err(|_| malformed())?; - let bytes = rest.get(4..4 + length).ok_or_else(malformed)?; - parts.push(bytes); - rest = &rest[4 + length..]; - } - if !rest.is_empty() { - return Err(malformed()); - } - Ok((parts[0], parts[1])) - } - pub fn from_object(object: &crate::Object<'_>) -> Result { let ObjectData::Properties(bytes) = object.data else { return Err(Error { @@ -735,6 +765,440 @@ pub(crate) trait Protection { fn seal_file(&self, clear: &[u8]) -> Vec; } +/// The global-ID entries a group of objects declared together uses: each object's own +/// and those its properties reference. A section's group stores only these. +fn used_entries<'o>( + table: &BTreeMap, + objects: impl IntoIterator)>, +) -> Result> { + let mut used = Vec::new(); + for (id, object) in objects { + used.push(u32::from_le_bytes(compact(id, table)?) >> 8); + if let ObjectData::Properties(bytes) = object.data { + used.extend(table_entries(bytes)?); + } + } + used.sort_unstable(); + used.dedup(); + Ok(used) +} + +/// A space's revision as appending a revision and reading it back leaves it. +pub(crate) struct LiveRevision<'a> { + pub revision: crate::ResolvedRevision<'a>, + /// Incoming references of each object reachable from the roots; a root counts once. + incoming: BTreeMap, + /// Reachable read-only property objects by type and content, which identical new + /// objects alias. + readonly: BTreeMap>>, + /// Revisions in the dependency chain, counted to 512; zero before the first. + depth: usize, +} + +impl<'a> LiveRevision<'a> { + pub(crate) fn new(revision: crate::ResolvedRevision<'a>, depth: usize) -> Result { + let incoming = if depth == 0 { + BTreeMap::new() + } else { + revision.checked_counts()? + }; + let mut readonly = BTreeMap::new(); + for id in incoming.keys() { + index(&mut readonly, *id, &revision.objects[id], true); + } + Ok(Self { + revision, + incoming, + readonly, + depth, + }) + } + + pub(crate) fn is_reachable(&self, id: ExGuid) -> bool { + self.incoming.contains_key(&id) + } + + /// Validates replacements and drops what the revision would not store: new read-only + /// objects identical to reachable or earlier ones, whose references move to those, and + /// reachable objects that stay as they are. + pub(crate) fn prepare( + &self, + mut replacements: BTreeMap, + is_section: bool, + ) -> Result> { + let revision = &self.revision; + for (id, replacement) in &replacements { + if let Some(object) = revision.objects.get(id) { + if object.jcid & 0x100000 != 0 { + return Err(Error { + offset: 0, + message: "Read-only object requires a new identity", + }); + } + if replacement.jcid != object.jcid + || !matches!(object.data, ObjectData::Properties(_)) + { + return Err(Error { + offset: 0, + message: "An existing object's type cannot be changed", + }); + } + } else if !is_section && replacement.jcid != 0x20001 { + return Err(Error { + offset: 0, + message: "New objects in a table of contents are its entries", + }); + } + if replacement.global_ids.keys().any(|i| *i > 0xffffff) { + return Err(Error { + offset: 0, + message: "Invalid property object declaration", + }); + } + compact(*id, &replacement.global_ids)?; + if is_file_declaration(replacement.jcid) { + if !is_section { + return Err(Error { + offset: 0, + message: "File-data objects require a section file", + }); + } + file_declaration(&replacement.bytes)?; + } else if replacement.jcid & 0x20000 == 0 { + return Err(Error { + offset: 0, + message: "Invalid property object declaration", + }); + } else { + PropertySets::parse(&replacement.bytes)?; + } + } + // Native coalescing of duplicate readonly styles can leave dangling references. + let mut aliases = BTreeMap::new(); + for (id, replacement) in &replacements { + if revision.objects.contains_key(id) + || replacement.jcid & 0x100000 == 0 + || PropertySets::parse(&replacement.bytes)? + .sets + .iter() + .flatten() + .any(|p| matches!(p.value, Value::References { .. })) + { + continue; + } + let existing = self + .readonly + .get(&replacement.jcid) + .and_then(|contents| contents.get(replacement.bytes.as_slice())) + .and_then(|ids| ids.first().copied()); + let existing = existing.or_else(|| { + replacements.range(..id).find_map(|(other, object)| { + (object.jcid == replacement.jcid && object.bytes == replacement.bytes) + .then_some(*aliases.get(other).unwrap_or(other)) + }) + }); + if let Some(existing) = existing { + aliases.insert(*id, existing); + } + } + for id in aliases.keys() { + replacements.remove(id); + } + for object in replacements.values_mut() { + if is_file_declaration(object.jcid) { + continue; + } + // The compact IDs this object's table gives aliased identities. + let targets: Vec<(u32, ExGuid)> = aliases + .iter() + .filter(|(id, _)| id.n <= 0xff) + .filter_map(|(id, existing)| { + let (index, _) = object.global_ids.iter().find(|(_, g)| **g == id.guid)?; + Some(((index << 8) | id.n, *existing)) + }) + .collect(); + if targets.is_empty() { + continue; + } + let mut remapped = Vec::new(); + for property in PropertySets::parse(&object.bytes)?.sets.iter().flatten() { + if let Value::References { + stream: crate::IdStream::Objects, + compact_ids, + } = property.value + { + for bytes in compact_ids.chunks_exact(4) { + let raw = u32::from_le_bytes(bytes.try_into().unwrap()); + if let Some((_, existing)) = targets.iter().find(|(id, _)| *id == raw) { + let offset = bytes.as_ptr().addr() - object.bytes.as_ptr().addr(); + remapped.push((offset, *existing)); + } + } + } + } + for (offset, id) in remapped { + let reference = object.reference(id)?; + object.bytes[offset..offset + 4].copy_from_slice(&reference); + } + } + // Detached objects must pass the final reachability check even when unchanged. Equal + // bytes are the same object when the table entries they name agree: stored tables + // keep only those. + let mut unchanged = Vec::new(); + for (id, replacement) in &replacements { + if let Some(object) = revision.objects.get(id) + && self.is_reachable(*id) + && object.data == ObjectData::Properties(&replacement.bytes) + && (Arc::ptr_eq(&object.global_ids, &replacement.global_ids) + || table_entries(&replacement.bytes)?.iter().all(|entry| { + object.global_ids.get(entry) == replacement.global_ids.get(entry) + })) + { + unchanged.push(*id); + } + } + for id in unchanged { + replacements.remove(&id); + } + Ok(replacements) + } + + /// Stores prepared replacements as the next revision, which checkpoints when the chain + /// would exceed 512 revisions. + pub(crate) fn commit( + &mut self, + replacements: Vec<(ExGuid, crate::Object<'a>)>, + ) -> Result { + let mut changed = BTreeSet::new(); + // Whether each object whose count may move was reachable before. + let mut touched = BTreeMap::new(); + let (mut added, mut removed) = (Vec::new(), Vec::new()); + for (id, object) in replacements { + changed.insert(id); + let reachable = self.is_reachable(id); + touched.insert(id, reachable); + if reachable { + moved_references( + &self.revision.objects[&id], + &object, + &mut removed, + &mut added, + )?; + } + self.revision.objects.insert(id, object); + } + let roots: BTreeSet = self.revision.roots.values().copied().collect(); + if roots.len() != self.revision.roots.len() { + return Err(Error { + offset: 0, + message: "Object is the root of multiple roles", + }); + } + added.extend( + roots + .into_iter() + .filter(|id| !self.incoming.contains_key(id)), + ); + // Attaching before detaching keeps a moved subtree from being walked twice. + while let Some(id) = added.pop() { + let object = self.revision.objects.get(&id).ok_or(Error { + offset: 0, + message: "Reachable object has no declaration", + })?; + let count = self.incoming.entry(id).or_default(); + touched.entry(id).or_insert(*count > 0); + *count = count.checked_add(1).ok_or(Error { + offset: 0, + message: "Object reference count overflows", + })?; + if *count == 1 { + added.extend(object.references()?.objects); + index(&mut self.readonly, id, object, true); + } + } + while let Some(id) = removed.pop() { + touched.entry(id).or_insert(true); + let count = self.incoming.get_mut(&id).unwrap(); + *count -= 1; + if *count == 0 { + self.incoming.remove(&id); + let object = &self.revision.objects[&id]; + removed.extend(object.references()?.objects); + index(&mut self.readonly, id, object, false); + } + } + for (&id, &reachable) in &touched { + let count = self.incoming.get(&id).copied(); + if changed.contains(&id) && count.is_none() { + return Err(Error { + offset: 0, + message: "Edited object is not reachable in the resulting revision", + }); + } + let object = self.revision.objects.get_mut(&id).unwrap(); + if (reachable || count.is_some()) && object.reference_count != count.unwrap_or(0) { + object.reference_count = count.unwrap_or(0); + changed.insert(id); + } + } + // Native cold-open fails on long dependency chains; cap their depth at 512. + let checkpoint = self.depth == 0 || self.depth >= 512; + self.depth = if checkpoint { 1 } else { self.depth + 1 }; + Ok(Commit { + checkpoint, + changed, + touched: touched.into_keys().collect(), + }) + } + + /// Gives the objects a revision declares the id tables reading it back yields: objects + /// sharing a table are declared in one group, which keeps the entries they use. + pub(crate) fn settle(&mut self, declared: impl IntoIterator) -> Result<()> { + let mut groups = BTreeMap::<_, Vec<_>>::new(); + for id in declared { + let table = Arc::clone(&self.revision.objects[&id].global_ids); + groups.entry(table).or_default().push(id); + } + for (table, ids) in groups { + let used = used_entries( + &table, + ids.iter().map(|id| (*id, &self.revision.objects[id])), + )?; + if used.iter().eq(table.keys()) { + continue; + } + let kept: Arc> = Arc::new( + table + .iter() + .filter(|(entry, _)| used.binary_search(entry).is_ok()) + .map(|(entry, guid)| (*entry, *guid)) + .collect(), + ); + for id in ids { + self.revision.objects.get_mut(&id).unwrap().global_ids = Arc::clone(&kept); + } + } + Ok(()) + } +} + +/// Adds a reachable read-only property object to `readonly`, or removes an unreachable one. +fn index<'a>( + readonly: &mut BTreeMap>>, + id: ExGuid, + object: &crate::Object<'a>, + reachable: bool, +) { + let ObjectData::Properties(bytes) = object.data else { + return; + }; + if object.jcid & 0x100000 == 0 { + return; + } + let ids = readonly + .entry(object.jcid) + .or_default() + .entry(bytes) + .or_default(); + if reachable { + ids.insert(id); + } else { + ids.remove(&id); + } +} + +/// Adds the object references `before` has and `after`, a later version of the object, +/// lacks to `removed`, and those it gains to `added`. +fn moved_references( + before: &crate::Object<'_>, + after: &crate::Object<'_>, + removed: &mut Vec, + added: &mut Vec, +) -> Result<()> { + let compact_ids = |object: &crate::Object<'_>| -> Result> { + let ObjectData::Properties(bytes) = object.data else { + return Ok(Vec::new()); + }; + let mut ids = Vec::new(); + for property in PropertySets::parse(bytes)?.sets.iter().flatten() { + if let Value::References { + stream: crate::IdStream::Objects, + compact_ids, + } = property.value + { + ids.extend( + compact_ids + .chunks_exact(4) + .map(|id| <[u8; 4]>::try_from(id).unwrap()), + ); + } + } + Ok(ids) + }; + let decode = |ids: &[[u8; 4]], table| -> Result> { + let bytes = ids.concat(); + let mut cursor = crate::bytes::Cursor { + bytes: &bytes, + offset: 0, + }; + std::iter::from_fn(|| (!cursor.bytes.is_empty()).then(|| cursor.compact(table))).collect() + }; + // Equal compact IDs name the same objects where the later table keeps the earlier entries. + let (old, new) = (&before.global_ids, &after.global_ids); + let extended = Arc::ptr_eq(old, new) || { + let mut later = new.iter(); + old.iter() + .all(|entry| later.find(|later| later.0 >= entry.0) == Some(entry)) + }; + if extended { + let (old_ids, new_ids) = (compact_ids(before)?, compact_ids(after)?); + let (gone, gained) = differing(&old_ids, &new_ids); + removed.extend(decode(gone, old)?); + added.extend(decode(gained, new)?); + } else { + let (old_refs, new_refs) = (before.references()?.objects, after.references()?.objects); + let (gone, gained) = differing(&old_refs, &new_refs); + removed.extend_from_slice(gone); + added.extend_from_slice(gained); + } + Ok(()) +} + +/// The parts of two sequences between their common prefix and suffix: removing the first +/// from `before` and adding the second yields `after` as a multiset. +pub(crate) fn differing<'s, T: PartialEq>(before: &'s [T], after: &'s [T]) -> (&'s [T], &'s [T]) { + let prefix = before.iter().zip(after).take_while(|(a, b)| a == b).count(); + let suffix = before[prefix..] + .iter() + .rev() + .zip(after[prefix..].iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + ( + &before[prefix..before.len() - suffix], + &after[prefix..after.len() - suffix], + ) +} + +/// A revision `LiveRevision::commit` stored. +pub(crate) struct Commit { + /// Whether the revision declares every object, depending on none. + pub checkpoint: bool, + /// The objects it declares otherwise: the replacements and those whose counts moved. + pub changed: BTreeSet, + /// Objects whose reachability or reference count may have moved. + pub touched: BTreeSet, +} + +/// Revisions in the dependency chain of `rid`, counted to 512. +pub(crate) fn chain_depth(index: &RevisionIndex<'_>, space: ExGuid, rid: ExGuid) -> usize { + std::iter::successors(Some(rid), |id| { + index.spaces[&space].revisions[id].dependency + }) + .take(512) + .count() +} + pub(crate) fn write_revision( source: &[u8], space: ExGuid, @@ -880,12 +1344,20 @@ pub(crate) fn write_revision_on( Ok(output) } -fn build_on( +#[cfg(test)] +thread_local! { + /// Revisions `build_on` has built on this thread. + pub(crate) static BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(crate) fn build_on( index: &RevisionIndex<'_>, payloads: &[([u8; 16], &[u8])], protection: Option<&dyn Protection>, edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, ) -> Result> { + #[cfg(test)] + BUILDS.with(|builds| builds.set(builds.get() + 1)); let store = index.store; let source = store.data; let is_section = store.header.file_type == FileType::Section; @@ -933,7 +1405,7 @@ fn build_on( for (space, change) in changes { let new_space = matches!(change, RevisionEdit::Create { .. }); let current = (ExGuid::default(), 1_u32); - let (rid, label, mut revision, mut replacements) = match change { + let (rid, label, revision, replacements) = match change { RevisionEdit::Update(objects) => { let rid = index.active(space)?; (Some(rid), current, resolve(space, rid)?, objects) @@ -986,179 +1458,29 @@ fn build_on( } }; let existing: BTreeSet = revision.objects.keys().copied().collect(); - let reachable = if rid.is_some() { - revision.reachable()? - } else { - BTreeSet::new() - }; - for (id, replacement) in &replacements { - if let Some(object) = revision.objects.get(id) { - if object.jcid & 0x100000 != 0 { - return Err(Error { - offset: 0, - message: "Read-only object requires a new identity", - }); - } - if replacement.jcid != object.jcid - || !matches!(object.data, ObjectData::Properties(_)) - { - return Err(Error { - offset: 0, - message: "An existing object's type cannot be changed", - }); - } - } else if !is_section && replacement.jcid != 0x20001 { - return Err(Error { - offset: 0, - message: "New objects in a table of contents are its entries", - }); - } - if replacement.global_ids.keys().any(|i| *i > 0xffffff) { - return Err(Error { - offset: 0, - message: "Invalid property object declaration", - }); - } - compact(*id, &replacement.global_ids)?; - if is_file_declaration(replacement.jcid) { - if !is_section { - return Err(Error { - offset: 0, - message: "File-data objects require a section file", - }); - } - replacement.file_declaration()?; - } else if replacement.jcid & 0x20000 == 0 { - return Err(Error { - offset: 0, - message: "Invalid property object declaration", - }); - } else { - PropertySets::parse(&replacement.bytes)?; - } - } - // Native coalescing of duplicate readonly styles can leave dangling references. - let mut aliases = BTreeMap::new(); - for (id, replacement) in &replacements { - if revision.objects.contains_key(id) - || replacement.jcid & 0x100000 == 0 - || PropertySets::parse(&replacement.bytes)? - .sets - .iter() - .flatten() - .any(|p| matches!(p.value, Value::References { .. })) - { - continue; - } - let existing = revision.objects.iter().find_map(|(other, object)| { - (reachable.contains(other) - && object.jcid == replacement.jcid - && object.data == ObjectData::Properties(&replacement.bytes)) - .then_some(*other) - }); - let existing = existing.or_else(|| { - replacements.range(..id).find_map(|(other, object)| { - (object.jcid == replacement.jcid && object.bytes == replacement.bytes) - .then_some(*aliases.get(other).unwrap_or(other)) - }) - }); - if let Some(existing) = existing { - aliases.insert(*id, existing); - } - } - for id in aliases.keys() { - replacements.remove(id); - } - for object in replacements.values_mut() { - if is_file_declaration(object.jcid) { - continue; - } - let mut remapped = Vec::new(); - for property in PropertySets::parse(&object.bytes)?.sets.iter().flatten() { - if let Value::References { - stream: crate::IdStream::Objects, - compact_ids, - } = property.value - { - for bytes in compact_ids.chunks_exact(4) { - let offset = bytes.as_ptr().addr() - object.bytes.as_ptr().addr(); - let id = - crate::bytes::Cursor { bytes, offset }.compact(&object.global_ids)?; - if let Some(existing) = aliases.get(&id) { - remapped.push((offset, *existing)); - } - } - } - } - for (offset, id) in remapped { - let reference = object.reference(id)?; - object.bytes[offset..offset + 4].copy_from_slice(&reference); - } - } - // Detached objects must pass the final reachability check even when unchanged. Equal - // bytes are the same object when the table entries they name agree: stored tables - // keep only those. - let mut unchanged = Vec::new(); - for (id, replacement) in &replacements { - if let Some(object) = revision.objects.get(id) - && reachable.contains(id) - && object.data == ObjectData::Properties(&replacement.bytes) - && table_entries(&replacement.bytes)? - .iter() - .all(|entry| object.global_ids.get(entry) == replacement.global_ids.get(entry)) - { - unchanged.push(*id); - } - } - for id in unchanged { - replacements.remove(&id); - } + let depth = rid.map_or(0, |rid| chain_depth(index, space, rid)); + let mut live = LiveRevision::new(revision, depth)?; + let replacements = live.prepare(replacements, is_section)?; if replacements.is_empty() { continue; } - let mut changed: BTreeSet<_> = replacements.keys().copied().collect(); - for (id, replacement) in &replacements { - let data = if is_file_declaration(replacement.jcid) { - let (reference, extension) = replacement.file_declaration()?; - ObjectData::File { - reference, - extension, - } - } else { - ObjectData::Properties(&replacement.bytes) - }; - revision.objects.insert( - *id, - crate::Object { - jcid: replacement.jcid, - reference_count: 0, - data, - global_ids: Arc::clone(&replacement.global_ids), - }, - ); - } - let incoming = revision.reference_counts()?; - if replacements.keys().any(|id| !incoming.contains_key(id)) { - return Err(Error { - offset: 0, - message: "Edited object is not reachable in the resulting revision", - }); - } - for (id, object) in &mut revision.objects { - if reachable.contains(id) || incoming.contains_key(id) { - let count = incoming.get(id).copied().unwrap_or(0); - if object.reference_count != count { - object.reference_count = count; - changed.insert(*id); - } - } - } - - // Native cold-open fails on long dependency chains; cap their depth at 512. - let checkpoint = rid.is_none() - || std::iter::successors(rid, |id| index.spaces[&space].revisions[id].dependency) - .nth(511) - .is_some(); + let Commit { + checkpoint, + changed, + .. + } = live.commit( + replacements + .iter() + .map(|(id, replacement)| { + let global_ids = Arc::clone(&replacement.global_ids); + Ok(( + *id, + declared(replacement.jcid, &replacement.bytes, global_ids)?, + )) + }) + .collect::>>()?, + )?; + let revision = &live.revision; let selected: Vec<_> = revision .objects .iter() @@ -1272,15 +1594,9 @@ fn build_on( }; // A table read from a long-lived page names every session that edited it; the // group stores the entries its objects use. - let mut used = BTreeSet::new(); - for (id, object) in &objects { - used.insert(u32::from_le_bytes(compact(*id, table)?) >> 8); - if let ObjectData::Properties(bytes) = object.data { - used.extend(table_entries(bytes)?); - } - } + let used = used_entries(table, objects.iter().copied())?; for (id, guid) in table { - if is_section && !used.contains(id) { + if is_section && used.binary_search(id).is_err() { continue; } let mut entry = id.to_le_bytes().to_vec(); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 4be17ac4d601113010092d7f9ed2923815462aa7..1e6c526eb701802bd1d8fd5b7f967b6e2317f76b 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1323,24 +1323,21 @@ impl State { return Ok(()); }; let after = self.view.editor.page()?; - match session - .section - .save(session.space, &session.before, &after, "snowbound")? + if after == session.before { + return Ok(()); + } + if let Some(entry) = session + .pages + .iter_mut() + .find(|(space, ..)| *space == session.space) { - notebook::session::Save::Unchanged => {} - notebook::session::Save::Queued(_) => { - session.before = session.section.page(session.space)?; - session.status = "Saving"; - if let Some(entry) = session - .pages - .iter_mut() - .find(|(space, ..)| *space == session.space) - { - entry.1 = after.title; - } - } - notebook::session::Save::Stale => self.reload()?, + entry.1.clone_from(&after.title); } + // Queued saves chain: each one's `after` is the next one's `before`. + let before = std::mem::replace(&mut session.before, after.clone()); + session + .section + .queue_save(session.space, before, after, "snowbound")?; Ok(()) } @@ -1397,6 +1394,18 @@ impl State { }; let shown = (session.status, session.conflict); let mut refreshed = false; + let mut stale = false; + for (space, outcome) in session.section.saved() { + match outcome { + Ok(notebook::session::Save::Queued(_)) => session.status = "Saving", + Ok(notebook::session::Save::Unchanged) => {} + Ok(notebook::session::Save::Stale) => stale |= space == session.space, + Err(error) => { + eprintln!("Saving failed: {error}"); + session.status = "Not saving"; + } + } + } for event in session.section.events() { use notebook::session::Event; session.status = match event { @@ -1436,6 +1445,8 @@ impl State { } else if session.section.page(session.space)? != session.before { self.reload()?; } + } else if stale { + self.reload()?; } Ok(()) } -- 2.54.0