| author | |
| committer | |
| log | 0289b50606536e89c716af39171ffc698788b1f2 |
| tree | a97df45f83bccdd2ee225a0c76fb8ace89f7783e |
| parent | 384e5ef14c8d95bb74b186fe57af0cc578fa9276 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
OneNote 2010's Notebook Properties changes only the display name. A
"Rename the folder too" box, on by default, shows under Display name once
the name changes; checked, OK renames the notebook's folder. In the Windows
7 lab OneNote 2010, finding a notebook folder renamed outside it, drops the
notebook from its list (even while running) and opens the renamed folder
again with every file as it was: the TOC keeps its name (Open
Notebook.onetoc2) and no byte changes, and the colour stays with it. So
only the folder is renamed.
Notebook::rename_folder renames it through the notebook's storage. On a
share it first takes and lets go of OneNote's writer opening and locks on
every section and TOC, refusing while another writer holds one; the server
itself refuses a folder whose files are open. The notebook's replicas
(copies' included) and its listing then follow, so edits queued offline
publish into the renamed folder. The app stops the notebook's background
(Background::stop now waits for its thread), closes the open section,
renames on a thread and opens the notebook again in its place in the
sidebar at the section it showed; display names, read state, recents, the
sidebar's folds and last pages follow the new location, and settings save
it. A refusal reopens the notebook as it was and says why; nothing changes.
The dialog warns, for a notebook on a share, that other computers must
reopen it from the new folder.
In the app's iCloud Drive folder, a listing taken mid-rename neither closes
the renamed notebook nor opens the old name, and a listing that finds the
renamed folder first is folded into the same entry.
Assisted-by: claude-opus-5.516 files changed, 663 insertions(+), 22 deletions(-)
arc/sync.md+3-2| ... | @@ -80,8 +80,9 @@ by its own path instead, the original being the one the folder's TOC lists. | ... | @@ -80,8 +80,9 @@ by its own path instead, the original being the one the folder's TOC lists. |
| 80 | 80 | ||
| 81 | When a notebook moves, its queue follows by one of three roads: | 81 | When a notebook moves, its queue follows by one of three roads: |
| 82 | 82 | ||
| 83 | - **the app moved it** (iOS Rename): `location::moved` moves the folder's | 83 | - **the app moved it** (iOS Rename, or Notebook Properties' "Rename the folder |
| 84 | replicas to the new location; | 84 | too"): `location::moved` moves the folder's replicas to the new location, the |
| 85 | latter through `Notebook::rename_folder` once no other writer holds a file; | ||
| 85 | - **something else moved it** (Finder): on open, a section with no replica takes | 86 | - **something else moved it** (Finder): on open, a section with no replica takes |
| 86 | one from the folder of a local location that no longer exists, if the file | 87 | one from the folder of a local location that no longer exists, if the file |
| 87 | stands as that replica's base or has moved on from it (a higher header | 88 | stands as that replica's base or has moved on from it (a higher header |
crates/notebook/README.md+4| ... | @@ -330,6 +330,10 @@ unavailable bin refuses the delete (`corpus/recycle-bin-repair`). `empty_recycle | ... | @@ -330,6 +330,10 @@ unavailable bin refuses the delete (`corpus/recycle-bin-repair`). `empty_recycle |
| 330 | Empty Recycle Bin: Deleted Pages loses every page in one revision and each binned section's | 330 | Empty Recycle Bin: Deleted Pages loses every page in one revision and each binned section's |
| 331 | file goes, the bin's TOC left as it was (`corpus/recycle-bin-view`). An entry a TOC still | 331 | file goes, the bin's TOC left as it was (`corpus/recycle-bin-view`). An entry a TOC still |
| 332 | lists for a file gone from its folder gives way to the file an edit gives its name. | 332 | lists for a file gone from its folder gives way to the file an edit gives its name. |
| 333 | `rename_folder` renames the notebook's own folder, which OneNote 2010 opens again from its | ||
| 334 | new name with no file inside changed (it drops the old one from its list): on a share it | ||
| 335 | first takes and lets go of OneNote's writer locks on every section and TOC, refusing with | ||
| 336 | `WouldBlock` while another writer holds one, then the replicas and listing follow. | ||
| 333 | Edits keep entries in the order their ordering numbers give; a rename or colour keeps | 337 | Edits keep entries in the order their ordering numbers give; a rename or colour keeps |
| 334 | the numbers. Every created, renamed or moved file is placed with | 338 | the numbers. Every created, renamed or moved file is placed with |
| 335 | `onestore::place_file`, which sets the header's ancestor to the parent TOC's | 339 | `onestore::place_file`, which sets the header's ancestor to the parent TOC's |
crates/notebook/src/background.rs+18-3| ... | @@ -42,7 +42,7 @@ const SETTLE: Duration = Duration::from_secs(1); | ... | @@ -42,7 +42,7 @@ const SETTLE: Duration = Duration::from_secs(1); |
| 42 | /// its offline copy, where the notebook keeps them. A section a session holds is left to that | 42 | /// its offline copy, where the notebook keeps them. A section a session holds is left to that |
| 43 | /// session's worker, which the watch wakes. Dropping requests cancellation without waiting for | 43 | /// session's worker, which the watch wakes. Dropping requests cancellation without waiting for |
| 44 | /// the step in flight. | 44 | /// the step in flight. |
| 45 | pub struct Background(Arc<Shared>); | 45 | pub struct Background(Arc<Shared>, Mutex<Option<crate::task::JoinHandle<()>>>); |
| 46 | 46 | ||
| 47 | /// A section for `Background::watch`, as its notebook knows it. | 47 | /// A section for `Background::watch`, as its notebook knows it. |
| 48 | pub struct Known { | 48 | pub struct Known { |
| ... | @@ -138,7 +138,7 @@ impl Background { | ... | @@ -138,7 +138,7 @@ impl Background { |
| 138 | }); | 138 | }); |
| 139 | let weak = Arc::downgrade(&shared); | 139 | let weak = Arc::downgrade(&shared); |
| 140 | let owner = Arc::clone(&shared); | 140 | let owner = Arc::clone(&shared); |
| 141 | crate::task::spawn("onestore-background", move || async move { | 141 | let thread = crate::task::spawn("onestore-background", move || async move { |
| 142 | let signal = &owner.signal; | 142 | let signal = &owner.signal; |
| 143 | let mut bound: Option<(B, L)> = None; | 143 | let mut bound: Option<(B, L)> = None; |
| 144 | let mut news = false; | 144 | let mut news = false; |
| ... | @@ -295,7 +295,7 @@ impl Background { | ... | @@ -295,7 +295,7 @@ impl Background { |
| 295 | } | 295 | } |
| 296 | } | 296 | } |
| 297 | })?; | 297 | })?; |
| 298 | Ok(Self(shared)) | 298 | Ok(Self(shared, Mutex::new(Some(thread)))) |
| 299 | } | 299 | } |
| 300 | 300 | ||
| 301 | /// Keeps the sections of a notebook on a share in sync while they are not open, with an | 301 | /// Keeps the sections of a notebook on a share in sync while they are not open, with an |
| ... | @@ -448,6 +448,21 @@ impl Background { | ... | @@ -448,6 +448,21 @@ impl Background { |
| 448 | self.0.signal.wake(); | 448 | self.0.signal.wake(); |
| 449 | } | 449 | } |
| 450 | 450 | ||
| 451 | /// Stops for good, waiting for the step in flight, so that no replica stays open and no | ||
| 452 | /// watch holds the notebook's folder. | ||
| 453 | pub fn stop(&self) { | ||
| 454 | self.0.signal.stopped.store(true, Ordering::Release); | ||
| 455 | self.0.signal.wake(); | ||
| 456 | let thread = self.1.lock().ok().and_then(|mut thread| thread.take()); | ||
| 457 | // In the browser no step is in flight while another task runs. | ||
| 458 | #[cfg(not(target_arch = "wasm32"))] | ||
| 459 | if let Some(thread) = thread { | ||
| 460 | let _ = thread.join(); | ||
| 461 | } | ||
| 462 | #[cfg(target_arch = "wasm32")] | ||
| 463 | drop(thread); | ||
| 464 | } | ||
| 465 | |||
| 451 | /// Working offline, nothing is checked until `wake`, or until working online again, which | 466 | /// Working offline, nothing is checked until `wake`, or until working online again, which |
| 452 | /// lists every folder again. | 467 | /// lists every folder again. |
| 453 | pub fn set_offline(&self, offline: bool) { | 468 | pub fn set_offline(&self, offline: bool) { |
crates/notebook/src/session.rs+97-7| ... | @@ -92,6 +92,9 @@ pub trait Storage: Send + Sync { | ... | @@ -92,6 +92,9 @@ pub trait Storage: Send + Sync { |
| 92 | fn hide(&self, path: &str) -> Result<()>; | 92 | fn hide(&self, path: &str) -> Result<()>; |
| 93 | /// Renames or moves a file or directory; an existing target is an error. | 93 | /// Renames or moves a file or directory; an existing target is an error. |
| 94 | fn rename(&self, from: &str, to: &str) -> Result<()>; | 94 | fn rename(&self, from: &str, to: &str) -> Result<()>; |
| 95 | /// Renames the notebook's own folder to `name` beside it once no other writer holds any | ||
| 96 | /// of `files`; the location it then has. | ||
| 97 | fn rename_root(&self, name: &str, files: &[String]) -> Result<String>; | ||
| 95 | /// Renames a file over another, replacing it. | 98 | /// Renames a file over another, replacing it. |
| 96 | fn replace(&self, from: &str, to: &str) -> Result<()>; | 99 | fn replace(&self, from: &str, to: &str) -> Result<()>; |
| 97 | /// Deletes a file or an empty directory. | 100 | /// Deletes a file or an empty directory. |
| ... | @@ -211,6 +214,18 @@ impl Storage for Directory { | ... | @@ -211,6 +214,18 @@ impl Storage for Directory { |
| 211 | Ok(fs::rename(self.path(from), self.path(to))?) | 214 | Ok(fs::rename(self.path(from), self.path(to))?) |
| 212 | } | 215 | } |
| 213 | 216 | ||
| 217 | /// A local file system shows no other writer's hold; one that refuses to rename a folder | ||
| 218 | /// whose files are open says so as the rename fails. | ||
| 219 | fn rename_root(&self, name: &str, _: &[String]) -> Result<String> { | ||
| 220 | let to = self.0.with_file_name(name); | ||
| 221 | // A change of case alone finds the folder itself on a case-insensitive volume. | ||
| 222 | if fs::metadata(&to).is_ok() && fs::canonicalize(&to)? != self.0 { | ||
| 223 | return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); | ||
| 224 | } | ||
| 225 | fs::rename(&self.0, &to)?; | ||
| 226 | Ok(to.to_string_lossy().into_owned()) | ||
| 227 | } | ||
| 228 | |||
| 214 | fn replace(&self, from: &str, to: &str) -> Result<()> { | 229 | fn replace(&self, from: &str, to: &str) -> Result<()> { |
| 215 | Ok(fs::rename(self.path(from), self.path(to))?) | 230 | Ok(fs::rename(self.path(from), self.path(to))?) |
| 216 | } | 231 | } |
| ... | @@ -306,6 +321,29 @@ impl Storage for Share { | ... | @@ -306,6 +321,29 @@ impl Storage for Share { |
| 306 | Ok(self.client.rename(&self.path(from), &self.path(to))?) | 321 | Ok(self.client.rename(&self.path(from), &self.path(to))?) |
| 307 | } | 322 | } |
| 308 | 323 | ||
| 324 | fn rename_root(&self, name: &str, files: &[String]) -> Result<String> { | ||
| 325 | if self.root.is_empty() { | ||
| 326 | return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); | ||
| 327 | } | ||
| 328 | for file in files { | ||
| 329 | self.client.unheld(&self.path(file))?; | ||
| 330 | } | ||
| 331 | let (parent, _) = split(&self.root); | ||
| 332 | let taken = self | ||
| 333 | .client | ||
| 334 | .read_dir(parent, LIMITS.entries)? | ||
| 335 | .iter() | ||
| 336 | .any(|entry| { | ||
| 337 | entry.name.eq_ignore_ascii_case(name) && entry.name != split(&self.root).1 | ||
| 338 | }); | ||
| 339 | if taken { | ||
| 340 | return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); | ||
| 341 | } | ||
| 342 | let to = catalog_path(parent, name); | ||
| 343 | self.client.rename(&self.root, &to)?; | ||
| 344 | Ok(self.client.location(&to)) | ||
| 345 | } | ||
| 346 | |||
| 309 | fn replace(&self, from: &str, to: &str) -> Result<()> { | 347 | fn replace(&self, from: &str, to: &str) -> Result<()> { |
| 310 | Ok(self.client.replace(&self.path(from), &self.path(to))?) | 348 | Ok(self.client.replace(&self.path(from), &self.path(to))?) |
| 311 | } | 349 | } |
| ... | @@ -408,13 +446,8 @@ impl Notebook { | ... | @@ -408,13 +446,8 @@ impl Notebook { |
| 408 | cache: impl AsRef<Path>, | 446 | cache: impl AsRef<Path>, |
| 409 | ) -> Result<Self> { | 447 | ) -> Result<Self> { |
| 410 | let cache = cache.as_ref().to_path_buf(); | 448 | let cache = cache.as_ref().to_path_buf(); |
| 411 | let listings = cache.join("listings"); | 449 | let listing = listing(&cache, &storage.location()); |
| 412 | fs::create_dir_all(&listings)?; | 450 | fs::create_dir_all(listing.parent().unwrap_or(&cache))?; |
| 413 | let name: String = <sha2::Sha256 as sha2::Digest>::digest(storage.location())[..16] | ||
| 414 | .iter() | ||
| 415 | .map(|byte| format!("{byte:02x}")) | ||
| 416 | .collect(); | ||
| 417 | let listing = listings.join(format!("{name}.json")); | ||
| 418 | let mut read = fs::read(&listing) | 451 | let mut read = fs::read(&listing) |
| 419 | .ok() | 452 | .ok() |
| 420 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) | 453 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) |
| ... | @@ -738,6 +771,45 @@ impl Notebook { | ... | @@ -738,6 +771,45 @@ impl Notebook { |
| 738 | Ok(renamed) | 771 | Ok(renamed) |
| 739 | } | 772 | } |
| 740 | 773 | ||
| 774 | /// Renames the notebook's folder to `name`, as OneNote 2010 finds a notebook folder renamed | ||
| 775 | /// outside it: no file inside changes, and it opens the folder again by its new name. Refused, | ||
| 776 | /// `WouldBlock`, while another writer holds one of its sections or tables of contents, and | ||
| 777 | /// `AlreadyExists` where `name` is taken. The replicas and the catalog's listing follow, so | ||
| 778 | /// edits waiting publish to the renamed folder; every replica must be closed. Returns the | ||
| 779 | /// notebook's new location, as `crate::location` names it. | ||
| 780 | pub fn rename_folder(mut self, name: &str) -> Result<String> { | ||
| 781 | if !folder_name(name) { | ||
| 782 | return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); | ||
| 783 | } | ||
| 784 | let from = self.storage.location(); | ||
| 785 | let files: Vec<String> = (self.catalog.folders()) | ||
| 786 | .flat_map(|folder| { | ||
| 787 | let toc = folder.toc.as_ref().map(|toc| &toc.filename); | ||
| 788 | (toc.map(|toc| catalog_path(&folder.path, toc)).into_iter()) | ||
| 789 | .chain(folder.sections.iter().map(|section| section.path.clone())) | ||
| 790 | }) | ||
| 791 | .collect(); | ||
| 792 | let to = self.storage.rename_root(name, &files)?; | ||
| 793 | let mut moves = vec![(from.clone(), to.clone())]; | ||
| 794 | moves.extend( | ||
| 795 | (self.catalog.sections()) | ||
| 796 | .filter(|section| section.copy) | ||
| 797 | .map(|section| { | ||
| 798 | ( | ||
| 799 | replica_location(&from, section), | ||
| 800 | replica_location(&to, section), | ||
| 801 | ) | ||
| 802 | }), | ||
| 803 | ); | ||
| 804 | for (from, to) in moves { | ||
| 805 | crate::location::moved(&self.cache, &from, &to)?; | ||
| 806 | } | ||
| 807 | let _ = fs::remove_file(&self.listing); | ||
| 808 | self.listing = listing(&self.cache, &to); | ||
| 809 | self.keep_listing(); | ||
| 810 | Ok(to) | ||
| 811 | } | ||
| 812 | |||
| 741 | /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it. | 813 | /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it. |
| 742 | pub fn set_section_color(&mut self, path: &str, color: Option<u32>) -> Result<()> { | 814 | pub fn set_section_color(&mut self, path: &str, color: Option<u32>) -> Result<()> { |
| 743 | let path = self.section_path(path)?.path.clone(); | 815 | let path = self.section_path(path)?.path.clone(); |
| ... | @@ -1678,6 +1750,24 @@ pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> { | ... | @@ -1678,6 +1750,24 @@ pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> { |
| 1678 | })) | 1750 | })) |
| 1679 | } | 1751 | } |
| 1680 | 1752 | ||
| 1753 | /// Where the cache keeps what reading the catalog of the notebook at `location` took. | ||
| 1754 | fn listing(cache: &Path, location: &str) -> PathBuf { | ||
| 1755 | let name: String = <sha2::Sha256 as sha2::Digest>::digest(location)[..16] | ||
| 1756 | .iter() | ||
| 1757 | .map(|byte| format!("{byte:02x}")) | ||
| 1758 | .collect(); | ||
| 1759 | cache.join("listings").join(format!("{name}.json")) | ||
| 1760 | } | ||
| 1761 | |||
| 1762 | /// Whether `name` can name a folder on every system a notebook's readers use, Windows's | ||
| 1763 | /// included. | ||
| 1764 | fn folder_name(name: &str) -> bool { | ||
| 1765 | component(name) | ||
| 1766 | && !name.contains(['<', '>', ':', '"', '|', '?', '*']) | ||
| 1767 | && !name.chars().any(char::is_control) | ||
| 1768 | && !name.ends_with(['.', ' ']) | ||
| 1769 | } | ||
| 1770 | |||
| 1681 | fn split(path: &str) -> (&str, &str) { | 1771 | fn split(path: &str) -> (&str, &str) { |
| 1682 | match path.rsplit_once('/') { | 1772 | match path.rsplit_once('/') { |
| 1683 | Some((folder, name)) => (folder, name), | 1773 | Some((folder, name)) => (folder, name), |
crates/notebook/src/session/tests.rs+3| ... | @@ -49,6 +49,9 @@ impl Storage for Racing { | ... | @@ -49,6 +49,9 @@ impl Storage for Racing { |
| 49 | fn rename(&self, from: &str, to: &str) -> Result<()> { | 49 | fn rename(&self, from: &str, to: &str) -> Result<()> { |
| 50 | self.inner.rename(from, to) | 50 | self.inner.rename(from, to) |
| 51 | } | 51 | } |
| 52 | fn rename_root(&self, name: &str, files: &[String]) -> Result<String> { | ||
| 53 | self.inner.rename_root(name, files) | ||
| 54 | } | ||
| 52 | fn replace(&self, from: &str, to: &str) -> Result<()> { | 55 | fn replace(&self, from: &str, to: &str) -> Result<()> { |
| 53 | self.inner.replace(from, to) | 56 | self.inner.replace(from, to) |
| 54 | } | 57 | } |
crates/notebook/src/sidecar/tests.rs+4| ... | @@ -139,6 +139,10 @@ impl Storage for Folder { | ... | @@ -139,6 +139,10 @@ impl Storage for Folder { |
| 139 | Ok(()) | 139 | Ok(()) |
| 140 | } | 140 | } |
| 141 | 141 | ||
| 142 | fn rename_root(&self, _: &str, _: &[String]) -> Result<String> { | ||
| 143 | unreachable!() | ||
| 144 | } | ||
| 145 | |||
| 142 | fn replace(&self, from: &str, to: &str) -> Result<()> { | 146 | fn replace(&self, from: &str, to: &str) -> Result<()> { |
| 143 | let mut files = self.files.lock().unwrap(); | 147 | let mut files = self.files.lock().unwrap(); |
| 144 | let bytes = files.remove(from).ok_or_else(missing)?; | 148 | let bytes = files.remove(from).ok_or_else(missing)?; |
crates/notebook/src/smb/mod.rs+6| ... | @@ -329,6 +329,12 @@ impl Client { | ... | @@ -329,6 +329,12 @@ impl Client { |
| 329 | file.close() | 329 | file.close() |
| 330 | } | 330 | } |
| 331 | 331 | ||
| 332 | /// Takes and lets go of OneNote 2010's writer opening and locks on the file at `path`, as a | ||
| 333 | /// commit does; `WouldBlock` while another writer holds them. | ||
| 334 | pub(crate) fn unheld(&self, path: &str) -> io::Result<()> { | ||
| 335 | self.open(path, true)?.coordinate(path, true, &[])?.close() | ||
| 336 | } | ||
| 337 | |||
| 332 | /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010 | 338 | /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010 |
| 333 | /// skips a hidden folder. | 339 | /// skips a hidden folder. |
| 334 | pub(crate) fn hide(&self, path: &str) -> io::Result<()> { | 340 | pub(crate) fn hide(&self, path: &str) -> io::Result<()> { |
crates/notebook/src/smb/tests.rs+66| ... | @@ -835,6 +835,72 @@ fn live_structure() { | ... | @@ -835,6 +835,72 @@ fn live_structure() { |
| 835 | client.delete(&root).unwrap(); | 835 | client.delete(&root).unwrap(); |
| 836 | } | 836 | } |
| 837 | 837 | ||
| 838 | /// Renames a notebook folder of its own on the share, signing in as `ONESTORE_SMB_LAB_USER` | ||
| 839 | /// where set: refused while another client holds OneNote's writer locks on a section, then | ||
| 840 | /// done with every file as it was. | ||
| 841 | #[test] | ||
| 842 | #[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"] | ||
| 843 | fn live_folder_rename() { | ||
| 844 | use crate::session::Notebook; | ||
| 845 | let (user, password) = ( | ||
| 846 | std::env::var("ONESTORE_SMB_LAB_USER").unwrap_or_default(), | ||
| 847 | std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default(), | ||
| 848 | ); | ||
| 849 | let connect = || { | ||
| 850 | let credentials = Credentials { | ||
| 851 | username: &user, | ||
| 852 | password: &password, | ||
| 853 | domain: "", | ||
| 854 | }; | ||
| 855 | let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); | ||
| 856 | Client::connect(&address, "agent", credentials, Duration::from_secs(10)).unwrap() | ||
| 857 | }; | ||
| 858 | let (client, other) = (std::sync::Arc::new(connect()), connect()); | ||
| 859 | let parent = format!("rename-{}", std::process::id()); | ||
| 860 | let (root, renamed) = (format!("{parent}/Before"), format!("{parent}/After")); | ||
| 861 | client.create_directory(&parent).unwrap(); | ||
| 862 | client.create_directory(&root).unwrap(); | ||
| 863 | let section = onestore::create_section("First.one", "First page", "Author").unwrap(); | ||
| 864 | client | ||
| 865 | .create(&format!("{root}/First.one"), &section) | ||
| 866 | .unwrap(); | ||
| 867 | let id = onestore::Store::parse(&section).unwrap().header.file_id; | ||
| 868 | let toc = | ||
| 869 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", id)]).unwrap(); | ||
| 870 | client | ||
| 871 | .create(&format!("{root}/Open Notebook.onetoc2"), &toc) | ||
| 872 | .unwrap(); | ||
| 873 | let cache = tempfile::tempdir().unwrap(); | ||
| 874 | let open = || Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap(); | ||
| 875 | let held = format!("{root}/First.one"); | ||
| 876 | let writer = other | ||
| 877 | .open(&held, true) | ||
| 878 | .unwrap() | ||
| 879 | .coordinate(&held, true, &[]) | ||
| 880 | .unwrap(); | ||
| 881 | let refused = open().rename_folder("After").unwrap_err(); | ||
| 882 | assert!( | ||
| 883 | matches!(&refused, crate::Error::Io(error) if error.kind() == io::ErrorKind::WouldBlock), | ||
| 884 | "{refused}" | ||
| 885 | ); | ||
| 886 | assert!(client.read_dir(&root, 10).is_ok()); | ||
| 887 | writer.close().unwrap(); | ||
| 888 | let to = open().rename_folder("After").unwrap(); | ||
| 889 | assert_eq!(to, client.location(&renamed)); | ||
| 890 | assert!(client.read_dir(&root, 10).is_err()); | ||
| 891 | assert_eq!( | ||
| 892 | client | ||
| 893 | .read_storage(&format!("{renamed}/First.one"), 1 << 20) | ||
| 894 | .unwrap(), | ||
| 895 | section | ||
| 896 | ); | ||
| 897 | for path in ["First.one", "Open Notebook.onetoc2"] { | ||
| 898 | client.delete(&format!("{renamed}/{path}")).unwrap(); | ||
| 899 | } | ||
| 900 | client.delete(&renamed).unwrap(); | ||
| 901 | client.delete(&parent).unwrap(); | ||
| 902 | } | ||
| 903 | |||
| 838 | /// Signs in as `ONESTORE_SMB_LAB_USER` with `ONESTORE_SMB_LAB_PASSWORD` where set, else as a | 904 | /// Signs in as `ONESTORE_SMB_LAB_USER` with `ONESTORE_SMB_LAB_PASSWORD` where set, else as a |
| 839 | /// guest, to a server with a share `agent`; only lists and reads. | 905 | /// guest, to a server with a share `agent`; only lists and reads. |
| 840 | #[test] | 906 | #[test] |
crates/notebook/tests/location.rs+53| ... | @@ -167,6 +167,59 @@ fn a_notebook_the_app_moves_keeps_its_queued_edits() { | ... | @@ -167,6 +167,59 @@ fn a_notebook_the_app_moves_keeps_its_queued_edits() { |
| 167 | assert_eq!(published(&root, &cache), 0); | 167 | assert_eq!(published(&root, &cache), 0); |
| 168 | } | 168 | } |
| 169 | 169 | ||
| 170 | #[test] | ||
| 171 | fn renaming_a_notebook_folder_keeps_its_queued_edits_and_files() { | ||
| 172 | let directory = tempfile::tempdir().unwrap(); | ||
| 173 | let cache = directory.path().join("cache"); | ||
| 174 | let root = notebook(directory.path(), "Notebook"); | ||
| 175 | let space = queued(&root, &cache, "Queued "); | ||
| 176 | let before = std::fs::read(root.join("Second.one")).unwrap(); | ||
| 177 | let renamed = directory.path().join("Renamed"); | ||
| 178 | let to = Notebook::open(&root, &cache) | ||
| 179 | .unwrap() | ||
| 180 | .rename_folder("Renamed") | ||
| 181 | .unwrap(); | ||
| 182 | assert_eq!(to, notebook::location::local(&renamed).unwrap()); | ||
| 183 | assert!(std::fs::metadata(&root).is_err()); | ||
| 184 | // OneNote finds nothing inside changed. | ||
| 185 | assert_eq!(std::fs::read(renamed.join("Second.one")).unwrap(), before); | ||
| 186 | // A new notebook where the old one was takes nothing of the queue. | ||
| 187 | notebook(directory.path(), "Notebook"); | ||
| 188 | assert_eq!(published(&renamed, &cache), 1); | ||
| 189 | assert!(stored(&renamed.join("First.one"), space).starts_with("Queued ")); | ||
| 190 | assert_eq!(published(&root, &cache), 0); | ||
| 191 | } | ||
| 192 | |||
| 193 | #[test] | ||
| 194 | fn a_notebook_folder_rename_refuses_a_taken_or_unportable_name_and_changes_nothing() { | ||
| 195 | let directory = tempfile::tempdir().unwrap(); | ||
| 196 | let cache = directory.path().join("cache"); | ||
| 197 | let root = notebook(directory.path(), "Notebook"); | ||
| 198 | notebook(directory.path(), "Taken"); | ||
| 199 | for (name, kind) in [ | ||
| 200 | ("Taken", std::io::ErrorKind::AlreadyExists), | ||
| 201 | ("Notes: 2026", std::io::ErrorKind::InvalidInput), | ||
| 202 | ("a/b", std::io::ErrorKind::InvalidInput), | ||
| 203 | ("Trailing.", std::io::ErrorKind::InvalidInput), | ||
| 204 | ] { | ||
| 205 | let error = Notebook::open(&root, &cache) | ||
| 206 | .unwrap() | ||
| 207 | .rename_folder(name) | ||
| 208 | .unwrap_err(); | ||
| 209 | assert!( | ||
| 210 | matches!(&error, notebook::Error::Io(error) if error.kind() == kind), | ||
| 211 | "{name}: {error}" | ||
| 212 | ); | ||
| 213 | assert!(std::fs::metadata(root.join("First.one")).is_ok()); | ||
| 214 | } | ||
| 215 | // A change of case alone renames the folder itself. | ||
| 216 | let to = Notebook::open(&root, &cache) | ||
| 217 | .unwrap() | ||
| 218 | .rename_folder("NOTEBOOK") | ||
| 219 | .unwrap(); | ||
| 220 | assert!(to.ends_with("NOTEBOOK")); | ||
| 221 | } | ||
| 222 | |||
| 170 | #[test] | 223 | #[test] |
| 171 | fn a_notebook_moved_outside_the_app_takes_its_replicas_along() { | 224 | fn a_notebook_moved_outside_the_app_takes_its_replicas_along() { |
| 172 | let directory = tempfile::tempdir().unwrap(); | 225 | let directory = tempfile::tempdir().unwrap(); |
crates/snowbound/src/library.rs+214-6| ... | @@ -452,12 +452,76 @@ impl Library { | ... | @@ -452,12 +452,76 @@ impl Library { |
| 452 | /// Names the notebook `name` on this computer, leaving its folder as it is, as OneNote | 452 | /// Names the notebook `name` on this computer, leaving its folder as it is, as OneNote |
| 453 | /// 2010's Notebook Properties does; the notebook read again with `with` shows it. | 453 | /// 2010's Notebook Properties does; the notebook read again with `with` shows it. |
| 454 | pub fn set_display_name(&self, name: &str) -> io::Result<()> { | 454 | pub fn set_display_name(&self, name: &str) -> io::Result<()> { |
| 455 | let file = display_names(&self.cache); | 455 | edit_display_names(&self.cache, |names| { |
| 456 | let mut names = read_display_names(&file); | 456 | names.insert(self.location.clone(), name.to_owned()); |
| 457 | names.insert(self.location.clone(), name.to_owned()); | 457 | }) |
| 458 | let partial = file.with_extension("partial"); | 458 | } |
| 459 | notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; | 459 | |
| 460 | notebook::fs::rename(partial, file) | 460 | /// Where the notebook would be with its folder named `name`; none for a section opened on |
| 461 | /// its own, or a notebook at the top of its share. | ||
| 462 | pub fn renamed_location(&self, name: &str) -> Option<String> { | ||
| 463 | self.catalog()?; | ||
| 464 | if let Some(mut mount) = server_address(&self.location) { | ||
| 465 | let (parent, _) = mount.root.rsplit_once('/').unwrap_or(("", &mount.root)); | ||
| 466 | mount.root = match (parent, mount.root.is_empty()) { | ||
| 467 | (_, true) => return None, | ||
| 468 | ("", false) => name.to_owned(), | ||
| 469 | (parent, false) => format!("{parent}/{name}"), | ||
| 470 | }; | ||
| 471 | return Some(mount.url()); | ||
| 472 | } | ||
| 473 | let parent = Path::new(&self.location).parent()?; | ||
| 474 | Some(parent.join(name).to_string_lossy().into_owned()) | ||
| 475 | } | ||
| 476 | |||
| 477 | /// Renames the notebook's folder to `name` once nothing of this notebook holds its files: | ||
| 478 | /// its background stopped and its kept sections closed, as the open section must be too. | ||
| 479 | /// The replicas, and edits waiting in them, follow, and the folder's own name takes the | ||
| 480 | /// place of a display name. Refused, with nothing changed, while another writer holds a file | ||
| 481 | /// of it. Returns the notebook's new location, where it opens again, or why not, as its | ||
| 482 | /// reader is told. | ||
| 483 | pub fn rename_folder(&self, name: &str) -> Result<String, String> { | ||
| 484 | let to = self | ||
| 485 | .renamed_location(name) | ||
| 486 | .ok_or("This notebook’s folder can’t be renamed.")?; | ||
| 487 | if let Some(background) = &self.background { | ||
| 488 | background.stop(); | ||
| 489 | } | ||
| 490 | self.close_kept(); | ||
| 491 | let renamed = self | ||
| 492 | .reopen() | ||
| 493 | .and_then(|notebook| Ok(notebook.rename_folder(name)?)); | ||
| 494 | if let Err(error) = renamed { | ||
| 495 | let kind = match error.downcast_ref::<notebook::Error>() { | ||
| 496 | Some(notebook::Error::Io(error) | notebook::Error::RemoteIo(error)) => { | ||
| 497 | Some(error.kind()) | ||
| 498 | } | ||
| 499 | _ => error.downcast_ref::<io::Error>().map(io::Error::kind), | ||
| 500 | }; | ||
| 501 | return Err(match kind { | ||
| 502 | Some(io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy) => { | ||
| 503 | "Another computer is saving to this notebook. Try again in a moment.".into() | ||
| 504 | } | ||
| 505 | Some(io::ErrorKind::PermissionDenied) => "Its files are open on another \ | ||
| 506 | computer, or you can’t rename folders there. Close the notebook in OneNote \ | ||
| 507 | on other computers, then try again." | ||
| 508 | .into(), | ||
| 509 | Some(io::ErrorKind::AlreadyExists) => { | ||
| 510 | format!("A folder named “{name}” is already there. Choose another name.") | ||
| 511 | } | ||
| 512 | Some(io::ErrorKind::InvalidInput) => "A folder name can’t contain \\ / : * ? \" \ | ||
| 513 | < > | or end with a dot or space." | ||
| 514 | .into(), | ||
| 515 | _ => error.to_string(), | ||
| 516 | }); | ||
| 517 | } | ||
| 518 | // The folder's own name shows now. | ||
| 519 | if let Err(error) = edit_display_names(&self.cache, |names| { | ||
| 520 | names.remove(&self.location); | ||
| 521 | }) { | ||
| 522 | eprintln!("{}: {error}", self.location); | ||
| 523 | } | ||
| 524 | Ok(to) | ||
| 461 | } | 525 | } |
| 462 | 526 | ||
| 463 | /// The art the notebook's tags draw with. | 527 | /// The art the notebook's tags draw with. |
| ... | @@ -795,6 +859,11 @@ impl Library { | ... | @@ -795,6 +859,11 @@ impl Library { |
| 795 | .then(|| Path::new(&self.location)) | 859 | .then(|| Path::new(&self.location)) |
| 796 | } | 860 | } |
| 797 | 861 | ||
| 862 | /// Whether the notebook is on an SMB share, however it is reached. | ||
| 863 | pub fn on_smb(&self) -> bool { | ||
| 864 | self.server.is_some() || server_address(&self.location).is_some() || self.notice.is_some() | ||
| 865 | } | ||
| 866 | |||
| 798 | /// Whether iCloud Drive keeps the notebook, or the section opened on its own. | 867 | /// Whether iCloud Drive keeps the notebook, or the section opened on its own. |
| 799 | pub fn in_icloud(&self) -> bool { | 868 | pub fn in_icloud(&self) -> bool { |
| 800 | self.folder().is_some_and(crate::icloud::ubiquitous) | 869 | self.folder().is_some_and(crate::icloud::ubiquitous) |
| ... | @@ -1038,6 +1107,18 @@ fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String> | ... | @@ -1038,6 +1107,18 @@ fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String> |
| 1038 | .unwrap_or_default() | 1107 | .unwrap_or_default() |
| 1039 | } | 1108 | } |
| 1040 | 1109 | ||
| 1110 | fn edit_display_names( | ||
| 1111 | cache: &Path, | ||
| 1112 | edit: impl FnOnce(&mut std::collections::BTreeMap<String, String>), | ||
| 1113 | ) -> io::Result<()> { | ||
| 1114 | let file = display_names(cache); | ||
| 1115 | let mut names = read_display_names(&file); | ||
| 1116 | edit(&mut names); | ||
| 1117 | let partial = file.with_extension("partial"); | ||
| 1118 | notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; | ||
| 1119 | notebook::fs::rename(partial, file) | ||
| 1120 | } | ||
| 1121 | |||
| 1041 | fn display_name(cache: &Path, location: &str) -> Option<String> { | 1122 | fn display_name(cache: &Path, location: &str) -> Option<String> { |
| 1042 | read_display_names(&display_names(cache)).remove(location) | 1123 | read_display_names(&display_names(cache)).remove(location) |
| 1043 | } | 1124 | } |
| ... | @@ -1428,6 +1509,133 @@ mod tests { | ... | @@ -1428,6 +1509,133 @@ mod tests { |
| 1428 | } | 1509 | } |
| 1429 | } | 1510 | } |
| 1430 | 1511 | ||
| 1512 | /// Renames a notebook folder on the lab share (`ONESTORE_SMB_LAB`, as above) with an edit | ||
| 1513 | /// queued offline, which publishes into the renamed folder; `notebook`'s | ||
| 1514 | /// `live_folder_rename` covers the refusal while another writer holds a section. | ||
| 1515 | #[test] | ||
| 1516 | #[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"] | ||
| 1517 | fn a_notebook_folder_on_a_share_renames_with_its_queue() { | ||
| 1518 | let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); | ||
| 1519 | let parent = format!("snowbound-rename-{}", std::process::id()); | ||
| 1520 | let user = std::env::var("ONESTORE_SMB_LAB_USER").ok(); | ||
| 1521 | let mount = |root: &str| Mount { | ||
| 1522 | server: address.clone(), | ||
| 1523 | share: "agent".into(), | ||
| 1524 | user: user.clone(), | ||
| 1525 | domain: String::new(), | ||
| 1526 | root: format!("{parent}/{root}"), | ||
| 1527 | }; | ||
| 1528 | let login = Login { | ||
| 1529 | user: user.clone().unwrap_or_default(), | ||
| 1530 | password: std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default(), | ||
| 1531 | domain: String::new(), | ||
| 1532 | }; | ||
| 1533 | let connect = || { | ||
| 1534 | Server { | ||
| 1535 | mount: mount("Before"), | ||
| 1536 | login: login.clone(), | ||
| 1537 | } | ||
| 1538 | .connect() | ||
| 1539 | .unwrap() | ||
| 1540 | }; | ||
| 1541 | let client = Arc::new(connect()); | ||
| 1542 | remove_tree(&client, &parent); | ||
| 1543 | client.create_directory(&parent).unwrap(); | ||
| 1544 | client.create_directory(&mount("Before").root).unwrap(); | ||
| 1545 | let cache = std::env::temp_dir().join(&parent); | ||
| 1546 | let page = onestore::PageCreation::new(None, Some(""), "Rust Author").unwrap(); | ||
| 1547 | Notebook::open_smb(Arc::clone(&client), &mount("Before").root, &cache) | ||
| 1548 | .unwrap() | ||
| 1549 | .create_section("", "Queued", &page) | ||
| 1550 | .unwrap(); | ||
| 1551 | let open = |root: &str| { | ||
| 1552 | let location = mount(root).url(); | ||
| 1553 | Library::on_share(&location, mount(root), login.clone(), &cache).unwrap() | ||
| 1554 | }; | ||
| 1555 | let library = open("Before"); | ||
| 1556 | let section = library.open("Queued.one", || {}).unwrap(); | ||
| 1557 | section.set_offline(true); | ||
| 1558 | let (space, ..) = section.pages().unwrap()[0].clone(); | ||
| 1559 | let title = (section.page(space).unwrap().objects.iter()) | ||
| 1560 | .find_map(|object| match object { | ||
| 1561 | onestore::page::PageObject::Title(title) => { | ||
| 1562 | title.outlines[0].paragraphs[0].text().map(|text| text.id) | ||
| 1563 | } | ||
| 1564 | _ => None, | ||
| 1565 | }) | ||
| 1566 | .unwrap(); | ||
| 1567 | let typed = onestore::op::Edit { | ||
| 1568 | at: crate::filetime(), | ||
| 1569 | ops: vec![onestore::op::Op::Page { | ||
| 1570 | space, | ||
| 1571 | op: onestore::op::PageOp::Text { | ||
| 1572 | text: title, | ||
| 1573 | range: 0..0, | ||
| 1574 | with: "Renamed over SMB".into(), | ||
| 1575 | }, | ||
| 1576 | }], | ||
| 1577 | }; | ||
| 1578 | section.apply("Rust Author", typed).unwrap(); | ||
| 1579 | section.close().unwrap(); | ||
| 1580 | assert_eq!( | ||
| 1581 | library.rename_folder("After").unwrap(), | ||
| 1582 | mount("After").url() | ||
| 1583 | ); | ||
| 1584 | assert!(client.read_dir(&mount("Before").root, 10).is_err()); | ||
| 1585 | let renamed = open("After"); | ||
| 1586 | let section = renamed.open("Queued.one", || {}).unwrap(); | ||
| 1587 | let file = format!("{}/Queued.one", mount("After").root); | ||
| 1588 | let deadline = std::time::Instant::now() + Duration::from_secs(60); | ||
| 1589 | loop { | ||
| 1590 | let titles = client | ||
| 1591 | .read_storage(&file, LIMIT) | ||
| 1592 | .ok() | ||
| 1593 | .and_then(|bytes| { | ||
| 1594 | let arena = onestore::Arena::default(); | ||
| 1595 | onestore::Section::open(&arena, bytes).ok()?.pages().ok() | ||
| 1596 | }) | ||
| 1597 | .unwrap_or_default(); | ||
| 1598 | if titles | ||
| 1599 | .iter() | ||
| 1600 | .any(|(_, title, _)| title == "Renamed over SMB") | ||
| 1601 | { | ||
| 1602 | break; | ||
| 1603 | } | ||
| 1604 | assert!(std::time::Instant::now() < deadline, "{titles:?}"); | ||
| 1605 | section.wake(); | ||
| 1606 | std::thread::sleep(Duration::from_millis(200)); | ||
| 1607 | } | ||
| 1608 | section.close().unwrap(); | ||
| 1609 | drop((library, renamed)); | ||
| 1610 | let _ = notebook::fs::remove_dir_all(&cache); | ||
| 1611 | remove_tree(&client, &parent); | ||
| 1612 | } | ||
| 1613 | |||
| 1614 | #[test] | ||
| 1615 | fn a_notebook_folder_renames_unless_its_name_is_taken() { | ||
| 1616 | let directory = | ||
| 1617 | std::env::temp_dir().join(format!("snowbound-rename-{}", std::process::id())); | ||
| 1618 | let cache = directory.join("cache"); | ||
| 1619 | let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap(); | ||
| 1620 | notebook::fs::create_dir_all(&directory).unwrap(); | ||
| 1621 | for name in ["Mine", "Taken"] { | ||
| 1622 | Notebook::create(directory.join(name), &cache, Notebook::NEW_COLOR, &page).unwrap(); | ||
| 1623 | } | ||
| 1624 | let location = |name: &str| directory.join(name).to_string_lossy().into_owned(); | ||
| 1625 | let library = Library::notebook(&location("Mine"), &cache); | ||
| 1626 | library.set_display_name("Shown").unwrap(); | ||
| 1627 | let refused = library.rename_folder("Taken").unwrap_err(); | ||
| 1628 | assert!(refused.starts_with("A folder named “Taken”"), "{refused}"); | ||
| 1629 | let library = Library::notebook(&location("Mine"), &cache); | ||
| 1630 | assert_eq!(library.name, "Shown"); | ||
| 1631 | assert_eq!(library.rename_folder("Ours").unwrap(), location("Ours")); | ||
| 1632 | assert!(notebook::fs::metadata(location("Mine")).is_err()); | ||
| 1633 | let renamed = Library::notebook(&location("Ours"), &cache); | ||
| 1634 | assert_eq!(renamed.name, "Ours"); | ||
| 1635 | assert_eq!(renamed.color(), Some(Notebook::NEW_COLOR)); | ||
| 1636 | notebook::fs::remove_dir_all(&directory).unwrap(); | ||
| 1637 | } | ||
| 1638 | |||
| 1431 | #[test] | 1639 | #[test] |
| 1432 | fn share_relative_sections_show_under_their_mount() { | 1640 | fn share_relative_sections_show_under_their_mount() { |
| 1433 | let library = |root: &str| Library { | 1641 | let library = |root: &str| Library { |
crates/snowbound/src/main.rs+7| ... | @@ -811,6 +811,8 @@ struct State { | ... | @@ -811,6 +811,8 @@ struct State { |
| 811 | /// Notebooks in iCloud Drive a thread is reading, or following the download of, by | 811 | /// Notebooks in iCloud Drive a thread is reading, or following the download of, by |
| 812 | /// location. | 812 | /// location. |
| 813 | icloud_reading: HashSet<String>, | 813 | icloud_reading: HashSet<String>, |
| 814 | /// The notebook locations a folder rename takes a notebook from and to, while it does. | ||
| 815 | folder_rename: Option<(String, String)>, | ||
| 814 | /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply. | 816 | /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply. |
| 815 | tags: Vec<canvas::editor::NoteTag>, | 817 | tags: Vec<canvas::editor::NoteTag>, |
| 816 | /// The Customize Tags dialog's list while it is open. | 818 | /// The Customize Tags dialog's list while it is open. |
| ... | @@ -1197,6 +1199,7 @@ impl State { | ... | @@ -1197,6 +1199,7 @@ impl State { |
| 1197 | peers: None, | 1199 | peers: None, |
| 1198 | server: None, | 1200 | server: None, |
| 1199 | icloud_reading: HashSet::new(), | 1201 | icloud_reading: HashSet::new(), |
| 1202 | folder_rename: None, | ||
| 1200 | tags: stored | 1203 | tags: stored |
| 1201 | .tags | 1204 | .tags |
| 1202 | .unwrap_or_else(canvas::editor::NoteTag::defaults), | 1205 | .unwrap_or_else(canvas::editor::NoteTag::defaults), |
| ... | @@ -3160,6 +3163,10 @@ impl State { | ... | @@ -3160,6 +3163,10 @@ impl State { |
| 3160 | fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> { | 3163 | fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> { |
| 3161 | match command { | 3164 | match command { |
| 3162 | Command::OpenSection(library, path) => { | 3165 | Command::OpenSection(library, path) => { |
| 3166 | // Its files are on their way to the renamed folder. | ||
| 3167 | if self.folder_renaming(&library.location) { | ||
| 3168 | return Ok(()); | ||
| 3169 | } | ||
| 3163 | if library.locked(&path) { | 3170 | if library.locked(&path) { |
| 3164 | return self.show_locked(library, path); | 3171 | return self.show_locked(library, path); |
| 3165 | } | 3172 | } |
crates/snowbound/src/manage.rs+133-2| ... | @@ -298,7 +298,11 @@ impl State { | ... | @@ -298,7 +298,11 @@ impl State { |
| 298 | .notebooks | 298 | .notebooks |
| 299 | .iter() | 299 | .iter() |
| 300 | .filter(|library| { | 300 | .filter(|library| { |
| 301 | in_icloud_folder(&library.location) && !listed.contains(&library.location) | 301 | in_icloud_folder(&library.location) |
| 302 | && !listed.contains(&library.location) | ||
| 303 | && !self.folder_renaming(&library.location) | ||
| 304 | // A listing taken before a rename names the folder by its old name. | ||
| 305 | && notebook::fs::metadata(&library.location).is_err() | ||
| 302 | }) | 306 | }) |
| 303 | .cloned() | 307 | .cloned() |
| 304 | .collect(); | 308 | .collect(); |
| ... | @@ -307,16 +311,21 @@ impl State { | ... | @@ -307,16 +311,21 @@ impl State { |
| 307 | } | 311 | } |
| 308 | for location in listed { | 312 | for location in listed { |
| 309 | if self.notebooks.iter().any(|open| open.location == location) | 313 | if self.notebooks.iter().any(|open| open.location == location) |
| 314 | || self.folder_renaming(&location) | ||
| 310 | || !self.icloud_reading.insert(location.clone()) | 315 | || !self.icloud_reading.insert(location.clone()) |
| 311 | { | 316 | { |
| 312 | continue; | 317 | continue; |
| 313 | } | 318 | } |
| 314 | let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); | 319 | let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); |
| 315 | crate::spawn(move || { | 320 | crate::spawn(move || { |
| 321 | let gone = notebook::fs::metadata(&location).is_err(); | ||
| 316 | let library = Arc::new(Library::notebook(&location, &cache)); | 322 | let library = Arc::new(Library::notebook(&location, &cache)); |
| 317 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { | 323 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { |
| 318 | state.icloud_reading.remove(&location); | 324 | state.icloud_reading.remove(&location); |
| 319 | if state.notebooks.iter().any(|open| open.location == location) { | 325 | if gone |
| 326 | || state.notebooks.iter().any(|open| open.location == location) | ||
| 327 | || state.folder_renaming(&location) | ||
| 328 | { | ||
| 320 | return Ok(()); | 329 | return Ok(()); |
| 321 | } | 330 | } |
| 322 | state.notebooks.push(Arc::clone(&library)); | 331 | state.notebooks.push(Arc::clone(&library)); |
| ... | @@ -329,6 +338,13 @@ impl State { | ... | @@ -329,6 +338,13 @@ impl State { |
| 329 | } | 338 | } |
| 330 | } | 339 | } |
| 331 | 340 | ||
| 341 | /// Whether a folder rename is taking the notebook from or to `location`. | ||
| 342 | pub(crate) fn folder_renaming(&self, location: &str) -> bool { | ||
| 343 | self.folder_rename | ||
| 344 | .as_ref() | ||
| 345 | .is_some_and(|(from, to)| from == location || to == location) | ||
| 346 | } | ||
| 347 | |||
| 332 | /// Shows `library`, a notebook that just arrived or whose sections did, where no other | 348 | /// Shows `library`, a notebook that just arrived or whose sections did, where no other |
| 333 | /// notebook is shown: at its first section, or without one while none is here yet. | 349 | /// notebook is shown: at its first section, or without one while none is here yet. |
| 334 | pub(crate) fn show_arrived(&mut self, library: &Arc<Library>) { | 350 | pub(crate) fn show_arrived(&mut self, library: &Arc<Library>) { |
| ... | @@ -502,6 +518,121 @@ impl State { | ... | @@ -502,6 +518,121 @@ impl State { |
| 502 | self.save_settings(); | 518 | self.save_settings(); |
| 503 | } | 519 | } |
| 504 | 520 | ||
| 521 | /// Renames `library`'s folder to `name` on a thread of its own, the notebook closed | ||
| 522 | /// meanwhile, then opens it from there in its place, at the section it showed, with `color` | ||
| 523 | /// given it. What this computer keeps by the notebook's location follows it. Refused, the | ||
| 524 | /// notebook opens again as it was. | ||
| 525 | pub(crate) fn rename_notebook( | ||
| 526 | &mut self, | ||
| 527 | library: Arc<Library>, | ||
| 528 | name: String, | ||
| 529 | color: Option<u32>, | ||
| 530 | ) { | ||
| 531 | let Some(to) = library.renamed_location(&name) else { | ||
| 532 | return; | ||
| 533 | }; | ||
| 534 | let ours = |shown: &Arc<Library>| Arc::ptr_eq(shown, &library); | ||
| 535 | let shown = self | ||
| 536 | .session | ||
| 537 | .as_ref() | ||
| 538 | .filter(|session| ours(&session.library)) | ||
| 539 | .map(|session| session.tabs[session.tab].path.clone()); | ||
| 540 | if shown.is_some() { | ||
| 541 | let closed = self.persist().and_then(|()| match self.session.take() { | ||
| 542 | Some(session) => Ok(session.section.close()?), | ||
| 543 | None => Ok(()), | ||
| 544 | }); | ||
| 545 | if let Err(error) = closed { | ||
| 546 | return platform::alert("Couldn't rename the folder", &error.to_string()); | ||
| 547 | } | ||
| 548 | } | ||
| 549 | let showing = shown.is_some() || self.sectionless.as_ref().is_some_and(ours); | ||
| 550 | if showing { | ||
| 551 | self.sectionless = Some(Arc::clone(&library)); | ||
| 552 | self.title(); | ||
| 553 | } | ||
| 554 | self.folder_rename = Some((library.location.clone(), to.clone())); | ||
| 555 | let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); | ||
| 556 | crate::spawn(move || { | ||
| 557 | let renamed = library.rename_folder(&name); | ||
| 558 | // A failure after the folder moved, as its replicas followed, leaves it there. | ||
| 559 | let moved = library | ||
| 560 | .folder() | ||
| 561 | .is_some_and(|folder| notebook::fs::metadata(folder).is_err()); | ||
| 562 | let location = match &renamed { | ||
| 563 | Ok(to) => to, | ||
| 564 | Err(_) if moved => &to, | ||
| 565 | Err(_) => &library.location, | ||
| 566 | }; | ||
| 567 | let mut reopened = Library::notebook(location, &cache); | ||
| 568 | let mut problem = renamed.err(); | ||
| 569 | if let (None, Some(color)) = (&problem, color) { | ||
| 570 | let colored = reopened.reopen().and_then(|mut notebook| { | ||
| 571 | notebook.set_color(color)?; | ||
| 572 | Ok(reopened.with(notebook)) | ||
| 573 | }); | ||
| 574 | match colored { | ||
| 575 | Ok(colored) => reopened = colored, | ||
| 576 | Err(error) => problem = Some(error.to_string()), | ||
| 577 | } | ||
| 578 | } | ||
| 579 | let reopened = Arc::new(reopened); | ||
| 580 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { | ||
| 581 | state.renamed_notebook(&library, reopened, showing.then_some(shown)); | ||
| 582 | if let Some(problem) = problem { | ||
| 583 | platform::alert("Couldn't rename the folder", &problem); | ||
| 584 | } | ||
| 585 | Ok(()) | ||
| 586 | }))); | ||
| 587 | }); | ||
| 588 | } | ||
| 589 | |||
| 590 | /// Lists `reopened` in the place of `old`, the notebook `rename_notebook` closed, moving | ||
| 591 | /// what this computer keeps by its location, and shows it again where `shown`: at the | ||
| 592 | /// section it showed, or its first. | ||
| 593 | fn renamed_notebook( | ||
| 594 | &mut self, | ||
| 595 | old: &Arc<Library>, | ||
| 596 | reopened: Arc<Library>, | ||
| 597 | shown: Option<Option<String>>, | ||
| 598 | ) { | ||
| 599 | self.folder_rename = None; | ||
| 600 | let (from, to) = (old.location.clone(), reopened.location.clone()); | ||
| 601 | match (self.notebooks.iter_mut()).find(|open| Arc::ptr_eq(open, old)) { | ||
| 602 | Some(open) => *open = Arc::clone(&reopened), | ||
| 603 | None => self.notebooks.push(Arc::clone(&reopened)), | ||
| 604 | } | ||
| 605 | // iCloud Drive's listing may have found the renamed folder first. | ||
| 606 | let mut listed = false; | ||
| 607 | self.notebooks | ||
| 608 | .retain(|open| open.location != to || !std::mem::replace(&mut listed, true)); | ||
| 609 | if from != to { | ||
| 610 | self.undo.close(&from); | ||
| 611 | self.trail.moved(&from, &to); | ||
| 612 | self.reads.moved(&from, &to); | ||
| 613 | let (from, to) = (crate::library::key(&from, ""), crate::library::key(&to, "")); | ||
| 614 | let rekey = |key: &String| match key.strip_prefix(&from) { | ||
| 615 | Some(rest) => format!("{to}{rest}"), | ||
| 616 | None => key.clone(), | ||
| 617 | }; | ||
| 618 | self.folded = self.folded.iter().map(rekey).collect(); | ||
| 619 | self.last_pages = (self.last_pages.iter()) | ||
| 620 | .map(|(key, space)| (rekey(key), *space)) | ||
| 621 | .collect(); | ||
| 622 | } | ||
| 623 | if let Some(shown) = shown { | ||
| 624 | self.sectionless = Some(Arc::clone(&reopened)); | ||
| 625 | self.title(); | ||
| 626 | if let Some(path) = shown | ||
| 627 | .filter(|path| reopened.contains(path)) | ||
| 628 | .or_else(|| reopened.first_section()) | ||
| 629 | { | ||
| 630 | self.commands.push(Command::OpenSection(reopened, path)); | ||
| 631 | } | ||
| 632 | } | ||
| 633 | self.save_settings(); | ||
| 634 | } | ||
| 635 | |||
| 505 | /// Changes `library`'s sections and groups on a thread of its own, then shows the | 636 | /// Changes `library`'s sections and groups on a thread of its own, then shows the |
| 506 | /// section the change leaves open: a new or moved one, or the one shown before. | 637 | /// section the change leaves open: a new or moved one, or the one shown before. |
| 507 | pub(crate) fn restructure(&mut self, library: Arc<Library>, change: Structure) { | 638 | pub(crate) fn restructure(&mut self, library: Arc<Library>, change: Structure) { |
crates/snowbound/src/navigation.rs+11| ... | @@ -31,6 +31,17 @@ const KEPT: usize = 100; | ... | @@ -31,6 +31,17 @@ const KEPT: usize = 100; |
| 31 | const RECENT: usize = 8; | 31 | const RECENT: usize = 8; |
| 32 | 32 | ||
| 33 | impl Trail { | 33 | impl Trail { |
| 34 | /// Follows the notebook at `from` to `to`, where it moved. | ||
| 35 | pub fn moved(&mut self, from: &str, to: &str) { | ||
| 36 | let places = (self.back.iter_mut()) | ||
| 37 | .chain(&mut self.here) | ||
| 38 | .chain(&mut self.forward) | ||
| 39 | .chain(&mut self.recent); | ||
| 40 | for place in places.filter(|place| place.notebook == from) { | ||
| 41 | place.notebook = to.to_owned(); | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 34 | /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages | 45 | /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages |
| 35 | /// ahead, as a browser does. | 46 | /// ahead, as a browser does. |
| 36 | pub fn visit(&mut self, place: Place) { | 47 | pub fn visit(&mut self, place: Place) { |
crates/snowbound/src/properties.rs+32-1| ... | @@ -15,6 +15,8 @@ pub struct Properties { | ... | @@ -15,6 +15,8 @@ pub struct Properties { |
| 15 | name: String, | 15 | name: String, |
| 16 | /// A colour picked, COLORREF. | 16 | /// A colour picked, COLORREF. |
| 17 | color: Option<u32>, | 17 | color: Option<u32>, |
| 18 | /// Renames the notebook's folder to a name changed. | ||
| 19 | rename_folder: bool, | ||
| 18 | } | 20 | } |
| 19 | 21 | ||
| 20 | fn id() -> Id { | 22 | fn id() -> Id { |
| ... | @@ -37,6 +39,7 @@ impl State { | ... | @@ -37,6 +39,7 @@ impl State { |
| 37 | name: library.name.clone(), | 39 | name: library.name.clone(), |
| 38 | library, | 40 | library, |
| 39 | color: None, | 41 | color: None, |
| 42 | rename_folder: true, | ||
| 40 | }); | 43 | }); |
| 41 | self.ui.open_popup(id()); | 44 | self.ui.open_popup(id()); |
| 42 | self.ui.focus_all(name_field()); | 45 | self.ui.focus_all(name_field()); |
| ... | @@ -126,7 +129,28 @@ impl State { | ... | @@ -126,7 +129,28 @@ impl State { |
| 126 | if let Some(node) = ui.access(name_field()) { | 129 | if let Some(node) = ui.access(name_field()) { |
| 127 | node.set_label("Display name"); | 130 | node.set_label("Display name"); |
| 128 | } | 131 | } |
| 129 | dim(ui, "hint", "Doesn’t change the notebook’s folder name"); | 132 | let renamable = dialog.name.trim() != dialog.library.name |
| 133 | && (dialog.library.renamed_location(dialog.name.trim())).is_some(); | ||
| 134 | if renamable { | ||
| 135 | let rename = ui::check_box(ui, "rename", "Rename the folder too", dialog.rename_folder); | ||
| 136 | if rename.clicked { | ||
| 137 | dialog.rename_folder = !dialog.rename_folder; | ||
| 138 | } | ||
| 139 | if dialog.rename_folder && dialog.library.on_smb() { | ||
| 140 | ui.leaf( | ||
| 141 | "shared", | ||
| 142 | Spec { | ||
| 143 | flags: ui::Flags::CLIP, | ||
| 144 | size: [fill(), px(row * 0.8)], | ||
| 145 | text: Some("Other computers must reopen it from the new folder"), | ||
| 146 | icon: Some(art::WARNING), | ||
| 147 | ..Spec::default() | ||
| 148 | }, | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | } else { | ||
| 152 | dim(ui, "hint", "Doesn’t change the notebook’s folder name"); | ||
| 153 | } | ||
| 130 | label(ui, "Color:"); | 154 | label(ui, "Color:"); |
| 131 | let shown = dialog.color.or(dialog.library.color()); | 155 | let shown = dialog.color.or(dialog.library.color()); |
| 132 | let named = SECTION_COLORS | 156 | let named = SECTION_COLORS |
| ... | @@ -161,6 +185,13 @@ impl State { | ... | @@ -161,6 +185,13 @@ impl State { |
| 161 | ui.close(); | 185 | ui.close(); |
| 162 | ui.close(); | 186 | ui.close(); |
| 163 | let name = dialog.name.trim(); | 187 | let name = dialog.name.trim(); |
| 188 | if ok && !name.is_empty() && renamable && dialog.rename_folder { | ||
| 189 | let (library, name, color) = | ||
| 190 | (Arc::clone(&dialog.library), name.to_owned(), dialog.color); | ||
| 191 | self.ui.close_popup(id()); | ||
| 192 | self.properties = None; | ||
| 193 | return self.rename_notebook(library, name, color); | ||
| 194 | } | ||
| 164 | if ok && !name.is_empty() { | 195 | if ok && !name.is_empty() { |
| 165 | if name != dialog.library.name || dialog.color.is_some() { | 196 | if name != dialog.library.name || dialog.color.is_some() { |
| 166 | self.commands.push(Command::Structure( | 197 | self.commands.push(Command::Structure( |
crates/snowbound/src/sidebar.rs+4-1| ... | @@ -1231,6 +1231,7 @@ impl crate::State { | ... | @@ -1231,6 +1231,7 @@ impl crate::State { |
| 1231 | /// it, with a button to add one; or while none is on this computer yet. | 1231 | /// it, with a button to add one; or while none is on this computer yet. |
| 1232 | pub(crate) fn no_sections(&mut self, library: Arc<Library>) { | 1232 | pub(crate) fn no_sections(&mut self, library: Arc<Library>) { |
| 1233 | let downloading = library.downloading(); | 1233 | let downloading = library.downloading(); |
| 1234 | let renaming = self.folder_renaming(&library.location); | ||
| 1234 | let new = crate::Command::Structure( | 1235 | let new = crate::Command::Structure( |
| 1235 | library, | 1236 | library, |
| 1236 | crate::manage::Structure::NewSection { | 1237 | crate::manage::Structure::NewSection { |
| ... | @@ -1238,7 +1239,9 @@ impl crate::State { | ... | @@ -1238,7 +1239,9 @@ impl crate::State { |
| 1238 | }, | 1239 | }, |
| 1239 | ); | 1240 | ); |
| 1240 | let id = self.ui.id("no sections"); | 1241 | let id = self.ui.id("no sections"); |
| 1241 | let (title, buttons) = if downloading { | 1242 | let (title, buttons) = if renaming { |
| 1243 | ("Renaming the folder…", Vec::new()) | ||
| 1244 | } else if downloading { | ||
| 1242 | ("Downloading from iCloud Drive…", Vec::new()) | 1245 | ("Downloading from iCloud Drive…", Vec::new()) |
| 1243 | } else { | 1246 | } else { |
| 1244 | ( | 1247 | ( |
crates/snowbound/src/unread.rs+8| ... | @@ -91,6 +91,14 @@ impl Reads { | ... | @@ -91,6 +91,14 @@ impl Reads { |
| 91 | }) | 91 | }) |
| 92 | } | 92 | } |
| 93 | 93 | ||
| 94 | /// Keeps what was read in the notebook at `from` for it at `to`, where it moved. | ||
| 95 | pub fn moved(&mut self, from: &str, to: &str) { | ||
| 96 | if let Some(kept) = self.notebooks.remove(from) { | ||
| 97 | self.notebooks.insert(to.to_owned(), kept); | ||
| 98 | self.save(); | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 94 | /// Whether Show Unread Changes is on for the notebook at `notebook`. | 102 | /// Whether Show Unread Changes is on for the notebook at `notebook`. |
| 95 | pub fn shown(&self, notebook: &str) -> bool { | 103 | pub fn shown(&self, notebook: &str) -> bool { |
| 96 | self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden) | 104 | self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden) |