use super::common::{check_path, json_error, normalize_tags};
use crate::server::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use camino::Utf8PathBuf;
use cooklang_find::{Menu, MenuItem, RecipeTree};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Serialize)]
pub struct MenuListItem {
pub name: String,
pub path: String,
}
pub fn collect_menus(
tree: &RecipeTree,
base_path: &camino::Utf8Path,
result: &mut Vec<MenuListItem>,
) {
if let Some(ref entry) = tree.recipe {
if entry.is_menu() {
if let Some(full_path) = entry.path() {
let relative = full_path
.strip_prefix(base_path)
.unwrap_or(full_path.as_ref());
let name = entry.name().clone().unwrap_or_else(|| relative.to_string());
result.push(MenuListItem {
name,
path: relative.to_string(),
});
}
}
}
for child in tree.children.values() {
collect_menus(child, base_path, result);
}
}
pub async fn list_menus(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<MenuListItem>>, (StatusCode, Json<serde_json::Value>)> {
let tree = cooklang_find::build_tree(&state.base_path).map_err(|e| {
tracing::error!("Failed to build recipe tree: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, json_error(&e))
})?;
let mut menus = Vec::new();
collect_menus(&tree, &state.base_path, &mut menus);
Ok(Json(menus))
}
#[derive(Deserialize)]
pub struct MenuQuery {
scale: Option<f64>,
}
#[derive(Serialize)]
pub struct MenuResponse {
pub name: String,
pub path: String,
pub metadata: serde_json::Value,
pub sections: Vec<MenuApiSection>,
}
#[derive(Serialize)]
pub struct MenuApiSection {
pub name: Option<String>,
pub date: Option<String>,
pub meals: Vec<MenuMeal>,
}
#[derive(Serialize)]
pub struct MenuMeal {
#[serde(rename = "type")]
pub meal_type: String,
pub time: Option<String>,
pub items: Vec<MenuMealItem>,
}
#[derive(Serialize)]
#[serde(tag = "kind")]
pub enum MenuMealItem {
#[serde(rename = "recipe_reference")]
RecipeReference {
name: String,
path: Option<String>,
scale: f64,
},
#[serde(rename = "ingredient")]
Ingredient {
name: String,
quantity: Option<String>,
unit: Option<String>,
},
}
const DEFAULT_MEAL_TYPE: &str = "Items";
pub async fn get_menu(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
Query(query): Query<MenuQuery>,
) -> Result<Json<MenuResponse>, (StatusCode, Json<serde_json::Value>)> {
check_path(&path)?;
let scale = query.scale.unwrap_or(1.0);
let recipe_path = Utf8PathBuf::from(&path);
let entry = cooklang_find::get_recipe(vec![&state.base_path], &recipe_path).map_err(|e| {
tracing::error!("Menu not found: {path}");
(
StatusCode::NOT_FOUND,
json_error(format!("Menu not found: {path}: {e}")),
)
})?;
if !entry.is_menu() {
return Err((
StatusCode::BAD_REQUEST,
json_error(format!("Path is not a menu file: {path}")),
));
}
let menu = crate::util::menu::load_menu(&entry, &recipe_path, &state.base_path, scale)
.map_err(|e| {
tracing::error!("Failed to parse menu: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to parse menu: {e}")),
)
})?;
Ok(Json(menu_response(&menu, path, scale)))
}
fn menu_response(menu: &Menu, path: String, scale: f64) -> MenuResponse {
let sections = menu
.sections
.iter()
.map(|section| MenuApiSection {
name: section.name.clone(),
date: section.date.clone(),
meals: section
.meals
.iter()
.filter_map(|meal| {
let items: Vec<_> = meal
.items
.iter()
.filter_map(|item| api_item(item, scale))
.collect();
(!items.is_empty()).then(|| MenuMeal {
meal_type: meal
.meal_type
.clone()
.unwrap_or_else(|| DEFAULT_MEAL_TYPE.to_string()),
time: meal.time.clone(),
items,
})
})
.collect(),
})
.collect();
MenuResponse {
name: menu.name.clone(),
path,
metadata: api_metadata(menu, scale),
sections,
}
}
fn api_item(item: &MenuItem, scale: f64) -> Option<MenuMealItem> {
match item {
MenuItem::RecipeReference {
path,
scale: factor,
..
} => Some(MenuMealItem::RecipeReference {
name: format!("./{path}"),
path: Some(format!("./{path}.cook")),
scale: factor.unwrap_or(1.0),
}),
MenuItem::Ingredient {
name,
quantity,
unit,
} => {
let (quantity, unit) =
crate::util::menu::scaled_quantity(quantity.as_deref(), unit.as_deref(), scale);
Some(MenuMealItem::Ingredient {
name: name.clone(),
quantity,
unit,
})
}
_ => None,
}
}
fn api_metadata(menu: &Menu, scale: f64) -> serde_json::Value {
let raw = serde_json::to_value(&menu.metadata).unwrap_or_default();
let mut map = serde_json::Map::new();
for (key, value) in raw.as_object().into_iter().flatten() {
let val = if key == "tags" {
value.clone()
} else if let Some(s) = value.as_str() {
serde_json::Value::String(s.to_string())
} else if let Some(n) = value.as_i64() {
serde_json::Value::String(n.to_string())
} else if let Some(n) = value.as_f64() {
serde_json::Value::String(crate::util::format::number::format_number(n))
} else {
serde_json::Value::Null
};
map.insert(key.clone(), val);
}
if let Some(base) = menu.metadata.get("servings").and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
}) {
let scaled = (base as f64 * scale).round() as u64;
map.insert(
"servings".to_string(),
serde_json::Value::String(scaled.to_string()),
);
}
let mut metadata = serde_json::Value::Object(map);
normalize_tags(&mut metadata);
metadata
}