1//! Where section replicas live in the app's cache: a folder per notebook location, then a
2//! file per section identity. OneNote lets a notebook folder be copied with its sections
3//! keeping their identities, so identity alone cannot tell a copy's replica from the
4//! original's; the location keeps each copy's queue its own.
5//!
6//! A location is a string naming where a notebook or lone section is reached: the canonical
7//! path of a local folder or file (`local`), or `smb` for one on a share.
8
9use crate::fs;
10use onestore::{Header, Stamp};
11use std::{
12 io,
13 path::{Path, PathBuf},
14};
15
16/// The file in a replica folder naming the location its replicas belong to.
17const LOCATION: &str = "location";
18
19/// The folder in `cache` holding the replicas of the sections at `location`.
20pub fn folder(cache: &Path, location: &str) -> PathBuf {
21 cache
22 .join("replicas")
23 .join(hex(&<sha2::Sha256 as sha2::Digest>::digest(location)[..16]))
24}
25
26/// The location of the local folder or file at `path`, which may have just been moved away:
27/// its canonical path, or its folder's with its name.
28pub fn local(path: &Path) -> io::Result<String> {
29 let canonical =
30 fs::canonicalize(path).or_else(|error| match (path.parent(), path.file_name()) {
31 (Some(parent), Some(name)) => Ok(fs::canonicalize(parent)?.join(name)),
32 _ => Err(error),
33 })?;
34 Ok(canonical.to_string_lossy().into_owned())
35}
36
37/// The location of the notebook folder `root` in `share` on the server at `address`, as its
38/// host names them.
39pub fn smb(address: &str, share: &str, root: &str) -> String {
40 let root = root.replace('\\', "/");
41 format!("smb://{address}/{share}/{}", root.trim_matches('/'))
42}
43
44/// Moves the replicas of the sections at `from` to `to`, as when the app renames or moves a
45/// notebook; each replica `to` already holds for the same section stays. Every replica moved
46/// must be closed.
47pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {
48 let old = folder(cache, from);
49 if fs::metadata(&old).is_err() {
50 return Ok(());
51 }
52 let new = claimed(cache, to)?;
53 for entry in fs::read_dir(&old)? {
54 let name = entry?.file_name();
55 if Path::new(&name).extension() == Some("sqlite".as_ref())
56 && fs::metadata(new.join(&name)).is_err()
57 {
58 adopt(&old.join(&name), &new.join(&name))?;
59 }
60 }
61 tidy(&old);
62 Ok(())
63}
64
65/// Deletes what the cache keeps of the notebook at `location`, as when the app deletes the
66/// notebook: its replicas, edits waiting in them included, its sections' copies' and its
67/// listing. Every replica must be closed.
68pub fn forget(cache: &Path, location: &str) -> io::Result<()> {
69 let within = format!("{location}/");
70 if let Ok(entries) = fs::read_dir(cache.join("replicas")) {
71 for entry in entries {
72 let folder = entry?.path();
73 let ours = fs::read_to_string(folder.join(LOCATION))
74 .is_ok_and(|label| label == location || label.starts_with(&within));
75 if ours {
76 fs::remove_dir_all(&folder)?;
77 }
78 }
79 }
80 match fs::remove_file(crate::session::listing(cache, location)) {
81 Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
82 _ => Ok(()),
83 }
84}
85
86/// The folder for `location`, made and labelled with it where it was not.
87fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> {
88 let folder = folder(cache, location);
89 let label = folder.join(LOCATION);
90 if fs::metadata(&label).is_err() {
91 fs::create_dir_all(&folder)?;
92 fs::write(&label, location)?;
93 }
94 Ok(folder)
95}
96
97/// The folder for `location`, having moved into it a replica for each of `sections` it lacks
98/// from where one was left behind:
99///
100/// - the cache's top or its `smb` folder, where replicas were named by identity alone before
101/// locations named folders: taken by the first location to open the section, as that
102/// replica served whichever copy opened then;
103/// - the folder of a local location that no longer exists, as a notebook moved outside the
104/// app leaves: taken only when the section file, as `stamp` reads it, stands as the
105/// replica's base or has moved on from it (a later generation). A replica whose base is
106/// newer than the file, or a different state of the same generation, belongs to another
107/// copy of the section and stays.
108///
109/// A replica another process holds stays where it is.
110pub(crate) fn claim(
111 cache: &Path,
112 location: &str,
113 sections: &[[u8; 16]],
114 stamp: impl Fn(&[u8; 16]) -> Option<Stamp>,
115) -> io::Result<PathBuf> {
116 let folder = claimed(cache, location)?;
117 let missing: Vec<&[u8; 16]> = sections
118 .iter()
119 .filter(|identity| fs::metadata(crate::session::replica_file(&folder, identity)).is_err())
120 .collect();
121 if missing.is_empty() {
122 return Ok(folder);
123 }
124 let orphans = orphans(cache);
125 for identity in missing {
126 let legacy = [cache.to_owned(), cache.join("smb")]
127 .into_iter()
128 .map(|legacy| (crate::session::replica_file(&legacy, identity), true));
129 let left = orphans
130 .iter()
131 .map(|orphan| (crate::session::replica_file(orphan, identity), false));
132 for (replica, legacy) in legacy.chain(left) {
133 if fs::metadata(&replica).is_err() {
134 continue;
135 }
136 let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) {
137 Ok((base, _)) => {
138 legacy || stamp(identity).is_some_and(|file| follows(&file, &base))
139 }
140 Err(error) if error.busy() => false,
141 // An older schema, which opening converts, predates locations.
142 Err(_) => legacy,
143 };
144 if adoptable {
145 adopt(&replica, &crate::session::replica_file(&folder, identity))?;
146 if let Some(parent) = replica.parent().filter(|_| !legacy) {
147 tidy(parent);
148 }
149 break;
150 }
151 }
152 }
153 Ok(folder)
154}
155
156/// The replica folders of local locations that no longer exist.
157fn orphans(cache: &Path) -> Vec<PathBuf> {
158 let Ok(entries) = fs::read_dir(cache.join("replicas")) else {
159 return Vec::new();
160 };
161 entries
162 .filter_map(|entry| {
163 let folder = entry.ok()?.path();
164 let location = fs::read_to_string(folder.join(LOCATION)).ok()?;
165 let path = Path::new(&location);
166 (path.is_absolute() && fs::metadata(path).is_err()).then_some(folder)
167 })
168 .collect()
169}
170
171/// Whether the section file standing as `file` may take a replica whose base is `base`.
172fn follows(file: &Stamp, base: &Stamp) -> bool {
173 file == base
174 || matches!(
175 (Header::parse(&file.header), Header::parse(&base.header)),
176 (Ok(file), Ok(base)) if file.generation > base.generation
177 )
178}
179
180/// Moves the replica at `from`, with the write-ahead log a crash may have left, to `to`.
181fn adopt(from: &Path, to: &Path) -> io::Result<()> {
182 for suffix in ["-wal", "-shm", ""] {
183 let (mut source, mut target) = (from.as_os_str().to_owned(), to.as_os_str().to_owned());
184 source.push(suffix);
185 target.push(suffix);
186 match fs::rename(&source, &target) {
187 Err(error) if !suffix.is_empty() && error.kind() == io::ErrorKind::NotFound => {}
188 moved => moved?,
189 }
190 }
191 Ok(())
192}
193
194/// Removes a replica folder that holds no replica.
195fn tidy(folder: &Path) {
196 let empty = fs::read_dir(folder).is_ok_and(|mut entries| {
197 entries.all(|entry| entry.is_ok_and(|entry| entry.file_name() == LOCATION))
198 });
199 if empty {
200 let _ = fs::remove_file(folder.join(LOCATION));
201 let _ = fs::remove_dir(folder);
202 }
203}
204
205fn hex(bytes: &[u8]) -> String {
206 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
207}