use crate::server::AppState;
use crate::util::menu_scale::{reference_scale_factor, resolve_recipe_info, RecipeInfo};
use crate::util::PARSER;
use anyhow::Context as _;
use axum::{extract::State, http::StatusCode, Json};
use camino::Utf8PathBuf;
use cookcli_core::shopping_list::{
extract_ingredients, recipe_display_name, ExtractOptions, ScaledRecipe, ShoppingListStore,
StoredEntry,
};
use cooklang::ingredient_list::IngredientList;
use serde::Deserialize;
use serde_json;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct RecipeRequest {
recipe: String,
scale: Option<f64>,
included_references: Option<Vec<String>>,
}
pub async fn shopping_list(
State(state): State<Arc<AppState>>,
axum::extract::Json(payload): axum::extract::Json<Vec<RecipeRequest>>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let mut list = IngredientList::new();
let core_ctx = cookcli_core::Context::new(state.base_path.clone());
for entry in payload {
let name = entry.recipe;
let recipe = ScaledRecipe {
source: cookcli_core::RecipeSource::Path(name.as_str().into()),
scale: entry.scale.unwrap_or(1.0),
};
let diagnostics = extract_ingredients(
&core_ctx,
&recipe,
&ExtractOptions {
ignore_references: false,
included_references: entry.included_references.as_deref(),
},
&mut list,
)
.map_err(|e| {
tracing::error!("Error processing recipe: {}", e);
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
for diagnostic in diagnostics {
tracing::warn!("Recipe '{}': {}", name, diagnostic.message);
}
}
let aisle_content = if let Some(path) = &state.aisle_path {
match std::fs::read_to_string(path) {
Ok(content) => {
tracing::debug!("Loaded aisle file from: {:?}", path);
content
}
Err(e) => {
tracing::warn!("Failed to read aisle file from {:?}: {}", path, e);
String::new()
}
}
} else {
tracing::debug!("No aisle file configured");
String::new()
};
let aisle_result = cooklang::aisle::parse_lenient(&aisle_content);
if aisle_result.report().has_warnings() {
for warning in aisle_result.report().warnings() {
tracing::warn!("Aisle configuration warning: {}", warning);
}
}
let aisle = aisle_result.output().cloned().unwrap_or_default();
let pantry_conf = if let Some(path) = &state.pantry_path {
match std::fs::read_to_string(path) {
Ok(content) => {
tracing::debug!("Loaded pantry file from: {:?}", path);
let result = cooklang::pantry::parse_lenient(&content);
if result.report().has_warnings() {
for warning in result.report().warnings() {
tracing::warn!("Pantry configuration warning: {}", warning);
}
}
result.output().cloned()
}
Err(e) => {
tracing::warn!("Failed to read pantry file from {:?}: {}", path, e);
None
}
}
} else {
tracing::debug!("No pantry file configured");
None
};
list = list.use_common_names(&aisle, PARSER.converter());
let mut pantry_items = Vec::new();
if let Some(ref pantry) = pantry_conf {
for (ingredient_name, _) in list.iter() {
if let Some((_, pantry_item)) = pantry.find_ingredient(ingredient_name) {
if let Some(qty_str) = pantry_item.quantity() {
if qty_str == "unlim" || qty_str == "unlimited" {
pantry_items.push(ingredient_name.clone());
} else if let Some((value, _)) = pantry_item.parsed_quantity() {
if value > 0.0 {
pantry_items.push(ingredient_name.clone());
}
}
} else {
pantry_items.push(ingredient_name.clone());
}
}
}
}
let final_list = if let Some(ref pantry) = pantry_conf {
list.subtract_pantry(pantry, PARSER.converter())
} else {
list
};
let categories = final_list.categorize(&aisle);
let mut shopping_categories = Vec::new();
for (category, items) in categories {
let mut entries: Vec<(String, _)> = items.into_iter().collect();
if category == "other" {
entries.sort_by_key(|(a, _)| a.to_lowercase());
}
let mut shopping_items = Vec::new();
for (name, qty) in entries {
let item_json = serde_json::json!({
"name": name,
"quantities": crate::util::format::quantity::ordered_components(&qty)
});
shopping_items.push(item_json);
}
if !shopping_items.is_empty() {
shopping_categories.push(serde_json::json!({
"category": category,
"items": shopping_items
}));
}
}
let store = ShoppingListStore::new(&state.base_path);
let checked = store.checked_set().unwrap_or_default();
let json_value = serde_json::json!({
"categories": shopping_categories,
"pantry_items": pantry_items,
"checked": checked.into_iter().collect::<Vec<_>>()
});
Ok(Json(json_value))
}
pub async fn get_shopping_list_items(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<StoredEntry>>, (StatusCode, Json<serde_json::Value>)> {
let store = ShoppingListStore::new(&state.base_path);
let items = store.load().map_err(|e| {
tracing::error!("Failed to load shopping list: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(Json(items))
}
#[derive(Debug, Deserialize)]
pub struct AddItemRequest {
pub path: String,
pub scale: f64,
pub included_references: Option<Vec<String>>,
}
pub async fn add_to_shopping_list(
State(state): State<Arc<AppState>>,
Json(payload): Json<AddItemRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let store = ShoppingListStore::new(&state.base_path);
let item = StoredEntry {
name: recipe_display_name(&payload.path),
path: payload.path,
scale: payload.scale,
included_references: payload.included_references,
recipes: None,
};
store.add(item).map_err(|e| {
tracing::error!("Failed to add to shopping list: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(StatusCode::OK)
}
#[derive(Debug, Deserialize)]
pub struct RemoveItemRequest {
pub path: String,
}
pub async fn remove_from_shopping_list(
State(state): State<Arc<AppState>>,
Json(payload): Json<RemoveItemRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let store = ShoppingListStore::new(&state.base_path);
store.remove(&payload.path).map_err(|e| {
tracing::error!("Failed to remove from shopping list: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
let _guard = state.checked_log_lock.lock().await;
match aggregate_current_ingredient_names(&state) {
Ok(names) => {
if let Err(e) = store.compact(names) {
tracing::warn!("Failed to compact checked log after remove: {:?}", e);
}
}
Err(e) => tracing::warn!(
"Skipping compact after remove — aggregation failed: {:?}",
e
),
}
Ok(StatusCode::OK)
}
pub async fn clear_shopping_list(
State(state): State<Arc<AppState>>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let _guard = state.checked_log_lock.lock().await;
let store = ShoppingListStore::new(&state.base_path);
store.clear().map_err(|e| {
tracing::error!("Failed to clear shopping list: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(StatusCode::OK)
}
#[derive(Debug, Deserialize)]
pub struct CheckItemRequest {
pub name: String,
}
pub async fn check_shopping_item(
State(state): State<Arc<AppState>>,
Json(payload): Json<CheckItemRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let _guard = state.checked_log_lock.lock().await;
let store = ShoppingListStore::new(&state.base_path);
store.check(&payload.name).map_err(|e| {
tracing::error!("Failed to check item: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(StatusCode::OK)
}
pub async fn uncheck_shopping_item(
State(state): State<Arc<AppState>>,
Json(payload): Json<CheckItemRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let _guard = state.checked_log_lock.lock().await;
let store = ShoppingListStore::new(&state.base_path);
store.uncheck(&payload.name).map_err(|e| {
tracing::error!("Failed to uncheck item: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(StatusCode::OK)
}
pub async fn get_checked_items(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<String>>, (StatusCode, Json<serde_json::Value>)> {
let store = ShoppingListStore::new(&state.base_path);
let checked = store.checked_set().map_err(|e| {
tracing::error!("Failed to get checked items: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(Json(checked.into_iter().collect()))
}
pub async fn compact_checked(
State(state): State<Arc<AppState>>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let _guard = state.checked_log_lock.lock().await;
let store = ShoppingListStore::new(&state.base_path);
let names = aggregate_current_ingredient_names(&state).map_err(|e| {
tracing::error!("Failed to aggregate ingredients for compact: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
store.compact(names).map_err(|e| {
tracing::error!("Failed to compact checked list: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(StatusCode::OK)
}
fn aggregate_current_ingredient_names(state: &AppState) -> anyhow::Result<Vec<String>> {
let store = ShoppingListStore::new(&state.base_path);
let items = store.load()?;
let mut list = IngredientList::new();
let core_ctx = cookcli_core::Context::new(state.base_path.clone());
let mut add = |path: &str, scale: f64, included: Option<&[String]>| -> anyhow::Result<()> {
extract_ingredients(
&core_ctx,
&ScaledRecipe {
source: cookcli_core::RecipeSource::Path(path.into()),
scale,
},
&ExtractOptions {
ignore_references: false,
included_references: included,
},
&mut list,
)
.with_context(|| format!("aggregating ingredients for {path} at scale {scale}"))?;
Ok(())
};
for item in &items {
if let Some(recipes) = &item.recipes {
for recipe in recipes {
add(
&recipe.path,
recipe.scale,
recipe.included_references.as_deref(),
)?;
}
} else {
add(&item.path, item.scale, item.included_references.as_deref())?;
}
}
Ok(list.iter().map(|(name, _)| name.clone()).collect())
}
#[derive(Debug, Deserialize)]
pub struct AddMenuRequest {
pub path: String,
pub scale: f64,
}
pub async fn add_menu_to_shopping_list(
State(state): State<Arc<AppState>>,
Json(payload): Json<AddMenuRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let store = ShoppingListStore::new(&state.base_path);
let menu_scale = payload.scale;
let recipe_path = Utf8PathBuf::from(&payload.path);
let entry = cooklang_find::get_recipe(vec![&state.base_path], &recipe_path).map_err(|e| {
tracing::error!("Menu not found: {}", payload.path);
(
StatusCode::NOT_FOUND,
Json(
serde_json::json!({ "error": format!("Menu not found: {}: {}", payload.path, e) }),
),
)
})?;
let menu = crate::util::parse_recipe_from_entry(&entry, 1.0).map_err(|e| {
tracing::error!("Failed to parse menu: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to parse menu: {e}") })),
)
})?;
let mut recipes = Vec::new();
for ingredient in &menu.ingredients {
if let Some(ref recipe_ref) = ingredient.reference {
let ref_display = if recipe_ref.components.is_empty() {
recipe_ref.name.clone()
} else {
format!("{}/{}", recipe_ref.components.join("/"), recipe_ref.name)
};
let ref_path_for_find = recipe_ref.path(cookcli_core::REFERENCE_SEPARATOR);
let info = match resolve_recipe_info(&state.base_path, &ref_path_for_find) {
Ok(info) => info,
Err(e) => {
tracing::warn!(
"Could not resolve referenced recipe '{}': {}",
ref_display,
e
);
RecipeInfo::default()
}
};
let recipe_factor =
reference_scale_factor(ingredient.quantity.as_ref(), &info, &ref_display);
let final_scale = recipe_factor * menu_scale;
let sub_refs = info.sub_refs;
let path = ref_display
.strip_prefix("./")
.unwrap_or(&ref_display)
.to_string();
recipes.push(StoredEntry {
name: recipe_display_name(&path),
path,
scale: final_scale,
included_references: Some(sub_refs),
recipes: None,
});
}
}
store
.add_menu(payload.path, menu_scale, recipes)
.map_err(|e| {
tracing::error!("Failed to add menu to shopping list: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(StatusCode::OK)
}