| ... | ... | @@ -12,9 +12,6 @@ use std::{ |
| 12 | 12 | sync::Arc, |
| 13 | 13 | }; |
| 14 | 14 | |
| 15 | | /// Seconds from 1970 to 1980, where OneNote's Time32 starts. |
| 16 | | const TIME32: u64 = 315_532_800; |
| 17 | | |
| 18 | 15 | /// What has been read, by notebook location. |
| 19 | 16 | #[derive(Default, serde::Serialize, serde::Deserialize)] |
| 20 | 17 | pub struct Reads { |
| ... | ... | @@ -41,8 +38,10 @@ struct Kept { |
| 41 | 38 | |
| 42 | 39 | #[derive(Default, serde::Serialize, serde::Deserialize)] |
| 43 | 40 | struct 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>, |
| 46 | 45 | unread: BTreeSet<ExGuid>, |
| 47 | 46 | } |
| 48 | 47 | |
| ... | ... | @@ -50,20 +49,29 @@ fn hex(identity: [u8; 16]) -> String { |
| 50 | 49 | identity.iter().map(|byte| format!("{byte:02x}")).collect() |
| 51 | 50 | } |
| 52 | 51 | |
| 53 | | fn now() -> u32 { |
| 52 | fn now() -> u64 { |
| 54 | 53 | let unix = web_time::SystemTime::now() |
| 55 | 54 | .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) |
| 58 | 57 | } |
| 59 | 58 | |
| 60 | 59 | impl Reads { |
| 61 | 60 | /// What the cache folder `cache` keeps. |
| 62 | 61 | pub fn load(cache: &Path) -> Self { |
| 63 | 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 | 64 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) |
| 66 | 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 | 75 | Self { file, ..reads } |
| 68 | 76 | } |
| 69 | 77 | |
| ... | ... | @@ -87,7 +95,7 @@ impl Reads { |
| 87 | 95 | let kept = self.notebooks.entry(notebook.to_owned()).or_default(); |
| 88 | 96 | (kept.sections.entry(hex(section))).or_insert_with(|| Section { |
| 89 | 97 | read: now(), |
| 90 | | unread: BTreeSet::new(), |
| 98 | ..Section::default() |
| 91 | 99 | }) |
| 92 | 100 | } |
| 93 | 101 | |
| ... | ... | @@ -118,39 +126,51 @@ impl Reads { |
| 118 | 126 | .map(|section| &section.unread) |
| 119 | 127 | } |
| 120 | 128 | |
| 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 | } |
| 129 | 149 | self.save(); |
| 130 | 150 | } |
| 131 | 151 | |
| 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 |
| 133 | 153 | /// whether the reader changed them last: those another author changed since it was last |
| 134 | 154 | /// known are unread. |
| 135 | 155 | fn arrived( |
| 136 | 156 | &mut self, |
| 137 | 157 | notebook: &str, |
| 138 | 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 | 161 | let kept = self.section_mut(notebook, section); |
| 143 | | let read = kept.read; |
| 144 | | let mut newest = read.max(now); |
| 145 | 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 | 167 | if mine { |
| 147 | 168 | kept.unread.remove(&space); |
| 148 | | } else if modified.is_some_and(|modified| modified > read) { |
| 169 | } else { |
| 149 | 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 | 174 | self.save(); |
| 155 | 175 | } |
| 156 | 176 | } |
| ... | ... | @@ -200,8 +220,7 @@ impl State { |
| 200 | 220 | self.unread_sections(library).next().is_some() |
| 201 | 221 | } |
| 202 | 222 | |
| 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. |
| 205 | 224 | pub(crate) fn follow_reading(&mut self) { |
| 206 | 225 | let taken: Vec<usize> = self |
| 207 | 226 | .notebooks |
| ... | ... | @@ -243,14 +262,8 @@ impl State { |
| 243 | 262 | return; |
| 244 | 263 | } |
| 245 | 264 | 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)); |
| 254 | 267 | } |
| 255 | 268 | } |
| 256 | 269 | self.reads.viewing = |
| ... | ... | @@ -279,13 +292,13 @@ impl State { |
| 279 | 292 | let pages = (replica.snapshot().ok()) |
| 280 | 293 | .and_then(|image| notebook::session::stored_pages(&image).ok()) |
| 281 | 294 | .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()))) |
| 286 | 299 | .collect(); |
| 287 | 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 | 302 | Ok(()) |
| 290 | 303 | }))); |
| 291 | 304 | }); |
| ... | ... | @@ -335,16 +348,25 @@ impl State { |
| 335 | 348 | |
| 336 | 349 | /// Mark as Read, or as Unread, on the page shown, as OneNote 2010's Ctrl+Q does. |
| 337 | 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 | 352 | return; |
| 340 | 353 | }; |
| 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(); |
| 346 | 369 | } |
| 347 | | self.reads.save(); |
| 348 | 370 | } |
| 349 | 371 | |
| 350 | 372 | /// Whether the page shown is unread. |
| ... | ... | @@ -362,10 +384,14 @@ impl State { |
| 362 | 384 | /// Mark Notebook as Read. |
| 363 | 385 | pub(crate) fn mark_notebook_read(&mut self, library: &Library) { |
| 364 | 386 | 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 | { |
| 369 | 395 | *kept = false; |
| 370 | 396 | } |
| 371 | 397 | self.reads.save(); |
| ... | ... | @@ -427,7 +453,7 @@ impl State { |
| 427 | 453 | mod tests { |
| 428 | 454 | use super::*; |
| 429 | 455 | |
| 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 | 458 | ExGuid { |
| 433 | 459 | guid: [n as u8; 16], |
| ... | ... | @@ -458,28 +484,14 @@ mod tests { |
| 458 | 484 | .collect() |
| 459 | 485 | }; |
| 460 | 486 | 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))]); |
| 469 | 488 | let mut reads = Reads::load(&folder); |
| 470 | 489 | assert_eq!(unread(&reads), [2, 4]); |
| 471 | 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 | 492 | reads.arrived(notebook, section, [page(2, Some(known + 5))]); |
| 474 | 493 | 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))]); |
| 483 | 495 | reads.notebooks.get_mut(notebook).unwrap().hidden = true; |
| 484 | 496 | assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook)); |
| 485 | 497 | notebook::fs::remove_dir_all(&folder).unwrap(); |
| ... | ... | @@ -514,4 +526,110 @@ mod tests { |
| 514 | 526 | assert!(unread(&reads).is_empty()); |
| 515 | 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 | } |