diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 5ab9213..39ded72 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -1287,6 +1287,15 @@ pub struct PhotoItem { /// tags it has not learned yet. #[serde(default)] pub favorite: bool, + /// How many files this entry stands for: a shot stored as a RAW and a JPEG + /// is one entry and two files. Older daemons omit it, and `0` reads the same + /// as `1` — one file, nothing to indicate. + #[serde(default)] + pub group_size: u32, + /// Whether one of those files is a camera raw. The tile badges it, because + /// the JPEG it shows is not the whole of what is stored. + #[serde(default)] + pub has_raw: bool, } /// A daemon too old to classify a timeline entry is assumed to have served a diff --git a/crates/pdfs-core/src/db/albums.rs b/crates/pdfs-core/src/db/albums.rs index a664234..6589369 100644 --- a/crates/pdfs-core/src/db/albums.rs +++ b/crates/pdfs-core/src/db/albums.rs @@ -182,6 +182,10 @@ impl Db { // photo on someone else's volume is not in our timeline, and // favouriting one is not supported yet anyway. favorite: false, + // Nor are album rows grouped: an album is a list the user + // made, and it shows exactly what they put in it. + group_size: 1, + has_raw: false, }) })? .collect::>()?; @@ -239,6 +243,10 @@ impl Db { // photo on someone else's volume is not in our timeline, and // favouriting one is not supported yet anyway. favorite: false, + // Nor are album rows grouped: an album is a list the user + // made, and it shows exactly what they put in it. + group_size: 1, + has_raw: false, }) })? .collect::>()?; diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index 966daf9..f01db7f 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 = 29; +pub(super) const SCHEMA_VERSION: i64 = 30; impl Db { pub(super) fn migrate(&self) -> Result<()> { @@ -236,6 +236,24 @@ impl Db { tx.execute_batch(MIGRATION_V29)?; } } + if current < 30 { + // Same guards as V26-V29: the column may already be there on a + // fixture rewound from the current schema, and a very old fixture + // may not have `photos` at all. + let has_column: bool = tx.query_row( + "SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'group_key'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + let has_photos: bool = tx.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + if has_photos && !has_column { + tx.execute_batch(MIGRATION_V30)?; + } + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -881,3 +899,22 @@ const MIGRATION_V29: &str = " ALTER TABLE trash ADD COLUMN parent_uid TEXT; CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid); "; + +/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person +/// who took it, so the timeline records what ties them together. +/// +/// `content_hash` and `main_uid` come from the server's own photo properties — +/// the duplicate-detection hash, and the main photo a related one belongs to. +/// `group_key` is what the gallery pages by: every member of one shot carries +/// the same key, and the member the grid shows is the one whose `main_uid` is +/// its own uid. +/// +/// All three are `NULL` until the next timeline refresh resolves the photo's +/// node, and a row without a `group_key` is its own group — which is exactly how +/// the gallery behaved before this column existed. +const MIGRATION_V30: &str = " +ALTER TABLE photos ADD COLUMN content_hash TEXT; +ALTER TABLE photos ADD COLUMN main_uid TEXT; +ALTER TABLE photos ADD COLUMN group_key TEXT; +CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key); +"; diff --git a/crates/pdfs-core/src/db/photos.rs b/crates/pdfs-core/src/db/photos.rs index aa2a5a4..e1c43ed 100644 --- a/crates/pdfs-core/src/db/photos.rs +++ b/crates/pdfs-core/src/db/photos.rs @@ -34,18 +34,200 @@ pub struct StoredPhoto { pub kind: crate::control::PhotoKind, /// Whether the photo carries Proton's `Favorite` tag. pub favorite: bool, + /// How many photos this photo's group holds — one shot taken as RAW+JPEG is + /// two files and one group. `1` for a photo that stands on its own. + pub group_size: usize, + /// Whether any member of the group is a camera raw file. The grid badges + /// that, because the JPEG it shows is not the whole of what is stored. + pub has_raw: 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); +/// One row of a timeline replacement. Everything but the uid and the capture +/// time is "what this refresh learned": a `None` keeps whatever is already +/// stored rather than clearing it, because the timeline DTO carries only the uid +/// and the capture time and resolving the node can fail. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TimelineRow { + pub uid: String, + pub capture_time: i64, + pub name: Option, + pub media_type: Option, + pub favorite: Option, + /// The server's duplicate-detection hash for the plaintext. + pub content_hash: Option, + /// The main photo this one is *related* to (a live-photo video, a burst + /// sibling), when the server says it belongs to one. + pub main_uid: Option, +} + +impl TimelineRow { + /// A row carrying only what the timeline listing itself gives. + pub fn new(uid: impl Into, capture_time: i64) -> Self { + Self { + uid: uid.into(), + capture_time, + ..Self::default() + } + } +} + +/// What a previous refresh learned about a photo and a new one must not lose. +#[derive(Clone, Debug, Default)] +struct Learned { + ratio: Option, + thumb_state: i64, + media_type: Option, + favorite: bool, + content_hash: Option, + main_uid: Option, +} + +/// The local calendar day a capture time falls on, as a `YYYY-MM-DD` string. +/// Grouping by day rather than by exact second is deliberate: a camera writes +/// the RAW and the JPEG of one shot a moment apart, and two shots of the same +/// subject on different days are not one photo. +fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option { + conn.query_row( + "SELECT strftime('%Y-%m-%d', ?1, 'unixepoch', 'localtime')", + params![capture_time], + |r| r.get::<_, Option>(0), + ) + .ok() + .flatten() +} + +/// The file name without its extension, lowercased — `IMG_1234.CR2` and +/// `IMG_1234.JPG` share `img_1234`. +fn name_stem(name: Option<&str>) -> Option { + let name = name?; + let stem = match name.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem, + _ => name, + }; + Some(stem.to_ascii_lowercase()) +} + +/// One entry of the grouping pass: what rule 2 needs, plus what choosing a +/// representative needs. +struct Grouped { + uid: String, + kind: crate::control::PhotoKind, + main_uid: Option, + day: Option, + stem: Option, +} + +/// Which group each photo belongs to, and which member of it the gallery shows. +/// +/// Precedence, as agreed: +/// +/// 1. The server's own relation — a photo with a `main_photo_uid` joins that +/// photo's group. The server knows about live photos and bursts; we do not +/// have to guess at them. +/// 2. Otherwise, same capture day **and** same file-name stem **and** one of the +/// two is a camera raw while the other is not. That is the RAW+JPEG pair a +/// camera writes, and the differing-kind clause keeps two JPEGs that happen +/// to share a name apart. +/// 3. Otherwise the photo is its own group. +/// +/// The representative is the non-raw member when the group has one — a JPEG +/// decodes in milliseconds and is what the person expects to see — and the +/// earliest member in server order otherwise. +fn group_photos(entries: &[Grouped]) -> Vec { + let mut parent: Vec = (0..entries.len()).collect(); + fn find(parent: &mut [usize], mut i: usize) -> usize { + while parent[i] != i { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + i + } + let union = |parent: &mut [usize], a: usize, b: usize| { + let (a, b) = (find(parent, a), find(parent, b)); + if a != b { + parent[b] = a; + } + }; + + let index: HashMap<&str, usize> = entries + .iter() + .enumerate() + .map(|(i, e)| (e.uid.as_str(), i)) + .collect(); + for (i, entry) in entries.iter().enumerate() { + if let Some(main) = entry.main_uid.as_deref() + && let Some(&j) = index.get(main) + { + union(&mut parent, j, i); + } + } + + // Rule 2 needs only one pass: everything sharing a day and a stem lands in + // the same bucket, and the raw / non-raw split is checked pairwise there. + let mut by_name: HashMap<(&str, &str), Vec> = HashMap::new(); + for (i, entry) in entries.iter().enumerate() { + if let (Some(day), Some(stem)) = (entry.day.as_deref(), entry.stem.as_deref()) { + by_name.entry((day, stem)).or_default().push(i); + } + } + let is_raw = |i: usize| entries[i].kind == crate::control::PhotoKind::Raw; + for bucket in by_name.values() { + for (n, &i) in bucket.iter().enumerate() { + for &j in &bucket[n + 1..] { + if is_raw(i) != is_raw(j) { + union(&mut parent, i, j); + } + } + } + } + + // Representative per root, then the key every member carries. + let mut representative: HashMap = HashMap::new(); + for i in 0..entries.len() { + let root = find(&mut parent, i); + match representative.get(&root).copied() { + Some(current) if !is_raw(current) || is_raw(i) => {} + _ => { + representative.insert(root, i); + } + } + } + (0..entries.len()) + .map(|i| { + let root = find(&mut parent, i); + entries[representative[&root]].uid.clone() + }) + .collect() +} + +/// The columns every photo read selects, with the group aggregates joined in. +/// `g.n` is how many photos the group holds and `g.raw` whether any of them is a +/// camera raw file — one query rather than a second lookup per tile. +const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \ + p.kind, p.favorite, g.n, g.raw \ + FROM photos p JOIN (SELECT group_key, COUNT(*) AS n, MAX(kind = 2) AS raw \ + FROM photos GROUP BY group_key) g ON g.group_key = p.group_key"; + +/// Read one row of [`PHOTO_SELECT`]. +fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(StoredPhoto { + uid: r.get(0)?, + capture_time: r.get(1)?, + name: r.get(2)?, + ratio: r.get(3)?, + thumb_state: r.get(4)?, + kind: crate::control::PhotoKind::from_i64(r.get(5)?), + favorite: r.get::<_, i64>(6)? != 0, + group_size: r.get::<_, i64>(7)?.max(1) as usize, + has_raw: r.get::<_, i64>(8)? != 0, + }) +} impl Db { - /// 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. + /// Replace the timeline wholesale. `favorite`, `media_type`, `content_hash` + /// and `main_uid` are `None` when the refresh could not resolve that photo's + /// node, in which case what is 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()?; @@ -53,48 +235,89 @@ impl Db { // timeline DTO carries only the uid and capture time, so the daemon may // 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, bool)> = { - let mut stmt = - tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?; + // refresh instead of collapsing back to name-extension guesses. The photo + // relation is kept for the same reason: losing it would break a group up + // until the next successful resolve. + let learned: HashMap = { + let mut stmt = tx.prepare( + "SELECT uid, ratio, thumb_state, media_type, favorite, content_hash, main_uid \ + FROM photos", + )?; let rows = stmt.query_map([], |r| { Ok(( r.get::<_, String>(0)?, - (r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0), + Learned { + ratio: r.get(1)?, + thumb_state: r.get(2)?, + media_type: r.get(3)?, + favorite: r.get::<_, i64>(4)? != 0, + content_hash: r.get(5)?, + main_uid: r.get(6)?, + }, )) })?; rows.collect::>()? }; + // Everything the insert and the grouping need, resolved once per photo. + let resolved: Vec<(&TimelineRow, Learned, crate::control::PhotoKind)> = items + .iter() + .map(|row| { + let mut carried = learned.get(&row.uid).cloned().unwrap_or(Learned { + thumb_state: THUMB_UNKNOWN, + ..Learned::default() + }); + carried.media_type = row.media_type.clone().or(carried.media_type); + carried.favorite = row.favorite.unwrap_or(carried.favorite); + carried.content_hash = row.content_hash.clone().or(carried.content_hash); + carried.main_uid = row.main_uid.clone().or(carried.main_uid); + // 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. + let kind = crate::control::PhotoKind::classify( + row.name.as_deref(), + carried.media_type.as_deref(), + ); + (row, carried, kind) + }) + .collect(); + + let entries: Vec = resolved + .iter() + .map(|(row, carried, kind)| Grouped { + uid: row.uid.clone(), + kind: *kind, + main_uid: carried.main_uid.clone(), + day: capture_day(&tx, row.capture_time), + stem: name_stem(row.name.as_deref()), + }) + .collect(); + let group_keys = group_photos(&entries); + tx.execute("DELETE FROM photos", [])?; { let mut stmt = tx.prepare( "INSERT INTO photos - (uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + (uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite, + content_hash, main_uid, group_key) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", )?; - 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. - let kind = - crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref()); + for (seq, ((row, carried, kind), group_key)) in + resolved.iter().zip(group_keys.iter()).enumerate() + { stmt.execute(params![ - uid, - capture_time, - name, - ratio, - thumb_state, + row.uid, + row.capture_time, + row.name, + carried.ratio, + carried.thumb_state, seq as i64, - media_type, + carried.media_type, kind.as_i64(), - favorite as i64, + carried.favorite as i64, + carried.content_hash, + carried.main_uid, + group_key, ])?; } } @@ -132,6 +355,10 @@ impl Db { /// 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. + /// + /// The page holds one row per *group*: the RAW and the JPEG of one shot are + /// one tile. The Raw tab is the exception and lists every raw file, because + /// that tab exists to answer "which raws do I have". pub fn photos_page( &self, offset: usize, @@ -143,23 +370,24 @@ impl Db { let conn = self.read(); // 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, favorite FROM photos", - ); + let mut sql = String::from(PHOTO_SELECT); let mut binds: Vec = Vec::new(); let mut conds: Vec = Vec::new(); + if kind != Some(crate::control::PhotoKind::Raw) { + conds.push("p.uid = p.group_key".to_string()); + } if let Some(k) = kind { binds.push(k.as_i64()); - conds.push(format!("kind = ?{}", binds.len())); + conds.push(format!("p.kind = ?{}", binds.len())); } if let Some((from, to)) = range { binds.push(from); - conds.push(format!("capture_time >= ?{}", binds.len())); + conds.push(format!("p.capture_time >= ?{}", binds.len())); binds.push(to); - conds.push(format!("capture_time < ?{}", binds.len())); + conds.push(format!("p.capture_time < ?{}", binds.len())); } if favorites { - conds.push("favorite = 1".to_string()); + conds.push("p.favorite = 1".to_string()); } if !conds.is_empty() { sql.push_str(" WHERE "); @@ -170,47 +398,41 @@ impl Db { binds.push(offset as i64); let offset_pos = binds.len(); sql.push_str(&format!( - " ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}" + " ORDER BY p.seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}" )); let mut stmt = conn.prepare(&sql)?; let rows = stmt - .query_map(rusqlite::params_from_iter(binds), |r| { - Ok(StoredPhoto { - uid: r.get(0)?, - capture_time: r.get(1)?, - name: r.get(2)?, - ratio: r.get(3)?, - thumb_state: r.get(4)?, - kind: crate::control::PhotoKind::from_i64(r.get(5)?), - favorite: r.get::<_, i64>(6)? != 0, - }) - })? + .query_map(rusqlite::params_from_iter(binds), stored_photo)? .collect::>()?; Ok(rows) } - /// The whole persisted timeline, in stored order. Only for whole-library - /// passes (the photo re-date) — the gallery pages with - /// [`Db::photos_page`]. + /// Every photo of one photo's group, in server order — the RAW and the JPEG + /// of a shot, or just the photo itself when it stands alone. The lightbox + /// switches between these, and deleting a tile trashes all of them. + pub fn photos_group(&self, uid: &str) -> Result> { + let conn = self.read(); + let sql = format!( + "{PHOTO_SELECT} WHERE p.group_key = \ + (SELECT group_key FROM photos WHERE uid = ?1) ORDER BY p.seq" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(params![uid], stored_photo)? + .collect::>()?; + Ok(rows) + } + + /// The whole persisted timeline, in stored order — every file, not one row + /// per group. Only for whole-library passes (the photo re-date); the gallery + /// pages with [`Db::photos_page`]. pub fn photos_all(&self) -> Result> { let conn = self.read(); - let mut stmt = conn.prepare( - "SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite \ - FROM photos ORDER BY seq", - )?; + let sql = format!("{PHOTO_SELECT} ORDER BY p.seq"); + let mut stmt = conn.prepare(&sql)?; let rows = stmt - .query_map([], |r| { - Ok(StoredPhoto { - uid: r.get(0)?, - capture_time: r.get(1)?, - name: r.get(2)?, - ratio: r.get(3)?, - thumb_state: r.get(4)?, - kind: crate::control::PhotoKind::from_i64(r.get(5)?), - favorite: r.get::<_, i64>(6)? != 0, - }) - })? + .query_map([], stored_photo)? .collect::>()?; Ok(rows) } @@ -225,9 +447,12 @@ impl Db { kind: Option, ) -> Result> { let conn = self.read(); + // The scrubber has to agree with the page it scrubs, so it counts what + // the page shows: groups everywhere except on the Raw tab. let (filter, binds): (&str, Vec) = match kind { - Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]), - None => ("", Vec::new()), + Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()), + Some(k) => (" WHERE uid = group_key AND kind = ?1", vec![k.as_i64()]), + None => (" WHERE uid = group_key", Vec::new()), }; let sql = format!( "SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \ @@ -252,7 +477,12 @@ impl Db { pub fn photos_counts(&self) -> Result<(usize, usize, usize)> { use crate::control::PhotoKind; let conn = self.read(); - let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?; + // Photos and Videos count groups — one shot is one entry, however many + // files it was stored as. Raw counts files, because the Raw tab lists + // them individually. + let mut stmt = conn.prepare( + "SELECT kind, COUNT(*) FROM photos WHERE uid = group_key OR kind = 2 GROUP BY kind", + )?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize); for row in rows { @@ -275,21 +505,9 @@ impl Db { } let conn = self.read(); let placeholders = vec!["?"; uids.len()].join(","); - let mut stmt = conn.prepare(&format!( - "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| { - Ok(StoredPhoto { - uid: r.get(0)?, - capture_time: r.get(1)?, - name: r.get(2)?, - 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 sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})"); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?; let mut photos = Vec::new(); for row in rows { photos.push(row?); diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 17ee6d7..e7cae9d 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -113,9 +113,13 @@ 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()), Some(true)), - ("p2".into(), 200, None, None, None), - ("p3".into(), 100, None, None, None), + TimelineRow { + media_type: Some("video/mp4".into()), + favorite: Some(true), + ..TimelineRow::new("p1", 300) + }, + TimelineRow::new("p2", 200), + TimelineRow::new("p3", 100), ]) .unwrap(); @@ -126,9 +130,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, Some(false)), - ("p1".into(), 300, None, None, None), - ("p2".into(), 200, None, None, None), + TimelineRow { + name: Some("new.jpg".into()), + favorite: Some(false), + ..TimelineRow::new("p0", 400) + }, + TimelineRow::new("p1", 300), + TimelineRow::new("p2", 200), ]) .unwrap(); @@ -200,8 +208,14 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() { 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)), + TimelineRow { + favorite: Some(true), + ..TimelineRow::new("p1", 300) + }, + TimelineRow { + favorite: Some(false), + ..TimelineRow::new("p2", 200) + }, ]) .unwrap(); let favorites = db.photos_page(0, 10, None, None, true).unwrap(); @@ -215,8 +229,11 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() { // (`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), + TimelineRow { + favorite: Some(false), + ..TimelineRow::new("p1", 300) + }, + TimelineRow::new("p2", 200), ]) .unwrap(); let favorites = db.photos_page(0, 10, None, None, true).unwrap(); @@ -232,7 +249,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() { 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::, None)) + .map(|i| TimelineRow::new(format!("p{i}"), 500 - i as i64)) .collect(); db.photos_replace(&items).unwrap(); @@ -3926,9 +3943,9 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() { fn photos_delete_removes_the_photo_and_its_album_membership() { let db = Db::open_in_memory().unwrap(); db.photos_replace(&[ - ("p1".into(), 300, None, None, None), - ("p2".into(), 200, None, None, None), - ("p3".into(), 100, None, None, None), + TimelineRow::new("p1", 300), + TimelineRow::new("p2", 200), + TimelineRow::new("p3", 100), ]) .unwrap(); db.albums_replace(&[StoredAlbum { @@ -3970,3 +3987,117 @@ fn photos_delete_removes_the_photo_and_its_album_membership() { assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 0); assert_eq!(db.photos_delete(&[]).unwrap(), 0); } + +/// A shot taken as RAW+JPEG is one photo to the person who took it: the grid +/// shows the JPEG once, and the Raw tab still lists the raw file. +#[test] +fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() { + let db = Db::open_in_memory().unwrap(); + db.photos_replace(&[ + TimelineRow { + name: Some("IMG_1234.JPG".into()), + ..TimelineRow::new("jpeg", 300) + }, + TimelineRow { + name: Some("img_1234.cr2".into()), + ..TimelineRow::new("raw", 300) + }, + TimelineRow { + name: Some("IMG_9999.JPG".into()), + ..TimelineRow::new("alone", 200) + }, + ]) + .unwrap(); + + let page = db.photos_page(0, 10, None, None, false).unwrap(); + assert_eq!( + page.iter().map(|p| p.uid.as_str()).collect::>(), + ["jpeg", "alone"], + "the pair shows once, as the member that can be displayed" + ); + assert_eq!(page[0].group_size, 2); + assert!(page[0].has_raw, "the tile can say the raw is there"); + assert_eq!(page[1].group_size, 1); + assert!(!page[1].has_raw); + + // The Raw tab answers "which raws do I have", so it lists files. + let raws = db + .photos_page(0, 10, Some(crate::control::PhotoKind::Raw), None, false) + .unwrap(); + assert_eq!( + raws.iter().map(|p| p.uid.as_str()).collect::>(), + ["raw"] + ); + assert_eq!(db.photos_counts().unwrap(), (2, 0, 1)); + + // Both members are reachable from either uid: the lightbox switches between + // them, and deleting the tile has to trash both. + let group = db.photos_group("raw").unwrap(); + assert_eq!( + group.iter().map(|p| p.uid.as_str()).collect::>(), + ["jpeg", "raw"] + ); + assert_eq!(db.photos_group("alone").unwrap().len(), 1); +} + +/// Two files that merely share a name are not one shot: the same stem on +/// different days, or two files of the same kind, stay apart. +#[test] +fn a_shared_name_alone_does_not_group_photos() { + let db = Db::open_in_memory().unwrap(); + db.photos_replace(&[ + TimelineRow { + name: Some("IMG_1234.JPG".into()), + ..TimelineRow::new("today", 1_700_000_000) + }, + TimelineRow { + name: Some("IMG_1234.CR2".into()), + ..TimelineRow::new("last-year", 1_600_000_000) + }, + TimelineRow { + name: Some("IMG_1234.PNG".into()), + ..TimelineRow::new("same-kind", 1_700_000_000) + }, + ]) + .unwrap(); + + let page = db.photos_page(0, 10, None, None, false).unwrap(); + assert_eq!(page.len(), 3, "three shots, three tiles"); + assert!(page.iter().all(|p| p.group_size == 1)); +} + +/// The server knows about live photos and bursts, so its own relation groups +/// files this client would never pair by name. +#[test] +fn the_servers_photo_relation_groups_a_live_photo() { + let db = Db::open_in_memory().unwrap(); + db.photos_replace(&[ + TimelineRow { + name: Some("IMG_0001.HEIC".into()), + ..TimelineRow::new("main", 300) + }, + TimelineRow { + name: Some("clip.mov".into()), + main_uid: Some("main".into()), + ..TimelineRow::new("related", 299) + }, + ]) + .unwrap(); + + let page = db.photos_page(0, 10, None, None, false).unwrap(); + assert_eq!( + page.iter().map(|p| p.uid.as_str()).collect::>(), + ["main"] + ); + assert_eq!(page[0].group_size, 2); + assert!(!page[0].has_raw, "a live photo's video is not a raw file"); + + // The relation is learned-and-kept: a refresh that could not resolve the + // nodes must not break the group up. + db.photos_replace(&[ + TimelineRow::new("main", 300), + TimelineRow::new("related", 299), + ]) + .unwrap(); + assert_eq!(db.photos_group("related").unwrap().len(), 2); +} diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index 1c615f2..f44eca5 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -407,6 +407,16 @@ fn extract_raw_preview( pub(crate) type TrashOutcome = (Vec, Vec<(String, String)>); /// Formats supported by the local thumbnail decoder and exposed by the GUI. +/// What a timeline enrichment pass resolves for one photo, beyond what the +/// timeline listing already carries. +#[derive(Clone, Debug)] +struct PhotoMeta { + name: Option, + media_type: Option, + favorite: bool, + content_hash: Option, +} + impl Core { pub(crate) fn photos_timeline( &self, @@ -458,6 +468,8 @@ impl Core { no_thumb: photo.thumb_state == db::THUMB_NONE, kind: photo.kind, favorite: photo.favorite, + group_size: photo.group_size as u32, + has_raw: photo.has_raw, } } @@ -1283,7 +1295,11 @@ 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, bool)> = HashMap::new(); + let mut meta: HashMap = HashMap::new(); + // A main photo names its related photos rather than the other way round, + // so the relation is recorded from whichever end resolves — the two ends + // can land in different chunks, and one of them may not resolve at all. + let mut main_of: HashMap = HashMap::new(); for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) { match photos.enumerate_nodes(chunk).await { Ok(nodes) => { @@ -1299,9 +1315,24 @@ impl Core { .photo .as_ref() .is_some_and(|p| p.tags.contains(&PhotoTag::Favorite)); + let key = node.uid.to_string(); + let content_hash = node.photo.as_ref().and_then(|p| p.content_hash.clone()); + if let Some(photo) = node.photo.as_ref() { + if let Some(main) = photo.main_photo_uid.as_ref() { + main_of.insert(key.clone(), main.to_string()); + } + for related in &photo.related_photo_uids { + main_of.insert(related.to_string(), key.clone()); + } + } meta.insert( - node.uid.to_string(), - (Some(node.name), media_type, favorite), + key, + PhotoMeta { + name: Some(node.name), + media_type, + favorite, + content_hash, + }, ); } } @@ -1313,13 +1344,22 @@ impl Core { .iter() .map(|it| { let key = it.uid.to_string(); + let main_uid = main_of.get(&key).cloned(); // 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), + Some(m) => db::TimelineRow { + name: m.name, + media_type: m.media_type, + favorite: Some(m.favorite), + content_hash: m.content_hash, + main_uid, + ..db::TimelineRow::new(key, it.capture_time) + }, + None => db::TimelineRow { + main_uid, + ..db::TimelineRow::new(key, it.capture_time) + }, } }) .collect();