1use rusqlite::{params, params_from_iter, types::ToSql, Connection, Result as SqliteResult};
4
5use super::MAX_ROWS_PER_INSERT;
6
7#[derive(Debug, Clone)]
8pub struct PhotoStackRecord {
9 pub id: i64,
10 pub kind: String,
11 pub source_group_id: i64,
12 pub cover_photo_id: i64,
13 pub member_count: i64,
14 pub confidence: f32,
15}
16
17#[derive(Debug, Clone)]
18pub struct PhotoStackMemberRecord {
19 pub photo_id: i64,
20 pub thumbnail_path: Option<String>,
21 pub date_taken: Option<String>,
22 pub quality_score: f32,
23 pub score_reasons: Option<String>,
24 pub is_cover: bool,
25}
26
27#[derive(Debug, Clone)]
28pub struct StackCandidate {
29 pub kind: String,
30 pub source_group_id: i64,
31 pub source_group_hash: Option<String>,
32 pub photo_ids: Vec<i64>,
33 pub cover_photo_id: i64,
34 pub confidence: f32,
35 pub member_scores: Vec<(i64, f32, String)>,
36}
37
38pub struct PhotoStackRepo<'a> {
39 conn: &'a Connection,
40}
41
42impl<'a> PhotoStackRepo<'a> {
43 pub fn new(conn: &'a Connection) -> Self {
44 Self { conn }
45 }
46
47 pub fn sync_stacks(&self, candidates: &[StackCandidate]) -> SqliteResult<()> {
48 use std::collections::HashSet;
49
50 let tx = self.conn.unchecked_transaction()?;
51 let mut seen: HashSet<(String, i64)> = HashSet::new();
52
53 for c in candidates {
54 if c.photo_ids.len() < 2 || !c.photo_ids.contains(&c.cover_photo_id) {
55 continue;
56 }
57 seen.insert((c.kind.clone(), c.source_group_id));
58
59 let existing = tx.query_row(
60 "SELECT id, dismissed, cover_photo_id FROM photo_stacks WHERE kind = ?1 AND source_group_id = ?2",
61 params![c.kind, c.source_group_id],
62 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, bool>(1)?, r.get::<_, i64>(2)?)),
63 );
64
65 let (stack_id, dismissed, existing_cover) = match existing {
66 Ok(row) => row,
67 Err(rusqlite::Error::QueryReturnedNoRows) => {
68 tx.execute(
69 r#"
70 INSERT INTO photo_stacks
71 (kind, source_group_id, source_group_hash, cover_photo_id, confidence)
72 VALUES (?1, ?2, ?3, ?4, ?5)
73 "#,
74 params![
75 c.kind,
76 c.source_group_id,
77 c.source_group_hash,
78 c.cover_photo_id,
79 c.confidence
80 ],
81 )?;
82 (tx.last_insert_rowid(), false, c.cover_photo_id)
83 }
84 Err(e) => return Err(e),
85 };
86
87 if dismissed {
88 continue;
89 }
90
91 let cover = if c.photo_ids.contains(&existing_cover) {
92 existing_cover
93 } else {
94 c.cover_photo_id
95 };
96
97 tx.execute(
98 r#"
99 UPDATE photo_stacks
100 SET source_group_hash = ?3,
101 cover_photo_id = ?4,
102 confidence = ?5,
103 updated_at = CURRENT_TIMESTAMP
104 WHERE kind = ?1 AND source_group_id = ?2
105 "#,
106 params![
107 c.kind,
108 c.source_group_id,
109 c.source_group_hash,
110 cover,
111 c.confidence
112 ],
113 )?;
114 tx.execute(
115 "DELETE FROM photo_stack_members WHERE stack_id = ?1",
116 params![stack_id],
117 )?;
118 insert_members(&tx, stack_id, cover, &c.member_scores)?;
119 }
120
121 let mut stmt = tx.prepare(
122 "SELECT id, kind, source_group_id FROM photo_stacks WHERE dismissed = FALSE",
123 )?;
124 let existing: Vec<(i64, String, i64)> = stmt
125 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
126 .collect::<SqliteResult<Vec<_>>>()?;
127 drop(stmt);
128 for (id, kind, source_id) in existing {
129 if !seen.contains(&(kind, source_id)) {
130 tx.execute("DELETE FROM photo_stacks WHERE id = ?1", params![id])?;
131 }
132 }
133
134 tx.commit()
135 }
136
137 pub fn get_stack(&self, stack_id: i64) -> SqliteResult<Option<PhotoStackRecord>> {
138 match self.conn.query_row(
139 r#"
140 SELECT s.id, s.kind, s.source_group_id, s.cover_photo_id,
141 COUNT(m.id), s.confidence
142 FROM photo_stacks s
143 JOIN photo_stack_members m ON m.stack_id = s.id
144 JOIN photos p ON p.id = m.photo_id AND p.is_trashed = FALSE
145 WHERE s.id = ?1 AND s.dismissed = FALSE
146 GROUP BY s.id
147 HAVING COUNT(m.id) >= 2
148 "#,
149 params![stack_id],
150 row_to_stack,
151 ) {
152 Ok(s) => Ok(Some(s)),
153 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
154 Err(e) => Err(e),
155 }
156 }
157
158 pub fn get_stack_for_photo(&self, photo_id: i64) -> SqliteResult<Option<PhotoStackRecord>> {
159 match self.conn.query_row(
160 r#"
161 SELECT s.id, s.kind, s.source_group_id, s.cover_photo_id,
162 COUNT(all_m.id), s.confidence
163 FROM photo_stack_members m
164 JOIN photos p ON p.id = m.photo_id AND p.is_trashed = FALSE
165 JOIN photo_stacks s ON s.id = m.stack_id
166 JOIN photo_stack_members all_m ON all_m.stack_id = s.id
167 JOIN photos all_p ON all_p.id = all_m.photo_id AND all_p.is_trashed = FALSE
168 WHERE m.photo_id = ?1 AND s.dismissed = FALSE
169 GROUP BY s.id
170 HAVING COUNT(all_m.id) >= 2
171 "#,
172 params![photo_id],
173 row_to_stack,
174 ) {
175 Ok(s) => Ok(Some(s)),
176 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
177 Err(e) => Err(e),
178 }
179 }
180
181 pub fn get_members(&self, stack_id: i64) -> SqliteResult<Vec<PhotoStackMemberRecord>> {
182 let mut stmt = self.conn.prepare(
183 r#"
184 SELECT m.photo_id, p.thumbnail_path, p.date_taken,
185 m.quality_score, m.score_reasons, m.is_cover
186 FROM photo_stack_members m
187 JOIN photos p ON p.id = m.photo_id
188 JOIN photo_stacks s ON s.id = m.stack_id
189 WHERE m.stack_id = ?1 AND s.dismissed = FALSE AND p.is_trashed = FALSE
190 ORDER BY m.is_cover DESC, m.quality_score DESC, p.date_taken ASC, m.photo_id ASC
191 "#,
192 )?;
193 let rows = stmt.query_map(params![stack_id], |r| {
194 Ok(PhotoStackMemberRecord {
195 photo_id: r.get(0)?,
196 thumbnail_path: r.get(1)?,
197 date_taken: r.get(2)?,
198 quality_score: r.get(3)?,
199 score_reasons: r.get(4)?,
200 is_cover: r.get(5)?,
201 })
202 })?;
203 rows.collect()
204 }
205
206 pub fn set_cover(&self, stack_id: i64, photo_id: i64) -> SqliteResult<()> {
207 let tx = self.conn.unchecked_transaction()?;
208 let exists: i64 = tx.query_row(
209 "SELECT COUNT(*)
210 FROM photo_stack_members m
211 JOIN photo_stacks s ON s.id = m.stack_id
212 JOIN photos p ON p.id = m.photo_id
213 WHERE m.stack_id = ?1
214 AND m.photo_id = ?2
215 AND s.dismissed = FALSE
216 AND p.is_trashed = FALSE",
217 params![stack_id, photo_id],
218 |r| r.get(0),
219 )?;
220 if exists == 0 {
221 return Err(rusqlite::Error::QueryReturnedNoRows);
222 }
223 tx.execute(
224 "UPDATE photo_stack_members SET is_cover = FALSE WHERE stack_id = ?1",
225 params![stack_id],
226 )?;
227 tx.execute(
228 "UPDATE photo_stack_members SET is_cover = TRUE WHERE stack_id = ?1 AND photo_id = ?2",
229 params![stack_id, photo_id],
230 )?;
231 tx.execute(
232 "UPDATE photo_stacks SET cover_photo_id = ?2, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
233 params![stack_id, photo_id],
234 )?;
235 tx.commit()
236 }
237
238 pub fn remove_member(&self, stack_id: i64, photo_id: i64) -> SqliteResult<()> {
239 let tx = self.conn.unchecked_transaction()?;
240 let removed = tx.execute(
241 "DELETE FROM photo_stack_members
242 WHERE stack_id = ?1
243 AND photo_id = ?2
244 AND EXISTS (
245 SELECT 1 FROM photo_stacks s
246 WHERE s.id = ?1 AND s.dismissed = FALSE
247 )",
248 params![stack_id, photo_id],
249 )?;
250 if removed == 0 {
251 return Err(rusqlite::Error::QueryReturnedNoRows);
252 }
253 let count: i64 = tx.query_row(
254 "SELECT COUNT(*)
255 FROM photo_stack_members m
256 JOIN photos p ON p.id = m.photo_id
257 WHERE m.stack_id = ?1 AND p.is_trashed = FALSE",
258 params![stack_id],
259 |r| r.get(0),
260 )?;
261 if count < 2 {
262 tx.execute("DELETE FROM photo_stacks WHERE id = ?1", params![stack_id])?;
263 tx.commit()?;
264 return Ok(());
265 }
266 let cover_exists: i64 = tx.query_row(
267 "SELECT COUNT(*)
268 FROM photo_stack_members m
269 JOIN photos p ON p.id = m.photo_id
270 WHERE m.stack_id = ?1 AND m.is_cover = TRUE AND p.is_trashed = FALSE",
271 params![stack_id],
272 |r| r.get(0),
273 )?;
274 if cover_exists == 0 {
275 let next_cover: i64 = tx.query_row(
276 "SELECT m.photo_id
277 FROM photo_stack_members m
278 JOIN photos p ON p.id = m.photo_id
279 WHERE m.stack_id = ?1 AND p.is_trashed = FALSE
280 ORDER BY m.quality_score DESC, m.photo_id ASC
281 LIMIT 1",
282 params![stack_id],
283 |r| r.get(0),
284 )?;
285 tx.execute(
286 "UPDATE photo_stack_members SET is_cover = TRUE WHERE stack_id = ?1 AND photo_id = ?2",
287 params![stack_id, next_cover],
288 )?;
289 tx.execute(
290 "UPDATE photo_stacks SET cover_photo_id = ?2 WHERE id = ?1",
291 params![stack_id, next_cover],
292 )?;
293 }
294 tx.commit()
295 }
296
297 pub fn unstack(&self, stack_id: i64) -> SqliteResult<()> {
298 let updated = self.conn.execute(
299 "UPDATE photo_stacks
300 SET dismissed = TRUE, updated_at = CURRENT_TIMESTAMP
301 WHERE id = ?1 AND dismissed = FALSE",
302 params![stack_id],
303 )?;
304 if updated == 0 {
305 return Err(rusqlite::Error::QueryReturnedNoRows);
306 }
307 Ok(())
308 }
309
310 pub fn photos_to_trash_except_cover(&self, stack_id: i64) -> SqliteResult<Vec<i64>> {
311 let mut stmt = self.conn.prepare(
312 r#"
313 SELECT m.photo_id
314 FROM photo_stack_members m
315 JOIN photo_stacks s ON s.id = m.stack_id
316 JOIN photos p ON p.id = m.photo_id
317 WHERE m.stack_id = ?1 AND m.photo_id != s.cover_photo_id
318 AND s.dismissed = FALSE
319 AND p.is_trashed = FALSE
320 "#,
321 )?;
322 let rows = stmt.query_map(params![stack_id], |r| r.get(0))?;
323 rows.collect()
324 }
325
326 pub fn reconcile_after_photos_trashed(&self, photo_ids: &[i64]) -> SqliteResult<()> {
327 let mut ids: Vec<i64> = photo_ids.iter().copied().filter(|id| *id > 0).collect();
328 ids.sort_unstable();
329 ids.dedup();
330 if ids.is_empty() {
331 return Ok(());
332 }
333
334 let placeholders = repeat_vars(ids.len());
335 let mut stmt = self.conn.prepare(&format!(
336 "SELECT DISTINCT stack_id FROM photo_stack_members WHERE photo_id IN ({placeholders})"
337 ))?;
338 let stack_ids: Vec<i64> = stmt
339 .query_map(params_from_iter(ids.iter()), |r| r.get(0))?
340 .collect::<SqliteResult<Vec<_>>>()?;
341 drop(stmt);
342
343 self.conn.execute(
344 &format!("DELETE FROM photo_stack_members WHERE photo_id IN ({placeholders})"),
345 params_from_iter(ids.iter()),
346 )?;
347
348 for stack_id in stack_ids {
349 let live_count: i64 = self.conn.query_row(
350 "SELECT COUNT(*)
351 FROM photo_stack_members m
352 JOIN photos p ON p.id = m.photo_id
353 WHERE m.stack_id = ?1 AND p.is_trashed = FALSE",
354 params![stack_id],
355 |r| r.get(0),
356 )?;
357 if live_count < 2 {
358 self.conn
359 .execute("DELETE FROM photo_stacks WHERE id = ?1", params![stack_id])?;
360 continue;
361 }
362
363 let live_cover_exists: i64 = self.conn.query_row(
364 "SELECT COUNT(*)
365 FROM photo_stack_members m
366 JOIN photos p ON p.id = m.photo_id
367 WHERE m.stack_id = ?1 AND m.is_cover = TRUE AND p.is_trashed = FALSE",
368 params![stack_id],
369 |r| r.get(0),
370 )?;
371 if live_cover_exists > 0 {
372 continue;
373 }
374
375 let next_cover: i64 = self.conn.query_row(
376 "SELECT m.photo_id
377 FROM photo_stack_members m
378 JOIN photos p ON p.id = m.photo_id
379 WHERE m.stack_id = ?1 AND p.is_trashed = FALSE
380 ORDER BY m.quality_score DESC, m.photo_id ASC
381 LIMIT 1",
382 params![stack_id],
383 |r| r.get(0),
384 )?;
385 self.conn.execute(
386 "UPDATE photo_stack_members SET is_cover = FALSE WHERE stack_id = ?1",
387 params![stack_id],
388 )?;
389 self.conn.execute(
390 "UPDATE photo_stack_members SET is_cover = TRUE WHERE stack_id = ?1 AND photo_id = ?2",
391 params![stack_id, next_cover],
392 )?;
393 self.conn.execute(
394 "UPDATE photo_stacks SET cover_photo_id = ?2, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
395 params![stack_id, next_cover],
396 )?;
397 }
398
399 Ok(())
400 }
401
402 pub fn delete_stack(&self, stack_id: i64) -> SqliteResult<()> {
403 self.conn
404 .execute("DELETE FROM photo_stacks WHERE id = ?1", params![stack_id])?;
405 Ok(())
406 }
407}
408
409fn row_to_stack(row: &rusqlite::Row) -> SqliteResult<PhotoStackRecord> {
410 Ok(PhotoStackRecord {
411 id: row.get(0)?,
412 kind: row.get(1)?,
413 source_group_id: row.get(2)?,
414 cover_photo_id: row.get(3)?,
415 member_count: row.get(4)?,
416 confidence: row.get(5)?,
417 })
418}
419
420fn insert_members(
421 conn: &Connection,
422 stack_id: i64,
423 cover_photo_id: i64,
424 members: &[(i64, f32, String)],
425) -> SqliteResult<()> {
426 for chunk in members.chunks(MAX_ROWS_PER_INSERT) {
427 let placeholders = (0..chunk.len())
428 .map(|_| "(?, ?, ?, ?, ?)")
429 .collect::<Vec<_>>()
430 .join(", ");
431 let sql = format!(
432 "INSERT INTO photo_stack_members \
433 (stack_id, photo_id, quality_score, score_reasons, is_cover) VALUES {}",
434 placeholders
435 );
436 let mut values: Vec<Box<dyn ToSql>> = Vec::with_capacity(chunk.len() * 5);
437 for (photo_id, score, reasons) in chunk {
438 values.push(Box::new(stack_id));
439 values.push(Box::new(*photo_id));
440 values.push(Box::new(*score));
441 values.push(Box::new(reasons.clone()));
442 values.push(Box::new(*photo_id == cover_photo_id));
443 }
444 let refs: Vec<&dyn ToSql> = values.iter().map(|v| v.as_ref()).collect();
445 conn.execute(&sql, refs.as_slice())?;
446 }
447 Ok(())
448}
449
450fn repeat_vars(count: usize) -> String {
451 std::iter::repeat_n("?", count)
452 .collect::<Vec<_>>()
453 .join(", ")
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn setup() -> Connection {
461 let conn = Connection::open_in_memory().expect("open in-memory db");
462 crate::db::create_schema(&conn).expect("create schema");
463 conn
464 }
465
466 fn insert_photo(conn: &Connection, id: i64) {
467 conn.execute(
468 r#"
469 INSERT INTO photos
470 (id, file_path, file_name, file_hash, file_size, date_taken, thumbnail_path)
471 VALUES
472 (?1, ?2, ?3, ?4, ?5, ?6, ?7)
473 "#,
474 params![
475 id,
476 format!("IMG_{id:04}.jpg"),
477 format!("IMG_{id:04}.jpg"),
478 format!("hash-{id}"),
479 1000 + id,
480 format!("2024-01-01T12:0{id}:00Z"),
481 format!(".photovault/thumbs/{id}.jpg"),
482 ],
483 )
484 .expect("insert photo");
485 }
486
487 fn candidate(cover_photo_id: i64) -> StackCandidate {
488 StackCandidate {
489 kind: "burst".into(),
490 source_group_id: 42,
491 source_group_hash: None,
492 photo_ids: vec![1, 2, 3],
493 cover_photo_id,
494 confidence: 0.8,
495 member_scores: vec![
496 (1, 10.0, "resolution".into()),
497 (2, 30.0, "sharpness".into()),
498 (3, 20.0, "faces".into()),
499 ],
500 }
501 }
502
503 #[test]
504 fn sync_stacks_persists_members_and_cover() {
505 let conn = setup();
506 for id in 1..=3 {
507 insert_photo(&conn, id);
508 }
509
510 let repo = PhotoStackRepo::new(&conn);
511 repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
512
513 let stack = repo.get_stack_for_photo(1).expect("load stack").unwrap();
514 assert_eq!(stack.kind, "burst");
515 assert_eq!(stack.cover_photo_id, 2);
516 assert_eq!(stack.member_count, 3);
517
518 let members = repo.get_members(stack.id).expect("load members");
519 assert_eq!(members.len(), 3);
520 assert_eq!(members[0].photo_id, 2);
521 assert!(members[0].is_cover);
522 }
523
524 #[test]
525 fn refresh_preserves_manual_cover_when_member_still_exists() {
526 let conn = setup();
527 for id in 1..=3 {
528 insert_photo(&conn, id);
529 }
530
531 let repo = PhotoStackRepo::new(&conn);
532 repo.sync_stacks(&[candidate(2)]).expect("initial sync");
533 let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
534 repo.set_cover(stack_id, 1).expect("manual cover");
535
536 repo.sync_stacks(&[candidate(2)]).expect("refresh");
537
538 let stack = repo.get_stack(stack_id).expect("load stack").unwrap();
539 assert_eq!(stack.cover_photo_id, 1);
540 let members = repo.get_members(stack_id).expect("load members");
541 assert_eq!(members[0].photo_id, 1);
542 assert!(members[0].is_cover);
543 }
544
545 #[test]
546 fn sync_stacks_preserves_dismissed_stacks_not_seen_this_run() {
547 let conn = setup();
548 for id in 1..=3 {
549 insert_photo(&conn, id);
550 }
551
552 let repo = PhotoStackRepo::new(&conn);
553 repo.sync_stacks(&[candidate(2)]).expect("initial sync");
554 let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
555 repo.unstack(stack_id).expect("dismiss stack");
556
557 repo.sync_stacks(&[]).expect("refresh with no candidates");
558
559 let dismissed: bool = conn
560 .query_row(
561 "SELECT dismissed FROM photo_stacks WHERE id = ?1",
562 params![stack_id],
563 |row| row.get(0),
564 )
565 .expect("dismissed row remains");
566 assert!(dismissed);
567 }
568
569 #[test]
570 fn removing_cover_promotes_best_remaining_member_and_deletes_singleton() {
571 let conn = setup();
572 for id in 1..=3 {
573 insert_photo(&conn, id);
574 }
575
576 let repo = PhotoStackRepo::new(&conn);
577 repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
578 let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
579
580 repo.remove_member(stack_id, 2).expect("remove cover");
581 let stack = repo.get_stack(stack_id).expect("load stack").unwrap();
582 assert_eq!(stack.cover_photo_id, 3);
583
584 repo.remove_member(stack_id, 3)
585 .expect("remove second member");
586 assert!(repo.get_stack(stack_id).expect("load stack").is_none());
587 }
588
589 #[test]
590 fn trashed_members_are_hidden_from_stack_reads() {
591 let conn = setup();
592 for id in 1..=3 {
593 insert_photo(&conn, id);
594 }
595
596 let repo = PhotoStackRepo::new(&conn);
597 repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
598 conn.execute("UPDATE photos SET is_trashed = TRUE WHERE id = 3", [])
599 .expect("trash photo");
600
601 let stack = repo.get_stack_for_photo(1).expect("load stack").unwrap();
602 assert_eq!(stack.member_count, 2);
603 let members = repo.get_members(stack.id).expect("load members");
604 assert_eq!(members.len(), 2);
605 assert!(members.iter().all(|m| m.photo_id != 3));
606 assert!(repo
607 .get_stack_for_photo(3)
608 .expect("trashed photo stack")
609 .is_none());
610 }
611
612 #[test]
613 fn reconcile_after_trash_prunes_singletons_and_promotes_live_cover() {
614 let conn = setup();
615 for id in 1..=3 {
616 insert_photo(&conn, id);
617 }
618
619 let repo = PhotoStackRepo::new(&conn);
620 repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
621 let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
622
623 conn.execute("UPDATE photos SET is_trashed = TRUE WHERE id = 2", [])
624 .expect("trash cover");
625 repo.reconcile_after_photos_trashed(&[2])
626 .expect("reconcile cover");
627 let stack = repo.get_stack(stack_id).expect("load stack").unwrap();
628 assert_eq!(stack.cover_photo_id, 3);
629 assert_eq!(stack.member_count, 2);
630
631 conn.execute("UPDATE photos SET is_trashed = TRUE WHERE id = 3", [])
632 .expect("trash second member");
633 repo.reconcile_after_photos_trashed(&[3])
634 .expect("reconcile singleton");
635 assert!(repo.get_stack(stack_id).expect("load stack").is_none());
636 }
637
638 #[test]
639 fn dismissed_stack_rejects_stale_detail_mutations() {
640 let conn = setup();
641 for id in 1..=3 {
642 insert_photo(&conn, id);
643 }
644
645 let repo = PhotoStackRepo::new(&conn);
646 repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
647 let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
648 repo.unstack(stack_id).expect("dismiss stack");
649
650 assert!(repo.set_cover(stack_id, 1).is_err());
651 assert!(repo.remove_member(stack_id, 1).is_err());
652 assert!(repo.unstack(stack_id).is_err());
653 assert!(repo
654 .photos_to_trash_except_cover(stack_id)
655 .unwrap()
656 .is_empty());
657 }
658}