1use rusqlite::params;
2use serde::Serialize;
3
4use crate::db::FondDb;
5use crate::error::StoreError;
6
7#[derive(Debug, Serialize)]
9pub struct RecipeSummary {
10 pub id: i64,
11 pub slug: String,
12 pub title: String,
13 pub source: String,
14 pub tags: Vec<String>,
15}
16
17#[derive(Debug, Serialize)]
19pub struct RecipeRecord {
20 pub id: i64,
21 pub file_path: String,
22 pub slug: String,
23 pub title: String,
24 pub source: String,
25 pub source_url: String,
26 pub description: String,
27 pub recipe_yield: String,
28 pub prep_time: String,
29 pub cook_time: String,
30 pub total_time: String,
31 pub servings: String,
32 pub content_hash: String,
33 pub raw_source: String,
34 pub created_at: String,
35 pub updated_at: String,
36}
37
38#[derive(Debug, Serialize)]
40pub struct SearchResult {
41 pub recipe_id: i64,
42 pub title: String,
43 pub slug: String,
44 pub rank: f64,
45}
46
47pub struct RecipeRepository<'a> {
49 db: &'a FondDb,
50}
51
52impl<'a> RecipeRepository<'a> {
53 pub fn new(db: &'a FondDb) -> Self {
54 Self { db }
55 }
56
57 pub fn upsert_recipe(
61 &self,
62 file_path: &str,
63 recipe: &fond_domain::Recipe,
64 content_hash: &str,
65 ) -> Result<i64, StoreError> {
66 let conn = self.db.conn();
67
68 let existing_id: Option<i64> = conn
70 .query_row(
71 "SELECT id FROM recipes WHERE file_path = ?1",
72 params![file_path],
73 |row| row.get(0),
74 )
75 .ok();
76
77 let recipe_id = if let Some(id) = existing_id {
78 conn.execute(
80 "UPDATE recipes SET slug = ?1, title = ?2, source = ?3, source_url = ?4,
81 description = ?5, recipe_yield = ?6, prep_time = ?7, cook_time = ?8,
82 total_time = ?9, servings = ?10, content_hash = ?11, raw_source = ?12,
83 updated_at = datetime('now')
84 WHERE id = ?13",
85 params![
86 recipe.slug,
87 recipe.title,
88 recipe.source.as_deref().unwrap_or(""),
89 recipe.source_url.as_deref().unwrap_or(""),
90 recipe.description.as_deref().unwrap_or(""),
91 recipe.recipe_yield.as_deref().unwrap_or(""),
92 recipe.prep_time.as_deref().unwrap_or(""),
93 recipe.cook_time.as_deref().unwrap_or(""),
94 recipe.total_time.as_deref().unwrap_or(""),
95 recipe.servings.as_deref().unwrap_or(""),
96 content_hash,
97 recipe.raw_source.as_deref().unwrap_or(""),
98 id,
99 ],
100 )?;
101
102 conn.execute(
104 "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
105 params![id],
106 )?;
107 conn.execute("DELETE FROM steps WHERE recipe_id = ?1", params![id])?;
108 conn.execute("DELETE FROM cookware WHERE recipe_id = ?1", params![id])?;
109 conn.execute("DELETE FROM tags WHERE recipe_id = ?1", params![id])?;
110 conn.execute("DELETE FROM recipe_fts WHERE rowid = ?1", params![id])?;
111
112 id
113 } else {
114 conn.execute(
116 "INSERT INTO recipes (file_path, slug, title, source, source_url,
117 description, recipe_yield, prep_time, cook_time, total_time,
118 servings, content_hash, raw_source)
119 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
120 params![
121 file_path,
122 recipe.slug,
123 recipe.title,
124 recipe.source.as_deref().unwrap_or(""),
125 recipe.source_url.as_deref().unwrap_or(""),
126 recipe.description.as_deref().unwrap_or(""),
127 recipe.recipe_yield.as_deref().unwrap_or(""),
128 recipe.prep_time.as_deref().unwrap_or(""),
129 recipe.cook_time.as_deref().unwrap_or(""),
130 recipe.total_time.as_deref().unwrap_or(""),
131 recipe.servings.as_deref().unwrap_or(""),
132 content_hash,
133 recipe.raw_source.as_deref().unwrap_or(""),
134 ],
135 )?;
136 conn.last_insert_rowid()
137 };
138
139 for (i, ing) in recipe.ingredients.iter().enumerate() {
141 conn.execute(
142 "INSERT INTO recipe_ingredients (recipe_id, name, quantity, unit, note, optional, sort_order)
143 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
144 params![
145 recipe_id,
146 ing.name,
147 ing.quantity.as_deref().unwrap_or(""),
148 ing.unit.as_deref().unwrap_or(""),
149 ing.note.as_deref().unwrap_or(""),
150 ing.optional as i32,
151 i as i32,
152 ],
153 )?;
154 }
155
156 for step in &recipe.steps {
157 conn.execute(
158 "INSERT INTO steps (recipe_id, section, body, sort_order)
159 VALUES (?1, ?2, ?3, ?4)",
160 params![
161 recipe_id,
162 step.section.as_deref().unwrap_or(""),
163 step.body,
164 step.order as i32,
165 ],
166 )?;
167 }
168
169 for cw in &recipe.cookware {
170 conn.execute(
171 "INSERT INTO cookware (recipe_id, name, quantity)
172 VALUES (?1, ?2, ?3)",
173 params![recipe_id, cw.name, cw.quantity.as_deref().unwrap_or(""),],
174 )?;
175 }
176
177 for tag in &recipe.tags {
178 conn.execute(
179 "INSERT OR IGNORE INTO tags (name, recipe_id) VALUES (?1, ?2)",
180 params![tag, recipe_id],
181 )?;
182 }
183
184 let ingredients_text: String = recipe
186 .ingredients
187 .iter()
188 .map(|i| i.name.as_str())
189 .collect::<Vec<_>>()
190 .join(" ");
191 let steps_text: String = recipe
192 .steps
193 .iter()
194 .map(|s| s.body.as_str())
195 .collect::<Vec<_>>()
196 .join(" ");
197 let tags_text: String = recipe.tags.join(" ");
198
199 conn.execute(
200 "INSERT INTO recipe_fts (rowid, title, ingredients_text, steps_text, tags_text)
201 VALUES (?1, ?2, ?3, ?4, ?5)",
202 params![
203 recipe_id,
204 recipe.title,
205 ingredients_text,
206 steps_text,
207 tags_text
208 ],
209 )?;
210
211 Ok(recipe_id)
212 }
213
214 pub fn get_recipe_by_id(&self, id: i64) -> Result<Option<RecipeRecord>, StoreError> {
216 let conn = self.db.conn();
217 let mut stmt = conn.prepare(
218 "SELECT id, file_path, slug, title, source, source_url, description,
219 recipe_yield, prep_time, cook_time, total_time, servings,
220 content_hash, raw_source, created_at, updated_at
221 FROM recipes WHERE id = ?1",
222 )?;
223
224 let record = stmt.query_row(params![id], row_to_record).ok();
225
226 Ok(record)
227 }
228
229 pub fn get_recipe_by_slug(&self, slug: &str) -> Result<Option<RecipeRecord>, StoreError> {
231 let conn = self.db.conn();
232 let mut stmt = conn.prepare(
233 "SELECT id, file_path, slug, title, source, source_url, description,
234 recipe_yield, prep_time, cook_time, total_time, servings,
235 content_hash, raw_source, created_at, updated_at
236 FROM recipes WHERE slug = ?1",
237 )?;
238
239 let record = stmt.query_row(params![slug], row_to_record).ok();
240
241 Ok(record)
242 }
243
244 pub fn get_recipe_by_path(&self, file_path: &str) -> Result<Option<RecipeRecord>, StoreError> {
246 let conn = self.db.conn();
247 let mut stmt = conn.prepare(
248 "SELECT id, file_path, slug, title, source, source_url, description,
249 recipe_yield, prep_time, cook_time, total_time, servings,
250 content_hash, raw_source, created_at, updated_at
251 FROM recipes WHERE file_path = ?1",
252 )?;
253
254 let record = stmt.query_row(params![file_path], row_to_record).ok();
255
256 Ok(record)
257 }
258
259 pub fn list_recipes(&self) -> Result<Vec<RecipeSummary>, StoreError> {
261 let conn = self.db.conn();
262 let mut stmt = conn.prepare(
263 "SELECT r.id, r.slug, r.title, r.source
264 FROM recipes r ORDER BY r.title",
265 )?;
266
267 let recipes: Vec<RecipeSummary> = stmt
268 .query_map([], |row| {
269 let id: i64 = row.get(0)?;
270 Ok((
271 id,
272 RecipeSummary {
273 id,
274 slug: row.get(1)?,
275 title: row.get(2)?,
276 source: row.get(3)?,
277 tags: Vec::new(),
278 },
279 ))
280 })?
281 .filter_map(|r| r.ok())
282 .map(|(id, mut summary)| {
283 if let Ok(mut tag_stmt) =
285 conn.prepare_cached("SELECT name FROM tags WHERE recipe_id = ?1 ORDER BY name")
286 && let Ok(tags) = tag_stmt
287 .query_map(params![id], |row| row.get::<_, String>(0))
288 .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
289 {
290 summary.tags = tags;
291 }
292 summary
293 })
294 .collect();
295
296 Ok(recipes)
297 }
298
299 pub fn search(&self, query: &str) -> Result<Vec<SearchResult>, StoreError> {
301 let conn = self.db.conn();
302 let mut stmt = conn.prepare(
303 "SELECT f.rowid, r.title, r.slug, rank
304 FROM recipe_fts f
305 JOIN recipes r ON r.id = f.rowid
306 WHERE recipe_fts MATCH ?1
307 ORDER BY rank",
308 )?;
309
310 let results = stmt
311 .query_map(params![query], |row| {
312 Ok(SearchResult {
313 recipe_id: row.get(0)?,
314 title: row.get(1)?,
315 slug: row.get(2)?,
316 rank: row.get(3)?,
317 })
318 })?
319 .collect::<Result<Vec<_>, _>>()?;
320
321 Ok(results)
322 }
323
324 pub fn delete_all_derived(&self) -> Result<(), StoreError> {
326 let conn = self.db.conn();
327 conn.execute_batch(
328 "DELETE FROM recipe_fts;
329 DELETE FROM tags;
330 DELETE FROM cookware;
331 DELETE FROM steps;
332 DELETE FROM recipe_ingredients;
333 DELETE FROM recipes;",
334 )?;
335 Ok(())
336 }
337
338 pub fn count_recipes(&self) -> Result<i64, StoreError> {
340 let conn = self.db.conn();
341 let count: i64 = conn.query_row("SELECT count(*) FROM recipes", [], |row| row.get(0))?;
342 Ok(count)
343 }
344
345 pub fn delete_recipe_by_slug(&self, slug: &str) -> Result<Option<String>, StoreError> {
351 let conn = self.db.conn();
352
353 let row: Option<(i64, String)> = conn
355 .query_row(
356 "SELECT id, file_path FROM recipes WHERE slug = ?1",
357 params![slug],
358 |row| Ok((row.get(0)?, row.get(1)?)),
359 )
360 .ok();
361
362 let Some((id, file_path)) = row else {
363 return Ok(None);
364 };
365
366 conn.execute("DELETE FROM recipe_fts WHERE rowid = ?1", params![id])?;
368 conn.execute("DELETE FROM recipes WHERE id = ?1", params![id])?;
370
371 Ok(Some(file_path))
372 }
373}
374
375fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecipeRecord> {
376 Ok(RecipeRecord {
377 id: row.get(0)?,
378 file_path: row.get(1)?,
379 slug: row.get(2)?,
380 title: row.get(3)?,
381 source: row.get(4)?,
382 source_url: row.get(5)?,
383 description: row.get(6)?,
384 recipe_yield: row.get(7)?,
385 prep_time: row.get(8)?,
386 cook_time: row.get(9)?,
387 total_time: row.get(10)?,
388 servings: row.get(11)?,
389 content_hash: row.get(12)?,
390 raw_source: row.get(13)?,
391 created_at: row.get(14)?,
392 updated_at: row.get(15)?,
393 })
394}