diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index a2fe2f5..0943c6f 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -230,6 +230,43 @@ enum Command { #[arg(long, default_value_t = 0)] offset: usize, }, + /// Create a photo album and print its uid. + NewAlbum { + /// Album name. + name: String, + }, + /// Rename a photo album. + RenameAlbum { + /// Album node uid in `volume~link` form (from `pdfs albums`). + uid: String, + /// New name. + name: String, + }, + /// Delete a photo album. Its photos stay in the timeline. + DeleteAlbum { + /// Album node uid in `volume~link` form (from `pdfs albums`). + uid: String, + /// Also delete photos that exist only in this album (saved from a + /// shared album); without it the server refuses to delete such an album. + #[arg(long)] + with_photos: bool, + }, + /// Add photos to an album. + AddToAlbum { + /// Album node uid in `volume~link` form (from `pdfs albums`). + uid: String, + /// Photo node uids (from `pdfs photos`). + #[arg(required = true)] + photos: Vec, + }, + /// Take photos out of an album. They stay in the timeline. + RemoveFromAlbum { + /// Album node uid in `volume~link` form (from `pdfs albums`). + uid: String, + /// Photo node uids (from `pdfs album `). + #[arg(required = true)] + photos: Vec, + }, /// Download a photo by uid (`volume~link`) and print its cached path. OpenPhoto { /// Photo node uid in `volume~link` form (from `pdfs photos`). @@ -732,6 +769,22 @@ fn main() -> Result<()> { Command::ImportStatus => cmd_import_status(), Command::CancelImport => cmd_cancel_import(), Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset), + Command::NewAlbum { name } => cmd_new_album(name), + Command::RenameAlbum { uid, name } => { + ok_or_bail(control_request(CtlRequest::RenameAlbum { uid, name })?) + } + Command::DeleteAlbum { uid, with_photos } => { + ok_or_bail(control_request(CtlRequest::DeleteAlbum { + uid, + delete_photos: with_photos, + })?) + } + Command::AddToAlbum { uid, photos } => { + cmd_album_change(CtlRequest::AddToAlbum { uid, photos }, "added") + } + Command::RemoveFromAlbum { uid, photos } => { + cmd_album_change(CtlRequest::RemoveFromAlbum { uid, photos }, "removed") + } Command::OpenPhoto { uid } => cmd_open_photo(uid), Command::Search { query, @@ -2068,6 +2121,42 @@ fn cmd_albums() -> Result<()> { Ok(()) } +fn cmd_new_album(name: String) -> Result<()> { + match control_request(CtlRequest::CreateAlbum { name })? { + CtlResponse::AlbumCreated { uid } => println!("{uid}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +/// Send an album add or remove and report it per photo; any failed photo +/// fails the command. +fn cmd_album_change(request: CtlRequest, verb: &str) -> Result<()> { + match control_request(request)? { + CtlResponse::AlbumChanged { changed, failed } => { + println!("{} {verb}", photo_count(changed.len())); + for failure in &failed { + eprintln!("{}: {}", failure.uid, failure.message); + } + if !failed.is_empty() { + bail!("{} failed", photo_count(failed.len())); + } + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn photo_count(n: usize) -> String { + if n == 1 { + "1 photo".to_string() + } else { + format!("{n} photos") + } +} + fn cmd_import_google_photos(archives: Vec, dry_run: bool, wait: bool) -> Result<()> { // The daemon opens the archives itself off the shared filesystem, so it gets // absolute paths — the same arrangement `pdfs upload` uses. diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 04ca1df..77ee3d7 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -94,6 +94,25 @@ pub enum Request { offset: usize, limit: usize, }, + /// Create an album named `name`. Replies with [`Response::AlbumCreated`]. + CreateAlbum { name: String }, + /// Rename the album `uid`. Replies with [`Response::Ok`]. + RenameAlbum { uid: String, name: String }, + /// Delete the album `uid`. Its photos stay in the timeline; with + /// `delete_photos` set, photos that exist only in the album (saved from a + /// shared album) are deleted with it, which the server otherwise refuses. + /// Replies with [`Response::Ok`]. + DeleteAlbum { + uid: String, + #[serde(default)] + delete_photos: bool, + }, + /// Add timeline photos to the album `uid`. Replies with + /// [`Response::AlbumChanged`]. + AddToAlbum { uid: String, photos: Vec }, + /// Take photos out of the album `uid`. They stay in the timeline: this is + /// not a trash. Replies with [`Response::AlbumChanged`]. + RemoveFromAlbum { uid: String, photos: Vec }, /// Fetch thumbnails for the given photo uids, downloading the ones not /// already cached (one batched round-trip) and replying with their on-disk /// paths. Keep the batch small — it is served on demand, as tiles scroll in. @@ -1745,6 +1764,15 @@ pub enum Response { trashed: Vec, failed: Vec, }, + /// The album a [`Request::CreateAlbum`] made. + AlbumCreated { uid: String }, + /// Outcome of a [`Request::AddToAlbum`] or [`Request::RemoveFromAlbum`], + /// per photo like [`Response::Trashed`]: `changed` photos were added or + /// removed, `failed` ones were not. + AlbumChanged { + changed: Vec, + failed: Vec, + }, /// A [`Request::FileThumbs`] generation is no longer current. This is /// retryable after reserving a fresh generation and must never be interpreted /// as a permanent "no thumbnail" verdict. @@ -2360,6 +2388,15 @@ mod tests { )); } + #[test] + fn delete_album_keeps_photos_unless_asked() { + let request: Request = serde_json::from_str(r#"{"DeleteAlbum":{"uid":"v~a"}}"#).unwrap(); + assert!(matches!( + request, + Request::DeleteAlbum { uid, delete_photos: false } if uid == "v~a" + )); + } + /// The trash requests carry uids rather than paths; they must survive the same /// line-delimited round-trip, since a mangled uid would restore or destroy the /// wrong node. diff --git a/crates/pdfs-fuse/src/albums.rs b/crates/pdfs-fuse/src/albums.rs index 3845e99..f301f5c 100644 --- a/crates/pdfs-fuse/src/albums.rs +++ b/crates/pdfs-fuse/src/albums.rs @@ -221,6 +221,84 @@ impl Core { Ok(()) } + /// Create an album and return its uid. The listing is refreshed before + /// answering, so the next [`Core::albums`] already shows it. + pub(crate) fn create_album(&self, name: &str) -> CoreResult { + let uid = self + .rt + .block_on(self.photos().create_album(name)) + .map_err(|e| CoreError::from_api(&e, "create album"))?; + self.after_album_change(None); + Ok(uid.to_string()) + } + + pub(crate) fn rename_album(&self, album: &NodeUid, name: &str) -> CoreResult<()> { + self.rt + .block_on(self.photos().rename_album(album, name)) + .map_err(|e| CoreError::from_api(&e, "rename album"))?; + self.after_album_change(None); + Ok(()) + } + + /// Delete an album. Its photos stay in the timeline unless `delete_photos`, + /// which only matters for photos that exist in the album alone. + pub(crate) fn delete_album(&self, album: &NodeUid, delete_photos: bool) -> CoreResult<()> { + self.rt + .block_on(self.photos().delete_album(album, delete_photos)) + .map_err(|e| CoreError::from_api(&e, "delete album"))?; + let key = album.to_string(); + if let Err(error) = self.db.album_photos_replace(&key, &[]) { + warn!(%album, %error, "a deleted album's photos could not be dropped"); + } + self.after_album_change(None); + Ok(()) + } + + /// Add photos to an album, per photo: `(added, failed)`. + pub(crate) fn add_to_album( + &self, + album: &NodeUid, + photos: &[NodeUid], + ) -> CoreResult { + let outcomes = self + .rt + .block_on(self.photos().add_photos_to_album(album, photos)) + .map_err(|e| CoreError::from_api(&e, "add to album"))?; + self.after_album_change(Some(album)); + Ok(album_outcome(outcomes)) + } + + /// Take photos out of an album, per photo: `(removed, failed)`. The photos + /// stay in the timeline. + pub(crate) fn remove_from_album( + &self, + album: &NodeUid, + photos: &[NodeUid], + ) -> CoreResult { + let outcomes = self + .rt + .block_on(self.photos().remove_photos_from_album(album, photos)) + .map_err(|e| CoreError::from_api(&e, "remove from album"))?; + self.after_album_change(Some(album)); + Ok(album_outcome(outcomes)) + } + + /// Bring the persisted listing, and `album`'s contents when given, up to + /// date after a change made here, so the reply is followed by a listing + /// that shows it. A failed refresh only leaves them stale: the change + /// itself went through. + fn after_album_change(&self, album: Option<&NodeUid>) { + self.invalidate_albums(); + if let Err(error) = self.rt.block_on(self.refresh_albums()) { + warn!(%error, "album listing refresh after a change failed"); + } + if let Some(album) = album + && let Err(error) = self.rt.block_on(self.refresh_album(album)) + { + warn!(%album, %error, "album refresh after a change failed"); + } + } + /// Drop the album listing's and every album's freshness stamp, so the next /// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change /// to the timeline can just as easily be a change to an album. @@ -230,6 +308,20 @@ impl Core { } } +/// Photos an album change applied to, and the ones it failed for with why. +pub(crate) type AlbumOutcome = (Vec, Vec<(String, String)>); + +fn album_outcome(outcomes: Vec) -> AlbumOutcome { + let (done, failed) = pdfs_core::batch::split(outcomes); + ( + done.iter().map(|uid| uid.to_string()).collect(), + failed + .into_iter() + .map(|(uid, error)| (uid.to_string(), error.to_string())) + .collect(), + ) +} + /// Project a persisted album into the wire item a front-end paints. fn album_info(album: StoredAlbum) -> AlbumInfo { AlbumInfo { diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 809e3bf..19c2015 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -46,6 +46,32 @@ const MAX_ROW_LIMIT: usize = 1000; fn clamp_limit(limit: usize) -> usize { limit.min(MAX_ROW_LIMIT) } +/// Answer an album add or remove: parse the uids, run `change`, and report +/// per photo. +fn album_change( + uid: &str, + photos: &[String], + change: impl FnOnce(&NodeUid, &[NodeUid]) -> CoreResult, +) -> CtlResponse { + let Some(album) = parse_uid(uid) else { + return CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))); + }; + let photos = match Core::parse_uids(photos) { + Ok(photos) => photos, + Err(e) => return CtlResponse::error(e), + }; + match change(&album, &photos) { + Ok((changed, failed)) => CtlResponse::AlbumChanged { + changed, + failed: failed + .into_iter() + .map(|(uid, message)| TrashFailure { uid, message }) + .collect(), + }, + Err(e) => CtlResponse::error(e), + } +} + static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0); struct ControlHandlerPermit; @@ -421,6 +447,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))), }, + Ok(CtlRequest::CreateAlbum { name }) => match core.create_album(&name) { + Ok(uid) => CtlResponse::AlbumCreated { uid }, + Err(e) => CtlResponse::error(e), + }, + Ok(CtlRequest::RenameAlbum { uid, name }) => match parse_uid(&uid) { + Some(album) => match core.rename_album(&album, &name) { + Ok(()) => CtlResponse::Ok { + message: format!("renamed album to {name}"), + }, + Err(e) => CtlResponse::error(e), + }, + None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))), + }, + Ok(CtlRequest::DeleteAlbum { uid, delete_photos }) => match parse_uid(&uid) { + Some(album) => match core.delete_album(&album, delete_photos) { + Ok(()) => CtlResponse::Ok { + message: "album deleted".to_string(), + }, + Err(e) => CtlResponse::error(e), + }, + None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))), + }, + Ok(CtlRequest::AddToAlbum { uid, photos }) => { + album_change(&uid, &photos, |album, photos| { + core.add_to_album(album, photos) + }) + } + Ok(CtlRequest::RemoveFromAlbum { uid, photos }) => { + album_change(&uid, &photos, |album, photos| { + core.remove_from_album(album, photos) + }) + } Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) { Ok(months) => CtlResponse::PhotoMonths { months: months diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 56100b8..d1c49a3 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -475,6 +475,8 @@ fn build_window(app: &adw::Application) { view_switch: gallery_widgets.view_switch.clone(), albums_loading: Cell::new(false), album: RefCell::new(None), + timeline_stale: Cell::new(false), + album_list: RefCell::new(Vec::new()), back: gallery_widgets.back.clone(), filters: gallery_widgets.filters.clone(), kind: Cell::new(None), @@ -506,6 +508,7 @@ fn build_window(app: &adw::Application) { select_bar: gallery_widgets.select_bar.clone(), select_label: gallery_widgets.select_label.clone(), select_trash: gallery_widgets.select_trash.clone(), + select_album: gallery_widgets.select_album.clone(), }, shared: SharedState { content: shared_widgets.content.clone(), @@ -1143,6 +1146,12 @@ fn install_window_actions(ui: &Rc, window: &adw::ApplicationWindow) { show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout")); window.add_action(&show_import); + // The Albums view's New Album button. + let new_album = gio::SimpleAction::new("new-album", None); + let ui_album = ui.clone(); + new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new())); + window.add_action(&new_album); + let shortcuts = gio::SimpleAction::new("shortcuts", None); let win = window.clone(); shortcuts.connect_activate(move |_, _| show_shortcuts(&win)); diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs index 94df434..6bef465 100644 --- a/crates/pdfs-gui/src/app/pages/albums.rs +++ b/crates/pdfs-gui/src/app/pages/albums.rs @@ -16,23 +16,34 @@ const COVER_EDGE: i32 = 200; /// Show the Albums grid and (re)load it. Called when the Albums toggle goes on. pub(crate) fn show_albums(ui: &Rc) { - close_album(ui); - ui.gallery.content.set_visible_child_name("albums"); + show_album_grid(ui); load_albums(ui); } +/// Switch to the album grid as it was last filled, keeping its scroll +/// position. The timeline's filters do not apply to albums, so they go. +fn show_album_grid(ui: &Rc) { + close_album(ui); + ui.gallery.filters.set_visible(false); + ui.gallery.content.set_visible_child_name("albums"); +} + /// Ask the daemon for the album listing and rebuild the grid. pub(crate) fn load_albums(ui: &Rc) { if ui.gallery.albums_loading.get() { return; } ui.gallery.albums_loading.set(true); - albums_status( - ui, - "view-grid-symbolic", - "Loading albums…", - "Reading your Proton Drive albums.", - ); + // A grid already on screen stays there while it refreshes, rather than + // blinking to a loading page and losing its scroll position. + if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") { + albums_status( + ui, + "view-grid-symbolic", + "Loading albums…", + "Reading your Proton Drive albums.", + ); + } let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums); let ui = ui.clone(); @@ -52,7 +63,7 @@ pub(crate) fn load_albums(ui: &Rc) { &ui, "view-grid-symbolic", "No albums", - "Albums you create in Proton Photos appear here.", + "Create one with New Album, or in Proton Photos.", ), Ok(Ok(Response::Albums { items, .. })) => { fill_albums(&ui, &items); @@ -80,15 +91,31 @@ pub(crate) fn load_albums(ui: &Rc) { /// Replace the grid's cards with `albums`, in the order the daemon gave them /// (newest activity first). fn fill_albums(ui: &Rc, albums: &[AlbumInfo]) { + let scroll = ui + .gallery + .albums + .ancestor(gtk4::ScrolledWindow::static_type()) + .and_downcast::(); + let position = scroll.as_ref().map(|s| s.vadjustment().value()); while let Some(child) = ui.gallery.albums.first_child() { ui.gallery.albums.remove(&child); } + *ui.gallery.album_list.borrow_mut() = albums.to_vec(); + if ui.gallery.album.borrow().is_none() { + ui.gallery + .title + .set_subtitle(&count_noun(albums.len(), "album", "albums")); + } for album in albums { ui.gallery.albums.append(&album_card(ui, album)); } // The covers are ordinary photos as far as the daemon is concerned, so the // batch that fills them is the gallery's own. schedule_thumbs(ui); + // A refresh rebuilds the cards; put the view back where the user was. + if let (Some(scroll), Some(position)) = (scroll, position) { + glib::idle_add_local_once(move || scroll.vadjustment().set_value(position)); + } } /// One album as a clickable card: its cover, its name, and how many photos it @@ -150,12 +177,354 @@ fn album_card(ui: &Rc, album: &AlbumInfo) -> gtk4::Button { want_cover(ui, uid, &picture); } + let context = gtk4::GestureClick::builder().button(3).build(); + let ui_context = ui.clone(); + let menu_album = album.clone(); + context.connect_pressed(move |gesture, _, x, y| { + gesture.set_state(gtk4::EventSequenceState::Claimed); + if let Some(anchor) = gesture.widget() { + show_album_menu(&ui_context, &menu_album, &anchor, x, y); + } + }); + button.add_controller(context); + let ui_open = ui.clone(); let album = album.clone(); button.connect_clicked(move |_| open_album(&ui_open, album.clone())); button } +/// The album card's menu. Albums shared with us belong to someone else, so +/// they only open. +fn show_album_menu(ui: &Rc, album: &AlbumInfo, anchor: >k4::Widget, x: f64, y: f64) { + let mut menu = ActionMenu::new(); + let (ui_c, album_c) = (ui.clone(), album.clone()); + menu.item("Open", move || open_album(&ui_c, album_c.clone())); + if !album.shared { + menu.section(); + let (ui_c, album_c) = (ui.clone(), album.clone()); + menu.item("Rename…", move || prompt_rename_album(&ui_c, &album_c)); + menu.section(); + let (ui_c, album_c) = (ui.clone(), album.clone()); + menu.item("Delete Album…", move || { + confirm_delete_album(&ui_c, &album_c) + }); + } + menu.popup_at(anchor, x, y); +} + +/// Ask for an album name. `on_name` gets the trimmed name; Create stays off +/// while the entry is blank. +fn prompt_album_name( + ui: &Rc, + heading: &str, + action: &str, + current: &str, + on_name: impl Fn(String) + 'static, +) { + let win = ui_window(ui); + let dialog = adw::AlertDialog::builder().heading(heading).build(); + let group = adw::PreferencesGroup::new(); + let row = adw::EntryRow::builder() + .title("Album name") + .activates_default(true) + .build(); + row.set_text(current); + group.add(&row); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("ok", action); + dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested); + dialog.set_response_enabled("ok", !current.trim().is_empty()); + dialog.set_default_response(Some("ok")); + dialog.set_close_response("cancel"); + let dialog_typed = dialog.clone(); + row.connect_changed(move |row| { + dialog_typed.set_response_enabled("ok", !row.text().trim().is_empty()); + }); + dialog.connect_response(None, move |_, resp| { + let name = row.text().trim().to_string(); + if resp == "ok" && !name.is_empty() { + on_name(name); + } + }); + dialog.present(win.as_ref()); +} + +/// Ask for a name and create an album, then add `photos` to it, if any. +pub(crate) fn prompt_new_album(ui: &Rc, photos: Vec) { + let ui_c = ui.clone(); + prompt_album_name(ui, "New Album", "Create", "", move |name| { + let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name }); + let ui = ui_c.clone(); + let photos = photos.clone(); + ui.busy_begin(); + glib::spawn_future_local(async move { + let reply = rx.recv().await; + ui.busy_end(); + match reply { + Ok(Ok(Response::AlbumCreated { uid })) => { + if photos.is_empty() { + toast(&ui, "Album created"); + reload_album_grid(&ui); + } else { + add_to_album(&ui, uid, photos); + } + } + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't create the album", &message, kind) + } + _ => toast_error( + &ui, + "Couldn't create the album", + "The mount service didn't respond.", + ), + } + }); + }); +} + +fn prompt_rename_album(ui: &Rc, album: &AlbumInfo) { + let (ui_c, uid) = (ui.clone(), album.uid.clone()); + prompt_album_name(ui, "Rename Album", "Rename", &album.name, move |name| { + run_album_request( + &ui_c, + Request::RenameAlbum { + uid: uid.clone(), + name, + }, + "Album renamed", + "Couldn't rename the album", + ); + }); +} + +fn confirm_delete_album(ui: &Rc, album: &AlbumInfo) { + let Some(win) = ui_window(ui) else { return }; + let (ui_c, uid) = (ui.clone(), album.uid.clone()); + confirm_destructive( + &win, + "Delete Album?", + &format!( + "“{}” is deleted. Its photos stay in your timeline.", + album.name + ), + "Delete", + move || { + run_album_request( + &ui_c, + Request::DeleteAlbum { + uid: uid.clone(), + delete_photos: false, + }, + "Album deleted", + "Couldn't delete the album", + ) + }, + ); +} + +/// Send an album rename or delete and refresh the grid once it lands. +fn run_album_request(ui: &Rc, request: Request, done: &'static str, failed: &'static str) { + let rx = spawn_request(ui.dirs.control_socket(), request); + let ui = ui.clone(); + ui.busy_begin(); + glib::spawn_future_local(async move { + let reply = rx.recv().await; + ui.busy_end(); + match reply { + Ok(Ok(Response::Ok { .. })) => { + toast(&ui, done); + reload_album_grid(&ui); + } + Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), + _ => toast_error(&ui, failed, "The mount service didn't respond."), + } + }); +} + +/// Refill the album grid if it is on screen; otherwise it reloads when shown. +fn reload_album_grid(ui: &Rc) { + if ui.gallery.content.visible_child_name().as_deref() == Some("albums") { + load_albums(ui); + } +} + +/// Offer our own albums to add `photos` to, plus a new one. +pub(crate) fn prompt_add_to_album(ui: &Rc, photos: Vec) { + let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let albums: Vec = match rx.recv().await { + Ok(Ok(Response::Albums { items, .. })) => { + items.into_iter().filter(|album| !album.shared).collect() + } + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't load albums", &message, kind); + return; + } + _ => { + toast_error( + &ui, + "Couldn't load albums", + "The mount service didn't respond.", + ); + return; + } + }; + if albums.is_empty() { + prompt_new_album(&ui, photos); + return; + } + + let list = gtk4::ListBox::builder() + .selection_mode(gtk4::SelectionMode::None) + .build(); + list.add_css_class("boxed-list"); + for album in &albums { + let row = adw::ActionRow::builder() + .title(glib::markup_escape_text(&album.name)) + .subtitle(album_subtitle(album)) + .activatable(true) + .build(); + list.append(&row); + } + let scroll = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .propagate_natural_height(true) + .max_content_height(360) + .child(&list) + .build(); + let dialog = adw::AlertDialog::builder() + .heading("Add to Album") + .body(match photos.len() { + 1 => "Choose an album for this photo.".to_string(), + n => format!("Choose an album for {n} photos."), + }) + .extra_child(&scroll) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("new", "New Album…"); + dialog.set_close_response("cancel"); + + let (ui_c, dialog_c, photos_c) = (ui.clone(), dialog.clone(), photos.clone()); + list.connect_row_activated(move |_, row| { + if let Some(album) = albums.get(row.index().max(0) as usize) { + dialog_c.close(); + add_to_album(&ui_c, album.uid.clone(), photos_c.clone()); + } + }); + let ui_c = ui.clone(); + dialog.connect_response(Some("new"), move |_, _| { + prompt_new_album(&ui_c, photos.clone()) + }); + dialog.present(ui_window(&ui).as_ref()); + }); +} + +fn add_to_album(ui: &Rc, album: String, photos: Vec) { + let rx = spawn_request( + ui.dirs.control_socket(), + Request::AddToAlbum { uid: album, photos }, + ); + let ui = ui.clone(); + ui.busy_begin(); + glib::spawn_future_local(async move { + let reply = rx.recv().await; + ui.busy_end(); + match reply { + Ok(Ok(Response::AlbumChanged { changed, failed })) => { + if let Some(failure) = failed.first() { + toast_error(&ui, "Some photos couldn't be added", &failure.message); + } else { + toast( + &ui, + &format!( + "Added {} to the album", + count_noun(changed.len(), "photo", "photos") + ), + ); + } + reload_album_grid(&ui); + } + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't add to the album", &message, kind) + } + _ => toast_error( + &ui, + "Couldn't add to the album", + "The mount service didn't respond.", + ), + } + }); +} + +/// Take photos out of the open album. They leave the view at once and come +/// back if the server refuses; the timeline keeps them either way. +pub(crate) fn remove_from_album(ui: &Rc, album: &AlbumInfo, photos: Vec) { + let removed = remove_photos(ui, &photos); + set_selection_mode(ui, false); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::RemoveFromAlbum { + uid: album.uid.clone(), + photos, + }, + ); + let ui = ui.clone(); + ui.busy_begin(); + glib::spawn_future_local(async move { + let reply = rx.recv().await; + ui.busy_end(); + match reply { + Ok(Ok(Response::AlbumChanged { changed, failed })) => { + if !failed.is_empty() { + let kept: Vec = removed + .iter() + .filter(|photo| failed.iter().any(|f| f.uid == photo.uid)) + .cloned() + .collect(); + restore_photos(&ui, kept); + toast_error( + &ui, + "Some photos couldn't be removed from the album", + &failed[0].message, + ); + } + if changed.is_empty() { + return; + } + // The open album's count, as the header shows it. + let mut open = ui.gallery.album.borrow_mut(); + if let Some(album) = open.as_mut() { + album.photo_count = album.photo_count.saturating_sub(changed.len()); + ui.gallery.title.set_subtitle(&album_subtitle(album)); + } + drop(open); + toast( + &ui, + &format!( + "Removed {} from the album", + count_noun(changed.len(), "photo", "photos") + ), + ); + } + Ok(Ok(Response::Error { message, kind })) => { + restore_photos(&ui, removed); + toast_failure(&ui, "Couldn't remove from the album", &message, kind) + } + _ => { + restore_photos(&ui, removed); + toast_error( + &ui, + "Couldn't remove from the album", + "The mount service didn't respond.", + ) + } + } + }); +} + /// "12 photos", plus where the album came from when it isn't ours. fn album_subtitle(album: &AlbumInfo) -> String { let photos = match album.photo_count { @@ -195,6 +564,7 @@ pub(crate) fn open_album(ui: &Rc, album: AlbumInfo) { ui.gallery.title.set_title(&album.name); ui.gallery.title.set_subtitle(&album_subtitle(&album)); *ui.gallery.album.borrow_mut() = Some(album); + ui.gallery.timeline_stale.set(true); // An album page carries no kind or date filter, and Upload targets the // timeline rather than an album — hide those rather than offer controls that @@ -219,7 +589,6 @@ pub(crate) fn close_album(ui: &Rc) { return; } ui.gallery.title.set_title("Photos"); - ui.gallery.filters.set_visible(true); ui.gallery.view_switch.set_visible(true); ui.gallery.upload.set_visible(true); ui.gallery.back.set_visible(false); @@ -250,15 +619,28 @@ pub(crate) fn wire_albums(ui: &Rc) { if !btn.is_active() { return; } - // The timeline is reloaded rather than restored, because an open album - // left the model holding its own photos. close_album(&ui_photos); + ui_photos.gallery.filters.set_visible(true); ui_photos.gallery.content.set_visible_child_name("timeline"); - load_gallery(&ui_photos, false); + // The timeline comes back as it was left unless an album took over the + // model since; then it has to be reloaded. + if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0 + { + load_gallery(&ui_photos, false); + } else { + update_gallery_subtitle(&ui_photos); + } }); + // Back returns to the grid the album was opened from, scrolled where it + // was, instead of reloading it. let ui_back = ui.clone(); ui.gallery.back.clone().connect_clicked(move |_| { - show_albums(&ui_back); + show_album_grid(&ui_back); + let count = ui_back.gallery.album_list.borrow().len(); + ui_back + .gallery + .title + .set_subtitle(&count_noun(count, "album", "albums")); }); } diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 906f59e..0829df5 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -63,6 +63,12 @@ pub(crate) struct GalleryState { /// Leaves an open album for the grid it was opened from. Visible only while /// an album is open. pub(crate) back: gtk4::Button, + /// Set when an album replaced the timeline in [`Self::model`], so going + /// back to the timeline has to reload it; otherwise the timeline is shown + /// as it was left, scroll position included. + pub(crate) timeline_stale: Cell, + /// The albums the grid shows, as last listed. + pub(crate) album_list: RefCell>, /// The kind toggles and the date jump, hidden while an album is open — an /// album page is served whole, not filtered. pub(crate) filters: gtk4::Box, @@ -149,6 +155,7 @@ pub(crate) struct GalleryState { pub(crate) select_bar: gtk4::Revealer, pub(crate) select_label: gtk4::Label, pub(crate) select_trash: gtk4::Button, + pub(crate) select_album: gtk4::Button, } /// How many photos to pull per [`Request::PhotosTimeline`] page. @@ -302,6 +309,7 @@ pub(crate) struct GalleryWidgets { pub(crate) select_bar: gtk4::Revealer, pub(crate) select_label: gtk4::Label, pub(crate) select_trash: gtk4::Button, + pub(crate) select_album: gtk4::Button, pub(crate) select_done: gtk4::Button, /// The Albums grid, its own status page and the stack between them, plus the /// Photos/Albums switcher and the back button out of an album. @@ -454,6 +462,11 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { .sensitive(false) .build(); select_trash.add_css_class("destructive-action"); + let select_album = gtk4::Button::builder() + .label("Add to Album…") + .valign(gtk4::Align::Center) + .sensitive(false) + .build(); let select_done = gtk4::Button::builder() .icon_name("window-close-symbolic") .tooltip_text("Leave selection (Esc)") @@ -465,6 +478,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { select_box.add_css_class("toolbar"); select_box.add_css_class("bulk-bar"); select_box.append(&select_label); + select_box.append(&select_album); select_box.append(&select_trash); select_box.append(&select_done); let select_bar = gtk4::Revealer::builder() @@ -574,13 +588,23 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { albums_stack.set_vexpand(true); albums_stack.add_named(&albums_scroll, Some("grid")); albums_stack.add_named(&albums_status, Some("status")); + let new_album = gtk4::Button::builder() + .icon_name("list-add-symbolic") + .label("New Album…") + .halign(gtk4::Align::Start) + .action_name("win.new-album") + .build(); + new_album.add_css_class("pill"); + let albums_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + albums_page.append(&new_album); + albums_page.append(&albums_stack); let content = gtk4::Stack::new(); content.set_vexpand(true); content.set_transition_type(gtk4::StackTransitionType::Crossfade); content.add_named(&timeline, Some("timeline")); content.add_named(&status, Some("status")); - content.add_named(&albums_stack, Some("albums")); + content.add_named(&albums_page, Some("albums")); let import_banner = adw::Banner::builder() .title("Importing from Google Photos…") @@ -630,6 +654,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { select_bar, select_label, select_trash, + select_album, select_done, tabs, favorites_btn, @@ -932,6 +957,11 @@ pub(crate) fn wire_gallery( set_selection_mode(&ui_select, btn.is_active()); }); let ui_trash = ui.clone(); + let ui_album = ui.clone(); + ui.gallery.select_album.clone().connect_clicked(move |_| { + let uids: Vec = ui_album.gallery.selected.borrow().iter().cloned().collect(); + prompt_add_to_album(&ui_album, uids); + }); ui.gallery.select_trash.clone().connect_clicked(move |_| { delete_selected(&ui_trash); }); @@ -1426,6 +1456,7 @@ fn sync_selection_bar(ui: &Rc) { n => format!("{n} selected"), }); ui.gallery.select_trash.set_sensitive(count > 0); + ui.gallery.select_album.set_sensitive(count > 0); ui.gallery .select_bar .set_reveal_child(ui.gallery.selecting.get()); @@ -1555,7 +1586,7 @@ pub(crate) fn trash_photos(ui: &Rc, uids: Vec) { /// Take photos out of the loaded model, returning them so a failure can put them /// back. -fn remove_photos(ui: &Rc, uids: &[String]) -> Vec { +pub(crate) fn remove_photos(ui: &Rc, uids: &[String]) -> Vec { let model = &ui.gallery.model; let mut removed = Vec::new(); let mut index = 0; @@ -1577,7 +1608,7 @@ fn remove_photos(ui: &Rc, uids: &[String]) -> Vec { } /// Put photos back into the loaded model, at their place in the timeline. -fn restore_photos(ui: &Rc, photos: Vec) { +pub(crate) fn restore_photos(ui: &Rc, photos: Vec) { let model = &ui.gallery.model; for photo in photos { // The timeline is newest first, so a photo belongs before the first @@ -2001,7 +2032,7 @@ fn clone_row(row: &GalleryRow) -> GalleryRow { } /// "1,204 photos" under the page title. -fn update_gallery_subtitle(ui: &Rc) { +pub(crate) fn update_gallery_subtitle(ui: &Rc) { let loaded = ui.gallery.model.n_items() as usize; if loaded == 0 { ui.gallery.title.set_subtitle(""); @@ -2204,6 +2235,18 @@ fn show_photo_menu(ui: &Rc, photo: &PhotoItem, anchor: >k4::Button, x: f64 }); menu.section(); let (ui_c, uid) = (ui.clone(), photo.uid.clone()); + menu.item("Add to Album…", move || { + prompt_add_to_album(&ui_c, vec![uid.clone()]) + }); + let open_album = ui.gallery.album.borrow().clone(); + if let Some(album) = open_album.filter(|album| !album.shared) { + let (ui_c, uid) = (ui.clone(), photo.uid.clone()); + menu.item("Remove from Album", move || { + remove_from_album(&ui_c, &album, vec![uid.clone()]) + }); + } + menu.section(); + let (ui_c, uid) = (ui.clone(), photo.uid.clone()); menu.item("Move to Trash…", move || { confirm_trash_photos(&ui_c, vec![uid.clone()]) }); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e719afd..f0e9ed9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -83,6 +83,16 @@ Schema: **31** (unchanged). In the list view a Location column shows which folder each hit is in. Pinned files listed by the search prompt and `pdfs-query` show whether they are on disk, instead of always showing them as not downloaded. +- **Album management.** The Albums view has a New Album button, and an album's right-click menu + offers Rename… and Delete Album…. Deleting an album keeps its photos in the timeline. A photo's + menu and the selection bar offer Add to Album…, and inside an album, Remove from Album takes a + photo out without trashing it. Albums shared with you only open. The CLI gains `pdfs new-album`, + `rename-album`, `delete-album`, `add-to-album` and `remove-from-album`. This needs + proton-drive-rs 0.6.6. +- **Albums view fixes.** The timeline's filter row no longer shows over the album grid. The header + counts albums. Back from an album returns to the grid where you left it instead of reloading it, + and going from Albums back to Timeline keeps the timeline's scroll position unless an album was + opened in between. - **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the `pdfs favorite` command.