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(); + } }