mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(gui): show a grouped shot as one tile, and reach its other files
The grouping landed in the daemon; this is what it looks like. A tile whose shot was stored as more than one file badges that in the corner — "RAW" when one of the members is a raw file, since that is the word the person is looking for, and a plain count otherwise (a live photo, a burst). The lightbox gains a control that steps through the group's files, which is the only way to reach the RAW now that the grid shows the shot once. Deleting says what it will do: the daemon takes the whole group, so the confirmation names the file count when a selection stands for more files than tiles.
This commit is contained in:
1 parent
afd71713d5
commit
c1df3b6cc2
8 files changed
+283
-5
No files matched your search
@@ -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<String> },
|
||||
/// 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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Vec<PhotoItem>> {
|
||||
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<NodeUid> {
|
||||
let mut out: Vec<NodeUid> = Vec::with_capacity(uids.len());
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
for uid in uids {
|
||||
let members = self.db.photos_group(&uid.to_string()).unwrap_or_default();
|
||||
let mut group: Vec<NodeUid> = 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<TrashOutcome> {
|
||||
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"))?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<bool>,
|
||||
/// 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<Vec<PhotoItem>>,
|
||||
/// uid of the photo currently on screen.
|
||||
pub(crate) uid: RefCell<String>,
|
||||
/// On-disk path of the full-size photo, once it has been downloaded.
|
||||
@@ -81,6 +86,59 @@ fn set_gallery_favorite(ui: &Rc<Ui>, 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<Ui>, viewer: &Rc<Viewer>, 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<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.spinner.set_visible(true);
|
||||
viewer.spinner.start();
|
||||
@@ -96,6 +154,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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 |_| {
|
||||
|
||||
@@ -1283,6 +1283,24 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, 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<Ui>) {
|
||||
}
|
||||
|
||||
/// 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<Ui>, 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::<BoxedAnyObject>() else {
|
||||
continue;
|
||||
};
|
||||
let photo = boxed.borrow::<PhotoItem>();
|
||||
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<Ui>) {
|
||||
let uids: Vec<String> = 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");
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user