Skip to main content

smriti/db/
duplicate_repo.rs

1//! Duplicate groups database operations
2
3use std::collections::HashMap;
4
5use rusqlite::{params, params_from_iter, types::ToSql, Connection, Result as SqliteResult};
6
7use super::MAX_ROWS_PER_INSERT;
8
9/// Duplicate group record
10#[derive(Debug, Clone)]
11pub struct DuplicateGroupRecord {
12    pub id: i64,
13    pub member_count: i64,
14    /// Relative thumbnail path of one member — the suggested-keep when set,
15    /// otherwise the first member by photo id. The listing UI shows this
16    /// as the group's cover so the user can recognise the photo at a
17    /// glance instead of just seeing "Group of 4".
18    pub cover_thumbnail_path: Option<String>,
19    /// Photo id matching the cover_thumbnail_path. Lets the listing
20    /// route directly to the photo viewer on click (which then uses
21    /// `member_photo_ids` as its prev/next scope).
22    pub cover_photo_id: Option<i64>,
23    /// All members in display order — same scope used by PhotoDetail's
24    /// arrow navigation when the user clicks a thumb in the listing.
25    pub member_photo_ids: Vec<i64>,
26}
27
28/// Duplicate group member record
29#[derive(Debug, Clone)]
30pub struct DuplicateGroupMemberRecord {
31    pub photo_id: i64,
32    pub is_suggested_keep: bool,
33
34    // Joined from photos table
35    pub file_path: Option<String>,
36    pub thumbnail_path: Option<String>,
37    pub file_size: Option<i64>,
38    pub date_taken: Option<String>,
39}
40
41/// Duplicate repository
42pub struct DuplicateRepo<'a> {
43    conn: &'a Connection,
44}
45
46impl<'a> DuplicateRepo<'a> {
47    pub fn new(conn: &'a Connection) -> Self {
48        Self { conn }
49    }
50
51    /// Create or update duplicate groups from detection results, preserving user decisions.
52    ///
53    /// Uses merge-based approach: existing groups whose hash matches are kept
54    /// intact (preserving `is_suggested_keep`), new groups are created, and groups
55    /// whose hash no longer has duplicates are removed.
56    pub fn sync_duplicate_groups(
57        &self,
58        groups: &[(String, Vec<i64>, Option<i64>, &'static str)], // (hash, photo_ids, suggested_keep, duplicate_type)
59    ) -> SqliteResult<()> {
60        use std::collections::{HashMap, HashSet};
61
62        // Load existing groups by hash
63        let mut existing_hashes: HashMap<String, (i64, bool)> = HashMap::new();
64        {
65            let mut stmt = self
66                .conn
67                .prepare("SELECT id, group_hash, resolved FROM duplicate_groups")?;
68            let rows = stmt.query_map([], |row| {
69                Ok((
70                    row.get::<_, i64>(0)?,
71                    row.get::<_, String>(1)?,
72                    row.get::<_, bool>(2)?,
73                ))
74            })?;
75            for row in rows {
76                let (id, hash, resolved) = row?;
77                existing_hashes.insert(hash, (id, resolved));
78            }
79        }
80
81        let tx = self.conn.unchecked_transaction()?;
82        let mut seen_hashes: HashSet<String> = HashSet::new();
83
84        for (hash, photo_ids, suggested_keep, dup_type) in groups {
85            seen_hashes.insert(hash.clone());
86
87            if let Some((group_id, resolved)) = existing_hashes.get(hash).copied() {
88                if resolved {
89                    continue;
90                }
91                let existing_keep: Option<i64> = tx
92                    .query_row(
93                        "SELECT photo_id FROM duplicate_group_members
94                         WHERE group_id = ?1 AND is_suggested_keep = TRUE
95                         LIMIT 1",
96                        params![group_id],
97                        |row| row.get(0),
98                    )
99                    .ok();
100                let keep = existing_keep
101                    .filter(|id| photo_ids.contains(id))
102                    .or(*suggested_keep);
103
104                tx.execute(
105                    "DELETE FROM duplicate_group_members WHERE group_id = ?1",
106                    params![group_id],
107                )?;
108                tx.execute(
109                    "UPDATE duplicate_groups SET duplicate_type = ?2 WHERE id = ?1",
110                    params![group_id, dup_type],
111                )?;
112                insert_duplicate_members(&tx, group_id, photo_ids, keep)?;
113                continue;
114            }
115
116            // Create new duplicate group. Use the tx handle (already open
117            // above); nesting unchecked_transaction would conflict.
118            tx.execute(
119                r#"
120                INSERT INTO duplicate_groups (group_hash, duplicate_type)
121                VALUES (?1, ?2)
122                "#,
123                params![hash, dup_type],
124            )?;
125
126            let group_id = tx.last_insert_rowid();
127            insert_duplicate_members(&tx, group_id, photo_ids, *suggested_keep)?;
128        }
129
130        // Remove groups whose hash no longer has duplicates
131        for (hash, (group_id, _)) in &existing_hashes {
132            if !seen_hashes.contains(hash) {
133                tx.execute(
134                    "DELETE FROM duplicate_group_members WHERE group_id = ?1",
135                    params![group_id],
136                )?;
137                tx.execute(
138                    "DELETE FROM duplicate_groups WHERE id = ?1",
139                    params![group_id],
140                )?;
141            }
142        }
143
144        tx.commit()
145    }
146
147    /// Insert or refresh the supplied groups without deleting groups
148    /// absent from this batch. Used by long-running detectors so
149    /// already-found results can stream into the UI and survive cancel.
150    pub fn upsert_duplicate_groups(
151        &self,
152        groups: &[(String, Vec<i64>, Option<i64>, &'static str)],
153    ) -> SqliteResult<()> {
154        use std::collections::HashMap;
155
156        let mut existing_hashes: HashMap<String, (i64, bool)> = HashMap::new();
157        {
158            let mut stmt = self
159                .conn
160                .prepare("SELECT id, group_hash, resolved FROM duplicate_groups")?;
161            let rows = stmt.query_map([], |row| {
162                Ok((
163                    row.get::<_, i64>(0)?,
164                    row.get::<_, String>(1)?,
165                    row.get::<_, bool>(2)?,
166                ))
167            })?;
168            for row in rows {
169                let (id, hash, resolved) = row?;
170                existing_hashes.insert(hash, (id, resolved));
171            }
172        }
173
174        let tx = self.conn.unchecked_transaction()?;
175        for (hash, photo_ids, suggested_keep, dup_type) in groups {
176            if let Some((group_id, resolved)) = existing_hashes.get(hash).copied() {
177                if resolved {
178                    continue;
179                }
180                let existing_keep: Option<i64> = tx
181                    .query_row(
182                        "SELECT photo_id FROM duplicate_group_members
183                         WHERE group_id = ?1 AND is_suggested_keep = TRUE
184                         LIMIT 1",
185                        params![group_id],
186                        |row| row.get(0),
187                    )
188                    .ok();
189                let keep = existing_keep
190                    .filter(|id| photo_ids.contains(id))
191                    .or(*suggested_keep);
192                tx.execute(
193                    "DELETE FROM duplicate_group_members WHERE group_id = ?1",
194                    params![group_id],
195                )?;
196                tx.execute(
197                    "UPDATE duplicate_groups SET duplicate_type = ?2 WHERE id = ?1",
198                    params![group_id, dup_type],
199                )?;
200                insert_duplicate_members(&tx, group_id, photo_ids, keep)?;
201            } else {
202                tx.execute(
203                    "INSERT INTO duplicate_groups (group_hash, duplicate_type) VALUES (?1, ?2)",
204                    params![hash, dup_type],
205                )?;
206                let group_id = tx.last_insert_rowid();
207                insert_duplicate_members(&tx, group_id, photo_ids, *suggested_keep)?;
208            }
209        }
210        tx.commit()
211    }
212
213    /// Get all duplicate groups with member counts and a cover thumbnail.
214    ///
215    /// The cover comes from the suggested-keep member when one is set,
216    /// otherwise the lowest-id member. We pull the cover with a
217    /// correlated sub-query so the result is one round-trip per listing.
218    pub fn get_all_groups(&self) -> SqliteResult<Vec<DuplicateGroupRecord>> {
219        self.get_groups(i64::MAX, 0)
220    }
221
222    pub fn get_groups(&self, limit: i64, offset: i64) -> SqliteResult<Vec<DuplicateGroupRecord>> {
223        let mut stmt = self.conn.prepare(
224            r#"
225            SELECT
226                dg.id,
227                COUNT(p.id) AS member_count,
228                (
229                    SELECT p.thumbnail_path
230                     FROM duplicate_group_members m
231                      JOIN photos p ON p.id = m.photo_id
232                     WHERE m.group_id = dg.id
233                       AND p.is_trashed = FALSE
234                  ORDER BY m.is_suggested_keep DESC, m.photo_id ASC
235                     LIMIT 1
236                ) AS cover_thumbnail_path,
237                (
238                    SELECT m.photo_id
239                      FROM duplicate_group_members m
240                      JOIN photos p ON p.id = m.photo_id
241                     WHERE m.group_id = dg.id
242                       AND p.is_trashed = FALSE
243                  ORDER BY m.is_suggested_keep DESC, m.photo_id ASC
244                     LIMIT 1
245                ) AS cover_photo_id
246            FROM duplicate_groups dg
247            LEFT JOIN duplicate_group_members dgm ON dg.id = dgm.group_id
248            LEFT JOIN photos p ON p.id = dgm.photo_id AND p.is_trashed = FALSE
249            WHERE dg.resolved = FALSE
250            GROUP BY dg.id
251            HAVING COUNT(p.id) > 1
252            ORDER BY member_count DESC
253            LIMIT ?1 OFFSET ?2
254            "#,
255        )?;
256
257        let rows = stmt.query_map(params![limit.max(0), offset.max(0)], |row| {
258            Ok(DuplicateGroupRecord {
259                id: row.get(0)?,
260                member_count: row.get(1)?,
261                cover_thumbnail_path: row.get(2)?,
262                cover_photo_id: row.get(3)?,
263                member_photo_ids: Vec::new(),
264            })
265        })?;
266
267        let mut groups = Vec::new();
268        for row in rows {
269            groups.push(row?);
270        }
271
272        // Pull every member's photo_id per group — drives the
273        // browseContext scope when the user clicks a duplicate's
274        // thumb in the listing.
275        if !groups.is_empty() {
276            let placeholders = vec!["?"; groups.len()].join(",");
277            let sql = format!(
278                "SELECT m.group_id, m.photo_id
279                   FROM duplicate_group_members m
280                   JOIN photos p ON p.id = m.photo_id
281                  WHERE m.group_id IN ({placeholders}) AND p.is_trashed = FALSE
282               ORDER BY m.group_id, m.is_suggested_keep DESC, m.photo_id ASC"
283            );
284            let group_ids: Vec<i64> = groups.iter().map(|g| g.id).collect();
285            let mut by_group: HashMap<i64, Vec<i64>> = HashMap::with_capacity(groups.len());
286            let mut members_stmt = self.conn.prepare(&sql)?;
287            let rows = members_stmt.query_map(params_from_iter(group_ids), |row| {
288                Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
289            })?;
290            for row in rows {
291                let (group_id, photo_id) = row?;
292                by_group.entry(group_id).or_default().push(photo_id);
293            }
294            for group in &mut groups {
295                group.member_photo_ids = by_group.remove(&group.id).unwrap_or_default();
296            }
297        }
298
299        Ok(groups)
300    }
301
302    /// Get members of a specific group
303    pub fn get_group_members(
304        &self,
305        group_id: i64,
306    ) -> SqliteResult<Vec<DuplicateGroupMemberRecord>> {
307        let mut stmt = self.conn.prepare(
308            r#"
309            SELECT 
310                dgm.photo_id,
311                dgm.is_suggested_keep,
312                p.file_path,
313                p.thumbnail_path,
314                p.file_size,
315                p.date_taken
316            FROM duplicate_group_members dgm
317            JOIN duplicate_groups dg ON dg.id = dgm.group_id
318            JOIN photos p ON dgm.photo_id = p.id
319            WHERE dgm.group_id = ?1 AND dg.resolved = FALSE AND p.is_trashed = FALSE
320            ORDER BY dgm.is_suggested_keep DESC, p.date_taken ASC
321            "#,
322        )?;
323
324        let rows = stmt.query_map(params![group_id], |row| {
325            Ok(DuplicateGroupMemberRecord {
326                photo_id: row.get(0)?,
327                is_suggested_keep: row.get(1)?,
328                file_path: row.get(2)?,
329                thumbnail_path: row.get(3)?,
330                file_size: row.get(4)?,
331                date_taken: row.get(5)?,
332            })
333        })?;
334
335        let mut members = Vec::new();
336        for row in rows {
337            members.push(row?);
338        }
339
340        Ok(members)
341    }
342
343    /// Mark a photo as the one to keep in a group (atomic)
344    pub fn set_keep_photo(&self, group_id: i64, photo_id: i64) -> SqliteResult<()> {
345        let tx = self.conn.unchecked_transaction()?;
346
347        let exists: i64 = tx.query_row(
348            "SELECT COUNT(*)
349               FROM duplicate_group_members dgm
350               JOIN duplicate_groups dg ON dg.id = dgm.group_id
351              WHERE dgm.group_id = ?1
352                AND dgm.photo_id = ?2
353                AND dg.resolved = FALSE",
354            params![group_id, photo_id],
355            |row| row.get(0),
356        )?;
357        if exists == 0 {
358            return Err(rusqlite::Error::QueryReturnedNoRows);
359        }
360
361        tx.execute(
362            "UPDATE duplicate_group_members SET is_suggested_keep = FALSE WHERE group_id = ?1",
363            params![group_id],
364        )?;
365
366        tx.execute(
367            "UPDATE duplicate_group_members SET is_suggested_keep = TRUE WHERE group_id = ?1 AND photo_id = ?2",
368            params![group_id, photo_id],
369        )?;
370
371        tx.commit()
372    }
373
374    /// Delete a duplicate group (after resolution)
375    pub fn delete_group(&self, group_id: i64) -> SqliteResult<()> {
376        self.conn.execute(
377            "DELETE FROM duplicate_group_members WHERE group_id = ?1",
378            params![group_id],
379        )?;
380        self.conn.execute(
381            "DELETE FROM duplicate_groups WHERE id = ?1",
382            params![group_id],
383        )?;
384        Ok(())
385    }
386
387    /// Mark a duplicate group as handled without deleting its hash.
388    pub fn dismiss_group(&self, group_id: i64) -> SqliteResult<()> {
389        let updated = self.conn.execute(
390            "UPDATE duplicate_groups SET resolved = TRUE WHERE id = ?1",
391            params![group_id],
392        )?;
393        if updated == 0 {
394            return Err(rusqlite::Error::QueryReturnedNoRows);
395        }
396        Ok(())
397    }
398
399    /// Get photos to trash (all members except the one to keep)
400    pub fn get_photos_to_trash(&self, group_id: i64) -> SqliteResult<Vec<i64>> {
401        let mut stmt = self.conn.prepare(
402            r#"
403            SELECT dgm.photo_id
404              FROM duplicate_group_members dgm
405              JOIN duplicate_groups dg ON dg.id = dgm.group_id
406              JOIN photos p ON p.id = dgm.photo_id
407             WHERE dgm.group_id = ?1
408               AND dgm.is_suggested_keep = FALSE
409               AND dg.resolved = FALSE
410               AND p.is_trashed = FALSE
411            "#,
412        )?;
413
414        let rows = stmt.query_map(params![group_id], |row| row.get(0))?;
415
416        let mut photo_ids = Vec::new();
417        for row in rows {
418            photo_ids.push(row?);
419        }
420
421        Ok(photo_ids)
422    }
423}
424
425/// Batch-insert duplicate-group members via multi-row VALUES inside
426/// the caller's transaction. ~3× faster than one INSERT per row for
427/// large duplicate groups.
428fn insert_duplicate_members(
429    conn: &Connection,
430    group_id: i64,
431    photo_ids: &[i64],
432    suggested_keep: Option<i64>,
433) -> SqliteResult<()> {
434    if photo_ids.is_empty() {
435        return Ok(());
436    }
437    for chunk in photo_ids.chunks(MAX_ROWS_PER_INSERT) {
438        let placeholders: String = (0..chunk.len())
439            .map(|_| "(?, ?, ?)")
440            .collect::<Vec<_>>()
441            .join(", ");
442        let sql = format!(
443            "INSERT INTO duplicate_group_members (group_id, photo_id, is_suggested_keep) VALUES {}",
444            placeholders
445        );
446        let mut params_vec: Vec<Box<dyn ToSql>> = Vec::with_capacity(chunk.len() * 3);
447        for pid in chunk {
448            let is_suggested = suggested_keep.map(|s| s == *pid).unwrap_or(false);
449            params_vec.push(Box::new(group_id));
450            params_vec.push(Box::new(*pid));
451            params_vec.push(Box::new(is_suggested));
452        }
453        let params_refs: Vec<&dyn ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
454        conn.execute(&sql, params_refs.as_slice())?;
455    }
456    Ok(())
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::db::create_schema;
463
464    #[test]
465    fn invalid_keep_photo_does_not_clear_existing_keep() {
466        let conn = Connection::open_in_memory().unwrap();
467        create_schema(&conn).unwrap();
468        conn.execute(
469            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size)
470             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10),
471                    (2, 'b.jpg', 'b.jpg', 'b', 10)",
472            [],
473        )
474        .unwrap();
475
476        let repo = DuplicateRepo::new(&conn);
477        repo.sync_duplicate_groups(&[("group".into(), vec![1, 2], Some(1), "exact")])
478            .unwrap();
479        let group_id = repo.get_all_groups().unwrap()[0].id;
480
481        assert!(repo.set_keep_photo(group_id, 999).is_err());
482
483        let keep_id: i64 = conn
484            .query_row(
485                "SELECT photo_id FROM duplicate_group_members
486                 WHERE group_id = ?1 AND is_suggested_keep = TRUE",
487                params![group_id],
488                |row| row.get(0),
489            )
490            .unwrap();
491        assert_eq!(keep_id, 1);
492    }
493
494    #[test]
495    fn dismissed_group_does_not_reappear_on_next_detection() {
496        let conn = Connection::open_in_memory().unwrap();
497        create_schema(&conn).unwrap();
498        conn.execute(
499            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size)
500             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10),
501                    (2, 'b.jpg', 'b.jpg', 'b', 10)",
502            [],
503        )
504        .unwrap();
505
506        let repo = DuplicateRepo::new(&conn);
507        let detected = [("group".into(), vec![1, 2], Some(1), "exact")];
508        repo.sync_duplicate_groups(&detected).unwrap();
509        let group_id = repo.get_all_groups().unwrap()[0].id;
510
511        repo.dismiss_group(group_id).unwrap();
512        assert!(repo.get_all_groups().unwrap().is_empty());
513
514        repo.upsert_duplicate_groups(&detected).unwrap();
515        assert!(repo.get_all_groups().unwrap().is_empty());
516
517        repo.sync_duplicate_groups(&detected).unwrap();
518        assert!(repo.get_all_groups().unwrap().is_empty());
519    }
520
521    #[test]
522    fn dismissed_group_cannot_be_mutated_or_trashed_from_stale_detail() {
523        let conn = Connection::open_in_memory().unwrap();
524        create_schema(&conn).unwrap();
525        conn.execute(
526            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size)
527             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10),
528                    (2, 'b.jpg', 'b.jpg', 'b', 10)",
529            [],
530        )
531        .unwrap();
532
533        let repo = DuplicateRepo::new(&conn);
534        repo.sync_duplicate_groups(&[("group".into(), vec![1, 2], Some(1), "exact")])
535            .unwrap();
536        let group_id = repo.get_all_groups().unwrap()[0].id;
537
538        repo.dismiss_group(group_id).unwrap();
539
540        assert!(repo.set_keep_photo(group_id, 2).is_err());
541        assert!(repo.get_photos_to_trash(group_id).unwrap().is_empty());
542    }
543}