From 773ac767abacd8ff5c3f32991045fca1cd000278 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 10 Oct 2026 19:08:04 -0700 Subject: [PATCH] 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 --- crates/mobile/src/library.rs | 4 +- crates/notebook/src/session.rs | 36 ++++- crates/notebook/tests/session.rs | 77 +++++++++ crates/onestore/src/active.rs | 1 + crates/onestore/src/create.rs | 5 +- crates/onestore/src/op/apply.rs | 10 +- crates/onestore/src/section.rs | 78 +++++++++- crates/snowbound/src/search.rs | 4 +- crates/snowbound/src/unread.rs | 258 ++++++++++++++++++++++--------- 9 files changed, 391 insertions(+), 82 deletions(-) diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index b65081e000a703e57360d5f71fe4c79f038bc248..55bb06d08e32e2ef30a0d3310eb1795b541ec807 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -1443,7 +1443,9 @@ impl Library { let pages = session::stored_pages(&read()?)?; index.retain(|entry| entry.section != path); for stored in pages { - let modified = stored.modified.map_or(0, u64::from); + let modified = stored.modified.map_or(0, |filetime| { + (filetime / 10_000_000).saturating_sub(11_960_006_400) + }); index.set(Entry::new(path, stored.space, &stored.page, modified)); } } diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 9bfc62d2bf3d0cb9dbf18626745f85fd08c2b7e5..4b11e2b5c55289727a8916f4b8ef6db7113bc6fa 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -1127,8 +1127,11 @@ impl Notebook { /// Returns how many pages and sections went. pub fn purge_recycle_bin(&mut self, now: u32) -> Result { const DELETED: &str = "OneNote_DeletedPages.one"; - let expired = |modified: Option| { - modified.is_some_and(|modified| now.saturating_sub(modified) > RECYCLE_DAYS * 86_400) + let now = (u64::from(now) + 11_960_006_400) * 10_000_000; + let expired = |modified: Option| { + modified.is_some_and(|modified| { + now.saturating_sub(modified) > u64::from(RECYCLE_DAYS) * 86_400 * 10_000_000 + }) }; let Ok(bin) = self.folder(RECYCLE_BIN) else { return Ok(0); @@ -1732,9 +1735,9 @@ impl Entry { pub struct StoredPage { pub space: ExGuid, pub page: Page, - /// The page's `LastModifiedTime`, Time32 seconds since 1980. - pub modified: Option, - /// Who changed it last: the `AuthorMostRecent` of its latest modified object. + /// The revision metadata's timestamp, or the page's `LastModifiedTime`, as FILETIME. + pub modified: Option, + /// Who changed it last: revision metadata, or the latest modified object in older files. pub author: Option, } @@ -1763,7 +1766,13 @@ fn pages_of(document: &Document<'_>) -> Result> { let latest = (revision.nodes.values()) .filter(|node| node.latest_author.is_some()) .max_by_key(|node| node.modified); - let author = latest + let metadata = revision.roots.get(&4).and_then(|id| revision.nodes.get(id)); + let author = metadata + .filter(|node| { + matches!(node.kind, onestore::document::Kind::RevisionMetadata { .. }) + }) + .filter(|node| node.latest_author.is_some()) + .or(latest) .and_then(|node| revision.nodes.get(&node.latest_author?)) .and_then(|node| match &node.kind { onestore::document::Kind::Author { name } => name.clone(), @@ -1772,7 +1781,20 @@ fn pages_of(document: &Document<'_>) -> Result> { Some(StoredPage { space, page: Page::from_revision(revision, id).ok()?, - modified: revision.nodes.get(&id).and_then(|node| node.modified), + modified: metadata + .and_then(|node| match node.kind { + onestore::document::Kind::RevisionMetadata { modified_filetime } => { + modified_filetime + } + _ => None, + }) + .or_else(|| { + revision + .nodes + .get(&id) + .and_then(|node| node.modified) + .map(|seconds| (u64::from(seconds) + 11_960_006_400) * 10_000_000) + }), author, }) }) diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 2ab09726b442a1a266b5d85b27a48e8e1ece5450..3121ab9040f5d503682870089146a383282b345d 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -1559,3 +1559,80 @@ fn a_stored_page_names_who_changed_it_last() { let edited = ¬ebook::session::stored_pages(§ion.image()).unwrap()[0]; assert_eq!(edited.author.as_deref(), Some("Bob")); } + +#[test] +fn text_edits_name_the_latest_author_even_within_the_same_second() { + let image = onestore::create_section("Authors.one", "Body", "Alice").unwrap(); + let first = notebook::session::stored_pages(&image).unwrap().remove(0); + let at = first.modified.unwrap() + 12345; + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image).unwrap(); + for (author, at) in [("Bob", at), ("Alice", at + 1), ("Bob", at + 20_000_000)] { + let before = section.image(); + let store = onestore::Store::parse(&before).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + let revisions = index.spaces[&first.space].revisions.len(); + let edit = Edit { + at, + ops: vec![Op::Page { + space: first.space, + op: PageOp::Text { + text: first_text(&first.page), + range: 0..0, + with: author.into(), + }, + }], + }; + section.apply(author, &edit).unwrap(); + section.seal().unwrap().unwrap(); + let image = section.image(); + let page = notebook::session::stored_pages(&image).unwrap().remove(0); + assert_eq!(page.author.as_deref(), Some(author)); + assert_eq!(page.modified, Some(at)); + let store = onestore::Store::parse(&image).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + assert_eq!(index.spaces[&first.space].revisions.len(), revisions + 1); + let document = onestore::document::Document::parse(&index).unwrap(); + let revision = document.active(first.space).unwrap(); + let metadata = &revision.nodes[&revision.roots[&4]]; + assert!( + matches!(metadata.kind, onestore::document::Kind::RevisionMetadata { modified_filetime: Some(stored) } if stored == at) + ); + let paragraph = revision + .nodes + .values() + .find(|node| node.content.contains(&first_text(&first.page))) + .unwrap(); + let name = |id: Option| match &revision.nodes[&id.unwrap()].kind { + onestore::document::Kind::Author { name } => name.clone().unwrap(), + _ => panic!("author reference names another kind of object"), + }; + assert_eq!(name(paragraph.original_author), "Alice"); + assert_eq!(name(paragraph.latest_author), author); + } + section + .apply( + "Nobody", + &Edit { + at: at + 30_000_000, + ops: vec![Op::Page { + space: first.space, + op: PageOp::Text { + text: first_text(&first.page), + range: 0..0, + with: String::new(), + }, + }], + }, + ) + .unwrap(); + assert!(section.seal().unwrap().is_none()); + if let Some(folder) = std::env::var_os("SNOWBOUND_AUTHOR_EXPORT") { + std::fs::create_dir(&folder).unwrap(); + std::fs::write( + std::path::Path::new(&folder).join("Authors.one"), + section.image(), + ) + .unwrap(); + } +} diff --git a/crates/onestore/src/active.rs b/crates/onestore/src/active.rs index 4413eedcf249dab2661cc25e2adb01c9b67ebc7c..7dc372abe002e6034a37cbe07dc4140d076fd8b4 100644 --- a/crates/onestore/src/active.rs +++ b/crates/onestore/src/active.rs @@ -233,6 +233,7 @@ impl<'a> ActivePage<'a> { /// 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<()> { + self.view.roots.clone_from(&self.live.revision.roots); let mut attach = Vec::new(); let mut detach = Vec::new(); let mut gone = BTreeMap::new(); diff --git a/crates/onestore/src/create.rs b/crates/onestore/src/create.rs index bd7201ec69fcea5936704ddf5c8bd7ff0163691b..6df842542b203b9ff26fadd43dc5ff8ed51497db 100644 --- a/crates/onestore/src/create.rs +++ b/crates/onestore/src/create.rs @@ -184,7 +184,10 @@ pub(crate) fn current_timestamps() -> Result<(u32, u64)> { offset: 0, message: "System time exceeds the OneNote Time32 range", })?; - Ok((modified, (now + 11644473600) * 10000000)) + Ok(( + modified, + CLOCK.get().unwrap_or((now + 11644473600) * 10000000), + )) } /// Creates a section with one page and one plain-text paragraph, without a template. diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs index 78584962b7cabc2f02ee6431996e6e1c63af6c12..c90082fe5f150499396452c0327cab30547f1292 100644 --- a/crates/onestore/src/op/apply.rs +++ b/crates/onestore/src/op/apply.rs @@ -477,7 +477,15 @@ impl<'a> Writer<'_, 'a> { changes: impl FnOnce(&ActivePage<'a>) -> Result, ) -> Result<(), Failure> { let changes = changes(self.page()?)?; - self.section.apply_changes(self.space, payloads, changes)?; + let paragraphs = changes + .iter() + .filter(|(_, object)| object.jcid == 0x6000d) + .map(|(id, _)| *id) + .collect(); + if self.section.apply_changes(self.space, payloads, changes)? { + self.section + .attribute(self.space, self.author, paragraphs)?; + } Ok(()) } diff --git a/crates/onestore/src/section.rs b/crates/onestore/src/section.rs index dfa489a32ab926a48810a488da8dfa4d41fe4f8b..b06485f4a1f924df40dad8058b50c01748779515 100644 --- a/crates/onestore/src/section.rs +++ b/crates/onestore/src/section.rs @@ -15,7 +15,10 @@ use crate::{ }, }; use bumpalo::Bump; -use std::collections::{BTreeMap, BTreeSet}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; mod versions; pub(crate) use versions::HISTORY; @@ -604,6 +607,75 @@ impl<'a> Section<'a> { Ok(true) } + pub(crate) fn attribute( + &mut self, + space: ExGuid, + author: &str, + paragraphs: Vec, + ) -> Result<()> { + self.broken = true; + let guid = crate::write::fresh_guid()?; + let named = |n| ExGuid { guid, n }; + let page = self.active(space)?; + let revision = &page.live.revision; + let author_id = page.view.nodes.iter().find_map(|(id, node)| { + matches!(&node.kind, crate::document::Kind::Author { name } if name.as_deref() == Some(author)) + .then_some(*id) + }).unwrap_or(named(2)); + let mut changes = Changes::new(); + if author_id == named(2) { + changes.insert( + author_id, + PropertyObject { + jcid: 0x120001, + bytes: crate::create::properties(&crate::create::author_properties(author))?, + global_ids: Arc::new(BTreeMap::from([(0, guid)])), + }, + ); + } + let metadata_id = revision.roots.get(&4).copied().unwrap_or(named(1)); + let mut metadata = match revision.objects.get(&metadata_id) { + Some(object) if object.jcid == 0x20044 => PropertyObject::from_object(object)?, + Some(_) => { + return Err(Error { + offset: 0, + message: "The page's revision metadata is unavailable", + }); + } + None => PropertyObject { + jcid: 0x20044, + bytes: crate::create::properties(&[])?, + global_ids: Arc::new(BTreeMap::from([(0, guid)])), + }, + }; + let (modified, filetime) = crate::create::current_timestamps()?; + let reference = metadata.reference(author_id)?; + metadata.set(&[ + (0x18001d77, &filetime.to_le_bytes()), + (0x20001d79, &reference), + ])?; + changes.insert(metadata_id, metadata); + for id in paragraphs { + let mut paragraph = PropertyObject::from_object(&revision.objects[&id])?; + let reference = paragraph.reference(author_id)?; + paragraph.set(&[ + (0x14001d7a, &modified.to_le_bytes()), + (0x20001d79, &reference), + ])?; + changes.insert(id, paragraph); + } + self.remember(space); + self.editable(space)? + .page + .live + .revision + .roots + .insert(4, metadata_id); + self.broken = false; + self.apply_changes(space, &[], changes)?; + Ok(()) + } + /// Creates an object space holding `changes` under `roots` until the next seal, which /// declares it. An error after validation leaves the section to be reopened. pub(crate) fn create_space( @@ -1058,6 +1130,10 @@ fn changed<'a>( }; objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?)); } + if file.revision.roots != page.revision.roots { + file.depth = 0; + file.revision.roots.clone_from(&page.revision.roots); + } Ok(Some(Revised { commit: file.commit(objects)?, replaced, diff --git a/crates/snowbound/src/search.rs b/crates/snowbound/src/search.rs index d8c1bd5ebde5b1e8a098860d05240113b019c078..0c46cf366b5fc24657454dcec286ee36420cf43e 100644 --- a/crates/snowbound/src/search.rs +++ b/crates/snowbound/src/search.rs @@ -383,7 +383,9 @@ fn sync( .iter() .find(|page| page.space == space) .and_then(|page| page.modified) - .map_or_else(now, u64::from) + .map_or_else(now, |filetime| { + (filetime / 10_000_000).saturating_sub(11_960_006_400) + }) }; Ok(match &replica { // The open section shows its edits before they reach the file. diff --git a/crates/snowbound/src/unread.rs b/crates/snowbound/src/unread.rs index 2e605a8a4e63e4cecb007d78a415968db932d200..8d883f7b41b0e1b07dca74d0f1ba214c76203f4c 100644 --- a/crates/snowbound/src/unread.rs +++ b/crates/snowbound/src/unread.rs @@ -12,9 +12,6 @@ use std::{ sync::Arc, }; -/// Seconds from 1970 to 1980, where OneNote's Time32 starts. -const TIME32: u64 = 315_532_800; - /// What has been read, by notebook location. #[derive(Default, serde::Serialize, serde::Deserialize)] pub struct Reads { @@ -41,8 +38,10 @@ struct Kept { #[derive(Default, serde::Serialize, serde::Deserialize)] struct Section { - /// Time32 up to which changes to the section's pages are known. - read: u32, + /// FILETIME up to which the section was marked read. + read: u64, + #[serde(default)] + pages: BTreeMap, unread: BTreeSet, } @@ -50,20 +49,29 @@ fn hex(identity: [u8; 16]) -> String { identity.iter().map(|byte| format!("{byte:02x}")).collect() } -fn now() -> u32 { +fn now() -> u64 { let unix = web_time::SystemTime::now() .duration_since(web_time::UNIX_EPOCH) - .map_or(0, |since| since.as_secs()); - u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX) + .unwrap_or_default(); + (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) } impl Reads { /// What the cache folder `cache` keeps. pub fn load(cache: &Path) -> Self { let file = cache.join("read.json"); - let reads: Self = (notebook::fs::read(&file).ok()) + let mut reads: Self = (notebook::fs::read(&file).ok()) .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default(); + for section in reads + .notebooks + .values_mut() + .flat_map(|kept| kept.sections.values_mut()) + { + if section.read <= u64::from(u32::MAX) { + section.read = (section.read + 11_960_006_400) * 10_000_000; + } + } Self { file, ..reads } } @@ -87,7 +95,7 @@ impl Reads { let kept = self.notebooks.entry(notebook.to_owned()).or_default(); (kept.sections.entry(hex(section))).or_insert_with(|| Section { read: now(), - unread: BTreeSet::new(), + ..Section::default() }) } @@ -118,39 +126,51 @@ impl Reads { .map(|section| §ion.unread) } - /// Takes in the pages of a section another author changed, by space. - fn changed( - &mut self, - notebook: &str, - section: [u8; 16], - pages: impl IntoIterator, - ) { - self.section_mut(notebook, section).unread.extend(pages); + pub(crate) fn mark_read(&mut self, notebook: &str, section: [u8; 16], page: Option) { + let kept = self.section_mut(notebook, section); + match page { + Some(page) => { + kept.unread.remove(&page); + let read = kept.pages.entry(page).or_default(); + *read = (*read).max(now()); + } + None => { + kept.unread.clear(); + kept.read = kept.read.max(now()); + } + } + if let Some((viewing, viewed_section, viewed_page, keep)) = &mut self.viewing + && viewing == notebook + && *viewed_section == section + && page.is_none_or(|page| page == *viewed_page) + { + *keep = false; + } self.save(); } - /// Takes in a section as its file stands, its pages with their `LastModifiedTime` and + /// 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. fn arrived( &mut self, notebook: &str, section: [u8; 16], - pages: impl IntoIterator, bool)>, + pages: impl IntoIterator, bool)>, ) { - let now = now(); let kept = self.section_mut(notebook, section); - let read = kept.read; - let mut newest = read.max(now); for (space, modified, mine) in pages { + let read = kept.read.max(kept.pages.get(&space).copied().unwrap_or(0)); + let Some(modified) = modified.filter(|modified| *modified > read) else { + continue; + }; if mine { kept.unread.remove(&space); - } else if modified.is_some_and(|modified| modified > read) { + } else { kept.unread.insert(space); } - newest = newest.max(modified.unwrap_or(0)); + kept.pages.insert(space, modified); } - kept.read = newest; self.save(); } } @@ -200,8 +220,7 @@ impl State { self.unread_sections(library).next().is_some() } - /// Follows what is read, each frame: a notebook's sections are known from when it opens, - /// and the page shown turns read once left, its section known up to then. + /// A notebook's sections are known from when it opens; the page shown turns read once left. pub(crate) fn follow_reading(&mut self) { let taken: Vec = self .notebooks @@ -243,14 +262,8 @@ impl State { return; } if let Some((notebook, section, space, kept)) = self.reads.viewing.take() { - let left = self.reads.section_mut(¬ebook, section); - let removed = !kept && left.unread.remove(&space); - let moved = viewing.as_ref().is_none_or(|(_, now, _)| *now != section); - if moved { - left.read = left.read.max(now()); - } - if removed || moved { - self.reads.save(); + if !kept { + self.reads.mark_read(¬ebook, section, Some(space)); } } self.reads.viewing = @@ -279,13 +292,13 @@ impl State { let pages = (replica.snapshot().ok()) .and_then(|image| notebook::session::stored_pages(&image).ok()) .unwrap_or_default(); - let others: Vec = (spaces.into_iter()) - .filter(|space| { - !(pages.iter()).any(|page| page.space == *space && me(page.author.as_deref())) - }) + let pages: Vec<_> = pages + .into_iter() + .filter(|page| spaces.contains(&page.space)) + .map(|page| (page.space, page.modified, me(page.author.as_deref()))) .collect(); let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { - state.reads.changed(&location, section, others); + state.reads.arrived(&location, section, pages); Ok(()) }))); }); @@ -335,16 +348,25 @@ 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, kept)) = self.reads.viewing.as_mut() else { + let Some((notebook, section, space, _)) = self.reads.viewing.clone() else { return; }; - let pages = &mut self.reads.notebooks.entry(notebook.clone()).or_default(); - let unread = &mut (pages.sections.entry(hex(*section)).or_default()).unread; - *kept = unread.insert(*space); - if !*kept { - unread.remove(space); + 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(); } - self.reads.save(); } /// Whether the page shown is unread. @@ -362,10 +384,14 @@ impl State { /// Mark Notebook as Read. pub(crate) fn mark_notebook_read(&mut self, library: &Library) { if let Some(kept) = self.reads.notebooks.get_mut(&library.location) { - kept.sections - .values_mut() - .for_each(|section| section.unread.clear()); - if let Some((.., kept)) = &mut self.reads.viewing { + let now = now(); + for section in kept.sections.values_mut() { + section.unread.clear(); + section.read = section.read.max(now); + } + if let Some((viewing, .., kept)) = &mut self.reads.viewing + && viewing == &library.location + { *kept = false; } self.reads.save(); @@ -427,7 +453,7 @@ impl State { mod tests { use super::*; - fn page(n: u32, modified: Option) -> (ExGuid, Option, bool) { + fn page(n: u32, modified: Option) -> (ExGuid, Option, bool) { ( ExGuid { guid: [n as u8; 16], @@ -458,28 +484,14 @@ mod tests { .collect() }; assert_eq!(unread(&reads), [2]); - reads.changed( - notebook, - section, - [ExGuid { - guid: [4; 16], - n: 4, - }], - ); + reads.arrived(notebook, section, [page(4, Some(known + 6))]); let mut reads = Reads::load(&folder); assert_eq!(unread(&reads), [2, 4]); // Known up to the newest change: the same page arriving again is no news. - reads.section_mut(notebook, section).unread.clear(); + reads.mark_read(notebook, section, None); reads.arrived(notebook, section, [page(2, Some(known + 5))]); assert!(unread(&reads).is_empty()); - reads.changed( - notebook, - section, - [ExGuid { - guid: [4; 16], - n: 4, - }], - ); + reads.arrived(notebook, section, [page(4, Some(known + 7))]); reads.notebooks.get_mut(notebook).unwrap().hidden = true; assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook)); notebook::fs::remove_dir_all(&folder).unwrap(); @@ -514,4 +526,110 @@ mod tests { assert!(unread(&reads).is_empty()); notebook::fs::remove_dir_all(&folder).unwrap(); } + + #[test] + fn marking_read_targets_only_the_clicked_page_or_section_and_rejects_stale_changes() { + let folder = + std::env::temp_dir().join(format!("snowbound-unread-target-{}", std::process::id())); + notebook::fs::create_dir_all(&folder).unwrap(); + let (notebook, section, other) = ("/local-notebook", [3; 16], [4; 16]); + let mut reads = Reads::load(&folder); + let at = (now() / 10_000_000 - 10) * 10_000_000; + reads.section_mut(notebook, section).read = at; + reads.section_mut(notebook, other).read = at; + reads.arrived( + notebook, + section, + [page(1, Some(at + 1)), page(2, Some(at + 1))], + ); + reads.arrived(notebook, other, [page(3, Some(at + 1))]); + let first = page(1, None).0; + let second = page(2, None).0; + reads.viewing = Some((notebook.into(), section, second, true)); + reads.mark_read(notebook, section, Some(first)); + reads.mark_read(notebook, section, Some(first)); + assert_eq!( + reads.pages(notebook, section).unwrap(), + &BTreeSet::from([second]) + ); + assert!(reads.viewing.as_ref().unwrap().3); + reads.arrived(notebook, section, [page(1, Some(at + 1))]); + assert!(!reads.pages(notebook, section).unwrap().contains(&first)); + reads.mark_read(notebook, section, None); + assert!(!reads.viewing.as_ref().unwrap().3); + assert!(reads.pages(notebook, section).unwrap().is_empty()); + assert!( + reads + .pages(notebook, other) + .unwrap() + .contains(&page(3, None).0) + ); + let mut reads = Reads::load(&folder); + reads.arrived(notebook, section, [page(1, Some(at + 1))]); + assert!(reads.pages(notebook, section).unwrap().is_empty()); + let after = reads.section_mut(notebook, section).read + 1; + reads.arrived(notebook, section, [page(1, Some(after))]); + assert!(reads.pages(notebook, section).unwrap().contains(&first)); + notebook::fs::remove_dir_all(&folder).unwrap(); + } + + #[test] + fn delayed_other_pages_and_same_second_external_changes_remain_unread() { + let folder = + std::env::temp_dir().join(format!("snowbound-unread-order-{}", 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() / 10_000_000 - 10) * 10_000_000; + reads.section_mut(notebook, section).read = at; + reads.arrived(notebook, section, [page(1, Some(at + 1))]); + let first = page(1, None).0; + reads.mark_read(notebook, section, Some(first)); + reads.arrived(notebook, section, [page(2, Some(at + 2))]); + assert!( + reads + .pages(notebook, section) + .unwrap() + .contains(&page(2, None).0) + ); + let marked = reads.section_mut(notebook, section).pages[&first]; + reads.arrived(notebook, section, [page(1, Some(marked + 1))]); + assert!(reads.pages(notebook, section).unwrap().contains(&first)); + reads.arrived(notebook, section, [(first, Some(at + 1), true)]); + assert!(reads.pages(notebook, section).unwrap().contains(&first)); + let second = page(2, None).0; + reads.arrived(notebook, section, [(second, Some(at + 3), true)]); + assert!(!reads.pages(notebook, section).unwrap().contains(&second)); + reads.arrived(notebook, section, [page(2, Some(at + 4))]); + assert!(reads.pages(notebook, section).unwrap().contains(&second)); + let reads = Reads::load(&folder); + assert_eq!(reads.pages(notebook, section).unwrap().len(), 2); + notebook::fs::remove_dir_all(&folder).unwrap(); + } + + #[test] + fn legacy_time32_read_watermarks_load_without_losing_unread_pages() { + let folder = + std::env::temp_dir().join(format!("snowbound-unread-legacy-{}", std::process::id())); + notebook::fs::create_dir_all(&folder).unwrap(); + let (notebook, section) = ("/local-notebook", [3; 16]); + let first = page(1, None).0; + notebook::fs::write( + folder.join("read.json"), + serde_json::to_vec(&serde_json::json!({ + "notebooks": { notebook: { "sections": { hex(section): { + "read": 1_450_000_000_u32, "unread": [first] + } } } } + })) + .unwrap(), + ) + .unwrap(); + let reads = Reads::load(&folder); + assert_eq!( + reads.section(notebook, section).unwrap().read, + (1_450_000_000 + 11_960_006_400) * 10_000_000 + ); + assert!(reads.pages(notebook, section).unwrap().contains(&first)); + notebook::fs::remove_dir_all(&folder).unwrap(); + } } -- 2.54.0