1use 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#[derive(Debug, Clone)]
11pub struct DuplicateGroupRecord {
12 pub id: i64,
13 pub member_count: i64,
14 pub cover_thumbnail_path: Option<String>,
19 pub cover_photo_id: Option<i64>,
23 pub member_photo_ids: Vec<i64>,
26}
27
28#[derive(Debug, Clone)]
30pub struct DuplicateGroupMemberRecord {
31 pub photo_id: i64,
32 pub is_suggested_keep: bool,
33
34 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
41pub 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 pub fn sync_duplicate_groups(
57 &self,
58 groups: &[(String, Vec<i64>, Option<i64>, &'static str)], ) -> SqliteResult<()> {
60 use std::collections::{HashMap, HashSet};
61
62 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 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 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 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 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 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 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 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 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 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 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
425fn 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}