1use crate::error::{Error, Result};
6use crate::types::*;
7use rusqlite::Connection;
8use std::collections::HashMap;
9
10pub fn faces_list(conn: &Connection) -> Result<FacesData> {
12 let mut people: HashMap<String, PersonData> = HashMap::new();
13 {
14 let mut stmt = conn.prepare(
15 "SELECT f.id, f.hash, f.person_label, COALESCE(p.full_name, f.person_label) \
19 FROM faces f LEFT JOIN people p ON p.name = f.person_label \
20 WHERE f.confirmed = 1 AND f.person_label IS NOT NULL \
21 ORDER BY f.person_label, f.is_primary DESC, f.id ASC",
22 )?;
23 let rows = stmt.query_map([], |r| {
24 Ok((
25 r.get::<_, i64>(0)?,
26 r.get::<_, String>(1)?,
27 r.get::<_, String>(2)?,
28 r.get::<_, String>(3)?,
29 ))
30 })?;
31 for row in rows {
32 let (id, hash, label, full_name) = row?;
33 let person = people.entry(label.clone()).or_insert(PersonData {
34 label: label.clone(),
35 full_name,
36 face_ids: vec![],
37 representative_id: id,
38 hashes: vec![],
39 });
40 person.face_ids.push(id);
41 if !person.hashes.contains(&hash) {
42 person.hashes.push(hash);
43 }
44 }
45 }
46
47 let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
48 {
49 let mut stmt = conn.prepare(
50 "SELECT id, hash, cluster_id FROM faces \
51 WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
52 ORDER BY cluster_id, id",
53 )?;
54 let rows = stmt.query_map([], |r| {
55 Ok((
56 r.get::<_, i64>(0)?,
57 r.get::<_, String>(1)?,
58 r.get::<_, i64>(2)?,
59 ))
60 })?;
61 for row in rows {
62 let (id, hash, cid) = row?;
63 let cluster = cluster_map.entry(cid).or_insert(ClusterData {
64 cluster_id: cid,
65 face_ids: vec![],
66 hashes: vec![],
67 });
68 cluster.face_ids.push(id);
69 if !cluster.hashes.contains(&hash) {
70 cluster.hashes.push(hash);
71 }
72 }
73 }
74
75 let mut singletons: Vec<SingletonData> = vec![];
76 {
77 let mut stmt = conn.prepare(
78 "SELECT id, hash FROM faces \
79 WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
80 ORDER BY id",
81 )?;
82 let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
83 for row in rows {
84 let (id, hash) = row?;
85 singletons.push(SingletonData { face_id: id, hash });
86 }
87 }
88
89 let mut people: Vec<PersonData> = people.into_values().collect();
101 people.sort_by(|a, b| a.full_name.to_lowercase().cmp(&b.full_name.to_lowercase()));
102 let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
103 clusters.sort_by(|a, b| {
104 b.face_ids
105 .len()
106 .cmp(&a.face_ids.len())
107 .then(a.cluster_id.cmp(&b.cluster_id))
108 });
109
110 Ok(FacesData {
111 people,
112 clusters,
113 singletons,
114 })
115}
116
117pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
119 let mut stmt = conn.prepare(
120 "SELECT f.id, f.hash, fh.path FROM faces f \
121 JOIN file_hashes fh ON f.hash = fh.hash \
122 WHERE f.cluster_id = ?1 ORDER BY f.id",
123 )?;
124 let faces = stmt
125 .query_map([cluster_id], |r| {
126 Ok(ClusterFaceData {
127 face_id: r.get(0)?,
128 hash: r.get(1)?,
129 path: r.get(2)?,
130 })
131 })?
132 .collect::<rusqlite::Result<Vec<_>>>()?;
133 Ok(ClusterDetail { cluster_id, faces })
134}
135
136pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
138 let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
142 let name = name.as_str();
143 let mut stmt = conn.prepare(
144 "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
145 JOIN file_hashes fh ON f.hash = fh.hash \
146 WHERE f.person_label = ?1 AND f.confirmed = 1 \
147 ORDER BY f.is_primary DESC, f.id",
148 )?;
149 let faces = stmt
150 .query_map([name], |r| {
151 Ok(PersonFaceData {
152 face_id: r.get(0)?,
153 hash: r.get(1)?,
154 path: r.get(2)?,
155 is_primary: r.get::<_, i64>(3)? != 0,
156 })
157 })?
158 .collect::<rusqlite::Result<Vec<_>>>()?;
159 let full_name: String = conn
162 .query_row(
163 "SELECT full_name FROM people WHERE name = ?1",
164 rusqlite::params![name],
165 |r| r.get(0),
166 )
167 .unwrap_or_else(|_| name.to_string());
168 Ok(PersonDetail {
169 label: name.to_string(),
170 full_name,
171 faces,
172 })
173}
174
175pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
178 Ok(videre_core::person_search::search_by_person(
179 conn, name, None,
180 )?)
181}
182
183pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
186 let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
190 let label = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
191 conn.execute(
192 "INSERT INTO people (name, full_name) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING",
193 rusqlite::params![&label, &display],
194 )?;
195 for id in face_ids {
196 conn.execute(
197 "UPDATE faces SET person_label = ?1, confirmed = 1 WHERE id = ?2",
198 rusqlite::params![label, id],
199 )?;
200 }
201 Ok(())
202}
203
204pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
208 assign(conn, face_ids, label)
209}
210
211pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
213 conn.execute(
214 "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
215 [face_id],
216 )?;
217 Ok(())
218}
219
220pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
222 conn.execute(
223 "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
224 [cluster_id],
225 )?;
226 Ok(())
227}
228
229pub fn set_full_name(conn: &Connection, name: &str, full_name: &str) -> Result<()> {
240 let display = crate::label::sanitize_person_label(full_name).ok_or(Error::Invalid)?;
241 let name = videre_core::person::normalize(name).ok_or(Error::Invalid)?;
242 let n = conn.execute(
243 "UPDATE people SET full_name = ?1 WHERE name = ?2",
244 rusqlite::params![display, name],
245 )?;
246 if n == 0 {
247 return Err(Error::NotFound);
248 }
249 Ok(())
250}
251
252pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
253 let label = videre_core::person::normalize(label).unwrap_or_else(|| label.to_string());
254 conn.execute(
255 "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0 WHERE person_label = ?1",
256 rusqlite::params![label],
257 )?;
258 Ok(())
259}
260
261pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
266 let person_label =
267 videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
268 conn.execute_batch("BEGIN")?;
269 let result = (|| -> rusqlite::Result<()> {
270 conn.execute(
271 "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
272 rusqlite::params![person_label],
273 )?;
274 conn.execute(
275 "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
276 rusqlite::params![person_label, face_id],
277 )?;
278 Ok(())
279 })();
280 match result {
281 Ok(()) => {
282 conn.execute_batch("COMMIT")?;
283 Ok(())
284 }
285 Err(e) => {
286 let _ = conn.execute_batch("ROLLBACK");
287 Err(Error::Db(e))
288 }
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 pub(super) fn seed() -> Connection {
303 let conn = Connection::open_in_memory().unwrap();
304 videre_core::face_db::create_faces_table(&conn).unwrap();
305 conn.execute_batch(
306 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
307 INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
308 ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
309 -- Labels are stored in identity form, as `assign` writes them and
310 -- as the migration leaves them; `people` carries what a reader
311 -- sees. Seeding raw 'Alice' would test a state the application no
312 -- longer produces.
313 INSERT INTO people (name, full_name) VALUES ('alice','Alice');
314 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
315 (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
316 (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
317 (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
318 (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
319 (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
320 )
321 .unwrap();
322 videre_core::db::ensure_file_hashes_columns(&conn);
323 conn
324 }
325
326 #[test]
327 fn the_list_comes_back_in_the_same_order_every_time() {
328 let conn = seed();
335 conn.execute_batch(
339 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
342 ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
343 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
344 (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
345 (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
346 (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
347 (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
348 (10,'h10','0,0,9,9',X'0000',NULL,'Bob',1,0);",
349 )
350 .unwrap();
351
352 let a = faces_list(&conn).unwrap();
355 let b = faces_list(&conn).unwrap();
356
357 let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
358 let names =
359 |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
360 assert!(ids(&a).len() >= 3, "fixture must have several clusters");
361 assert_eq!(
362 ids(&a),
363 ids(&b),
364 "cluster order must not change between calls"
365 );
366 assert_eq!(
367 names(&a),
368 names(&b),
369 "people order must not change between calls"
370 );
371
372 let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
375 let mut want = sizes.clone();
376 want.sort_unstable_by(|x, y| y.cmp(x));
377 assert_eq!(
378 sizes, want,
379 "clusters must be ordered largest first, got {sizes:?}"
380 );
381 }
382
383 #[test]
384 fn faces_list_splits_people_clusters_singletons() {
385 let conn = seed();
386 let d = faces_list(&conn).unwrap();
387 assert_eq!(d.people.len(), 1);
388 assert_eq!(d.people[0].label, "alice");
390 assert_eq!(d.people[0].full_name, "Alice");
391 assert_eq!(
392 d.people[0].representative_id, 1,
393 "primary face is representative"
394 );
395 assert_eq!(d.clusters.len(), 1);
396 assert_eq!(d.clusters[0].cluster_id, 7);
397 assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
398 assert_eq!(d.singletons.len(), 1);
399 assert_eq!(d.singletons[0].face_id, 5);
400 }
401
402 #[test]
403 fn person_detail_marks_primary() {
404 let conn = seed();
405 let p = person_detail(&conn, "Alice").unwrap();
406 assert_eq!(p.faces.len(), 2);
407 assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
408 assert!(!p.faces[1].is_primary);
409 }
410
411 #[test]
412 fn cluster_detail_lists_faces() {
413 let conn = seed();
414 let c = cluster_detail(&conn, 7).unwrap();
415 assert_eq!(c.cluster_id, 7);
416 assert_eq!(
417 c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
418 vec![3, 4]
419 );
420 }
421
422 #[test]
423 fn assign_labels_and_confirms() {
424 let conn = seed();
425 assign(&conn, &[3, 4], "Bob").unwrap();
426 let p = person_detail(&conn, "Bob").unwrap();
427 assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
428 }
429
430 #[test]
431 fn assign_rejects_empty_label() {
432 let conn = seed();
433 assert!(matches!(assign(&conn, &[3], " "), Err(Error::Invalid)));
434 }
435
436 #[test]
437 fn remove_face_unassigns_everything() {
438 let conn = seed();
439 remove_face(&conn, 1).unwrap();
440 let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
441 .query_row(
442 "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
443 [],
444 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
445 )
446 .unwrap();
447 assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
448 }
449
450 #[test]
451 fn dissolve_cluster_nulls_cluster_id() {
452 let conn = seed();
453 dissolve_cluster(&conn, 7).unwrap();
454 assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
455 assert_eq!(
456 faces_list(&conn).unwrap().singletons.len(),
457 3,
458 "3,4 join 5 as singletons"
459 );
460 }
461
462 #[test]
463 fn delete_person_unassigns_without_touching_cluster() {
464 let conn = seed();
465 conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
469 .unwrap();
470 delete_person(&conn, "Alice").unwrap();
471 assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
472 let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
473 .query_row(
474 "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
475 [],
476 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
477 )
478 .unwrap();
479 assert_eq!(cid, Some(42), "cluster_id must be preserved");
480 assert_eq!(label, None, "person_label cleared");
481 assert_eq!(confirmed, 0, "confirmed cleared");
482 }
483
484 #[test]
485 fn set_primary_is_exclusive_per_person() {
486 let conn = seed();
487 set_primary(&conn, 2, "Alice").unwrap();
488 let primaries: Vec<i64> = {
489 let mut s = conn
490 .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
491 .unwrap();
492 s.query_map([], |r| r.get(0))
493 .unwrap()
494 .collect::<rusqlite::Result<_>>()
495 .unwrap()
496 };
497 assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
498 }
499
500 #[test]
501 fn renaming_only_the_spelling_keeps_the_identity() {
502 let conn = seed();
505 set_full_name(&conn, "alice", "Alice Smith").unwrap();
506 let (name, full): (String, String) = conn
507 .query_row("SELECT name, full_name FROM people", [], |r| {
508 Ok((r.get(0)?, r.get(1)?))
509 })
510 .unwrap();
511 assert_eq!(name, "alice", "identity is unchanged");
512 assert_eq!(full, "Alice Smith", "only the display name moved");
513 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
514 }
515}
516
517#[cfg(test)]
518mod identity_tests {
519 use super::tests::seed;
520 use super::*;
521
522 fn people(conn: &Connection) -> Vec<(String, String)> {
523 conn.prepare("SELECT name, full_name FROM people ORDER BY name")
524 .unwrap()
525 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
526 .unwrap()
527 .collect::<rusqlite::Result<_>>()
528 .unwrap()
529 }
530
531 #[test]
532 fn assign_stores_the_identity_and_records_the_display_name() {
533 let conn = seed();
534 assign(&conn, &[3], "Işıl Özyeğin").unwrap();
535
536 let label: String = conn
537 .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
538 r.get(0)
539 })
540 .unwrap();
541 assert_eq!(label, "isil_ozyegin", "faces hold the identity");
542 assert!(
543 people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
544 "and the spelling is kept for display"
545 );
546 }
547
548 #[test]
549 fn assigning_an_existing_name_in_another_case_joins_that_person() {
550 let conn = seed();
553 assign(&conn, &[3], "ALICE").unwrap();
554 assert_eq!(people(&conn).len(), 1, "still one person, not two");
555 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
556 assert_eq!(
557 people(&conn)[0].1,
558 "Alice",
559 "the existing spelling is not overwritten by the new casing"
560 );
561 }
562
563 #[test]
564 fn assign_rejects_a_name_with_no_usable_identity() {
565 let conn = seed();
568 assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
569 }
570
571 #[test]
572 fn person_detail_resolves_every_form_of_the_name() {
573 let conn = seed();
574 for form in ["alice", "Alice", "ALICE", " alice "] {
575 assert_eq!(
576 person_detail(&conn, form).unwrap().faces.len(),
577 2,
578 "form {form:?}"
579 );
580 }
581 }
582
583 #[test]
584 fn person_detail_reports_the_display_name() {
585 let d = person_detail(&seed(), "alice").unwrap();
586 assert_eq!(d.label, "alice");
587 assert_eq!(d.full_name, "Alice");
588 }
589
590 #[test]
591 fn person_detail_falls_back_when_there_is_no_people_row() {
592 let conn = seed();
594 conn.execute(
595 "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
596 VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
597 [],
598 )
599 .unwrap();
600 let d = person_detail(&conn, "orphan").unwrap();
601 assert_eq!(d.full_name, "orphan", "falls back to the identity");
602 }
603
604 #[test]
605 fn set_full_name_changes_only_the_display_name() {
606 let conn = seed();
607 set_full_name(&conn, "alice", "Alice Smith").unwrap();
608 assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
609 assert_eq!(
610 person_detail(&conn, "alice").unwrap().faces.len(),
611 2,
612 "no face was touched"
613 );
614 }
615
616 #[test]
617 fn set_full_name_accepts_any_form_of_the_identity() {
618 let conn = seed();
619 set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
620 assert_eq!(people(&conn)[0].1, "Alice Smith");
621 }
622
623 #[test]
624 fn set_full_name_on_a_missing_person_is_not_found() {
625 assert!(matches!(
626 set_full_name(&seed(), "nobody", "Someone"),
627 Err(Error::NotFound)
628 ));
629 }
630
631 #[test]
632 fn set_full_name_rejects_an_empty_display_name() {
633 assert!(matches!(
635 set_full_name(&seed(), "alice", " "),
636 Err(Error::Invalid)
637 ));
638 }
639
640 #[test]
641 fn delete_person_accepts_any_form_of_the_name() {
642 let conn = seed();
643 delete_person(&conn, "Alice").unwrap();
644 let left: i64 = conn
645 .query_row(
646 "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
647 [],
648 |r| r.get(0),
649 )
650 .unwrap();
651 assert_eq!(left, 0, "faces are unassigned whichever form was passed");
652 }
653
654 #[test]
655 fn set_primary_accepts_any_form_of_the_name() {
656 let conn = seed();
657 set_primary(&conn, 2, "ALICE").unwrap();
658 let primary: i64 = conn
659 .query_row(
660 "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
661 [],
662 |r| r.get(0),
663 )
664 .unwrap();
665 assert_eq!(primary, 2);
666 }
667}