136 lines
5.7 KiB
Rust
136 lines
5.7 KiB
Rust
//! Data Recovery in the app: the timer that keeps recovery copies of modified documents every
|
|
//! Preferences → File Handling → Data Recovery interval (written in the background like Background
|
|
//! Save, the status bar showing it), and the dialog the first frame shows when the last session
|
|
//! left copies behind (a crash). The copies themselves are the engine's
|
|
//! ([`vectorcraft_engine::cmd::recovery`]).
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_engine::cmd::recovery;
|
|
|
|
use crate::VectorcraftApp;
|
|
use crate::background::{self, Writer};
|
|
use crate::state::Dialog;
|
|
|
|
/// Dialog kind of the startup question. Fields: `copies` (`[{file, title, saved}]`, from
|
|
/// `file.recovery.list`) and `discard` (Discard instead of Restore).
|
|
pub const KIND: &str = "recovery";
|
|
|
|
/// The timer's state.
|
|
#[derive(Default)]
|
|
pub struct Timer {
|
|
/// The first frame looked for copies left behind.
|
|
checked: bool,
|
|
/// When copies were last written (app time, s); the interval runs from there.
|
|
last: Option<f64>,
|
|
/// When the heartbeat was last refreshed (app time, s).
|
|
beat: f64,
|
|
}
|
|
|
|
/// Each frame: the startup question once, the timer and the heartbeat (`now`: app time in
|
|
/// seconds) → in how many seconds they want the next frame (none while Data Recovery is off).
|
|
pub fn frame(app: &mut VectorcraftApp, now: f64) -> Option<f64> {
|
|
if !app.recovery.checked {
|
|
app.recovery.checked = true;
|
|
offer(app);
|
|
}
|
|
tick(app, now);
|
|
if !(0.0..recovery::HEARTBEAT_EVERY).contains(&(now - app.recovery.beat)) {
|
|
app.recovery.beat = now;
|
|
recovery::heartbeat(&app.session);
|
|
}
|
|
let interval = f64::from(app.session.prefs.autosave_interval.max(1)) * 60.0;
|
|
let next = app.recovery.last.map(|t| t + interval - now)?;
|
|
Some(next.min(app.recovery.beat + recovery::HEARTBEAT_EVERY - now).max(1.0))
|
|
}
|
|
|
|
/// Ask about the copies a crash left behind, if any (the Recover Documents dialog). → whether it
|
|
/// asked.
|
|
pub fn offer(app: &mut VectorcraftApp) -> bool {
|
|
if recovery::store(&app.session).is_none() || app.ui.dialog.is_some() {
|
|
return false;
|
|
}
|
|
let Ok(r) = app.session.execute("file.recovery.list", &json!({})) else { return false };
|
|
// Neither this app's own nor kept by another VectorCraft that is running.
|
|
let left: Vec<Value> = r["copies"].as_array().into_iter().flatten().filter(|c| c["open"] == false && c["running"] == false).cloned().collect();
|
|
if left.is_empty() {
|
|
return false;
|
|
}
|
|
app.ui.dialog = Some(Dialog::new(KIND, json!({ "copies": left })));
|
|
true
|
|
}
|
|
|
|
/// Start a recovery copy of every document that needs one when the interval has passed since the
|
|
/// last (the first call only starts the clock) → how many were started.
|
|
pub fn tick(app: &mut VectorcraftApp, now: f64) -> usize {
|
|
let prefs = &app.session.prefs;
|
|
let Some(store) = recovery::store(&app.session).filter(|_| prefs.autosave_recovery) else {
|
|
app.recovery.last = None;
|
|
return 0;
|
|
};
|
|
let interval = f64::from(prefs.autosave_interval.max(1)) * 60.0;
|
|
match app.recovery.last {
|
|
Some(t) if now >= t && now - t < interval => return 0,
|
|
Some(t) if now >= t => app.recovery.last = Some(now),
|
|
// First call, or a clock that went back: the interval starts now.
|
|
_ => {
|
|
app.recovery.last = Some(now);
|
|
return 0;
|
|
}
|
|
}
|
|
recovery::forget_clean(&mut app.session);
|
|
let jobs = match recovery::jobs(&mut app.session, &store) {
|
|
// A document whose save or last copy is still being written waits for the next round.
|
|
Ok((jobs, _)) => jobs.into_iter().filter(|j| !app.background.busy_with(j.uid)).collect::<Vec<_>>(),
|
|
Err(e) => {
|
|
app.status(format!("Couldn't save recovery data: {e}"));
|
|
return 0;
|
|
}
|
|
};
|
|
let mut started = 0;
|
|
for job in jobs {
|
|
let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone());
|
|
let (done, store) = (job.clone(), store.clone());
|
|
let work = move |_: Writer| job.write(store.as_ref());
|
|
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
|
|
let r = r?;
|
|
done.finish(&mut app.session);
|
|
app.status(format!("Saved recovery data for {title}"));
|
|
Ok(r)
|
|
};
|
|
// Its own store, not the file writer: this only decides whether it runs on the worker.
|
|
if let Err(e) = background::run(app, true, label, Some(uid), work, then) {
|
|
app.status(format!("Couldn't save recovery data: {e}"));
|
|
}
|
|
started += 1;
|
|
}
|
|
started
|
|
}
|
|
|
|
/// The startup dialog's OK (Restore) or Discard: every copy it lists.
|
|
pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|
app.ui.dialog = None;
|
|
let id = if d.bool("discard") { "file.recovery.discard" } else { "file.recovery.restore" };
|
|
let files: Vec<String> =
|
|
d.fields.get("copies").and_then(Value::as_array).into_iter().flatten().filter_map(|c| c["file"].as_str()).map(str::to_string).collect();
|
|
let (mut done, mut errors) = (vec![], vec![]);
|
|
for file in files {
|
|
match app.run(id, json!({ "file": file })) {
|
|
Ok(r) => {
|
|
if let Some(restored) = r["restored"].as_array().and_then(|v| v.first()) {
|
|
crate::dialogs::missing_links::after_open(app, restored);
|
|
}
|
|
done.push(r);
|
|
}
|
|
Err(e) => errors.push(e),
|
|
}
|
|
}
|
|
if !errors.is_empty() {
|
|
return Err(errors.join("; "));
|
|
}
|
|
app.status(match id {
|
|
"file.recovery.discard" => format!("Discarded {} recovered document(s)", done.len()),
|
|
_ => format!("Restored {} recovered document(s): save them to keep them", done.len()),
|
|
});
|
|
Ok(json!(done))
|
|
}
|