diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 39ded72..1e5c041 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -68,6 +68,11 @@ pub enum Request { #[serde(default)] favorites: bool, }, + /// Every photo of one photo's group — the JPEG and the RAW of one shot, a + /// live photo and its clip. Replies with [`Response::Photos`], holding one + /// item per file in server order; a photo that stands alone comes back as a + /// single item. The lightbox uses it to switch between a shot's files. + PhotoGroup { uid: String }, /// 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. /// `kind` scopes the counts to one tab when set. @@ -99,6 +104,10 @@ pub enum Request { /// part of the FUSE mount: the gallery knows a photo only by its uid, and /// [`Request::Delete`] has no path to take for it. The nodes land in Proton /// trash, so this stays recoverable from the Trash page. + /// + /// A photo that belongs to a group takes the whole group with it: trashing + /// the JPEG of a shot and leaving its RAW behind is not what anyone means by + /// deleting that photo. TrashNodes { uids: Vec }, /// Fetch thumbnails for ordinary Drive image files shown outside the Photos /// timeline. The modification time is the cache validity tag, so replacing @@ -1956,6 +1965,32 @@ mod tests { assert!(items[0].name.is_empty()); } + /// The lightbox asks for one shot's files by the uid of the tile that was + /// clicked, and gets them back in the shape the gallery already paints. + #[test] + fn photo_group_requests_roundtrip() { + let request = Request::PhotoGroup { + uid: "volume~first".into(), + }; + let line = serde_json::to_string(&request).unwrap(); + assert_eq!(line, r#"{"PhotoGroup":{"uid":"volume~first"}}"#); + let decoded: Request = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + } + + /// The group fields are new, so a page from a daemon that predates them has + /// to keep painting: one file, nothing to badge. + #[test] + fn an_older_daemons_photo_has_no_group() { + let item: PhotoItem = serde_json::from_str( + r#"{"uid":"volume~first","capture_time":1700000000,"thumb_path":null, + "name":"a.jpg","ratio":1.5,"no_thumb":false}"#, + ) + .unwrap(); + assert_eq!(item.group_size, 0, "absent reads the same as one file"); + assert!(!item.has_raw); + } + /// The gallery trashes by uid, and reports per-uid outcomes back: a partial /// failure has to survive the wire so the GUI can put those tiles back. #[test] diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 2a36cd0..27a3be5 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -293,6 +293,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) { + Ok(items) => CtlResponse::Photos { + available: true, + items, + counts: None, + }, + 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 { diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index f44eca5..4c3fbf9 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -505,6 +505,41 @@ impl Core { Ok(()) } + /// Every file of one photo's group, in server order, as wire items. A photo + /// the timeline does not hold comes back empty, which the lightbox reads as + /// "nothing to switch between". + pub(crate) fn photo_group(&self, uid: &str) -> CoreResult> { + let members = self.db.photos_group(uid).map_err(CoreError::from)?; + Ok(members + .into_iter() + .map(|photo| self.photo_item(photo)) + .collect()) + } + + /// Every uid asked for, plus the other files of any group they belong to, + /// each once. A uid the timeline does not hold (an album photo on someone + /// else's volume) is passed through unchanged. + fn expand_photo_groups(&self, uids: &[NodeUid]) -> Vec { + let mut out: Vec = Vec::with_capacity(uids.len()); + let mut seen: HashSet = HashSet::new(); + for uid in uids { + let members = self.db.photos_group(&uid.to_string()).unwrap_or_default(); + let mut group: Vec = members + .iter() + .filter_map(|photo| parse_uid(&photo.uid)) + .collect(); + if group.is_empty() { + group.push(uid.clone()); + } + for member in group { + if seen.insert(member.to_string()) { + out.push(member); + } + } + } + out + } + /// Trash photos by uid, and forget them locally. /// /// The gallery is not part of the FUSE mount, so this is the photos-side @@ -515,10 +550,16 @@ impl Core { /// The server answers per node, and so does this: a batch where one photo /// fails still removes the ones that went, and the caller is told which is /// which. Only what actually left the server is forgotten locally. + /// + /// A photo that is one file of a group takes its whole group with it. The + /// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to + /// delete the shot — leaving the RAW behind would put the photo back on the + /// Raw tab and nowhere else. pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult { if uids.is_empty() { return Ok((Vec::new(), Vec::new())); } + let uids = &self.expand_photo_groups(uids); for uid in uids { self.require_uid_writable(uid) .map_err(|errno| self.errno_error(errno, "trash access"))?; diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 93958c2..6acb734 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -265,6 +265,7 @@ fn load_proton_theme() { .photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\ .photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\ .photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\ + .photo-group-badge {{ font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }}\n\ .photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\ .album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\ .album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\ @@ -1287,6 +1288,59 @@ mod tests { assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty()); } + /// One file of a shot, for the group-switch tests. + fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem { + PhotoItem { + uid: uid.into(), + capture_time: 0, + thumb_path: None, + name: name.map(str::to_string), + ratio: None, + no_thumb: false, + kind, + favorite: false, + group_size: 2, + has_raw: kind == PhotoKind::Raw, + } + } + + /// The lightbox steps through a shot's files and comes back round, so a + /// burst of three is as reachable as a RAW+JPEG pair. + #[test] + fn the_group_switch_cycles_through_every_file() { + let members = [ + member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo), + member("raw", Some("IMG_1.CR2"), PhotoKind::Raw), + member("clip", None, PhotoKind::Video), + ]; + let next = |uid| next_group_member(&members, uid).map(|item| item.uid.as_str()); + assert_eq!(next("jpeg"), Some("raw")); + assert_eq!(next("raw"), Some("clip")); + assert_eq!(next("clip"), Some("jpeg")); + // A photo that is not in the group leaves the button alone rather than + // jumping somewhere arbitrary. + assert_eq!(next("elsewhere"), None); + assert!(next_group_member(&[], "jpeg").is_none()); + } + + /// The switch names what it will show; a file whose name the daemon has not + /// resolved still gets a sentence rather than a blank. + #[test] + fn a_group_member_without_a_name_is_still_described() { + assert_eq!( + member_label(&member("raw", Some("IMG_1.CR2"), PhotoKind::Raw)), + "IMG_1.CR2" + ); + assert_eq!( + member_label(&member("raw", None, PhotoKind::Raw)), + "the raw file" + ); + assert_eq!( + member_label(&member("clip", Some(""), PhotoKind::Video)), + "the video" + ); + } + #[test] fn a_window_narrower_than_one_photo_still_lays_it_out() { // A tile of at least one px, rather than a zero-width widget or a diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index df7e09a..b61efe0 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -24,6 +24,11 @@ pub(crate) struct Viewer { /// 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, + /// The button that switches between the files of one shot, and the files it + /// switches between (the JPEG and the RAW of a photo, a live photo and its + /// clip). Empty, and the button hidden, for a photo stored as one file. + pub(crate) group_btn: gtk4::Button, + pub(crate) group: RefCell>, /// uid of the photo currently on screen. pub(crate) uid: RefCell, /// On-disk path of the full-size photo, once it has been downloaded. @@ -81,6 +86,59 @@ fn set_gallery_favorite(ui: &Rc, uid: &str, favorite: bool) { /// 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. +/// Ask the daemon which files this shot was stored as, and offer a switch when +/// there is more than one. The grid shows such a shot as a single tile, so the +/// lightbox is the only place the RAW is reachable. +fn load_photo_group(ui: &Rc, viewer: &Rc, uid: &str) { + viewer.group.borrow_mut().clear(); + viewer.group_btn.set_visible(false); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::PhotoGroup { + uid: uid.to_string(), + }, + ); + let viewer = viewer.clone(); + let uid = uid.to_string(); + glib::spawn_future_local(async move { + let Ok(Ok(Response::Photos { items, .. })) = rx.recv().await else { + return; + }; + // A late reply for a photo the user has already left must not relabel + // the button under the one now on screen. + if *viewer.uid.borrow() != uid || items.len() < 2 { + return; + } + if let Some(next) = next_group_member(&items, &uid) { + viewer + .group_btn + .set_tooltip_text(Some(&format!("Show {}", member_label(next)))); + } + *viewer.group.borrow_mut() = items; + viewer.group_btn.set_visible(true); + }); +} + +/// The member after `uid`, wrapping round — the button steps through a shot's +/// files rather than toggling two of them, so a burst of three works too. +pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Option<&'a PhotoItem> { + let at = members.iter().position(|item| item.uid == uid)?; + members.get((at + 1) % members.len()) +} + +/// What to call one file of a shot: its name, or its kind when the daemon has +/// not resolved a name for it. +pub(crate) fn member_label(item: &PhotoItem) -> String { + match item.name.as_deref() { + Some(name) if !name.is_empty() => name.to_string(), + _ => match item.kind { + PhotoKind::Raw => "the raw file".to_string(), + PhotoKind::Video => "the video".to_string(), + PhotoKind::Photo => "the photo".to_string(), + }, + } +} + pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { viewer.spinner.set_visible(true); viewer.spinner.start(); @@ -96,6 +154,7 @@ pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { .and_downcast::() .is_some_and(|boxed| boxed.borrow::().favorite); show_favorite(viewer, favorite); + load_photo_group(ui, viewer, &uid); // The thumbnail the gallery already decoded stands in for the full photo // while it downloads: blurry for a moment beats black for a second. @@ -503,6 +562,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { favorite_btn.add_css_class("flat"); favorite_btn.add_css_class("viewer-action-btn"); + // Only shown for a shot stored as more than one file; `load_photo_group` + // decides that per photo. + let group_btn = action("view-paged-symbolic", "Other files of this photo"); + group_btn.set_visible(false); + let download_btn = action("document-save-symbolic", "Save a copy…"); let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)"); let open_ext_btn = action("document-open-symbolic", "Open with another app"); @@ -514,6 +578,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(&group_btn); top_bar.append(&favorite_btn); top_bar.append(&info_toggle); top_bar.append(&download_btn); @@ -616,6 +681,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { info_map: info_map.clone(), favorite: favorite_btn.clone(), favorite_suppress: Cell::new(false), + group_btn: group_btn.clone(), + group: RefCell::new(Vec::new()), coords: RefCell::new(None), uid: RefCell::new(initial_uid.clone()), path: RefCell::new(None), @@ -709,6 +776,18 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { trash_photos(&ui_delete, vec![uid]); }); + let ui_group = ui.clone(); + let viewer_group = viewer.clone(); + group_btn.connect_clicked(move |_| { + let uid = viewer_group.uid.borrow().clone(); + let next = + next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone()); + if let Some(next) = next { + *viewer_group.uid.borrow_mut() = next.clone(); + load_photo(&ui_group, &viewer_group, next); + } + }); + 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 e85c420..540744a 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -1283,6 +1283,24 @@ pub(crate) fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { overlay.add_overlay(&badge); } + // A shot stored as more than one file says so, in the corner the caption + // does not use. "RAW" is the useful word when one of the members is a raw + // file — that is what the person wants to find — and a plain count covers + // the rest (a live photo, a burst). + if tile.photo.has_raw || tile.photo.group_size > 1 { + let badge = gtk4::Label::builder() + .label(if tile.photo.has_raw { + "RAW".to_string() + } else { + format!("{}", tile.photo.group_size) + }) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Start) + .build(); + badge.add_css_class("photo-group-badge"); + overlay.add_overlay(&badge); + } + // Selection mode marks every tile, picked or not: a check that only appears // once a photo is chosen leaves the user guessing what else is clickable. if tile.selecting { @@ -1404,17 +1422,41 @@ fn sync_selection_bar(ui: &Rc) { } /// Confirm, then move every selected photo to Proton trash. +/// How many files the selected tiles stand for. A tile whose photo the model no +/// longer holds counts as one, which is what it looks like on screen. +fn selected_file_count(ui: &Rc, uids: &[String]) -> usize { + let wanted: HashSet<&str> = uids.iter().map(String::as_str).collect(); + let mut files = 0; + for idx in 0..ui.gallery.model.n_items() { + let Some(boxed) = ui.gallery.model.item(idx).and_downcast::() else { + continue; + }; + let photo = boxed.borrow::(); + if wanted.contains(photo.uid.as_str()) { + files += photo.group_size.max(1) as usize; + } + } + files.max(uids.len()) +} + pub(crate) fn delete_selected(ui: &Rc) { let uids: Vec = ui.gallery.selected.borrow().iter().cloned().collect(); if uids.is_empty() { return; } + // A selected tile can stand for more than one file — a shot kept as RAW and + // JPEG — and all of them go. The dialog says so rather than letting the user + // find out from the trash. + let files = selected_file_count(ui, &uids); + let body = match (uids.len(), files) { + (1, 1) => "Move this photo to Trash?".to_string(), + (1, files) => format!("Move this photo to Trash? It is stored as {files} files."), + (n, files) if files == n => format!("Move {n} photos to Trash?"), + (n, files) => format!("Move {n} photos to Trash? They are stored as {files} files."), + }; let dialog = adw::AlertDialog::builder() .heading("Move to Trash") - .body(match uids.len() { - 1 => "Move this photo to Trash?".to_string(), - n => format!("Move {n} photos to Trash?"), - }) + .body(body) .build(); dialog.add_response("cancel", "Cancel"); dialog.add_response("trash", "Move to Trash"); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2b25a52..b6a7104 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,7 +11,8 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] -Schema: **29** (`trash.parent_uid`); SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.5**. +Schema: **30** (`photos.content_hash` / `main_uid` / `group_key`); SDK bumped to `proton-sdk` / +`proton-drive-rs` **0.6.5**. ### Changed - **The gallery lays photos out in justified rows.** Every tile is now its own photo's shape: @@ -40,6 +41,13 @@ Schema: **29** (`trash.parent_uid`); SDK bumped to `proton-sdk` / `proton-drive- not cancel the rest. ### Added +- **A RAW and its JPEG are one photo.** A camera that writes both put two tiles of the same shot + in the timeline; now it puts one, badged `RAW`, and the lightbox has a control that switches + between the files. The Raw tab still lists every raw file individually, because that is what + the tab is for. Grouping follows the server's own photo relation where it has one — which + also collapses live photos and bursts — and otherwise pairs files with the same name taken on + the same day where one is raw and the other is not. Deleting the tile deletes the whole shot, + and the confirmation says how many files that is. - **Delete from the gallery.** Select photos with the Select button, with Ctrl+click or with Shift+click, then Move to Trash — or press Delete in the lightbox. The tiles leave the grid immediately with an Undo toast, and the photos land in Proton's trash, where they stay diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3169713..0748249 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -152,6 +152,17 @@ The gallery lays each day out in justified rows (`justify_rows` / `plan_rows` / - **Delete is uid-addressed.** The photos volume is not in the FUSE mount, so the path-based `Request::Delete` cannot reach it. `Request::TrashNodes { uids }` trashes through the SDK and answers `Response::Trashed { trashed, failed }`; the daemon then drops those rows with `Db::photos_delete` (album membership included) so the gallery does not wait for a timeline refresh. The GUI removes the tiles optimistically with an Undo toast and puts back anything the server refused. - **Remote deletions arrive on their own.** The Drive volume and the photos volume have separate event streams, so `run_photos_event_sync` polls the photos volume with its own cursor (`photos_event_cursor` in the state table). Trash and delete events remove rows; anything else only invalidates freshness, because there is no inode space to converge on this volume. Together with a 60 s `TIMELINE_TTL` and a `RefreshScope::Photos` that now awaits the refresh, a photo deleted on a phone leaves the grid in about ten seconds. +### RAW + JPEG Grouping (Implemented) +**Files**: [`photos.rs`](../crates/pdfs-core/src/db/photos.rs), [`migrations.rs`](../crates/pdfs-core/src/db/migrations.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs) + +Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favourite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand. + +- **Precedence.** The server relation first (`main_photo_uid` and `related_photo_uids`, read from whichever end resolves — the two can land in different enrichment chunks); then same capture day, same case-insensitive name stem, and one member raw while the other is not; otherwise the photo is its own group. The union-find pass is over the timeline in memory, so it costs one pass per refresh rather than a query per photo. +- **Representative.** The non-raw member when there is one — a JPEG decodes in milliseconds and is what the person expects to see. `group_key` holds that member's uid, so "is this the tile" is `uid = group_key`, an index scan. +- **What is grouped and what is not.** `photos_page`, `photos_months` and `photos_counts` count groups, except on the Raw tab, which lists files. Album pages are never grouped: an album is a list someone made. The relation is learned-and-kept like `media_type`, so a refresh that could not resolve a node does not break a group up. +- **Deleting.** `Core::trash_photos` expands each uid to its group before trashing, because the grid shows the shot as one tile — leaving the RAW behind would put the photo back on the Raw tab and nowhere else. The GUI's confirmation names the file count. +- **The other files.** `Request::PhotoGroup { uid }` answers with the group's members as ordinary `PhotoItem`s, which is what the lightbox's switch steps through. + ### 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)