authorclover caruso <git@paperclover.net> 2026-10-12 03:35:54-07:00
committerclover caruso <git@paperclover.net> 2026-10-12 03:36:16-07:00
log24b46353a9b3963f2093a92d493d7cb5229416db
tree3bca745a395be79e478cd81c310fd5277ae01bcc
parent6eb07a6251a76619f3c454214d98076d8951273e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(mobile): require notebook backups before page deletion

A cached SMB section can reopen while its notebook catalog is unavailable. Classify the stored notebook location so backup failure returns before source Delete is submitted. Cover original cached page and queued edits, canonical notebook recycling, and standalone last-page replacement. Refs #133 Assisted-by: gpt-6.1-sol

2 files changed, 140 insertions(+), 9 deletions(-)

crates/mobile/src/library.rs+95-7
...@@ -278,7 +278,7 @@ struct Checked {...@@ -278,7 +278,7 @@ struct Checked {
278}278}
279279
280pub struct Library {280pub struct Library {
281 /// `None` for a lone section.281 /// A share's catalog may be absent while cached sections remain available.
282 notebook: Mutex<Option<Notebook>>,282 notebook: Mutex<Option<Notebook>>,
283 place: Place,283 place: Place,
284 cache: PathBuf,284 cache: PathBuf,
...@@ -1020,14 +1020,14 @@ impl Section {...@@ -1020,14 +1020,14 @@ impl Section {
1020 Ok(space)1020 Ok(space)
1021 }1021 }
10221022
1023 /// Deletes a page as OneNote 2010 does, keeping a copy in the notebook's recycle bin;1023 /// Recycles an ordinary notebook page before submitting its deletion; a lone section's
1024 /// a conflict page goes for good. A section left empty gets a new page.1024 /// page or a conflict page goes for good. A section left empty gets a new page.
1025 pub(crate) fn delete_page(&self, space: ExGuid, date: &str, time: &str) -> Result<()> {1025 pub(crate) fn delete_page(&self, space: ExGuid, date: &str, time: &str) -> Result<()> {
1026 let section = &self.shared.section;1026 let section = &self.shared.section;
1027 let listed = section.pages()?;1027 let listed = section.pages()?;
1028 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];1028 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];
1029 if listed.iter().any(|(page, ..)| *page == space) {1029 if listed.iter().any(|(page, ..)| *page == space) {
1030 if self.library.notebook().is_some() {1030 if !matches!(self.library.place, Place::File(_)) {
1031 let page = section.page(space)?;1031 let page = section.page(space)?;
1032 let author = self.shared.author();1032 let author = self.shared.author();
1033 self.library.with_notebook(true, |notebook| {1033 self.library.with_notebook(true, |notebook| {
...@@ -2351,9 +2351,9 @@ pub unsafe extern "C" fn sb_section_set_theme(...@@ -2351,9 +2351,9 @@ pub unsafe extern "C" fn sb_section_set_theme(
2351 report(set).is_some()2351 report(set).is_some()
2352}2352}
23532353
2354/// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section2354/// Recycles notebook page `id` before submitting its deletion; a lone section's page or a
2355/// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it2355/// conflict page goes for good. A section left empty gets one titled `date` and `time`.
2356/// off the main thread.2356/// Writes to the notebook, so call it off the main thread.
2357///2357///
2358/// # Safety2358/// # Safety
2359/// Every string is NUL-terminated UTF-8.2359/// Every string is NUL-terminated UTF-8.
...@@ -2437,3 +2437,91 @@ pub extern "C" fn sb_section_wake(section: &Section) {...@@ -2437,3 +2437,91 @@ pub extern "C" fn sb_section_wake(section: &Section) {
2437pub unsafe extern "C" fn sb_section_free(section: *mut Section) {2437pub unsafe extern "C" fn sb_section_free(section: *mut Section) {
2438 drop(unsafe { Box::from_raw(section) });2438 drop(unsafe { Box::from_raw(section) });
2439}2439}
2440
2441#[cfg(test)]
2442mod tests {
2443 use super::*;
2444
2445 #[test]
2446 fn deleting_a_cold_offline_share_page_preserves_its_source_and_queue() {
2447 let directory = tempfile::tempdir().unwrap();
2448 let root = directory.path().join("notebook");
2449 let cache = directory.path().join("cache");
2450 std::fs::create_dir(&root).unwrap();
2451 let bytes =
2452 onestore::create_section("Read.one", "Offline source content", "Fixture").unwrap();
2453 std::fs::write(root.join("Read.one"), &bytes).unwrap();
2454 let notebook = Notebook::open(&root, &cache).unwrap();
2455 let mut listed = Listed {
2456 tabs: Vec::new(),
2457 files: BTreeMap::new(),
2458 };
2459 tabs(notebook.catalog(), &HashMap::new(), &mut listed.tabs);
2460 files(notebook.catalog(), &mut listed.files);
2461 let identity = listed.files["Read.one"].clone();
2462 drop(notebook);
2463
2464 let address = "127.0.0.1:0";
2465 let location = notebook::location::smb(address, "agent", "Notebook");
2466 let replicas = notebook::location::folder(&cache, &location);
2467 std::fs::create_dir_all(&replicas).unwrap();
2468 let original = replicas.join(format!("{identity}.sqlite"));
2469 let replica = Replica::create(&original, &bytes).unwrap();
2470 let space = replica.pages().unwrap()[0].0;
2471 replica
2472 .apply(
2473 "Fixture",
2474 Edit {
2475 at: filetime(),
2476 ops: vec![Op::Page {
2477 space,
2478 op: onestore::op::PageOp::Color(Some(0x00ef_cdab)),
2479 }],
2480 },
2481 )
2482 .unwrap();
2483 let page = replica.page(space).unwrap();
2484 let pending = replica.pending().unwrap();
2485 assert_eq!(pending.len(), 1);
2486 drop(replica);
2487 let listing = cache.join("smb");
2488 std::fs::create_dir_all(&listing).unwrap();
2489 std::fs::write(
2490 listing.join("127_0_0_1_0_agent_Notebook.json"),
2491 serde_json::to_vec(&listed).unwrap(),
2492 )
2493 .unwrap();
2494
2495 let library = Arc::new(
2496 Library::server(
2497 Server {
2498 address: address.into(),
2499 share: "agent".into(),
2500 user: String::new(),
2501 password: String::new(),
2502 domain: String::new(),
2503 },
2504 "Notebook",
2505 &cache,
2506 )
2507 .unwrap(),
2508 );
2509 sb_library_set_offline(&library, true);
2510 assert!(library.notebook().is_none());
2511 let section =
2512 Section::open(Arc::clone(&library), "Read.one", "Fixture".into(), || {}).unwrap();
2513 assert_eq!(section.shared.section.page(space).unwrap(), page);
2514 assert_eq!(section.shared.section.pending().unwrap(), pending);
2515 assert_eq!(
2516 hex(&section.shared.section.replica().identity().unwrap()),
2517 identity
2518 );
2519
2520 assert!(section.delete_page(space, "Date", "Time").is_err());
2521 section.shared.section.written().unwrap();
2522 assert!(library.notebook().is_none());
2523 assert_eq!(section.shared.section.page(space).unwrap(), page);
2524 assert_eq!(section.shared.section.pending().unwrap(), pending);
2525 assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), bytes);
2526 }
2527}
crates/mobile/src/tests.rs+45-2
...@@ -491,6 +491,7 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin(...@@ -491,6 +491,7 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin(
491 .shared491 .shared
492 .apply(canvas.edit().unwrap().unwrap())492 .apply(canvas.edit().unwrap().unwrap())
493 .unwrap();493 .unwrap();
494 section.shared.section.written().unwrap();
494 assert!(495 assert!(
495 section496 section
496 .rows()497 .rows()
...@@ -498,9 +499,12 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin(...@@ -498,9 +499,12 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin(
498 .iter()499 .iter()
499 .any(|row| row.title == "Sketches")500 .any(|row| row.title == "Sketches")
500 );501 );
502 let deleted = section.shared.section.page(subpage).unwrap();
503 assert!(deleted.identity.is_some());
501 section504 section
502 .delete_page(subpage, "Sunday, September 27, 2026", "3:06 AM")505 .delete_page(subpage, "Sunday, September 27, 2026", "3:06 AM")
503 .unwrap();506 .unwrap();
507 section.shared.section.written().unwrap();
504 assert!(508 assert!(
505 section509 section
506 .rows()510 .rows()
...@@ -508,12 +512,40 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin(...@@ -508,12 +512,40 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin(
508 .iter()512 .iter()
509 .all(|row| row.id != subpage.to_string())513 .all(|row| row.id != subpage.to_string())
510 );514 );
515 let bytes = std::fs::read(root.join("OneNote_RecycleBin/OneNote_DeletedPages.one")).unwrap();
511 assert!(516 assert!(
512 root.join("OneNote_RecycleBin/OneNote_DeletedPages.one")517 notebook::session::stored_pages(&bytes)
513 .exists()518 .unwrap()
519 .iter()
520 .any(|stored| stored.page.identity == deleted.identity
521 && stored.page.title == "Sketches")
514 );522 );
515}523}
516524
525#[test]
526fn deleting_a_lone_section_page_still_creates_its_replacement_without_a_bin() {
527 let directory = tempfile::tempdir().unwrap();
528 let file = directory.path().join("Lone.one");
529 std::fs::write(
530 &file,
531 onestore::create_section("Lone.one", "Standalone page", "Fixture").unwrap(),
532 )
533 .unwrap();
534 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
535 sb_library_set_offline(&library, true);
536 let section = open(&library, &file.to_string_lossy());
537 let space = section.shared.section.pages().unwrap()[0].0;
538 section
539 .delete_page(space, "Sunday, September 27, 2026", "3:06 AM")
540 .unwrap();
541 section.shared.section.written().unwrap();
542 let pages = section.shared.section.pages().unwrap();
543 assert_eq!(pages.len(), 1);
544 assert_ne!(pages[0].0, space);
545 assert!(section.shared.section.page(space).is_err());
546 assert!(!directory.path().join("OneNote_RecycleBin").exists());
547}
548
517#[test]549#[test]
518fn conflict_pages_list_under_their_page_and_take_no_edits() {550fn conflict_pages_list_under_their_page_and_take_no_edits() {
519 let (directory, root) = copy("conflict-page/candidate");551 let (directory, root) = copy("conflict-page/candidate");
...@@ -825,6 +857,17 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {...@@ -825,6 +857,17 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {
825 .unwrap()857 .unwrap()
826 .0;858 .0;
827 assert!(canvas::search::page_text(&page).contains("Shared "));859 assert!(canvas::search::page_text(&page).contains("Shared "));
860 sb_library_set_offline(&offline, true);
861 let space = space(&reopened, "Paragraph controls");
862 let pending = reopened.shared.section.pending().unwrap();
863 assert!(
864 reopened
865 .delete_page(space, "Sunday, September 27, 2026", "3:06 AM")
866 .is_err()
867 );
868 reopened.shared.section.written().unwrap();
869 assert_eq!(reopened.shared.section.page(space).unwrap(), page);
870 assert_eq!(reopened.shared.section.pending().unwrap(), pending);
828 client.delete(&format!("{root}/Features.one")).unwrap();871 client.delete(&format!("{root}/Features.one")).unwrap();
829 client.delete(&root).unwrap();872 client.delete(&root).unwrap();
830}873}