use crate::quantity::grouped_quantity_fmt;
use cooklang::{convert::Converter, model::Item, Recipe};
use serde_json::{json, Value};
use std::io;
pub fn print_schema(
recipe: &Recipe,
name: &str,
scale: f64,
converter: &Converter,
writer: impl io::Write,
pretty: bool,
) -> io::Result<()> {
let schema = create_schema_object(recipe, name, scale, converter);
if pretty {
serde_json::to_writer_pretty(writer, &schema)?;
} else {
serde_json::to_writer(writer, &schema)?;
}
Ok(())
}
fn create_schema_object(recipe: &Recipe, name: &str, scale: f64, converter: &Converter) -> Value {
let mut schema = json!({
"@context": "https://schema.org",
"@type": "Recipe",
"name": if scale != 1.0 {
format!("{name} (scaled {scale}x)")
} else {
name.to_string()
}
});
if let Some(desc) = recipe.metadata.description() {
schema["description"] = json!(desc);
}
if let Some(tags) = recipe.metadata.tags() {
schema["keywords"] = json!(tags.join(", "));
}
if let Some(author) = recipe.metadata.author() {
let author_name = author.name().unwrap_or(author.url().unwrap_or(""));
schema["author"] = json!({
"@type": "Person",
"name": author_name
});
}
if let Some(source) = recipe.metadata.source() {
if let Some(url) = source.url() {
schema["url"] = json!(url);
} else if let Some(name) = source.name() {
schema["url"] = json!(name);
}
}
if let Some(servings) = recipe.metadata.servings() {
use cooklang::metadata::Servings;
match servings {
Servings::Number(n) => {
let scaled_servings = if scale != 1.0 {
(n as f64 * scale).round() as u32
} else {
n
};
schema["recipeYield"] = json!(format!("{} servings", scaled_servings));
}
Servings::Text(text) => {
schema["recipeYield"] = json!(text);
}
}
}
add_time_fields(&mut schema, recipe);
add_nutrition_info(&mut schema, recipe);
let ingredients = create_ingredients_list(recipe, converter);
if !ingredients.is_empty() {
schema["recipeIngredient"] = json!(ingredients);
}
let tools = create_tools_list(recipe, converter);
if !tools.is_empty() {
schema["tool"] = json!(tools);
}
let instructions = create_instructions_list(recipe);
if !instructions.is_empty() {
schema["recipeInstructions"] = json!(instructions);
}
if let Some(category) = recipe.metadata.map.get("category") {
if let Some(cat_str) = category.as_str() {
schema["recipeCategory"] = json!(cat_str);
}
}
if let Some(cuisine) = recipe.metadata.map.get("cuisine") {
if let Some(cuisine_str) = cuisine.as_str() {
schema["recipeCuisine"] = json!(cuisine_str);
}
}
if let Some(image) = recipe.metadata.map.get("image") {
if let Some(image_str) = image.as_str() {
schema["image"] = json!(image_str);
}
}
schema
}
fn add_time_fields(schema: &mut Value, recipe: &Recipe) {
if let Some(prep_time_val) = recipe.metadata.get("prep time") {
if let Some(prep_time_str) = prep_time_val.as_str() {
schema["prepTime"] = json!(format_iso_duration(prep_time_str));
}
}
if let Some(cook_time_val) = recipe.metadata.get("cook time") {
if let Some(cook_time_str) = cook_time_val.as_str() {
schema["cookTime"] = json!(format_iso_duration(cook_time_str));
}
}
let has_prep = recipe.metadata.get("prep time").is_some();
let has_cook = recipe.metadata.get("cook time").is_some();
if has_prep && has_cook {
let mut total_minutes = 0;
if let Some(prep_val) = recipe.metadata.get("prep time").and_then(|v| v.as_str()) {
total_minutes += extract_number(prep_val).unwrap_or(0);
}
if let Some(cook_val) = recipe.metadata.get("cook time").and_then(|v| v.as_str()) {
total_minutes += extract_number(cook_val).unwrap_or(0);
}
schema["totalTime"] = json!(format!("PT{}M", total_minutes));
}
}
fn format_iso_duration(time_str: &str) -> String {
let lower = time_str.to_lowercase();
if lower.contains("hour") {
if let Some(hours) = extract_number(&lower) {
return format!("PT{hours}H");
}
} else if lower.contains("min") {
if let Some(minutes) = extract_number(&lower) {
return format!("PT{minutes}M");
}
}
if let Some(minutes) = extract_number(&lower) {
return format!("PT{minutes}M");
}
"PT0M".to_string()
}
fn extract_number(s: &str) -> Option<i32> {
s.chars()
.filter(|c| c.is_numeric())
.collect::<String>()
.parse::<i32>()
.ok()
}
fn add_nutrition_info(schema: &mut Value, recipe: &Recipe) {
let mut nutrition = json!({
"@type": "NutritionInformation"
});
let mut has_nutrition = false;
if let Some(calories) = recipe.metadata.map.get("calories") {
if let Some(cal_str) = calories.as_str() {
nutrition["calories"] = json!(format!("{} calories", cal_str));
has_nutrition = true;
} else if let Some(cal_num) = calories.as_u64() {
nutrition["calories"] = json!(format!("{} calories", cal_num));
has_nutrition = true;
}
}
if let Some(protein) = recipe.metadata.map.get("protein") {
if let Some(prot_str) = protein.as_str() {
nutrition["proteinContent"] = json!(prot_str);
has_nutrition = true;
}
}
if let Some(fat) = recipe.metadata.map.get("fat") {
if let Some(fat_str) = fat.as_str() {
nutrition["fatContent"] = json!(fat_str);
has_nutrition = true;
}
}
if let Some(carbs) = recipe.metadata.map.get("carbohydrates") {
if let Some(carbs_str) = carbs.as_str() {
nutrition["carbohydrateContent"] = json!(carbs_str);
has_nutrition = true;
}
}
if let Some(fiber) = recipe.metadata.map.get("fiber") {
if let Some(fiber_str) = fiber.as_str() {
nutrition["fiberContent"] = json!(fiber_str);
has_nutrition = true;
}
}
if let Some(sugar) = recipe.metadata.map.get("sugar") {
if let Some(sugar_str) = sugar.as_str() {
nutrition["sugarContent"] = json!(sugar_str);
has_nutrition = true;
}
}
if let Some(sodium) = recipe.metadata.map.get("sodium") {
if let Some(sodium_str) = sodium.as_str() {
nutrition["sodiumContent"] = json!(sodium_str);
has_nutrition = true;
}
}
if has_nutrition {
schema["nutrition"] = nutrition;
}
}
fn create_ingredients_list(recipe: &Recipe, converter: &Converter) -> Vec<String> {
let mut ingredients = Vec::new();
for entry in recipe.group_ingredients(converter) {
let ingredient = entry.ingredient;
if !ingredient.modifiers().should_be_listed() {
continue;
}
let mut ingredient_text = String::new();
if !entry.quantity.is_empty() {
ingredient_text.push_str(&grouped_quantity_fmt(&entry.quantity));
ingredient_text.push(' ');
}
ingredient_text.push_str(&ingredient.display_name());
if ingredient.modifiers().is_optional() {
ingredient_text.push_str(" (optional)");
}
if let Some(note) = &ingredient.note {
ingredient_text.push_str(&format!(", {note}"));
}
ingredients.push(ingredient_text);
}
ingredients
}
fn create_tools_list(recipe: &Recipe, converter: &Converter) -> Vec<String> {
let mut tools = Vec::new();
for item in recipe.group_cookware(converter) {
let cw = item.cookware;
let mut tool_text = String::new();
if !item.quantity.is_empty() {
tool_text.push_str(&grouped_quantity_fmt(&item.quantity));
tool_text.push(' ');
}
tool_text.push_str(cw.display_name());
if cw.modifiers().is_optional() {
tool_text.push_str(" (optional)");
}
if let Some(note) = &cw.note {
tool_text.push_str(&format!(", {note}"));
}
tools.push(tool_text);
}
tools
}
fn build_step(recipe: &Recipe, step: &cooklang::model::Step, step_number: usize) -> Value {
let mut step_text = String::new();
let mut total_seconds: f64 = 0.0;
let mut has_timers = false;
for item in &step.items {
match item {
Item::Text { value } => {
step_text.push_str(value);
}
&Item::Ingredient { index } => {
let igr = &recipe.ingredients[index];
step_text.push_str(&igr.display_name());
}
&Item::Cookware { index } => {
let cw = &recipe.cookware[index];
step_text.push_str(&cw.name);
}
&Item::Timer { index } => {
let t = &recipe.timers[index];
if let Some(name) = &t.name {
step_text.push_str(&format!("{name} for "));
}
if let Some(quantity) = &t.quantity {
step_text.push_str(&quantity.to_string());
let seconds = match quantity.value() {
cooklang::quantity::Value::Number(n) => Some(n.value()),
cooklang::quantity::Value::Range { start, end } => {
Some((start.value() + end.value()) / 2.0)
}
cooklang::quantity::Value::Text(_) => None,
};
if let Some(val) = seconds {
let multiplier = match quantity.unit().map(|u| u.to_lowercase()).as_deref()
{
Some("s" | "sec" | "second" | "seconds") => Some(1.0),
Some("h" | "hr" | "hour" | "hours") => Some(3600.0),
Some("m" | "min" | "minute" | "minutes") | None => Some(60.0),
Some(_) => None, };
if let Some(m) = multiplier {
total_seconds += val * m;
has_timers = true;
}
}
}
}
&Item::InlineQuantity { index } => {
let q = &recipe.inline_quantities[index];
step_text.push_str(&q.to_string());
}
}
}
let mut instruction = json!({
"@type": "HowToStep",
"position": step_number,
"text": step_text.trim()
});
let total_secs = total_seconds.round() as i64;
if has_timers && total_secs > 0 {
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
let mut dur = "PT".to_string();
if hours > 0 {
dur.push_str(&format!("{hours}H"));
}
if mins > 0 {
dur.push_str(&format!("{mins}M"));
}
if secs > 0 {
dur.push_str(&format!("{secs}S"));
}
instruction["timeRequired"] = json!(dur);
}
instruction
}
fn create_instructions_list(recipe: &Recipe) -> Vec<Value> {
let mut instructions = Vec::new();
let mut step_number = 0;
for section in &recipe.sections {
let mut section_steps = Vec::new();
for content in §ion.content {
if let cooklang::Content::Step(step) = content {
step_number += 1;
section_steps.push(build_step(recipe, step, step_number));
}
}
if section_steps.is_empty() {
continue;
}
if let Some(name) = §ion.name {
let section_obj = json!({
"@type": "HowToSection",
"name": name,
"itemListElement": section_steps
});
instructions.push(section_obj);
} else {
instructions.extend(section_steps);
}
}
instructions
}