authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 03:36:47-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 04:09:27-07:00
loge860c97095edca027e9a21a94783ad92e2f3305e
tree5eb01ddb6f73bc58e3940505239cc89407820053
parent9ffccbbee0705d77fefa54a2cc1670a25a3dab7a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix(sync): section copies keep their own TOC entries

A copy of a section opens beside its original with its own replica; renaming, moving, deleting, or reordering it edits only the copy's own TOC entry, as OneNote 2010 lists copies under fresh identities and leaves their files as they are. Assisted-by: claude-opus-5.5

9 files changed, 227 insertions(+), 151 deletions(-)

apps/ios/Snowbound/Lists.swift+1-1
...@@ -233,7 +233,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -233,7 +233,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
233 systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill")233 systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill")
234 content.imageProperties.tintColor = tab.uiColor234 content.imageProperties.tintColor = tab.uiColor
235 if !tab.readable {235 if !tab.readable {
236 content.secondaryText = tab.downloading ? "Downloading…" : tab.copy.map { "Copy of “\($0)”" } ?? tab.problem ?? "Can’t be opened here"236 content.secondaryText = tab.downloading ? "Downloading…" : tab.problem ?? "Can’t be opened here"
237 content.textProperties.color = .secondaryLabel237 content.textProperties.color = .secondaryLabel
238 }238 }
239 case .status(let id):239 case .status(let id):
apps/ios/Snowbound/Notebooks.swift-2
...@@ -128,8 +128,6 @@ struct Tab: Decodable, Equatable {...@@ -128,8 +128,6 @@ struct Tab: Decodable, Equatable {
128 let readable: Bool128 let readable: Bool
129 /// Not on this device yet; `Notebook` asks iCloud Drive for it.129 /// Not on this device yet; `Notebook` asks iCloud Drive for it.
130 let downloading: Bool130 let downloading: Bool
131 /// The section this file copies, which opens instead.
132 let copy: String?
133 /// Why the file could not be read, where retrying or repair may help.131 /// Why the file could not be read, where retrying or repair may help.
134 let problem: String?132 let problem: String?
135133
arc/sync.md+26-3
...@@ -43,9 +43,9 @@ pages. Both desktop and iOS use exactly this surface....@@ -43,9 +43,9 @@ pages. Both desktop and iOS use exactly this surface.
4343
44## The replica44## The replica
4545
46Each open section has a replica: a SQLite database in the app's cache,46Each open section has a replica: a SQLite database in the app's cache, named by
47identified by the section's logical identity, so the same file reopens the47where the notebook is and the section's logical identity, so the same file
48same queue after a relaunch. It holds:48reopens the same queue after a relaunch. It holds:
4949
50- **the base**: the last remote image the queue applies to, stored in chunks so50- **the base**: the last remote image the queue applies to, stored in chunks so
51 that acknowledging a publication rewrites only the chunks the transaction51 that acknowledging a publication rewrites only the chunks the transaction
...@@ -59,6 +59,29 @@ replayed and the open batch applied, rebuilt on open. The database runs in WAL...@@ -59,6 +59,29 @@ replayed and the open batch applied, rebuilt on open. The database runs in WAL
59mode with full synchronous commits, and every setting is read back to check it59mode with full synchronous commits, and every setting is read back to check it
60took.60took.
6161
62The location matters because OneNote lets a notebook folder be copied whole, a
63Finder duplicate or an iCloud `Name 2` conflict copy, and every section in the
64copy keeps its identity. Keyed by identity alone, the copy would open the
65original's queue and publish its edits into the wrong file. So replicas live in
66`cache/replicas/<hash of location>/<identity>.sqlite` (`notebook::location`),
67where a location is a canonical local path or `smb://server/share/root`, and a
68`location` file in each folder names it. A section renamed or moved inside its
69notebook keeps its replica; a copy of a section inside one notebook (`Cross
702.one` beside `Cross.one`, which OneNote opens as a section of its own) is keyed
71by its own path instead, the original being the one the folder's TOC lists.
72
73When a notebook moves, its queue follows by one of three roads:
74
75- **the app moved it** (iOS Rename): `location::moved` moves the folder's
76 replicas to the new location;
77- **something else moved it** (Finder): on open, a section with no replica takes
78 one from the folder of a local location that no longer exists, if the file
79 stands as that replica's base or has moved on from it (a higher header
80 generation). A replica whose base is newer than the file, or a different state
81 of the same generation, belongs to another copy and stays;
82- **an older install** named replicas by identity alone in the cache's top or
83 its `smb` folder: the first location to open the section takes it.
84
62An attached file is a payload like a picture: its bytes ride in the edit that85An attached file is a payload like a picture: its bytes ride in the edit that
63inserts it and publish inside that edit's revision. No other file is written,86inserts it and publish inside that edit's revision. No other file is written,
64so nothing needs to reach the share before the revision that names it. That87so nothing needs to reach the share before the revision that names it. That
crates/mobile/src/library.rs+1-10
...@@ -290,9 +290,6 @@ pub(crate) struct Tab {...@@ -290,9 +290,6 @@ pub(crate) struct Tab {
290 /// Not on this device yet, as iCloud Drive keeps it elsewhere; the host downloads it.290 /// Not on this device yet, as iCloud Drive keeps it elsewhere; the host downloads it.
291 #[serde(default)]291 #[serde(default)]
292 pub(crate) downloading: bool,292 pub(crate) downloading: bool,
293 /// The name of the section this file copies, which opens instead.
294 #[serde(default)]
295 pub(crate) copy: Option<String>,
296 /// Why the file could not be read, where retrying or repair may help.293 /// Why the file could not be read, where retrying or repair may help.
297 #[serde(default)]294 #[serde(default)]
298 pub(crate) problem: Option<String>,295 pub(crate) problem: Option<String>,
...@@ -325,7 +322,6 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {...@@ -325,7 +322,6 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
325 color: rgb(color.unwrap_or(SECTION_COLOR)),322 color: rgb(color.unwrap_or(SECTION_COLOR)),
326 readable,323 readable,
327 downloading: false,324 downloading: false,
328 copy: None,
329 problem: None,325 problem: None,
330 });326 });
331 }327 }
...@@ -337,10 +333,6 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {...@@ -337,10 +333,6 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
337 color: rgb(SECTION_COLOR),333 color: rgb(SECTION_COLOR),
338 readable: false,334 readable: false,
339 downloading: entry.reason == Reason::Evicted,335 downloading: entry.reason == Reason::Evicted,
340 copy: match &entry.reason {
341 Reason::Copy { of } => Some(stem(of)),
342 _ => None,
343 },
344 problem: match entry.reason {336 problem: match entry.reason {
345 Reason::InUse => Some("Section in use".into()),337 Reason::InUse => Some("Section in use".into()),
346 Reason::Unreadable => Some("Can’t read this section".into()),338 Reason::Unreadable => Some("Can’t read this section".into()),
...@@ -520,7 +512,6 @@ impl Library {...@@ -520,7 +512,6 @@ impl Library {
520 color: rgb(SECTION_COLOR),512 color: rgb(SECTION_COLOR),
521 readable: true,513 readable: true,
522 downloading: false,514 downloading: false,
523 copy: None,
524 problem: None,515 problem: None,
525 }]);516 }]);
526 }517 }
...@@ -1311,7 +1302,7 @@ pub unsafe extern "C" fn sb_notebook_moved(...@@ -1311,7 +1302,7 @@ pub unsafe extern "C" fn sb_notebook_moved(
1311}1302}
13121303
1313/// The notebook's sections read again, as JSON: each with `name`, `path`, `group`,1304/// The notebook's sections read again, as JSON: each with `name`, `path`, `group`,
1314/// `color` as sRGB bytes, `readable`, `downloading` and `copy`, in the notebook's order; null1305/// `color` as sRGB bytes, `readable`, `downloading` and `problem`, in the notebook's order; null
1315/// if it cannot be read.1306/// if it cannot be read.
1316#[unsafe(no_mangle)]1307#[unsafe(no_mangle)]
1317pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char {1308pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char {
crates/notebook/README.md+10-5
...@@ -53,9 +53,10 @@ thread rereads the queue....@@ -53,9 +53,10 @@ thread rereads the queue.
53the files its folders list otherwise than when last opened (`discover::Cache`, kept under53the files its folders list otherwise than when last opened (`discover::Cache`, kept under
54`cache_dir/listings`), and54`cache_dir/listings`), and
55`session::Section::open(file, cache_dir, notify)` opens one section file through55`session::Section::open(file, cache_dir, notify)` opens one section file through
56a replica named by the section's document identity, so the same file reopens the56a replica named by the file's location and the section's document identity, so the same
57same queue after a relaunch. A section is `Send + Sync`: `apply(author, edit)`57file reopens the same queue after a relaunch and a copy of it elsewhere has its own
58returns at once and reports a refusal as `Event::Rejected`; `events()` drains58(`notebook::location`: `folder`, `local`, `smb`, and `moved` for a notebook the app moves).
59A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains
59remote changes (`Changed(spaces)`), publication outcomes, unreachable files and60remote changes (`Changed(spaces)`), publication outcomes, unreachable files and
60failures; `notify` runs on a background thread whenever an event waits.61failures; `notify` runs on a background thread whenever an event waits.
61`conflicts()` lists each page's conflict pages, which `page` reads and `delete_pages`62`conflicts()` lists each page's conflict pages, which `page` reads and `delete_pages`
...@@ -287,8 +288,12 @@ cache as it was....@@ -287,8 +288,12 @@ cache as it was.
287Each file read must be a consistent, bounded snapshot. The result is an288Each file read must be a consistent, bounded snapshot. The result is an
288observation across multiple files, not an atomic notebook transaction or289observation across multiple files, not an atomic notebook transaction or
289authorization to publish an edit. Refresh rejects observed topology changes. Of290authorization to publish an edit. Refresh rejects observed topology changes. Of
290several files with one identity (a `Name 2.one` copy), the one its folder's TOC names,291several section files with one identity (a `Name 2.one` copy), each lists, as OneNote opens
291else the newest, lists; the others list in `unavailable` as `Reason::Copy`. iOS's292each; all but the one its folder's TOC names (else the one with the shortest path) are
293marked `copy`, have replicas of their own, and are never placed or listed anew by structure
294operations, which touch only a TOC entry OneNote gave the copy's name. Of several groups
295with one TOC identity, the one its parent's TOC names, else the newest, lists; the others
296list in `unavailable` as `Reason::Copy`. iOS's
292`.Name.one.icloud` placeholders list there under their real names as `Reason::Evicted`.297`.Name.one.icloud` placeholders list there under their real names as `Reason::Evicted`.
293Retain the last accepted catalog if discovery fails; a connection failure does not mean298Retain the last accepted catalog if discovery fails; a connection failure does not mean
294files were deleted.299files were deleted.
crates/notebook/src/session.rs+112-46
...@@ -663,7 +663,11 @@ impl Notebook {...@@ -663,7 +663,11 @@ impl Notebook {
663 /// Renames a section or section group: the file or folder and its TOC entry.663 /// Renames a section or section group: the file or folder and its TOC entry.
664 pub fn rename(&mut self, path: &str, name: &str) -> Result<String> {664 pub fn rename(&mut self, path: &str, name: &str) -> Result<String> {
665 let (folder, entry) = split(path);665 let (folder, entry) = split(path);
666 let (filename, identity) = self.entry(folder, entry)?;666 let Entry {
667 filename,
668 identity,
669 copy,
670 } = self.entry(folder, entry)?;
667 let target = if filename.to_ascii_lowercase().ends_with(".one") {671 let target = if filename.to_ascii_lowercase().ends_with(".one") {
668 format!("{name}.one")672 format!("{name}.one")
669 } else {673 } else {
...@@ -675,20 +679,24 @@ impl Notebook {...@@ -675,20 +679,24 @@ impl Notebook {
675 }679 }
676 self.storage680 self.storage
677 .rename(&catalog_path(folder, &filename), &renamed)?;681 .rename(&catalog_path(folder, &filename), &renamed)?;
678 let (_, ancestor) = self.toc(folder)?;682 if !copy {
679 let placed = if target.ends_with(".one") {683 let (_, ancestor) = self.toc(folder)?;
680 renamed.clone()684 let placed = if target.ends_with(".one") {
681 } else {685 renamed.clone()
682 catalog_path(&renamed, TOC)686 } else {
683 };687 catalog_path(&renamed, TOC)
684 self.storage.place(&placed, ancestor, &target)?;688 };
685 self.edit_toc(689 self.storage.place(&placed, ancestor, &target)?;
686 folder,690 }
687 &[onestore::TocEdit::Rename {691 if let Some(identity) = identity {
688 identity,692 self.edit_toc(
689 filename: target,693 folder,
690 }],694 &[onestore::TocEdit::Rename {
691 )?;695 identity,
696 filename: target,
697 }],
698 )?;
699 }
692 self.refresh()?;700 self.refresh()?;
693 Ok(renamed)701 Ok(renamed)
694 }702 }
...@@ -718,7 +726,7 @@ impl Notebook {...@@ -718,7 +726,7 @@ impl Notebook {
718 if parent != folder {726 if parent != folder {
719 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());727 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
720 }728 }
721 identities.push(self.entry(folder, entry)?.1);729 identities.extend(self.entry(folder, entry)?.identity);
722 }730 }
723 self.edit_toc(folder, &[onestore::TocEdit::Order(identities)])?;731 self.edit_toc(folder, &[onestore::TocEdit::Order(identities)])?;
724 self.refresh().map(drop)732 self.refresh().map(drop)
...@@ -730,12 +738,12 @@ impl Notebook {...@@ -730,12 +738,12 @@ impl Notebook {
730 /// there the same way, then its folders go. Either way its folder's TOC entry goes.738 /// there the same way, then its folders go. Either way its folder's TOC entry goes.
731 pub fn delete(&mut self, path: &str) -> Result<()> {739 pub fn delete(&mut self, path: &str) -> Result<()> {
732 let (folder, entry) = split(path);740 let (folder, entry) = split(path);
733 let (filename, identity) = self.entry(folder, entry)?;741 let deleted = self.entry(folder, entry)?;
734 if path == RECYCLE_BIN {742 if path == RECYCLE_BIN {
735 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());743 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
736 }744 }
737 if filename.to_ascii_lowercase().ends_with(".one") {745 if deleted.filename.to_ascii_lowercase().ends_with(".one") {
738 self.bin_section(path, &filename, identity)?;746 self.bin_section(path, &deleted)?;
739 } else {747 } else {
740 // Sections first, then each folder once it is empty, deepest first.748 // Sections first, then each folder once it is empty, deepest first.
741 let mut sections = Vec::new();749 let mut sections = Vec::new();
...@@ -754,7 +762,7 @@ impl Notebook {...@@ -754,7 +762,7 @@ impl Notebook {
754 group762 group
755 .sections763 .sections
756 .iter()764 .iter()
757 .map(|section| (section.path.clone(), section.file_id)),765 .map(|section| (section.path.clone(), Entry::of(group, section))),
758 );766 );
759 folders.push((767 folders.push((
760 group.path.clone(),768 group.path.clone(),
...@@ -762,9 +770,8 @@ impl Notebook {...@@ -762,9 +770,8 @@ impl Notebook {
762 ));770 ));
763 pending.extend(&group.groups);771 pending.extend(&group.groups);
764 }772 }
765 for (section, identity) in sections {773 for (section, entry) in sections {
766 let (_, name) = split(&section);774 self.bin_section(&section, &entry)?;
767 self.bin_section(&section, name, identity)?;
768 }775 }
769 for (group, toc) in folders.into_iter().rev() {776 for (group, toc) in folders.into_iter().rev() {
770 if let Some(toc) = toc {777 if let Some(toc) = toc {
...@@ -773,7 +780,9 @@ impl Notebook {...@@ -773,7 +780,9 @@ impl Notebook {
773 self.storage.delete(&group)?;780 self.storage.delete(&group)?;
774 }781 }
775 }782 }
776 self.edit_toc(folder, &[onestore::TocEdit::Remove { identity }])?;783 if let Some(identity) = deleted.identity {
784 self.edit_toc(folder, &[onestore::TocEdit::Remove { identity }])?;
785 }
777 self.refresh().map(drop)786 self.refresh().map(drop)
778 }787 }
779788
...@@ -824,9 +833,10 @@ impl Notebook {...@@ -824,9 +833,10 @@ impl Notebook {
824 Ok((bin_toc, identity))833 Ok((bin_toc, identity))
825 }834 }
826835
827 /// Moves the section file at `path` into the recycle bin, under a name no binned836 /// Moves the section file at `path`, its folder's `entry`, into the recycle bin, under a
828 /// section has, and lists it there; its own folder's TOC is the caller's.837 /// name no binned section has, and lists it there; its own folder's TOC is the caller's.
829 fn bin_section(&self, path: &str, filename: &str, identity: [u8; 16]) -> Result<()> {838 fn bin_section(&self, path: &str, entry: &Entry) -> Result<()> {
839 let filename = &entry.filename;
830 let (bin_toc, bin_identity) = self.bin()?;840 let (bin_toc, bin_identity) = self.bin()?;
831 let mut target = filename.to_owned();841 let mut target = filename.to_owned();
832 let mut attempt = 1;842 let mut attempt = 1;
...@@ -837,6 +847,9 @@ impl Notebook {...@@ -837,6 +847,9 @@ impl Notebook {
837 }847 }
838 let binned = catalog_path(RECYCLE_BIN, &target);848 let binned = catalog_path(RECYCLE_BIN, &target);
839 self.storage.rename(path, &binned)?;849 self.storage.rename(path, &binned)?;
850 let (false, Some(identity)) = (entry.copy, entry.identity) else {
851 return Ok(());
852 };
840 self.storage.place(&binned, bin_identity, &target)?;853 self.storage.place(&binned, bin_identity, &target)?;
841 self.commit_toc(854 self.commit_toc(
842 &bin_toc,855 &bin_toc,
...@@ -900,7 +913,11 @@ impl Notebook {...@@ -900,7 +913,11 @@ impl Notebook {
900 /// entry follows it. Returns its new catalog path.913 /// entry follows it. Returns its new catalog path.
901 pub fn move_entry(&mut self, path: &str, folder: &str) -> Result<String> {914 pub fn move_entry(&mut self, path: &str, folder: &str) -> Result<String> {
902 let (from, entry) = split(path);915 let (from, entry) = split(path);
903 let (filename, identity) = self.entry(from, entry)?;916 let Entry {
917 filename,
918 identity,
919 copy,
920 } = self.entry(from, entry)?;
904 self.folder(folder)?;921 self.folder(folder)?;
905 let group = !filename.to_ascii_lowercase().ends_with(".one");922 let group = !filename.to_ascii_lowercase().ends_with(".one");
906 let target = catalog_path(folder, &filename);923 let target = catalog_path(folder, &filename);
...@@ -913,34 +930,40 @@ impl Notebook {...@@ -913,34 +930,40 @@ impl Notebook {
913 }930 }
914 let (_, ancestor) = self.toc(folder)?;931 let (_, ancestor) = self.toc(folder)?;
915 self.storage.rename(path, &target)?;932 self.storage.rename(path, &target)?;
916 let placed = if group {933 if !copy {
917 catalog_path(&target, TOC)934 let placed = if group {
918 } else {935 catalog_path(&target, TOC)
919 target.clone()936 } else {
920 };937 target.clone()
921 self.storage.place(&placed, ancestor, &filename)?;938 };
922 self.edit_toc(939 self.storage.place(&placed, ancestor, &filename)?;
923 folder,940 if let Some(identity) = identity {
924 &[onestore::TocEdit::Add {941 self.edit_toc(
925 filename,942 folder,
926 identity,943 &[onestore::TocEdit::Add {
927 group,944 filename,
928 }],945 identity,
929 )?;946 group,
930 self.edit_toc(from, &[onestore::TocEdit::Remove { identity }])?;947 }],
948 )?;
949 }
950 }
951 if let Some(identity) = identity {
952 self.edit_toc(from, &[onestore::TocEdit::Remove { identity }])?;
953 }
931 self.refresh()?;954 self.refresh()?;
932 Ok(target)955 Ok(target)
933 }956 }
934957
935 /// The stored filename and TOC identity of a folder's section or group.958 /// The stored filename and TOC identity of a folder's section or group.
936 fn entry(&self, folder: &str, name: &str) -> Result<(String, [u8; 16])> {959 fn entry(&self, folder: &str, name: &str) -> Result<Entry> {
937 let parent = self.folder(folder)?;960 let parent = self.folder(folder)?;
938 if let Some(section) = parent961 if let Some(section) = parent
939 .sections962 .sections
940 .iter()963 .iter()
941 .find(|section| split(&section.path).1 == name)964 .find(|section| split(&section.path).1 == name)
942 {965 {
943 return Ok((name.to_owned(), section.file_id));966 return Ok(Entry::of(parent, section));
944 }967 }
945 if let Some(group) = parent968 if let Some(group) = parent
946 .groups969 .groups
...@@ -951,7 +974,11 @@ impl Notebook {...@@ -951,7 +974,11 @@ impl Notebook {
951 .toc974 .toc
952 .as_ref()975 .as_ref()
953 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;976 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
954 return Ok((name.to_owned(), toc.file_id));977 return Ok(Entry {
978 filename: name.to_owned(),
979 identity: Some(toc.file_id),
980 copy: false,
981 });
955 }982 }
956 Err(io::Error::from(io::ErrorKind::NotFound).into())983 Err(io::Error::from(io::ErrorKind::NotFound).into())
957 }984 }
...@@ -1179,6 +1206,45 @@ impl Notebook {...@@ -1179,6 +1206,45 @@ impl Notebook {
1179 }1206 }
1180}1207}
11811208
1209/// A section or group as its folder holds it, for the structure operations.
1210struct Entry {
1211 filename: String,
1212 /// What its folder's TOC lists it under, if anything.
1213 identity: Option<[u8; 16]>,
1214 /// A copy of another section of the notebook (`discover::Section::copy`). Its header names
1215 /// the other's file, so it is never placed or listed anew: OneNote lists a copy it finds
1216 /// under an identity of its own, leaving the file as it is.
1217 copy: bool,
1218}
1219
1220impl Entry {
1221 /// `section` of the folder `parent`: a copy has only the entry its folder's TOC lists
1222 /// under its name.
1223 fn of(parent: &discover::Folder, section: &discover::Section) -> Self {
1224 let filename = split(&section.path).1.to_owned();
1225 let identity = if section.copy {
1226 parent
1227 .toc
1228 .iter()
1229 .flat_map(|toc| &toc.unresolved)
1230 .find(|entry| {
1231 entry
1232 .filename
1233 .as_deref()
1234 .is_some_and(|name| name.eq_ignore_ascii_case(&filename))
1235 })
1236 .map(|entry| entry.file)
1237 } else {
1238 Some(section.file_id)
1239 };
1240 Self {
1241 filename,
1242 identity,
1243 copy: section.copy,
1244 }
1245 }
1246}
1247
1182/// A page as a section file stores it.1248/// A page as a section file stores it.
1183pub struct StoredPage {1249pub struct StoredPage {
1184 pub space: ExGuid,1250 pub space: ExGuid,
crates/notebook/tests/discovery.rs+25-28
...@@ -83,41 +83,39 @@ fn copy_of(unavailable: &notebook::discover::Unavailable) -> Option<&str> {...@@ -83,41 +83,39 @@ fn copy_of(unavailable: &notebook::discover::Unavailable) -> Option<&str> {
83 }83 }
84}84}
8585
86/// OneNote 2010 lists a copied section file beside its original86/// Which of the folder's sections are copies of another, by path.
87/// (`Cross 2.one`, 2026-09-29 lab run); one replica cannot hold both, so the copy the TOC87fn copies(folder: &notebook::discover::Folder) -> Vec<(&str, bool)> {
88/// does not name lists as unavailable.88 folder
89 .sections
90 .iter()
91 .map(|section| (section.path.as_str(), section.copy))
92 .collect()
93}
94
95/// OneNote 2010 opens a copied section file beside its original as a section of its own
96/// (`Cross 2.one`, 2026-09-29 lab run); the one the TOC names is the original, however
97/// short the copy's path.
89#[test]98#[test]
90#[ignore = "expects copies set aside; duplicates now open as sections"]99fn a_copied_section_lists_beside_the_one_the_toc_names() {
91fn a_copied_section_lists_as_a_copy_of_the_one_the_toc_names() {
92 let root = tempfile::tempdir().unwrap();100 let root = tempfile::tempdir().unwrap();
93 fixture(root.path());101 fixture(root.path());
94 fs::create_dir(root.path().join("group")).unwrap();102 fs::create_dir(root.path().join("group")).unwrap();
95 for copy in ["one 2.one", "group/other.one"] {103 for copy in ["o.one", "group/other.one"] {
96 fs::copy(root.path().join("one.one"), root.path().join(copy)).unwrap();104 fs::copy(root.path().join("one.one"), root.path().join(copy)).unwrap();
97 touch(&root.path().join(copy), 2_000_000_000);105 touch(&root.path().join(copy), 2_000_000_000);
98 }106 }
99 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();107 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
100 let paths = |folder: &notebook::discover::Folder| {108 assert!(catalog.unavailable.is_empty());
101 folder109 let mut listed = copies(&catalog);
102 .sections110 listed.sort();
103 .iter()111 assert_eq!(listed, [("o.one", true), ("one.one", false)]);
104 .map(|section| section.path.clone())112 assert_eq!(copies(&catalog.groups[0]), [("group/other.one", true)]);
105 .collect::<Vec<_>>()
106 };
107 assert_eq!(paths(&catalog), ["one.one"]);
108 assert_eq!(catalog.unavailable.len(), 1);
109 assert_eq!(catalog.unavailable[0].path, "one 2.one");
110 assert_eq!(copy_of(&catalog.unavailable[0]), Some("one.one"));
111 assert!(catalog.unavailable[0].error.contains("\u{201c}one\u{201d}"));
112 let group = &catalog.groups[0];
113 assert!(paths(group).is_empty());
114 assert_eq!(group.unavailable[0].path, "group/other.one");
115 assert_eq!(copy_of(&group.unavailable[0]), Some("one.one"));
116}113}
117114
115/// Without a TOC, the original is the copy with the shortest path, however new the others:
116/// a choice that stands while the files keep their names.
118#[test]117#[test]
119#[ignore = "expects copies set aside; duplicates now open as sections"]118fn without_a_toc_the_shortest_path_is_the_original() {
120fn without_a_toc_the_newest_copy_lists() {
121 let root = tempfile::tempdir().unwrap();119 let root = tempfile::tempdir().unwrap();
122 fixture(root.path());120 fixture(root.path());
123 fs::remove_file(root.path().join("Open Notebook.onetoc2")).unwrap();121 fs::remove_file(root.path().join("Open Notebook.onetoc2")).unwrap();
...@@ -125,10 +123,9 @@ fn without_a_toc_the_newest_copy_lists() {...@@ -125,10 +123,9 @@ fn without_a_toc_the_newest_copy_lists() {
125 touch(&root.path().join("one.one"), 1_000_000_000);123 touch(&root.path().join("one.one"), 1_000_000_000);
126 touch(&root.path().join("one 2.one"), 2_000_000_000);124 touch(&root.path().join("one 2.one"), 2_000_000_000);
127 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();125 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
128 assert_eq!(catalog.sections.len(), 1);126 let mut listed = copies(&catalog);
129 assert_eq!(catalog.sections[0].path, "one 2.one");127 listed.sort();
130 assert_eq!(catalog.unavailable[0].path, "one.one");128 assert_eq!(listed, [("one 2.one", true), ("one.one", false)]);
131 assert_eq!(copy_of(&catalog.unavailable[0]), Some("one 2.one"));
132}129}
133130
134#[test]131#[test]
crates/notebook/tests/location.rs+52-8
...@@ -226,28 +226,72 @@ fn a_replica_named_by_identity_alone_moves_to_the_first_location_that_opens_it()...@@ -226,28 +226,72 @@ fn a_replica_named_by_identity_alone_moves_to_the_first_location_that_opens_it()
226}226}
227227
228#[test]228#[test]
229#[ignore = "expects copies set aside; duplicates now open as sections"]229fn a_section_copy_beside_its_original_opens_with_its_own_replica() {
230fn a_section_copy_beside_its_original_opens_only_as_a_lone_file_with_its_own_replica() {
231 let directory = tempfile::tempdir().unwrap();230 let directory = tempfile::tempdir().unwrap();
232 let cache = directory.path().join("cache");231 let cache = directory.path().join("cache");
233 let root = notebook(directory.path(), "Notebook");232 let root = notebook(directory.path(), "Notebook");
234 std::fs::copy(root.join("First.one"), root.join("First 2.one")).unwrap();233 std::fs::copy(root.join("First.one"), root.join("First 2.one")).unwrap();
235 let mut notebook = Notebook::open(&root, &cache).unwrap();234 let mut notebook = Notebook::open(&root, &cache).unwrap();
236 // Discovery lists the copy as unavailable, so one replica serves the section.235 assert_ne!(
237 assert!(notebook.section("First 2.one", || {}).is_err());236 notebook.replica_path("First.one").unwrap(),
237 notebook.replica_path("First 2.one").unwrap()
238 );
238 assert_eq!(239 assert_eq!(
239 notebook240 notebook
240 .replicas()241 .replicas()
241 .iter()242 .iter()
242 .filter(|known| known.path.starts_with("First"))243 .filter(|known| known.path.starts_with("First"))
243 .count(),244 .count(),
244 1245 2
245 );246 );
246 let original = Section::open(root.join("First.one"), &cache, || {}).unwrap();247 let original = notebook.section("First.one", || {}).unwrap();
247 let copy = Section::open(root.join("First 2.one"), &cache, || {}).unwrap();248 let copy = notebook.section("First 2.one", || {}).unwrap();
248 original.set_offline(true);249 original.set_offline(true);
249 typed(&original, "Mine ");250 let space = typed(&original, "Mine ");
250 assert!(copy.pending().unwrap().is_empty());251 assert!(copy.pending().unwrap().is_empty());
252 original.set_offline(false);
253 until("the original published", || {
254 stored(&root.join("First.one"), space).starts_with("Mine ")
255 });
256 assert_eq!(stored(&root.join("First 2.one"), space), "First");
251 original.close().unwrap();257 original.close().unwrap();
252 copy.close().unwrap();258 copy.close().unwrap();
253}259}
260
261/// Whether the catalog marks the section at `path` a copy.
262fn copy(notebook: &Notebook, path: &str) -> bool {
263 notebook
264 .catalog()
265 .sections
266 .iter()
267 .find(|section| section.path == path)
268 .unwrap()
269 .copy
270}
271
272#[test]
273fn renaming_reordering_or_deleting_a_copy_leaves_the_originals_toc_entry() {
274 let directory = tempfile::tempdir().unwrap();
275 let root = notebook(directory.path(), "Notebook");
276 // `F.one` would be the original by its shorter path were the TOC not to list `First.one`.
277 for copy in ["F.one", "First 2.one"] {
278 std::fs::copy(root.join("First.one"), root.join(copy)).unwrap();
279 }
280 let original = std::fs::read(root.join("First.one")).unwrap();
281 let identity = onestore::Store::parse(&original).unwrap().header.file_id;
282 std::fs::write(
283 root.join("Open Notebook.onetoc2"),
284 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", identity)])
285 .unwrap(),
286 )
287 .unwrap();
288 let mut notebook = Notebook::open(&root, directory.path().join("cache")).unwrap();
289 assert!(!copy(&notebook, "First.one") && copy(&notebook, "F.one"));
290 notebook.delete("First 2.one").unwrap();
291 assert_eq!(notebook.rename("F.one", "G").unwrap(), "G.one");
292 notebook.reorder("", &["G.one", "First.one"]).unwrap();
293 assert!(!copy(&notebook, "First.one") && copy(&notebook, "G.one"));
294 // A copy keeps its header as it was, as OneNote leaves one.
295 assert_eq!(std::fs::read(root.join("G.one")).unwrap(), original);
296 assert_eq!(std::fs::read(root.join("First.one")).unwrap(), original);
297}
crates/onestore/examples/scratch_pressure.rs deleted-48
...@@ -1,48 +0,0 @@
1use onestore::{RevisionIndex, Store, document::{Document, Kind}};
2fn mb(bytes: &[u8]) -> Vec<i64> {
3 let mut out = Vec::new(); let mut v = 0u64; let mut s = 0;
4 for &b in bytes { v |= u64::from(b & 0x7f) << s; s += 7; if b & 0x80 == 0 { out.push(v as i64); v = 0; s = 0; } }
5 let count = (out[0] >> 1) as usize;
6 out[1..].iter().take(count).map(|r| if r & 1 == 1 { -(r >> 1) } else { r >> 1 }).collect()
7}
8fn guid(b: &[u8]) -> String {
9 let d1 = u32::from_le_bytes(b[0..4].try_into().unwrap());
10 let d2 = u16::from_le_bytes(b[4..6].try_into().unwrap());
11 let d3 = u16::from_le_bytes(b[6..8].try_into().unwrap());
12 format!("{d1:08x}-{d2:04x}-{d3:04x}-{}", b[8..].iter().map(|x| format!("{x:02x}")).collect::<String>())
13}
14fn main() {
15 let path = std::env::args().nth(1).unwrap();
16 let bytes = std::fs::read(path).unwrap();
17 let store = Store::parse(&bytes).unwrap();
18 let index = RevisionIndex::parse(&store).unwrap();
19 let document = Document::parse(&index).unwrap();
20 for (space, _) in document.pages().unwrap() {
21 let rev = document.active(space).unwrap();
22 for (id, node) in &rev.nodes {
23 match &node.kind {
24 Kind::InkStyle { dimensions, .. } => {
25 println!("STYLE {id:?} {:?}", node.kind);
26 for d in dimensions.chunks_exact(32) {
27 let lo = i32::from_le_bytes(d[16..20].try_into().unwrap());
28 let hi = i32::from_le_bytes(d[20..24].try_into().unwrap());
29 let units = u32::from_le_bytes(d[24..28].try_into().unwrap());
30 let res = f32::from_le_bytes(d[28..32].try_into().unwrap());
31 println!(" dim {} [{lo}, {hi}] units {units} res {res}", guid(&d[..16]));
32 }
33 for set in &node.extra { for f in set { println!(" extra {:#x} {:?}", f.id, f.value); } }
34 }
35 Kind::InkStroke { path, style, bias, index, .. } => {
36 let v = mb(path);
37 println!("STROKE {id:?} style {style:?} bias {bias:?} index {index:?} n={} values {:?}", v.len(), v);
38 for set in &node.extra { for f in set { println!(" extra {:#x} {:?}", f.id, f.value); } }
39 }
40 Kind::Ink { .. } | Kind::InkData { .. } => {
41 println!("{id:?} {:?} layout {:?}", node.kind, node.layout);
42 for set in &node.extra { for f in set { println!(" extra {:#x} {:?}", f.id, f.value); } }
43 }
44 _ => {}
45 }
46 }
47 }
48}