cooklang_format/
schema.rs1use crate::quantity::grouped_quantity_fmt;
7use cooklang::{convert::Converter, model::Item, Recipe};
8use serde_json::{json, Value};
9use std::io;
10
11pub fn print_schema(
16 recipe: &Recipe,
17 name: &str,
18 scale: f64,
19 converter: &Converter,
20 writer: impl io::Write,
21 pretty: bool,
22) -> io::Result<()> {
23 let schema = create_schema_object(recipe, name, scale, converter);
24
25 if pretty {
26 serde_json::to_writer_pretty(writer, &schema)?;
27 } else {
28 serde_json::to_writer(writer, &schema)?;
29 }
30
31 Ok(())
32}
33
34fn create_schema_object(recipe: &Recipe, name: &str, scale: f64, converter: &Converter) -> Value {
37 let mut schema = json!({
38 "@context": "https://schema.org",
39 "@type": "Recipe",
40 "name": if scale != 1.0 {
41 format!("{name} (scaled {scale}x)")
42 } else {
43 name.to_string()
44 }
45 });
46
47 if let Some(desc) = recipe.metadata.description() {
49 schema["description"] = json!(desc);
50 }
51
52 if let Some(tags) = recipe.metadata.tags() {
54 schema["keywords"] = json!(tags.join(", "));
55 }
56
57 if let Some(author) = recipe.metadata.author() {
59 let author_name = author.name().unwrap_or(author.url().unwrap_or(""));
60 schema["author"] = json!({
61 "@type": "Person",
62 "name": author_name
63 });
64 }
65
66 if let Some(source) = recipe.metadata.source() {
68 if let Some(url) = source.url() {
69 schema["url"] = json!(url);
70 } else if let Some(name) = source.name() {
71 schema["url"] = json!(name);
72 }
73 }
74
75 if let Some(servings) = recipe.metadata.servings() {
77 use cooklang::metadata::Servings;
78 match servings {
79 Servings::Number(n) => {
80 let scaled_servings = if scale != 1.0 {
82 (n as f64 * scale).round() as u32
83 } else {
84 n
85 };
86 schema["recipeYield"] = json!(format!("{} servings", scaled_servings));
87 }
88 Servings::Text(text) => {
89 schema["recipeYield"] = json!(text);
90 }
91 }
92 }
93
94 add_time_fields(&mut schema, recipe);
96
97 add_nutrition_info(&mut schema, recipe);
99
100 let ingredients = create_ingredients_list(recipe, converter);
102 if !ingredients.is_empty() {
103 schema["recipeIngredient"] = json!(ingredients);
104 }
105
106 let tools = create_tools_list(recipe, converter);
108 if !tools.is_empty() {
109 schema["tool"] = json!(tools);
110 }
111
112 let instructions = create_instructions_list(recipe);
114 if !instructions.is_empty() {
115 schema["recipeInstructions"] = json!(instructions);
116 }
117
118 if let Some(category) = recipe.metadata.map.get("category") {
120 if let Some(cat_str) = category.as_str() {
121 schema["recipeCategory"] = json!(cat_str);
122 }
123 }
124
125 if let Some(cuisine) = recipe.metadata.map.get("cuisine") {
126 if let Some(cuisine_str) = cuisine.as_str() {
127 schema["recipeCuisine"] = json!(cuisine_str);
128 }
129 }
130
131 if let Some(image) = recipe.metadata.map.get("image") {
133 if let Some(image_str) = image.as_str() {
134 schema["image"] = json!(image_str);
135 }
136 }
137
138 schema
139}
140
141fn add_time_fields(schema: &mut Value, recipe: &Recipe) {
142 if let Some(prep_time_val) = recipe.metadata.get("prep time") {
144 if let Some(prep_time_str) = prep_time_val.as_str() {
145 schema["prepTime"] = json!(format_iso_duration(prep_time_str));
146 }
147 }
148
149 if let Some(cook_time_val) = recipe.metadata.get("cook time") {
151 if let Some(cook_time_str) = cook_time_val.as_str() {
152 schema["cookTime"] = json!(format_iso_duration(cook_time_str));
153 }
154 }
155
156 let has_prep = recipe.metadata.get("prep time").is_some();
158 let has_cook = recipe.metadata.get("cook time").is_some();
159 if has_prep && has_cook {
160 let mut total_minutes = 0;
162 if let Some(prep_val) = recipe.metadata.get("prep time").and_then(|v| v.as_str()) {
163 total_minutes += extract_number(prep_val).unwrap_or(0);
164 }
165 if let Some(cook_val) = recipe.metadata.get("cook time").and_then(|v| v.as_str()) {
166 total_minutes += extract_number(cook_val).unwrap_or(0);
167 }
168 schema["totalTime"] = json!(format!("PT{}M", total_minutes));
169 }
170}
171
172fn format_iso_duration(time_str: &str) -> String {
173 let lower = time_str.to_lowercase();
176
177 if lower.contains("hour") {
178 if let Some(hours) = extract_number(&lower) {
179 return format!("PT{hours}H");
180 }
181 } else if lower.contains("min") {
182 if let Some(minutes) = extract_number(&lower) {
183 return format!("PT{minutes}M");
184 }
185 }
186
187 if let Some(minutes) = extract_number(&lower) {
189 return format!("PT{minutes}M");
190 }
191
192 "PT0M".to_string()
193}
194
195fn extract_number(s: &str) -> Option<i32> {
196 s.chars()
197 .filter(|c| c.is_numeric())
198 .collect::<String>()
199 .parse::<i32>()
200 .ok()
201}
202
203fn add_nutrition_info(schema: &mut Value, recipe: &Recipe) {
204 let mut nutrition = json!({
205 "@type": "NutritionInformation"
206 });
207
208 let mut has_nutrition = false;
209
210 if let Some(calories) = recipe.metadata.map.get("calories") {
212 if let Some(cal_str) = calories.as_str() {
213 nutrition["calories"] = json!(format!("{} calories", cal_str));
214 has_nutrition = true;
215 } else if let Some(cal_num) = calories.as_u64() {
216 nutrition["calories"] = json!(format!("{} calories", cal_num));
217 has_nutrition = true;
218 }
219 }
220
221 if let Some(protein) = recipe.metadata.map.get("protein") {
222 if let Some(prot_str) = protein.as_str() {
223 nutrition["proteinContent"] = json!(prot_str);
224 has_nutrition = true;
225 }
226 }
227
228 if let Some(fat) = recipe.metadata.map.get("fat") {
229 if let Some(fat_str) = fat.as_str() {
230 nutrition["fatContent"] = json!(fat_str);
231 has_nutrition = true;
232 }
233 }
234
235 if let Some(carbs) = recipe.metadata.map.get("carbohydrates") {
236 if let Some(carbs_str) = carbs.as_str() {
237 nutrition["carbohydrateContent"] = json!(carbs_str);
238 has_nutrition = true;
239 }
240 }
241
242 if let Some(fiber) = recipe.metadata.map.get("fiber") {
243 if let Some(fiber_str) = fiber.as_str() {
244 nutrition["fiberContent"] = json!(fiber_str);
245 has_nutrition = true;
246 }
247 }
248
249 if let Some(sugar) = recipe.metadata.map.get("sugar") {
250 if let Some(sugar_str) = sugar.as_str() {
251 nutrition["sugarContent"] = json!(sugar_str);
252 has_nutrition = true;
253 }
254 }
255
256 if let Some(sodium) = recipe.metadata.map.get("sodium") {
257 if let Some(sodium_str) = sodium.as_str() {
258 nutrition["sodiumContent"] = json!(sodium_str);
259 has_nutrition = true;
260 }
261 }
262
263 if has_nutrition {
264 schema["nutrition"] = nutrition;
265 }
266}
267
268fn create_ingredients_list(recipe: &Recipe, converter: &Converter) -> Vec<String> {
269 let mut ingredients = Vec::new();
270
271 for entry in recipe.group_ingredients(converter) {
272 let ingredient = entry.ingredient;
273
274 if !ingredient.modifiers().should_be_listed() {
275 continue;
276 }
277
278 let mut ingredient_text = String::new();
279
280 if !entry.quantity.is_empty() {
281 ingredient_text.push_str(&grouped_quantity_fmt(&entry.quantity));
282 ingredient_text.push(' ');
283 }
284
285 ingredient_text.push_str(&ingredient.display_name());
286
287 if ingredient.modifiers().is_optional() {
288 ingredient_text.push_str(" (optional)");
289 }
290
291 if let Some(note) = &ingredient.note {
292 ingredient_text.push_str(&format!(", {note}"));
293 }
294
295 ingredients.push(ingredient_text);
296 }
297
298 ingredients
299}
300
301fn create_tools_list(recipe: &Recipe, converter: &Converter) -> Vec<String> {
302 let mut tools = Vec::new();
303
304 for item in recipe.group_cookware(converter) {
305 let cw = item.cookware;
306
307 let mut tool_text = String::new();
308
309 if !item.quantity.is_empty() {
310 tool_text.push_str(&grouped_quantity_fmt(&item.quantity));
311 tool_text.push(' ');
312 }
313
314 tool_text.push_str(cw.display_name());
315
316 if cw.modifiers().is_optional() {
317 tool_text.push_str(" (optional)");
318 }
319
320 if let Some(note) = &cw.note {
321 tool_text.push_str(&format!(", {note}"));
322 }
323
324 tools.push(tool_text);
325 }
326
327 tools
328}
329
330fn build_step(recipe: &Recipe, step: &cooklang::model::Step, step_number: usize) -> Value {
331 let mut step_text = String::new();
332 let mut total_seconds: f64 = 0.0;
333 let mut has_timers = false;
334
335 for item in &step.items {
336 match item {
337 Item::Text { value } => {
338 step_text.push_str(value);
339 }
340 &Item::Ingredient { index } => {
341 let igr = &recipe.ingredients[index];
342 step_text.push_str(&igr.display_name());
343 }
344 &Item::Cookware { index } => {
345 let cw = &recipe.cookware[index];
346 step_text.push_str(&cw.name);
347 }
348 &Item::Timer { index } => {
349 let t = &recipe.timers[index];
350 if let Some(name) = &t.name {
351 step_text.push_str(&format!("{name} for "));
352 }
353 if let Some(quantity) = &t.quantity {
354 step_text.push_str(&quantity.to_string());
355
356 let seconds = match quantity.value() {
361 cooklang::quantity::Value::Number(n) => Some(n.value()),
362 cooklang::quantity::Value::Range { start, end } => {
363 Some((start.value() + end.value()) / 2.0)
364 }
365 cooklang::quantity::Value::Text(_) => None,
366 };
367 if let Some(val) = seconds {
368 let multiplier = match quantity.unit().map(|u| u.to_lowercase()).as_deref()
369 {
370 Some("s" | "sec" | "second" | "seconds") => Some(1.0),
371 Some("h" | "hr" | "hour" | "hours") => Some(3600.0),
372 Some("m" | "min" | "minute" | "minutes") | None => Some(60.0),
374 Some(_) => None, };
376 if let Some(m) = multiplier {
377 total_seconds += val * m;
378 has_timers = true;
379 }
380 }
381 }
382 }
383 &Item::InlineQuantity { index } => {
384 let q = &recipe.inline_quantities[index];
385 step_text.push_str(&q.to_string());
386 }
387 }
388 }
389
390 let mut instruction = json!({
391 "@type": "HowToStep",
392 "position": step_number,
393 "text": step_text.trim()
394 });
395
396 let total_secs = total_seconds.round() as i64;
400 if has_timers && total_secs > 0 {
401 let hours = total_secs / 3600;
402 let mins = (total_secs % 3600) / 60;
403 let secs = total_secs % 60;
404
405 let mut dur = "PT".to_string();
406 if hours > 0 {
407 dur.push_str(&format!("{hours}H"));
408 }
409 if mins > 0 {
410 dur.push_str(&format!("{mins}M"));
411 }
412 if secs > 0 {
413 dur.push_str(&format!("{secs}S"));
414 }
415 instruction["timeRequired"] = json!(dur);
416 }
417
418 instruction
419}
420
421fn create_instructions_list(recipe: &Recipe) -> Vec<Value> {
422 let mut instructions = Vec::new();
423 let mut step_number = 0;
426
427 for section in &recipe.sections {
428 let mut section_steps = Vec::new();
429 for content in §ion.content {
430 if let cooklang::Content::Step(step) = content {
431 step_number += 1;
432 section_steps.push(build_step(recipe, step, step_number));
433 }
434 }
435
436 if section_steps.is_empty() {
437 continue;
438 }
439
440 if let Some(name) = §ion.name {
441 let section_obj = json!({
443 "@type": "HowToSection",
444 "name": name,
445 "itemListElement": section_steps
446 });
447 instructions.push(section_obj);
448 } else {
449 instructions.extend(section_steps);
451 }
452 }
453
454 instructions
455}