authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 14:39:43-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 14:52:40-07:00
log0289b50606536e89c716af39171ffc698788b1f2
treea97df45f83bccdd2ee225a0c76fb8ace89f7783e
parent384e5ef14c8d95bb74b186fe57af0cc578fa9276
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: Notebook Properties can rename the notebook's folder too

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.5

16 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.
8080
8181When a notebook moves, its queue follows by one of three roads:
8282
83- **the app moved it** (iOS Rename): `location::moved` moves the folder's
84 replicas to the new location;
83- **the app moved it** (iOS Rename, or Notebook Properties' "Rename the folder
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;
8586- **something else moved it** (Finder): on open, a section with no replica takes
8687 one from the folder of a local location that no longer exists, if the file
8788 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
330330Empty Recycle Bin: Deleted Pages loses every page in one revision and each binned section's
331331file goes, the bin's TOC left as it was (`corpus/recycle-bin-view`). An entry a TOC still
332332lists 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
334new name with no file inside changed (it drops the old one from its list): on a share it
335first 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.
333337Edits keep entries in the order their ordering numbers give; a rename or colour keeps
334338the numbers. Every created, renamed or moved file is placed with
335339`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);
4242/// its offline copy, where the notebook keeps them. A section a session holds is left to that
4343/// session's worker, which the watch wakes. Dropping requests cancellation without waiting for
4444/// the step in flight.
45pub struct Background(Arc<Shared>);
45pub struct Background(Arc<Shared>, Mutex<Option<crate::task::JoinHandle<()>>>);
4646
4747/// A section for `Background::watch`, as its notebook knows it.
4848pub struct Known {
......@@ -138,7 +138,7 @@ impl Background {
138138 });
139139 let weak = Arc::downgrade(&shared);
140140 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 {
142142 let signal = &owner.signal;
143143 let mut bound: Option<(B, L)> = None;
144144 let mut news = false;
......@@ -295,7 +295,7 @@ impl Background {
295295 }
296296 }
297297 })?;
298 Ok(Self(shared))
298 Ok(Self(shared, Mutex::new(Some(thread))))
299299 }
300300
301301 /// Keeps the sections of a notebook on a share in sync while they are not open, with an
......@@ -448,6 +448,21 @@ impl Background {
448448 self.0.signal.wake();
449449 }
450450
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
451466 /// Working offline, nothing is checked until `wake`, or until working online again, which
452467 /// lists every folder again.
453468 pub fn set_offline(&self, offline: bool) {
crates/notebook/src/session.rs+97-7
......@@ -92,6 +92,9 @@ pub trait Storage: Send + Sync {
9292 fn hide(&self, path: &str) -> Result<()>;
9393 /// Renames or moves a file or directory; an existing target is an error.
9494 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>;
9598 /// Renames a file over another, replacing it.
9699 fn replace(&self, from: &str, to: &str) -> Result<()>;
97100 /// Deletes a file or an empty directory.
......@@ -211,6 +214,18 @@ impl Storage for Directory {
211214 Ok(fs::rename(self.path(from), self.path(to))?)
212215 }
213216
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
214229 fn replace(&self, from: &str, to: &str) -> Result<()> {
215230 Ok(fs::rename(self.path(from), self.path(to))?)
216231 }
......@@ -306,6 +321,29 @@ impl Storage for Share {
306321 Ok(self.client.rename(&self.path(from), &self.path(to))?)
307322 }
308323
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
309347 fn replace(&self, from: &str, to: &str) -> Result<()> {
310348 Ok(self.client.replace(&self.path(from), &self.path(to))?)
311349 }
......@@ -408,13 +446,8 @@ impl Notebook {
408446 cache: impl AsRef<Path>,
409447 ) -> Result<Self> {
410448 let cache = cache.as_ref().to_path_buf();
411 let listings = cache.join("listings");
412 fs::create_dir_all(&listings)?;
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"));
449 let listing = listing(&cache, &storage.location());
450 fs::create_dir_all(listing.parent().unwrap_or(&cache))?;
418451 let mut read = fs::read(&listing)
419452 .ok()
420453 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
......@@ -738,6 +771,45 @@ impl Notebook {
738771 Ok(renamed)
739772 }
740773
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
741813 /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it.
742814 pub fn set_section_color(&mut self, path: &str, color: Option<u32>) -> Result<()> {
743815 let path = self.section_path(path)?.path.clone();
......@@ -1678,6 +1750,24 @@ pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> {
16781750 }))
16791751}
16801752
1753/// Where the cache keeps what reading the catalog of the notebook at `location` took.
1754fn 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.
1764fn folder_name(name: &str) -> bool {
1765 component(name)
1766 && !name.contains(['<', '>', ':', '"', '|', '?', '*'])
1767 && !name.chars().any(char::is_control)
1768 && !name.ends_with(['.', ' '])
1769}
1770
16811771fn split(path: &str) -> (&str, &str) {
16821772 match path.rsplit_once('/') {
16831773 Some((folder, name)) => (folder, name),
crates/notebook/src/session/tests.rs+3
......@@ -49,6 +49,9 @@ impl Storage for Racing {
4949 fn rename(&self, from: &str, to: &str) -> Result<()> {
5050 self.inner.rename(from, to)
5151 }
52 fn rename_root(&self, name: &str, files: &[String]) -> Result<String> {
53 self.inner.rename_root(name, files)
54 }
5255 fn replace(&self, from: &str, to: &str) -> Result<()> {
5356 self.inner.replace(from, to)
5457 }
crates/notebook/src/sidecar/tests.rs+4
......@@ -139,6 +139,10 @@ impl Storage for Folder {
139139 Ok(())
140140 }
141141
142 fn rename_root(&self, _: &str, _: &[String]) -> Result<String> {
143 unreachable!()
144 }
145
142146 fn replace(&self, from: &str, to: &str) -> Result<()> {
143147 let mut files = self.files.lock().unwrap();
144148 let bytes = files.remove(from).ok_or_else(missing)?;
crates/notebook/src/smb/mod.rs+6
......@@ -329,6 +329,12 @@ impl Client {
329329 file.close()
330330 }
331331
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
332338 /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010
333339 /// skips a hidden folder.
334340 pub(crate) fn hide(&self, path: &str) -> io::Result<()> {
crates/notebook/src/smb/tests.rs+66
......@@ -835,6 +835,72 @@ fn live_structure() {
835835 client.delete(&root).unwrap();
836836}
837837
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"]
843fn 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
838904/// Signs in as `ONESTORE_SMB_LAB_USER` with `ONESTORE_SMB_LAB_PASSWORD` where set, else as a
839905/// guest, to a server with a share `agent`; only lists and reads.
840906#[test]
crates/notebook/tests/location.rs+53
......@@ -167,6 +167,59 @@ fn a_notebook_the_app_moves_keeps_its_queued_edits() {
167167 assert_eq!(published(&root, &cache), 0);
168168}
169169
170#[test]
171fn 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]
194fn 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
170223#[test]
171224fn a_notebook_moved_outside_the_app_takes_its_replicas_along() {
172225 let directory = tempfile::tempdir().unwrap();
crates/snowbound/src/library.rs+214-6
......@@ -452,12 +452,76 @@ impl Library {
452452 /// Names the notebook `name` on this computer, leaving its folder as it is, as OneNote
453453 /// 2010's Notebook Properties does; the notebook read again with `with` shows it.
454454 pub fn set_display_name(&self, name: &str) -> io::Result<()> {
455 let file = display_names(&self.cache);
456 let mut names = read_display_names(&file);
457 names.insert(self.location.clone(), name.to_owned());
458 let partial = file.with_extension("partial");
459 notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?;
460 notebook::fs::rename(partial, file)
455 edit_display_names(&self.cache, |names| {
456 names.insert(self.location.clone(), name.to_owned());
457 })
458 }
459
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)
461525 }
462526
463527 /// The art the notebook's tags draw with.
......@@ -795,6 +859,11 @@ impl Library {
795859 .then(|| Path::new(&self.location))
796860 }
797861
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
798867 /// Whether iCloud Drive keeps the notebook, or the section opened on its own.
799868 pub fn in_icloud(&self) -> bool {
800869 self.folder().is_some_and(crate::icloud::ubiquitous)
......@@ -1038,6 +1107,18 @@ fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String>
10381107 .unwrap_or_default()
10391108}
10401109
1110fn 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
10411122fn display_name(cache: &Path, location: &str) -> Option<String> {
10421123 read_display_names(&display_names(cache)).remove(location)
10431124}
......@@ -1428,6 +1509,133 @@ mod tests {
14281509 }
14291510 }
14301511
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
14311639 #[test]
14321640 fn share_relative_sections_show_under_their_mount() {
14331641 let library = |root: &str| Library {
crates/snowbound/src/main.rs+7
......@@ -811,6 +811,8 @@ struct State {
811811 /// Notebooks in iCloud Drive a thread is reading, or following the download of, by
812812 /// location.
813813 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)>,
814816 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
815817 tags: Vec<canvas::editor::NoteTag>,
816818 /// The Customize Tags dialog's list while it is open.
......@@ -1197,6 +1199,7 @@ impl State {
11971199 peers: None,
11981200 server: None,
11991201 icloud_reading: HashSet::new(),
1202 folder_rename: None,
12001203 tags: stored
12011204 .tags
12021205 .unwrap_or_else(canvas::editor::NoteTag::defaults),
......@@ -3160,6 +3163,10 @@ impl State {
31603163 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {
31613164 match command {
31623165 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 }
31633170 if library.locked(&path) {
31643171 return self.show_locked(library, path);
31653172 }
crates/snowbound/src/manage.rs+133-2
......@@ -298,7 +298,11 @@ impl State {
298298 .notebooks
299299 .iter()
300300 .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()
302306 })
303307 .cloned()
304308 .collect();
......@@ -307,16 +311,21 @@ impl State {
307311 }
308312 for location in listed {
309313 if self.notebooks.iter().any(|open| open.location == location)
314 || self.folder_renaming(&location)
310315 || !self.icloud_reading.insert(location.clone())
311316 {
312317 continue;
313318 }
314319 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());
315320 crate::spawn(move || {
321 let gone = notebook::fs::metadata(&location).is_err();
316322 let library = Arc::new(Library::notebook(&location, &cache));
317323 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
318324 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 {
320329 return Ok(());
321330 }
322331 state.notebooks.push(Arc::clone(&library));
......@@ -329,6 +338,13 @@ impl State {
329338 }
330339 }
331340
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
332348 /// Shows `library`, a notebook that just arrived or whose sections did, where no other
333349 /// notebook is shown: at its first section, or without one while none is here yet.
334350 pub(crate) fn show_arrived(&mut self, library: &Arc<Library>) {
......@@ -502,6 +518,121 @@ impl State {
502518 self.save_settings();
503519 }
504520
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
505636 /// Changes `library`'s sections and groups on a thread of its own, then shows the
506637 /// section the change leaves open: a new or moved one, or the one shown before.
507638 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;
3131const RECENT: usize = 8;
3232
3333impl 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
3445 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages
3546 /// ahead, as a browser does.
3647 pub fn visit(&mut self, place: Place) {
crates/snowbound/src/properties.rs+32-1
......@@ -15,6 +15,8 @@ pub struct Properties {
1515 name: String,
1616 /// A colour picked, COLORREF.
1717 color: Option<u32>,
18 /// Renames the notebook's folder to a name changed.
19 rename_folder: bool,
1820}
1921
2022fn id() -> Id {
......@@ -37,6 +39,7 @@ impl State {
3739 name: library.name.clone(),
3840 library,
3941 color: None,
42 rename_folder: true,
4043 });
4144 self.ui.open_popup(id());
4245 self.ui.focus_all(name_field());
......@@ -126,7 +129,28 @@ impl State {
126129 if let Some(node) = ui.access(name_field()) {
127130 node.set_label("Display name");
128131 }
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 }
130154 label(ui, "Color:");
131155 let shown = dialog.color.or(dialog.library.color());
132156 let named = SECTION_COLORS
......@@ -161,6 +185,13 @@ impl State {
161185 ui.close();
162186 ui.close();
163187 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 }
164195 if ok && !name.is_empty() {
165196 if name != dialog.library.name || dialog.color.is_some() {
166197 self.commands.push(Command::Structure(
crates/snowbound/src/sidebar.rs+4-1
......@@ -1231,6 +1231,7 @@ impl crate::State {
12311231 /// it, with a button to add one; or while none is on this computer yet.
12321232 pub(crate) fn no_sections(&mut self, library: Arc<Library>) {
12331233 let downloading = library.downloading();
1234 let renaming = self.folder_renaming(&library.location);
12341235 let new = crate::Command::Structure(
12351236 library,
12361237 crate::manage::Structure::NewSection {
......@@ -1238,7 +1239,9 @@ impl crate::State {
12381239 },
12391240 );
12401241 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 {
12421245 ("Downloading from iCloud Drive…", Vec::new())
12431246 } else {
12441247 (
crates/snowbound/src/unread.rs+8
......@@ -91,6 +91,14 @@ impl Reads {
9191 })
9292 }
9393
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
94102 /// Whether Show Unread Changes is on for the notebook at `notebook`.
95103 pub fn shown(&self, notebook: &str) -> bool {
96104 self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden)