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:
nvrl committed 2026-09-19 15:39:14 +02:00
1 parent afd71713d5
commit c1df3b6cc2
8 files changed
+283 -5

No files matched your search

+8
View File
@@ -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 {
+41
View File
@@ -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"))?;