From a5e03ad8da12cea8a6d44451923dfb5f33488ffc Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 10 Aug 2026 15:22:04 +0200 Subject: [PATCH] persistent entity cache, streaming batche --- Cargo.lock | 10 +- Cargo.toml | 6 +- README.md | 2 + crates/pdfs-cli/src/main.rs | 139 ++++++- crates/pdfs-core/Cargo.toml | 1 + crates/pdfs-core/src/auth.rs | 59 ++- crates/pdfs-core/src/batch.rs | 84 +++++ crates/pdfs-core/src/control.rs | 178 +++++++++ crates/pdfs-core/src/db/albums.rs | 8 + crates/pdfs-core/src/db/migrations.rs | 18 +- crates/pdfs-core/src/db/mod.rs | 2 +- crates/pdfs-core/src/db/photos.rs | 69 +++- crates/pdfs-core/src/db/tests.rs | 73 +++- crates/pdfs-core/src/lib.rs | 2 + crates/pdfs-core/src/sdkcache.rs | 283 ++++++++++++++ crates/pdfs-fuse/Cargo.toml | 1 + crates/pdfs-fuse/src/background.rs | 8 + crates/pdfs-fuse/src/control.rs | 88 ++++- crates/pdfs-fuse/src/devices.rs | 6 +- crates/pdfs-fuse/src/drain.rs | 2 + crates/pdfs-fuse/src/filesystem.rs | 1 + crates/pdfs-fuse/src/lib.rs | 73 +++- crates/pdfs-fuse/src/photos.rs | 64 +++- crates/pdfs-fuse/src/revisions.rs | 264 +++++++++++++ crates/pdfs-fuse/src/sweep.rs | 2 + crates/pdfs-fuse/src/sync.rs | 14 +- crates/pdfs-gui/src/app/main.rs | 3 + crates/pdfs-gui/src/app/pages/photo_viewer.rs | 94 +++++ crates/pdfs-gui/src/app/pages/photos.rs | 30 ++ crates/pdfs-gui/src/app/widgets/details.rs | 19 + crates/pdfs-gui/src/app/widgets/mod.rs | 1 + .../src/app/widgets/versions_dialog.rs | 353 ++++++++++++++++++ docs/DEVELOPMENT.md | 44 ++- 33 files changed, 1936 insertions(+), 65 deletions(-) create mode 100644 crates/pdfs-core/src/batch.rs create mode 100644 crates/pdfs-core/src/sdkcache.rs create mode 100644 crates/pdfs-fuse/src/revisions.rs create mode 100644 crates/pdfs-gui/src/app/widgets/versions_dialog.rs diff --git a/Cargo.lock b/Cargo.lock index f3371c6..84ddd87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3535,6 +3535,7 @@ dependencies = [ name = "pdfs-core" version = "1.3.0" dependencies = [ + "async-trait", "directories", "ignore", "keyring", @@ -3556,6 +3557,7 @@ name = "pdfs-fuse" version = "1.3.0" dependencies = [ "fuser", + "futures", "image", "libc", "notify", @@ -3830,9 +3832,9 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.3.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06f6b2b3aaaf90cd3eab823e3c13a4ff784693f183f7fde97f865378c4d3e0bd" +checksum = "3abae5e90e6889b120208159eb2c60b332612e5b708fdf3c14287307ce01715d" dependencies = [ "async-trait", "base64", @@ -3857,9 +3859,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.3.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1037a17f8951cf3ade40a8d2a8230720caf6136383eecc7d61e89636e229793e" +checksum = "3fa729310c1a66e12bb41b9f5f036c86b9857e76536556052203ffecdb0b44c3" dependencies = [ "aes-gcm 0.11.0", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index b98a4ca..1bbbf16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ authors = ["Nils Pukropp "] rust-version = "1.96" [workspace.dependencies] -proton-sdk = { version = "0.3.2" } -proton-drive-rs = { version = "0.3.2" } +proton-sdk = { version = "0.5.1" } +proton-drive-rs = { version = "0.5.1" } # Internal crates. pdfs-core = { path = "crates/pdfs-core" } @@ -40,5 +40,7 @@ rusqlite = { version = "0.32", features = ["bundled"] } keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } clap = { version = "4", features = ["derive"] } rpassword = "7" +async-trait = "0.1" +futures = "0.3" fuser = "0.17" libc = "0.2" diff --git a/README.md b/README.md index eebc825..d9c4732 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc - **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. - **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages. - **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI). +- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one. +- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite ` / `pdfs photos --favorites`. - **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album `. Album contents open in the same gallery as the timeline. - **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token. - **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules. diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 70a1cb3..d29c27a 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -144,6 +144,17 @@ enum Command { /// Skip this many photos from the start of the timeline. #[arg(long, default_value_t = 0)] offset: usize, + /// List only favourited photos. + #[arg(long)] + favorites: bool, + }, + /// Add or remove a photo's favourite mark. + Favorite { + /// Photo node uid in `volume~link` form (from `pdfs photos`). + uid: String, + /// Remove the mark instead of adding it. + #[arg(long)] + remove: bool, }, /// List the photo albums (including ones shared with this account). Albums, @@ -286,6 +297,11 @@ enum Command { /// Entry kind: member, proton or external. kind: ShareKindArg, }, + /// Inspect and restore a file's earlier versions. + Versions { + #[command(subcommand)] + action: VersionCmd, + }, /// Manage a node's public link. PublicLink { #[command(subcommand)] @@ -380,6 +396,43 @@ impl ShareKindArg { } } +/// `pdfs versions …` — a file's revision history. +#[derive(Subcommand)] +enum VersionCmd { + /// List a file's versions, newest first. + List { + /// File path, inside the mountpoint or relative to it. + path: PathBuf, + }, + /// Make an earlier version the file's current content. + /// + /// The server applies this in the background, so the new content may take a + /// moment to show up in the mount. + Restore { + /// File path, inside the mountpoint or relative to it. + path: PathBuf, + /// Version id, from `pdfs versions list`. + id: String, + }, + /// Write an earlier version to a local file, leaving the file untouched. + Save { + /// File path, inside the mountpoint or relative to it. + path: PathBuf, + /// Version id, from `pdfs versions list`. + id: String, + /// Where to write it. Must not already exist. + dest: PathBuf, + }, + /// Permanently delete an earlier version. The content is unrecoverable, and + /// the version that is currently active cannot be deleted. + Rm { + /// File path, inside the mountpoint or relative to it. + path: PathBuf, + /// Version id, from `pdfs versions list`. + id: String, + }, +} + #[derive(Subcommand)] enum DeviceCmd { /// List registered devices. @@ -529,7 +582,12 @@ fn main() -> Result<()> { Command::Unpin { path } => cmd_unpin(path), Command::Pins => cmd_pins(), Command::Ls { path } => cmd_ls(path), - Command::Photos { limit, offset } => cmd_photos(limit, offset), + Command::Photos { + limit, + offset, + favorites, + } => cmd_photos(limit, offset, favorites), + Command::Favorite { uid, remove } => cmd_favorite(uid, remove), Command::Albums => cmd_albums(), Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset), Command::OpenPhoto { uid } => cmd_open_photo(uid), @@ -561,6 +619,7 @@ fn main() -> Result<()> { role, } => cmd_share_role(path, id, kind, role), Command::Unshare { path, id, kind } => cmd_unshare(path, id, kind), + Command::Versions { action } => cmd_versions(action), Command::PublicLink { action } => cmd_public_link(action), Command::Shared => cmd_shared(), Command::SharedWithMe { uid } => cmd_shared_with_me(uid), @@ -788,6 +847,71 @@ fn cmd_share( })?) } +fn cmd_versions(action: VersionCmd) -> Result<()> { + match action { + VersionCmd::List { path } => { + let response = control_request(CtlRequest::ListRevisions { + path: path_arg(&path)?, + })?; + if emit_json(&response)? { + return Ok(()); + } + match response { + CtlResponse::Revisions { items } if items.is_empty() => { + println!("No versions."); + } + CtlResponse::Revisions { items } => { + for r in items { + // The claimed plaintext size is what the user recognises; + // the storage size is ciphertext and always larger. + let size = r + .claimed_size + .filter(|size| *size >= 0) + .unwrap_or(r.size_on_storage) + .max(0) as u64; + let mark = if r.is_active { "*" } else { " " }; + let by = r + .signed_by + .map(|email| format!(" {email}")) + .unwrap_or_default(); + println!( + "{mark} {:<20} {:>10} {}{by}", + r.id, + human_bytes(size), + format_epoch(r.created), + ); + } + println!("(* = current)"); + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) + } + VersionCmd::Restore { path, id } => { + ok_or_bail(control_request(CtlRequest::RestoreRevision { + path: path_arg(&path)?, + revision_id: id, + })?) + } + VersionCmd::Save { path, id, dest } => { + // The daemon writes the file, so it needs a path that means the same + // thing in its process as in this one. + let dest = std::path::absolute(&dest) + .with_context(|| format!("resolve {}", dest.display()))?; + ok_or_bail(control_request(CtlRequest::SaveRevisionAs { + path: path_arg(&path)?, + revision_id: id, + dest: dest.to_string_lossy().into_owned(), + })?) + } + VersionCmd::Rm { path, id } => ok_or_bail(control_request(CtlRequest::DeleteRevision { + path: path_arg(&path)?, + revision_id: id, + })?), + } +} + fn cmd_members(path: PathBuf) -> Result<()> { match control_request(CtlRequest::ListShare { path: path_arg(&path)?, @@ -1487,12 +1611,13 @@ fn cmd_ls(path: Option) -> Result<()> { Ok(()) } -fn cmd_photos(limit: usize, offset: usize) -> Result<()> { +fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> { match control_request(CtlRequest::PhotosTimeline { offset, limit, kind: None, range: None, + favorites, })? { CtlResponse::Photos { available: false, .. @@ -1511,6 +1636,13 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> { /// /// A timeline or album reply only carries thumbnails already in the cache, so /// the rest of the page's are pulled in one batch rather than left blank. +fn cmd_favorite(uid: String, remove: bool) -> Result<()> { + ok_or_bail(control_request(CtlRequest::SetPhotoFavorite { + uid, + favorite: !remove, + })?) +} + fn print_photo_page(items: Vec) -> Result<()> { let uids: Vec = items.iter().map(|p| p.uid.clone()).collect(); let thumbs: HashMap> = @@ -1524,7 +1656,8 @@ fn print_photo_page(items: Vec) -> Result<()> { .and_then(|p| p.as_deref()) .or(p.thumb_path.as_deref()) .unwrap_or("(no thumbnail)"); - println!("{} {} {thumb}", p.capture_time, p.uid); + let fav = if p.favorite { "*" } else { " " }; + println!("{fav} {} {} {thumb}", p.capture_time, p.uid); } Ok(()) } diff --git a/crates/pdfs-core/Cargo.toml b/crates/pdfs-core/Cargo.toml index fa88d05..71cbef4 100644 --- a/crates/pdfs-core/Cargo.toml +++ b/crates/pdfs-core/Cargo.toml @@ -21,4 +21,5 @@ sha2.workspace = true rusqlite.workspace = true keyring.workspace = true parking_lot.workspace = true +async-trait.workspace = true libc.workspace = true diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index 7edd1f8..c0833d5 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -9,8 +9,10 @@ use keyring::Entry; use proton_drive_rs::{KeySalt, ProtonDriveClient}; use proton_sdk::api::{HumanVerification, HumanVerificationCredential}; +use proton_sdk::cache::EncryptedCacheRepository; use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; +use proton_sdk::telemetry::TracingTelemetry; use serde::{Deserialize, Serialize}; use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT}; @@ -220,6 +222,21 @@ pub fn load() -> Result { /// Forget the persisted session (best-effort; absent entry is not an error). pub fn logout() -> Result<()> { + // The persisted entity cache describes the account being logged out of. It + // is encrypted under that account's mailbox password, but leaving it behind + // would also mean the next account inherits a store it can only ever read as + // misses. Best-effort: failing to remove a cache must not block the logout. + if let Ok(dirs) = AppDirs::new() { + let path = dirs.state_dir().join("sdk_cache.db"); + if let Some(cache) = crate::sdkcache::SdkCache::opened() + && let Err(e) = cache.clear_now() + { + tracing::warn!(error = %e, "clearing the SDK entity cache on logout failed"); + } + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); + } + } match keyring_entry()?.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(e.into()), @@ -259,10 +276,50 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { register_refresh_handler(&session, password.clone(), key_salts.clone()); - let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts); + let client = tune( + ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts), + password.as_bytes(), + ); Ok((client, session)) } +/// The client settings every front-end wants, applied wherever a +/// [`ProtonDriveClient`] is built for real work. +/// +/// - **Telemetry** into `tracing`, so the SDK's own spans (transfers, block +/// storage, per-request timings) land in the daemon's log next to the +/// daemon's, instead of being dropped by the default no-op sink. +/// - **Small-file uploads**: a file that fits in one block is uploaded as a +/// single atomic request instead of the multi-step draft/block/commit dance. +/// The SDK makes this opt-in because it has no remote feature-flag provider; +/// the drain queue writes plenty of small files, and the shorter path is one +/// failure point per write instead of three. +/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not +/// re-fetch and re-decrypt the tree the previous run already walked. It is +/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox +/// password, so the decrypted node names it holds are not readable from the +/// file alone — and a password change simply reads as a cold cache, since the +/// SDK treats an undecryptable entry as a miss and clears the store. +/// +/// Opening it is best-effort: a store this process cannot open costs cache +/// hits, not the session, so the client falls back to the SDK's in-memory +/// default. +fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient { + let client = client + .with_telemetry(TracingTelemetry::shared()) + .with_small_file_upload(true); + match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) { + Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared( + cache, + mailbox_password.to_vec(), + )), + Err(e) => { + tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory"); + client + } + } +} + /// Write the session's current tokens back to the keyring. /// /// The session does not carry the mailbox password (it is only needed to rebuild diff --git a/crates/pdfs-core/src/batch.rs b/crates/pdfs-core/src/batch.rs new file mode 100644 index 0000000..615f77b --- /dev/null +++ b/crates/pdfs-core/src/batch.rs @@ -0,0 +1,84 @@ +//! Per-node outcomes of the SDK's batch node operations. +//! +//! `trash_nodes`, `restore_nodes`, `delete_nodes` and `move_nodes` report one +//! outcome per node instead of failing the whole call: a node the server +//! rejects leaves the rest of the batch applied. The daemon's callers fall into +//! two shapes, and both are here so neither has to re-derive the fold: +//! +//! - one node in, one answer out — [`into_unit`], which collapses the single +//! outcome back to a `Result`; +//! - a user-supplied list — [`split`], which keeps the successes and hands back +//! the failures to report. + +use proton_sdk::error::ProtonError; +use proton_sdk::ids::NodeUid; + +/// One node's outcome, as the SDK batch operations report it. +pub type Outcome = (NodeUid, Result<(), ProtonError>); + +/// Collapse a batch result to the first per-node failure, for the callers that +/// pass exactly one node and want the old all-or-nothing answer. +/// +/// An empty outcome list is `Ok`: the server accepted the request and reported +/// on nothing, which is not an error the caller can act on. +pub fn into_unit(outcomes: Vec) -> Result<(), ProtonError> { + for (_, outcome) in outcomes { + outcome?; + } + Ok(()) +} + +/// Split a batch result into the nodes that succeeded and the ones that failed. +pub fn split(outcomes: Vec) -> (Vec, Vec<(NodeUid, ProtonError)>) { + let mut done = Vec::with_capacity(outcomes.len()); + let mut failed = Vec::new(); + for (uid, outcome) in outcomes { + match outcome { + Ok(()) => done.push(uid), + Err(e) => failed.push((uid, e)), + } + } + (done, failed) +} + +#[cfg(test)] +mod tests { + use super::*; + use proton_sdk::ids::{LinkId, VolumeId}; + + fn uid(link: &str) -> NodeUid { + NodeUid::new(VolumeId::new("vol"), LinkId::new(link)) + } + + fn failure() -> ProtonError { + ProtonError::invalid_operation("nope") + } + + #[test] + fn into_unit_is_ok_when_every_node_succeeded() { + assert!(into_unit(vec![(uid("a"), Ok(())), (uid("b"), Ok(()))]).is_ok()); + assert!( + into_unit(Vec::new()).is_ok(), + "nothing reported is not a failure" + ); + } + + #[test] + fn into_unit_surfaces_a_failed_node() { + let outcomes = vec![(uid("a"), Ok(())), (uid("b"), Err(failure()))]; + assert!(into_unit(outcomes).is_err()); + } + + #[test] + fn split_keeps_successes_and_failures_apart() { + let outcomes = vec![ + (uid("a"), Ok(())), + (uid("b"), Err(failure())), + (uid("c"), Ok(())), + ]; + let (done, failed) = split(outcomes); + assert_eq!(done, vec![uid("a"), uid("c")]); + assert_eq!(failed.len(), 1); + assert_eq!(failed[0].0, uid("b")); + } +} diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 522f030..ac7358c 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -63,6 +63,10 @@ pub enum Request { /// timeline. Like `kind`, the offset is relative to the filtered set. #[serde(default)] range: Option<(i64, i64)>, + /// Restrict the page to favourited photos. Older front-ends omit it and + /// get the whole timeline, as before. + #[serde(default)] + favorites: bool, }, /// The months the timeline spans (newest first, with per-month counts) so a /// front-end can build a date scrubber without paging the whole library. @@ -91,6 +95,14 @@ pub enum Request { PhotoThumbs { uids: Vec }, /// Download a photo's full content into the cache; replies with its path. OpenPhoto { uid: String }, + /// Add or remove Proton's `Favorite` tag on a photo. Replies with + /// [`Response::Ok`]. + /// + /// Favouriting a photo that is *not* on this account's own photos volume — + /// one shared with us, or one that lives only in an album — needs it + /// re-encrypted for our timeline, which the SDK does not implement yet; the + /// daemon reports that as an error rather than silently doing nothing. + SetPhotoFavorite { uid: String, favorite: bool }, /// Upload the photo at `source_path` under the given name and media type. /// /// A path, not the bytes: this protocol is line-delimited JSON, and @@ -325,6 +337,39 @@ pub enum Request { /// The by-uid twin of [`Request::RemovePublicLink`]. RemovePublicLinkByUid { uid: String, id: String }, + // ---- revisions -------------------------------------------------------- + /// List the version history of the file at mountpoint-relative `path`, + /// newest first. Replies with [`Response::Revisions`]. + ListRevisions { path: String }, + /// The by-uid twin of [`Request::ListRevisions`]. + ListRevisionsByUid { uid: String }, + /// Make revision `revision_id` of the file at `path` current again. The + /// server applies this asynchronously, so the restored content may take a + /// moment to appear. Replies with [`Response::Ok`]. + RestoreRevision { path: String, revision_id: String }, + /// The by-uid twin of [`Request::RestoreRevision`]. + RestoreRevisionByUid { uid: String, revision_id: String }, + /// Permanently delete revision `revision_id` of the file at `path`. The + /// content is unrecoverable, and the server refuses to delete the revision + /// that is currently active. Replies with [`Response::Ok`]. + DeleteRevision { path: String, revision_id: String }, + /// The by-uid twin of [`Request::DeleteRevision`]. + DeleteRevisionByUid { uid: String, revision_id: String }, + /// Write revision `revision_id` of the file at `path` to the absolute local + /// path `dest`, leaving the file's current content untouched. Replies with + /// [`Response::Ok`]. + SaveRevisionAs { + path: String, + revision_id: String, + dest: String, + }, + /// The by-uid twin of [`Request::SaveRevisionAs`]. + SaveRevisionAsByUid { + uid: String, + revision_id: String, + dest: String, + }, + // ---- shared by me ----------------------------------------------------- /// List the nodes I have shared with others — collaborative shares that still /// have members, pending invitations or a public link. Replies with @@ -608,6 +653,36 @@ pub struct SharedItem { pub link: Option, } +/// One entry of a file's version history (in [`Response::Revisions`]). +/// +/// The size and modification time are the ones the *uploader* claimed in the +/// revision's extended attributes, so an old client that wrote none leaves them +/// `None`; `size_on_storage` is always known but counts ciphertext, which is +/// larger than the file. A front-end shows the claimed size when it has one and +/// falls back to the storage size. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct RevisionInfo { + /// Server-assigned id — the handle for [`Request::RestoreRevision`], + /// [`Request::DeleteRevision`] and [`Request::SaveRevisionAs`]. + pub id: String, + /// True for the revision that is the file's current content. It cannot be + /// deleted, and restoring it is a no-op. + pub is_active: bool, + /// When the revision was created, epoch seconds. + pub created: i64, + /// Encrypted size on Proton's storage, in bytes. + pub size_on_storage: i64, + /// Plaintext size as claimed by the uploader, when it wrote one. + pub claimed_size: Option, + /// Modification time as claimed by the uploader (ISO-8601, verbatim). + pub claimed_modified: Option, + /// The address that signed the revision, when it was not signed anonymously + /// by the node key. + pub signed_by: Option, + /// Whether the revision carries thumbnails. + pub has_thumbnails: bool, +} + /// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to /// an icon and a verb, and the human detail lives in the entry's fields. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] @@ -1014,6 +1089,11 @@ pub struct PhotoItem { /// the split omit it; a front-end then treats everything as a still photo. #[serde(default = "default_photo_kind")] pub kind: PhotoKind, + /// Whether the photo is favourited. Older daemons omit it, which a front-end + /// reads as "not favourited" — the same thing it would show for a photo whose + /// tags it has not learned yet. + #[serde(default)] + pub favorite: bool, } /// A daemon too old to classify a timeline entry is assumed to have served a @@ -1209,6 +1289,9 @@ pub enum Response { AccountQuota { max_space: i64, used_space: i64 }, /// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]). SharedByMe { items: Vec }, + /// A file's version history, newest first (reply to + /// [`Request::ListRevisions`]). + Revisions { items: Vec }, /// The daemon's recent activity, newest first (reply to /// [`Request::ListActivity`]). Activity { items: Vec }, @@ -1858,6 +1941,101 @@ mod tests { ); } + /// The revision requests come in path and by-uid pairs like the share ones, + /// and carry a revision id that must survive the round-trip verbatim: acting + /// on the wrong revision deletes content the user meant to keep. + #[test] + fn revision_requests_roundtrip_in_both_addressing_forms() { + let cases = [ + ( + Request::ListRevisions { path: "a/b".into() }, + r#"{"ListRevisions":{"path":"a/b"}}"#, + ), + ( + Request::ListRevisionsByUid { + uid: "vol~link".into(), + }, + r#"{"ListRevisionsByUid":{"uid":"vol~link"}}"#, + ), + ( + Request::RestoreRevision { + path: "a/b".into(), + revision_id: "rev-1".into(), + }, + r#"{"RestoreRevision":{"path":"a/b","revision_id":"rev-1"}}"#, + ), + ( + Request::RestoreRevisionByUid { + uid: "vol~link".into(), + revision_id: "rev-1".into(), + }, + r#"{"RestoreRevisionByUid":{"uid":"vol~link","revision_id":"rev-1"}}"#, + ), + ( + Request::DeleteRevision { + path: "a/b".into(), + revision_id: "rev-2".into(), + }, + r#"{"DeleteRevision":{"path":"a/b","revision_id":"rev-2"}}"#, + ), + ( + Request::DeleteRevisionByUid { + uid: "vol~link".into(), + revision_id: "rev-2".into(), + }, + r#"{"DeleteRevisionByUid":{"uid":"vol~link","revision_id":"rev-2"}}"#, + ), + ( + Request::SaveRevisionAs { + path: "a/b".into(), + revision_id: "rev-3".into(), + dest: "/tmp/out.bin".into(), + }, + r#"{"SaveRevisionAs":{"path":"a/b","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#, + ), + ( + Request::SaveRevisionAsByUid { + uid: "vol~link".into(), + revision_id: "rev-3".into(), + dest: "/tmp/out.bin".into(), + }, + r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#, + ), + ]; + for (request, wire) in cases { + assert_eq!(serde_json::to_string(&request).unwrap(), wire); + let decoded: Request = serde_json::from_str(wire).unwrap(); + assert_eq!(serde_json::to_string(&decoded).unwrap(), wire); + } + } + + /// A front-end that predates the favourites filter sends no `favorites` + /// field, and must keep getting the whole timeline rather than an empty page. + #[test] + fn timeline_requests_without_the_favorites_filter_still_decode() { + let decoded: Request = + serde_json::from_str(r#"{"PhotosTimeline":{"offset":0,"limit":50}}"#).unwrap(); + assert!(matches!( + decoded, + Request::PhotosTimeline { + favorites: false, + kind: None, + range: None, + .. + } + )); + + let wire = serde_json::to_string(&Request::SetPhotoFavorite { + uid: "vol~link".into(), + favorite: true, + }) + .unwrap(); + assert_eq!( + wire, + r#"{"SetPhotoFavorite":{"uid":"vol~link","favorite":true}}"# + ); + } + #[test] fn path_share_requests_keep_their_existing_wire_shape() { let cases = [ diff --git a/crates/pdfs-core/src/db/albums.rs b/crates/pdfs-core/src/db/albums.rs index d34ea48..21bd232 100644 --- a/crates/pdfs-core/src/db/albums.rs +++ b/crates/pdfs-core/src/db/albums.rs @@ -178,6 +178,10 @@ impl Db { ratio: r.get(3)?, thumb_state: r.get(4)?, kind: crate::control::PhotoKind::from_i64(r.get(5)?), + // Album rows have no favourite state of their own: an album + // photo on someone else's volume is not in our timeline, and + // favouriting one is not supported yet anyway. + favorite: false, }) })? .collect::>()?; @@ -218,6 +222,10 @@ impl Db { ratio: r.get(3)?, thumb_state: r.get(4)?, kind: crate::control::PhotoKind::from_i64(r.get(5)?), + // Album rows have no favourite state of their own: an album + // photo on someone else's volume is not in our timeline, and + // favouriting one is not supported yet anyway. + favorite: false, }) })? .collect::>()?; diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index b9c8801..44b9ce3 100644 --- a/crates/pdfs-core/src/db/migrations.rs +++ b/crates/pdfs-core/src/db/migrations.rs @@ -9,7 +9,7 @@ use super::Db; use crate::Result; /// Current schema version. Bump on every forward migration added below. -pub(super) const SCHEMA_VERSION: i64 = 19; +pub(super) const SCHEMA_VERSION: i64 = 20; impl Db { pub(super) fn migrate(&self) -> Result<()> { @@ -98,6 +98,9 @@ impl Db { if current < 19 { tx.execute_batch(MIGRATION_V19)?; } + if current < 20 { + tx.execute_batch(MIGRATION_V20)?; + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -511,3 +514,16 @@ CREATE TABLE album_photos ( CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq); CREATE INDEX idx_album_photos_uid ON album_photos(uid); "; + +/// V20: whether a photo is a favourite. +/// +/// A column rather than a tag table: `Favorite` is the only Proton photo tag a +/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the +/// server's own classification, and the Photos page already derives its tabs +/// from the media type). Defaulting to 0 means an existing timeline stays +/// intact and each row learns its real value on the next refresh, the same way +/// `media_type` did. +const MIGRATION_V20: &str = " +ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq); +"; diff --git a/crates/pdfs-core/src/db/mod.rs b/crates/pdfs-core/src/db/mod.rs index f74b78f..84a91d0 100644 --- a/crates/pdfs-core/src/db/mod.rs +++ b/crates/pdfs-core/src/db/mod.rs @@ -49,7 +49,7 @@ pub use ops::{ AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes, }; -pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN}; +pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow}; pub use sync::{StoredSyncEntry, StoredSyncFolder}; pub use trash::StoredTrash; diff --git a/crates/pdfs-core/src/db/photos.rs b/crates/pdfs-core/src/db/photos.rs index 39da889..77ddc31 100644 --- a/crates/pdfs-core/src/db/photos.rs +++ b/crates/pdfs-core/src/db/photos.rs @@ -32,13 +32,21 @@ pub struct StoredPhoto { /// Which Photos-page tab this entry belongs to, derived from its name and /// media type when the timeline was last replaced. pub kind: crate::control::PhotoKind, + /// Whether the photo carries Proton's `Favorite` tag. + pub favorite: bool, } +/// One row of a timeline replacement: `(uid, capture_time, name, media_type, +/// favorite)`. The two trailing options are "what this refresh learned" — a +/// `None` keeps whatever is already stored rather than clearing it. +pub type TimelineRow = (String, i64, Option, Option, Option); + impl Db { - pub fn photos_replace( - &self, - items: &[(String, i64, Option, Option)], - ) -> Result<()> { + /// Replace the timeline wholesale. Each item is + /// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None` + /// when the refresh could not resolve that photo's node, in which case the + /// flag already stored is kept rather than silently cleared. + pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> { let mut conn = self.conn.lock(); let tx = conn.transaction()?; // `media_type` is learned-and-kept like the ratio and thumb verdict: the @@ -46,10 +54,14 @@ impl Db { // not know a photo's media type yet when it replaces the timeline. Keep // any previously learned value so the Photos/Videos/Raw split survives a // refresh instead of collapsing back to name-extension guesses. - let learned: HashMap, i64, Option)> = { - let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?; + let learned: HashMap, i64, Option, bool)> = { + let mut stmt = + tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?; let rows = stmt.query_map([], |r| { - Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?))) + Ok(( + r.get::<_, String>(0)?, + (r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0), + )) })?; rows.collect::>()? }; @@ -58,16 +70,16 @@ impl Db { { let mut stmt = tx.prepare( "INSERT INTO photos - (uid, capture_time, name, ratio, thumb_state, seq, media_type, kind) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + (uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", )?; - for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() { - let (ratio, thumb_state, learned_media) = - learned - .get(uid) - .cloned() - .unwrap_or((None, THUMB_UNKNOWN, None)); + for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() { + let (ratio, thumb_state, learned_media, learned_favorite) = learned + .get(uid) + .cloned() + .unwrap_or((None, THUMB_UNKNOWN, None, false)); let media_type = media_type.clone().or(learned_media); + let favorite = favorite.unwrap_or(learned_favorite); // The tab this photo lands in is derived here, once, so a page or // count query is a plain indexed `WHERE kind = ?` rather than a // reclassification of every row. @@ -82,6 +94,7 @@ impl Db { seq as i64, media_type, kind.as_i64(), + favorite as i64, ])?; } } @@ -93,18 +106,21 @@ impl Db { /// restricts the page to one tab (Photos / Videos / Raw); `range`, when set, /// restricts it to a `[from, to)` capture-time window (epoch seconds) — the /// date scrubber's jump. `offset` is relative to whatever the filters leave. + /// `favorites` restricts the page to favourited photos. pub fn photos_page( &self, offset: usize, limit: usize, kind: Option, range: Option<(i64, i64)>, + favorites: bool, ) -> Result> { let conn = self.conn.lock(); // Built up so any combination of the optional filters is one indexed // query rather than a statement per case. - let mut sql = - String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos"); + let mut sql = String::from( + "SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos", + ); let mut binds: Vec = Vec::new(); let mut conds: Vec = Vec::new(); if let Some(k) = kind { @@ -117,6 +133,9 @@ impl Db { binds.push(to); conds.push(format!("capture_time < ?{}", binds.len())); } + if favorites { + conds.push("favorite = 1".to_string()); + } if !conds.is_empty() { sql.push_str(" WHERE "); sql.push_str(&conds.join(" AND ")); @@ -139,6 +158,7 @@ impl Db { ratio: r.get(3)?, thumb_state: r.get(4)?, kind: crate::control::PhotoKind::from_i64(r.get(5)?), + favorite: r.get::<_, i64>(6)? != 0, }) })? .collect::>()?; @@ -206,7 +226,7 @@ impl Db { let conn = self.conn.lock(); let placeholders = vec!["?"; uids.len()].join(","); let mut stmt = conn.prepare(&format!( - "SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos + "SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos WHERE uid IN ({placeholders})" ))?; let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| { @@ -217,6 +237,7 @@ impl Db { ratio: r.get(3)?, thumb_state: r.get(4)?, kind: crate::control::PhotoKind::from_i64(r.get(5)?), + favorite: r.get::<_, i64>(6)? != 0, }) })?; let mut photos = Vec::new(); @@ -226,6 +247,18 @@ impl Db { Ok(photos) } + /// Record a photo's favourite flag locally, after the server accepted the + /// change. A uid the timeline does not hold is a no-op — an album photo on + /// someone else's volume is never in our own `photos` table. + pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE photos SET favorite = ?2 WHERE uid = ?1", + params![uid, favorite as i64], + )?; + Ok(()) + } + /// Record what a thumbnail attempt learned: whether the photo now has one /// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were /// seen. A `None` ratio leaves any previously learned one alone. diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 50df23d..cf19498 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -113,9 +113,9 @@ fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json fn photos_replace_keeps_what_was_learned_and_drops_what_left() { let db = Db::open_in_memory().unwrap(); db.photos_replace(&[ - ("p1".into(), 300, None, Some("video/mp4".into())), - ("p2".into(), 200, None, None), - ("p3".into(), 100, None, None), + ("p1".into(), 300, None, Some("video/mp4".into()), Some(true)), + ("p2".into(), 200, None, None, None), + ("p3".into(), 100, None, None, None), ]) .unwrap(); @@ -126,13 +126,13 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() { // The next refresh brings a new photo, keeps p1 and p2, and loses p3. // p1's media type arrives as `None` this time and must not be forgotten. db.photos_replace(&[ - ("p0".into(), 400, Some("new.jpg".into()), None), - ("p1".into(), 300, None, None), - ("p2".into(), 200, None, None), + ("p0".into(), 400, Some("new.jpg".into()), None, Some(false)), + ("p1".into(), 300, None, None, None), + ("p2".into(), 200, None, None, None), ]) .unwrap(); - let page = db.photos_page(0, 10, None, None).unwrap(); + let page = db.photos_page(0, 10, None, None, false).unwrap(); assert_eq!( page.iter().map(|p| p.uid.as_str()).collect::>(), ["p0", "p1", "p2"], @@ -155,7 +155,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() { // The counts break down by tab, and a filtered page returns only its tab. assert_eq!(db.photos_counts().unwrap(), (2, 1, 0)); let videos = db - .photos_page(0, 10, Some(crate::control::PhotoKind::Video), None) + .photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false) .unwrap(); assert_eq!( videos.iter().map(|p| p.uid.as_str()).collect::>(), @@ -167,7 +167,9 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() { // A date-range page keeps only the window's photos: [150, 350) is p1+p2, // not p0 at 400. Combined with a kind filter both conditions apply. - let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap(); + let ranged = db + .photos_page(0, 10, None, Some((150, 350)), false) + .unwrap(); assert_eq!( ranged.iter().map(|p| p.uid.as_str()).collect::>(), ["p1", "p2"] @@ -178,6 +180,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() { 10, Some(crate::control::PhotoKind::Video), Some((150, 350)), + false, ) .unwrap(); assert_eq!( @@ -193,20 +196,52 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() { assert_eq!(months[0].2, 3); } +#[test] +fn favorites_are_remembered_across_refreshes_and_filter_a_page() { + let db = Db::open_in_memory().unwrap(); + db.photos_replace(&[ + ("p1".into(), 300, None, None, Some(true)), + ("p2".into(), 200, None, None, Some(false)), + ]) + .unwrap(); + let favorites = db.photos_page(0, 10, None, None, true).unwrap(); + assert_eq!( + favorites.iter().map(|p| p.uid.as_str()).collect::>(), + ["p1"], + "only the favourited photo is in a favourites page" + ); + + // A local toggle survives a refresh that could not resolve the photo's node + // (`None`), and loses to one that could. + db.photos_set_favorite("p2", true).unwrap(); + db.photos_replace(&[ + ("p1".into(), 300, None, None, Some(false)), + ("p2".into(), 200, None, None, None), + ]) + .unwrap(); + let favorites = db.photos_page(0, 10, None, None, true).unwrap(); + assert_eq!( + favorites.iter().map(|p| p.uid.as_str()).collect::>(), + ["p2"], + "the server's answer wins where it has one; the local flag holds where it doesn't" + ); + assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite); +} + #[test] fn photos_page_slices_the_timeline_in_order() { let db = Db::open_in_memory().unwrap(); let items: Vec<_> = (0..5) - .map(|i| (format!("p{i}"), 500 - i as i64, None, None::)) + .map(|i| (format!("p{i}"), 500 - i as i64, None, None::, None)) .collect(); db.photos_replace(&items).unwrap(); - let page = db.photos_page(2, 2, None, None).unwrap(); + let page = db.photos_page(2, 2, None, None, false).unwrap(); assert_eq!( page.iter().map(|p| p.uid.as_str()).collect::>(), ["p2", "p3"] ); - assert!(db.photos_page(9, 2, None, None).unwrap().is_empty()); + assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty()); } #[test] @@ -1336,6 +1371,8 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() { DROP TABLE share_access; DROP TABLE album_photos; DROP TABLE albums; + DROP INDEX idx_photos_favorite; + ALTER TABLE photos DROP COLUMN favorite; UPDATE sync_state SET value = '16' WHERE key = 'schema_version';", ) .unwrap(); @@ -1402,6 +1439,18 @@ fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() { VALUES (41, '/home/me/Existing', 'vol~existing', 'share-existing', 'ondemand', 'syncing', 123); + -- The timeline as V17 held it: later migrations add columns to it, + -- so a fixture without it isn't a database any release produced. + CREATE TABLE photos ( + uid TEXT PRIMARY KEY, + capture_time INTEGER NOT NULL, + name TEXT, + ratio REAL, + thumb_state INTEGER NOT NULL DEFAULT 0, + seq INTEGER NOT NULL, + media_type TEXT, + kind INTEGER NOT NULL DEFAULT 0 + ); CREATE TABLE share_access ( root_uid TEXT PRIMARY KEY, access TEXT NOT NULL diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 5aaf5bc..d8df99c 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod access; pub mod auth; +pub mod batch; pub mod cache; pub mod config; pub mod control; @@ -12,6 +13,7 @@ pub mod error; pub mod localindex; pub mod mounts; pub mod profile; +pub mod sdkcache; pub mod search; pub mod service; pub mod shell; diff --git a/crates/pdfs-core/src/sdkcache.rs b/crates/pdfs-core/src/sdkcache.rs new file mode 100644 index 0000000..02171be --- /dev/null +++ b/crates/pdfs-core/src/sdkcache.rs @@ -0,0 +1,283 @@ +//! A persistent [`CacheRepository`] for the SDK's Drive entity cache. +//! +//! The SDK caches decrypted node metadata (name, size, parent, the share whose +//! membership signs operations on it) so navigating a tree does not re-fetch and +//! re-decrypt what it already resolved. That cache defaults to memory, which +//! means every daemon restart pays for the whole walk again. This backs it with +//! SQLite so it survives one. +//! +//! **Its own database file, not `cache.db`.** The daemon's `Db` is a single +//! `Mutex` shared by every FUSE thread and the control socket; SDK +//! cache traffic is frequent, small, and entirely reconstructible, so putting it +//! on that mutex would add contention to the daemon's hottest lock (and to a +//! write-ahead log whose growth is already sensitive to how long that lock is +//! held) for no durability benefit. A separate file also means no schema +//! migration: this store can be deleted and rebuilt at any time. +//! +//! **What it holds.** Decrypted metadata, never key material — the SDK keeps +//! node keys and hash keys in a separate in-memory secret cache that nothing +//! here can reach. The same names and sizes are already persisted in `cache.db` +//! (`nodes`), so this file widens no exposure that the state directory did not +//! already have; `AppDirs::ensure` is what keeps that directory private. +//! +//! **Staleness.** A persisted cache outlives the process, so an entry can +//! describe a node another client has since changed. The daemon's event loop is +//! what closes that: it resumes from a persisted cursor and calls the SDK's +//! `invalidate_caches_for_event` for every event it replays, including the ones +//! that happened while the daemon was down. The one case that leaves no trail is +//! a *seeded* cursor — a first-ever mount, or a cursor the daemon lost — where +//! nothing says what changed, so the caller [`clear`](SdkCache::clear)s this +//! store instead of trusting it. + +use std::path::Path; +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use parking_lot::Mutex; +use proton_sdk::cache::CacheRepository; +use proton_sdk::error::{ProtonError, Result as SdkResult}; +use rusqlite::{Connection, params}; + +use crate::config::AppDirs; +use crate::error::Result; + +/// Process-wide handle, so the daemon's event loop can clear the same store the +/// client is writing through without threading it down every call. +static SHARED: OnceLock> = OnceLock::new(); + +/// SQLite-backed entity cache for the SDK. +pub struct SdkCache { + conn: Mutex, +} + +impl SdkCache { + /// Open (creating if needed) the entity cache under the app's state + /// directory. The first call wins; later calls return the same handle, which + /// is what lets the event loop clear the store the Drive client is using. + pub fn shared(dirs: &AppDirs) -> Result> { + if let Some(existing) = SHARED.get() { + return Ok(existing.clone()); + } + let cache = Arc::new(Self::open(&dirs.state_dir().join("sdk_cache.db"))?); + Ok(SHARED.get_or_init(|| cache).clone()) + } + + /// The already-open shared cache, if one was opened in this process. + pub fn opened() -> Option> { + SHARED.get().cloned() + } + + /// Open the store at `path`, creating its schema. + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path)?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + // Tags cascade with their entry, so a `set` that replaces a value cannot + // leave the old value's tags behind pointing at the new one. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS entry ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS tag ( + key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (key, tag) + ); + CREATE INDEX IF NOT EXISTS idx_tag_tag ON tag(tag);", + )?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + /// Drop every entry. Used when the daemon cannot prove the cache is current + /// — a seeded event cursor — and on logout. + pub fn clear_now(&self) -> Result<()> { + let conn = self.conn.lock(); + conn.execute_batch("DELETE FROM tag; DELETE FROM entry;")?; + Ok(()) + } + + fn set_now(&self, key: &str, value: &str, tags: &[String]) -> Result<()> { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO entry (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + )?; + tx.execute("DELETE FROM tag WHERE key = ?1", params![key])?; + { + let mut stmt = tx.prepare("INSERT INTO tag (key, tag) VALUES (?1, ?2)")?; + for tag in tags { + stmt.execute(params![key, tag])?; + } + } + tx.commit()?; + Ok(()) + } + + fn get_now(&self, key: &str) -> Result> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT value FROM entry WHERE key = ?1")?; + let mut rows = stmt.query(params![key])?; + match rows.next()? { + Some(row) => Ok(Some(row.get(0)?)), + None => Ok(None), + } + } + + fn remove_now(&self, key: &str) -> Result<()> { + let conn = self.conn.lock(); + conn.execute("DELETE FROM entry WHERE key = ?1", params![key])?; + Ok(()) + } + + fn remove_by_tag_now(&self, tag: &str) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "DELETE FROM entry WHERE key IN (SELECT key FROM tag WHERE tag = ?1)", + params![tag], + )?; + Ok(()) + } + + /// Entries carrying **all** of `tags` — the set intersection the SDK's + /// `get_by_tags` contract specifies, not a union. + fn get_by_tags_now(&self, tags: &[String]) -> Result> { + if tags.is_empty() { + return Ok(Vec::new()); + } + let conn = self.conn.lock(); + let placeholders = vec!["?"; tags.len()].join(","); + let sql = format!( + "SELECT e.key, e.value FROM entry e + JOIN tag t ON t.key = e.key + WHERE t.tag IN ({placeholders}) + GROUP BY e.key + HAVING COUNT(DISTINCT t.tag) = {}", + tags.len() + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(tags), |r| { + Ok((r.get(0)?, r.get(1)?)) + })?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + } +} + +/// A cache failure is never fatal to the operation that triggered it — the SDK +/// treats this repository as best-effort — but it still has to come back as the +/// SDK's error type. +fn sdk_error(e: crate::error::Error) -> ProtonError { + ProtonError::invalid_operation(format!("sdk entity cache: {e}")) +} + +// Every method is a local SQLite statement on its own connection: microseconds, +// no network, no shared lock with the daemon's database. Running them inline on +// the caller's task is cheaper than the thread hop that moving them off would +// cost. +#[async_trait] +impl CacheRepository for SdkCache { + async fn set(&self, key: &str, value: &str, tags: &[String]) -> SdkResult<()> { + self.set_now(key, value, tags).map_err(sdk_error) + } + + async fn get(&self, key: &str) -> SdkResult> { + self.get_now(key).map_err(sdk_error) + } + + async fn remove(&self, key: &str) -> SdkResult<()> { + self.remove_now(key).map_err(sdk_error) + } + + async fn remove_by_tag(&self, tag: &str) -> SdkResult<()> { + self.remove_by_tag_now(tag).map_err(sdk_error) + } + + async fn clear(&self) -> SdkResult<()> { + self.clear_now().map_err(sdk_error) + } + + async fn get_by_tags(&self, tags: &[String]) -> SdkResult> { + self.get_by_tags_now(tags).map_err(sdk_error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cache() -> SdkCache { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + conn.execute_batch( + "CREATE TABLE entry (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE tag ( + key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (key, tag) + );", + ) + .unwrap(); + SdkCache { + conn: Mutex::new(conn), + } + } + + fn tags(values: &[&str]) -> Vec { + values.iter().map(|t| t.to_string()).collect() + } + + #[test] + fn set_replaces_the_value_and_its_tags() { + let cache = cache(); + cache.set_now("k", "one", &tags(&["a", "b"])).unwrap(); + cache.set_now("k", "two", &tags(&["b"])).unwrap(); + + assert_eq!(cache.get_now("k").unwrap().as_deref(), Some("two")); + // The tag dropped by the second write must not still select the entry — + // that is how a stale invalidation would miss it. + assert!(cache.get_by_tags_now(&tags(&["a"])).unwrap().is_empty()); + assert_eq!(cache.get_by_tags_now(&tags(&["b"])).unwrap().len(), 1); + } + + #[test] + fn get_by_tags_intersects_rather_than_unions() { + let cache = cache(); + cache.set_now("both", "1", &tags(&["x", "y"])).unwrap(); + cache.set_now("one", "2", &tags(&["x"])).unwrap(); + + let hits = cache.get_by_tags_now(&tags(&["x", "y"])).unwrap(); + assert_eq!(hits, vec![("both".to_string(), "1".to_string())]); + // An empty tag list selects nothing, matching the SDK contract. + assert!(cache.get_by_tags_now(&[]).unwrap().is_empty()); + } + + #[test] + fn removing_by_tag_takes_the_entry_and_its_tags() { + let cache = cache(); + cache.set_now("k", "v", &tags(&["gone"])).unwrap(); + cache.remove_by_tag_now("gone").unwrap(); + + assert!(cache.get_now("k").unwrap().is_none()); + assert!(cache.get_by_tags_now(&tags(&["gone"])).unwrap().is_empty()); + } + + #[test] + fn clear_empties_the_store() { + let cache = cache(); + cache.set_now("a", "1", &tags(&["t"])).unwrap(); + cache.set_now("b", "2", &[]).unwrap(); + cache.clear_now().unwrap(); + + assert!(cache.get_now("a").unwrap().is_none()); + assert!(cache.get_now("b").unwrap().is_none()); + } +} diff --git a/crates/pdfs-fuse/Cargo.toml b/crates/pdfs-fuse/Cargo.toml index c911eee..83c8d62 100644 --- a/crates/pdfs-fuse/Cargo.toml +++ b/crates/pdfs-fuse/Cargo.toml @@ -10,6 +10,7 @@ description = "FUSE on-demand filesystem for Proton Drive" [dependencies] pdfs-core.workspace = true proton-drive-rs.workspace = true +futures.workspace = true tokio.workspace = true tracing.workspace = true fuser.workspace = true diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index 126a9fc..bda02b1 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -358,6 +358,14 @@ pub(super) async fn run_event_sync( // started offline (offline.md Phase 1) — so retry rather than giving up, // which used to disable live sync for the life of the daemon. Ok(None) => { + // Nothing says what changed before this cursor, so a persisted SDK + // entity cache from an earlier run cannot be trusted: drop it rather + // than serve metadata no event will ever invalidate. + if let Some(cache) = pdfs_core::sdkcache::SdkCache::opened() + && let Err(e) = cache.clear_now() + { + warn!(error = %e, "clearing the persisted SDK entity cache failed"); + } let mut delay = ONLINE_PROBE_MIN; loop { match client.enumerate_events(&scope, None).await { diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index b7564cc..4067c01 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -203,7 +203,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U limit, kind, range, - }) => match core.photos_timeline(offset, limit, kind, range) { + favorites, + }) => match core.photos_timeline(offset, limit, kind, range, favorites) { Ok(Some(items)) => CtlResponse::Photos { available: true, items, @@ -216,6 +217,19 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) { + Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) { + Ok(()) => CtlResponse::Ok { + message: if favorite { + "added to favourites".to_string() + } else { + "removed from favourites".to_string() + }, + }, + Err(e) => CtlResponse::error(e), + }, + None => CtlResponse::error(CoreError::invalid(format!("invalid uid: {uid}"))), + }, Ok(CtlRequest::PhotoAlbums) => match core.albums() { Ok(Some(items)) => CtlResponse::Albums { available: true, @@ -863,6 +877,78 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Err(e) => CtlResponse::error(e), } } + Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.list_revisions(&rel) { + Ok(items) => CtlResponse::Revisions { items }, + Err(e) => CtlResponse::error(e), + }, + Err(e) => CtlResponse::error(e), + }, + Ok(CtlRequest::ListRevisionsByUid { uid }) => match core.list_revisions_by_uid(&uid) { + Ok(items) => CtlResponse::Revisions { items }, + Err(e) => CtlResponse::error(e), + }, + Ok(CtlRequest::RestoreRevision { path, revision_id }) => { + match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.restore_revision(&rel, &revision_id) { + Ok(()) => CtlResponse::Ok { + message: format!("restoring version {revision_id} of {path}"), + }, + Err(e) => CtlResponse::error(e), + }, + Err(e) => CtlResponse::error(e), + } + } + Ok(CtlRequest::RestoreRevisionByUid { uid, revision_id }) => { + match core.restore_revision_by_uid(&uid, &revision_id) { + Ok(()) => CtlResponse::Ok { + message: format!("restoring version {revision_id}"), + }, + Err(e) => CtlResponse::error(e), + } + } + Ok(CtlRequest::DeleteRevision { path, revision_id }) => { + match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.delete_revision(&rel, &revision_id) { + Ok(()) => CtlResponse::Ok { + message: format!("deleted version {revision_id} of {path}"), + }, + Err(e) => CtlResponse::error(e), + }, + Err(e) => CtlResponse::error(e), + } + } + Ok(CtlRequest::DeleteRevisionByUid { uid, revision_id }) => { + match core.delete_revision_by_uid(&uid, &revision_id) { + Ok(()) => CtlResponse::Ok { + message: format!("deleted version {revision_id}"), + }, + Err(e) => CtlResponse::error(e), + } + } + Ok(CtlRequest::SaveRevisionAs { + path, + revision_id, + dest, + }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.save_revision_as(&rel, &revision_id, Path::new(&dest)) { + Ok(written) => CtlResponse::Ok { + message: format!("wrote {} to {dest}", human_bytes(written)), + }, + Err(e) => CtlResponse::error(e), + }, + Err(e) => CtlResponse::error(e), + }, + Ok(CtlRequest::SaveRevisionAsByUid { + uid, + revision_id, + dest, + }) => match core.save_revision_as_by_uid(&uid, &revision_id, Path::new(&dest)) { + Ok(written) => CtlResponse::Ok { + message: format!("wrote {} to {dest}", human_bytes(written)), + }, + Err(e) => CtlResponse::error(e), + }, Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() { Ok(entries) => CtlResponse::Entries { entries }, Err(e) => CtlResponse::error(e), diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 22dfa54..8525830 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use parking_lot::Mutex; +use pdfs_core::batch; use pdfs_core::control::{ ActivityKind, DeviceInfo, JobItem, MountKind, MountSpec, SyncFolderInfo, SyncPhase, SyncProgress, @@ -557,7 +558,10 @@ impl Core { let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); if delete_remote && let Some(uid) = parse_uid(&folder.remote_uid) - && let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) + && let Err(e) = self + .rt + .block_on(self.client.trash_nodes(&[uid])) + .and_then(batch::into_unit) { warn!(id, error = %e, "delete remote device folder failed"); } diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index d5bef46..2f6337f 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -25,6 +25,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::time::Duration; +use pdfs_core::batch; use pdfs_core::cache::{Baseline, StagedWrite}; use pdfs_core::control::{ActivityKind, TransferDirection}; use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta}; @@ -340,6 +341,7 @@ impl Core { match self .rt .block_on(self.client.trash_nodes(std::slice::from_ref(&uid))) + .and_then(batch::into_unit) { Ok(()) => {} Err(e) if is_gone(&e) => { diff --git a/crates/pdfs-fuse/src/filesystem.rs b/crates/pdfs-fuse/src/filesystem.rs index 53828f5..7358510 100644 --- a/crates/pdfs-fuse/src/filesystem.rs +++ b/crates/pdfs-fuse/src/filesystem.rs @@ -1856,6 +1856,7 @@ impl ProtonFs { .core .rt .block_on(self.core.client.trash_nodes(std::slice::from_ref(&uid))) + .and_then(batch::into_unit) { error!(%uid, error = %e, "trash failed"); self.core diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index cd9ae2d..f608104 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -53,6 +53,8 @@ use fuser::{ ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyIoctl, ReplyLseek, ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags, }; +use futures::StreamExt as _; +use pdfs_core::batch; use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite}; use pdfs_core::config::{AppDirs, SweepMode}; use pdfs_core::control::{ @@ -88,6 +90,7 @@ mod mount; mod photos; mod profile; mod reads; +mod revisions; mod sharing; mod state; mod sweep; @@ -3287,6 +3290,7 @@ impl Core { .map_err(|error| self.errno_error(error, "trash access"))?; self.rt .block_on(self.client.trash_nodes(std::slice::from_ref(&uid))) + .and_then(batch::into_unit) .map_err(|e| CoreError::from_api(&e, "trash"))?; let name = self .state @@ -3325,6 +3329,7 @@ impl Core { if let Err(e) = self .rt .block_on(self.client.trash_nodes(std::slice::from_ref(uid))) + .and_then(batch::into_unit) { error!(%uid, name, error = %e, "trashing the node a rename replaces failed"); self.log_activity(ActivityKind::Trash, name, e.to_string(), false); @@ -3372,6 +3377,7 @@ impl Core { match self .rt .block_on(self.client.restore_nodes(std::slice::from_ref(uid))) + .and_then(batch::into_unit) { Ok(()) => { self.invalidate_trash(); @@ -3586,14 +3592,37 @@ impl Core { .into_iter() .filter_map(|n| n.parent_uid) .collect(); + // Streamed per-node outcomes: a uid the server refuses (already restored, + // gone) leaves the rest of the batch restored, and each node is unhidden + // as *its* batch lands rather than after the last one — a daemon killed + // half way through a large restore leaves local state describing what the + // server actually did. Only a batch that restored nothing is an error the + // caller can act on. + let mut restored: Vec = Vec::with_capacity(parsed.len()); + let mut first_error: Option = None; self.rt - .block_on(self.client.restore_nodes(&parsed)) + .block_on(async { + let mut outcomes = std::pin::pin!(self.client.restore_nodes_streaming(&parsed)); + while let Some(item) = outcomes.next().await { + let (uid, outcome) = item?; + match outcome { + Ok(()) => { + self.hidden.lock().remove(&uid); + restored.push(uid); + } + Err(e) => { + warn!(%uid, error = %e, "restore failed for a node"); + first_error.get_or_insert(e); + } + } + } + Ok::<(), ProtonError>(()) + }) .map_err(|e| CoreError::from_api(&e, "restore"))?; + if restored.is_empty() + && let Some(error) = first_error { - let mut hidden = self.hidden.lock(); - for uid in &parsed { - hidden.remove(uid); - } + return Err(CoreError::from_api(&error, "restore")); } // Keyed by uid, so it applies to every mount: a restored node reappears // in whichever inode spaces show its parent, not only the primary one. @@ -3605,19 +3634,45 @@ impl Core { } }); self.invalidate_trash(); - Ok(parsed.len()) + Ok(restored.len()) } /// Permanently delete trashed nodes. Irreversible on the server; locally it /// drops any metadata and cached content the node still owns. fn delete_forever(&self, uids: &[String]) -> CoreResult { let parsed = Self::parse_uids(uids)?; + // Streamed, and each node's local state is dropped as its batch lands. + // A permanent delete is irreversible, so the useful property is that a + // daemon interrupted mid-batch has already forgotten exactly the nodes + // the server destroyed — never more, never fewer. + let mut deleted = 0usize; + let mut first_error: Option = None; self.rt - .block_on(self.client.delete_nodes(&parsed)) + .block_on(async { + let mut outcomes = std::pin::pin!(self.client.delete_nodes_streaming(&parsed)); + while let Some(item) = outcomes.next().await { + let (uid, outcome) = item?; + match outcome { + Ok(()) => { + self.drop_local(std::slice::from_ref(&uid)); + deleted += 1; + } + Err(e) => { + warn!(%uid, error = %e, "permanent delete failed for a node"); + first_error.get_or_insert(e); + } + } + } + Ok::<(), ProtonError>(()) + }) .map_err(|e| CoreError::from_api(&e, "delete"))?; - self.drop_local(&parsed); + if deleted == 0 + && let Some(error) = first_error + { + return Err(CoreError::from_api(&error, "delete")); + } self.invalidate_trash(); - Ok(parsed.len()) + Ok(deleted) } /// Permanently delete everything in the trash. The uids are listed first so diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index d7142c5..2c320d2 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -14,7 +14,7 @@ use pdfs_core::{CoreError, CoreResult}; use std::sync::atomic::Ordering; use proton_drive_rs::proton_sdk::ids::NodeUid; -use proton_drive_rs::{NodeKind, ThumbnailType}; +use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType}; use tracing::{info, warn}; use super::{ @@ -97,6 +97,7 @@ impl Core { limit: usize, kind: Option, range: Option<(i64, i64)>, + favorites: bool, ) -> CoreResult>> { let count = self.db.photos_count().map_err(CoreError::from)?; if count == 0 { @@ -116,7 +117,7 @@ impl Core { let page = self .db - .photos_page(offset, limit, kind, range) + .photos_page(offset, limit, kind, range, favorites) .map_err(CoreError::from)?; Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect())) } @@ -139,9 +140,42 @@ impl Core { ratio: photo.ratio, no_thumb: photo.thumb_state == db::THUMB_NONE, kind: photo.kind, + favorite: photo.favorite, } } + /// Add or remove Proton's `Favorite` tag on a photo, then record the change + /// locally so the gallery reflects it without waiting for a timeline refresh. + /// + /// The SDK reports one outcome per photo rather than failing the call, so the + /// single outcome is unwrapped back into this request's result. + pub(crate) fn set_photo_favorite(&self, uid: &NodeUid, favorite: bool) -> CoreResult<()> { + let photos = self.photos(); + let update = PhotoTagsUpdate { + node_uid: uid.clone(), + tags_to_add: if favorite { + vec![PhotoTag::Favorite] + } else { + Vec::new() + }, + tags_to_remove: if favorite { + Vec::new() + } else { + vec![PhotoTag::Favorite] + }, + }; + let outcomes = self + .rt + .block_on(photos.update_photos(std::slice::from_ref(&update))) + .map_err(|e| CoreError::from_api(&e, "update photo tags"))?; + pdfs_core::batch::into_unit(outcomes) + .map_err(|e| CoreError::from_api(&e, "update photo tags"))?; + self.db + .photos_set_favorite(&uid.to_string(), favorite) + .map_err(CoreError::from)?; + Ok(()) + } + /// Thumbnails for `uids`, served from the cache, fetched from the server for /// whatever is missing, and — for the photos the server has no thumbnail for /// at all — *generated locally* from the full file (see @@ -381,7 +415,7 @@ impl Core { // learned before (or classifies from nothing, i.e. a still photo), so a // partial resolve never blanks the timeline. let uids: Vec = items.iter().map(|it| it.uid.clone()).collect(); - let mut meta: HashMap, Option)> = HashMap::new(); + let mut meta: HashMap, Option, bool)> = HashMap::new(); for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) { match photos.enumerate_nodes(chunk).await { Ok(nodes) => { @@ -390,19 +424,35 @@ impl Core { NodeKind::File { media_type, .. } => Some(media_type.clone()), NodeKind::Folder => None, }; - meta.insert(node.uid.to_string(), (Some(node.name), media_type)); + // The favourite state is a tag on the photo node, so it + // rides along with the metadata resolve rather than + // costing a listing of its own. + let favorite = node + .photo + .as_ref() + .is_some_and(|p| p.tags.contains(&PhotoTag::Favorite)); + meta.insert( + node.uid.to_string(), + (Some(node.name), media_type, favorite), + ); } } Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"), } } - let rows: Vec<(String, i64, Option, Option)> = items + let rows: Vec = items .iter() .map(|it| { let key = it.uid.to_string(); - let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None)); - (key, it.capture_time, name, media_type) + // An unresolved photo keeps whatever was learned before, for the + // favourite flag as much as for the name and media type. + match meta.get(&key).cloned() { + Some((name, media_type, favorite)) => { + (key, it.capture_time, name, media_type, Some(favorite)) + } + None => (key, it.capture_time, None, None, None), + } }) .collect(); self.db.photos_replace(&rows).map_err(CoreError::from)?; diff --git a/crates/pdfs-fuse/src/revisions.rs b/crates/pdfs-fuse/src/revisions.rs new file mode 100644 index 0000000..240b170 --- /dev/null +++ b/crates/pdfs-fuse/src/revisions.rs @@ -0,0 +1,264 @@ +//! File version history: listing a file's revisions, restoring one, deleting +//! one, and writing an old one out to a local path. +//! +//! Proton Drive keeps every revision a client ever committed, and until now the +//! daemon only ever addressed the active one — a file that a sync pass or a +//! `(sync-conflict)` fork overwrote could only be recovered from whatever the +//! local `recovery/` directory happened to hold. This is the remote equivalent, +//! and it is the reason a *restore* here is not a download-then-upload: the +//! server re-points the file at an existing revision, so no content crosses the +//! wire and nothing lands in the drain queue. +//! +//! Two things about the SDK surface leak into every method here: +//! +//! - **A restore is asynchronous.** The server answers 202 and swaps the active +//! revision in the background, so the daemon drops its cached content for the +//! file rather than trying to describe the new one. The next read repopulates +//! from whatever is live by then. +//! - **The active revision cannot be deleted**, and the server — not this code — +//! is what enforces that. [`Core::delete_revision_for_uid`] refuses locally +//! too, so the user gets a sentence instead of an API code. + +use std::path::Path; + +use pdfs_core::control::{ActivityKind, RevisionInfo}; +use pdfs_core::{CoreError, CoreResult}; +use proton_drive_rs::Revision; +use proton_drive_rs::proton_sdk::ids::NodeUid; +use tracing::info; + +use super::Core; + +/// A [`Revision`] as the control protocol reports it. +fn revision_info(revision: Revision) -> RevisionInfo { + RevisionInfo { + is_active: revision.is_active(), + id: revision.revision_id, + created: revision.creation_time, + size_on_storage: revision.size_on_storage, + claimed_size: revision.claimed_size, + claimed_modified: revision.claimed_modification_time, + // An empty signer means the node key signed it anonymously, which is not + // an address a front-end can show. + signed_by: revision.signature_email.filter(|email| !email.is_empty()), + has_thumbnails: revision.has_thumbnails, + } +} + +impl Core { + /// The version history of the file at mountpoint-relative `rel`. + pub(crate) fn list_revisions(&self, rel: &Path) -> CoreResult> { + let (_ino, uid) = self.resolve(rel)?; + self.list_revisions_for_uid(&uid) + } + + /// [`Core::list_revisions`] for a node addressed through any daemon location. + pub(crate) fn list_revisions_by_uid(&self, uid: &str) -> CoreResult> { + let uid = self.resolve_anywhere(uid)?; + self.list_revisions_for_uid(&uid) + } + + fn list_revisions_for_uid(&self, uid: &NodeUid) -> CoreResult> { + let revisions = self + .rt + .block_on(self.client.enumerate_revisions(uid)) + .map_err(|e| CoreError::from_api(&e, "list revisions"))?; + Ok(revisions.into_iter().map(revision_info).collect()) + } + + /// Make `revision_id` the current content of the file at `rel`. + pub(crate) fn restore_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> { + let (_ino, uid) = self.resolve(rel)?; + let name = rel + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| uid.to_string()); + self.restore_revision_for_uid(&uid, revision_id, &name)?; + // The size and mtime a listing shows describe the revision that was + // active a moment ago. + self.invalidate_parent_listing(rel); + Ok(()) + } + + /// [`Core::restore_revision`] for a node addressed through any daemon + /// location. + pub(crate) fn restore_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> { + let uid = self.resolve_anywhere(uid)?; + let name = uid.to_string(); + self.restore_revision_for_uid(&uid, revision_id, &name) + } + + fn restore_revision_for_uid( + &self, + uid: &NodeUid, + revision_id: &str, + name: &str, + ) -> CoreResult<()> { + // Restoring rewrites the file's content: the same authority a write + // needs, not the one a read needs. + self.require_uid_writable(uid) + .map_err(|error| self.errno_error(error, "restore revision"))?; + if let Err(e) = self + .rt + .block_on(self.client.restore_revision(uid, revision_id)) + { + let error = CoreError::from_api(&e, "restore revision"); + self.log_activity(ActivityKind::Restore, name, &error, false); + return Err(error); + } + // The active revision is changing behind our back — anything describing + // the old one (cached blocks, an open reader, the size in a listing) is + // stale from here on, and the server may not have applied the swap yet. + self.cache.evict(uid); + self.evict_reader(uid); + self.log_activity( + ActivityKind::Restore, + name, + format!("restored version {revision_id}"), + true, + ); + info!(%uid, revision_id, name, "restored an earlier revision"); + Ok(()) + } + + /// Permanently delete `revision_id` from the history of the file at `rel`. + pub(crate) fn delete_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> { + let (_ino, uid) = self.resolve(rel)?; + let name = rel + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| uid.to_string()); + self.delete_revision_for_uid(&uid, revision_id, &name) + } + + /// [`Core::delete_revision`] for a node addressed through any daemon + /// location. + pub(crate) fn delete_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> { + let uid = self.resolve_anywhere(uid)?; + let name = uid.to_string(); + self.delete_revision_for_uid(&uid, revision_id, &name) + } + + fn delete_revision_for_uid( + &self, + uid: &NodeUid, + revision_id: &str, + name: &str, + ) -> CoreResult<()> { + self.require_uid_writable(uid) + .map_err(|error| self.errno_error(error, "delete revision"))?; + // The server rejects deleting the active revision; saying so here is a + // sentence the user can act on instead of an API code, and it costs one + // listing the front-end already made to show the version list. + let revisions = self + .rt + .block_on(self.client.enumerate_revisions(uid)) + .map_err(|e| CoreError::from_api(&e, "list revisions"))?; + let target = revisions + .iter() + .find(|r| r.revision_id == revision_id) + .ok_or_else(|| CoreError::not_found(format!("no version {revision_id} on {name}")))?; + if target.is_active() { + return Err(CoreError::invalid(format!( + "version {revision_id} is the current content of {name}; restore another version first" + ))); + } + self.rt + .block_on(self.client.delete_revision(uid, revision_id)) + .map_err(|e| CoreError::from_api(&e, "delete revision"))?; + self.log_activity( + ActivityKind::DeleteForever, + name, + format!("deleted version {revision_id}"), + true, + ); + info!(%uid, revision_id, name, "deleted a revision"); + Ok(()) + } + + /// Write `revision_id` of the file at `rel` to the absolute local path + /// `dest`, leaving the file itself untouched. + pub(crate) fn save_revision_as( + &self, + rel: &Path, + revision_id: &str, + dest: &Path, + ) -> CoreResult { + let (_ino, uid) = self.resolve(rel)?; + let name = rel + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| uid.to_string()); + self.save_revision_for_uid(&uid, revision_id, &name, dest) + } + + /// [`Core::save_revision_as`] for a node addressed through any daemon + /// location. + pub(crate) fn save_revision_as_by_uid( + &self, + uid: &str, + revision_id: &str, + dest: &Path, + ) -> CoreResult { + let uid = self.resolve_anywhere(uid)?; + let name = uid.to_string(); + self.save_revision_for_uid(&uid, revision_id, &name, dest) + } + + fn save_revision_for_uid( + &self, + uid: &NodeUid, + revision_id: &str, + name: &str, + dest: &Path, + ) -> CoreResult { + if !dest.is_absolute() { + return Err(CoreError::invalid(format!( + "destination must be an absolute path: {}", + dest.display() + ))); + } + // Never silently replace something the user already has: an old version + // written over the wrong file is exactly the loss this feature exists to + // undo. + if dest.exists() { + return Err(CoreError::invalid(format!( + "{} already exists", + dest.display() + ))); + } + let file = std::fs::File::create(dest) + .map_err(|e| CoreError::internal(format!("create {}: {e}", dest.display())))?; + let mut out = std::io::BufWriter::new(file); + let written = match self + .rt + .block_on(self.client.download_revision_to(uid, revision_id, &mut out)) + .map_err(|e| CoreError::from_api(&e, "download revision")) + .and_then(|()| { + use std::io::Write as _; + out.flush() + .map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))?; + let written = std::fs::metadata(dest) + .map(|m| m.len()) + .map_err(|e| CoreError::internal(format!("stat {}: {e}", dest.display())))?; + Ok(written) + }) { + Ok(written) => written, + Err(error) => { + // A half-written file looks like a successful export to every + // tool that opens it afterwards. + let _ = std::fs::remove_file(dest); + self.log_activity(ActivityKind::Download, name, &error, false); + return Err(error); + } + }; + self.log_activity( + ActivityKind::Download, + name, + format!("saved version {revision_id} to {}", dest.display()), + true, + ); + info!(%uid, revision_id, dest = %dest.display(), "wrote an earlier revision to disk"); + Ok(written) + } +} diff --git a/crates/pdfs-fuse/src/sweep.rs b/crates/pdfs-fuse/src/sweep.rs index 350c99c..6530174 100644 --- a/crates/pdfs-fuse/src/sweep.rs +++ b/crates/pdfs-fuse/src/sweep.rs @@ -21,6 +21,7 @@ use std::collections::HashMap; use std::time::Duration; +use pdfs_core::batch; use pdfs_core::config::SweepMode; use pdfs_core::control::ActivityKind; use proton_drive_rs::proton_sdk::ids::NodeUid; @@ -149,6 +150,7 @@ impl Core { match self .rt .block_on(self.client.trash_nodes(std::slice::from_ref(uid))) + .and_then(batch::into_unit) { Ok(()) => {} Err(e) if super::is_gone(&e) => {} diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index b7bdfbe..d12ce30 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -38,6 +38,7 @@ use tracing::{debug, info, warn}; use crate::transfers::{CountingWriter, OwnedCountingReader}; use crate::{Core, now_secs, parse_uid}; +use pdfs_core::batch; use pdfs_core::control::{ActivityKind, TransferDirection}; use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder}; use pdfs_core::syncignore::IgnoreRules; @@ -513,7 +514,11 @@ impl Core { let _ = self.db.sync_entry_remove(folder_id, rel); continue; }; - if let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) { + if let Err(e) = self + .rt + .block_on(self.client.trash_nodes(&[uid])) + .and_then(batch::into_unit) + { warn!(rel, error = %e, "sync: trash remote failed"); self.log_activity(ActivityKind::Trash, base_name(rel), e.to_string(), false); outcome.errors += 1; @@ -754,6 +759,7 @@ impl Core { if let Err(e) = self .rt .block_on(self.client.trash_nodes(std::slice::from_ref(&remote.uid))) + .and_then(batch::into_unit) { warn!(rel, error = %e, "sync: trash remote failed"); self.log_activity( @@ -808,7 +814,11 @@ impl Core { } delete_remote_dirs.sort_by_key(|(p, _)| std::cmp::Reverse(p.matches('/').count())); for (rel, uid) in delete_remote_dirs { - if let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) { + if let Err(e) = self + .rt + .block_on(self.client.trash_nodes(&[uid])) + .and_then(batch::into_unit) + { warn!(rel, error = %e, "sync: trash remote folder failed"); self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false); outcome.errors += 1; diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 533e9e0..5c4b9de 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -18,6 +18,7 @@ use pages::trash::*; use pages::verify::*; use widgets::details::*; use widgets::share_dialog::*; +use widgets::versions_dialog::*; use std::cell::{Cell, RefCell}; @@ -424,6 +425,8 @@ fn build_window(app: &adw::Application) { dates: gallery_widgets.dates.clone(), date_ranges: RefCell::new(vec![None]), range: Cell::new(None), + favorites_btn: gallery_widgets.favorites_btn.clone(), + favorites: Cell::new(false), date_suppress: Cell::new(false), loading: Cell::new(false), width: Cell::new(0), diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index f163935..6ef6dd2 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -19,6 +19,11 @@ pub(crate) struct Viewer { pub(crate) info_map: gtk4::Button, /// Coordinates behind `info_map`, once a photo with GPS tags is shown. pub(crate) coords: RefCell>, + /// The favourite toggle in the top bar, and whether it is being set from + /// the model rather than by the user — the same suppression the details pane + /// uses for its pin switch, so painting a photo doesn't fire a round-trip. + pub(crate) favorite: gtk4::ToggleButton, + pub(crate) favorite_suppress: Cell, /// uid of the photo currently on screen. pub(crate) uid: RefCell, /// On-disk path of the full-size photo, once it has been downloaded. @@ -41,6 +46,38 @@ pub(crate) struct ExifInfo { pub(crate) coords: Option<(f64, f64)>, } +/// Paint the favourite toggle without firing its handler. +pub(crate) fn show_favorite(viewer: &Rc, favorite: bool) { + viewer.favorite_suppress.set(true); + viewer.favorite.set_active(favorite); + set_favorite_icon(&viewer.favorite, favorite); + viewer.favorite_suppress.set(false); +} + +/// Filled star for a favourite, outline for the rest. +fn set_favorite_icon(button: >k4::ToggleButton, favorite: bool) { + button.set_icon_name(if favorite { + "starred-symbolic" + } else { + "non-starred-symbolic" + }); +} + +/// Write a favourite change back into the loaded gallery page, so returning to +/// the grid (or reopening the photo) shows what was just set without a reload. +fn set_gallery_favorite(ui: &Rc, uid: &str, favorite: bool) { + let Some(idx) = find_photo_index(&ui.gallery.model, uid) else { + return; + }; + let Some(boxed) = ui.gallery.model.item(idx).and_downcast::() else { + return; + }; + let mut item = boxed.borrow::().clone(); + item.favorite = favorite; + ui.gallery.model.remove(idx); + ui.gallery.model.insert(idx, &BoxedAnyObject::new(item)); +} + /// Show the photo behind `uid`: paint its (already cached) thumbnail immediately /// so the lightbox never opens on a blank screen, ask the daemon for the /// full-size file, and swap it in — plus its EXIF — when it lands. @@ -52,6 +89,14 @@ pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { *viewer.path.borrow_mut() = None; clear_info(viewer); + // The favourite state comes from the timeline page the gallery already has, + // so the button is right on the first frame instead of after a round-trip. + let favorite = find_photo_index(&ui.gallery.model, &uid) + .and_then(|idx| ui.gallery.model.item(idx)) + .and_downcast::() + .is_some_and(|boxed| boxed.borrow::().favorite); + show_favorite(viewer, favorite); + // The thumbnail the gallery already decoded stands in for the full photo // while it downloads: blurry for a moment beats black for a second. match ui.gallery.photo_tex.borrow().get(&uid) { @@ -450,6 +495,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { info_toggle.add_css_class("flat"); info_toggle.add_css_class("viewer-action-btn"); + let favorite_btn = gtk4::ToggleButton::builder() + .icon_name("non-starred-symbolic") + .tooltip_text("Favourite") + .valign(gtk4::Align::Center) + .build(); + favorite_btn.add_css_class("flat"); + favorite_btn.add_css_class("viewer-action-btn"); + let download_btn = action("document-save-symbolic", "Save a copy…"); let open_ext_btn = action("document-open-symbolic", "Open with another app"); let close_btn = action("window-close-symbolic", "Close (Esc)"); @@ -460,6 +513,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { top_bar.set_valign(gtk4::Align::Start); top_bar.set_hexpand(true); top_bar.append(&titles); + top_bar.append(&favorite_btn); top_bar.append(&info_toggle); top_bar.append(&download_btn); top_bar.append(&open_ext_btn); @@ -558,6 +612,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { info_revealer: info_revealer.clone(), info_rows, info_map: info_map.clone(), + favorite: favorite_btn.clone(), + favorite_suppress: Cell::new(false), coords: RefCell::new(None), uid: RefCell::new(initial_uid.clone()), path: RefCell::new(None), @@ -601,6 +657,44 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { } }); + let ui_fav = ui.clone(); + let viewer_fav = viewer.clone(); + favorite_btn.connect_toggled(move |btn| { + if viewer_fav.favorite_suppress.get() { + return; + } + let favorite = btn.is_active(); + set_favorite_icon(btn, favorite); + let uid = viewer_fav.uid.borrow().clone(); + let rx = spawn_request( + ui_fav.dirs.control_socket(), + Request::SetPhotoFavorite { + uid: uid.clone(), + favorite, + }, + ); + let ui_result = ui_fav.clone(); + let viewer_result = viewer_fav.clone(); + glib::spawn_future_local(async move { + let failed = match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => { + set_gallery_favorite(&ui_result, &uid, favorite); + None + } + Ok(Ok(Response::Error { message, .. })) => Some(message), + _ => Some("The mount service didn't respond.".to_string()), + }; + if let Some(detail) = failed { + toast_error(&ui_result, "Couldn't change the favourite", &detail); + // The server refused, so the button must go back to describing + // what is actually stored — without firing this handler again. + if *viewer_result.uid.borrow() == uid { + show_favorite(&viewer_result, !favorite); + } + } + }); + }); + let w_download = window.clone(); let viewer_download = viewer.clone(); download_btn.connect_clicked(move |_| { diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 212051f..a20b3fd 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -63,6 +63,10 @@ pub(crate) struct GalleryState { /// The capture-time window the timeline is currently filtered to, or `None` /// for the whole span. Read by [`load_gallery`], set by the date dropdown. pub(crate) range: Cell>, + /// The favourites toggle, and whether it is on. When on, the timeline is + /// restricted to photos carrying Proton's `Favorite` tag. + pub(crate) favorites_btn: gtk4::ToggleButton, + pub(crate) favorites: Cell, /// Set while the date dropdown is being repopulated, so resetting its model /// doesn't fire the selection handler and kick off a spurious reload. pub(crate) date_suppress: Cell, @@ -188,6 +192,7 @@ pub(crate) struct GalleryWidgets { /// The All / Photos / Videos / Raw filter toggles, in that order (index maps /// to [`kind_for_tab`]). pub(crate) tabs: [gtk4::ToggleButton; 4], + pub(crate) favorites_btn: gtk4::ToggleButton, /// The date-jump dropdown, populated with the timeline's months. pub(crate) dates: gtk4::DropDown, } @@ -314,6 +319,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { tab_group.append(btn); } + // Favourites: a filter, not a tab — it cuts across Photos / Videos / Raw, so + // it stays outside the segmented control rather than becoming a fifth option + // that would silently drop the kind the user picked. + let favorites_btn = gtk4::ToggleButton::builder() + .icon_name("starred-symbolic") + .tooltip_text("Show only favourites") + .build(); + favorites_btn.add_css_class("pill"); + // Date jump: "All dates" plus a row per month, filled in once the timeline's // months are known (see [`refresh_photo_months`]). Pushed to the far end of // the filter row, opposite the kind toggles. @@ -326,6 +340,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); filters.set_hexpand(true); filters.append(&tab_group); + filters.append(&favorites_btn); let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); spacer.set_hexpand(true); filters.append(&spacer); @@ -415,6 +430,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { upload, refresh, tabs, + favorites_btn, dates, albums, albums_stack, @@ -679,6 +695,19 @@ pub(crate) fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::Sc }); } + // Favourites: reload the timeline restricted to favourites (or back to all). + // Independent of the kind tabs and the date jump, both of which keep their + // current value across the toggle. + let ui_fav = ui.clone(); + ui.gallery.favorites_btn.connect_toggled(move |btn| { + let on = btn.is_active(); + if ui_fav.gallery.favorites.get() == on { + return; + } + ui_fav.gallery.favorites.set(on); + load_gallery(&ui_fav, false); + }); + // Date jump: selecting a month loads that window; "All dates" (row 0) clears // it. Skipped while the model is being repopulated (see `gallery_date_suppress`). let ui_dates = ui.clone(); @@ -1378,6 +1407,7 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { limit: PHOTOS_PAGE, kind: ui.gallery.kind.get(), range: ui.gallery.range.get(), + favorites: ui.gallery.favorites.get(), }, }; let rx = spawn_request(ui.dirs.control_socket(), request); diff --git a/crates/pdfs-gui/src/app/widgets/details.rs b/crates/pdfs-gui/src/app/widgets/details.rs index 58a8a0e..40a8f90 100644 --- a/crates/pdfs-gui/src/app/widgets/details.rs +++ b/crates/pdfs-gui/src/app/widgets/details.rs @@ -26,6 +26,8 @@ pub(crate) struct DetailsWidgets { pub(crate) pin_row: adw::SwitchRow, pub(crate) open_button: gtk4::Button, pub(crate) rename_button: gtk4::Button, + /// Files only — folders have no revision history. + pub(crate) versions_button: gtk4::Button, pub(crate) trash_button: gtk4::Button, pub(crate) close_button: gtk4::Button, } @@ -96,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { open_button.add_css_class("pill"); let rename_button = gtk4::Button::builder().label("Rename").build(); rename_button.add_css_class("pill"); + let versions_button = gtk4::Button::builder().label("Versions").build(); + versions_button.add_css_class("pill"); let trash_button = gtk4::Button::builder().label("Move to Trash").build(); trash_button.add_css_class("destructive-action"); trash_button.add_css_class("pill"); @@ -104,6 +108,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { actions.set_margin_top(6); actions.append(&open_button); actions.append(&rename_button); + actions.append(&versions_button); actions.append(&trash_button); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); @@ -137,6 +142,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { pin_row, open_button, rename_button, + versions_button, trash_button, close_button, }, @@ -190,6 +196,16 @@ pub(crate) fn wire_details(ui: &Rc) { prompt_rename(&ui_rename, &entry); } }); + let ui_versions = ui.clone(); + ui.details + .details + .versions_button + .connect_clicked(move |_| { + let entry = ui_versions.details.details_entry.borrow().clone(); + if let Some(entry) = entry { + open_versions_dialog(&ui_versions, &entry); + } + }); let ui_trash = ui.clone(); ui.details.details.trash_button.connect_clicked(move |_| { let entry = ui_trash.details.details_entry.borrow().clone(); @@ -246,6 +262,9 @@ pub(crate) fn show_details(ui: &Rc, entry: &DirEntry) { d.pin_row.set_sensitive(*ui.mounted.borrow()); d.open_button .set_label(if entry.is_dir { "Open folder" } else { "Open" }); + // Only files have revisions, and only a connected daemon can fetch them. + d.versions_button.set_visible(!entry.is_dir); + d.versions_button.set_sensitive(*ui.mounted.borrow()); ui.details.details_suppress.set(false); *ui.details.details_entry.borrow_mut() = Some(entry.clone()); diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs index d798a7f..5584e1f 100644 --- a/crates/pdfs-gui/src/app/widgets/mod.rs +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod details; pub(crate) mod share_dialog; +pub(crate) mod versions_dialog; diff --git a/crates/pdfs-gui/src/app/widgets/versions_dialog.rs b/crates/pdfs-gui/src/app/widgets/versions_dialog.rs new file mode 100644 index 0000000..23f5da5 --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/versions_dialog.rs @@ -0,0 +1,353 @@ +//! The per-file **Versions** dialog: what Proton Drive still holds of a file's +//! earlier content, and the three things a user can do with one. +//! +//! Every row is one revision the server kept. Restoring re-points the file at an +//! old revision (server-side, so nothing is uploaded and the mount may take a +//! moment to catch up); saving writes an old revision out to a local file +//! without touching the live one; deleting is permanent, and the active revision +//! cannot be deleted at all — that row simply has no delete button. + +use crate::*; + +use pdfs_core::control::RevisionInfo; + +/// How an open Versions dialog addresses its file — the same split as +/// [`ShareTarget`](super::share_dialog::ShareTarget), for the same reason: a +/// node reached from Shared or a device folder may have no path in the primary +/// mount's namespace, and an older daemon must reject the request rather than +/// resolve an empty path to the mount root. +pub(crate) enum VersionTarget { + Path(String), + Uid(String), +} + +impl VersionTarget { + fn list(&self) -> Request { + match self { + VersionTarget::Path(path) => Request::ListRevisions { path: path.clone() }, + VersionTarget::Uid(uid) => Request::ListRevisionsByUid { uid: uid.clone() }, + } + } + + fn restore(&self, revision_id: String) -> Request { + match self { + VersionTarget::Path(path) => Request::RestoreRevision { + path: path.clone(), + revision_id, + }, + VersionTarget::Uid(uid) => Request::RestoreRevisionByUid { + uid: uid.clone(), + revision_id, + }, + } + } + + fn delete(&self, revision_id: String) -> Request { + match self { + VersionTarget::Path(path) => Request::DeleteRevision { + path: path.clone(), + revision_id, + }, + VersionTarget::Uid(uid) => Request::DeleteRevisionByUid { + uid: uid.clone(), + revision_id, + }, + } + } + + fn save_as(&self, revision_id: String, dest: String) -> Request { + match self { + VersionTarget::Path(path) => Request::SaveRevisionAs { + path: path.clone(), + revision_id, + dest, + }, + VersionTarget::Uid(uid) => Request::SaveRevisionAsByUid { + uid: uid.clone(), + revision_id, + dest, + }, + } + } +} + +/// The state behind an open Versions dialog, so an action can rebuild the list +/// in place instead of tearing the dialog down. +pub(crate) struct VersionsDialog { + pub(crate) ui: Rc, + pub(crate) target: VersionTarget, + /// The file's name, for the save dialog's suggested filename and for toasts. + pub(crate) name: String, + pub(crate) group: adw::PreferencesGroup, + pub(crate) rows: RefCell>, +} + +/// Open the Versions dialog for a file. +pub(crate) fn open_versions_dialog(ui: &Rc, entry: &DirEntry) { + if !*ui.mounted.borrow() { + toast_error(ui, "Can't load versions", "Proton Drive isn't connected."); + return; + } + if entry.is_dir { + toast_error(ui, "No versions", "Folders don't have versions."); + return; + } + let target = if entry.path.is_empty() { + VersionTarget::Uid(entry.uid.clone()) + } else { + VersionTarget::Path(entry_rel(ui, entry)) + }; + + let toolbar = adw::ToolbarView::new(); + let header = adw::HeaderBar::new(); + header.set_title_widget(Some(&adw::WindowTitle::new("Versions", &entry.name))); + toolbar.add_top_bar(&header); + + let group = adw::PreferencesGroup::builder() + .title("Version history") + .description("Proton Drive keeps earlier versions of a file until you delete them.") + .build(); + + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18); + content.set_margin_top(12); + content.set_margin_bottom(12); + content.set_margin_start(12); + content.set_margin_end(12); + content.append(&group); + let clamp = adw::Clamp::builder().child(&content).build(); + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .hscrollbar_policy(gtk4::PolicyType::Never) + .child(&clamp) + .build(); + toolbar.set_content(Some(&scroll)); + + let dialog = adw::Dialog::builder() + .title("Versions") + .content_width(520) + .content_height(520) + .child(&toolbar) + .build(); + + let state = Rc::new(VersionsDialog { + ui: ui.clone(), + target, + name: entry.name.clone(), + group, + rows: RefCell::new(Vec::new()), + }); + + versions_dialog_reload(&state); + dialog.present(ui_window(ui).as_ref()); +} + +/// Re-fetch the file's revisions and rebuild the rows. +pub(crate) fn versions_dialog_reload(state: &Rc) { + let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list()); + let state = state.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items), + Ok(Ok(Response::Error { message, .. })) => { + toast_error(&state.ui, "Couldn't load versions", &message) + } + _ => toast_error( + &state.ui, + "Couldn't load versions", + "The mount service didn't respond.", + ), + } + }); +} + +/// Rebuild the version rows from a fresh listing. +fn repaint_versions(state: &Rc, items: &[RevisionInfo]) { + for row in state.rows.borrow_mut().drain(..) { + state.group.remove(&row); + } + let mut rows: Vec = Vec::new(); + if items.is_empty() { + let row = dim_row("No earlier versions."); + state.group.add(&row); + rows.push(row.upcast()); + *state.rows.borrow_mut() = rows; + return; + } + + for item in items { + // The uploader's claimed plaintext size is the one that matches what the + // user sees in a listing; the storage figure counts ciphertext. + let size = item + .claimed_size + .filter(|size| *size >= 0) + .unwrap_or(item.size_on_storage) + .max(0) as u64; + let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size)); + if let Some(email) = &item.signed_by { + subtitle.push_str(" · "); + subtitle.push_str(email); + } + let title = if item.is_active { + "Current version".to_string() + } else { + format!("Version {}", item.id) + }; + let row = adw::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + + let save = gtk4::Button::builder() + .icon_name("document-save-symbolic") + .tooltip_text("Save a copy…") + .valign(gtk4::Align::Center) + .build(); + save.add_css_class("flat"); + let state_save = state.clone(); + let id = item.id.clone(); + save.connect_clicked(move |_| prompt_save_version(&state_save, &id)); + row.add_suffix(&save); + + if !item.is_active { + let restore = gtk4::Button::builder() + .icon_name("edit-undo-symbolic") + .tooltip_text("Restore this version") + .valign(gtk4::Align::Center) + .build(); + restore.add_css_class("flat"); + let state_restore = state.clone(); + let id = item.id.clone(); + restore.connect_clicked(move |_| prompt_restore_version(&state_restore, &id)); + row.add_suffix(&restore); + + let delete = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Delete this version permanently") + .valign(gtk4::Align::Center) + .build(); + delete.add_css_class("flat"); + let state_delete = state.clone(); + let id = item.id.clone(); + delete.connect_clicked(move |_| prompt_delete_version(&state_delete, &id)); + row.add_suffix(&delete); + } + + state.group.add(&row); + rows.push(row.upcast()); + } + *state.rows.borrow_mut() = rows; +} + +/// Confirm, then restore. Confirmed because it replaces the file's content for +/// every device on the account, not only this one. +fn prompt_restore_version(state: &Rc, revision_id: &str) { + let dialog = adw::AlertDialog::builder() + .heading("Restore version") + .body(format!( + "Make this version the current content of “{}”? The version it replaces stays in the history.", + state.name + )) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("restore", "Restore"); + dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + + let window = ui_window(&state.ui); + let state = state.clone(); + let revision_id = revision_id.to_string(); + dialog.connect_response(None, move |_, resp| { + if resp != "restore" { + return; + } + versions_dialog_op( + &state, + state.target.restore(revision_id.clone()), + // The server applies the swap in the background, so the wording + // promises the request, not the result. + "Restoring that version", + "Couldn't restore that version", + ); + }); + dialog.present(window.as_ref()); +} + +/// Confirm, then permanently delete one revision. +fn prompt_delete_version(state: &Rc, revision_id: &str) { + let dialog = adw::AlertDialog::builder() + .heading("Delete version") + .body("Delete this version permanently? Its content can't be recovered.") + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("delete", "Delete"); + dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + + let window = ui_window(&state.ui); + let state = state.clone(); + let revision_id = revision_id.to_string(); + dialog.connect_response(None, move |_, resp| { + if resp != "delete" { + return; + } + versions_dialog_op( + &state, + state.target.delete(revision_id.clone()), + "Version deleted", + "Couldn't delete that version", + ); + }); + dialog.present(window.as_ref()); +} + +/// Ask where to write an old version, then have the daemon download it there. +/// +/// The daemon writes the file, so the chosen location has to be one it can see: +/// a path from the portal-free `FileDialog` is a real filesystem path, which it +/// can. +fn prompt_save_version(state: &Rc, revision_id: &str) { + let window = ui_window(&state.ui); + let dialog = gtk4::FileDialog::builder() + .title("Save version") + .initial_name(&state.name) + .build(); + let state = state.clone(); + let revision_id = revision_id.to_string(); + dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| { + let Ok(file) = res else { return }; + let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else { + toast_error( + &state.ui, + "Couldn't save that version", + "That location isn't a local file.", + ); + return; + }; + versions_dialog_op( + &state, + state.target.save_as(revision_id.clone(), dest), + "Version saved", + "Couldn't save that version", + ); + }); +} + +/// Send one version request, toast the outcome, and refresh the list. +fn versions_dialog_op(state: &Rc, request: Request, done: &str, failed: &str) { + let rx = spawn_request(state.ui.dirs.control_socket(), request); + let state = state.clone(); + let done = done.to_string(); + let failed = failed.to_string(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => { + toast(&state.ui, &done); + versions_dialog_reload(&state); + } + Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message), + _ => toast_error(&state.ui, &failed, "The mount service didn't respond."), + } + }); +} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b59df48..83efb59 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -7,21 +7,20 @@ This document summarizes the work still pending after the 1.0.0 release branch a ## 1. Core Feature Roadmap (Horizon Tasks) Consolidated from the historical feature roadmap; current bug status lives in [`BUGS.md`](BUGS.md). -* **Local Cache & Metadata Encryption at Rest (Priority 5 / Horizon 1)**: Encrypt the SQLite database `cache.db` on disk using SQLCipher (`rusqlite` feature `bundled-sqlcipher`), and encrypt raw content cache blocks (`content/blocks/` and `content/scratch/`) using a fast symmetric scheme like AES-GCM or ChaCha20-Poly1305. The encryption key should be derived from the OS Keyring. +* **Local Cache & Metadata Encryption at Rest (Priority 5 / Horizon 1)**: Encrypt the SQLite database `cache.db` on disk using SQLCipher (`rusqlite` feature `bundled-sqlcipher`), and encrypt raw content cache blocks (`content/blocks/` and `content/scratch/`) using a fast symmetric scheme like AES-GCM or ChaCha20-Poly1305. The encryption key should be derived from the OS Keyring. (The SDK entity cache in `sdk_cache.db` is already encrypted at rest — see *Persistent SDK Entity Cache* below — but `cache.db` and the content cache are not.) * **Active Bandwidth Throttling & Traffic Shaping (Horizon 1)**: Add speed governors for daemon uploads and downloads. Support configuration variables `max_upload_rate_kbps` and `max_download_rate_kbps` in `config.json` that can be dynamically adjusted over the Unix control socket. * **Interactive & Policy-Based Conflict Resolution (Horizon 1)**: Implement configurable conflict resolution policies (`rename-local`, `rename-remote`, `prefer-local`, `prefer-remote`, `interactive`). Add a "Conflicts" tab to the GUI to notify the user, block sync, and launch external visual diff tools (like Meld or KDiff3). * **Multi-Account / Multi-Profile Support (Horizon 3)**: Enable running multiple profiles concurrently. Support separate configuration namespaces (e.g. `profiles//`), independent SQLite databases, distinct mount paths (e.g. `~/ProtonDrive/Personal` and `~/ProtonDrive/Work`), and multiple daemon processes. * **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration. * **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: `). * **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges. -* **Integrated Photo Gallery Enhancements (Horizon 2)**: Extract and display Exif metadata (camera model, exposure, coordinates) from photos locally, support creating and editing Proton Photo Albums, build a date-based timeline scrollbar, and create an auto-upload Pictures folder pipeline. +* **Integrated Photo Gallery Enhancements (Horizon 2)**: Support creating and editing Proton Photo Albums (album *writes* are unported in the SDK too) and create an auto-upload Pictures folder pipeline. Exif display, the date-based timeline scrubber and favourites are implemented. * **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches. * **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls. * **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs. * **Shutdown Safety & Write Queue Visibility (Horizon 1 / P4)**: Register systemd inhibitor locks in `pdfs-fuse` when there are outstanding staging writes to prevent shutdown or sleep, pop up warnings when exiting the GUI, and build a blocking `pdfs sync flush` command. * **Block-Level Deduplication (Horizon 3)**: Transition to a Content-Addressable block cache where cached blocks are stored on disk by their SHA-256 hash. Map logical ranges in the database: `(node_uid, block_idx) -> block_hash` to avoid storing or downloading identical blocks. * **Pre-emptive Sync Debouncing & File System Events (Horizon 3)**: Group filesystem write events on paths and delay sync queue insertion until a quiet period has elapsed (e.g. 5 seconds of inactivity) to prevent thrashing the Proton API. -* **File Version History (Horizon 2)**: Expose past file revisions and version history inside the GUI browser tabs. --- @@ -122,6 +121,45 @@ Derived from: [`BUGS.md`](BUGS.md) ## 6. Recently Implemented Features +### File Version History (Implemented) +**Files**: [`revisions.rs`](../crates/pdfs-fuse/src/revisions.rs), [`control.rs`](../crates/pdfs-core/src/control.rs), [`versions_dialog.rs`](../crates/pdfs-gui/src/app/widgets/versions_dialog.rs) + +Proton Drive keeps every revision a client committed; the daemon only ever addressed the active one, so a file overwritten by a sync pass could be recovered only from whatever the local `recovery/` directory happened to hold. + +The control protocol gained `ListRevisions` / `RestoreRevision` / `DeleteRevision` / `SaveRevisionAs` (each with a `…ByUid` twin for nodes the primary mount cannot name), the CLI gained `pdfs versions list|restore|save|rm`, and the browser's details pane gained a **Versions** button opening a per-file dialog. + +Three properties that shape the code: + +- **A restore is server-side and asynchronous.** No content crosses the wire and nothing enters the drain queue; the server answers 202 and swaps the active revision in the background, so the daemon evicts the file's cached blocks and open readers rather than describing the new state, and the UI wording promises the request, not the result. +- **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button. +- **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards. + +### Photo Favourites (Implemented) +**Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs) + +The gallery can mark and filter favourites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favourites filter, and the CLI has `pdfs favorite [--remove]` plus `pdfs photos --favorites`. + +Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing. + +### Persistent SDK Entity Cache (Implemented) +**Files**: [`sdkcache.rs`](../crates/pdfs-core/src/sdkcache.rs), [`auth.rs`](../crates/pdfs-core/src/auth.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs) + +The SDK's Drive entity cache (decrypted node metadata: name, size, parent, signing share) defaulted to memory, so every daemon restart re-fetched and re-decrypted the tree the previous run had already walked. It is now backed by SQLite. + +- **Its own file** (`sdk_cache.db` in the state directory), not `cache.db`: the daemon's `Db` is one `Mutex` shared by every FUSE thread and the control socket, and this traffic is frequent, small and entirely reconstructible. A separate file also means no schema migration — the store can be deleted at any time. +- **Encrypted at rest** by wrapping it in the SDK's `EncryptedCacheRepository`, keyed by the mailbox password. A password change reads as a cold cache (the SDK treats an undecryptable entry as a miss and clears the store), not as an error. +- **Staleness** is closed by the event loop, which already replays from a persisted cursor and calls `invalidate_caches_for_event` for every event — including those raised while the daemon was down. The one case with no trail is a *seeded* cursor (first-ever mount, or a lost cursor), where the seed path now clears the store instead of trusting it. `auth::logout` removes the file. + +Needed a small additive SDK change (`ProtonDriveClient::with_entity_repository`, 0.5.1): `with_entity_cache` is a constructor and so could not be combined with `with_key_salts`, which the daemon requires. + +### Per-node Batch Outcomes (Implemented) +**File**: [`batch.rs`](../crates/pdfs-core/src/batch.rs) + +SDK 0.5.0 made `trash_nodes` / `restore_nodes` / `delete_nodes` report one outcome per node instead of failing the whole call (mirroring upstream's streamed `NodeActionResult`). `pdfs_core::batch::into_unit` collapses the single-node calls back to a `Result`; `batch::split` handles a collected batch. + +The trash view's restore and permanent-delete use the SDK's **streaming** variants (`restore_nodes_streaming` / `delete_nodes_streaming`) and apply local state per node as each batch lands rather than after the last one. For a permanent delete — which is irreversible — that means a daemon interrupted mid-batch has forgotten exactly the nodes the server destroyed, no more and no fewer. Both report per-node failures and count only what actually succeeded; only a batch where nothing succeeded is an error. + + ### Open-for-Write Deferral for Mirror Sync (Implemented) **File**: [`sync.rs`](../crates/pdfs-fuse/src/sync.rs)