Skip to main content

smriti/services/
trash.rs

1//! Trash management service.
2
3use std::fs;
4use std::path::Path;
5
6use rusqlite::{params, Connection, Result as SqliteResult};
7
8use crate::db::{album_repo::AlbumRepo, FaceRepo, PhotoStackRepo};
9use crate::services::path_util::{safe_existing_path_under_root, safe_join_relative};
10
11/// Result of a permanent delete operation.
12#[derive(Debug, Default, Clone)]
13pub struct DeleteResult {
14    pub files_deleted: usize,
15    pub db_records_deleted: usize,
16    pub errors: Vec<String>,
17}
18
19/// Trash statistics.
20#[derive(Debug, Default, Clone)]
21pub struct TrashStats {
22    pub count: usize,
23    pub total_size: u64,
24}
25
26/// Trash service.
27pub struct TrashService;
28
29impl TrashService {
30    pub fn trash_photos(conn: &Connection, photo_ids: &[i64]) -> SqliteResult<usize> {
31        let tx = conn.unchecked_transaction()?;
32        let mut count = 0usize;
33
34        for photo_id in photo_ids {
35            let path: Option<String> = tx
36                .query_row(
37                    "SELECT file_path FROM photos WHERE id = ?1 AND is_trashed = FALSE",
38                    params![photo_id],
39                    |row| row.get(0),
40                )
41                .ok();
42
43            if let Some(path) = path {
44                tx.execute(
45                    "INSERT OR IGNORE INTO trash (photo_id, original_path) VALUES (?1, ?2)",
46                    params![photo_id, path],
47                )?;
48                tx.execute(
49                    "UPDATE photos SET is_trashed = TRUE, trashed_at = CURRENT_TIMESTAMP WHERE id = ?1",
50                    params![photo_id],
51                )?;
52                count += 1;
53            }
54        }
55
56        PhotoStackRepo::new(&tx).reconcile_after_photos_trashed(photo_ids)?;
57        refresh_album_state_for_photos(&tx, photo_ids)?;
58        refresh_face_state_for_photos(&tx, photo_ids)?;
59        tx.commit()?;
60        Ok(count)
61    }
62
63    pub fn restore_photos(conn: &Connection, photo_ids: &[i64]) -> SqliteResult<usize> {
64        let tx = conn.unchecked_transaction()?;
65        let mut count = 0usize;
66
67        for photo_id in photo_ids {
68            let removed = tx.execute("DELETE FROM trash WHERE photo_id = ?1", params![photo_id])?;
69            let updated = tx.execute(
70                "UPDATE photos SET is_trashed = FALSE, trashed_at = NULL WHERE id = ?1 AND is_trashed = TRUE",
71                params![photo_id],
72            )?;
73            if removed > 0 || updated > 0 {
74                count += 1;
75            }
76        }
77
78        refresh_album_state_for_photos(&tx, photo_ids)?;
79        refresh_face_state_for_photos(&tx, photo_ids)?;
80        tx.commit()?;
81        Ok(count)
82    }
83
84    pub fn permanent_delete(
85        conn: &Connection,
86        photo_ids: &[i64],
87        drive_root: &Path,
88    ) -> SqliteResult<DeleteResult> {
89        let tx = conn.unchecked_transaction()?;
90        let mut result = DeleteResult::default();
91
92        for photo_id in photo_ids {
93            let row: Option<(String, Option<String>)> = tx
94                .query_row(
95                    "SELECT file_path, thumbnail_path FROM photos WHERE id = ?1 AND is_trashed = TRUE",
96                    params![photo_id],
97                    |row| Ok((row.get(0)?, row.get(1)?)),
98                )
99                .ok();
100
101            let Some((relative_path, thumbnail_path)) = row else {
102                continue;
103            };
104
105            let full_path = match safe_join_relative(drive_root, &relative_path) {
106                Ok(path) => path,
107                Err(e) => {
108                    result.errors.push(format!("{}: {}", relative_path, e));
109                    continue;
110                }
111            };
112            if full_path.exists() {
113                let delete_path = match safe_existing_path_under_root(drive_root, &relative_path) {
114                    Ok(path) => path,
115                    Err(e) => {
116                        result.errors.push(format!("{}: {}", relative_path, e));
117                        continue;
118                    }
119                };
120                if let Err(e) = fs::remove_file(&delete_path) {
121                    result.errors.push(format!("{}: {}", relative_path, e));
122                    continue;
123                }
124                result.files_deleted += 1;
125            }
126
127            if let Some(tp) = thumbnail_path {
128                if let Ok(thumb_full) = safe_join_relative(drive_root, &tp) {
129                    if thumb_full.exists() {
130                        let _ = fs::remove_file(thumb_full);
131                    }
132                }
133            }
134
135            tx.execute("DELETE FROM trash WHERE photo_id = ?1", params![photo_id])?;
136            tx.execute("DELETE FROM photos WHERE id = ?1", params![photo_id])?;
137            result.db_records_deleted += 1;
138        }
139
140        refresh_album_state_for_photos(&tx, photo_ids)?;
141        refresh_face_state_for_photos(&tx, photo_ids)?;
142        tx.commit()?;
143        Ok(result)
144    }
145
146    pub fn empty_trash(conn: &Connection, drive_root: &Path) -> SqliteResult<DeleteResult> {
147        let mut stmt = conn.prepare("SELECT photo_id FROM trash")?;
148        let ids: Vec<i64> = stmt
149            .query_map([], |row| row.get(0))?
150            .collect::<SqliteResult<Vec<_>>>()?;
151        drop(stmt);
152        Self::permanent_delete(conn, &ids, drive_root)
153    }
154
155    pub fn get_stats(conn: &Connection) -> SqliteResult<TrashStats> {
156        let count: i64 = conn.query_row(
157            "SELECT COUNT(*) FROM photos WHERE is_trashed = TRUE",
158            [],
159            |row| row.get(0),
160        )?;
161
162        let total: i64 = conn.query_row(
163            "SELECT COALESCE(SUM(file_size), 0) FROM photos WHERE is_trashed = TRUE",
164            [],
165            |row| row.get(0),
166        )?;
167
168        Ok(TrashStats {
169            count: count as usize,
170            total_size: total.max(0) as u64,
171        })
172    }
173}
174
175fn refresh_album_state_for_photos(conn: &Connection, photo_ids: &[i64]) -> SqliteResult<()> {
176    if photo_ids.is_empty() {
177        return Ok(());
178    }
179
180    let mut album_ids = Vec::new();
181    for chunk in photo_ids.chunks(900) {
182        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
183        let sql = format!(
184            "SELECT DISTINCT album_id FROM album_photos WHERE photo_id IN ({})",
185            placeholders
186        );
187        let mut stmt = conn.prepare(&sql)?;
188        let rows = stmt.query_map(rusqlite::params_from_iter(chunk.iter().copied()), |row| {
189            row.get(0)
190        })?;
191        for row in rows {
192            album_ids.push(row?);
193        }
194    }
195    album_ids.sort_unstable();
196    album_ids.dedup();
197
198    let repo = AlbumRepo::new(conn);
199    for album_id in album_ids {
200        repo.refresh_stats(album_id)?;
201    }
202    Ok(())
203}
204
205fn refresh_face_state_for_photos(
206    tx: &rusqlite::Transaction<'_>,
207    photo_ids: &[i64],
208) -> SqliteResult<()> {
209    if photo_ids.is_empty() {
210        return Ok(());
211    }
212
213    let mut cluster_ids = Vec::new();
214    for chunk in photo_ids.chunks(900) {
215        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
216        let sql = format!(
217            "SELECT DISTINCT cluster_id FROM faces
218             WHERE photo_id IN ({0}) AND cluster_id IS NOT NULL
219             UNION
220             SELECT DISTINCT cluster_id FROM photo_inferred_identities
221             WHERE photo_id IN ({0})",
222            placeholders
223        );
224        let mut params = Vec::with_capacity(chunk.len() * 2);
225        params.extend(chunk.iter().copied());
226        params.extend(chunk.iter().copied());
227        let mut stmt = tx.prepare(&sql)?;
228        let rows = stmt.query_map(rusqlite::params_from_iter(params), |row| row.get(0))?;
229        for row in rows {
230            cluster_ids.push(row?);
231        }
232    }
233
234    cluster_ids.sort_unstable();
235    cluster_ids.dedup();
236    for cluster_id in cluster_ids {
237        FaceRepo::refresh_cluster_stats_tx(tx, cluster_id)?;
238        FaceRepo::refresh_gallery_tx(tx, cluster_id)?;
239    }
240    tx.execute(
241        "DELETE FROM face_clusters WHERE face_count <= 0 AND photo_count <= 0",
242        [],
243    )?;
244    Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::db::create_schema;
251    use rusqlite::Connection;
252
253    #[test]
254    fn permanent_delete_ignores_photos_not_in_trash() {
255        let temp = tempfile::tempdir().unwrap();
256        let photo_path = temp.path().join("keep.jpg");
257        std::fs::write(&photo_path, b"original").unwrap();
258
259        let conn = Connection::open_in_memory().unwrap();
260        create_schema(&conn).unwrap();
261        conn.execute(
262            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, is_trashed)
263             VALUES (1, 'keep.jpg', 'keep.jpg', 'hash', 8, FALSE)",
264            [],
265        )
266        .unwrap();
267
268        let result = TrashService::permanent_delete(&conn, &[1], temp.path()).unwrap();
269
270        assert_eq!(result.files_deleted, 0);
271        assert_eq!(result.db_records_deleted, 0);
272        assert!(photo_path.exists());
273        let count: i64 = conn
274            .query_row("SELECT COUNT(*) FROM photos WHERE id = 1", [], |row| {
275                row.get(0)
276            })
277            .unwrap();
278        assert_eq!(count, 1);
279    }
280
281    #[test]
282    fn empty_trash_deletes_collected_rows() {
283        let temp = tempfile::tempdir().unwrap();
284        let photo_path = temp.path().join("gone.jpg");
285        std::fs::write(&photo_path, b"original").unwrap();
286
287        let conn = Connection::open_in_memory().unwrap();
288        create_schema(&conn).unwrap();
289        conn.execute(
290            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, is_trashed)
291             VALUES (1, 'gone.jpg', 'gone.jpg', 'hash', 8, TRUE)",
292            [],
293        )
294        .unwrap();
295        conn.execute(
296            "INSERT INTO trash (photo_id, original_path) VALUES (1, 'gone.jpg')",
297            [],
298        )
299        .unwrap();
300
301        let result = TrashService::empty_trash(&conn, temp.path()).unwrap();
302
303        assert_eq!(result.files_deleted, 1);
304        assert_eq!(result.db_records_deleted, 1);
305        assert!(!photo_path.exists());
306        let count: i64 = conn
307            .query_row("SELECT COUNT(*) FROM photos", [], |row| row.get(0))
308            .unwrap();
309        assert_eq!(count, 0);
310    }
311
312    #[test]
313    fn permanent_delete_refuses_symlink_escape() {
314        let temp = tempfile::tempdir().unwrap();
315        let outside = tempfile::tempdir().unwrap();
316        let outside_file = outside.path().join("secret.jpg");
317        std::fs::write(&outside_file, b"secret").unwrap();
318
319        #[cfg(unix)]
320        std::os::unix::fs::symlink(outside.path(), temp.path().join("link")).unwrap();
321
322        #[cfg(windows)]
323        if std::os::windows::fs::symlink_dir(outside.path(), temp.path().join("link")).is_err() {
324            return;
325        }
326
327        let conn = Connection::open_in_memory().unwrap();
328        create_schema(&conn).unwrap();
329        conn.execute(
330            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, is_trashed)
331             VALUES (1, 'link/secret.jpg', 'secret.jpg', 'hash', 6, TRUE)",
332            [],
333        )
334        .unwrap();
335        conn.execute(
336            "INSERT INTO trash (photo_id, original_path) VALUES (1, 'link/secret.jpg')",
337            [],
338        )
339        .unwrap();
340
341        let result = TrashService::permanent_delete(&conn, &[1], temp.path()).unwrap();
342
343        assert_eq!(result.files_deleted, 0);
344        assert_eq!(result.db_records_deleted, 0);
345        assert!(!result.errors.is_empty());
346        assert!(outside_file.exists());
347        let count: i64 = conn
348            .query_row("SELECT COUNT(*) FROM photos WHERE id = 1", [], |row| {
349                row.get(0)
350            })
351            .unwrap();
352        assert_eq!(count, 1);
353    }
354
355    #[test]
356    fn trash_and_restore_refresh_face_cluster_stats() {
357        let conn = Connection::open_in_memory().unwrap();
358        create_schema(&conn).unwrap();
359        conn.execute(
360            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size)
361             VALUES (1, 'one.jpg', 'one.jpg', 'h1', 8),
362                    (2, 'two.jpg', 'two.jpg', 'h2', 8)",
363            [],
364        )
365        .unwrap();
366        conn.execute(
367            "INSERT INTO face_clusters (id, face_count, photo_count)
368             VALUES (10, 2, 2)",
369            [],
370        )
371        .unwrap();
372        conn.execute(
373            "INSERT INTO faces
374                (id, photo_id, bbox_x, bbox_y, bbox_width, bbox_height, embedding, cluster_id, confidence)
375             VALUES
376                (1, 1, 0.1, 0.1, 0.2, 0.2, zeroblob(16), 10, 0.5),
377                (2, 2, 0.1, 0.1, 0.2, 0.2, zeroblob(16), 10, 0.9)",
378            [],
379        )
380        .unwrap();
381        conn.execute(
382            "UPDATE face_clusters SET representative_face_id = 2 WHERE id = 10",
383            [],
384        )
385        .unwrap();
386
387        assert_eq!(TrashService::trash_photos(&conn, &[2]).unwrap(), 1);
388        let after_trash: (i64, i64, i64) = conn
389            .query_row(
390                "SELECT face_count, photo_count, representative_face_id FROM face_clusters WHERE id = 10",
391                [],
392                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
393            )
394            .unwrap();
395        assert_eq!(after_trash, (1, 1, 1));
396
397        assert_eq!(TrashService::restore_photos(&conn, &[2]).unwrap(), 1);
398        let after_restore: (i64, i64, i64) = conn
399            .query_row(
400                "SELECT face_count, photo_count, representative_face_id FROM face_clusters WHERE id = 10",
401                [],
402                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
403            )
404            .unwrap();
405        assert_eq!(after_restore, (2, 2, 2));
406    }
407}