| author | |
| committer | |
| log | e860c97095edca027e9a21a94783ad92e2f3305e |
| tree | 5eb01ddb6f73bc58e3940505239cc89407820053 |
| parent | 9ffccbbee0705d77fefa54a2cc1670a25a3dab7a |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.59 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.uiColor | 234 | 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 = .secondaryLabel | 237 | 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: Bool | 128 | 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: Bool | 130 | 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? |
| 135 | 133 |
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. |
| 43 | 43 | ||
| 44 | ## The replica | 44 | ## The replica |
| 45 | 45 | ||
| 46 | Each open section has a replica: a SQLite database in the app's cache, | 46 | Each open section has a replica: a SQLite database in the app's cache, named by |
| 47 | identified by the section's logical identity, so the same file reopens the | 47 | where the notebook is and the section's logical identity, so the same file |
| 48 | same queue after a relaunch. It holds: | 48 | reopens the same queue after a relaunch. It holds: |
| 49 | 49 | ||
| 50 | - **the base**: the last remote image the queue applies to, stored in chunks so | 50 | - **the base**: the last remote image the queue applies to, stored in chunks so |
| 51 | that acknowledging a publication rewrites only the chunks the transaction | 51 | 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 |
| 59 | mode with full synchronous commits, and every setting is read back to check it | 59 | mode with full synchronous commits, and every setting is read back to check it |
| 60 | took. | 60 | took. |
| 61 | 61 | ||
| 62 | The location matters because OneNote lets a notebook folder be copied whole, a | ||
| 63 | Finder duplicate or an iCloud `Name 2` conflict copy, and every section in the | ||
| 64 | copy keeps its identity. Keyed by identity alone, the copy would open the | ||
| 65 | original's queue and publish its edits into the wrong file. So replicas live in | ||
| 66 | `cache/replicas/<hash of location>/<identity>.sqlite` (`notebook::location`), | ||
| 67 | where 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 | ||
| 69 | notebook keeps its replica; a copy of a section inside one notebook (`Cross | ||
| 70 | 2.one` beside `Cross.one`, which OneNote opens as a section of its own) is keyed | ||
| 71 | by its own path instead, the original being the one the folder's TOC lists. | ||
| 72 | |||
| 73 | When 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 | |||
| 62 | An attached file is a payload like a picture: its bytes ride in the edit that | 85 | An attached file is a payload like a picture: its bytes ride in the edit that |
| 63 | inserts it and publish inside that edit's revision. No other file is written, | 86 | inserts it and publish inside that edit's revision. No other file is written, |
| 64 | so nothing needs to reach the share before the revision that names it. That | 87 | so 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 | } |
| 1312 | 1303 | ||
| 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; null | 1305 | /// `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)] |
| 1317 | pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char { | 1308 | pub 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. |
| 53 | the files its folders list otherwise than when last opened (`discover::Cache`, kept under | 53 | the files its folders list otherwise than when last opened (`discover::Cache`, kept under |
| 54 | `cache_dir/listings`), and | 54 | `cache_dir/listings`), and |
| 55 | `session::Section::open(file, cache_dir, notify)` opens one section file through | 55 | `session::Section::open(file, cache_dir, notify)` opens one section file through |
| 56 | a replica named by the section's document identity, so the same file reopens the | 56 | a replica named by the file's location and the section's document identity, so the same |
| 57 | same queue after a relaunch. A section is `Send + Sync`: `apply(author, edit)` | 57 | file reopens the same queue after a relaunch and a copy of it elsewhere has its own |
| 58 | returns at once and reports a refusal as `Event::Rejected`; `events()` drains | 58 | (`notebook::location`: `folder`, `local`, `smb`, and `moved` for a notebook the app moves). |
| 59 | A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains | ||
| 59 | remote changes (`Changed(spaces)`), publication outcomes, unreachable files and | 60 | remote changes (`Changed(spaces)`), publication outcomes, unreachable files and |
| 60 | failures; `notify` runs on a background thread whenever an event waits. | 61 | failures; `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. |
| 287 | Each file read must be a consistent, bounded snapshot. The result is an | 288 | Each file read must be a consistent, bounded snapshot. The result is an |
| 288 | observation across multiple files, not an atomic notebook transaction or | 289 | observation across multiple files, not an atomic notebook transaction or |
| 289 | authorization to publish an edit. Refresh rejects observed topology changes. Of | 290 | authorization to publish an edit. Refresh rejects observed topology changes. Of |
| 290 | several files with one identity (a `Name 2.one` copy), the one its folder's TOC names, | 291 | several section files with one identity (a `Name 2.one` copy), each lists, as OneNote opens |
| 291 | else the newest, lists; the others list in `unavailable` as `Reason::Copy`. iOS's | 292 | each; all but the one its folder's TOC names (else the one with the shortest path) are |
| 293 | marked `copy`, have replicas of their own, and are never placed or listed anew by structure | ||
| 294 | operations, which touch only a TOC entry OneNote gave the copy's name. Of several groups | ||
| 295 | with one TOC identity, the one its parent's TOC names, else the newest, lists; the others | ||
| 296 | list 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`. |
| 293 | Retain the last accepted catalog if discovery fails; a connection failure does not mean | 298 | Retain the last accepted catalog if discovery fails; a connection failure does not mean |
| 294 | files were deleted. | 299 | files 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.storage | 680 | 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 | group | 762 | group |
| 755 | .sections | 763 | .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 | } |
| 779 | 788 | ||
| ... | @@ -824,9 +833,10 @@ impl Notebook { | ... | @@ -824,9 +833,10 @@ impl Notebook { |
| 824 | Ok((bin_toc, identity)) | 833 | Ok((bin_toc, identity)) |
| 825 | } | 834 | } |
| 826 | 835 | ||
| 827 | /// Moves the section file at `path` into the recycle bin, under a name no binned | 836 | /// 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 | } |
| 934 | 957 | ||
| 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) = parent | 961 | if let Some(section) = parent |
| 939 | .sections | 962 | .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) = parent | 968 | if let Some(group) = parent |
| 946 | .groups | 969 | .groups |
| ... | @@ -951,7 +974,11 @@ impl Notebook { | ... | @@ -951,7 +974,11 @@ impl Notebook { |
| 951 | .toc | 974 | .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 | } |
| 1181 | 1208 | ||
| 1209 | /// A section or group as its folder holds it, for the structure operations. | ||
| 1210 | struct 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 | |||
| 1220 | impl 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. |
| 1183 | pub struct StoredPage { | 1249 | pub 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 | } |
| 85 | 85 | ||
| 86 | /// OneNote 2010 lists a copied section file beside its original | 86 | /// 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 TOC | 87 | fn 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"] | 99 | fn a_copied_section_lists_beside_the_one_the_toc_names() { |
| 91 | fn 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 | folder | 109 | let mut listed = copies(&catalog); |
| 102 | .sections | 110 | 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 | } |
| 117 | 114 | ||
| 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"] | 118 | fn without_a_toc_the_shortest_path_is_the_original() { |
| 120 | fn 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 | } |
| 133 | 130 | ||
| 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 | } |
| 227 | 227 | ||
| 228 | #[test] | 228 | #[test] |
| 229 | #[ignore = "expects copies set aside; duplicates now open as sections"] | 229 | fn a_section_copy_beside_its_original_opens_with_its_own_replica() { |
| 230 | fn 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 | notebook | 240 | 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 | 1 | 245 | 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. | ||
| 262 | fn 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] | ||
| 273 | fn 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 @@ | ||
| 1 | use onestore::{RevisionIndex, Store, document::{Document, Kind}}; | ||
| 2 | fn 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 | } | ||
| 8 | fn 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 | } | ||
| 14 | fn 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 | } | ||