| author | |
| committer | |
| log | f4e1afda77c93656bcf5c1e0e634aa9ca735d9ec |
| tree | e32ef5880f4ca7924fae722a64029adb731c7655 |
| parent | 24912d53294b686bf8bfebdd7ab2e17ca14aba1f |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Each open notebook gets a background thread that polls every closed section's
stamp every 15 s (immediately on Sync Now or when a section closes). A section
with queued edits or a moved file runs the existing sync step, publishing or
merging as one appended revision. Work Offline stops it. The sync popup lists
every section, and the top-right status shows the notebook's worst state.
Assisted-by: claude-opus-5.512 files changed, 842 insertions(+), 142 deletions(-)
arc/sync.md+10| ... | @@ -167,6 +167,16 @@ Working offline on purpose, as OneNote's Work Offline does, is the same state | ... | @@ -167,6 +167,16 @@ Working offline on purpose, as OneNote's Work Offline does, is the same state |
| 167 | chosen: the sync thread stops stepping until Sync Now or until the user works | 167 | chosen: the sync thread stops stepping until Sync Now or until the user works |
| 168 | online again. | 168 | online again. |
| 169 | 169 | ||
| 170 | ## Sections that aren't open | ||
| 171 | |||
| 172 | OneNote keeps every section of an open notebook in sync, not just the one on screen: it | ||
| 173 | reads a section another client changed within seconds and publishes a closed section's | ||
| 174 | offline edits the moment the share is back. Snowbound's `session::Background` does the | ||
| 175 | same with one thread per notebook. Each round reads every section file's stamp. Only a | ||
| 176 | section whose replica has edits waiting, or whose file moved past the replica's base, | ||
| 177 | has its replica opened for the usual sync steps, and it is closed again afterwards. The | ||
| 178 | open section is left to its own session. | ||
| 179 | |||
| 170 | ## Notebook structure | 180 | ## Notebook structure |
| 171 | 181 | ||
| 172 | Sections, groups and the notebook's own colour live in the `.onetoc2` files, | 182 | Sections, groups and the notebook's own colour live in the `.onetoc2` files, |
crates/notebook/README.md+11| ... | @@ -64,6 +64,17 @@ edit names is the host's: the app passes the account's full name. | ... | @@ -64,6 +64,17 @@ edit names is the host's: the app passes the account's full name. |
| 64 | and how many edits wait for it. `set_offline(true)` works offline as OneNote does: the | 64 | and how many edits wait for it. `set_offline(true)` works offline as OneNote does: the |
| 65 | worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`. | 65 | worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`. |
| 66 | 66 | ||
| 67 | `session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps | ||
| 68 | every section of an open notebook: `Notebook::background(interval, notify)` for a mounted | ||
| 69 | notebook, `Background::smb(root, limit, interval, connect, notify)` on a share, then | ||
| 70 | `watch(notebook.replicas())`. Each round reads every watched file's stamp; a section whose | ||
| 71 | replica has edits waiting, or whose file moved past the replica's base, has the replica | ||
| 72 | opened for the synchronization steps that publish or rebase it and closed again. A replica | ||
| 73 | a session holds is skipped (`Error::busy`), so opening a section may wait out one step. | ||
| 74 | `status()` gives each section's `SyncStatus`, `changed()` the sections another client | ||
| 75 | changed, and `wake` and `set_offline` follow Sync Now and Work Offline. | ||
| 76 | `Notebook::replica_path(path)` names a section's replica for either kind of notebook. | ||
| 77 | |||
| 67 | `Section::resume(file, replica, notify)` starts from an owned `Replica` without | 78 | `Section::resume(file, replica, notify)` starts from an owned `Replica` without |
| 68 | consulting the remote file; with the `smb` feature, `Section::resume_smb(path, | 79 | consulting the remote file; with the `smb` feature, `Section::resume_smb(path, |
| 69 | replica, limit, connect, notify)` binds a share-relative path, `connect` running on | 80 | replica, limit, connect, notify)` binds a share-relative path, `connect` running on |
crates/notebook/examples/scratch_bgsync.rs deleted-42| ... | @@ -1,42 +0,0 @@ | ||
| 1 | //! Scratch lab writer (delete before reporting): appends TEXT to the first body paragraph of | ||
| 2 | //! the first page of a share-relative section. `scratch_bgsync ADDRESS PATH TEXT` | ||
| 3 | use notebook::{ | ||
| 4 | Replica, SmbRemote, | ||
| 5 | smb::{Client, Credentials}, | ||
| 6 | }; | ||
| 7 | use onestore::op::{Edit, Op, PageOp}; | ||
| 8 | use std::time::Duration; | ||
| 9 | |||
| 10 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 11 | let args: Vec<_> = std::env::args().skip(1).collect(); | ||
| 12 | let [address, path, with] = &args[..] else { | ||
| 13 | return Err("scratch_bgsync ADDRESS PATH TEXT".into()); | ||
| 14 | }; | ||
| 15 | let connect = || Client::connect(address, "agent", Credentials::default(), Duration::from_secs(5)); | ||
| 16 | let source = connect()?.read_storage(path, 1 << 26)?; | ||
| 17 | let directory = tempfile::tempdir()?; | ||
| 18 | let replica = Replica::create(directory.path().join("cache.sqlite"), &source)?; | ||
| 19 | let space = replica.pages()?[0].0; | ||
| 20 | let page = replica.page(space)?; | ||
| 21 | let (text, len) = page | ||
| 22 | .objects | ||
| 23 | .iter() | ||
| 24 | .find_map(|object| match object { | ||
| 25 | onestore::page::PageObject::Outline(outline) => outline | ||
| 26 | .paragraphs | ||
| 27 | .iter() | ||
| 28 | .find_map(|p| p.text().map(|t| (t.id, t.text.text().encode_utf16().count() as u32))), | ||
| 29 | _ => None, | ||
| 30 | }) | ||
| 31 | .ok_or("no text")?; | ||
| 32 | replica.apply( | ||
| 33 | "Lab", | ||
| 34 | Edit { | ||
| 35 | at: 134_000_000_000_000_000, | ||
| 36 | ops: vec![Op::Page { space, op: PageOp::Text { text, range: len..len, with: with.clone() } }], | ||
| 37 | }, | ||
| 38 | )?; | ||
| 39 | let mut remote = SmbRemote::new(connect()?, path.clone(), 1 << 26); | ||
| 40 | println!("{:?}", replica.sync_once(&mut remote)?.edit); | ||
| 41 | Ok(()) | ||
| 42 | } | ||
crates/notebook/src/background.rs created+326| ... | @@ -0,0 +1,326 @@ | ||
| 1 | //! The sections of an open notebook that no session holds, kept in sync as OneNote 2010 | ||
| 2 | //! keeps every section of an open notebook: queued edits publish and other clients' changes | ||
| 3 | //! are noticed without the section being open. | ||
| 4 | |||
| 5 | use crate::{ | ||
| 6 | EditStatus, Error, Remote, Replica, Result, | ||
| 7 | session::{SyncStatus, reached}, | ||
| 8 | worker::Signal, | ||
| 9 | }; | ||
| 10 | use onestore::Stamp; | ||
| 11 | use std::{ | ||
| 12 | io, | ||
| 13 | path::{Path, PathBuf}, | ||
| 14 | sync::{Arc, Mutex, atomic::Ordering}, | ||
| 15 | thread, | ||
| 16 | time::Duration, | ||
| 17 | }; | ||
| 18 | |||
| 19 | /// Polls each watched section file's stamp every interval. A section whose replica has | ||
| 20 | /// edits waiting, or whose file moved past the replica's base, has its replica opened for | ||
| 21 | /// the synchronization steps that publish or rebase it, then closed again; any other costs | ||
| 22 | /// one stamp read. A replica a session holds is left to that session's worker. Dropping | ||
| 23 | /// requests cancellation without waiting for the step in flight. | ||
| 24 | pub struct Background { | ||
| 25 | signal: Arc<Signal>, | ||
| 26 | watched: Arc<Mutex<Watched>>, | ||
| 27 | } | ||
| 28 | |||
| 29 | #[derive(Default)] | ||
| 30 | struct Watched { | ||
| 31 | sections: Vec<Watch>, | ||
| 32 | /// Sections whose file changed since `changed` was last asked. | ||
| 33 | changed: Vec<String>, | ||
| 34 | } | ||
| 35 | |||
| 36 | struct Watch { | ||
| 37 | path: String, | ||
| 38 | replica: Option<PathBuf>, | ||
| 39 | /// The file's stamp when last reached. | ||
| 40 | stamp: Option<Stamp>, | ||
| 41 | status: SyncStatus, | ||
| 42 | } | ||
| 43 | |||
| 44 | impl Background { | ||
| 45 | pub(crate) fn start<R, B>( | ||
| 46 | interval: Duration, | ||
| 47 | mut connect: impl FnMut() -> io::Result<B> + Send + 'static, | ||
| 48 | notify: impl Fn() + Send + 'static, | ||
| 49 | ) -> Result<Self> | ||
| 50 | where | ||
| 51 | R: Remote, | ||
| 52 | B: FnMut(&str) -> R, | ||
| 53 | { | ||
| 54 | let (signal, receiver) = Signal::new(); | ||
| 55 | let watched = Arc::new(Mutex::new(Watched::default())); | ||
| 56 | let (shared, sections) = (Arc::clone(&signal), Arc::clone(&watched)); | ||
| 57 | thread::Builder::new() | ||
| 58 | .name("onestore-background".into()) | ||
| 59 | .spawn(move || { | ||
| 60 | let mut bound: Option<B> = None; | ||
| 61 | while !shared.stopped.load(Ordering::Acquire) { | ||
| 62 | if shared.offline.load(Ordering::Acquire) | ||
| 63 | && !shared.requested.load(Ordering::Acquire) | ||
| 64 | { | ||
| 65 | bound = None; | ||
| 66 | let _ = receiver.recv(); | ||
| 67 | continue; | ||
| 68 | } | ||
| 69 | shared.requested.store(false, Ordering::Release); | ||
| 70 | let round: Vec<_> = match sections.lock() { | ||
| 71 | Ok(watched) => watched | ||
| 72 | .sections | ||
| 73 | .iter() | ||
| 74 | .map(|watch| { | ||
| 75 | ( | ||
| 76 | watch.path.clone(), | ||
| 77 | watch.replica.clone(), | ||
| 78 | watch.stamp.clone(), | ||
| 79 | ) | ||
| 80 | }) | ||
| 81 | .collect(), | ||
| 82 | Err(_) => return, | ||
| 83 | }; | ||
| 84 | let mut news = false; | ||
| 85 | for (path, replica, seen) in round { | ||
| 86 | if shared.stopped.load(Ordering::Acquire) { | ||
| 87 | return; | ||
| 88 | } | ||
| 89 | let bind = match &mut bound { | ||
| 90 | Some(bind) => bind, | ||
| 91 | None => match connect() { | ||
| 92 | Ok(bind) => bound.insert(bind), | ||
| 93 | Err(error) => { | ||
| 94 | // Nothing on the share is reachable this round. | ||
| 95 | let Ok(mut watched) = sections.lock() else { | ||
| 96 | return; | ||
| 97 | }; | ||
| 98 | for watch in &mut watched.sections { | ||
| 99 | news |= watch.fail( | ||
| 100 | &Error::RemoteIo(io::Error::new( | ||
| 101 | error.kind(), | ||
| 102 | error.to_string(), | ||
| 103 | )), | ||
| 104 | None, | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | break; | ||
| 108 | } | ||
| 109 | }, | ||
| 110 | }; | ||
| 111 | let (queued, outcome) = | ||
| 112 | step(&mut bind(&path), replica.as_deref(), seen.as_ref()); | ||
| 113 | if matches!(outcome, Err(Error::RemoteIo(_) | Error::Remote(_))) { | ||
| 114 | bound = None; | ||
| 115 | } | ||
| 116 | let Ok(mut watched) = sections.lock() else { | ||
| 117 | return; | ||
| 118 | }; | ||
| 119 | let Watched { sections, changed } = &mut *watched; | ||
| 120 | let Some(watch) = sections.iter_mut().find(|watch| watch.path == path) | ||
| 121 | else { | ||
| 122 | continue; | ||
| 123 | }; | ||
| 124 | news |= match outcome { | ||
| 125 | Ok((stamp, moved)) => { | ||
| 126 | watch.stamp = Some(stamp); | ||
| 127 | if moved { | ||
| 128 | changed.push(path); | ||
| 129 | } | ||
| 130 | let before = summary(&watch.status); | ||
| 131 | if let Some(queued) = queued { | ||
| 132 | watch.status = SyncStatus { | ||
| 133 | synced: Some(crate::now()), | ||
| 134 | error: None, | ||
| 135 | queued, | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | moved || before != summary(&watch.status) | ||
| 139 | } | ||
| 140 | Err(error) => watch.fail(&error, queued), | ||
| 141 | }; | ||
| 142 | } | ||
| 143 | if news { | ||
| 144 | notify(); | ||
| 145 | } | ||
| 146 | let _ = receiver.recv_timeout(interval); | ||
| 147 | } | ||
| 148 | })?; | ||
| 149 | Ok(Self { signal, watched }) | ||
| 150 | } | ||
| 151 | |||
| 152 | /// Keeps the sections of a notebook on a share in sync while they are not open; | ||
| 153 | /// `connect` runs again after a transport failure. Paths are relative to `root`. | ||
| 154 | #[cfg(feature = "smb")] | ||
| 155 | pub fn smb( | ||
| 156 | root: &str, | ||
| 157 | limit: usize, | ||
| 158 | interval: Duration, | ||
| 159 | mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static, | ||
| 160 | notify: impl Fn() + Send + 'static, | ||
| 161 | ) -> Result<Self> { | ||
| 162 | let root = root.replace('\\', "/"); | ||
| 163 | Self::start( | ||
| 164 | interval, | ||
| 165 | move || { | ||
| 166 | let client = Arc::new(connect()?); | ||
| 167 | let root = root.clone(); | ||
| 168 | Ok(move |path: &str| { | ||
| 169 | let file = match root.as_str() { | ||
| 170 | "" => path.to_owned(), | ||
| 171 | root => format!("{root}/{path}"), | ||
| 172 | }; | ||
| 173 | crate::SmbRemote::new(Arc::clone(&client), file, limit) | ||
| 174 | }) | ||
| 175 | }, | ||
| 176 | notify, | ||
| 177 | ) | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Watches these sections, by catalog path and replica (`Notebook::replicas`), from | ||
| 181 | /// the next round, which starts now. | ||
| 182 | pub fn watch(&self, sections: Vec<(String, Option<PathBuf>)>) { | ||
| 183 | if let Ok(mut watched) = self.watched.lock() { | ||
| 184 | let mut previous = std::mem::take(&mut watched.sections); | ||
| 185 | watched.sections = sections | ||
| 186 | .into_iter() | ||
| 187 | .map( | ||
| 188 | |(path, replica)| match previous.iter().position(|watch| watch.path == path) { | ||
| 189 | Some(index) => Watch { | ||
| 190 | replica, | ||
| 191 | ..previous.swap_remove(index) | ||
| 192 | }, | ||
| 193 | None => Watch { | ||
| 194 | path, | ||
| 195 | replica, | ||
| 196 | stamp: None, | ||
| 197 | status: SyncStatus { | ||
| 198 | synced: None, | ||
| 199 | error: None, | ||
| 200 | queued: 0, | ||
| 201 | }, | ||
| 202 | }, | ||
| 203 | }, | ||
| 204 | ) | ||
| 205 | .collect(); | ||
| 206 | } | ||
| 207 | self.signal.wake(); | ||
| 208 | } | ||
| 209 | |||
| 210 | /// Each watched section's status as its last round left it, in watch order. A section a | ||
| 211 | /// session holds keeps the status it had before. | ||
| 212 | pub fn status(&self) -> Vec<(String, SyncStatus)> { | ||
| 213 | self.watched.lock().map_or_else( | ||
| 214 | |_| Vec::new(), | ||
| 215 | |watched| { | ||
| 216 | watched | ||
| 217 | .sections | ||
| 218 | .iter() | ||
| 219 | .map(|watch| { | ||
| 220 | let status = &watch.status; | ||
| 221 | ( | ||
| 222 | watch.path.clone(), | ||
| 223 | SyncStatus { | ||
| 224 | synced: status.synced, | ||
| 225 | error: status | ||
| 226 | .error | ||
| 227 | .as_ref() | ||
| 228 | .map(|error| io::Error::new(error.kind(), error.to_string())), | ||
| 229 | queued: status.queued, | ||
| 230 | }, | ||
| 231 | ) | ||
| 232 | }) | ||
| 233 | .collect() | ||
| 234 | }, | ||
| 235 | ) | ||
| 236 | } | ||
| 237 | |||
| 238 | /// Sections whose file changed since the last call, by catalog path. | ||
| 239 | pub fn changed(&self) -> Vec<String> { | ||
| 240 | self.watched | ||
| 241 | .lock() | ||
| 242 | .map(|mut watched| std::mem::take(&mut watched.changed)) | ||
| 243 | .unwrap_or_default() | ||
| 244 | } | ||
| 245 | |||
| 246 | /// Runs a round now, working offline included (Sync Now). | ||
| 247 | pub fn wake(&self) { | ||
| 248 | self.signal.requested.store(true, Ordering::Release); | ||
| 249 | self.signal.wake(); | ||
| 250 | } | ||
| 251 | |||
| 252 | /// Working offline, no round runs until `wake`, or until working online again. | ||
| 253 | pub fn set_offline(&self, offline: bool) { | ||
| 254 | self.signal.offline.store(offline, Ordering::Release); | ||
| 255 | self.signal.wake(); | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | impl Drop for Background { | ||
| 260 | fn drop(&mut self) { | ||
| 261 | self.signal.stopped.store(true, Ordering::Release); | ||
| 262 | self.signal.wake(); | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | impl Watch { | ||
| 267 | /// Records a failed step and what it found waiting, answering whether the status shown | ||
| 268 | /// changes. | ||
| 269 | fn fail(&mut self, error: &Error, queued: Option<u64>) -> bool { | ||
| 270 | let before = summary(&self.status); | ||
| 271 | self.status.error = Some(reached(error)); | ||
| 272 | self.status.queued = queued.unwrap_or(self.status.queued); | ||
| 273 | before != summary(&self.status) | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | /// What the host shows of a status. | ||
| 278 | fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) { | ||
| 279 | ( | ||
| 280 | status.synced.is_some(), | ||
| 281 | status.error.as_ref().map(io::Error::kind), | ||
| 282 | status.queued, | ||
| 283 | ) | ||
| 284 | } | ||
| 285 | |||
| 286 | /// One section's step: how many of its edits wait (`None` while unknown, as while a session | ||
| 287 | /// holds its replica), then the file's stamp now and whether it changed since `seen`. | ||
| 288 | fn step<R: Remote>( | ||
| 289 | remote: &mut R, | ||
| 290 | replica: Option<&Path>, | ||
| 291 | seen: Option<&Stamp>, | ||
| 292 | ) -> (Option<u64>, Result<(Stamp, bool)>) { | ||
| 293 | let stamp = match remote.stamp() { | ||
| 294 | Ok(stamp) => stamp, | ||
| 295 | Err(error) => return (None, Err(Error::RemoteIo(error))), | ||
| 296 | }; | ||
| 297 | let moved = seen.is_some_and(|seen| *seen != stamp); | ||
| 298 | let Some(replica) = replica.filter(|replica| replica.exists()) else { | ||
| 299 | return (Some(0), Ok((stamp, moved))); | ||
| 300 | }; | ||
| 301 | match crate::peek(replica) { | ||
| 302 | Ok((base, 0)) if base == stamp => return (Some(0), Ok((stamp, moved))), | ||
| 303 | Err(error) if error.busy() => return (None, Ok((stamp, false))), | ||
| 304 | _ => {} | ||
| 305 | } | ||
| 306 | let replica = match Replica::open(replica) { | ||
| 307 | Ok(replica) => replica, | ||
| 308 | Err(error) if error.busy() => return (None, Ok((stamp, false))), | ||
| 309 | Err(error) => return (None, Err(error)), | ||
| 310 | }; | ||
| 311 | let synced = (|| { | ||
| 312 | let mut changed = moved; | ||
| 313 | loop { | ||
| 314 | let synced = replica.sync_once(remote)?; | ||
| 315 | changed |= !synced.changed.is_empty(); | ||
| 316 | if !matches!(synced.edit, Some((_, EditStatus::Published { .. }))) { | ||
| 317 | return Ok((remote.stamp().map_err(Error::RemoteIo)?, changed)); | ||
| 318 | } | ||
| 319 | } | ||
| 320 | })(); | ||
| 321 | let queued = replica | ||
| 322 | .recovery_summary() | ||
| 323 | .ok() | ||
| 324 | .map(|summary| summary.queued_edits); | ||
| 325 | (queued, synced) | ||
| 326 | } | ||
crates/notebook/src/lib.rs+24| ... | @@ -20,6 +20,7 @@ use std::{ | ... | @@ -20,6 +20,7 @@ use std::{ |
| 20 | }; | 20 | }; |
| 21 | 21 | ||
| 22 | mod assets; | 22 | mod assets; |
| 23 | mod background; | ||
| 23 | mod base; | 24 | mod base; |
| 24 | mod merge; | 25 | mod merge; |
| 25 | mod migrate; | 26 | mod migrate; |
| ... | @@ -60,6 +61,14 @@ pub enum Error { | ... | @@ -60,6 +61,14 @@ pub enum Error { |
| 60 | Protected(#[from] onestore::protected::Error), | 61 | Protected(#[from] onestore::protected::Error), |
| 61 | } | 62 | } |
| 62 | 63 | ||
| 64 | impl Error { | ||
| 65 | /// Whether the replica is open elsewhere, as in another section session of this process. | ||
| 66 | pub fn busy(&self) -> bool { | ||
| 67 | matches!(self, Self::Database(rusqlite::Error::SqliteFailure(error, _)) | ||
| 68 | if error.code == rusqlite::ErrorCode::DatabaseBusy) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 63 | type Result<T> = std::result::Result<T, Error>; | 72 | type Result<T> = std::result::Result<T, Error>; |
| 64 | 73 | ||
| 65 | const APPLICATION_ID: u32 = 0x4f4e454f; | 74 | const APPLICATION_ID: u32 = 0x4f4e454f; |
| ... | @@ -307,6 +316,21 @@ fn validate(source: &[u8]) -> Result<ExGuid> { | ... | @@ -307,6 +316,21 @@ fn validate(source: &[u8]) -> Result<ExGuid> { |
| 307 | Ok(onestore::Section::open(&arena, source.to_vec())?.root()) | 316 | Ok(onestore::Section::open(&arena, source.to_vec())?.root()) |
| 308 | } | 317 | } |
| 309 | 318 | ||
| 319 | /// A closed cache's base stamp and how many edits wait, without opening the replica. | ||
| 320 | fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> { | ||
| 321 | let connection = cache_connection(path)?; | ||
| 322 | let application: u32 = | ||
| 323 | connection.pragma_query_value(None, "application_id", |row| row.get(0))?; | ||
| 324 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 325 | if application != APPLICATION_ID || version != schema::VERSION { | ||
| 326 | return Err(io::Error::from(io::ErrorKind::InvalidData).into()); | ||
| 327 | } | ||
| 328 | let base = base::stamp(&connection, base::Image::Base)? | ||
| 329 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; | ||
| 330 | let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?; | ||
| 331 | Ok((base, unsigned(queued)?)) | ||
| 332 | } | ||
| 333 | |||
| 310 | fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> { | 334 | fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> { |
| 311 | queue::load(connection, None) | 335 | queue::load(connection, None) |
| 312 | } | 336 | } |
crates/notebook/src/session.rs+73-11| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | //! The application's view of a notebook: sections opened through a local replica that | 1 | //! The application's view of a notebook: sections opened through a local replica that |
| 2 | //! publishes their edits to the section file in the background. | 2 | //! publishes their edits to the section file in the background. |
| 3 | 3 | ||
| 4 | pub use crate::background::Background; | ||
| 4 | use crate::{ | 5 | use crate::{ |
| 5 | EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, | 6 | EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, |
| 6 | }; | 7 | }; |
| ... | @@ -881,6 +882,61 @@ impl Notebook { | ... | @@ -881,6 +882,61 @@ impl Notebook { |
| 881 | Ok(()) | 882 | Ok(()) |
| 882 | } | 883 | } |
| 883 | 884 | ||
| 885 | /// Where the replica of the section at catalog `path` lives: named by the section's | ||
| 886 | /// document identity in a mounted notebook (`Section::open`), by its file identity under | ||
| 887 | /// `smb` on a share. It exists once the section has been opened. | ||
| 888 | pub fn replica_path(&self, path: &str) -> Result<PathBuf> { | ||
| 889 | let section = self.section_path(path)?; | ||
| 890 | Ok(match &self.root { | ||
| 891 | Some(_) => { | ||
| 892 | let image = self.storage.read(&section.path)?; | ||
| 893 | let root = RevisionIndex::parse(&Store::parse(&image)?)?.root; | ||
| 894 | replica_file(&self.cache, &root.guid) | ||
| 895 | } | ||
| 896 | None => replica_file(&self.cache.join("smb"), &section.file_id), | ||
| 897 | }) | ||
| 898 | } | ||
| 899 | |||
| 900 | /// Every readable section's catalog path and replica, for `Background::watch`. | ||
| 901 | pub fn replicas(&self) -> Vec<(String, Option<PathBuf>)> { | ||
| 902 | let mut sections = Vec::new(); | ||
| 903 | let mut folders = vec![&self.catalog]; | ||
| 904 | while let Some(folder) = folders.pop() { | ||
| 905 | for section in &folder.sections { | ||
| 906 | if matches!(section.state, discover::SectionState::Readable { .. }) { | ||
| 907 | let replica = self.replica_path(&section.path).ok(); | ||
| 908 | sections.push((section.path.clone(), replica)); | ||
| 909 | } | ||
| 910 | } | ||
| 911 | folders.extend(folder.groups.iter().rev()); | ||
| 912 | } | ||
| 913 | sections | ||
| 914 | } | ||
| 915 | |||
| 916 | /// Keeps the sections of a mounted notebook in sync while they are not open | ||
| 917 | /// (`Background`), polling each file every `interval` once watched. | ||
| 918 | pub fn background( | ||
| 919 | &self, | ||
| 920 | interval: Duration, | ||
| 921 | notify: impl Fn() + Send + 'static, | ||
| 922 | ) -> Result<Background> { | ||
| 923 | let Some(root) = self.root.clone() else { | ||
| 924 | return Err(io::Error::new( | ||
| 925 | io::ErrorKind::Unsupported, | ||
| 926 | "Sections on a share sync through Background::smb", | ||
| 927 | ) | ||
| 928 | .into()); | ||
| 929 | }; | ||
| 930 | Background::start( | ||
| 931 | interval, | ||
| 932 | move || { | ||
| 933 | let root = root.clone(); | ||
| 934 | Ok(move |path: &str| FileRemote(root.join(path))) | ||
| 935 | }, | ||
| 936 | notify, | ||
| 937 | ) | ||
| 938 | } | ||
| 939 | |||
| 884 | /// Opens a section of a mounted notebook by its catalog path. | 940 | /// Opens a section of a mounted notebook by its catalog path. |
| 885 | pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> { | 941 | pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> { |
| 886 | self.section_with(path, |file| Ok(FileRemote(file.to_owned())), notify) | 942 | self.section_with(path, |file| Ok(FileRemote(file.to_owned())), notify) |
| ... | @@ -1010,6 +1066,21 @@ fn catalog_path(folder: &str, name: &str) -> String { | ... | @@ -1010,6 +1066,21 @@ fn catalog_path(folder: &str, name: &str) -> String { |
| 1010 | } | 1066 | } |
| 1011 | } | 1067 | } |
| 1012 | 1068 | ||
| 1069 | /// The replica in `cache` of the section `identity` names. | ||
| 1070 | fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf { | ||
| 1071 | let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect(); | ||
| 1072 | cache.join(format!("{name}.sqlite")) | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | /// Why a synchronization step did not reach the section file, as the host shows it. | ||
| 1076 | pub(crate) fn reached(error: &Error) -> io::Error { | ||
| 1077 | match error { | ||
| 1078 | Error::RemoteIo(error) => io::Error::new(error.kind(), error.to_string()), | ||
| 1079 | Error::Remote(error) => io::Error::new(error.error.kind(), error.to_string()), | ||
| 1080 | error => io::Error::other(error.to_string()), | ||
| 1081 | } | ||
| 1082 | } | ||
| 1083 | |||
| 1013 | /// What happened to the section since the last poll. | 1084 | /// What happened to the section since the last poll. |
| 1014 | #[derive(Debug)] | 1085 | #[derive(Debug)] |
| 1015 | pub enum Event { | 1086 | pub enum Event { |
| ... | @@ -1079,13 +1150,8 @@ impl Section { | ... | @@ -1079,13 +1150,8 @@ impl Section { |
| 1079 | let source = connect(&file)?.read()?; | 1150 | let source = connect(&file)?.read()?; |
| 1080 | let store = Store::parse(&source)?; | 1151 | let store = Store::parse(&source)?; |
| 1081 | let identity = RevisionIndex::parse(&store)?.root; | 1152 | let identity = RevisionIndex::parse(&store)?.root; |
| 1082 | let name: String = identity | ||
| 1083 | .guid | ||
| 1084 | .iter() | ||
| 1085 | .map(|byte| format!("{byte:02x}")) | ||
| 1086 | .collect(); | ||
| 1087 | std::fs::create_dir_all(&cache)?; | 1153 | std::fs::create_dir_all(&cache)?; |
| 1088 | let cache = cache.as_ref().join(format!("{name}.sqlite")); | 1154 | let cache = replica_file(cache.as_ref(), &identity.guid); |
| 1089 | let replica = if cache.exists() { | 1155 | let replica = if cache.exists() { |
| 1090 | Replica::open(&cache)? | 1156 | Replica::open(&cache)? |
| 1091 | } else { | 1157 | } else { |
| ... | @@ -1149,11 +1215,7 @@ impl Section { | ... | @@ -1149,11 +1215,7 @@ impl Section { |
| 1149 | let (sender, notify) = (sender.clone(), Arc::clone(&notify)); | 1215 | let (sender, notify) = (sender.clone(), Arc::clone(&notify)); |
| 1150 | let observed = Arc::clone(&observed); | 1216 | let observed = Arc::clone(&observed); |
| 1151 | replica.start_sync(Duration::from_secs(2), connect, move |result| { | 1217 | replica.start_sync(Duration::from_secs(2), connect, move |result| { |
| 1152 | let error = result.as_ref().err().map(|error| match error { | 1218 | let error = result.as_ref().err().map(reached); |
| 1153 | Error::RemoteIo(error) => io::Error::new(error.kind(), error.to_string()), | ||
| 1154 | Error::Remote(error) => io::Error::new(error.error.kind(), error.to_string()), | ||
| 1155 | error => io::Error::other(error.to_string()), | ||
| 1156 | }); | ||
| 1157 | // Reaching the file as the last attempt did is no news to the host. | 1219 | // Reaching the file as the last attempt did is no news to the host. |
| 1158 | let mut changed = false; | 1220 | let mut changed = false; |
| 1159 | if let Ok(mut observed) = observed.lock() { | 1221 | if let Ok(mut observed) = observed.lock() { |
crates/notebook/src/smb/remote.rs+5-4| ... | @@ -1,20 +1,21 @@ | ... | @@ -1,20 +1,21 @@ |
| 1 | use super::Client; | 1 | use super::Client; |
| 2 | use crate::Remote; | 2 | use crate::Remote; |
| 3 | use onestore::{CommitError, Stamp, Transaction}; | 3 | use onestore::{CommitError, Stamp, Transaction}; |
| 4 | use std::io; | 4 | use std::{io, sync::Arc}; |
| 5 | 5 | ||
| 6 | /// Binds every reconciliation operation to one share-relative file and read limit. | 6 | /// Binds every reconciliation operation to one share-relative file and read limit. |
| 7 | /// Connection loss retires the client; reconnect before subsequent sync attempts. | 7 | /// Connection loss retires the client; reconnect before subsequent sync attempts. |
| 8 | pub struct SmbRemote { | 8 | pub struct SmbRemote { |
| 9 | client: Client, | 9 | client: Arc<Client>, |
| 10 | path: String, | 10 | path: String, |
| 11 | limit: usize, | 11 | limit: usize, |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | impl SmbRemote { | 14 | impl SmbRemote { |
| 15 | pub fn new(client: Client, path: impl Into<String>, limit: usize) -> Self { | 15 | /// A client shared between remotes serves each of their files over one connection. |
| 16 | pub fn new(client: impl Into<Arc<Client>>, path: impl Into<String>, limit: usize) -> Self { | ||
| 16 | Self { | 17 | Self { |
| 17 | client, | 18 | client: client.into(), |
| 18 | path: path.into(), | 19 | path: path.into(), |
| 19 | limit, | 20 | limit, |
| 20 | } | 21 | } |
crates/notebook/src/worker.rs+17-12| ... | @@ -12,16 +12,28 @@ use std::{ | ... | @@ -12,16 +12,28 @@ use std::{ |
| 12 | }; | 12 | }; |
| 13 | 13 | ||
| 14 | pub(super) struct Signal { | 14 | pub(super) struct Signal { |
| 15 | stopped: AtomicBool, | 15 | pub(crate) stopped: AtomicBool, |
| 16 | offline: AtomicBool, | 16 | pub(crate) offline: AtomicBool, |
| 17 | /// FILETIME of the last step or poll that reached the remote; 0 before one has. | 17 | /// FILETIME of the last step or poll that reached the remote; 0 before one has. |
| 18 | synced: AtomicU64, | 18 | synced: AtomicU64, |
| 19 | /// Asked for by `SyncWorker::wake`: steps run while offline until one leaves nothing to do. | 19 | /// Asked for by `wake`: steps run while offline until one leaves nothing to do. |
| 20 | requested: AtomicBool, | 20 | pub(crate) requested: AtomicBool, |
| 21 | sender: SyncSender<()>, | 21 | sender: SyncSender<()>, |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | impl Signal { | 24 | impl Signal { |
| 25 | pub(crate) fn new() -> (Arc<Self>, mpsc::Receiver<()>) { | ||
| 26 | let (sender, receiver) = mpsc::sync_channel(1); | ||
| 27 | let signal = Self { | ||
| 28 | stopped: AtomicBool::new(false), | ||
| 29 | offline: AtomicBool::new(false), | ||
| 30 | synced: AtomicU64::new(0), | ||
| 31 | requested: AtomicBool::new(false), | ||
| 32 | sender, | ||
| 33 | }; | ||
| 34 | (Arc::new(signal), receiver) | ||
| 35 | } | ||
| 36 | |||
| 25 | pub(super) fn wake(&self) { | 37 | pub(super) fn wake(&self) { |
| 26 | // One retained notification covers edits that arrive during network I/O. | 38 | // One retained notification covers edits that arrive during network I/O. |
| 27 | let _ = self.sender.try_send(()); | 39 | let _ = self.sender.try_send(()); |
| ... | @@ -110,14 +122,7 @@ impl Replica { | ... | @@ -110,14 +122,7 @@ impl Replica { |
| 110 | if owner.upgrade().is_some() { | 122 | if owner.upgrade().is_some() { |
| 111 | return Err(io::ErrorKind::WouldBlock.into()); | 123 | return Err(io::ErrorKind::WouldBlock.into()); |
| 112 | } | 124 | } |
| 113 | let (sender, receiver) = mpsc::sync_channel(1); | 125 | let (signal, receiver) = Signal::new(); |
| 114 | let signal = Arc::new(Signal { | ||
| 115 | stopped: AtomicBool::new(false), | ||
| 116 | offline: AtomicBool::new(false), | ||
| 117 | synced: AtomicU64::new(0), | ||
| 118 | requested: AtomicBool::new(false), | ||
| 119 | sender, | ||
| 120 | }); | ||
| 121 | let replica = Arc::clone(self); | 126 | let replica = Arc::clone(self); |
| 122 | let worker_signal = Arc::clone(&signal); | 127 | let worker_signal = Arc::clone(&signal); |
| 123 | let thread = thread::Builder::new() | 128 | let thread = thread::Builder::new() |
crates/notebook/tests/session.rs+126| ... | @@ -999,3 +999,129 @@ fn a_missing_section_file_reports_its_error_until_it_returns() { | ... | @@ -999,3 +999,129 @@ fn a_missing_section_file_reports_its_error_until_it_returns() { |
| 999 | assert!(notified.load(Ordering::SeqCst) > before); | 999 | assert!(notified.load(Ordering::SeqCst) > before); |
| 1000 | section.close().unwrap(); | 1000 | section.close().unwrap(); |
| 1001 | } | 1001 | } |
| 1002 | |||
| 1003 | /// A notebook folder holding `First.one` and `Second.one`, and a cache beside it. | ||
| 1004 | fn two_sections(directory: &Path) -> Notebook { | ||
| 1005 | let root = directory.join("Shared"); | ||
| 1006 | std::fs::create_dir(&root).unwrap(); | ||
| 1007 | for name in ["First", "Second"] { | ||
| 1008 | let file = format!("{name}.one"); | ||
| 1009 | std::fs::write( | ||
| 1010 | root.join(&file), | ||
| 1011 | onestore::create_section(&file, name, "Author").unwrap(), | ||
| 1012 | ) | ||
| 1013 | .unwrap(); | ||
| 1014 | } | ||
| 1015 | Notebook::open(&root, directory.join("cache")).unwrap() | ||
| 1016 | } | ||
| 1017 | |||
| 1018 | fn until(what: &str, mut accept: impl FnMut() -> bool) { | ||
| 1019 | let deadline = Instant::now() + Duration::from_secs(20); | ||
| 1020 | while !accept() { | ||
| 1021 | assert!(Instant::now() < deadline, "{what}"); | ||
| 1022 | std::thread::sleep(Duration::from_millis(20)); | ||
| 1023 | } | ||
| 1024 | } | ||
| 1025 | |||
| 1026 | #[test] | ||
| 1027 | fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { | ||
| 1028 | let directory = tempfile::tempdir().unwrap(); | ||
| 1029 | let notebook = two_sections(directory.path()); | ||
| 1030 | let file = directory.path().join("Shared/Second.one"); | ||
| 1031 | let background = notebook | ||
| 1032 | .background(Duration::from_millis(20), || {}) | ||
| 1033 | .unwrap(); | ||
| 1034 | background.set_offline(true); | ||
| 1035 | background.watch(notebook.replicas()); | ||
| 1036 | // The round started with the background ends before the edit exists. | ||
| 1037 | std::thread::sleep(Duration::from_millis(200)); | ||
| 1038 | let section = notebook.section("Second.one", || {}).unwrap(); | ||
| 1039 | section.set_offline(true); | ||
| 1040 | let space = section.pages().unwrap()[0].0; | ||
| 1041 | let before = section.page(space).unwrap(); | ||
| 1042 | typed(&section, space, &before, 0..0, "Closed "); | ||
| 1043 | section.close().unwrap(); | ||
| 1044 | std::thread::sleep(Duration::from_millis(300)); | ||
| 1045 | assert_eq!( | ||
| 1046 | stored_page(&file, space), | ||
| 1047 | before, | ||
| 1048 | "working offline publishes nothing" | ||
| 1049 | ); | ||
| 1050 | background.set_offline(false); | ||
| 1051 | let mut after = edited(&before, "Closed "); | ||
| 1052 | until("the closed section published", || { | ||
| 1053 | let stored = stored_page(&file, space); | ||
| 1054 | after.title = stored.title.clone(); | ||
| 1055 | stored == after | ||
| 1056 | }); | ||
| 1057 | until("the status shows nothing waiting", || { | ||
| 1058 | background.status().iter().any(|(path, status)| { | ||
| 1059 | path == "Second.one" && status.synced.is_some() && status.queued == 0 | ||
| 1060 | }) | ||
| 1061 | }); | ||
| 1062 | drop(background); | ||
| 1063 | let mut reopened = None; | ||
| 1064 | until("the background released the replica", || { | ||
| 1065 | reopened = notebook.section("Second.one", || {}).ok(); | ||
| 1066 | reopened.is_some() | ||
| 1067 | }); | ||
| 1068 | let reopened = reopened.unwrap(); | ||
| 1069 | assert!(reopened.pending().unwrap().is_empty()); | ||
| 1070 | reopened.close().unwrap(); | ||
| 1071 | } | ||
| 1072 | |||
| 1073 | #[test] | ||
| 1074 | fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { | ||
| 1075 | let directory = tempfile::tempdir().unwrap(); | ||
| 1076 | let notebook = two_sections(directory.path()); | ||
| 1077 | // Second has a replica from being opened once; First has never been opened. | ||
| 1078 | notebook | ||
| 1079 | .section("Second.one", || {}) | ||
| 1080 | .unwrap() | ||
| 1081 | .close() | ||
| 1082 | .unwrap(); | ||
| 1083 | let notified = Arc::new(AtomicUsize::new(0)); | ||
| 1084 | let counter = Arc::clone(&notified); | ||
| 1085 | let background = notebook | ||
| 1086 | .background(Duration::from_millis(20), move || { | ||
| 1087 | counter.fetch_add(1, Ordering::SeqCst); | ||
| 1088 | }) | ||
| 1089 | .unwrap(); | ||
| 1090 | background.watch(notebook.replicas()); | ||
| 1091 | until("both sections were reached", || { | ||
| 1092 | background | ||
| 1093 | .status() | ||
| 1094 | .iter() | ||
| 1095 | .all(|(_, status)| status.synced.is_some() && status.error.is_none()) | ||
| 1096 | }); | ||
| 1097 | assert!(background.changed().is_empty()); | ||
| 1098 | let mut changed = Vec::new(); | ||
| 1099 | for name in ["First.one", "Second.one"] { | ||
| 1100 | let file = directory.path().join("Shared").join(name); | ||
| 1101 | let bytes = onestore::read_file(&file).unwrap(); | ||
| 1102 | let space = notebook::session::stored_pages(&bytes).unwrap()[0].space; | ||
| 1103 | let native = edited(&model_ops::page_of(&bytes, space), "Native "); | ||
| 1104 | ops::save(&bytes, space, &native) | ||
| 1105 | .unwrap() | ||
| 1106 | .commit_file(&file) | ||
| 1107 | .unwrap(); | ||
| 1108 | changed.push((name, space, native)); | ||
| 1109 | } | ||
| 1110 | let mut noticed = Vec::new(); | ||
| 1111 | until("both changes were noticed", || { | ||
| 1112 | noticed.extend(background.changed()); | ||
| 1113 | changed | ||
| 1114 | .iter() | ||
| 1115 | .all(|(name, ..)| noticed.iter().any(|path| path == name)) | ||
| 1116 | }); | ||
| 1117 | assert!(notified.load(Ordering::SeqCst) > 0); | ||
| 1118 | drop(background); | ||
| 1119 | let (_, space, native) = &changed[1]; | ||
| 1120 | let replica = notebook.replica_path("Second.one").unwrap(); | ||
| 1121 | let mut opened = None; | ||
| 1122 | until("the background released the replica", || { | ||
| 1123 | opened = notebook::Replica::open(&replica).ok(); | ||
| 1124 | opened.is_some() | ||
| 1125 | }); | ||
| 1126 | assert_same(opened.unwrap().page(*space).unwrap(), native); | ||
| 1127 | } |
crates/snowbound/src/library.rs+99-48| ... | @@ -1,17 +1,17 @@ | ... | @@ -1,17 +1,17 @@ |
| 1 | //! Open notebooks: where each lives and the sections its tabs offer. | 1 | //! Open notebooks: where each lives and the sections its tabs offer. |
| 2 | 2 | ||
| 3 | use notebook::discover::{Folder, SectionState}; | 3 | use notebook::discover::{Folder, SectionState}; |
| 4 | use notebook::session::{Notebook, Section}; | 4 | use notebook::session::{Background, Notebook, Section}; |
| 5 | use notebook::smb::{Client, Credentials}; | 5 | use notebook::smb::{Client, Credentials}; |
| 6 | use std::{ | 6 | use std::{ |
| 7 | error::Error, | 7 | error::Error, |
| 8 | io, | 8 | io, |
| 9 | path::{Path, PathBuf}, | 9 | path::{Path, PathBuf}, |
| 10 | sync::{ | 10 | sync::{ |
| 11 | Arc, | 11 | Arc, Mutex, OnceLock, |
| 12 | atomic::{AtomicBool, Ordering}, | 12 | atomic::{AtomicBool, Ordering}, |
| 13 | }, | 13 | }, |
| 14 | time::Duration, | 14 | time::{Duration, Instant}, |
| 15 | }; | 15 | }; |
| 16 | 16 | ||
| 17 | /// OneNote's Work Offline, which like OneNote's holds for every notebook. | 17 | /// OneNote's Work Offline, which like OneNote's holds for every notebook. |
| ... | @@ -26,6 +26,23 @@ pub fn set_offline(offline: bool) { | ... | @@ -26,6 +26,23 @@ pub fn set_offline(offline: bool) { |
| 26 | OFFLINE.store(offline, Ordering::Relaxed); | 26 | OFFLINE.store(offline, Ordering::Relaxed); |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | /// Wakes the app when a notebook's closed sections report, once set. | ||
| 30 | static NOTIFY: OnceLock<Box<dyn Fn() + Send + Sync>> = OnceLock::new(); | ||
| 31 | |||
| 32 | pub fn on_background(notify: impl Fn() + Send + Sync + 'static) { | ||
| 33 | let _ = NOTIFY.set(Box::new(notify)); | ||
| 34 | } | ||
| 35 | |||
| 36 | /// How often the sections no tab shows are polled; OneNote 2010 read a closed section | ||
| 37 | /// fifteen seconds after another client changed it. | ||
| 38 | const BACKGROUND: Duration = Duration::from_secs(15); | ||
| 39 | |||
| 40 | fn notify_background() { | ||
| 41 | if let Some(notify) = NOTIFY.get() { | ||
| 42 | notify(); | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 29 | /// How long an SMB request may take before the share counts as unreachable. | 46 | /// How long an SMB request may take before the share counts as unreachable. |
| 30 | const TIMEOUT: Duration = Duration::from_secs(10); | 47 | const TIMEOUT: Duration = Duration::from_secs(10); |
| 31 | /// The largest section file read whole over SMB. | 48 | /// The largest section file read whole over SMB. |
| ... | @@ -151,6 +168,9 @@ pub struct Library { | ... | @@ -151,6 +168,9 @@ pub struct Library { |
| 151 | /// Why a notebook on a mounted SMB share opened through the mount instead. | 168 | /// Why a notebook on a mounted SMB share opened through the mount instead. |
| 152 | pub notice: Option<String>, | 169 | pub notice: Option<String>, |
| 153 | cache: PathBuf, | 170 | cache: PathBuf, |
| 171 | /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every | ||
| 172 | /// section of an open notebook. | ||
| 173 | pub background: Option<Arc<Background>>, | ||
| 154 | } | 174 | } |
| 155 | 175 | ||
| 156 | impl Library { | 176 | impl Library { |
| ... | @@ -171,12 +191,12 @@ impl Library { | ... | @@ -171,12 +191,12 @@ impl Library { |
| 171 | } | 191 | } |
| 172 | } | 192 | } |
| 173 | } | 193 | } |
| 194 | let notebook = Notebook::open(location, cache); | ||
| 174 | Self { | 195 | Self { |
| 175 | location: location.to_owned(), | 196 | location: location.to_owned(), |
| 176 | name: file_name(Path::new(location)), | 197 | name: file_name(Path::new(location)), |
| 177 | notebook: Notebook::open(location, cache) | 198 | background: notebook.as_ref().ok().and_then(local_background), |
| 178 | .map(Some) | 199 | notebook: notebook.map(Some).map_err(|error| error.to_string()), |
| 179 | .map_err(|error| error.to_string()), | ||
| 180 | server: None, | 200 | server: None, |
| 181 | notice, | 201 | notice, |
| 182 | cache: cache.to_owned(), | 202 | cache: cache.to_owned(), |
| ... | @@ -195,6 +215,17 @@ impl Library { | ... | @@ -195,6 +215,17 @@ impl Library { |
| 195 | let client = server.connect().map_err(|error| error.to_string())?; | 215 | let client = server.connect().map_err(|error| error.to_string())?; |
| 196 | let notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache) | 216 | let notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache) |
| 197 | .map_err(|error| error.to_string())?; | 217 | .map_err(|error| error.to_string())?; |
| 218 | let connect = Arc::clone(&server); | ||
| 219 | let background = Background::smb( | ||
| 220 | &server.mount.root, | ||
| 221 | LIMIT, | ||
| 222 | BACKGROUND, | ||
| 223 | move || connect.connect(), | ||
| 224 | notify_background, | ||
| 225 | ) | ||
| 226 | .map_err(|error| error.to_string())?; | ||
| 227 | background.set_offline(offline()); | ||
| 228 | background.watch(notebook.replicas()); | ||
| 198 | Ok(Self { | 229 | Ok(Self { |
| 199 | location: location.to_owned(), | 230 | location: location.to_owned(), |
| 200 | name: file_name(Path::new(location)), | 231 | name: file_name(Path::new(location)), |
| ... | @@ -202,6 +233,7 @@ impl Library { | ... | @@ -202,6 +233,7 @@ impl Library { |
| 202 | server: Some(server), | 233 | server: Some(server), |
| 203 | notice: None, | 234 | notice: None, |
| 204 | cache: cache.to_owned(), | 235 | cache: cache.to_owned(), |
| 236 | background: Some(Arc::new(background)), | ||
| 205 | }) | 237 | }) |
| 206 | } | 238 | } |
| 207 | 239 | ||
| ... | @@ -217,6 +249,9 @@ impl Library { | ... | @@ -217,6 +249,9 @@ impl Library { |
| 217 | 249 | ||
| 218 | /// This notebook as `notebook`, read again after a change. | 250 | /// This notebook as `notebook`, read again after a change. |
| 219 | pub fn with(&self, notebook: Notebook) -> Self { | 251 | pub fn with(&self, notebook: Notebook) -> Self { |
| 252 | if let Some(background) = &self.background { | ||
| 253 | background.watch(notebook.replicas()); | ||
| 254 | } | ||
| 220 | Self { | 255 | Self { |
| 221 | location: self.location.clone(), | 256 | location: self.location.clone(), |
| 222 | name: self.name.clone(), | 257 | name: self.name.clone(), |
| ... | @@ -224,6 +259,7 @@ impl Library { | ... | @@ -224,6 +259,7 @@ impl Library { |
| 224 | server: self.server.clone(), | 259 | server: self.server.clone(), |
| 225 | notice: self.notice.clone(), | 260 | notice: self.notice.clone(), |
| 226 | cache: self.cache.clone(), | 261 | cache: self.cache.clone(), |
| 262 | background: self.background.clone(), | ||
| 227 | } | 263 | } |
| 228 | } | 264 | } |
| 229 | 265 | ||
| ... | @@ -232,6 +268,7 @@ impl Library { | ... | @@ -232,6 +268,7 @@ impl Library { |
| 232 | Self { | 268 | Self { |
| 233 | location: location.to_owned(), | 269 | location: location.to_owned(), |
| 234 | name: file_name(Path::new(location)), | 270 | name: file_name(Path::new(location)), |
| 271 | background: local_background(&notebook), | ||
| 235 | notebook: Ok(Some(notebook)), | 272 | notebook: Ok(Some(notebook)), |
| 236 | server: None, | 273 | server: None, |
| 237 | notice: None, | 274 | notice: None, |
| ... | @@ -248,6 +285,7 @@ impl Library { | ... | @@ -248,6 +285,7 @@ impl Library { |
| 248 | server: None, | 285 | server: None, |
| 249 | notice: None, | 286 | notice: None, |
| 250 | cache: cache.to_owned(), | 287 | cache: cache.to_owned(), |
| 288 | background: None, | ||
| 251 | } | 289 | } |
| 252 | } | 290 | } |
| 253 | 291 | ||
| ... | @@ -258,31 +296,50 @@ impl Library { | ... | @@ -258,31 +296,50 @@ impl Library { |
| 258 | path: &str, | 296 | path: &str, |
| 259 | notify: impl Fn() + Send + 'static, | 297 | notify: impl Fn() + Send + 'static, |
| 260 | ) -> Result<Section, Box<dyn Error>> { | 298 | ) -> Result<Section, Box<dyn Error>> { |
| 261 | let section = match (&self.notebook, &self.server) { | 299 | // The background may hold the replica for a step, which takes a network round trip. |
| 262 | (Ok(Some(notebook)), Some(server)) => { | 300 | let deadline = Instant::now() + TIMEOUT * 3; |
| 263 | let identity = self | 301 | let notify = Arc::new(Mutex::new(notify)); |
| 264 | .catalog_section(notebook.catalog(), path) | 302 | let notifier = || { |
| 265 | .ok_or("The notebook doesn’t list this section")?; | 303 | let notify = Arc::clone(&notify); |
| 266 | let file = match server.mount.root.as_str() { | 304 | move || { |
| 267 | "" => path.to_owned(), | 305 | if let Ok(notify) = notify.lock() { |
| 268 | root => format!("{root}/{path}"), | 306 | notify(); |
| 269 | }; | 307 | } |
| 270 | let cache = self.cache.join("smb").join(format!("{identity}.sqlite")); | 308 | } |
| 271 | std::fs::create_dir_all(self.cache.join("smb"))?; | 309 | }; |
| 272 | let replica = if cache.exists() { | 310 | let section = loop { |
| 273 | notebook::Replica::open(&cache)? | 311 | let opened = match (&self.notebook, &self.server) { |
| 274 | } else { | 312 | (Ok(Some(notebook)), Some(server)) => { |
| 275 | notebook::Replica::create( | 313 | let file = match server.mount.root.as_str() { |
| 276 | &cache, | 314 | "" => path.to_owned(), |
| 277 | &server.connect()?.read_storage(&file, LIMIT)?, | 315 | root => format!("{root}/{path}"), |
| 278 | )? | 316 | }; |
| 279 | }; | 317 | let cache = notebook.replica_path(path)?; |
| 280 | let server = Arc::clone(server); | 318 | std::fs::create_dir_all(self.cache.join("smb"))?; |
| 281 | Section::resume_smb(file, replica, LIMIT, move || server.connect(), notify)? | 319 | let replica = if cache.exists() { |
| 320 | notebook::Replica::open(&cache) | ||
| 321 | } else { | ||
| 322 | notebook::Replica::create( | ||
| 323 | &cache, | ||
| 324 | &server.connect()?.read_storage(&file, LIMIT)?, | ||
| 325 | ) | ||
| 326 | }; | ||
| 327 | replica.and_then(|replica| { | ||
| 328 | let server = Arc::clone(server); | ||
| 329 | let connect = move || server.connect(); | ||
| 330 | Section::resume_smb(file, replica, LIMIT, connect, notifier()) | ||
| 331 | }) | ||
| 332 | } | ||
| 333 | (Ok(Some(notebook)), None) => notebook.section(path, notifier()), | ||
| 334 | (Ok(None), _) => Section::open(path, &self.cache, notifier()), | ||
| 335 | (Err(error), _) => return Err(error.clone().into()), | ||
| 336 | }; | ||
| 337 | match opened { | ||
| 338 | Err(error) if error.busy() && Instant::now() < deadline => { | ||
| 339 | std::thread::sleep(Duration::from_millis(50)); | ||
| 340 | } | ||
| 341 | opened => break opened?, | ||
| 282 | } | 342 | } |
| 283 | (Ok(Some(notebook)), None) => notebook.section(path, notify)?, | ||
| 284 | (Ok(None), _) => Section::open(path, &self.cache, notify)?, | ||
| 285 | (Err(error), _) => return Err(error.clone().into()), | ||
| 286 | }; | 343 | }; |
| 287 | section.set_offline(offline()); | 344 | section.set_offline(offline()); |
| 288 | Ok(section) | 345 | Ok(section) |
| ... | @@ -311,24 +368,6 @@ impl Library { | ... | @@ -311,24 +368,6 @@ impl Library { |
| 311 | } | 368 | } |
| 312 | } | 369 | } |
| 313 | 370 | ||
| 314 | /// The file identity, in hex, of the section at catalog `path`. | ||
| 315 | fn catalog_section(&self, catalog: &Folder, path: &str) -> Option<String> { | ||
| 316 | let mut folders = vec![catalog]; | ||
| 317 | while let Some(folder) = folders.pop() { | ||
| 318 | if let Some(section) = folder.sections.iter().find(|section| section.path == path) { | ||
| 319 | return Some( | ||
| 320 | section | ||
| 321 | .file_id | ||
| 322 | .iter() | ||
| 323 | .map(|byte| format!("{byte:02x}")) | ||
| 324 | .collect(), | ||
| 325 | ); | ||
| 326 | } | ||
| 327 | folders.extend(&folder.groups); | ||
| 328 | } | ||
| 329 | None | ||
| 330 | } | ||
| 331 | |||
| 332 | /// Names the section at catalog `path` across every open notebook, for remembering | 371 | /// Names the section at catalog `path` across every open notebook, for remembering |
| 333 | /// its pages. | 372 | /// its pages. |
| 334 | pub fn key(&self, path: &str) -> String { | 373 | pub fn key(&self, path: &str) -> String { |
| ... | @@ -407,6 +446,17 @@ pub fn recycle_bin(path: &str) -> bool { | ... | @@ -407,6 +446,17 @@ pub fn recycle_bin(path: &str) -> bool { |
| 407 | path.rsplit('/').next() == Some("OneNote_RecycleBin") | 446 | path.rsplit('/').next() == Some("OneNote_RecycleBin") |
| 408 | } | 447 | } |
| 409 | 448 | ||
| 449 | /// A mounted notebook's background sync, following Work Offline. | ||
| 450 | fn local_background(notebook: &Notebook) -> Option<Arc<Background>> { | ||
| 451 | let background = notebook | ||
| 452 | .background(BACKGROUND, notify_background) | ||
| 453 | .inspect_err(|error| eprintln!("Background sync did not start: {error}")) | ||
| 454 | .ok()?; | ||
| 455 | background.set_offline(offline()); | ||
| 456 | background.watch(notebook.replicas()); | ||
| 457 | Some(Arc::new(background)) | ||
| 458 | } | ||
| 459 | |||
| 410 | pub fn file_name(path: &Path) -> String { | 460 | pub fn file_name(path: &Path) -> String { |
| 411 | path.canonicalize() | 461 | path.canonicalize() |
| 412 | .ok() | 462 | .ok() |
| ... | @@ -617,6 +667,7 @@ mod tests { | ... | @@ -617,6 +667,7 @@ mod tests { |
| 617 | })), | 667 | })), |
| 618 | notice: None, | 668 | notice: None, |
| 619 | cache: PathBuf::new(), | 669 | cache: PathBuf::new(), |
| 670 | background: None, | ||
| 620 | }; | 671 | }; |
| 621 | let shown = |root, file| library(root).local(Path::new(file)); | 672 | let shown = |root, file| library(root).local(Path::new(file)); |
| 622 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); | 673 | let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); |
crates/snowbound/src/main.rs+31-6| ... | @@ -662,6 +662,10 @@ impl State { | ... | @@ -662,6 +662,10 @@ impl State { |
| 662 | } | 662 | } |
| 663 | let layouts = Arc::new(Mutex::new(engine.clone())); | 663 | let layouts = Arc::new(Mutex::new(engine.clone())); |
| 664 | let temporary = matches!(input, Input::Notes { .. } | Input::Page(_)); | 664 | let temporary = matches!(input, Input::Notes { .. } | Input::Page(_)); |
| 665 | let background = proxy.clone(); | ||
| 666 | library::on_background(move || { | ||
| 667 | let _ = background.send_event(UserEvent::Sync); | ||
| 668 | }); | ||
| 665 | let mut notebooks = Vec::new(); | 669 | let mut notebooks = Vec::new(); |
| 666 | let mut session = None; | 670 | let mut session = None; |
| 667 | let mut sectionless = None; | 671 | let mut sectionless = None; |
| ... | @@ -2429,7 +2433,19 @@ impl State { | ... | @@ -2429,7 +2433,19 @@ impl State { |
| 2429 | Some(library) => *library = Arc::clone(&session.library), | 2433 | Some(library) => *library = Arc::clone(&session.library), |
| 2430 | None => self.notebooks.push(Arc::clone(&session.library)), | 2434 | None => self.notebooks.push(Arc::clone(&session.library)), |
| 2431 | } | 2435 | } |
| 2432 | self.session = Some(*session); | 2436 | // The notebook's background sync takes the section over once it is closed. |
| 2437 | if let Some(previous) = self.session.replace(*session) { | ||
| 2438 | let background = previous.library.background.clone(); | ||
| 2439 | let section = previous.section; | ||
| 2440 | std::thread::spawn(move || { | ||
| 2441 | if let Err(error) = section.close() { | ||
| 2442 | eprintln!("Synchronization stopped: {error}"); | ||
| 2443 | } | ||
| 2444 | if let Some(background) = background { | ||
| 2445 | background.wake(); | ||
| 2446 | } | ||
| 2447 | }); | ||
| 2448 | } | ||
| 2433 | self.sectionless = None; | 2449 | self.sectionless = None; |
| 2434 | if other_notebook { | 2450 | if other_notebook { |
| 2435 | self.save_settings(); | 2451 | self.save_settings(); |
| ... | @@ -2769,12 +2785,24 @@ impl State { | ... | @@ -2769,12 +2785,24 @@ impl State { |
| 2769 | Ok(()) | 2785 | Ok(()) |
| 2770 | } | 2786 | } |
| 2771 | 2787 | ||
| 2772 | /// Applies what the section reported since the last poll. | 2788 | /// Applies what the section, and each notebook's closed sections, reported since the |
| 2789 | /// last poll. | ||
| 2773 | fn synced(&mut self) -> Result<(), Box<dyn Error>> { | 2790 | fn synced(&mut self) -> Result<(), Box<dyn Error>> { |
| 2791 | // Every notebook's changes are taken, so none is reported again. | ||
| 2792 | let noticed = self | ||
| 2793 | .notebooks | ||
| 2794 | .iter() | ||
| 2795 | .filter_map(|library| library.background.as_ref()) | ||
| 2796 | .filter(|background| !background.changed().is_empty()) | ||
| 2797 | .count(); | ||
| 2798 | if noticed > 0 { | ||
| 2799 | self.sync_index(true); | ||
| 2800 | } | ||
| 2801 | // Reports are rare: a status changed, or a section did. | ||
| 2802 | self.window.request_redraw(); | ||
| 2774 | let Some(session) = &mut self.session else { | 2803 | let Some(session) = &mut self.session else { |
| 2775 | return Ok(()); | 2804 | return Ok(()); |
| 2776 | }; | 2805 | }; |
| 2777 | let shown = sync::label(&session.sync); | ||
| 2778 | let mut listed = false; | 2806 | let mut listed = false; |
| 2779 | let mut changed = false; | 2807 | let mut changed = false; |
| 2780 | let mut rejected = None; | 2808 | let mut rejected = None; |
| ... | @@ -2802,9 +2830,6 @@ impl State { | ... | @@ -2802,9 +2830,6 @@ impl State { |
| 2802 | } | 2830 | } |
| 2803 | } | 2831 | } |
| 2804 | session.sync = session.section.sync_status()?; | 2832 | session.sync = session.section.sync_status()?; |
| 2805 | if listed || changed || shown != sync::label(&session.sync) { | ||
| 2806 | self.window.request_redraw(); | ||
| 2807 | } | ||
| 2808 | if listed { | 2833 | if listed { |
| 2809 | let back = session.shown; | 2834 | let back = session.shown; |
| 2810 | session.pages = session.section.pages()?; | 2835 | session.pages = session.section.pages()?; |
crates/snowbound/src/sync.rs+120-19| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | //! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook | 1 | //! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook |
| 2 | //! Synchronization for the open section. | 2 | //! Synchronization for the open section's notebook and each of its sections. |
| 3 | 3 | ||
| 4 | use crate::{Session, State, art, filetime, library, platform}; | 4 | use crate::{Session, State, art, filetime, library, platform}; |
| 5 | use notebook::session::SyncStatus; | 5 | use notebook::session::SyncStatus; |
| ... | @@ -56,8 +56,71 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option | ... | @@ -56,8 +56,71 @@ fn describe(sync: &SyncStatus) -> (&'static str, &'static [&'static str], Option |
| 56 | } | 56 | } |
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | pub(crate) fn label(sync: &SyncStatus) -> &'static str { | 59 | /// Labels from the best state to the worst, which a notebook's status shows. |
| 60 | describe(sync).0 | 60 | const ORDER: [&str; 5] = [ |
| 61 | "Up to date", | ||
| 62 | "Syncing…", | ||
| 63 | "Section in use", | ||
| 64 | "Not connected", | ||
| 65 | "Unable to sync", | ||
| 66 | ]; | ||
| 67 | |||
| 68 | fn rank(sync: &SyncStatus) -> usize { | ||
| 69 | let label = describe(sync).0; | ||
| 70 | ORDER.iter().position(|shown| *shown == label).unwrap_or(0) | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Every section of the open section's notebook with its status, in catalog order: the | ||
| 74 | /// open one's from its session, the others' from the notebook's background sync. | ||
| 75 | fn sections(session: &Session) -> Vec<(String, SyncStatus)> { | ||
| 76 | let open = &session.tabs[session.tab].path; | ||
| 77 | let copy = |sync: &SyncStatus| SyncStatus { | ||
| 78 | synced: sync.synced, | ||
| 79 | error: sync | ||
| 80 | .error | ||
| 81 | .as_ref() | ||
| 82 | .map(|error| std::io::Error::new(error.kind(), error.to_string())), | ||
| 83 | queued: sync.queued, | ||
| 84 | }; | ||
| 85 | let mut sections = session | ||
| 86 | .library | ||
| 87 | .background | ||
| 88 | .as_ref() | ||
| 89 | .map(|background| background.status()) | ||
| 90 | .unwrap_or_default(); | ||
| 91 | match sections.iter_mut().find(|(path, _)| path == open) { | ||
| 92 | Some((_, sync)) => *sync = copy(&session.sync), | ||
| 93 | None => sections.insert(0, (open.clone(), copy(&session.sync))), | ||
| 94 | } | ||
| 95 | sections | ||
| 96 | } | ||
| 97 | |||
| 98 | /// The notebook as a whole: the worst section's error, every waiting change, and the | ||
| 99 | /// oldest last sync. | ||
| 100 | fn overall(sections: &[(String, SyncStatus)]) -> SyncStatus { | ||
| 101 | let worst = sections | ||
| 102 | .iter() | ||
| 103 | .map(|(_, sync)| sync) | ||
| 104 | .filter(|sync| sync.error.is_some()) | ||
| 105 | .max_by_key(|sync| rank(sync)); | ||
| 106 | SyncStatus { | ||
| 107 | synced: sections | ||
| 108 | .iter() | ||
| 109 | .map(|(_, sync)| sync.synced) | ||
| 110 | .collect::<Option<Vec<_>>>() | ||
| 111 | .and_then(|times| times.into_iter().min()), | ||
| 112 | error: worst | ||
| 113 | .and_then(|sync| sync.error.as_ref()) | ||
| 114 | .map(|error| std::io::Error::new(error.kind(), error.to_string())), | ||
| 115 | queued: sections.iter().map(|(_, sync)| sync.queued).sum(), | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | fn changes(count: u64) -> String { | ||
| 120 | match count { | ||
| 121 | 1 => "1 change".to_owned(), | ||
| 122 | count => format!("{count} changes"), | ||
| 123 | } | ||
| 61 | } | 124 | } |
| 62 | 125 | ||
| 63 | /// A FILETIME as OneNote's "Last sync": the time, and the date too before today. | 126 | /// A FILETIME as OneNote's "Last sync": the time, and the date too before today. |
| ... | @@ -73,8 +136,9 @@ fn when(time: u64) -> String { | ... | @@ -73,8 +136,9 @@ fn when(time: u64) -> String { |
| 73 | 136 | ||
| 74 | /// The status's icon in the toolbar, named in its tooltip, which opens the popup. | 137 | /// The status's icon in the toolbar, named in its tooltip, which opens the popup. |
| 75 | pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) { | 138 | pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) { |
| 76 | let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline(); | 139 | let sync = overall(&sections(session)); |
| 77 | let (label, icon, _) = describe(&session.sync); | 140 | let strong = ui.popup_open(id()) || sync.error.is_some() && !library::offline(); |
| 141 | let (label, icon, _) = describe(&sync); | ||
| 78 | ui.open_as( | 142 | ui.open_as( |
| 79 | button(), | 143 | button(), |
| 80 | Spec { | 144 | Spec { |
| ... | @@ -102,7 +166,7 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) { | ... | @@ -102,7 +166,7 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) { |
| 102 | impl State { | 166 | impl State { |
| 103 | /// Builds the sync status popup while it is open. | 167 | /// Builds the sync status popup while it is open. |
| 104 | pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> { | 168 | pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> { |
| 105 | let ui = &mut self.ui; | 169 | let (ui, notebooks) = (&mut self.ui, &self.notebooks); |
| 106 | let Some(session) = &mut self.session else { | 170 | let Some(session) = &mut self.session else { |
| 107 | ui.close_popup(id()); | 171 | ui.close_popup(id()); |
| 108 | return Ok(()); | 172 | return Ok(()); |
| ... | @@ -114,7 +178,9 @@ impl State { | ... | @@ -114,7 +178,9 @@ impl State { |
| 114 | session.sync = session.section.sync_status()?; | 178 | session.sync = session.section.sync_status()?; |
| 115 | let theme = ui.theme.clone(); | 179 | let theme = ui.theme.clone(); |
| 116 | let offline = library::offline(); | 180 | let offline = library::offline(); |
| 117 | let (progress, _, advice) = describe(&session.sync); | 181 | let sections = sections(session); |
| 182 | let sync = overall(&sections); | ||
| 183 | let (progress, _, advice) = describe(&sync); | ||
| 118 | ui.open_as( | 184 | ui.open_as( |
| 119 | id(), | 185 | id(), |
| 120 | Spec { | 186 | Spec { |
| ... | @@ -177,23 +243,54 @@ impl State { | ... | @@ -177,23 +243,54 @@ impl State { |
| 177 | row(ui, "Notebook", &session.library.name); | 243 | row(ui, "Notebook", &session.library.name); |
| 178 | row(ui, "Location", &session.library.location); | 244 | row(ui, "Location", &session.library.location); |
| 179 | row(ui, "Connection", &session.library.transport()); | 245 | row(ui, "Connection", &session.library.transport()); |
| 180 | row(ui, "Section", &session.tabs[session.tab].name); | ||
| 181 | row(ui, "Progress", progress); | 246 | row(ui, "Progress", progress); |
| 182 | if let Some(synced) = session.sync.synced { | 247 | if let Some(synced) = sync.synced { |
| 183 | row(ui, "Last sync", &when(synced)); | 248 | row(ui, "Last sync", &when(synced)); |
| 184 | } | 249 | } |
| 185 | if session.sync.queued > 0 { | 250 | if sync.queued > 0 { |
| 186 | let queued = match session.sync.queued { | 251 | row(ui, "Not yet synced", &changes(sync.queued)); |
| 187 | 1 => "1 change".to_owned(), | ||
| 188 | count => format!("{count} changes"), | ||
| 189 | }; | ||
| 190 | row(ui, "Not yet synced", &queued); | ||
| 191 | } | 252 | } |
| 192 | if let Some(advice) = advice { | 253 | if let Some(advice) = advice { |
| 193 | text(ui, "advice", advice, theme.text, false); | 254 | text(ui, "advice", advice, theme.text, false); |
| 194 | } | 255 | } |
| 195 | if let Some(error) = &session.sync.error { | 256 | text(ui, "sections", "Sections", theme.text, true); |
| 196 | text(ui, "error", &error.to_string(), theme.text_dim, false); | 257 | for (index, (path, sync)) in sections.iter().enumerate() { |
| 258 | ui.open( | ||
| 259 | format!("section-{index}"), | ||
| 260 | Spec { | ||
| 261 | size: [fill(), children()], | ||
| 262 | gap: 8.0, | ||
| 263 | ..Spec::default() | ||
| 264 | }, | ||
| 265 | ); | ||
| 266 | let name = path.strip_suffix(".one").unwrap_or(path); | ||
| 267 | ui.leaf( | ||
| 268 | "name", | ||
| 269 | Spec { | ||
| 270 | size: [fill(), px(theme.font_size * 1.6)], | ||
| 271 | text: Some(name), | ||
| 272 | overflow: Overflow::Ellipsis, | ||
| 273 | ..Spec::default() | ||
| 274 | }, | ||
| 275 | ); | ||
| 276 | let status = match sync.queued { | ||
| 277 | 0 => describe(sync).0.to_owned(), | ||
| 278 | queued => format!("{}, {}", describe(sync).0, changes(queued)), | ||
| 279 | }; | ||
| 280 | ui.leaf( | ||
| 281 | "status", | ||
| 282 | Spec { | ||
| 283 | size: [fit(), px(theme.font_size * 1.6)], | ||
| 284 | text: Some(&status), | ||
| 285 | color: Some(theme.text_dim), | ||
| 286 | ..Spec::default() | ||
| 287 | }, | ||
| 288 | ); | ||
| 289 | ui.close(); | ||
| 290 | if let Some(error) = &sync.error { | ||
| 291 | let part = format!("section-{index}-error"); | ||
| 292 | text(ui, &part, &error.to_string(), theme.text_dim, false); | ||
| 293 | } | ||
| 197 | } | 294 | } |
| 198 | if let Some(notice) = &session.library.notice { | 295 | if let Some(notice) = &session.library.notice { |
| 199 | let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}"); | 296 | let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}"); |
| ... | @@ -239,12 +336,16 @@ impl State { | ... | @@ -239,12 +336,16 @@ impl State { |
| 239 | let now = ui::button(ui, "sync-now", "Sync Now").clicked; | 336 | let now = ui::button(ui, "sync-now", "Sync Now").clicked; |
| 240 | ui.close(); | 337 | ui.close(); |
| 241 | ui.close(); | 338 | ui.close(); |
| 339 | let backgrounds = notebooks | ||
| 340 | .iter() | ||
| 341 | .filter_map(|library| library.background.as_ref()); | ||
| 242 | if toggled { | 342 | if toggled { |
| 243 | library::set_offline(!offline); | 343 | library::set_offline(!offline); |
| 244 | session.section.set_offline(!offline); | 344 | session.section.set_offline(!offline); |
| 245 | } | 345 | backgrounds.for_each(|background| background.set_offline(!offline)); |
| 246 | if now { | 346 | } else if now { |
| 247 | session.section.wake(); | 347 | session.section.wake(); |
| 348 | backgrounds.for_each(|background| background.wake()); | ||
| 248 | } | 349 | } |
| 249 | if let Some(file) = file.filter(|_| show) { | 350 | if let Some(file) = file.filter(|_| show) { |
| 250 | platform::show_file(&file); | 351 | platform::show_file(&file); |