diff --git a/Cargo.lock b/Cargo.lock index 68e1c4681e..2d65812337 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,6 +505,15 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.69.1" @@ -622,11 +631,10 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.0.83" +version = "1.0.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "0f8e7c90afad890484a21653d08b6e209ae34770fb5ee298f9c699fcc1e5c856" dependencies = [ - "jobserver", "libc", ] @@ -1021,6 +1029,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41b319d1b62ffbd002e057f36bebd1f42b9f97927c9577461d855f3513c4289f" +[[package]] +name = "deranged" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_builder" version = "0.11.2" @@ -1075,6 +1092,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "downcast-rs" version = "1.2.0" @@ -2160,15 +2198,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" -dependencies = [ - "libc", -] - [[package]] name = "jpeg-decoder" version = "0.3.0" @@ -2274,6 +2303,17 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libredox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +dependencies = [ + "bitflags 2.4.1", + "libc", + "redox_syscall", +] + [[package]] name = "librsvg" version = "2.57.0" @@ -2680,6 +2720,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -2773,6 +2822,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "owo-colors" version = "3.5.0" @@ -3157,6 +3212,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bccab0e7fd7cc19f820a1c8c91720af652d0c88dc9664dd72aef2614f04af3b" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -3355,6 +3416,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.10.2" @@ -3400,6 +3472,7 @@ dependencies = [ "anyhow", "base64", "cairo-rs", + "directories", "fs_extra", "futures", "gettext-rs", @@ -3436,6 +3509,7 @@ dependencies = [ "serde_json", "svg", "thiserror", + "time", "unicode-segmentation", "url", "winresource", @@ -3494,6 +3568,7 @@ dependencies = [ "anyhow", "approx", "base64", + "bincode", "cairo-rs", "chrono", "clap", @@ -4239,6 +4314,37 @@ dependencies = [ "weezl", ] +[[package]] +name = "time" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" +dependencies = [ + "deranged", + "itoa", + "libc", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +dependencies = [ + "time-core", +] + [[package]] name = "tiny-skia-path" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 60d3fbf727..8dbdd2c88e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,9 @@ poppler-rs = { version = "0.22", features = ["v20_9"] } gtk4 = { version = "0.7.3", features = ["v4_12"] } adw = { version = "0.5.3", package="libadwaita", features = ["v1_4"] } numeric-sort = "0.1" +time = { version = "0.3.22", default-features = false, features = ["formatting", "local-offset"] } +directories = "5.0.1" +bincode = "1.3.3" [patch.crates-io] # once a new piet (current v0.6.2) is released with the updated cairo, this can be removed diff --git a/crates/rnote-engine/Cargo.toml b/crates/rnote-engine/Cargo.toml index 9fba942434..649e39d2ec 100644 --- a/crates/rnote-engine/Cargo.toml +++ b/crates/rnote-engine/Cargo.toml @@ -56,6 +56,7 @@ poppler-rs = { workspace = true } # the long-term plan is to remove the gtk4 dependency entirely after switching to another renderer. gtk4 = { workspace = true, optional = true } clap = { workspace = true, optional = true } +bincode = { workspace = true } [dev-dependencies] approx = { workspace = true } diff --git a/crates/rnote-engine/src/engine/export.rs b/crates/rnote-engine/src/engine/export.rs index 11beae7c4a..58a1086244 100644 --- a/crates/rnote-engine/src/engine/export.rs +++ b/crates/rnote-engine/src/engine/export.rs @@ -1,6 +1,7 @@ // Imports use super::{Engine, EngineConfig, StrokeContent}; use crate::fileformats::rnoteformat::RnoteFile; +use crate::fileformats::rnoterecoveryformat::RnoteRecoveryFile; use crate::fileformats::{xoppformat, FileFormatSaver}; use anyhow::Context; use futures::channel::oneshot; @@ -334,6 +335,24 @@ impl Engine { }); oneshot_receiver } + /// Save the current document as a .rnote~ file. + pub fn save_as_rnote_recovery_bytes( + &self, + file_name: String, + ) -> oneshot::Receiver>> { + let (oneshot_sender, oneshot_receiver) = oneshot::channel::>>(); + let engine_snapshot = self.take_snapshot(); + rayon::spawn(move || { + let result = || -> anyhow::Result> { + let rnote_file = RnoteRecoveryFile { engine_snapshot }; + rnote_file.save_as_bytes(&file_name) + }; + if let Err(_data) = oneshot_sender.send(result()) { + log::error!("Sending result to receiver in save_as_rnote_bytes() failed. Receiver was already dropped."); + } + }); + oneshot_receiver + } /// Extract the current engine configuration. pub fn extract_engine_config(&self) -> EngineConfig { diff --git a/crates/rnote-engine/src/engine/snapshot.rs b/crates/rnote-engine/src/engine/snapshot.rs index 109757bd41..747b9a8db9 100644 --- a/crates/rnote-engine/src/engine/snapshot.rs +++ b/crates/rnote-engine/src/engine/snapshot.rs @@ -1,7 +1,7 @@ // Imports use crate::document::background; use crate::engine::import::XoppImportPrefs; -use crate::fileformats::{rnoteformat, xoppformat, FileFormatLoader}; +use crate::fileformats::{rnoteformat, rnoterecoveryformat, xoppformat, FileFormatLoader}; use crate::store::{ChronoComponent, StrokeKey}; use crate::strokes::Stroke; use crate::{Camera, Document, Engine}; @@ -60,6 +60,25 @@ impl EngineSnapshot { snapshot_receiver.await? } + + pub async fn load_from_rnote_recovery_bytes(bytes: Vec) -> anyhow::Result { + let (snapshot_sender, snapshot_receiver) = oneshot::channel::>(); + + rayon::spawn(move || { + let result = || -> anyhow::Result { + let rnote_recovery_file = + rnoterecoveryformat::RnoteRecoveryFile::load_from_bytes(&bytes) + .context("loading RnoteRecoveryFile failed")?; + Ok(rnote_recovery_file.engine_snapshot) + }; + + if let Err(_data) = snapshot_sender.send(result()) { + log::error!("Sending result to receiver in open_from_rnote_bytes() failed. Receiver was already dropped."); + } + }); + + snapshot_receiver.await? + } /// Loads from the bytes of a Xournal++ .xopp file. /// /// To import this snapshot into the current engine, use [`Engine::load_snapshot()`]. diff --git a/crates/rnote-engine/src/fileformats/mod.rs b/crates/rnote-engine/src/fileformats/mod.rs index cbad8df318..05cbe20869 100644 --- a/crates/rnote-engine/src/fileformats/mod.rs +++ b/crates/rnote-engine/src/fileformats/mod.rs @@ -1,5 +1,7 @@ // Modules +pub mod recoverymetadataformat; pub mod rnoteformat; +pub mod rnoterecoveryformat; pub mod xoppformat; // Imports diff --git a/crates/rnote-engine/src/fileformats/recoverymetadataformat.rs b/crates/rnote-engine/src/fileformats/recoverymetadataformat.rs new file mode 100644 index 0000000000..0e55102c99 --- /dev/null +++ b/crates/rnote-engine/src/fileformats/recoverymetadataformat.rs @@ -0,0 +1,154 @@ +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use std::{ + cell::{Cell, RefCell}, + fs::remove_file, + path::{Path, PathBuf}, +}; + +use super::{FileFormatLoader, FileFormatSaver}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(default)] +/// Metadata of a revovery save +pub struct RnRecoveryMetadata { + title: RefCell>, + document_path: RefCell>, + created: u64, + last_changed: Cell, + recovery_file_path: PathBuf, + #[serde(skip)] + metadata_path: PathBuf, +} + +impl Default for RnRecoveryMetadata { + fn default() -> Self { + Self { + title: RefCell::new(None), + document_path: RefCell::new(None), + created: 0, + last_changed: Cell::new(0), + // Triggers error if default used + recovery_file_path: PathBuf::from("/"), + // Will be overwritten + metadata_path: PathBuf::from("/"), + } + } +} + +impl FileFormatSaver for RnRecoveryMetadata { + fn save_as_bytes(&self, file_name: &str) -> anyhow::Result> { + let data = serde_json::to_string(self).expect("Failed to parse recovery format"); + let bytes = data.as_bytes(); + std::fs::write(file_name, bytes).expect("Failed to write file"); + Ok(bytes.to_vec()) + } +} +impl FileFormatLoader for RnRecoveryMetadata { + fn load_from_bytes(bytes: &[u8]) -> anyhow::Result + where + Self: Sized, + { + serde_json::from_slice(bytes).context("failed to parse bytes") + } +} + +impl RnRecoveryMetadata { + /// Get the path to the file being backed up + pub fn document_path(&self) -> Option { + self.document_path.borrow().clone() + } + /// Remove recovery file and metadata from disk + pub fn delete(&self) { + if let Err(e) = remove_file(&self.recovery_file_path) { + log::error!( + "Failed to delete recovery file {}: {e}", + self.recovery_file_path.display() + ) + }; + self.delete_meta() + } + /// Remove recovery metadata from disk + pub fn delete_meta(&self) { + if let Err(e) = remove_file(&self.metadata_path) { + log::error!( + "Failed to delete recovery metadata {}: {e}", + self.metadata_path.display() + ) + } + } + + /// Get the creation date as unix timestamp + pub fn crated(&self) -> u64 { + self.created + } + /// Get the last changed date as unix timestamp + pub fn last_changed(&self) -> u64 { + self.last_changed.get() + } + /// Load instance from given Path + pub fn load_from_path(path: impl AsRef) -> anyhow::Result { + let path = path.as_ref(); + let bytes = std::fs::read(path).context("Failed to read file")?; + let mut out = Self::load_from_bytes(&bytes)?; + out.metadata_path = path.to_path_buf(); + Ok(out) + } + /// Get the metadata path + pub fn metadata_path(&self) -> PathBuf { + self.metadata_path.clone() + } + + /// Create new Cargo metadata + pub fn new( + created: u64, + metadata_path: impl Into, + recovery_file_path: impl Into, + ) -> Self { + let out = Self { + title: RefCell::new(None), + document_path: RefCell::new(None), + last_changed: Cell::new(0), + created, + recovery_file_path: recovery_file_path.into(), + metadata_path: metadata_path.into(), + }; + out.update_last_changed(); + out + } + /// Get the path to Recovery file + pub fn recovery_file_path(&self) -> PathBuf { + self.recovery_file_path.clone() + } + /// Save recovery data + pub fn save(&self) -> anyhow::Result> { + self.save_as_bytes(self.metadata_path.to_str().unwrap()) + } + /// Get the document title + pub fn title(&self) -> Option { + self.title.borrow().clone() + } + /// Update Metadata based of the given document option + pub fn update(&self, document_path: &Option) { + self.update_last_changed(); + match document_path { + Some(p) if document_path.ne(&*self.document_path.borrow()) => { + self.document_path.replace(document_path.clone()); + self.title + .borrow_mut() + .replace(p.file_stem().unwrap().to_string_lossy().to_string()); + } + Some(_) => (), + None => (), + }; + } + /// Replace last_changed with the current unix time + pub(crate) fn update_last_changed(&self) { + self.last_changed.replace( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Failed to get unix time") + .as_secs(), + ); + } +} diff --git a/crates/rnote-engine/src/fileformats/rnoterecoveryformat.rs b/crates/rnote-engine/src/fileformats/rnoterecoveryformat.rs new file mode 100644 index 0000000000..33f42fafab --- /dev/null +++ b/crates/rnote-engine/src/fileformats/rnoterecoveryformat.rs @@ -0,0 +1,43 @@ +use anyhow::Context; + +use crate::engine::EngineSnapshot; + +use super::{FileFormatLoader, FileFormatSaver}; + +#[derive(Debug)] +pub struct RnoteRecoveryFile { + pub engine_snapshot: EngineSnapshot, +} + +// impl From<&Engine> for RnoteRecoveryFile { +// fn from(value: &Engine) -> Self { +// Self { +// engine_snapshot: bincode::serialize(value).unwrap(), +// } +// } +// } + +impl FileFormatSaver for RnoteRecoveryFile { + fn save_as_bytes(&self, _file_name: &str) -> anyhow::Result> { + let bytes = bincode::serialize(&self.engine_snapshot)?; + Ok(bytes) + } +} + +impl FileFormatLoader for RnoteRecoveryFile { + fn load_from_bytes(bytes: &[u8]) -> anyhow::Result + where + Self: Sized, + { + Ok(Self { + engine_snapshot: bincode::deserialize(bytes) + .context("Failed to load recovery snapshot")?, + }) + } +} + +// impl From for Engine { +// fn from(val: RnoteRecoveryFile) -> Self { +// bincode::deserialize(&val.engine_snapshot).unwrap() +// } +// } diff --git a/crates/rnote-engine/src/lib.rs b/crates/rnote-engine/src/lib.rs index 6377c48a7f..c2e1816b66 100644 --- a/crates/rnote-engine/src/lib.rs +++ b/crates/rnote-engine/src/lib.rs @@ -33,6 +33,7 @@ pub use document::Document; pub use drawable::Drawable; pub use drawable::DrawableOnDoc; pub use engine::Engine; +pub use fileformats::recoverymetadataformat::RnRecoveryMetadata; pub use pens::PenHolder; pub use selectioncollision::SelectionCollision; pub use store::StrokeStore; diff --git a/crates/rnote-ui/Cargo.toml b/crates/rnote-ui/Cargo.toml index 14e98a11e3..647cda8bae 100644 --- a/crates/rnote-ui/Cargo.toml +++ b/crates/rnote-ui/Cargo.toml @@ -49,6 +49,8 @@ poppler-rs = {workspace = true } gettext-rs = {workspace = true } gtk4 = { workspace = true } adw = { workspace = true } +time = { workspace = true } +directories = { workspace = true } open = { workspace = true } numeric-sort = { workspace = true } diff --git a/crates/rnote-ui/data/app.gschema.xml.in b/crates/rnote-ui/data/app.gschema.xml.in index 95b8c4bdb4..7d0bb024d2 100644 --- a/crates/rnote-ui/data/app.gschema.xml.in +++ b/crates/rnote-ui/data/app.gschema.xml.in @@ -121,6 +121,14 @@ 120 the sec interval for the autosave + + true + true when document recovery is enabled + + + 120 + the sec interval for the recovery saves + false Whether the canvas scrollbars are shown diff --git a/crates/rnote-ui/data/resources.gresource.xml b/crates/rnote-ui/data/resources.gresource.xml index 30628dd3ec..581e85ed83 100644 --- a/crates/rnote-ui/data/resources.gresource.xml +++ b/crates/rnote-ui/data/resources.gresource.xml @@ -4,6 +4,7 @@ ui/dialogs/dialogs.ui ui/dialogs/export.ui ui/dialogs/import.ui + ui/dialogs/recovery.ui ui/groupediconpicker/groupediconpicker.ui ui/groupediconpicker/groupediconpickergroup.ui ui/penssidebar/brushpage.ui diff --git a/crates/rnote-ui/data/ui/appmenu.ui b/crates/rnote-ui/data/ui/appmenu.ui index 971fb7bb8d..3cdfb735e8 100644 --- a/crates/rnote-ui/data/ui/appmenu.ui +++ b/crates/rnote-ui/data/ui/appmenu.ui @@ -60,6 +60,14 @@ Export Engine _Config win.debug-export-engine-config + + Get _Recovery Info + win.debug-recovery-info + + + Crash App + win.crash-app + diff --git a/crates/rnote-ui/data/ui/dialogs/dialogs.ui b/crates/rnote-ui/data/ui/dialogs/dialogs.ui index 87ee558a44..2e04864683 100644 --- a/crates/rnote-ui/data/ui/dialogs/dialogs.ui +++ b/crates/rnote-ui/data/ui/dialogs/dialogs.ui @@ -12,6 +12,18 @@ + + Overwrite File + + overwrite + cancel + + Cancel + Overwrite + Select new .. + + + New Document Creating a new document will discard any unsaved changes. diff --git a/crates/rnote-ui/data/ui/dialogs/recovery.ui b/crates/rnote-ui/data/ui/dialogs/recovery.ui new file mode 100644 index 0000000000..a722f9122d --- /dev/null +++ b/crates/rnote-ui/data/ui/dialogs/recovery.ui @@ -0,0 +1,31 @@ + + + + Recovery Info + Info about document recovery for the current document. + ok + + Copy + Ok + + + + + Recover Documents + Oops, looks like Rnote crashed last time! + We recovered documents. + Please select what you want to do with them: + confirm + show_later + + + Recovered Documents + + + + Show Later + Discard All + Apply + + + diff --git a/crates/rnote-ui/data/ui/settingspanel.ui b/crates/rnote-ui/data/ui/settingspanel.ui index d750d3ddd2..ec0f227251 100644 --- a/crates/rnote-ui/data/ui/settingspanel.ui +++ b/crates/rnote-ui/data/ui/settingspanel.ui @@ -53,6 +53,20 @@ 0 + + + Recovery + Enable or disable document recovery + + + + + Recovery Interval (secs) + Set the recovery interval in seconds + general_recovery_interval_secs_adj + 0 + + Show Scrollbars @@ -529,5 +543,11 @@ on a drawing pad 5 120 + + 1 + 9999 + 5 + 120 + diff --git a/crates/rnote-ui/src/appwindow/appwindowactions.rs b/crates/rnote-ui/src/appwindow/appwindowactions.rs index b30ac8a499..3a3436e332 100644 --- a/crates/rnote-ui/src/appwindow/appwindowactions.rs +++ b/crates/rnote-ui/src/appwindow/appwindowactions.rs @@ -54,6 +54,11 @@ impl RnAppWindow { let action_debug_export_engine_config = gio::SimpleAction::new("debug-export-engine-config", None); self.add_action(&action_debug_export_engine_config); + let action_debug_display_recovery_info = + gio::SimpleAction::new("debug-recovery-info", None); + self.add_action(&action_debug_display_recovery_info); + let action_crash_app = gio::SimpleAction::new("crash-app", None); + self.add_action(&action_crash_app); let action_righthanded = gio::PropertyAction::new("righthanded", self, "righthanded"); self.add_action(&action_righthanded); let action_touch_drawing = gio::PropertyAction::new("touch-drawing", self, "touch-drawing"); @@ -264,6 +269,20 @@ impl RnAppWindow { }), ); + // Show recovery info + action_debug_display_recovery_info.connect_activate( + clone!(@weak self as appwindow => move |_, _| { + glib::MainContext::default().spawn_local(clone!(@weak appwindow => async move { + dialogs::dialog_recovery_info(&appwindow).await; + })); + }), + ); + + // Crash App + action_crash_app.connect_activate( + clone!(@weak self as appwindow => move |_, _| panic!("Test Crash") ), + ); + // Doc layout action_doc_layout.connect_activate( clone!(@weak self as appwindow => move |action, target| { diff --git a/crates/rnote-ui/src/appwindow/imp.rs b/crates/rnote-ui/src/appwindow/imp.rs index 06e32f44db..096119729e 100644 --- a/crates/rnote-ui/src/appwindow/imp.rs +++ b/crates/rnote-ui/src/appwindow/imp.rs @@ -1,5 +1,5 @@ // Imports -use crate::{config, dialogs, RnMainHeader, RnOverlays, RnSidebar}; +use crate::{config, dialogs, RnMainHeader, RnOverlays, RnRecoveryAction, RnSidebar}; use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; use gtk4::{ @@ -15,10 +15,14 @@ use std::rc::Rc; pub(crate) struct RnAppWindow { pub(crate) drawing_pad_controller: RefCell>, pub(crate) autosave_source_id: RefCell>, + pub(crate) recovery_source_id: RefCell>, pub(crate) periodic_configsave_source_id: RefCell>, pub(crate) autosave: Cell, pub(crate) autosave_interval_secs: Cell, + pub(crate) recovery: Cell, + pub(crate) recovery_interval_secs: Cell, + pub(crate) recovery_actions: RefCell>>, pub(crate) righthanded: Cell, pub(crate) block_pinch_zoom: Cell, pub(crate) touch_drawing: Cell, @@ -41,10 +45,14 @@ impl Default for RnAppWindow { Self { drawing_pad_controller: RefCell::new(None), autosave_source_id: RefCell::new(None), + recovery_source_id: RefCell::new(None), periodic_configsave_source_id: RefCell::new(None), autosave: Cell::new(true), autosave_interval_secs: Cell::new(super::RnAppWindow::AUTOSAVE_INTERVAL_DEFAULT), + recovery: Cell::new(true), + recovery_interval_secs: Cell::new(super::RnAppWindow::RECOVERY_INTERVAL_DEFAULT), + recovery_actions: RefCell::new(None), righthanded: Cell::new(true), block_pinch_zoom: Cell::new(false), touch_drawing: Cell::new(false), @@ -118,6 +126,14 @@ impl ObjectImpl for RnAppWindow { .maximum(u32::MAX) .default_value(super::RnAppWindow::AUTOSAVE_INTERVAL_DEFAULT) .build(), + glib::ParamSpecBoolean::builder("recovery") + .default_value(true) + .build(), + glib::ParamSpecUInt::builder("recovery-interval-secs") + .minimum(5) + .maximum(u32::MAX) + .default_value(super::RnAppWindow::RECOVERY_INTERVAL_DEFAULT) + .build(), glib::ParamSpecBoolean::builder("righthanded") .default_value(false) .build(), @@ -139,6 +155,8 @@ impl ObjectImpl for RnAppWindow { match pspec.name() { "autosave" => self.autosave.get().to_value(), "autosave-interval-secs" => self.autosave_interval_secs.get().to_value(), + "recovery" => self.recovery.get().to_value(), + "recovery-interval-secs" => self.recovery_interval_secs.get().to_value(), "righthanded" => self.righthanded.get().to_value(), "block-pinch-zoom" => self.block_pinch_zoom.get().to_value(), "touch-drawing" => self.touch_drawing.get().to_value(), @@ -174,6 +192,31 @@ impl ObjectImpl for RnAppWindow { self.update_autosave_handler(); } } + "recovery" => { + let recovery = value + .get::() + .expect("The value needs to be of type `bool`"); + + self.recovery.replace(recovery); + + if recovery { + self.update_recovery_handler(); + } else if let Some(recovery_source_id) = self.recovery_source_id.borrow_mut().take() + { + recovery_source_id.remove(); + } + } + "recovery-interval-secs" => { + let recovery_interval_secs = value + .get::() + .expect("The value needs to be of type `u32`"); + + self.recovery_interval_secs.replace(recovery_interval_secs); + + if self.recovery.get() { + self.update_recovery_handler(); + } + } "righthanded" => { let righthanded = value .get::() @@ -219,6 +262,7 @@ impl WindowImpl for RnAppWindow { dialogs::dialog_close_window(&obj).await; })); } else { + obj.tabs_recovery_metadata_delete(); obj.close_force(); } @@ -236,25 +280,60 @@ impl RnAppWindow { let obj = self.obj(); if let Some(removed_id) = self.autosave_source_id.borrow_mut().replace(glib::source::timeout_add_seconds_local(self.autosave_interval_secs.get(), - clone!(@weak obj as appwindow => @default-return glib::ControlFlow::Break, move || { - let canvas = appwindow.active_tab_wrapper().canvas(); + clone!(@weak obj as appwindow => @default-return glib::ControlFlow::Break, move || { + let canvas = appwindow.active_tab_wrapper().canvas(); - if let Some(output_file) = canvas.output_file() { - glib::MainContext::default().spawn_local(clone!(@weak canvas, @weak appwindow => async move { - if let Err(e) = canvas.save_document_to_file(&output_file).await { - canvas.set_output_file(None); + if let Some(output_file) = canvas.output_file() { + glib::MainContext::default().spawn_local(clone!(@weak canvas, @weak appwindow => async move { + if let Err(e) = canvas.save_document_to_file(&output_file).await { + canvas.set_output_file(None); - log::error!("saving document failed, Error: `{e:?}`"); - appwindow.overlays().dispatch_toast_error(&gettext("Saving document failed")); - } + log::error!("saving document failed, Error: `{e:?}`"); + appwindow.overlays().dispatch_toast_error(&gettext("Saving document failed")); } - )); + })); } + glib::ControlFlow::Continue + }) + )) { + removed_id.remove(); + } + } + fn update_recovery_handler(&self) { + let obj = self.obj(); + + if let Some(removed_id) = self.recovery_source_id.borrow_mut().replace(glib::source::timeout_add_seconds_local(self.recovery_interval_secs.get(), + clone!(@weak obj as appwindow => @default-return glib::ControlFlow::Break, move || { + let canvas = appwindow.active_tab_wrapper().canvas(); + // Doing both recovery and autosaves of saved files serves little advantage and could lead to slowdowns or io conflicts + if canvas.output_file().is_some() && appwindow.autosave() { + // Delete recovery files from disk to avoid suggesting the user an outdated file on next boot + if let Some(meta) = &*canvas.imp().recovery_metadata.borrow_mut() { + meta.delete(); + }; + canvas.set_recovery_paused(true); + // We keep the metadata itself in the canvas to make sure the path doesn't change when the user toggles autosave + // which would lead to wrong timestamps in the recovery menu + } else if dbg!(canvas.unsaved_changes_recovery()) { + glib::MainContext::default().spawn_local(clone!(@weak canvas, @weak appwindow => async move { + canvas.set_recovery_paused(false); + let recovery_file = canvas.get_or_generate_recovery_file(); + appwindow.overlays().progressbar_start_pulsing(); + canvas.set_recovery_in_progress(true); + if let Err(e) = canvas.save_document_to_file(&recovery_file).await { + log::error!("saving document failed, Error: `{e:?}`"); + appwindow.overlays().dispatch_toast_error(&gettext("Saving document failed")); + } + canvas.set_recovery_in_progress(false); + appwindow.overlays().progressbar_finish(); + })); + } glib::ControlFlow::Continue - }))) { - removed_id.remove(); - } + }) + )) { + removed_id.remove(); + } } fn setup_input(&self) { diff --git a/crates/rnote-ui/src/appwindow/mod.rs b/crates/rnote-ui/src/appwindow/mod.rs index c7b3c9574c..62bf3ec199 100644 --- a/crates/rnote-ui/src/appwindow/mod.rs +++ b/crates/rnote-ui/src/appwindow/mod.rs @@ -5,10 +5,12 @@ mod imp; // Imports use crate::{ - config, dialogs, FileType, RnApp, RnCanvas, RnCanvasWrapper, RnMainHeader, RnOverlays, - RnSidebar, + config, + dialogs::{self, RnRecoveryAction}, + FileType, RnApp, RnCanvas, RnCanvasWrapper, RnMainHeader, RnOverlays, RnSidebar, }; use adw::{prelude::*, subclass::prelude::*}; +use cairo::glib::clone; use gettextrs::gettext; use gtk4::{gdk, gio, glib, Application, IconTheme}; use rnote_compose::Color; @@ -28,12 +30,21 @@ glib::wrapper! { impl RnAppWindow { const AUTOSAVE_INTERVAL_DEFAULT: u32 = 30; + const RECOVERY_INTERVAL_DEFAULT: u32 = 20; const PERIODIC_CONFIGSAVE_INTERVAL: u32 = 10; pub(crate) fn new(app: &Application) -> Self { glib::Object::builder().property("application", app).build() } + pub(crate) fn set_recovery_action(&self, i: usize, action: RnRecoveryAction) { + self.imp() + .recovery_actions + .borrow_mut() + .as_mut() + .expect("Recovery actions not set")[i] = action + } + #[allow(unused)] pub(crate) fn autosave(&self) -> bool { self.property::("autosave") @@ -44,6 +55,25 @@ impl RnAppWindow { self.set_property("autosave", autosave.to_value()); } + #[allow(unused)] + pub(crate) fn recovery(&self) -> bool { + self.property::("recovery") + } + + #[allow(unused)] + pub(crate) fn set_recovery(&self, recovery: bool) { + self.set_property("recovery", recovery.to_value()); + } + #[allow(unused)] + pub(crate) fn recovery_interval_secs(&self) -> u32 { + self.property::("recovery-interval-secs") + } + + #[allow(unused)] + pub(crate) fn set_recovery_interval_secs(&self, autosave_interval_secs: u32) { + self.set_property("recovery-interval-secs", autosave_interval_secs.to_value()); + } + #[allow(unused)] pub(crate) fn autosave_interval_secs(&self) -> u32 { self.property::("autosave-interval-secs") @@ -149,6 +179,9 @@ impl RnAppWindow { .redo_button() .set_sensitive(false); self.refresh_ui_from_engine(&self.active_tab_wrapper()); + glib::MainContext::default().spawn_local(clone!(@weak self as appwindow => async move { + dialogs::dialog_recover_documents(&appwindow).await; + })); } fn setup_icon_theme(&self) { @@ -331,6 +364,25 @@ impl RnAppWindow { }) .any(|c| c.unsaved_changes()) } + pub(crate) fn tabs_recovery_metadata_delete(&self) { + for canvas in self + .overlays() + .tabview() + .pages() + .snapshot() + .iter() + .map(|o| { + o.downcast_ref::() + .unwrap() + .child() + .downcast_ref::() + .unwrap() + .canvas() + }) + { + canvas.recovery_metadata_delete(); + } + } pub(crate) fn tabs_query_file_opened( &self, @@ -481,7 +533,7 @@ impl RnAppWindow { let (bytes, _) = input_file.load_bytes_future().await?; let widget_flags = wrapper .canvas() - .load_in_rnote_bytes(bytes.to_vec(), input_file.path()) + .load_in_rnote_bytes(bytes.to_vec(), input_file.path(), None) .await?; if rnote_file_new_tab { appwindow.append_wrapper_new_tab(&wrapper); diff --git a/crates/rnote-ui/src/canvas/imexport.rs b/crates/rnote-ui/src/canvas/imexport.rs index 2b9cbfb665..cb29fcf7bd 100644 --- a/crates/rnote-ui/src/canvas/imexport.rs +++ b/crates/rnote-ui/src/canvas/imexport.rs @@ -6,6 +6,7 @@ use rnote_compose::ext::Vector2Ext; use rnote_engine::engine::export::{DocExportPrefs, DocPagesExportPrefs, SelectionExportPrefs}; use rnote_engine::engine::{EngineSnapshot, StrokeContent}; use rnote_engine::strokes::Stroke; +use rnote_engine::RnRecoveryMetadata; use rnote_engine::WidgetFlags; use std::ops::Range; use std::path::Path; @@ -21,19 +22,34 @@ impl RnCanvas { &self, bytes: Vec, file_path: Option

, + recovery_metadata: Option, ) -> anyhow::Result where P: AsRef, { - let engine_snapshot = EngineSnapshot::load_from_rnote_bytes(bytes).await?; + dbg!(&recovery_metadata); + let engine_snapshot = if recovery_metadata.is_some() { + EngineSnapshot::load_from_rnote_recovery_bytes(bytes).await? + } else { + EngineSnapshot::load_from_rnote_bytes(bytes).await? + }; let mut widget_flags = self.engine_mut().load_snapshot(engine_snapshot); widget_flags |= self .engine_mut() .set_scale_factor(self.scale_factor() as f64); + let mut unsaved_changes = false; + if let Some(meta) = &recovery_metadata { + self.set_output_file(meta.document_path().map(gio::File::for_path)); + unsaved_changes = true; + self.set_unsaved_changes_recovery(false); + } + self.dismiss_output_file_modified_toast(); self.set_output_file(file_path.map(gio::File::for_path)); self.dismiss_output_file_modified_toast(); - self.set_unsaved_changes(false); + self.set_recovery_metadata(recovery_metadata); + + self.set_unsaved_changes(unsaved_changes); self.set_empty(false); Ok(widget_flags) @@ -43,6 +59,7 @@ impl RnCanvas { /// /// If the origin file is set to None, this does nothing and returns an error. pub(crate) async fn reload_from_disk(&self) -> anyhow::Result<()> { + let recovery_metadata = self.recovery_metadata(); let Some(output_file) = self.output_file() else { return Err(anyhow::anyhow!( "Failed to reload file from disk, no file path saved." @@ -50,7 +67,7 @@ impl RnCanvas { }; let (bytes, _) = output_file.load_bytes_future().await?; let widget_flags = self - .load_in_rnote_bytes(bytes.to_vec(), output_file.path()) + .load_in_rnote_bytes(bytes.to_vec(), output_file.path(), recovery_metadata) .await?; self.emit_handle_widget_flags(widget_flags); Ok(()) @@ -256,10 +273,13 @@ impl RnCanvas { self.set_save_in_progress(true); - let rnote_bytes_receiver = self - .engine_ref() - .save_as_rnote_bytes(basename.to_string_lossy().to_string()); - + let rnote_bytes_receiver = if self.recovery_in_progress() { + self.engine_ref() + .save_as_rnote_recovery_bytes(basename.to_string_lossy().to_string()) + } else { + self.engine_ref() + .save_as_rnote_bytes(basename.to_string_lossy().to_string()) + }; let mut skip_set_output_file = false; if let Some(current_file_path) = self.output_file().and_then(|f| f.path()) { if same_file::is_same_file(current_file_path, file_path).unwrap_or(false) { @@ -269,7 +289,7 @@ impl RnCanvas { // this **must** come before actually saving the file to disk, // else the event might not be caught by the monitor for new or changed files - if !skip_set_output_file { + if !skip_set_output_file && !self.recovery_in_progress() { self.set_output_file(Some(file.to_owned())); } @@ -283,15 +303,26 @@ impl RnCanvas { if let Err(e) = res { self.set_save_in_progress(false); + if self.recovery_in_progress() { + self.set_recovery_in_progress(false) + } // If the file operations failed in any way, we make sure to clear the expect_write flag // because we can't know for sure if the output_file monitor will be able to. self.set_output_file_expect_write(false); return Err(e); } + self.update_recovery_metadata(); - self.set_unsaved_changes(false); + if self.recovery_in_progress() { + self.set_unsaved_changes_recovery(false); + } else { + self.set_unsaved_changes(false); + } self.set_save_in_progress(false); + if self.recovery_in_progress() { + self.set_recovery_in_progress(false) + } Ok(true) } diff --git a/crates/rnote-ui/src/canvas/mod.rs b/crates/rnote-ui/src/canvas/mod.rs index 2b0b96109f..09c5292e18 100644 --- a/crates/rnote-ui/src/canvas/mod.rs +++ b/crates/rnote-ui/src/canvas/mod.rs @@ -6,6 +6,7 @@ mod widgetflagsboxed; // Re-exports pub(crate) use canvaslayout::RnCanvasLayout; +use rand::Rng; pub(crate) use widgetflagsboxed::WidgetFlagsBoxed; // Imports @@ -22,8 +23,7 @@ use rnote_compose::ext::AabbExt; use rnote_compose::penevent::PenState; use rnote_engine::ext::GraphenePointExt; use rnote_engine::ext::GrapheneRectExt; -use rnote_engine::Camera; -use rnote_engine::{Engine, WidgetFlags}; +use rnote_engine::{Camera, Engine, RnRecoveryMetadata, WidgetFlags}; use std::cell::{Cell, Ref, RefCell, RefMut}; #[derive(Debug, Default)] @@ -67,6 +67,10 @@ mod imp { pub(crate) engine: RefCell, pub(crate) engine_task_handler_handle: RefCell>>, + pub(crate) recovery_in_progress: Cell, + pub(crate) recovery_metadata: RefCell>, + /// Only used for recovery info dialog + pub(crate) recovery_paused: Cell, pub(crate) output_file: RefCell>, pub(crate) output_file_saved_modified_date_time: RefCell>, pub(crate) output_file_monitor: RefCell>, @@ -75,6 +79,7 @@ mod imp { pub(crate) output_file_expect_write: Cell, pub(crate) save_in_progress: Cell, pub(crate) unsaved_changes: Cell, + pub(crate) unsaved_changes_recovery: Cell, pub(crate) empty: Cell, pub(crate) touch_drawing: Cell, pub(crate) show_drawing_cursor: Cell, @@ -160,6 +165,9 @@ mod imp { engine: RefCell::new(engine), engine_task_handler_handle: RefCell::new(None), + recovery_in_progress: Cell::new(false), + recovery_metadata: RefCell::new(None), + recovery_paused: Cell::new(false), output_file: RefCell::new(None), // is automatically updated whenever the output file changes. output_file_saved_modified_date_time: RefCell::new(None), @@ -169,6 +177,7 @@ mod imp { output_file_expect_write: Cell::new(false), save_in_progress: Cell::new(false), unsaved_changes: Cell::new(false), + unsaved_changes_recovery: Cell::new(false), empty: Cell::new(true), touch_drawing: Cell::new(false), show_drawing_cursor: Cell::new(false), @@ -253,6 +262,9 @@ mod imp { glib::ParamSpecBoolean::builder("unsaved-changes") .default_value(false) .build(), + glib::ParamSpecBoolean::builder("unsaved-changes-recovery") + .default_value(false) + .build(), glib::ParamSpecBoolean::builder("empty") .default_value(true) .build(), @@ -282,6 +294,7 @@ mod imp { match pspec.name() { "output-file" => self.output_file.borrow().to_value(), "unsaved-changes" => self.unsaved_changes.get().to_value(), + "unsaved-changes-recovery" => self.unsaved_changes.get().to_value(), "empty" => self.empty.get().to_value(), "hadjustment" => self.hadjustment.borrow().to_value(), "vadjustment" => self.vadjustment.borrow().to_value(), @@ -319,8 +332,18 @@ mod imp { "unsaved-changes" => { let unsaved_changes: bool = value.get().expect("The value needs to be of type `bool`"); + if unsaved_changes { + dbg!("ucr!"); + self.unsaved_changes_recovery.replace(true); + } self.unsaved_changes.replace(unsaved_changes); } + "unsaved-changes-recovery" => { + let unsaved_changes_recovery: bool = + value.get().expect("The value needs to be of type `bool`"); + self.unsaved_changes_recovery + .replace(dbg!(unsaved_changes_recovery)); + } "empty" => { let empty: bool = value.get().expect("The value needs to be of type `bool`"); self.empty.replace(empty); @@ -619,6 +642,43 @@ impl RnCanvas { self.set_property("drawing-cursor", drawing_cursor.to_value()); } + pub(crate) fn get_or_generate_recovery_file(&self) -> gio::File { + match self.imp().recovery_metadata.borrow().as_ref() { + Some(meta) => return gio::File::for_path(meta.recovery_file_path()), + _ => (), + } + let recovery_path = crate::env::recovery_dir().expect("Failed to get recovery path"); + if !recovery_path.exists() { + std::fs::create_dir_all(&recovery_path).expect("Failed to create directory") + }; + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Failed to get unix time") + .as_secs(); + // Random Suffix to avoid 2 opened documents created in the same second overwriting each others recovery saves + // the chance of 2 documents being created in the same second and having the same suffix is neglegable + let suffix: u16 = rand::thread_rng().gen_range(1000..=9999); + let name = format!("{time}_{suffix}.rnoterecovery"); + let recovery_file_path = recovery_path.join(name); + let mut metadata_path = recovery_file_path.clone(); + metadata_path.set_extension("json"); + let metadata = RnRecoveryMetadata::new(time, metadata_path, recovery_file_path); + let recovery_file_path = metadata.recovery_file_path(); + self.imp().recovery_metadata.borrow_mut().replace(metadata); + gio::File::for_path(recovery_file_path) + } + + /// Deletes recovery save and metadate from disk + pub fn recovery_metadata_delete(&self) { + if let Some(meta) = &*self.imp().recovery_metadata.borrow() { + meta.delete() + } + } + + pub fn recovery_metadata(&self) -> Option { + self.imp().recovery_metadata.borrow().as_ref().cloned() + } + #[allow(unused)] pub(crate) fn output_file(&self) -> Option { self.property::>("output-file") @@ -649,6 +709,28 @@ impl RnCanvas { self.imp().save_in_progress.set(save_in_progress); } + #[allow(unused)] + pub(crate) fn recovery_in_progress(&self) -> bool { + self.imp().recovery_in_progress.get() + } + + #[allow(unused)] + pub(crate) fn set_recovery_in_progress(&self, recovery_in_progress: bool) { + self.imp().recovery_in_progress.set(recovery_in_progress); + } + + pub(crate) fn recovery_paused(&self) -> bool { + self.imp().recovery_paused.get() + } + + pub(crate) fn set_recovery_paused(&self, recovery_paused: bool) { + self.imp().recovery_paused.replace(recovery_paused); + } + + pub(crate) fn set_recovery_metadata(&self, recovery_metadata: Option) { + self.imp().recovery_metadata.replace(recovery_metadata); + } + #[allow(unused)] pub(crate) fn unsaved_changes(&self) -> bool { self.property::("unsaved-changes") @@ -656,9 +738,20 @@ impl RnCanvas { #[allow(unused)] pub(crate) fn set_unsaved_changes(&self, unsaved_changes: bool) { - if self.imp().unsaved_changes.get() != unsaved_changes { - self.set_property("unsaved-changes", unsaved_changes.to_value()); - } + self.set_property("unsaved-changes", unsaved_changes.to_value()); + } + + #[allow(unused)] + pub(crate) fn unsaved_changes_recovery(&self) -> bool { + self.property::("unsaved-changes-recovery") + } + + #[allow(unused)] + pub(crate) fn set_unsaved_changes_recovery(&self, unsaved_changes_recovery: bool) { + self.set_property( + "unsaved-changes-recovery", + unsaved_changes_recovery.to_value(), + ); } #[allow(unused)] @@ -1305,4 +1398,19 @@ impl RnCanvas { na::point![f64::from(self.width()), f64::from(self.height())], ) } + pub(crate) fn update_recovery_metadata(&self) { + if let Some(m) = self.imp().recovery_metadata.borrow().as_ref() { + m.update( + &self + .imp() + .output_file + .borrow() + .clone() + .map(|f| f.path().unwrap()), + ); + if let Err(e) = m.save() { + log::error!("Failed to save recovery metadata: {e}") + }; + } + } } diff --git a/crates/rnote-ui/src/dialogs/mod.rs b/crates/rnote-ui/src/dialogs/mod.rs index 9af3dc8c31..707eba7b66 100644 --- a/crates/rnote-ui/src/dialogs/mod.rs +++ b/crates/rnote-ui/src/dialogs/mod.rs @@ -4,6 +4,7 @@ // Modules pub(crate) mod export; pub(crate) mod import; +pub(crate) mod recovery; // Imports use crate::appwindow::RnAppWindow; @@ -18,6 +19,12 @@ use gtk4::{ gio, glib, glib::clone, Builder, Button, CheckButton, ColorDialogButton, Dialog, FileDialog, Label, MenuButton, ResponseType, ShortcutsWindow, StringList, }; +use std::path::Path; + +// Re-exports +pub(crate) use recovery::dialog_recover_documents; +pub(crate) use recovery::dialog_recovery_info; +pub(crate) use recovery::RnRecoveryAction; // About Dialog pub(crate) fn dialog_about(appwindow: &RnAppWindow) { @@ -89,6 +96,26 @@ pub(crate) async fn dialog_clear_doc(appwindow: &RnAppWindow, canvas: &RnCanvas) } } +/// Display a dialog confirming a file overwrite. +/// +/// The file needs to be supplied as Path or PathBuf. +/// The return value is "cancel", "selectnew" or "overwrite" +pub(crate) async fn dialog_confirm_overwrite_file( + appwindow: &RnAppWindow, + path: impl AsRef, +) -> glib::GString { + let builder = Builder::from_resource( + (String::from(config::APP_IDPATH) + "ui/dialogs/dialogs.ui").as_str(), + ); + let dialog: adw::MessageDialog = builder.object("dialog_confirm_overwrite_file").unwrap(); + dialog.set_transient_for(Some(appwindow)); + dialog.set_body(&format!( + "File {} already exits!\n Overwrite existing file?", + path.as_ref().display() + )); + dialog.choose_future().await +} + pub(crate) async fn dialog_new_doc(appwindow: &RnAppWindow, canvas: &RnCanvas) { let builder = Builder::from_resource( (String::from(config::APP_IDPATH) + "ui/dialogs/dialogs.ui").as_str(), @@ -242,7 +269,10 @@ pub(crate) async fn dialog_close_tab(appwindow: &RnAppWindow, tab_page: &adw::Ta // Returns close_finish_confirm, a boolean that indicates if the tab should actually be closed or closing // should be aborted. match dialog.choose_future().await.as_str() { - "discard" => true, + "discard" => { + canvas.recovery_metadata_delete(); + true + } "save" => { if let Some(save_file) = save_file { appwindow.overlays().progressbar_start_pulsing(); @@ -286,6 +316,7 @@ pub(crate) async fn dialog_close_window(appwindow: &RnAppWindow) { let canvas_output_file = canvas.output_file(); if !canvas.unsaved_changes() { + canvas.recovery_metadata_delete(); continue; } @@ -377,7 +408,16 @@ pub(crate) async fn dialog_close_window(appwindow: &RnAppWindow) { let close = match dialog.choose_future().await.as_str() { "discard" => { - // do nothing and close + // remove recovery artifacts and close + for (i, _check, _save_file) in rows { + let canvas = tabs[i] + .child() + .downcast::() + .unwrap() + .canvas(); + + canvas.recovery_metadata_delete(); + } true } "save" => { diff --git a/crates/rnote-ui/src/dialogs/recovery.rs b/crates/rnote-ui/src/dialogs/recovery.rs new file mode 100644 index 0000000000..fd54745ba1 --- /dev/null +++ b/crates/rnote-ui/src/dialogs/recovery.rs @@ -0,0 +1,357 @@ +use adw::{ + prelude::MessageDialogExtManual, + traits::{ActionRowExt, MessageDialogExt, PreferencesGroupExt}, +}; +use cairo::glib::{self, clone}; +use gettextrs::gettext; +use gtk4::{ + gdk::Display, + gio, + prelude::{DisplayExt, FileExt}, + subclass::prelude::ObjectSubclassIsExt, + traits::GtkWindowExt, + traits::ToggleButtonExt, + Builder, FileDialog, ToggleButton, +}; +use std::{ + ffi::OsStr, + fs::remove_file, + path::{Path, PathBuf}, +}; +use time::{format_description::well_known::Rfc2822, OffsetDateTime}; + +use crate::{appwindow::RnAppWindow, config, env::recovery_dir}; +use rnote_engine::RnRecoveryMetadata; + +#[derive(Clone, Debug, Default)] +pub(crate) enum RnRecoveryAction { + Discard, + SaveAs(PathBuf), + ShowLater, + #[default] + Open, + CleanInvalid, +} + +pub(crate) async fn dialog_recovery_info(appwindow: &RnAppWindow) { + let builder = Builder::from_resource( + (String::from(config::APP_IDPATH) + "ui/dialogs/recovery.ui").as_str(), + ); + let dialog: adw::MessageDialog = builder.object("dialog_recovery_info").unwrap(); + dialog.set_transient_for(Some(appwindow)); + dialog.set_modal(true); + let canvas = appwindow.active_tab_wrapper().canvas(); + let info = { + let recovery = appwindow.recovery(); + let autosave = appwindow.autosave(); + let unsaved_changes_recovery = canvas.unsaved_changes_recovery(); + let unsaved_changes = canvas.unsaved_changes(); + let recovery_metadata = canvas.imp().recovery_metadata.borrow(); + let recovery_paused = canvas.recovery_paused(); + let created = recovery_metadata + .as_ref() + .map(|m| format_unix_timestamp(m.crated())); + let last_changed = recovery_metadata + .as_ref() + .map(|m| format_unix_timestamp(m.last_changed())); + format!( + "recovery: {recovery} +autosave: {autosave} +unsaved_changed: {unsaved_changes} +unsaved_changes_recovery: {unsaved_changes_recovery} +metadata: {recovery_metadata:#?} +recovery_paused: {recovery_paused} +created: {created:?} +last_changed: {last_changed:?}", + ) + }; + dialog.set_body(&info); + match dialog.choose_future().await.as_str() { + "copy" => Display::default().unwrap().clipboard().set_text(&info), + "ok" => (), + c => unimplemented!("{c}"), + }; +} + +pub(crate) async fn dialog_recover_documents(appwindow: &RnAppWindow) { + let metadata_found = find_metadata(); + if metadata_found.is_empty() { + log::debug!("No recovery files found"); + return; + } + let builder = Builder::from_resource( + (String::from(config::APP_IDPATH) + "ui/dialogs/recovery.ui").as_str(), + ); + let mut rows = Vec::new(); + let dialog: adw::MessageDialog = builder.object("dialog_recover_documents").unwrap(); + let recover_documents_group: adw::PreferencesGroup = + builder.object("recover_documents_group").unwrap(); + dialog.set_transient_for(Some(appwindow)); + appwindow.imp().recovery_actions.replace(Some(vec![ + RnRecoveryAction::default(); + metadata_found.len() + ])); + for (i, metadata) in metadata_found.iter().enumerate() { + // let recovery_row: RnRecoveryRow = RnRecoveryRow::new(); + // recovery_row.init(appwindow, metadata.clone()); + let mut valid = true; + let subtitle = match metadata.recovery_file_path() { + // detect default value => missing path + p if p == PathBuf::from("/") => { + valid = false; + "ERROR: No recovery_file specified".to_string() + } + p if !p.exists() => { + valid = false; + "ERROR: recovery_file does not exist".to_string() + } + _ => format!( + "Created: {}\nLast Changed: {}", + format_unix_timestamp(metadata.crated()), + format_unix_timestamp(metadata.last_changed()) + ), + }; + let row: adw::ActionRow = adw::ActionRow::builder() + .title(metadata.title().unwrap_or_else(|| String::from("Unsaved"))) + .subtitle(subtitle) + .subtitle_lines(2) + .build(); + if valid { + let open_button = ToggleButton::builder() + .icon_name("tab-new-symbolic") + .tooltip_text("Recover document in new tab") + .active(true) + .build(); + let save_as_button = ToggleButton::builder() + .icon_name("doc-save-symbolic") + .tooltip_text("Save file to selected path") + .group(&open_button) + .build(); + let show_later_button = ToggleButton::builder() + .icon_name("workspacelistentryicon-clock-symbolic") + .tooltip_text("Show option again next launch") + .group(&open_button) + .build(); + let discard_button = ToggleButton::builder() + .icon_name("trash-empty") + .tooltip_text("Discard document") + .group(&open_button) + .build(); + + discard_button.connect_toggled(clone!(@weak appwindow => move |button| { + if button.is_active() { + appwindow.set_recovery_action(i, RnRecoveryAction::Discard) + } + })); + open_button.connect_toggled(clone!(@weak appwindow => move |button| { + if button.is_active(){ + appwindow.set_recovery_action(i, RnRecoveryAction::Open) + } + })); + save_as_button.connect_toggled(clone!(@weak appwindow => move |button| { + if !button.is_active(){ + return; + } + glib::MainContext::default().spawn_local(clone!(@weak appwindow, @weak button => async move { + if let Some(path) = get_save_as_path(&appwindow).await { + appwindow.set_recovery_action(i, RnRecoveryAction::SaveAs(path)) + }else{ + button.set_active(false); + } + })); + })); + show_later_button.connect_toggled(clone!(@weak appwindow => move |button| { + if button.is_active(){ + appwindow.set_recovery_action(i, RnRecoveryAction::ShowLater) + } + })); + row.add_suffix(&open_button); + row.add_suffix(&save_as_button); + row.add_suffix(&show_later_button); + row.add_suffix(&discard_button); + } else { + appwindow.set_recovery_action(i, RnRecoveryAction::CleanInvalid) + } + + recover_documents_group.add(&row); + rows.push(row); + } + let choice = dialog.choose_future().await; + let mut actions = appwindow.imp().recovery_actions.replace(None).unwrap(); + assert_eq!(metadata_found.len(), actions.len()); + match choice.as_str() { + // CleanInvalid will always be executed + "discard_all" => { + for action in &mut actions { + match action { + RnRecoveryAction::CleanInvalid => (), + _ => *action = RnRecoveryAction::Discard, + } + } + } + "show_later" => { + for action in &mut actions { + match action { + RnRecoveryAction::CleanInvalid => (), + _ => *action = RnRecoveryAction::ShowLater, + } + } + } + + "apply" => (), + c => unimplemented!("unknown choice {}", c), + }; + for (i, meta) in metadata_found.into_iter().enumerate() { + match &actions[i] { + RnRecoveryAction::Discard => discard(meta), + RnRecoveryAction::ShowLater => (), + RnRecoveryAction::Open => open(appwindow, meta), + RnRecoveryAction::SaveAs(target) => { + save_as(&meta, target); + } + RnRecoveryAction::CleanInvalid => meta.delete_meta(), + } + } +} + +fn find_metadata() -> Vec { + let mut recovery_files = Vec::new(); + for file in recovery_dir() + .expect("Failed to get recovery dir") + .read_dir() + .expect("failed to read recovery dir") + { + let Ok(file) = file else { + log::error!("failed to get DirEntry"); + continue; + }; + // clean up .rnote files without metadata in the recovery dir + // they are usally a result of broken recovery metadata + if file.path().extension() == Some(OsStr::new("rnote")) { + let mut json_path = file.path(); + json_path.set_extension("json"); + if !json_path.exists() { + if let Err(e) = remove_file(&file.path()) { + log::error!("failed to remove {}, {e}", file.path().display()) + } + } + continue; + } else if file.path().extension() != Some(OsStr::new("json")) { + continue; + } + let metadata = + RnRecoveryMetadata::load_from_path(&file.path()).expect("Failed to load recovery file"); + recovery_files.push(metadata); + } + recovery_files +} + +fn format_unix_timestamp(unix: u64) -> String { + // Shows occuring errors in timesptamp label field instead of crashing + match OffsetDateTime::from_unix_timestamp(unix as i64) { + Err(e) => { + log::error!("Failed to get time from unix time: {e}"); + String::from("Error getting time") + } + Ok(ts) => { + let local_offset = time::UtcOffset::current_local_offset().unwrap_or_else(|e| { + log::error!("Failed to get get local time, defaulting to UTC: {e}"); + time::UtcOffset::UTC + }); + ts.to_offset(local_offset) + .format(&Rfc2822) + .unwrap_or_else(|e| { + log::error!("Failed to format time: {e}"); + String::from("Error formatting time") + }) + } + } +} + +pub(crate) fn discard(meta: RnRecoveryMetadata) { + meta.delete() +} + +pub(crate) async fn get_save_as_path(appwindow: &RnAppWindow) -> Option { + let filedialog = FileDialog::builder() + .title("Save recovered file as...") + .accept_label(gettext("Save")) + .modal(true) + .build(); + if let Some(dir) = appwindow.sidebar().workspacebrowser().dirlist_dir() { + filedialog.set_initial_folder(Some(&gio::File::for_path(dir))); + } + let mut initial_name: Option = None; + let mut cancel = false; + let mut out = None; + while out.is_none() && !cancel { + filedialog.set_initial_name(initial_name.as_deref()); + match filedialog.save_future(Some(appwindow)).await { + Ok(f) => { + let Some(mut path) = f.path() else { + log::error!("Failed to parse defined file no path"); + // Cancel + return None; + }; + if path.extension() != Some(OsStr::new("rnote")) { + path.set_extension("rnote"); + } + if path.exists() { + match super::dialog_confirm_overwrite_file(appwindow, &path) + .await + .as_str() + { + "overwrite" => out = Some(path), + "cancel" => cancel = true, + "selectnew" => { + if let Some(name) = path.file_name().and_then(|name| name.to_str()) { + initial_name = Some(name.to_string()) + } + } + r => unimplemented!("{r}"), + } + } else { + out = Some(path) + } + } + Err(e) => { + log::error!("Failed to get save path for revovery file: {e}"); + cancel = true; + } + } + } + out +} + +pub(crate) fn save_as(meta: &RnRecoveryMetadata, target: &Path) { + if let Err(e) = std::fs::rename(meta.recovery_file_path(), target) { + log::error!( + "Failed to move recovered document from {} to {}, because {e}", + meta.recovery_file_path().display(), + target.display() + ) + } else { + meta.delete_meta(); + } +} + +pub(crate) fn open(appwindow: &RnAppWindow, meta: RnRecoveryMetadata) { + let file = gio::File::for_path(meta.recovery_file_path()); + let wrapper = appwindow.new_canvas_wrapper(); + let canvas = wrapper.canvas(); + + glib::MainContext::default().spawn_local(clone!(@weak canvas, @weak appwindow => async move { + appwindow.overlays().progressbar_start_pulsing(); + match file.load_bytes_future().await { + Ok((bytes, _)) => { + if let Err(e) = canvas.load_in_rnote_bytes(bytes.to_vec(), file.path(), Some(meta)).await { + log::error!("load_in_rnote_bytes() failed with Err: {e:?}"); + appwindow.overlays().dispatch_toast_error(&gettext("Opening .rnote file from recovery failed")); + } + } + Err(e) => log::error!("failed to load bytes, Err: {e:?}"), + } + appwindow.overlays().progressbar_finish(); + })); + appwindow.append_wrapper_new_tab(&wrapper); +} diff --git a/crates/rnote-ui/src/env.rs b/crates/rnote-ui/src/env.rs index 1561f809cd..fe0c721447 100644 --- a/crates/rnote-ui/src/env.rs +++ b/crates/rnote-ui/src/env.rs @@ -1,8 +1,22 @@ // Imports use crate::config; +use anyhow::Context; use std::ffi::OsStr; +use std::fs::create_dir_all; use std::path::{Component, Path, PathBuf}; +pub(crate) fn recovery_dir() -> anyhow::Result { + let mut rnote_path = directories::ProjectDirs::from("com.gthub", "flxt", "rnote") + .context("Failed to get ProjectDirs")? + .data_dir() + .to_path_buf(); + rnote_path.push("recovery"); + if !rnote_path.exists() { + create_dir_all(&rnote_path).context("Failed to create recovery dir")?; + } + Ok(rnote_path) +} + pub(crate) fn lib_dir() -> anyhow::Result { if cfg!(target_os = "windows") { let exec_dir = exec_parent_dir()?; diff --git a/crates/rnote-ui/src/main.rs b/crates/rnote-ui/src/main.rs index 8a0c599c4b..eedeeacd33 100644 --- a/crates/rnote-ui/src/main.rs +++ b/crates/rnote-ui/src/main.rs @@ -1,4 +1,4 @@ -#![warn(missing_debug_implementations)] +#![warn(missing_debug_implementations, clippy::todo)] #![allow(clippy::single_match)] // Hides console window on windows #![windows_subsystem = "windows"] @@ -39,6 +39,7 @@ pub(crate) use canvas::RnCanvas; pub(crate) use canvasmenu::RnCanvasMenu; pub(crate) use canvaswrapper::RnCanvasWrapper; pub(crate) use colorpicker::RnColorPicker; +pub(crate) use dialogs::recovery::RnRecoveryAction; pub(crate) use filetype::FileType; pub(crate) use groupediconpicker::RnGroupedIconPicker; pub(crate) use iconpicker::RnIconPicker; diff --git a/crates/rnote-ui/src/mainheader.rs b/crates/rnote-ui/src/mainheader.rs index 4b9cc3fc5d..1be8835936 100644 --- a/crates/rnote-ui/src/mainheader.rs +++ b/crates/rnote-ui/src/mainheader.rs @@ -1,4 +1,3 @@ -// Imports use crate::{appmenu::RnAppMenu, appwindow::RnAppWindow, canvasmenu::RnCanvasMenu}; use gtk4::{ glib, prelude::*, subclass::prelude::*, CompositeTemplate, Label, ToggleButton, Widget, diff --git a/crates/rnote-ui/src/meson.build b/crates/rnote-ui/src/meson.build index 4015e4a209..5949bb845f 100644 --- a/crates/rnote-ui/src/meson.build +++ b/crates/rnote-ui/src/meson.build @@ -82,6 +82,9 @@ rnote_ui_sources = files( 'workspacebrowser/workspacesbar/workspacerow.rs', 'workspacebrowser/mod.rs', 'workspacebrowser/widgethelper.rs', + 'dialogs/mod.rs', + 'dialogs/import.rs', + 'dialogs/export.rs', 'appmenu.rs', 'canvasmenu.rs', 'canvaswrapper.rs', diff --git a/crates/rnote-ui/src/overlays.rs b/crates/rnote-ui/src/overlays.rs index 4e325033c0..0e78f449c5 100644 --- a/crates/rnote-ui/src/overlays.rs +++ b/crates/rnote-ui/src/overlays.rs @@ -255,15 +255,15 @@ impl RnOverlays { imp.tabview.connect_close_page( clone!(@weak self as overlays, @weak appwindow => @default-return true, move |_, page| { glib::MainContext::default().spawn_local(clone!(@weak overlays, @weak appwindow, @weak page => async move { - let close_finish_confirm = if page + let canvas = page .child() .downcast::() .unwrap() - .canvas() - .unsaved_changes() - { + .canvas(); + let close_finish_confirm = if canvas.unsaved_changes() { dialogs::dialog_close_tab(&appwindow, &page).await } else { + canvas.recovery_metadata_delete(); true }; diff --git a/crates/rnote-ui/src/settingspanel/mod.rs b/crates/rnote-ui/src/settingspanel/mod.rs index 7853a48cfa..e6f1449aa7 100644 --- a/crates/rnote-ui/src/settingspanel/mod.rs +++ b/crates/rnote-ui/src/settingspanel/mod.rs @@ -38,6 +38,10 @@ mod imp { #[template_child] pub(crate) general_show_scrollbars_row: TemplateChild, #[template_child] + pub(crate) general_recovery_row: TemplateChild, + #[template_child] + pub(crate) general_recovery_interval_secs_row: TemplateChild, + #[template_child] pub(crate) general_inertial_scrolling_row: TemplateChild, #[template_child] pub(crate) general_regular_cursor_picker: TemplateChild, @@ -468,8 +472,35 @@ impl RnSettingsPanel { .bidirectional() .build(); - let set_overlays_margins = |appwindow: &RnAppWindow, row_active: bool| { - let (m1, m2) = if row_active { (18, 72) } else { (9, 63) }; + // recovery enable switch + imp.general_recovery_row + .get() + .bind_property("active", appwindow, "recovery") + .sync_create() + .bidirectional() + .build(); + + imp.general_recovery_row + .get() + .bind_property( + "active", + &*imp.general_recovery_interval_secs_row, + "sensitive", + ) + .sync_create() + .build(); + + imp.general_autosave_interval_secs_row + .get() + .bind_property("value", appwindow, "recovery-interval-secs") + .transform_to(|_, val: f64| Some((val.round() as u32).to_value())) + .transform_from(|_, val: u32| Some(f64::from(val).to_value())) + .sync_create() + .bidirectional() + .build(); + + let set_overlays_margins = |appwindow: &RnAppWindow, switch_active: bool| { + let (m1, m2) = if switch_active { (18, 72) } else { (9, 63) }; appwindow.overlays().colorpicker().set_margin_top(m1); appwindow.overlays().penpicker().set_margin_bottom(m1); appwindow.overlays().sidebar_box().set_margin_start(m1);