1use rusqlite::params;
2use serde::Serialize;
3
4use crate::db::FondDb;
5use crate::error::StoreError;
6use fond_domain::{RecipeFilter, parse_time_minutes};
7
8#[derive(Debug, Serialize)]
10pub struct RecipeSummary {
11 pub id: i64,
12 pub slug: String,
13 pub title: String,
14 pub source: String,
15 pub tags: Vec<String>,
16 pub total_time: String,
17 pub total_time_minutes: Option<u32>,
18}
19
20#[derive(Debug, Serialize)]
22pub struct RecipeRecord {
23 pub id: i64,
24 pub file_path: String,
25 pub slug: String,
26 pub title: String,
27 pub source: String,
28 pub source_url: String,
29 pub description: String,
30 pub recipe_yield: String,
31 pub prep_time: String,
32 pub cook_time: String,
33 pub total_time: String,
34 pub servings: String,
35 pub content_hash: String,
36 pub raw_source: String,
37 pub created_at: String,
38 pub updated_at: String,
39}
40
41#[derive(Debug, Serialize)]
43pub struct SearchResult {
44 pub recipe_id: i64,
45 pub title: String,
46 pub slug: String,
47 pub source: String,
48 pub tags: Vec<String>,
49 pub rank: f64,
50}
51
52#[derive(Debug, Serialize)]
54pub struct TagCount {
55 pub name: String,
56 pub count: i64,
57}
58
59pub struct RecipeRepository<'a> {
61 db: &'a FondDb,
62}
63
64impl<'a> RecipeRepository<'a> {
65 pub fn new(db: &'a FondDb) -> Self {
66 Self { db }
67 }
68
69 pub fn upsert_recipe(
73 &self,
74 file_path: &str,
75 recipe: &fond_domain::Recipe,
76 content_hash: &str,
77 ) -> Result<i64, StoreError> {
78 let conn = self.db.conn();
79
80 let total_time_minutes = compute_total_time_minutes(recipe);
81
82 let existing_id: Option<i64> = conn
84 .query_row(
85 "SELECT id FROM recipes WHERE file_path = ?1",
86 params![file_path],
87 |row| row.get(0),
88 )
89 .ok();
90
91 let recipe_id = if let Some(id) = existing_id {
92 conn.execute(
94 "UPDATE recipes SET slug = ?1, title = ?2, source = ?3, source_url = ?4,
95 description = ?5, recipe_yield = ?6, prep_time = ?7, cook_time = ?8,
96 total_time = ?9, servings = ?10, content_hash = ?11, raw_source = ?12,
97 total_time_minutes = ?13, updated_at = datetime('now')
98 WHERE id = ?14",
99 params![
100 recipe.slug,
101 recipe.title,
102 recipe.source.as_deref().unwrap_or(""),
103 recipe.source_url.as_deref().unwrap_or(""),
104 recipe.description.as_deref().unwrap_or(""),
105 recipe.recipe_yield.as_deref().unwrap_or(""),
106 recipe.prep_time.as_deref().unwrap_or(""),
107 recipe.cook_time.as_deref().unwrap_or(""),
108 recipe.total_time.as_deref().unwrap_or(""),
109 recipe.servings.as_deref().unwrap_or(""),
110 content_hash,
111 recipe.raw_source.as_deref().unwrap_or(""),
112 total_time_minutes,
113 id,
114 ],
115 )?;
116
117 conn.execute(
119 "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
120 params![id],
121 )?;
122 conn.execute("DELETE FROM steps WHERE recipe_id = ?1", params![id])?;
123 conn.execute("DELETE FROM cookware WHERE recipe_id = ?1", params![id])?;
124 conn.execute("DELETE FROM tags WHERE recipe_id = ?1", params![id])?;
125 conn.execute("DELETE FROM recipe_fts WHERE rowid = ?1", params![id])?;
126
127 id
128 } else {
129 conn.execute(
131 "INSERT INTO recipes (file_path, slug, title, source, source_url,
132 description, recipe_yield, prep_time, cook_time, total_time,
133 servings, content_hash, raw_source, total_time_minutes)
134 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
135 params![
136 file_path,
137 recipe.slug,
138 recipe.title,
139 recipe.source.as_deref().unwrap_or(""),
140 recipe.source_url.as_deref().unwrap_or(""),
141 recipe.description.as_deref().unwrap_or(""),
142 recipe.recipe_yield.as_deref().unwrap_or(""),
143 recipe.prep_time.as_deref().unwrap_or(""),
144 recipe.cook_time.as_deref().unwrap_or(""),
145 recipe.total_time.as_deref().unwrap_or(""),
146 recipe.servings.as_deref().unwrap_or(""),
147 content_hash,
148 recipe.raw_source.as_deref().unwrap_or(""),
149 total_time_minutes,
150 ],
151 )?;
152 conn.last_insert_rowid()
153 };
154
155 for (i, ing) in recipe.ingredients.iter().enumerate() {
157 conn.execute(
158 "INSERT INTO recipe_ingredients (recipe_id, name, quantity, unit, note, optional, sort_order)
159 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
160 params![
161 recipe_id,
162 ing.name,
163 ing.quantity.as_deref().unwrap_or(""),
164 ing.unit.as_deref().unwrap_or(""),
165 ing.note.as_deref().unwrap_or(""),
166 ing.optional as i32,
167 i as i32,
168 ],
169 )?;
170 }
171
172 for step in &recipe.steps {
173 conn.execute(
174 "INSERT INTO steps (recipe_id, section, body, sort_order)
175 VALUES (?1, ?2, ?3, ?4)",
176 params![
177 recipe_id,
178 step.section.as_deref().unwrap_or(""),
179 step.body,
180 step.order as i32,
181 ],
182 )?;
183 }
184
185 for cw in &recipe.cookware {
186 conn.execute(
187 "INSERT INTO cookware (recipe_id, name, quantity)
188 VALUES (?1, ?2, ?3)",
189 params![recipe_id, cw.name, cw.quantity.as_deref().unwrap_or(""),],
190 )?;
191 }
192
193 for tag in &recipe.tags {
194 conn.execute(
195 "INSERT OR IGNORE INTO tags (name, recipe_id) VALUES (?1, ?2)",
196 params![tag, recipe_id],
197 )?;
198 }
199
200 let ingredients_text: String = recipe
202 .ingredients
203 .iter()
204 .map(|i| i.name.as_str())
205 .collect::<Vec<_>>()
206 .join(" ");
207 let steps_text: String = recipe
208 .steps
209 .iter()
210 .map(|s| s.body.as_str())
211 .collect::<Vec<_>>()
212 .join(" ");
213 let tags_text: String = recipe.tags.join(" ");
214
215 conn.execute(
216 "INSERT INTO recipe_fts (rowid, title, ingredients_text, steps_text, tags_text)
217 VALUES (?1, ?2, ?3, ?4, ?5)",
218 params![
219 recipe_id,
220 recipe.title,
221 ingredients_text,
222 steps_text,
223 tags_text
224 ],
225 )?;
226
227 Ok(recipe_id)
228 }
229
230 pub fn get_recipe_by_id(&self, id: i64) -> Result<Option<RecipeRecord>, StoreError> {
232 let conn = self.db.conn();
233 let mut stmt = conn.prepare(
234 "SELECT id, file_path, slug, title, source, source_url, description,
235 recipe_yield, prep_time, cook_time, total_time, servings,
236 content_hash, raw_source, created_at, updated_at
237 FROM recipes WHERE id = ?1",
238 )?;
239
240 let record = stmt.query_row(params![id], row_to_record).ok();
241
242 Ok(record)
243 }
244
245 pub fn get_recipe_by_slug(&self, slug: &str) -> Result<Option<RecipeRecord>, StoreError> {
247 let conn = self.db.conn();
248 let mut stmt = conn.prepare(
249 "SELECT id, file_path, slug, title, source, source_url, description,
250 recipe_yield, prep_time, cook_time, total_time, servings,
251 content_hash, raw_source, created_at, updated_at
252 FROM recipes WHERE slug = ?1",
253 )?;
254
255 let record = stmt.query_row(params![slug], row_to_record).ok();
256
257 Ok(record)
258 }
259
260 pub fn get_recipe_by_path(&self, file_path: &str) -> Result<Option<RecipeRecord>, StoreError> {
262 let conn = self.db.conn();
263 let mut stmt = conn.prepare(
264 "SELECT id, file_path, slug, title, source, source_url, description,
265 recipe_yield, prep_time, cook_time, total_time, servings,
266 content_hash, raw_source, created_at, updated_at
267 FROM recipes WHERE file_path = ?1",
268 )?;
269
270 let record = stmt.query_row(params![file_path], row_to_record).ok();
271
272 Ok(record)
273 }
274
275 pub fn list_recipes(&self) -> Result<Vec<RecipeSummary>, StoreError> {
277 self.list_recipes_filtered(&RecipeFilter::default())
278 }
279
280 pub fn list_recipes_filtered(
282 &self,
283 filter: &RecipeFilter,
284 ) -> Result<Vec<RecipeSummary>, StoreError> {
285 let conn = self.db.conn();
286
287 let (where_clause, bind_values) = build_filter_where(filter, 1);
288
289 let sql = format!(
290 "SELECT r.id, r.slug, r.title, r.source, r.total_time, r.total_time_minutes
291 FROM recipes r
292 {where_clause}
293 ORDER BY r.title"
294 );
295
296 let mut stmt = conn.prepare(&sql)?;
297
298 let recipes: Vec<RecipeSummary> = stmt
299 .query_map(rusqlite::params_from_iter(bind_values.iter()), |row| {
300 let id: i64 = row.get(0)?;
301 let total_time_minutes: Option<u32> = row.get(5)?;
302 Ok((
303 id,
304 RecipeSummary {
305 id,
306 slug: row.get(1)?,
307 title: row.get(2)?,
308 source: row.get(3)?,
309 tags: Vec::new(),
310 total_time: row.get(4)?,
311 total_time_minutes,
312 },
313 ))
314 })?
315 .filter_map(|r| r.ok())
316 .map(|(id, mut summary)| {
317 if let Ok(mut tag_stmt) =
318 conn.prepare_cached("SELECT name FROM tags WHERE recipe_id = ?1 ORDER BY name")
319 && let Ok(tags) = tag_stmt
320 .query_map(params![id], |row| row.get::<_, String>(0))
321 .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
322 {
323 summary.tags = tags;
324 }
325 summary
326 })
327 .collect();
328
329 Ok(recipes)
330 }
331
332 pub fn search(&self, query: &str) -> Result<Vec<SearchResult>, StoreError> {
334 self.search_filtered(query, &RecipeFilter::default())
335 }
336
337 pub fn search_filtered(
339 &self,
340 query: &str,
341 filter: &RecipeFilter,
342 ) -> Result<Vec<SearchResult>, StoreError> {
343 let conn = self.db.conn();
344
345 let (filter_where, filter_values) = build_filter_where(filter, 2);
346
347 let sql = if filter_where.is_empty() {
351 "SELECT f.rowid, r.title, r.slug, r.source, rank
352 FROM recipe_fts f
353 JOIN recipes r ON r.id = f.rowid
354 WHERE recipe_fts MATCH ?1
355 ORDER BY rank"
356 .to_string()
357 } else {
358 let filter_and = filter_where.replacen("WHERE", "AND", 1);
360 format!(
361 "SELECT f.rowid, r.title, r.slug, r.source, rank
362 FROM recipe_fts f
363 JOIN recipes r ON r.id = f.rowid
364 WHERE recipe_fts MATCH ?1
365 {filter_and}
366 ORDER BY rank"
367 )
368 };
369
370 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
372 all_params.push(Box::new(query.to_string()));
373 for v in &filter_values {
374 all_params.push(Box::new(v.clone()));
375 }
376
377 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
378 all_params.iter().map(|b| b.as_ref()).collect();
379
380 let mut stmt = conn.prepare(&sql)?;
381 let results: Vec<SearchResult> = stmt
382 .query_map(param_refs.as_slice(), |row| {
383 let recipe_id: i64 = row.get(0)?;
384 Ok((
385 recipe_id,
386 SearchResult {
387 recipe_id,
388 title: row.get(1)?,
389 slug: row.get(2)?,
390 source: row.get(3)?,
391 tags: Vec::new(),
392 rank: row.get(4)?,
393 },
394 ))
395 })?
396 .filter_map(|r| r.ok())
397 .map(|(id, mut result)| {
398 if let Ok(mut tag_stmt) =
399 conn.prepare_cached("SELECT name FROM tags WHERE recipe_id = ?1 ORDER BY name")
400 && let Ok(tags) = tag_stmt
401 .query_map(params![id], |row| row.get::<_, String>(0))
402 .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
403 {
404 result.tags = tags;
405 }
406 result
407 })
408 .collect();
409
410 Ok(results)
411 }
412
413 pub fn list_tags(&self) -> Result<Vec<TagCount>, StoreError> {
415 let conn = self.db.conn();
416 let mut stmt = conn.prepare(
417 "SELECT name, COUNT(*) as cnt FROM tags GROUP BY name ORDER BY cnt DESC, name",
418 )?;
419
420 let tags = stmt
421 .query_map([], |row| {
422 Ok(TagCount {
423 name: row.get(0)?,
424 count: row.get(1)?,
425 })
426 })?
427 .collect::<Result<Vec<_>, _>>()?;
428
429 Ok(tags)
430 }
431
432 pub fn get_tags_for_slug(&self, slug: &str) -> Result<Option<(i64, Vec<String>)>, StoreError> {
434 let conn = self.db.conn();
435
436 let row: Option<i64> = conn
437 .query_row(
438 "SELECT id FROM recipes WHERE slug = ?1",
439 params![slug],
440 |row| row.get(0),
441 )
442 .ok();
443
444 let Some(recipe_id) = row else {
445 return Ok(None);
446 };
447
448 let mut stmt = conn.prepare("SELECT name FROM tags WHERE recipe_id = ?1 ORDER BY name")?;
449 let tags: Vec<String> = stmt
450 .query_map(params![recipe_id], |row| row.get(0))?
451 .collect::<Result<Vec<_>, _>>()?;
452
453 Ok(Some((recipe_id, tags)))
454 }
455
456 pub fn delete_all_derived(&self) -> Result<(), StoreError> {
458 let conn = self.db.conn();
459 conn.execute_batch(
460 "DELETE FROM recipe_fts;
461 DELETE FROM tags;
462 DELETE FROM cookware;
463 DELETE FROM steps;
464 DELETE FROM recipe_ingredients;
465 DELETE FROM recipes;",
466 )?;
467 Ok(())
468 }
469
470 pub fn count_recipes(&self) -> Result<i64, StoreError> {
472 let conn = self.db.conn();
473 let count: i64 = conn.query_row("SELECT count(*) FROM recipes", [], |row| row.get(0))?;
474 Ok(count)
475 }
476
477 pub fn delete_recipe_by_slug(&self, slug: &str) -> Result<Option<String>, StoreError> {
483 let conn = self.db.conn();
484
485 let row: Option<(i64, String)> = conn
487 .query_row(
488 "SELECT id, file_path FROM recipes WHERE slug = ?1",
489 params![slug],
490 |row| Ok((row.get(0)?, row.get(1)?)),
491 )
492 .ok();
493
494 let Some((id, file_path)) = row else {
495 return Ok(None);
496 };
497
498 conn.execute("DELETE FROM recipe_fts WHERE rowid = ?1", params![id])?;
500 conn.execute("DELETE FROM recipes WHERE id = ?1", params![id])?;
502
503 Ok(Some(file_path))
504 }
505}
506
507fn compute_total_time_minutes(recipe: &fond_domain::Recipe) -> Option<u32> {
511 if let Some(ref total) = recipe.total_time
513 && let Some(mins) = parse_time_minutes(total)
514 {
515 return Some(mins);
516 }
517
518 let prep = recipe
520 .prep_time
521 .as_deref()
522 .and_then(parse_time_minutes)
523 .unwrap_or(0);
524 let cook = recipe
525 .cook_time
526 .as_deref()
527 .and_then(parse_time_minutes)
528 .unwrap_or(0);
529
530 if prep > 0 || cook > 0 {
531 Some(prep + cook)
532 } else {
533 None
534 }
535}
536
537fn build_filter_where(filter: &RecipeFilter, param_start: usize) -> (String, Vec<String>) {
543 if filter.is_empty() {
544 return (String::new(), Vec::new());
545 }
546
547 let mut conditions: Vec<String> = Vec::new();
548 let mut values: Vec<String> = Vec::new();
549 let mut param_idx = param_start;
550
551 if !filter.tags.is_empty() {
553 let deduped: Vec<&String> = {
554 let mut seen = std::collections::HashSet::new();
555 filter
556 .tags
557 .iter()
558 .filter(|t| seen.insert(t.as_str()))
559 .collect()
560 };
561 let placeholders: Vec<String> = deduped
562 .iter()
563 .map(|_| {
564 let p = format!("?{param_idx}");
565 param_idx += 1;
566 p
567 })
568 .collect();
569 let count = deduped.len();
570 conditions.push(format!(
571 "r.id IN (SELECT recipe_id FROM tags WHERE name IN ({}) GROUP BY recipe_id HAVING COUNT(DISTINCT name) = {})",
572 placeholders.join(", "),
573 count
574 ));
575 for tag in deduped {
576 values.push(tag.clone());
577 }
578 }
579
580 if let Some(max) = filter.max_time_minutes {
582 conditions.push(format!(
583 "r.total_time_minutes IS NOT NULL AND r.total_time_minutes <= ?{param_idx}"
584 ));
585 values.push(max.to_string());
586 param_idx += 1;
587 }
588
589 if let Some(ref source) = filter.source {
591 let escaped = source.replace('%', "\\%").replace('_', "\\_");
593 conditions.push(format!("r.source LIKE ?{param_idx} ESCAPE '\\'"));
594 values.push(format!("%{escaped}%"));
595 let _ = param_idx; }
597
598 let where_clause = format!("WHERE {}", conditions.join(" AND "));
599 (where_clause, values)
600}
601
602fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecipeRecord> {
603 Ok(RecipeRecord {
604 id: row.get(0)?,
605 file_path: row.get(1)?,
606 slug: row.get(2)?,
607 title: row.get(3)?,
608 source: row.get(4)?,
609 source_url: row.get(5)?,
610 description: row.get(6)?,
611 recipe_yield: row.get(7)?,
612 prep_time: row.get(8)?,
613 cook_time: row.get(9)?,
614 total_time: row.get(10)?,
615 servings: row.get(11)?,
616 content_hash: row.get(12)?,
617 raw_source: row.get(13)?,
618 created_at: row.get(14)?,
619 updated_at: row.get(15)?,
620 })
621}