1use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11use chrono::{Datelike, Duration, NaiveDate};
12use rusqlite::{params, Connection, Result as SqliteResult};
13
14use crate::services::semantic::{SemanticIndexCache, SemanticSearchService, SEMANTIC_MODEL_KEY};
15
16pub type MemoryId = String;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum MemoryKind {
20 OnThisDay,
21 FallbackWindow,
22 SeasonalRecap,
23 PersonStory,
24 PlaceStory,
25 VisualPattern,
26 YearRecap,
30}
31
32#[derive(Debug, Clone)]
33pub struct Memory {
34 pub id: MemoryId,
35 pub kind: MemoryKind,
36 pub title: String,
37 pub photo_ids: Vec<i64>,
38 pub hero_photo_id: i64,
39 pub hero_thumbnail_path: Option<String>,
43 pub score: f32,
44 pub year: i32,
45 pub has_faces: bool,
46}
47
48#[derive(Debug, Clone)]
49pub struct MemoryCard {
50 pub id: MemoryId,
51 #[allow(dead_code)]
53 pub kind: MemoryKind,
54 pub title: String,
55 pub hero_photo_id: i64,
56 pub hero_thumbnail_path: Option<String>,
57 pub photo_count: usize,
58 pub photo_ids: Vec<i64>,
59}
60
61impl From<Memory> for MemoryCard {
62 fn from(m: Memory) -> Self {
63 Self {
64 id: m.id,
65 kind: m.kind,
66 title: m.title,
67 hero_photo_id: m.hero_photo_id,
68 hero_thumbnail_path: m.hero_thumbnail_path,
69 photo_count: m.photo_ids.len(),
70 photo_ids: m.photo_ids,
71 }
72 }
73}
74
75const MIN_LIBRARY_AGE_MONTHS: i64 = 3;
78
79const MIN_PHOTO_AGE_MONTHS: i64 = 3;
82
83const MAX_CARDS: usize = 20;
85
86const SEASONAL_MIN_PHOTOS: i64 = 5;
90
91const YEAR_RECAP_MAX_PHOTOS: usize = 50;
93const MAX_MEMORY_PHOTOS: usize = 500;
94
95pub fn generate_for_today(conn: &Connection, today: NaiveDate) -> Result<Vec<MemoryCard>, String> {
98 generate_for_today_inner(conn, today, None)
99}
100
101pub fn generate_for_today_with_semantic(
102 conn: &Connection,
103 today: NaiveDate,
104 drive_root: &Path,
105 cache: &mut SemanticIndexCache,
106) -> Result<Vec<MemoryCard>, String> {
107 let visual = visual_pattern(conn, today, drive_root, cache).unwrap_or_else(|err| {
108 tracing::debug!("visual-pattern memory skipped: {err}");
109 None
110 });
111 generate_for_today_inner(conn, today, visual)
112}
113
114fn generate_for_today_inner(
115 conn: &Connection,
116 today: NaiveDate,
117 visual: Option<Memory>,
118) -> Result<Vec<MemoryCard>, String> {
119 let current_year = today.year();
120
121 let mut all: Vec<Memory> = Vec::new();
122
123 let on_this_day_results = on_this_day(conn, today, current_year)
124 .map_err(|e| format!("OnThisDay query failed: {}", e))?;
125 let had_exact = !on_this_day_results.is_empty();
126 all.extend(on_this_day_results);
127
128 if !had_exact {
129 let fb = fallback_window(conn, today, current_year)
130 .map_err(|e| format!("FallbackWindow query failed: {}", e))?;
131 all.extend(fb);
132 }
133
134 let seasonal = seasonal_recap(conn, today, current_year)
135 .map_err(|e| format!("SeasonalRecap query failed: {}", e))?;
136 all.extend(seasonal);
137
138 if let Some(person) =
139 person_story(conn, today).map_err(|e| format!("PersonStory query failed: {e}"))?
140 {
141 all.push(person);
142 }
143 if let Some(place) =
144 place_story(conn, today).map_err(|e| format!("PlaceStory query failed: {e}"))?
145 {
146 all.push(place);
147 }
148 if let Some(visual) = visual {
149 all.push(visual);
150 }
151
152 if all.is_empty() {
156 let recaps = year_recap(conn, today, current_year)
157 .map_err(|e| format!("YearRecap query failed: {}", e))?;
158 all.extend(recaps);
159 }
160
161 if all.is_empty() {
162 return Ok(Vec::new());
163 }
164
165 populate_hero_and_faces(conn, &mut all).map_err(|e| format!("Hero selection failed: {}", e))?;
166 rank(&mut all, current_year);
167
168 let blocks = load_person_blocks(conn).map_err(|e| format!("Block load failed: {}", e))?;
169 if !blocks.is_empty() {
170 filter_blocked(conn, &mut all, &blocks)
171 .map_err(|e| format!("Block filter failed: {}", e))?;
172 }
173
174 if all.len() > MAX_CARDS {
175 all.truncate(MAX_CARDS);
176 }
177
178 Ok(all.into_iter().map(MemoryCard::from).collect())
179}
180
181pub fn library_is_old_enough(conn: &Connection, today: NaiveDate) -> bool {
183 let oldest: Option<String> = conn
184 .query_row(
185 "SELECT MIN(date_taken) FROM photos WHERE is_trashed = FALSE AND date_taken IS NOT NULL",
186 [],
187 |row| row.get(0),
188 )
189 .ok()
190 .flatten();
191
192 let Some(oldest_str) = oldest else {
193 return false;
194 };
195
196 let parsed = chrono::NaiveDateTime::parse_from_str(&oldest_str, "%Y-%m-%d %H:%M:%S")
198 .map(|dt| dt.date())
199 .or_else(|_| {
200 NaiveDate::parse_from_str(&oldest_str[..oldest_str.len().min(10)], "%Y-%m-%d")
201 });
202
203 let Ok(oldest_date) = parsed else {
204 return false;
205 };
206
207 let age_days = today.signed_duration_since(oldest_date).num_days();
208 age_days >= MIN_LIBRARY_AGE_MONTHS * 30
209}
210
211fn memory_id(kind: MemoryKind, year: i32, today: NaiveDate) -> MemoryId {
212 format!(
213 "{}-{}-{:02}-{:02}",
214 match kind {
215 MemoryKind::OnThisDay => "otd",
216 MemoryKind::FallbackWindow => "fw",
217 MemoryKind::SeasonalRecap => "sr",
218 MemoryKind::PersonStory => "person",
219 MemoryKind::PlaceStory => "place",
220 MemoryKind::VisualPattern => "visual",
221 MemoryKind::YearRecap => "yr",
222 },
223 year,
224 today.month(),
225 today.day()
226 )
227}
228
229fn parse_ids(csv: &str) -> Vec<i64> {
230 csv.split(',')
231 .filter_map(|s| s.trim().parse::<i64>().ok())
232 .collect()
233}
234
235fn on_this_day(
238 conn: &Connection,
239 today: NaiveDate,
240 _current_year: i32,
241) -> SqliteResult<Vec<Memory>> {
242 let today_md = format!("{:02}-{:02}", today.month(), today.day());
243 let cutoff = today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30);
246 let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
247 let mut stmt = conn.prepare(
248 r#"
249 WITH matched AS (
250 SELECT id,
251 CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
252 ROW_NUMBER() OVER (
253 PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
254 ORDER BY date_taken DESC, id DESC
255 ) AS rn
256 FROM photos
257 WHERE is_trashed = FALSE
258 AND content_category = 'photo'
259 AND date_taken IS NOT NULL
260 AND strftime('%m-%d', date_taken) = ?1
261 AND date_taken < ?2
262 )
263 SELECT yr, GROUP_CONCAT(id) AS photo_ids
264 FROM matched
265 WHERE rn <= ?3
266 GROUP BY yr
267 ORDER BY yr DESC
268 LIMIT 10
269 "#,
270 )?;
271
272 let rows = stmt.query_map(
273 params![today_md, cutoff_str, MAX_MEMORY_PHOTOS as i64],
274 |row| Ok((row.get::<_, i32>(0)?, row.get::<_, String>(1)?)),
275 )?;
276
277 let mut out = Vec::new();
278 for row in rows {
279 let (yr, csv) = row?;
280 let photo_ids = parse_ids(&csv);
281 if photo_ids.is_empty() {
282 continue;
283 }
284 let photo_date = NaiveDate::from_ymd_opt(yr, today.month(), today.day())
285 .unwrap_or(NaiveDate::from_ymd_opt(yr, 1, 1).unwrap());
286 let title = format!("{} today", age_label(photo_date, today));
287 out.push(Memory {
288 id: memory_id(MemoryKind::OnThisDay, yr, today),
289 kind: MemoryKind::OnThisDay,
290 title,
291 photo_ids,
292 hero_photo_id: 0,
293 hero_thumbnail_path: None,
294 score: 0.0,
295 year: yr,
296 has_faces: false,
297 });
298 }
299 Ok(out)
300}
301
302fn fallback_window(
303 conn: &Connection,
304 today: NaiveDate,
305 _current_year: i32,
306) -> SqliteResult<Vec<Memory>> {
307 let mds: Vec<String> = (-3..=3)
308 .map(|offset| {
309 let d = today + Duration::days(offset);
310 format!("{:02}-{:02}", d.month(), d.day())
311 })
312 .collect();
313
314 let cutoff = today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30);
315 let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
316
317 let mut stmt = conn.prepare(
318 r#"
319 WITH matched AS (
320 SELECT id,
321 CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
322 ROW_NUMBER() OVER (
323 PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
324 ORDER BY date_taken DESC, id DESC
325 ) AS rn
326 FROM photos
327 WHERE is_trashed = FALSE
328 AND content_category = 'photo'
329 AND date_taken IS NOT NULL
330 AND strftime('%m-%d', date_taken) IN (?1, ?2, ?3, ?4, ?5, ?6, ?7)
331 AND date_taken < ?8
332 )
333 SELECT yr, GROUP_CONCAT(id) AS photo_ids
334 FROM matched
335 WHERE rn <= ?9
336 GROUP BY yr
337 ORDER BY yr DESC
338 LIMIT 10
339 "#,
340 )?;
341
342 let rows = stmt.query_map(
343 params![
344 mds[0],
345 mds[1],
346 mds[2],
347 mds[3],
348 mds[4],
349 mds[5],
350 mds[6],
351 cutoff_str,
352 MAX_MEMORY_PHOTOS as i64
353 ],
354 |row| Ok((row.get::<_, i32>(0)?, row.get::<_, String>(1)?)),
355 )?;
356
357 let mut out = Vec::new();
358 for row in rows {
359 let (yr, csv) = row?;
360 let photo_ids = parse_ids(&csv);
361 if photo_ids.is_empty() {
362 continue;
363 }
364 let photo_date = NaiveDate::from_ymd_opt(yr, today.month(), today.day())
365 .unwrap_or(NaiveDate::from_ymd_opt(yr, 1, 1).unwrap());
366 let title = format!("{} this week", age_label(photo_date, today));
367 out.push(Memory {
368 id: memory_id(MemoryKind::FallbackWindow, yr, today),
369 kind: MemoryKind::FallbackWindow,
370 title,
371 photo_ids,
372 hero_photo_id: 0,
373 hero_thumbnail_path: None,
374 score: 0.0,
375 year: yr,
376 has_faces: false,
377 });
378 }
379 Ok(out)
380}
381
382fn seasonal_recap(
383 conn: &Connection,
384 today: NaiveDate,
385 _current_year: i32,
386) -> SqliteResult<Vec<Memory>> {
387 let month = format!("{:02}", today.month());
388 let cutoff = today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30);
389 let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
390
391 let mut stmt = conn.prepare(
392 r#"
393 WITH matched AS (
394 SELECT id,
395 CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
396 COUNT(*) OVER (
397 PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
398 ) AS total_count,
399 ROW_NUMBER() OVER (
400 PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
401 ORDER BY date_taken DESC, id DESC
402 ) AS rn
403 FROM photos
404 WHERE is_trashed = FALSE
405 AND content_category = 'photo'
406 AND date_taken IS NOT NULL
407 AND strftime('%m', date_taken) = ?1
408 AND date_taken < ?2
409 )
410 SELECT yr, GROUP_CONCAT(id) AS photo_ids
411 FROM matched
412 WHERE rn <= ?4
413 GROUP BY yr
414 HAVING MAX(total_count) >= ?3
415 ORDER BY yr DESC
416 LIMIT 5
417 "#,
418 )?;
419
420 let rows = stmt.query_map(
421 params![
422 month,
423 cutoff_str,
424 SEASONAL_MIN_PHOTOS,
425 MAX_MEMORY_PHOTOS as i64
426 ],
427 |row| Ok((row.get::<_, i32>(0)?, row.get::<_, String>(1)?)),
428 )?;
429
430 let month_name = month_name(today.month());
431 let mut out = Vec::new();
432 for row in rows {
433 let (yr, csv) = row?;
434 let photo_ids = parse_ids(&csv);
435 if photo_ids.is_empty() {
436 continue;
437 }
438 out.push(Memory {
439 id: memory_id(MemoryKind::SeasonalRecap, yr, today),
440 kind: MemoryKind::SeasonalRecap,
441 title: format!("{} {}, worth another look", month_name, yr),
442 photo_ids,
443 hero_photo_id: 0,
444 hero_thumbnail_path: None,
445 score: 0.0,
446 year: yr,
447 has_faces: false,
448 });
449 }
450 Ok(out)
451}
452
453fn visual_pattern(
457 conn: &Connection,
458 today: NaiveDate,
459 drive_root: &Path,
460 cache: &mut SemanticIndexCache,
461) -> Result<Option<Memory>, String> {
462 let cutoff = (today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30))
463 .format("%Y-%m-%d")
464 .to_string();
465 let mut stmt = conn
466 .prepare(
467 r#"SELECT p.id
468 FROM photos p
469 JOIN semantic_index_state s
470 ON s.photo_id = p.id AND s.model_key = ?1 AND s.status = 'indexed'
471 WHERE p.is_trashed = FALSE
472 AND p.content_category = 'photo'
473 AND p.date_taken IS NOT NULL AND p.date_taken < ?2
474 ORDER BY p.is_favorite DESC,
475 p.date_taken DESC, p.id DESC
476 LIMIT 64"#,
477 )
478 .map_err(|e| e.to_string())?;
479 let seeds = stmt
480 .query_map(params![SEMANTIC_MODEL_KEY, cutoff], |row| {
481 row.get::<_, i64>(0)
482 })
483 .map_err(|e| e.to_string())?
484 .collect::<SqliteResult<Vec<_>>>()
485 .map_err(|e| e.to_string())?;
486 if seeds.is_empty() {
487 return Ok(None);
488 }
489
490 let service = SemanticSearchService::new(drive_root);
491 let start = today.ordinal0() as usize % seeds.len();
492 for offset in 0..seeds.len().min(12) {
493 let seed = seeds[(start + offset) % seeds.len()];
494 let neighbors = service
495 .similar_to_photo_cached(conn, cache, seed, 48)
496 .map_err(|e| e.to_string())?;
497 let Some(top_score) = neighbors.first().map(|candidate| candidate.score) else {
498 continue;
499 };
500 if top_score < 0.70 {
501 continue;
502 }
503 let threshold = (top_score - 0.04).max(top_score * 0.90);
504 let candidate_ids: Vec<i64> = std::iter::once(seed)
505 .chain(
506 neighbors
507 .into_iter()
508 .filter(|candidate| candidate.score >= threshold)
509 .map(|candidate| candidate.photo_id),
510 )
511 .collect();
512 let dated = dated_active_photos(conn, &candidate_ids)?;
513 let mut seen_days = HashSet::new();
514 let mut photo_ids = Vec::new();
515 let mut years = HashSet::new();
516 let mut first_year = today.year();
517 for (photo_id, date) in dated {
518 if seen_days.insert(date) {
519 years.insert(date.year());
520 first_year = first_year.min(date.year());
521 photo_ids.push(photo_id);
522 }
523 if photo_ids.len() == 24 {
524 break;
525 }
526 }
527 if photo_ids.len() < 4 || years.len() < 2 {
528 continue;
529 }
530 if dominant_named_person_coverage(conn, &photo_ids)? >= 0.60 {
531 continue;
532 }
533 return Ok(Some(Memory {
534 id: format!("visual-{seed}-{}-{:03}", today.year(), today.ordinal()),
535 kind: MemoryKind::VisualPattern,
536 title: "Something you kept noticing".to_string(),
537 photo_ids,
538 hero_photo_id: 0,
539 hero_thumbnail_path: None,
540 score: 0.0,
541 year: first_year,
542 has_faces: false,
543 }));
544 }
545 Ok(None)
546}
547
548fn dated_active_photos(
549 conn: &Connection,
550 photo_ids: &[i64],
551) -> Result<Vec<(i64, NaiveDate)>, String> {
552 if photo_ids.is_empty() {
553 return Ok(Vec::new());
554 }
555 let rank: HashMap<i64, usize> = photo_ids
556 .iter()
557 .enumerate()
558 .map(|(index, id)| (*id, index))
559 .collect();
560 let mut out = Vec::new();
561 let mut seen_hashes = HashSet::new();
562 for chunk in photo_ids.chunks(900) {
563 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
564 let sql = format!(
565 "SELECT id, date_taken, file_hash FROM photos
566 WHERE is_trashed = FALSE AND content_category = 'photo'
567 AND date_taken IS NOT NULL AND id IN ({placeholders})
568 AND NOT EXISTS (
569 SELECT 1
570 FROM photo_stack_members psm
571 JOIN photo_stacks ps ON ps.id = psm.stack_id
572 WHERE psm.photo_id = photos.id
573 AND ps.dismissed = FALSE
574 AND psm.is_cover = FALSE
575 )"
576 );
577 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
578 let rows = stmt
579 .query_map(rusqlite::params_from_iter(chunk.iter().copied()), |row| {
580 Ok((
581 row.get::<_, i64>(0)?,
582 row.get::<_, String>(1)?,
583 row.get::<_, String>(2)?,
584 ))
585 })
586 .map_err(|e| e.to_string())?;
587 for row in rows {
588 let (id, raw, file_hash) = row.map_err(|e| e.to_string())?;
589 if !seen_hashes.insert(file_hash) {
590 continue;
591 }
592 if let Ok(date) =
593 NaiveDate::parse_from_str(raw.get(..10).unwrap_or_default(), "%Y-%m-%d")
594 {
595 out.push((id, date));
596 }
597 }
598 }
599 out.sort_by_key(|(id, _)| rank.get(id).copied().unwrap_or(usize::MAX));
600 Ok(out)
601}
602
603fn dominant_named_person_coverage(conn: &Connection, photo_ids: &[i64]) -> Result<f32, String> {
604 if photo_ids.is_empty() {
605 return Ok(0.0);
606 }
607 let placeholders = (0..photo_ids.len())
608 .map(|_| "?")
609 .collect::<Vec<_>>()
610 .join(",");
611 let sql = format!(
612 r#"SELECT COUNT(DISTINCT f.photo_id)
613 FROM faces f
614 JOIN face_clusters fc ON fc.id = f.cluster_id
615 WHERE f.photo_id IN ({placeholders})
616 AND fc.name IS NOT NULL AND TRIM(fc.name) != ''
617 GROUP BY f.cluster_id
618 ORDER BY COUNT(DISTINCT f.photo_id) DESC
619 LIMIT 1"#
620 );
621 let count: i64 = conn
622 .query_row(
623 &sql,
624 rusqlite::params_from_iter(photo_ids.iter().copied()),
625 |row| row.get(0),
626 )
627 .unwrap_or(0);
628 Ok(count as f32 / photo_ids.len() as f32)
629}
630
631fn person_story(conn: &Connection, today: NaiveDate) -> SqliteResult<Option<Memory>> {
635 let mut stmt = conn.prepare(
636 r#"
637 WITH appearances AS (
638 SELECT DISTINCT f.cluster_id, fc.name, p.id AS photo_id,
639 CAST(strftime('%Y', p.date_taken) AS INTEGER) AS yr,
640 p.date_taken, p.is_favorite
641 FROM faces f
642 JOIN face_clusters fc ON fc.id = f.cluster_id
643 JOIN photos p ON p.id = f.photo_id
644 LEFT JOIN memory_blocks mb
645 ON mb.kind = 'person' AND mb.target_key = CAST(f.cluster_id AS TEXT)
646 WHERE p.is_trashed = FALSE
647 AND p.content_category = 'photo'
648 AND p.date_taken IS NOT NULL
649 AND fc.name IS NOT NULL AND TRIM(fc.name) != ''
650 AND mb.id IS NULL
651 ),
652 ranked AS (
653 SELECT *,
654 ROW_NUMBER() OVER (
655 PARTITION BY cluster_id, yr
656 ORDER BY is_favorite DESC, date_taken DESC, photo_id DESC
657 ) AS rn
658 FROM appearances
659 )
660 SELECT cluster_id, name, GROUP_CONCAT(photo_id), MIN(yr), MAX(yr),
661 COUNT(DISTINCT yr) AS year_count, COUNT(*) AS photo_count
662 FROM ranked
663 WHERE rn <= 8
664 GROUP BY cluster_id, name
665 HAVING year_count >= 3 AND photo_count >= 9
666 ORDER BY year_count DESC, photo_count DESC, name
667 LIMIT 24
668 "#,
669 )?;
670 let rows = stmt.query_map([], |row| {
671 Ok((
672 row.get::<_, i64>(0)?,
673 row.get::<_, String>(1)?,
674 row.get::<_, String>(2)?,
675 row.get::<_, i32>(3)?,
676 row.get::<_, i32>(4)?,
677 ))
678 })?;
679 let candidates = rows.collect::<SqliteResult<Vec<_>>>()?;
680 if candidates.is_empty() {
681 return Ok(None);
682 }
683 let index = today.ordinal0() as usize % candidates.len();
684 let (cluster_id, name, ids, first_year, last_year) = &candidates[index];
685 Ok(Some(Memory {
686 id: format!(
687 "person-{cluster_id}-{}-{:03}",
688 today.year(),
689 today.ordinal()
690 ),
691 kind: MemoryKind::PersonStory,
692 title: format!("{name}, through the years"),
693 photo_ids: parse_ids(ids),
694 hero_photo_id: 0,
695 hero_thumbnail_path: None,
696 score: 0.0,
697 year: *first_year.min(last_year),
698 has_faces: true,
699 }))
700}
701
702fn place_story(conn: &Connection, today: NaiveDate) -> SqliteResult<Option<Memory>> {
704 let home_city: Option<String> = conn
705 .query_row(
706 r#"SELECT location_city
707 FROM photos
708 WHERE is_trashed = FALSE
709 AND location_city IS NOT NULL AND location_city != ''
710 AND date_taken IS NOT NULL
711 GROUP BY location_city
712 ORDER BY COUNT(DISTINCT strftime('%Y-%W', date_taken)) DESC
713 LIMIT 1"#,
714 [],
715 |row| row.get(0),
716 )
717 .ok();
718 let mut stmt = conn.prepare(
719 r#"
720 WITH ranked AS (
721 SELECT id, location_city AS city,
722 CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
723 ROW_NUMBER() OVER (
724 PARTITION BY location_city, CAST(strftime('%Y', date_taken) AS INTEGER)
725 ORDER BY is_favorite DESC, date_taken DESC, id DESC
726 ) AS rn
727 FROM photos
728 WHERE is_trashed = FALSE
729 AND content_category = 'photo'
730 AND date_taken IS NOT NULL
731 AND location_city IS NOT NULL AND location_city != ''
732 AND (?1 IS NULL OR location_city != ?1)
733 )
734 SELECT city, GROUP_CONCAT(id), MIN(yr), MAX(yr),
735 COUNT(DISTINCT yr) AS year_count, COUNT(*) AS photo_count
736 FROM ranked
737 WHERE rn <= 8
738 GROUP BY city
739 HAVING year_count >= 3 AND photo_count >= 9
740 ORDER BY year_count DESC, photo_count DESC, city
741 LIMIT 24
742 "#,
743 )?;
744 let rows = stmt.query_map(params![home_city], |row| {
745 Ok((
746 row.get::<_, String>(0)?,
747 row.get::<_, String>(1)?,
748 row.get::<_, i32>(2)?,
749 row.get::<_, i32>(3)?,
750 ))
751 })?;
752 let candidates = rows.collect::<SqliteResult<Vec<_>>>()?;
753 if candidates.is_empty() {
754 return Ok(None);
755 }
756 let index = (today.ordinal0() as usize / 3) % candidates.len();
757 let (city, ids, first_year, last_year) = &candidates[index];
758 Ok(Some(Memory {
759 id: format!(
760 "place-{}-{}-{:03}",
761 stable_text_id(city),
762 today.year(),
763 today.ordinal()
764 ),
765 kind: MemoryKind::PlaceStory,
766 title: format!("Back to {city}, through the years"),
767 photo_ids: parse_ids(ids),
768 hero_photo_id: 0,
769 hero_thumbnail_path: None,
770 score: 0.0,
771 year: *first_year.min(last_year),
772 has_faces: false,
773 }))
774}
775
776fn stable_text_id(value: &str) -> u64 {
777 use std::hash::{DefaultHasher, Hash, Hasher};
778 let mut hasher = DefaultHasher::new();
779 value.hash(&mut hasher);
780 hasher.finish()
781}
782
783fn year_recap(
784 conn: &Connection,
785 today: NaiveDate,
786 _current_year: i32,
787) -> SqliteResult<Vec<Memory>> {
788 let cutoff = today - Duration::days(MIN_PHOTO_AGE_MONTHS * 30);
789 let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
790
791 let mut stmt = conn.prepare(
792 r#"
793 WITH matched AS (
794 SELECT id,
795 CAST(strftime('%Y', date_taken) AS INTEGER) AS yr,
796 ROW_NUMBER() OVER (
797 PARTITION BY CAST(strftime('%Y', date_taken) AS INTEGER)
798 ORDER BY date_taken DESC, id DESC
799 ) AS rn
800 FROM photos
801 WHERE is_trashed = FALSE
802 AND content_category = 'photo'
803 AND date_taken IS NOT NULL
804 AND date_taken < ?1
805 )
806 SELECT yr, GROUP_CONCAT(id) AS photo_ids
807 FROM matched
808 WHERE rn <= ?2
809 GROUP BY yr
810 ORDER BY yr DESC
811 LIMIT 5
812 "#,
813 )?;
814
815 let rows = stmt.query_map(params![cutoff_str, YEAR_RECAP_MAX_PHOTOS as i64], |row| {
816 Ok((row.get::<_, i32>(0)?, row.get::<_, String>(1)?))
817 })?;
818
819 let mut out = Vec::new();
820 for row in rows {
821 let (yr, csv) = row?;
822 let photo_ids = parse_ids(&csv);
823 if photo_ids.is_empty() {
824 continue;
825 }
826
827 let photo_date =
829 NaiveDate::from_ymd_opt(yr, 7, 1).unwrap_or(NaiveDate::from_ymd_opt(yr, 1, 1).unwrap());
830 let title = age_label(photo_date, today);
831 out.push(Memory {
832 id: memory_id(MemoryKind::YearRecap, yr, today),
833 kind: MemoryKind::YearRecap,
834 title,
835 photo_ids,
836 hero_photo_id: 0,
837 hero_thumbnail_path: None,
838 score: 0.0,
839 year: yr,
840 has_faces: false,
841 });
842 }
843 Ok(out)
844}
845
846fn month_name(m: u32) -> &'static str {
847 match m {
848 1 => "January",
849 2 => "February",
850 3 => "March",
851 4 => "April",
852 5 => "May",
853 6 => "June",
854 7 => "July",
855 8 => "August",
856 9 => "September",
857 10 => "October",
858 11 => "November",
859 12 => "December",
860 _ => "",
861 }
862}
863
864fn age_label(photo_date: NaiveDate, today: NaiveDate) -> String {
867 if photo_date >= today {
868 return "Recent".to_string();
869 }
870
871 let mut months = (today.year() - photo_date.year()) * 12
872 + (today.month() as i32 - photo_date.month() as i32);
873 if today.day() < photo_date.day() {
874 months -= 1;
875 }
876 let months = months.max(0);
877
878 if months < 12 {
879 match months {
880 0 => "Recent".to_string(),
881 1 => "1 month ago".to_string(),
882 _ => format!("{} months ago", months),
883 }
884 } else {
885 let years = months / 12;
886 if years == 1 {
887 "1 year ago".to_string()
888 } else {
889 format!("{} years ago", years)
890 }
891 }
892}
893
894fn populate_hero_and_faces(conn: &Connection, memories: &mut [Memory]) -> SqliteResult<()> {
899 let mut all_ids: HashSet<i64> = HashSet::new();
901 for m in memories.iter() {
902 all_ids.extend(m.photo_ids.iter().copied());
903 }
904 if all_ids.is_empty() {
905 for mem in memories.iter_mut() {
906 mem.hero_photo_id = 0;
907 mem.hero_thumbnail_path = None;
908 mem.has_faces = false;
909 }
910 return Ok(());
911 }
912
913 let ids_list: Vec<String> = all_ids.iter().map(|i| i.to_string()).collect();
914 let in_clause = ids_list.join(",");
915 let sql = format!(
916 r#"
917 SELECT p.id,
918 COALESCE(p.orientation, 1) AS orientation,
919 COALESCE(p.width, 0) AS width,
920 COALESCE(p.height, 0) AS height,
921 COALESCE((SELECT COUNT(*) FROM faces WHERE photo_id = p.id), 0) AS face_count,
922 p.thumbnail_path
923 FROM photos p
924 WHERE p.id IN ({}) AND p.is_trashed = FALSE
925 "#,
926 in_clause
927 );
928
929 #[derive(Clone)]
930 struct PhotoMeta {
931 orientation: i32,
932 width: i32,
933 height: i32,
934 face_count: i64,
935 thumbnail_path: Option<String>,
936 }
937
938 let mut stmt = conn.prepare(&sql)?;
939 let mut meta: HashMap<i64, PhotoMeta> = HashMap::new();
940 let rows = stmt.query_map([], |row| {
941 Ok((
942 row.get::<_, i64>(0)?,
943 PhotoMeta {
944 orientation: row.get(1)?,
945 width: row.get(2)?,
946 height: row.get(3)?,
947 face_count: row.get(4)?,
948 thumbnail_path: row.get(5)?,
949 },
950 ))
951 })?;
952 for row in rows {
953 let (id, m) = row?;
954 meta.insert(id, m);
955 }
956
957 for mem in memories.iter_mut() {
958 let Some(mut best_id) = mem.photo_ids.first().copied() else {
959 mem.hero_photo_id = 0;
960 mem.hero_thumbnail_path = None;
961 mem.has_faces = false;
962 continue;
963 };
964 let mut best_score = f32::MIN;
965 let mut any_faces = false;
966
967 for pid in &mem.photo_ids {
968 let Some(m) = meta.get(pid) else { continue };
969
970 let is_landscape = matches!(m.orientation, 1 | 3) && m.width >= m.height && m.width > 0;
973
974 let mut score = 0.0_f32;
975 if is_landscape {
976 score += 3.0;
977 }
978 if m.face_count > 0 {
979 score += 2.0;
980 any_faces = true;
981 }
982 if m.face_count >= 2 {
983 score += 1.0;
984 }
985 if m.thumbnail_path.is_some() {
989 score += 0.5;
990 }
991 score -= (*pid as f32) * 1e-9;
993
994 if score > best_score {
995 best_score = score;
996 best_id = *pid;
997 }
998 }
999
1000 mem.hero_photo_id = best_id;
1001 mem.hero_thumbnail_path = meta.get(&best_id).and_then(|m| m.thumbnail_path.clone());
1002 mem.has_faces = any_faces;
1003 }
1004
1005 Ok(())
1006}
1007
1008fn rank(memories: &mut [Memory], current_year: i32) {
1011 for m in memories.iter_mut() {
1012 let years_ago = (current_year - m.year).max(0);
1013 let count_factor = ((m.photo_ids.len() as f32) + 1.0).log2();
1014 let age_factor = ((years_ago as f32) + 1.0).sqrt();
1015 let face_factor = if m.has_faces { 1.3 } else { 1.0 };
1016 let kind_factor = match m.kind {
1017 MemoryKind::SeasonalRecap => 1.5,
1018 MemoryKind::PersonStory | MemoryKind::PlaceStory | MemoryKind::VisualPattern => 1.8,
1019 _ => 1.0,
1020 };
1021 m.score = count_factor * age_factor * face_factor * kind_factor;
1022 }
1023 memories.sort_by(|a, b| {
1024 b.score
1025 .partial_cmp(&a.score)
1026 .unwrap_or(std::cmp::Ordering::Equal)
1027 });
1028}
1029
1030fn load_person_blocks(conn: &Connection) -> SqliteResult<HashSet<i64>> {
1033 let mut stmt = conn.prepare("SELECT target_key FROM memory_blocks WHERE kind = 'person'")?;
1034 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
1035 let mut out = HashSet::new();
1036 for row in rows {
1037 if let Ok(id) = row?.parse::<i64>() {
1038 out.insert(id);
1039 }
1040 }
1041 Ok(out)
1042}
1043
1044fn filter_blocked(
1046 conn: &Connection,
1047 memories: &mut Vec<Memory>,
1048 blocks: &HashSet<i64>,
1049) -> SqliteResult<()> {
1050 if memories.is_empty() || blocks.is_empty() {
1051 return Ok(());
1052 }
1053
1054 let mut all_ids: HashSet<i64> = HashSet::new();
1056 for m in memories.iter() {
1057 all_ids.extend(m.photo_ids.iter().copied());
1058 }
1059 let ids_list: Vec<String> = all_ids.iter().map(|i| i.to_string()).collect();
1060 let blocks_list: Vec<String> = blocks.iter().map(|i| i.to_string()).collect();
1061
1062 let sql = format!(
1063 "SELECT DISTINCT photo_id FROM faces
1064 WHERE cluster_id IN ({}) AND photo_id IN ({})",
1065 blocks_list.join(","),
1066 ids_list.join(",")
1067 );
1068
1069 let mut stmt = conn.prepare(&sql)?;
1070 let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?;
1071 let blocked_photos: HashSet<i64> = rows.collect::<SqliteResult<HashSet<_>>>()?;
1072
1073 memories.retain(|m| {
1074 let blocked_count = m
1075 .photo_ids
1076 .iter()
1077 .filter(|id| blocked_photos.contains(id))
1078 .count();
1079 blocked_count * 2 <= m.photo_ids.len() });
1081
1082 Ok(())
1083}
1084
1085#[cfg(test)]
1088mod tests {
1089 use super::*;
1090 use rusqlite::Connection;
1091 use tempfile::tempdir;
1092
1093 fn fresh_db() -> Connection {
1094 let conn = Connection::open_in_memory().unwrap();
1095 crate::db::schema::create_schema(&conn).unwrap();
1096 conn
1097 }
1098
1099 fn insert_photo(conn: &Connection, id: i64, date: &str) {
1100 conn.execute(
1101 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, width, height, orientation) \
1102 VALUES (?1, ?2, ?3, 'h', 1, ?4, 1920, 1080, 1)",
1103 rusqlite::params![
1104 id,
1105 format!("photo{}.jpg", id),
1106 format!("photo{}.jpg", id),
1107 date,
1108 ],
1109 )
1110 .unwrap();
1111 }
1112
1113 #[test]
1114 fn empty_library_returns_empty() {
1115 let conn = fresh_db();
1116 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1117 let cards = generate_for_today(&conn, today).unwrap();
1118 assert!(cards.is_empty());
1119 }
1120
1121 #[test]
1122 fn hero_selection_tolerates_empty_memory_photo_ids() {
1123 let conn = fresh_db();
1124 let mut memories = vec![Memory {
1125 id: "empty".into(),
1126 kind: MemoryKind::FallbackWindow,
1127 title: "Empty".into(),
1128 photo_ids: Vec::new(),
1129 hero_photo_id: 99,
1130 hero_thumbnail_path: Some("stale.jpg".into()),
1131 score: 0.0,
1132 year: 2020,
1133 has_faces: true,
1134 }];
1135
1136 populate_hero_and_faces(&conn, &mut memories).unwrap();
1137
1138 assert_eq!(memories[0].hero_photo_id, 0);
1139 assert!(memories[0].hero_thumbnail_path.is_none());
1140 assert!(!memories[0].has_faces);
1141 }
1142
1143 #[test]
1144 fn on_this_day_matches_prior_year() {
1145 let conn = fresh_db();
1146 insert_photo(&conn, 1, "2022-04-15 10:00:00");
1147 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1148 let cards = generate_for_today(&conn, today).unwrap();
1149 assert_eq!(cards.len(), 1);
1150 assert_eq!(cards[0].kind, MemoryKind::OnThisDay);
1151 assert_eq!(cards[0].photo_count, 1);
1152 }
1153
1154 #[test]
1155 fn dense_memory_day_is_capped_before_detail_payload() {
1156 let conn = fresh_db();
1157 for id in 1..=(MAX_MEMORY_PHOTOS as i64 + 25) {
1158 insert_photo(&conn, id, "2022-04-15 10:00:00");
1159 }
1160
1161 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1162 let cards = generate_for_today(&conn, today).unwrap();
1163 let on_this_day = cards
1164 .iter()
1165 .find(|card| card.kind == MemoryKind::OnThisDay)
1166 .unwrap();
1167
1168 assert_eq!(on_this_day.photo_ids.len(), MAX_MEMORY_PHOTOS);
1169 assert_eq!(on_this_day.photo_count, MAX_MEMORY_PHOTOS);
1170 }
1171
1172 #[test]
1173 fn seasonal_recap_respects_threshold() {
1174 let conn = fresh_db();
1175 for i in 0..4 {
1177 insert_photo(&conn, 100 + i, "2020-04-03 10:00:00");
1178 }
1179 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1180 let cards = generate_for_today(&conn, today).unwrap();
1181 assert!(cards.iter().all(|c| c.kind != MemoryKind::SeasonalRecap));
1182 }
1183
1184 #[test]
1185 fn seasonal_recap_surfaces_above_threshold() {
1186 let conn = fresh_db();
1187 for i in 0..5 {
1189 insert_photo(&conn, 200 + i, "2020-04-03 10:00:00");
1190 }
1191 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1192 let cards = generate_for_today(&conn, today).unwrap();
1193 assert!(cards.iter().any(|c| c.kind == MemoryKind::SeasonalRecap));
1194 }
1195
1196 #[test]
1197 fn fallback_only_when_on_this_day_empty() {
1198 let conn = fresh_db();
1199 insert_photo(&conn, 1, "2023-04-13 10:00:00"); let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1201 let cards = generate_for_today(&conn, today).unwrap();
1202 assert_eq!(cards.len(), 1);
1203 assert_eq!(cards[0].kind, MemoryKind::FallbackWindow);
1204 }
1205
1206 #[test]
1207 fn fallback_skipped_if_on_this_day_present() {
1208 let conn = fresh_db();
1209 insert_photo(&conn, 1, "2022-04-15 10:00:00"); insert_photo(&conn, 2, "2023-04-13 10:00:00"); let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1212 let cards = generate_for_today(&conn, today).unwrap();
1213 assert!(cards.iter().all(|c| c.kind != MemoryKind::FallbackWindow));
1214 }
1215
1216 #[test]
1217 fn year_recap_fires_when_nothing_else_qualifies() {
1218 let conn = fresh_db();
1219 for i in 0..5 {
1223 insert_photo(&conn, 300 + i, "2022-01-05 10:00:00");
1224 }
1225 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1226 let cards = generate_for_today(&conn, today).unwrap();
1227 assert!(cards.iter().any(|c| c.kind == MemoryKind::YearRecap));
1228 assert!(cards.iter().all(|c| c.kind == MemoryKind::YearRecap));
1230 }
1231
1232 #[test]
1233 fn year_recap_does_not_fire_when_others_qualify() {
1234 let conn = fresh_db();
1235 insert_photo(&conn, 1, "2022-04-15 10:00:00");
1237 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1238 let cards = generate_for_today(&conn, today).unwrap();
1239 assert!(cards.iter().any(|c| c.kind == MemoryKind::OnThisDay));
1240 assert!(cards.iter().all(|c| c.kind != MemoryKind::YearRecap));
1241 }
1242
1243 #[test]
1244 fn library_age_gate() {
1245 let conn = fresh_db();
1246 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1247
1248 assert!(!library_is_old_enough(&conn, today));
1250
1251 insert_photo(&conn, 1, "2026-02-15 10:00:00");
1253 assert!(!library_is_old_enough(&conn, today));
1254
1255 insert_photo(&conn, 2, "2025-04-15 10:00:00");
1257 assert!(library_is_old_enough(&conn, today));
1258 }
1259
1260 #[test]
1261 fn memory_id_is_stable() {
1262 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1263 let a = memory_id(MemoryKind::OnThisDay, 2022, today);
1264 let b = memory_id(MemoryKind::OnThisDay, 2022, today);
1265 assert_eq!(a, b);
1266 let c = memory_id(MemoryKind::OnThisDay, 2021, today);
1267 assert_ne!(a, c);
1268 }
1269
1270 #[test]
1271 fn named_person_across_three_years_becomes_a_personal_story() {
1272 let conn = fresh_db();
1273 conn.execute(
1274 "INSERT INTO face_clusters (id, name, is_user_named) VALUES (7, 'Asha', TRUE)",
1275 [],
1276 )
1277 .unwrap();
1278 let mut id = 1_i64;
1279 for year in 2021..=2023 {
1280 for day in 1..=3 {
1281 insert_photo(&conn, id, &format!("{year}-06-{day:02} 10:00:00"));
1282 conn.execute(
1283 r#"INSERT INTO faces
1284 (id, photo_id, bbox_x, bbox_y, bbox_width, bbox_height,
1285 embedding, cluster_id, confidence)
1286 VALUES (?1, ?1, 0.1, 0.1, 0.3, 0.3, X'00', 7, 0.99)"#,
1287 [id],
1288 )
1289 .unwrap();
1290 id += 1;
1291 }
1292 }
1293
1294 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1295 let cards = generate_for_today(&conn, today).unwrap();
1296 let story = cards
1297 .iter()
1298 .find(|card| card.kind == MemoryKind::PersonStory)
1299 .unwrap();
1300 assert_eq!(story.title, "Asha, through the years");
1301 assert_eq!(story.photo_count, 9);
1302 }
1303
1304 #[test]
1305 fn recurring_place_story_excludes_the_inferred_home_city() {
1306 let conn = fresh_db();
1307 let mut id = 1_i64;
1308 for year in 2021..=2023 {
1309 for month in 1..=4 {
1310 insert_photo(&conn, id, &format!("{year}-{month:02}-01 10:00:00"));
1311 conn.execute(
1312 "UPDATE photos SET location_city = 'Delhi' WHERE id = ?1",
1313 [id],
1314 )
1315 .unwrap();
1316 id += 1;
1317 }
1318 for day in 1..=3 {
1319 insert_photo(&conn, id, &format!("{year}-08-{day:02} 10:00:00"));
1320 conn.execute(
1321 "UPDATE photos SET location_city = 'Goa' WHERE id = ?1",
1322 [id],
1323 )
1324 .unwrap();
1325 id += 1;
1326 }
1327 }
1328
1329 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1330 let cards = generate_for_today(&conn, today).unwrap();
1331 let story = cards
1332 .iter()
1333 .find(|card| card.kind == MemoryKind::PlaceStory)
1334 .unwrap();
1335 assert_eq!(story.title, "Back to Goa, through the years");
1336 }
1337
1338 #[test]
1339 fn semantic_neighbors_across_years_become_one_visual_pattern() {
1340 let mut conn = fresh_db();
1341 let dir = tempdir().unwrap();
1342 let service = SemanticSearchService::new(dir.path());
1343 for id in 1..=6_i64 {
1344 let year = 2020 + ((id - 1) / 2) as i32;
1345 let day = ((id - 1) % 2) + 1;
1346 insert_photo(&conn, id, &format!("{year}-09-{day:02} 10:00:00"));
1347 conn.execute(
1348 "UPDATE photos SET file_hash = ?1 WHERE id = ?2",
1349 params![format!("hash-{id}"), id],
1350 )
1351 .unwrap();
1352 let mut vector = vec![0.0_f32; crate::services::semantic::SEMANTIC_DIM];
1353 vector[0] = 1.0;
1354 vector[1] = id as f32 * 0.001;
1355 service.mark_indexed(&mut conn, id, &vector).unwrap();
1356 }
1357
1358 let today = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
1359 let mut cache = SemanticIndexCache::default();
1360 let cards = generate_for_today_with_semantic(&conn, today, dir.path(), &mut cache).unwrap();
1361 let visual = cards
1362 .iter()
1363 .find(|card| card.kind == MemoryKind::VisualPattern)
1364 .unwrap();
1365 assert_eq!(visual.title, "Something you kept noticing");
1366 assert_eq!(visual.photo_count, 6);
1367 }
1368}