authorclover caruso <git@paperclover.net> 2026-10-10 19:08:04-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:05-07:00
log773ac767abacd8ff5c3f32991045fca1cd000278
tree1e9a0ed323d3fa6b21effb367b39bb966b31da05
parent8c01efc1126e6d58946a2209125df65e46d10145
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix: attribute page edits and track unread changes precisely

Stamp each changed page revision with its latest author and exact edit FILETIME, preserving paragraph original authors. Checkpoint when an older page first gains revision metadata. Read authoritative revision metadata before legacy object attribution, and retain Time32 units at search and recycle-bin boundaries. Track read watermarks per page so delayed notifications and same-second edits remain ordered, migrate existing read.json timestamps, and preserve external authors editing local notebooks. Fixes #107 Assisted-by: gpt-6.1-sol

9 files changed, 391 insertions(+), 82 deletions(-)

crates/mobile/src/library.rs+3-1
...@@ -1443,7 +1443,9 @@ impl Library {...@@ -1443,7 +1443,9 @@ impl Library {
1443 let pages = session::stored_pages(&read()?)?;1443 let pages = session::stored_pages(&read()?)?;
1444 index.retain(|entry| entry.section != path);1444 index.retain(|entry| entry.section != path);
1445 for stored in pages {1445 for stored in pages {
1446 let modified = stored.modified.map_or(0, u64::from);1446 let modified = stored.modified.map_or(0, |filetime| {
1447 (filetime / 10_000_000).saturating_sub(11_960_006_400)
1448 });
1447 index.set(Entry::new(path, stored.space, &stored.page, modified));1449 index.set(Entry::new(path, stored.space, &stored.page, modified));
1448 }1450 }
1449 }1451 }
crates/notebook/src/session.rs+29-7
...@@ -1127,8 +1127,11 @@ impl Notebook {...@@ -1127,8 +1127,11 @@ impl Notebook {
1127 /// Returns how many pages and sections went.1127 /// Returns how many pages and sections went.
1128 pub fn purge_recycle_bin(&mut self, now: u32) -> Result<usize> {1128 pub fn purge_recycle_bin(&mut self, now: u32) -> Result<usize> {
1129 const DELETED: &str = "OneNote_DeletedPages.one";1129 const DELETED: &str = "OneNote_DeletedPages.one";
1130 let expired = |modified: Option<u32>| {1130 let now = (u64::from(now) + 11_960_006_400) * 10_000_000;
1131 modified.is_some_and(|modified| now.saturating_sub(modified) > RECYCLE_DAYS * 86_400)1131 let expired = |modified: Option<u64>| {
1132 modified.is_some_and(|modified| {
1133 now.saturating_sub(modified) > u64::from(RECYCLE_DAYS) * 86_400 * 10_000_000
1134 })
1132 };1135 };
1133 let Ok(bin) = self.folder(RECYCLE_BIN) else {1136 let Ok(bin) = self.folder(RECYCLE_BIN) else {
1134 return Ok(0);1137 return Ok(0);
...@@ -1732,9 +1735,9 @@ impl Entry {...@@ -1732,9 +1735,9 @@ impl Entry {
1732pub struct StoredPage {1735pub struct StoredPage {
1733 pub space: ExGuid,1736 pub space: ExGuid,
1734 pub page: Page,1737 pub page: Page,
1735 /// The page's `LastModifiedTime`, Time32 seconds since 1980.1738 /// The revision metadata's timestamp, or the page's `LastModifiedTime`, as FILETIME.
1736 pub modified: Option<u32>,1739 pub modified: Option<u64>,
1737 /// Who changed it last: the `AuthorMostRecent` of its latest modified object.1740 /// Who changed it last: revision metadata, or the latest modified object in older files.
1738 pub author: Option<String>,1741 pub author: Option<String>,
1739}1742}
17401743
...@@ -1763,7 +1766,13 @@ fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> {...@@ -1763,7 +1766,13 @@ fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> {
1763 let latest = (revision.nodes.values())1766 let latest = (revision.nodes.values())
1764 .filter(|node| node.latest_author.is_some())1767 .filter(|node| node.latest_author.is_some())
1765 .max_by_key(|node| node.modified);1768 .max_by_key(|node| node.modified);
1766 let author = latest1769 let metadata = revision.roots.get(&4).and_then(|id| revision.nodes.get(id));
1770 let author = metadata
1771 .filter(|node| {
1772 matches!(node.kind, onestore::document::Kind::RevisionMetadata { .. })
1773 })
1774 .filter(|node| node.latest_author.is_some())
1775 .or(latest)
1767 .and_then(|node| revision.nodes.get(&node.latest_author?))1776 .and_then(|node| revision.nodes.get(&node.latest_author?))
1768 .and_then(|node| match &node.kind {1777 .and_then(|node| match &node.kind {
1769 onestore::document::Kind::Author { name } => name.clone(),1778 onestore::document::Kind::Author { name } => name.clone(),
...@@ -1772,7 +1781,20 @@ fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> {...@@ -1772,7 +1781,20 @@ fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> {
1772 Some(StoredPage {1781 Some(StoredPage {
1773 space,1782 space,
1774 page: Page::from_revision(revision, id).ok()?,1783 page: Page::from_revision(revision, id).ok()?,
1775 modified: revision.nodes.get(&id).and_then(|node| node.modified),1784 modified: metadata
1785 .and_then(|node| match node.kind {
1786 onestore::document::Kind::RevisionMetadata { modified_filetime } => {
1787 modified_filetime
1788 }
1789 _ => None,
1790 })
1791 .or_else(|| {
1792 revision
1793 .nodes
1794 .get(&id)
1795 .and_then(|node| node.modified)
1796 .map(|seconds| (u64::from(seconds) + 11_960_006_400) * 10_000_000)
1797 }),
1776 author,1798 author,
1777 })1799 })
1778 })1800 })
crates/notebook/tests/session.rs+77
...@@ -1559,3 +1559,80 @@ fn a_stored_page_names_who_changed_it_last() {...@@ -1559,3 +1559,80 @@ fn a_stored_page_names_who_changed_it_last() {
1559 let edited = &notebook::session::stored_pages(&section.image()).unwrap()[0];1559 let edited = &notebook::session::stored_pages(&section.image()).unwrap()[0];
1560 assert_eq!(edited.author.as_deref(), Some("Bob"));1560 assert_eq!(edited.author.as_deref(), Some("Bob"));
1561}1561}
1562
1563#[test]
1564fn text_edits_name_the_latest_author_even_within_the_same_second() {
1565 let image = onestore::create_section("Authors.one", "Body", "Alice").unwrap();
1566 let first = notebook::session::stored_pages(&image).unwrap().remove(0);
1567 let at = first.modified.unwrap() + 12345;
1568 let arena = onestore::Arena::default();
1569 let mut section = onestore::Section::open(&arena, image).unwrap();
1570 for (author, at) in [("Bob", at), ("Alice", at + 1), ("Bob", at + 20_000_000)] {
1571 let before = section.image();
1572 let store = onestore::Store::parse(&before).unwrap();
1573 let index = onestore::RevisionIndex::parse(&store).unwrap();
1574 let revisions = index.spaces[&first.space].revisions.len();
1575 let edit = Edit {
1576 at,
1577 ops: vec![Op::Page {
1578 space: first.space,
1579 op: PageOp::Text {
1580 text: first_text(&first.page),
1581 range: 0..0,
1582 with: author.into(),
1583 },
1584 }],
1585 };
1586 section.apply(author, &edit).unwrap();
1587 section.seal().unwrap().unwrap();
1588 let image = section.image();
1589 let page = notebook::session::stored_pages(&image).unwrap().remove(0);
1590 assert_eq!(page.author.as_deref(), Some(author));
1591 assert_eq!(page.modified, Some(at));
1592 let store = onestore::Store::parse(&image).unwrap();
1593 let index = onestore::RevisionIndex::parse(&store).unwrap();
1594 assert_eq!(index.spaces[&first.space].revisions.len(), revisions + 1);
1595 let document = onestore::document::Document::parse(&index).unwrap();
1596 let revision = document.active(first.space).unwrap();
1597 let metadata = &revision.nodes[&revision.roots[&4]];
1598 assert!(
1599 matches!(metadata.kind, onestore::document::Kind::RevisionMetadata { modified_filetime: Some(stored) } if stored == at)
1600 );
1601 let paragraph = revision
1602 .nodes
1603 .values()
1604 .find(|node| node.content.contains(&first_text(&first.page)))
1605 .unwrap();
1606 let name = |id: Option<ExGuid>| match &revision.nodes[&id.unwrap()].kind {
1607 onestore::document::Kind::Author { name } => name.clone().unwrap(),
1608 _ => panic!("author reference names another kind of object"),
1609 };
1610 assert_eq!(name(paragraph.original_author), "Alice");
1611 assert_eq!(name(paragraph.latest_author), author);
1612 }
1613 section
1614 .apply(
1615 "Nobody",
1616 &Edit {
1617 at: at + 30_000_000,
1618 ops: vec![Op::Page {
1619 space: first.space,
1620 op: PageOp::Text {
1621 text: first_text(&first.page),
1622 range: 0..0,
1623 with: String::new(),
1624 },
1625 }],
1626 },
1627 )
1628 .unwrap();
1629 assert!(section.seal().unwrap().is_none());
1630 if let Some(folder) = std::env::var_os("SNOWBOUND_AUTHOR_EXPORT") {
1631 std::fs::create_dir(&folder).unwrap();
1632 std::fs::write(
1633 std::path::Path::new(&folder).join("Authors.one"),
1634 section.image(),
1635 )
1636 .unwrap();
1637 }
1638}
crates/onestore/src/active.rs+1
...@@ -233,6 +233,7 @@ impl<'a> ActivePage<'a> {...@@ -233,6 +233,7 @@ impl<'a> ActivePage<'a> {
233 /// Brings the view, parents and titles up to date with the revision for the objects a233 /// Brings the view, parents and titles up to date with the revision for the objects a
234 /// commit touched, which include those it replaced.234 /// commit touched, which include those it replaced.
235 fn refresh(&mut self, replaced: &BTreeSet<ExGuid>, touched: BTreeSet<ExGuid>) -> Result<()> {235 fn refresh(&mut self, replaced: &BTreeSet<ExGuid>, touched: BTreeSet<ExGuid>) -> Result<()> {
236 self.view.roots.clone_from(&self.live.revision.roots);
236 let mut attach = Vec::new();237 let mut attach = Vec::new();
237 let mut detach = Vec::new();238 let mut detach = Vec::new();
238 let mut gone = BTreeMap::new();239 let mut gone = BTreeMap::new();
crates/onestore/src/create.rs+4-1
...@@ -184,7 +184,10 @@ pub(crate) fn current_timestamps() -> Result<(u32, u64)> {...@@ -184,7 +184,10 @@ pub(crate) fn current_timestamps() -> Result<(u32, u64)> {
184 offset: 0,184 offset: 0,
185 message: "System time exceeds the OneNote Time32 range",185 message: "System time exceeds the OneNote Time32 range",
186 })?;186 })?;
187 Ok((modified, (now + 11644473600) * 10000000))187 Ok((
188 modified,
189 CLOCK.get().unwrap_or((now + 11644473600) * 10000000),
190 ))
188}191}
189192
190/// Creates a section with one page and one plain-text paragraph, without a template.193/// Creates a section with one page and one plain-text paragraph, without a template.
crates/onestore/src/op/apply.rs+9-1
...@@ -477,7 +477,15 @@ impl<'a> Writer<'_, 'a> {...@@ -477,7 +477,15 @@ impl<'a> Writer<'_, 'a> {
477 changes: impl FnOnce(&ActivePage<'a>) -> Result<Changes, Error>,477 changes: impl FnOnce(&ActivePage<'a>) -> Result<Changes, Error>,
478 ) -> Result<(), Failure> {478 ) -> Result<(), Failure> {
479 let changes = changes(self.page()?)?;479 let changes = changes(self.page()?)?;
480 self.section.apply_changes(self.space, payloads, changes)?;480 let paragraphs = changes
481 .iter()
482 .filter(|(_, object)| object.jcid == 0x6000d)
483 .map(|(id, _)| *id)
484 .collect();
485 if self.section.apply_changes(self.space, payloads, changes)? {
486 self.section
487 .attribute(self.space, self.author, paragraphs)?;
488 }
481 Ok(())489 Ok(())
482 }490 }
483491
crates/onestore/src/section.rs+77-1
...@@ -15,7 +15,10 @@ use crate::{...@@ -15,7 +15,10 @@ use crate::{
15 },15 },
16};16};
17use bumpalo::Bump;17use bumpalo::Bump;
18use std::collections::{BTreeMap, BTreeSet};18use std::{
19 collections::{BTreeMap, BTreeSet},
20 sync::Arc,
21};
1922
20mod versions;23mod versions;
21pub(crate) use versions::HISTORY;24pub(crate) use versions::HISTORY;
...@@ -604,6 +607,75 @@ impl<'a> Section<'a> {...@@ -604,6 +607,75 @@ impl<'a> Section<'a> {
604 Ok(true)607 Ok(true)
605 }608 }
606609
610 pub(crate) fn attribute(
611 &mut self,
612 space: ExGuid,
613 author: &str,
614 paragraphs: Vec<ExGuid>,
615 ) -> Result<()> {
616 self.broken = true;
617 let guid = crate::write::fresh_guid()?;
618 let named = |n| ExGuid { guid, n };
619 let page = self.active(space)?;
620 let revision = &page.live.revision;
621 let author_id = page.view.nodes.iter().find_map(|(id, node)| {
622 matches!(&node.kind, crate::document::Kind::Author { name } if name.as_deref() == Some(author))
623 .then_some(*id)
624 }).unwrap_or(named(2));
625 let mut changes = Changes::new();
626 if author_id == named(2) {
627 changes.insert(
628 author_id,
629 PropertyObject {
630 jcid: 0x120001,
631 bytes: crate::create::properties(&crate::create::author_properties(author))?,
632 global_ids: Arc::new(BTreeMap::from([(0, guid)])),
633 },
634 );
635 }
636 let metadata_id = revision.roots.get(&4).copied().unwrap_or(named(1));
637 let mut metadata = match revision.objects.get(&metadata_id) {
638 Some(object) if object.jcid == 0x20044 => PropertyObject::from_object(object)?,
639 Some(_) => {
640 return Err(Error {
641 offset: 0,
642 message: "The page's revision metadata is unavailable",
643 });
644 }
645 None => PropertyObject {
646 jcid: 0x20044,
647 bytes: crate::create::properties(&[])?,
648 global_ids: Arc::new(BTreeMap::from([(0, guid)])),
649 },
650 };
651 let (modified, filetime) = crate::create::current_timestamps()?;
652 let reference = metadata.reference(author_id)?;
653 metadata.set(&[
654 (0x18001d77, &filetime.to_le_bytes()),
655 (0x20001d79, &reference),
656 ])?;
657 changes.insert(metadata_id, metadata);
658 for id in paragraphs {
659 let mut paragraph = PropertyObject::from_object(&revision.objects[&id])?;
660 let reference = paragraph.reference(author_id)?;
661 paragraph.set(&[
662 (0x14001d7a, &modified.to_le_bytes()),
663 (0x20001d79, &reference),
664 ])?;
665 changes.insert(id, paragraph);
666 }
667 self.remember(space);
668 self.editable(space)?
669 .page
670 .live
671 .revision
672 .roots
673 .insert(4, metadata_id);
674 self.broken = false;
675 self.apply_changes(space, &[], changes)?;
676 Ok(())
677 }
678
607 /// Creates an object space holding `changes` under `roots` until the next seal, which679 /// Creates an object space holding `changes` under `roots` until the next seal, which
608 /// declares it. An error after validation leaves the section to be reopened.680 /// declares it. An error after validation leaves the section to be reopened.
609 pub(crate) fn create_space(681 pub(crate) fn create_space(
...@@ -1058,6 +1130,10 @@ fn changed<'a>(...@@ -1058,6 +1130,10 @@ fn changed<'a>(
1058 };1130 };
1059 objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?));1131 objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?));
1060 }1132 }
1133 if file.revision.roots != page.revision.roots {
1134 file.depth = 0;
1135 file.revision.roots.clone_from(&page.revision.roots);
1136 }
1061 Ok(Some(Revised {1137 Ok(Some(Revised {
1062 commit: file.commit(objects)?,1138 commit: file.commit(objects)?,
1063 replaced,1139 replaced,
crates/snowbound/src/search.rs+3-1
...@@ -383,7 +383,9 @@ fn sync(...@@ -383,7 +383,9 @@ fn sync(
383 .iter()383 .iter()
384 .find(|page| page.space == space)384 .find(|page| page.space == space)
385 .and_then(|page| page.modified)385 .and_then(|page| page.modified)
386 .map_or_else(now, u64::from)386 .map_or_else(now, |filetime| {
387 (filetime / 10_000_000).saturating_sub(11_960_006_400)
388 })
387 };389 };
388 Ok(match &replica {390 Ok(match &replica {
389 // The open section shows its edits before they reach the file.391 // The open section shows its edits before they reach the file.
crates/snowbound/src/unread.rs+188-70
...@@ -12,9 +12,6 @@ use std::{...@@ -12,9 +12,6 @@ use std::{
12 sync::Arc,12 sync::Arc,
13};13};
1414
15/// Seconds from 1970 to 1980, where OneNote's Time32 starts.
16const TIME32: u64 = 315_532_800;
17
18/// What has been read, by notebook location.15/// What has been read, by notebook location.
19#[derive(Default, serde::Serialize, serde::Deserialize)]16#[derive(Default, serde::Serialize, serde::Deserialize)]
20pub struct Reads {17pub struct Reads {
...@@ -41,8 +38,10 @@ struct Kept {...@@ -41,8 +38,10 @@ struct Kept {
4138
42#[derive(Default, serde::Serialize, serde::Deserialize)]39#[derive(Default, serde::Serialize, serde::Deserialize)]
43struct Section {40struct Section {
44 /// Time32 up to which changes to the section's pages are known.41 /// FILETIME up to which the section was marked read.
45 read: u32,42 read: u64,
43 #[serde(default)]
44 pages: BTreeMap<ExGuid, u64>,
46 unread: BTreeSet<ExGuid>,45 unread: BTreeSet<ExGuid>,
47}46}
4847
...@@ -50,20 +49,29 @@ fn hex(identity: [u8; 16]) -> String {...@@ -50,20 +49,29 @@ fn hex(identity: [u8; 16]) -> String {
50 identity.iter().map(|byte| format!("{byte:02x}")).collect()49 identity.iter().map(|byte| format!("{byte:02x}")).collect()
51}50}
5251
53fn now() -> u32 {52fn now() -> u64 {
54 let unix = web_time::SystemTime::now()53 let unix = web_time::SystemTime::now()
55 .duration_since(web_time::UNIX_EPOCH)54 .duration_since(web_time::UNIX_EPOCH)
56 .map_or(0, |since| since.as_secs());55 .unwrap_or_default();
57 u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX)56 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100)
58}57}
5958
60impl Reads {59impl Reads {
61 /// What the cache folder `cache` keeps.60 /// What the cache folder `cache` keeps.
62 pub fn load(cache: &Path) -> Self {61 pub fn load(cache: &Path) -> Self {
63 let file = cache.join("read.json");62 let file = cache.join("read.json");
64 let reads: Self = (notebook::fs::read(&file).ok())63 let mut reads: Self = (notebook::fs::read(&file).ok())
65 .and_then(|bytes| serde_json::from_slice(&bytes).ok())64 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
66 .unwrap_or_default();65 .unwrap_or_default();
66 for section in reads
67 .notebooks
68 .values_mut()
69 .flat_map(|kept| kept.sections.values_mut())
70 {
71 if section.read <= u64::from(u32::MAX) {
72 section.read = (section.read + 11_960_006_400) * 10_000_000;
73 }
74 }
67 Self { file, ..reads }75 Self { file, ..reads }
68 }76 }
6977
...@@ -87,7 +95,7 @@ impl Reads {...@@ -87,7 +95,7 @@ impl Reads {
87 let kept = self.notebooks.entry(notebook.to_owned()).or_default();95 let kept = self.notebooks.entry(notebook.to_owned()).or_default();
88 (kept.sections.entry(hex(section))).or_insert_with(|| Section {96 (kept.sections.entry(hex(section))).or_insert_with(|| Section {
89 read: now(),97 read: now(),
90 unread: BTreeSet::new(),98 ..Section::default()
91 })99 })
92 }100 }
93101
...@@ -118,39 +126,51 @@ impl Reads {...@@ -118,39 +126,51 @@ impl Reads {
118 .map(|section| &section.unread)126 .map(|section| &section.unread)
119 }127 }
120128
121 /// Takes in the pages of a section another author changed, by space.129 pub(crate) fn mark_read(&mut self, notebook: &str, section: [u8; 16], page: Option<ExGuid>) {
122 fn changed(130 let kept = self.section_mut(notebook, section);
123 &mut self,131 match page {
124 notebook: &str,132 Some(page) => {
125 section: [u8; 16],133 kept.unread.remove(&page);
126 pages: impl IntoIterator<Item = ExGuid>,134 let read = kept.pages.entry(page).or_default();
127 ) {135 *read = (*read).max(now());
128 self.section_mut(notebook, section).unread.extend(pages);136 }
137 None => {
138 kept.unread.clear();
139 kept.read = kept.read.max(now());
140 }
141 }
142 if let Some((viewing, viewed_section, viewed_page, keep)) = &mut self.viewing
143 && viewing == notebook
144 && *viewed_section == section
145 && page.is_none_or(|page| page == *viewed_page)
146 {
147 *keep = false;
148 }
129 self.save();149 self.save();
130 }150 }
131151
132 /// Takes in a section as its file stands, its pages with their `LastModifiedTime` and152 /// Takes in a section as its file stands, its pages with their FILETIME and
133 /// whether the reader changed them last: those another author changed since it was last153 /// whether the reader changed them last: those another author changed since it was last
134 /// known are unread.154 /// known are unread.
135 fn arrived(155 fn arrived(
136 &mut self,156 &mut self,
137 notebook: &str,157 notebook: &str,
138 section: [u8; 16],158 section: [u8; 16],
139 pages: impl IntoIterator<Item = (ExGuid, Option<u32>, bool)>,159 pages: impl IntoIterator<Item = (ExGuid, Option<u64>, bool)>,
140 ) {160 ) {
141 let now = now();
142 let kept = self.section_mut(notebook, section);161 let kept = self.section_mut(notebook, section);
143 let read = kept.read;
144 let mut newest = read.max(now);
145 for (space, modified, mine) in pages {162 for (space, modified, mine) in pages {
163 let read = kept.read.max(kept.pages.get(&space).copied().unwrap_or(0));
164 let Some(modified) = modified.filter(|modified| *modified > read) else {
165 continue;
166 };
146 if mine {167 if mine {
147 kept.unread.remove(&space);168 kept.unread.remove(&space);
148 } else if modified.is_some_and(|modified| modified > read) {169 } else {
149 kept.unread.insert(space);170 kept.unread.insert(space);
150 }171 }
151 newest = newest.max(modified.unwrap_or(0));172 kept.pages.insert(space, modified);
152 }173 }
153 kept.read = newest;
154 self.save();174 self.save();
155 }175 }
156}176}
...@@ -200,8 +220,7 @@ impl State {...@@ -200,8 +220,7 @@ impl State {
200 self.unread_sections(library).next().is_some()220 self.unread_sections(library).next().is_some()
201 }221 }
202222
203 /// Follows what is read, each frame: a notebook's sections are known from when it opens,223 /// A notebook's sections are known from when it opens; the page shown turns read once left.
204 /// and the page shown turns read once left, its section known up to then.
205 pub(crate) fn follow_reading(&mut self) {224 pub(crate) fn follow_reading(&mut self) {
206 let taken: Vec<usize> = self225 let taken: Vec<usize> = self
207 .notebooks226 .notebooks
...@@ -243,14 +262,8 @@ impl State {...@@ -243,14 +262,8 @@ impl State {
243 return;262 return;
244 }263 }
245 if let Some((notebook, section, space, kept)) = self.reads.viewing.take() {264 if let Some((notebook, section, space, kept)) = self.reads.viewing.take() {
246 let left = self.reads.section_mut(&notebook, section);265 if !kept {
247 let removed = !kept && left.unread.remove(&space);266 self.reads.mark_read(&notebook, section, Some(space));
248 let moved = viewing.as_ref().is_none_or(|(_, now, _)| *now != section);
249 if moved {
250 left.read = left.read.max(now());
251 }
252 if removed || moved {
253 self.reads.save();
254 }267 }
255 }268 }
256 self.reads.viewing =269 self.reads.viewing =
...@@ -279,13 +292,13 @@ impl State {...@@ -279,13 +292,13 @@ impl State {
279 let pages = (replica.snapshot().ok())292 let pages = (replica.snapshot().ok())
280 .and_then(|image| notebook::session::stored_pages(&image).ok())293 .and_then(|image| notebook::session::stored_pages(&image).ok())
281 .unwrap_or_default();294 .unwrap_or_default();
282 let others: Vec<ExGuid> = (spaces.into_iter())295 let pages: Vec<_> = pages
283 .filter(|space| {296 .into_iter()
284 !(pages.iter()).any(|page| page.space == *space && me(page.author.as_deref()))297 .filter(|page| spaces.contains(&page.space))
285 })298 .map(|page| (page.space, page.modified, me(page.author.as_deref())))
286 .collect();299 .collect();
287 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| {300 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| {
288 state.reads.changed(&location, section, others);301 state.reads.arrived(&location, section, pages);
289 Ok(())302 Ok(())
290 })));303 })));
291 });304 });
...@@ -335,16 +348,25 @@ impl State {...@@ -335,16 +348,25 @@ impl State {
335348
336 /// Mark as Read, or as Unread, on the page shown, as OneNote 2010's Ctrl+Q does.349 /// Mark as Read, or as Unread, on the page shown, as OneNote 2010's Ctrl+Q does.
337 pub(crate) fn toggle_read(&mut self) {350 pub(crate) fn toggle_read(&mut self) {
338 let Some((notebook, section, space, kept)) = self.reads.viewing.as_mut() else {351 let Some((notebook, section, space, _)) = self.reads.viewing.clone() else {
339 return;352 return;
340 };353 };
341 let pages = &mut self.reads.notebooks.entry(notebook.clone()).or_default();354 if self
342 let unread = &mut (pages.sections.entry(hex(*section)).or_default()).unread;355 .reads
343 *kept = unread.insert(*space);356 .pages(&notebook, section)
344 if !*kept {357 .is_some_and(|pages| pages.contains(&space))
345 unread.remove(space);358 {
359 self.reads.mark_read(&notebook, section, Some(space));
360 } else {
361 self.reads
362 .section_mut(&notebook, section)
363 .unread
364 .insert(space);
365 if let Some((.., kept)) = &mut self.reads.viewing {
366 *kept = true;
367 }
368 self.reads.save();
346 }369 }
347 self.reads.save();
348 }370 }
349371
350 /// Whether the page shown is unread.372 /// Whether the page shown is unread.
...@@ -362,10 +384,14 @@ impl State {...@@ -362,10 +384,14 @@ impl State {
362 /// Mark Notebook as Read.384 /// Mark Notebook as Read.
363 pub(crate) fn mark_notebook_read(&mut self, library: &Library) {385 pub(crate) fn mark_notebook_read(&mut self, library: &Library) {
364 if let Some(kept) = self.reads.notebooks.get_mut(&library.location) {386 if let Some(kept) = self.reads.notebooks.get_mut(&library.location) {
365 kept.sections387 let now = now();
366 .values_mut()388 for section in kept.sections.values_mut() {
367 .for_each(|section| section.unread.clear());389 section.unread.clear();
368 if let Some((.., kept)) = &mut self.reads.viewing {390 section.read = section.read.max(now);
391 }
392 if let Some((viewing, .., kept)) = &mut self.reads.viewing
393 && viewing == &library.location
394 {
369 *kept = false;395 *kept = false;
370 }396 }
371 self.reads.save();397 self.reads.save();
...@@ -427,7 +453,7 @@ impl State {...@@ -427,7 +453,7 @@ impl State {
427mod tests {453mod tests {
428 use super::*;454 use super::*;
429455
430 fn page(n: u32, modified: Option<u32>) -> (ExGuid, Option<u32>, bool) {456 fn page(n: u32, modified: Option<u64>) -> (ExGuid, Option<u64>, bool) {
431 (457 (
432 ExGuid {458 ExGuid {
433 guid: [n as u8; 16],459 guid: [n as u8; 16],
...@@ -458,28 +484,14 @@ mod tests {...@@ -458,28 +484,14 @@ mod tests {
458 .collect()484 .collect()
459 };485 };
460 assert_eq!(unread(&reads), [2]);486 assert_eq!(unread(&reads), [2]);
461 reads.changed(487 reads.arrived(notebook, section, [page(4, Some(known + 6))]);
462 notebook,
463 section,
464 [ExGuid {
465 guid: [4; 16],
466 n: 4,
467 }],
468 );
469 let mut reads = Reads::load(&folder);488 let mut reads = Reads::load(&folder);
470 assert_eq!(unread(&reads), [2, 4]);489 assert_eq!(unread(&reads), [2, 4]);
471 // Known up to the newest change: the same page arriving again is no news.490 // Known up to the newest change: the same page arriving again is no news.
472 reads.section_mut(notebook, section).unread.clear();491 reads.mark_read(notebook, section, None);
473 reads.arrived(notebook, section, [page(2, Some(known + 5))]);492 reads.arrived(notebook, section, [page(2, Some(known + 5))]);
474 assert!(unread(&reads).is_empty());493 assert!(unread(&reads).is_empty());
475 reads.changed(494 reads.arrived(notebook, section, [page(4, Some(known + 7))]);
476 notebook,
477 section,
478 [ExGuid {
479 guid: [4; 16],
480 n: 4,
481 }],
482 );
483 reads.notebooks.get_mut(notebook).unwrap().hidden = true;495 reads.notebooks.get_mut(notebook).unwrap().hidden = true;
484 assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook));496 assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook));
485 notebook::fs::remove_dir_all(&folder).unwrap();497 notebook::fs::remove_dir_all(&folder).unwrap();
...@@ -514,4 +526,110 @@ mod tests {...@@ -514,4 +526,110 @@ mod tests {
514 assert!(unread(&reads).is_empty());526 assert!(unread(&reads).is_empty());
515 notebook::fs::remove_dir_all(&folder).unwrap();527 notebook::fs::remove_dir_all(&folder).unwrap();
516 }528 }
529
530 #[test]
531 fn marking_read_targets_only_the_clicked_page_or_section_and_rejects_stale_changes() {
532 let folder =
533 std::env::temp_dir().join(format!("snowbound-unread-target-{}", std::process::id()));
534 notebook::fs::create_dir_all(&folder).unwrap();
535 let (notebook, section, other) = ("/local-notebook", [3; 16], [4; 16]);
536 let mut reads = Reads::load(&folder);
537 let at = (now() / 10_000_000 - 10) * 10_000_000;
538 reads.section_mut(notebook, section).read = at;
539 reads.section_mut(notebook, other).read = at;
540 reads.arrived(
541 notebook,
542 section,
543 [page(1, Some(at + 1)), page(2, Some(at + 1))],
544 );
545 reads.arrived(notebook, other, [page(3, Some(at + 1))]);
546 let first = page(1, None).0;
547 let second = page(2, None).0;
548 reads.viewing = Some((notebook.into(), section, second, true));
549 reads.mark_read(notebook, section, Some(first));
550 reads.mark_read(notebook, section, Some(first));
551 assert_eq!(
552 reads.pages(notebook, section).unwrap(),
553 &BTreeSet::from([second])
554 );
555 assert!(reads.viewing.as_ref().unwrap().3);
556 reads.arrived(notebook, section, [page(1, Some(at + 1))]);
557 assert!(!reads.pages(notebook, section).unwrap().contains(&first));
558 reads.mark_read(notebook, section, None);
559 assert!(!reads.viewing.as_ref().unwrap().3);
560 assert!(reads.pages(notebook, section).unwrap().is_empty());
561 assert!(
562 reads
563 .pages(notebook, other)
564 .unwrap()
565 .contains(&page(3, None).0)
566 );
567 let mut reads = Reads::load(&folder);
568 reads.arrived(notebook, section, [page(1, Some(at + 1))]);
569 assert!(reads.pages(notebook, section).unwrap().is_empty());
570 let after = reads.section_mut(notebook, section).read + 1;
571 reads.arrived(notebook, section, [page(1, Some(after))]);
572 assert!(reads.pages(notebook, section).unwrap().contains(&first));
573 notebook::fs::remove_dir_all(&folder).unwrap();
574 }
575
576 #[test]
577 fn delayed_other_pages_and_same_second_external_changes_remain_unread() {
578 let folder =
579 std::env::temp_dir().join(format!("snowbound-unread-order-{}", std::process::id()));
580 notebook::fs::create_dir_all(&folder).unwrap();
581 let (notebook, section) = ("/local-notebook", [3; 16]);
582 let mut reads = Reads::load(&folder);
583 let at = (now() / 10_000_000 - 10) * 10_000_000;
584 reads.section_mut(notebook, section).read = at;
585 reads.arrived(notebook, section, [page(1, Some(at + 1))]);
586 let first = page(1, None).0;
587 reads.mark_read(notebook, section, Some(first));
588 reads.arrived(notebook, section, [page(2, Some(at + 2))]);
589 assert!(
590 reads
591 .pages(notebook, section)
592 .unwrap()
593 .contains(&page(2, None).0)
594 );
595 let marked = reads.section_mut(notebook, section).pages[&first];
596 reads.arrived(notebook, section, [page(1, Some(marked + 1))]);
597 assert!(reads.pages(notebook, section).unwrap().contains(&first));
598 reads.arrived(notebook, section, [(first, Some(at + 1), true)]);
599 assert!(reads.pages(notebook, section).unwrap().contains(&first));
600 let second = page(2, None).0;
601 reads.arrived(notebook, section, [(second, Some(at + 3), true)]);
602 assert!(!reads.pages(notebook, section).unwrap().contains(&second));
603 reads.arrived(notebook, section, [page(2, Some(at + 4))]);
604 assert!(reads.pages(notebook, section).unwrap().contains(&second));
605 let reads = Reads::load(&folder);
606 assert_eq!(reads.pages(notebook, section).unwrap().len(), 2);
607 notebook::fs::remove_dir_all(&folder).unwrap();
608 }
609
610 #[test]
611 fn legacy_time32_read_watermarks_load_without_losing_unread_pages() {
612 let folder =
613 std::env::temp_dir().join(format!("snowbound-unread-legacy-{}", std::process::id()));
614 notebook::fs::create_dir_all(&folder).unwrap();
615 let (notebook, section) = ("/local-notebook", [3; 16]);
616 let first = page(1, None).0;
617 notebook::fs::write(
618 folder.join("read.json"),
619 serde_json::to_vec(&serde_json::json!({
620 "notebooks": { notebook: { "sections": { hex(section): {
621 "read": 1_450_000_000_u32, "unread": [first]
622 } } } }
623 }))
624 .unwrap(),
625 )
626 .unwrap();
627 let reads = Reads::load(&folder);
628 assert_eq!(
629 reads.section(notebook, section).unwrap().read,
630 (1_450_000_000 + 11_960_006_400) * 10_000_000
631 );
632 assert!(reads.pages(notebook, section).unwrap().contains(&first));
633 notebook::fs::remove_dir_all(&folder).unwrap();
634 }
517}635}