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 {
14431443 let pages = session::stored_pages(&read()?)?;
14441444 index.retain(|entry| entry.section != path);
14451445 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 });
14471449 index.set(Entry::new(path, stored.space, &stored.page, modified));
14481450 }
14491451 }
crates/notebook/src/session.rs+29-7
......@@ -1127,8 +1127,11 @@ impl Notebook {
11271127 /// Returns how many pages and sections went.
11281128 pub fn purge_recycle_bin(&mut self, now: u32) -> Result<usize> {
11291129 const DELETED: &str = "OneNote_DeletedPages.one";
1130 let expired = |modified: Option<u32>| {
1131 modified.is_some_and(|modified| now.saturating_sub(modified) > RECYCLE_DAYS * 86_400)
1130 let now = (u64::from(now) + 11_960_006_400) * 10_000_000;
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 })
11321135 };
11331136 let Ok(bin) = self.folder(RECYCLE_BIN) else {
11341137 return Ok(0);
......@@ -1732,9 +1735,9 @@ impl Entry {
17321735pub struct StoredPage {
17331736 pub space: ExGuid,
17341737 pub page: Page,
1735 /// The page's `LastModifiedTime`, Time32 seconds since 1980.
1736 pub modified: Option<u32>,
1737 /// Who changed it last: the `AuthorMostRecent` of its latest modified object.
1738 /// The revision metadata's timestamp, or the page's `LastModifiedTime`, as FILETIME.
1739 pub modified: Option<u64>,
1740 /// Who changed it last: revision metadata, or the latest modified object in older files.
17381741 pub author: Option<String>,
17391742}
17401743
......@@ -1763,7 +1766,13 @@ fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> {
17631766 let latest = (revision.nodes.values())
17641767 .filter(|node| node.latest_author.is_some())
17651768 .max_by_key(|node| node.modified);
1766 let author = latest
1769 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)
17671776 .and_then(|node| revision.nodes.get(&node.latest_author?))
17681777 .and_then(|node| match &node.kind {
17691778 onestore::document::Kind::Author { name } => name.clone(),
......@@ -1772,7 +1781,20 @@ fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> {
17721781 Some(StoredPage {
17731782 space,
17741783 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 }),
17761798 author,
17771799 })
17781800 })
crates/notebook/tests/session.rs+77
......@@ -1559,3 +1559,80 @@ fn a_stored_page_names_who_changed_it_last() {
15591559 let edited = &notebook::session::stored_pages(&section.image()).unwrap()[0];
15601560 assert_eq!(edited.author.as_deref(), Some("Bob"));
15611561}
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> {
233233 /// Brings the view, parents and titles up to date with the revision for the objects a
234234 /// commit touched, which include those it replaced.
235235 fn refresh(&mut self, replaced: &BTreeSet<ExGuid>, touched: BTreeSet<ExGuid>) -> Result<()> {
236 self.view.roots.clone_from(&self.live.revision.roots);
236237 let mut attach = Vec::new();
237238 let mut detach = Vec::new();
238239 let mut gone = BTreeMap::new();
crates/onestore/src/create.rs+4-1
......@@ -184,7 +184,10 @@ pub(crate) fn current_timestamps() -> Result<(u32, u64)> {
184184 offset: 0,
185185 message: "System time exceeds the OneNote Time32 range",
186186 })?;
187 Ok((modified, (now + 11644473600) * 10000000))
187 Ok((
188 modified,
189 CLOCK.get().unwrap_or((now + 11644473600) * 10000000),
190 ))
188191}
189192
190193/// 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> {
477477 changes: impl FnOnce(&ActivePage<'a>) -> Result<Changes, Error>,
478478 ) -> Result<(), Failure> {
479479 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 }
481489 Ok(())
482490 }
483491
crates/onestore/src/section.rs+77-1
......@@ -15,7 +15,10 @@ use crate::{
1515 },
1616};
1717use bumpalo::Bump;
18use std::collections::{BTreeMap, BTreeSet};
18use std::{
19 collections::{BTreeMap, BTreeSet},
20 sync::Arc,
21};
1922
2023mod versions;
2124pub(crate) use versions::HISTORY;
......@@ -604,6 +607,75 @@ impl<'a> Section<'a> {
604607 Ok(true)
605608 }
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
607679 /// Creates an object space holding `changes` under `roots` until the next seal, which
608680 /// declares it. An error after validation leaves the section to be reopened.
609681 pub(crate) fn create_space(
......@@ -1058,6 +1130,10 @@ fn changed<'a>(
10581130 };
10591131 objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?));
10601132 }
1133 if file.revision.roots != page.revision.roots {
1134 file.depth = 0;
1135 file.revision.roots.clone_from(&page.revision.roots);
1136 }
10611137 Ok(Some(Revised {
10621138 commit: file.commit(objects)?,
10631139 replaced,
crates/snowbound/src/search.rs+3-1
......@@ -383,7 +383,9 @@ fn sync(
383383 .iter()
384384 .find(|page| page.space == space)
385385 .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 })
387389 };
388390 Ok(match &replica {
389391 // The open section shows its edits before they reach the file.
crates/snowbound/src/unread.rs+188-70
......@@ -12,9 +12,6 @@ use std::{
1212 sync::Arc,
1313};
1414
15/// Seconds from 1970 to 1980, where OneNote's Time32 starts.
16const TIME32: u64 = 315_532_800;
17
1815/// What has been read, by notebook location.
1916#[derive(Default, serde::Serialize, serde::Deserialize)]
2017pub struct Reads {
......@@ -41,8 +38,10 @@ struct Kept {
4138
4239#[derive(Default, serde::Serialize, serde::Deserialize)]
4340struct Section {
44 /// Time32 up to which changes to the section's pages are known.
45 read: u32,
41 /// FILETIME up to which the section was marked read.
42 read: u64,
43 #[serde(default)]
44 pages: BTreeMap<ExGuid, u64>,
4645 unread: BTreeSet<ExGuid>,
4746}
4847
......@@ -50,20 +49,29 @@ fn hex(identity: [u8; 16]) -> String {
5049 identity.iter().map(|byte| format!("{byte:02x}")).collect()
5150}
5251
53fn now() -> u32 {
52fn now() -> u64 {
5453 let unix = web_time::SystemTime::now()
5554 .duration_since(web_time::UNIX_EPOCH)
56 .map_or(0, |since| since.as_secs());
57 u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX)
55 .unwrap_or_default();
56 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100)
5857}
5958
6059impl Reads {
6160 /// What the cache folder `cache` keeps.
6261 pub fn load(cache: &Path) -> Self {
6362 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())
6564 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
6665 .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 }
6775 Self { file, ..reads }
6876 }
6977
......@@ -87,7 +95,7 @@ impl Reads {
8795 let kept = self.notebooks.entry(notebook.to_owned()).or_default();
8896 (kept.sections.entry(hex(section))).or_insert_with(|| Section {
8997 read: now(),
90 unread: BTreeSet::new(),
98 ..Section::default()
9199 })
92100 }
93101
......@@ -118,39 +126,51 @@ impl Reads {
118126 .map(|section| &section.unread)
119127 }
120128
121 /// Takes in the pages of a section another author changed, by space.
122 fn changed(
123 &mut self,
124 notebook: &str,
125 section: [u8; 16],
126 pages: impl IntoIterator<Item = ExGuid>,
127 ) {
128 self.section_mut(notebook, section).unread.extend(pages);
129 pub(crate) fn mark_read(&mut self, notebook: &str, section: [u8; 16], page: Option<ExGuid>) {
130 let kept = self.section_mut(notebook, section);
131 match page {
132 Some(page) => {
133 kept.unread.remove(&page);
134 let read = kept.pages.entry(page).or_default();
135 *read = (*read).max(now());
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 }
129149 self.save();
130150 }
131151
132 /// Takes in a section as its file stands, its pages with their `LastModifiedTime` and
152 /// Takes in a section as its file stands, its pages with their FILETIME and
133153 /// whether the reader changed them last: those another author changed since it was last
134154 /// known are unread.
135155 fn arrived(
136156 &mut self,
137157 notebook: &str,
138158 section: [u8; 16],
139 pages: impl IntoIterator<Item = (ExGuid, Option<u32>, bool)>,
159 pages: impl IntoIterator<Item = (ExGuid, Option<u64>, bool)>,
140160 ) {
141 let now = now();
142161 let kept = self.section_mut(notebook, section);
143 let read = kept.read;
144 let mut newest = read.max(now);
145162 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 };
146167 if mine {
147168 kept.unread.remove(&space);
148 } else if modified.is_some_and(|modified| modified > read) {
169 } else {
149170 kept.unread.insert(space);
150171 }
151 newest = newest.max(modified.unwrap_or(0));
172 kept.pages.insert(space, modified);
152173 }
153 kept.read = newest;
154174 self.save();
155175 }
156176}
......@@ -200,8 +220,7 @@ impl State {
200220 self.unread_sections(library).next().is_some()
201221 }
202222
203 /// Follows what is read, each frame: a notebook's sections are known from when it opens,
204 /// and the page shown turns read once left, its section known up to then.
223 /// A notebook's sections are known from when it opens; the page shown turns read once left.
205224 pub(crate) fn follow_reading(&mut self) {
206225 let taken: Vec<usize> = self
207226 .notebooks
......@@ -243,14 +262,8 @@ impl State {
243262 return;
244263 }
245264 if let Some((notebook, section, space, kept)) = self.reads.viewing.take() {
246 let left = self.reads.section_mut(&notebook, section);
247 let removed = !kept && left.unread.remove(&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();
265 if !kept {
266 self.reads.mark_read(&notebook, section, Some(space));
254267 }
255268 }
256269 self.reads.viewing =
......@@ -279,13 +292,13 @@ impl State {
279292 let pages = (replica.snapshot().ok())
280293 .and_then(|image| notebook::session::stored_pages(&image).ok())
281294 .unwrap_or_default();
282 let others: Vec<ExGuid> = (spaces.into_iter())
283 .filter(|space| {
284 !(pages.iter()).any(|page| page.space == *space && me(page.author.as_deref()))
285 })
295 let pages: Vec<_> = pages
296 .into_iter()
297 .filter(|page| spaces.contains(&page.space))
298 .map(|page| (page.space, page.modified, me(page.author.as_deref())))
286299 .collect();
287300 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);
289302 Ok(())
290303 })));
291304 });
......@@ -335,16 +348,25 @@ impl State {
335348
336349 /// Mark as Read, or as Unread, on the page shown, as OneNote 2010's Ctrl+Q does.
337350 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 {
339352 return;
340353 };
341 let pages = &mut self.reads.notebooks.entry(notebook.clone()).or_default();
342 let unread = &mut (pages.sections.entry(hex(*section)).or_default()).unread;
343 *kept = unread.insert(*space);
344 if !*kept {
345 unread.remove(space);
354 if self
355 .reads
356 .pages(&notebook, section)
357 .is_some_and(|pages| pages.contains(&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();
346369 }
347 self.reads.save();
348370 }
349371
350372 /// Whether the page shown is unread.
......@@ -362,10 +384,14 @@ impl State {
362384 /// Mark Notebook as Read.
363385 pub(crate) fn mark_notebook_read(&mut self, library: &Library) {
364386 if let Some(kept) = self.reads.notebooks.get_mut(&library.location) {
365 kept.sections
366 .values_mut()
367 .for_each(|section| section.unread.clear());
368 if let Some((.., kept)) = &mut self.reads.viewing {
387 let now = now();
388 for section in kept.sections.values_mut() {
389 section.unread.clear();
390 section.read = section.read.max(now);
391 }
392 if let Some((viewing, .., kept)) = &mut self.reads.viewing
393 && viewing == &library.location
394 {
369395 *kept = false;
370396 }
371397 self.reads.save();
......@@ -427,7 +453,7 @@ impl State {
427453mod tests {
428454 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) {
431457 (
432458 ExGuid {
433459 guid: [n as u8; 16],
......@@ -458,28 +484,14 @@ mod tests {
458484 .collect()
459485 };
460486 assert_eq!(unread(&reads), [2]);
461 reads.changed(
462 notebook,
463 section,
464 [ExGuid {
465 guid: [4; 16],
466 n: 4,
467 }],
468 );
487 reads.arrived(notebook, section, [page(4, Some(known + 6))]);
469488 let mut reads = Reads::load(&folder);
470489 assert_eq!(unread(&reads), [2, 4]);
471490 // 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);
473492 reads.arrived(notebook, section, [page(2, Some(known + 5))]);
474493 assert!(unread(&reads).is_empty());
475 reads.changed(
476 notebook,
477 section,
478 [ExGuid {
479 guid: [4; 16],
480 n: 4,
481 }],
482 );
494 reads.arrived(notebook, section, [page(4, Some(known + 7))]);
483495 reads.notebooks.get_mut(notebook).unwrap().hidden = true;
484496 assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook));
485497 notebook::fs::remove_dir_all(&folder).unwrap();
......@@ -514,4 +526,110 @@ mod tests {
514526 assert!(unread(&reads).is_empty());
515527 notebook::fs::remove_dir_all(&folder).unwrap();
516528 }
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 }
517635}