1use 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#[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#[derive(Debug, Default, Clone)]
21pub struct TrashStats {
22 pub count: usize,
23 pub total_size: u64,
24}
25
26pub 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}