diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 3121ab9040f5d503682870089146a383282b345d..df92a48a82a68d2863fb332269268584b1ab7719 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -1636,3 +1636,61 @@ fn text_edits_name_the_latest_author_even_within_the_same_second() { .unwrap(); } } + +#[test] +fn page_level_edits_attribute_only_changed_pages_and_skip_noops() { + let image = onestore::create_section("Levels.one", "Parent", "Alice").unwrap(); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image).unwrap(); + let creation = onestore::PageCreation::new(None, Some("Child"), "Alice").unwrap(); + let child = creation.space(); + section + .apply( + "Alice", + &Edit { + at: model_ops::now(), + ops: vec![Op::Section(onestore::op::SectionOp::Create(creation))], + }, + ) + .unwrap(); + section.seal().unwrap().unwrap(); + let before = section.image(); + let pages = notebook::session::stored_pages(&before).unwrap(); + let at = pages.iter().filter_map(|page| page.modified).max().unwrap() + 12345; + let store = onestore::Store::parse(&before).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + let revisions = index.spaces[&child].revisions.len(); + let edit = Edit { + at, + ops: vec![Op::Section(onestore::op::SectionOp::Pages(vec![ + onestore::PageEdit::set_level(child, 2).unwrap(), + ]))], + }; + section.apply("Bob", &edit).unwrap(); + section.seal().unwrap().unwrap(); + let image = section.image(); + let after = notebook::session::stored_pages(&image).unwrap(); + assert_eq!(after.len(), 2); + for (before, after) in pages.iter().zip(&after) { + if after.space == child { + assert_eq!(after.author.as_deref(), Some("Bob")); + assert_eq!(after.modified, Some(at)); + } else { + assert_eq!(after.author, before.author); + assert_eq!(after.modified, before.modified); + } + } + assert_eq!(section.pages().unwrap()[1].2, 2); + let store = onestore::Store::parse(&image).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + assert_eq!(index.spaces[&child].revisions.len(), revisions + 1); + section + .apply("Nobody", &Edit { at: at + 1, ..edit }) + .unwrap(); + assert!(section.seal().unwrap().is_none()); + assert_eq!(section.image(), image); + if let Some(folder) = std::env::var_os("SNOWBOUND_AUTHOR_EXPORT") { + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(std::path::Path::new(&folder).join("Levels.one"), image).unwrap(); + } +} diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs index c90082fe5f150499396452c0327cab30547f1292..97d42752802ec9157636be4e25294ce9aa1ca117 100644 --- a/crates/onestore/src/op/apply.rs +++ b/crates/onestore/src/op/apply.rs @@ -157,7 +157,9 @@ impl<'a> Section<'a> { } SectionOp::Pages(edits) => { for (space, changes) in crate::pages::section_changes(self, edits, &[])? { - self.apply_changes(space, &[], changes)?; + if self.apply_changes(space, &[], changes)? && space != self.root() { + self.attribute(space, author, Vec::new())?; + } } } SectionOp::Delete(pages) => { diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 8d01fbacc9cd5b1d8242634a6a139a577a4b51fe..32a72afc0295d66a7ed8e9c31c8035180c70e9b5 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1556,7 +1556,7 @@ impl State { return Ok(()); } Id::MarkRead => { - self.toggle_read(); + self.reads.toggle_read(); return Ok(()); } Id::NextUnread => { diff --git a/crates/snowbound/src/unread.rs b/crates/snowbound/src/unread.rs index 8d883f7b41b0e1b07dca74d0f1ba214c76203f4c..2ef77773069d0979c73a5baed1ce2df7d4ddc585 100644 --- a/crates/snowbound/src/unread.rs +++ b/crates/snowbound/src/unread.rs @@ -149,6 +149,28 @@ impl Reads { self.save(); } + /// Mark as Read, or as Unread, on the page shown, as OneNote 2010's Ctrl+Q does. + pub(crate) fn toggle_read(&mut self) { + let Some((notebook, section, space, _)) = self.viewing.clone() else { + return; + }; + if self + .pages(¬ebook, section) + .is_some_and(|pages| pages.contains(&space)) + { + self.mark_read(¬ebook, section, Some(space)); + } else { + let kept = self.section_mut(¬ebook, section); + kept.unread.insert(space); + let seen = kept.pages.entry(space).or_default(); + *seen = (*seen).max(now()); + if let Some((.., kept)) = &mut self.viewing { + *kept = true; + } + self.save(); + } + } + /// Takes in a section as its file stands, its pages with their FILETIME and /// whether the reader changed them last: those another author changed since it was last /// known are unread. @@ -346,29 +368,6 @@ impl State { }); } - /// Mark as Read, or as Unread, on the page shown, as OneNote 2010's Ctrl+Q does. - pub(crate) fn toggle_read(&mut self) { - let Some((notebook, section, space, _)) = self.reads.viewing.clone() else { - return; - }; - if self - .reads - .pages(¬ebook, section) - .is_some_and(|pages| pages.contains(&space)) - { - self.reads.mark_read(¬ebook, section, Some(space)); - } else { - self.reads - .section_mut(¬ebook, section) - .unread - .insert(space); - if let Some((.., kept)) = &mut self.reads.viewing { - *kept = true; - } - self.reads.save(); - } - } - /// Whether the page shown is unread. pub(crate) fn page_unread(&self) -> bool { self.reads @@ -607,6 +606,33 @@ mod tests { notebook::fs::remove_dir_all(&folder).unwrap(); } + #[test] + fn explicitly_unread_page_rejects_earlier_own_callbacks() { + let folder = + std::env::temp_dir().join(format!("snowbound-unread-explicit-{}", std::process::id())); + notebook::fs::create_dir_all(&folder).unwrap(); + let (notebook, section) = ("/local-notebook", [3; 16]); + let mut reads = Reads::load(&folder); + let at = now() - 100_000_000; + reads.section_mut(notebook, section).read = at; + let first = page(1, None).0; + reads.arrived(notebook, section, [(first, Some(at + 1), true)]); + reads.viewing = Some((notebook.into(), section, first, false)); + reads.toggle_read(); + reads.arrived(notebook, section, [(first, Some(at + 2), true)]); + assert!(reads.pages(notebook, section).unwrap().contains(&first)); + assert!(reads.viewing.as_ref().unwrap().3); + let mut reads = Reads::load(&folder); + reads.arrived(notebook, section, [(first, Some(at + 2), true)]); + assert!(reads.pages(notebook, section).unwrap().contains(&first)); + let marked = reads.section_mut(notebook, section).pages[&first]; + reads.arrived(notebook, section, [(first, Some(marked + 1), true)]); + assert!(reads.pages(notebook, section).unwrap().is_empty()); + reads.arrived(notebook, section, [page(1, Some(marked + 2))]); + assert!(reads.pages(notebook, section).unwrap().contains(&first)); + notebook::fs::remove_dir_all(&folder).unwrap(); + } + #[test] fn legacy_time32_read_watermarks_load_without_losing_unread_pages() { let folder =