use crate::server::handlers::common::check_path;
use crate::server::{activity, AppState};
use crate::util::recipe_info::{resolve_recipe_info, RecipeInfo};
use crate::util::PARSER;
use crate::web::viewer::Viewer;
use anyhow::Context as _;
use axum::{
extract::{Extension, State},
http::StatusCode,
Json,
};
use camino::{Utf8Path, Utf8PathBuf};
use cookcli_core::shopping_list::{
extract_ingredients, recipe_display_name, ExtractOptions, ScaledRecipe, ShoppingIngredients,
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 ingredients = ShoppingIngredients::default();
let core_ctx = cookcli_core::Context::new(state.base_path.clone());
for entry in payload {
let name = entry.recipe;
check_path(&name)?;
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(),
include_optional: true,
},
&mut ingredients,
)
.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 mut list = merged(ingredients);
let aisle_content = read_aisle_file(&state);
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 mut 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() })),
)
})?;
let core_ctx = cookcli_core::Context::new(state.base_path.clone());
name_by_title(&core_ctx, &mut items);
Ok(Json(items))
}
fn name_by_title(ctx: &cookcli_core::Context, entries: &mut [StoredEntry]) {
for entry in entries {
let path = cookcli_core::resolve_reference(Utf8Path::new(""), &entry.path);
if let Some(title) = path.as_deref().and_then(|path| recipe_title(ctx, path)) {
entry.name = title;
}
if let Some(refs) = &entry.included_references {
let dir = path
.as_deref()
.and_then(Utf8Path::parent)
.unwrap_or(Utf8Path::new(""));
entry.included_reference_names = Some(
refs.iter()
.map(|reference| {
cookcli_core::resolve_reference(dir, reference)
.and_then(|path| recipe_title(ctx, &path))
.unwrap_or_else(|| recipe_display_name(reference))
})
.collect(),
);
}
if let Some(recipes) = &mut entry.recipes {
name_by_title(ctx, recipes);
}
}
}
fn recipe_title(ctx: &cookcli_core::Context, path: &Utf8Path) -> Option<String> {
let request = cookcli_core::recipe::ReadRequest {
source: cookcli_core::RecipeSource::Path(path.to_owned()),
scale: 1.0,
};
match cookcli_core::recipe::read(ctx, request) {
Ok(outcome) => Some(outcome.value.title).filter(|title| !title.is_empty()),
Err(e) => {
tracing::debug!("No title for shopping list entry '{path}': {e}");
None
}
}
}
#[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>>,
Extension(viewer): Extension<Viewer>,
Json(payload): Json<AddItemRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
check_path(&payload.path)?;
let store = ShoppingListStore::new(&state.base_path);
let added = format!(
"added {}{} to the shopping list",
activity::quoted(&payload.path),
scaled(payload.scale)
);
let item = StoredEntry {
name: recipe_display_name(&payload.path),
path: payload.path,
scale: payload.scale,
included_references: payload.included_references,
included_reference_names: None,
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() })),
)
})?;
activity::record(&viewer, added);
Ok(StatusCode::OK)
}
fn scaled(scale: f64) -> String {
if scale == 1.0 {
String::new()
} else {
format!(" ×{scale}")
}
}
#[derive(Debug, Deserialize)]
pub struct RemoveItemRequest {
pub path: String,
#[serde(default)]
pub index: Option<usize>,
}
pub async fn remove_from_shopping_list(
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
Json(payload): Json<RemoveItemRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let store = ShoppingListStore::new(&state.base_path);
let removed = match payload.index {
Some(index) => store.remove_at(index, &payload.path),
None => store.remove(&payload.path).map(|()| true),
}
.map_err(|e| {
tracing::error!("Failed to remove from shopping list: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
if !removed {
return Err((
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "The shopping list changed; reload it and try again."
})),
));
}
activity::record(
&viewer,
format_args!(
"removed {} from the shopping list",
activity::quoted(&payload.path)
),
);
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>>,
Extension(viewer): Extension<Viewer>,
) -> 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() })),
)
})?;
activity::record(&viewer, "cleared the shopping list");
Ok(StatusCode::OK)
}
#[derive(Debug, Deserialize)]
pub struct CheckItemRequest {
pub name: String,
}
impl CheckItemRequest {
fn ingredient(&self) -> Result<&str, (StatusCode, Json<serde_json::Value>)> {
let name = self.name.trim();
if name.is_empty() || name.contains(['\n', '\r']) {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Invalid ingredient name" })),
));
}
Ok(name)
}
}
pub async fn check_shopping_item(
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
Json(payload): Json<CheckItemRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let name = payload.ingredient()?;
let _guard = state.checked_log_lock.lock().await;
let store = ShoppingListStore::new(&state.base_path);
store.check(name).map_err(|e| {
tracing::error!("Failed to check item: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
activity::record(
&viewer,
format_args!(
"checked off {} on the shopping list",
activity::quoted(name)
),
);
Ok(StatusCode::OK)
}
pub async fn uncheck_shopping_item(
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
Json(payload): Json<CheckItemRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let name = payload.ingredient()?;
let _guard = state.checked_log_lock.lock().await;
let store = ShoppingListStore::new(&state.base_path);
store.uncheck(name).map_err(|e| {
tracing::error!("Failed to uncheck item: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
activity::record(
&viewer,
format_args!("unchecked {} on the shopping list", activity::quoted(name)),
);
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>>,
Extension(viewer): Extension<Viewer>,
) -> 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() })),
)
})?;
activity::record(
&viewer,
"dropped the shopping list's checks for items no longer on it",
);
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 = ShoppingIngredients::default();
let core_ctx = cookcli_core::Context::new(state.base_path.clone());
let mut add = |path: &str, scale: f64, included: Option<&[String]>| -> anyhow::Result<()> {
if !crate::util::is_safe_relative_path(path) {
tracing::warn!(
"Ignoring stored shopping list path outside the recipe directory: {path}"
);
return Ok(());
}
extract_ingredients(
&core_ctx,
&ScaledRecipe {
source: cookcli_core::RecipeSource::Path(path.into()),
scale,
},
&ExtractOptions {
ignore_references: false,
included_references: included,
include_optional: true,
},
&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())?;
}
}
let list = merged(list);
let mut names: Vec<String> = list.iter().map(|(name, _)| name.clone()).collect();
let aisle_content = read_aisle_file(state);
let aisle = cooklang::aisle::parse_lenient(&aisle_content)
.output()
.cloned()
.unwrap_or_default();
names.extend(
list.use_common_names(&aisle, PARSER.converter())
.iter()
.map(|(name, _)| name.clone()),
);
Ok(names)
}
fn read_aisle_file(state: &AppState) -> String {
let Some(path) = state.aisle_file() else {
tracing::debug!("No aisle file configured");
return String::new();
};
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()
}
}
}
fn merged(ingredients: ShoppingIngredients) -> IngredientList {
let ShoppingIngredients {
mut required,
optional,
} = ingredients;
for (name, quantity) in optional.iter() {
required.add_ingredient(name.clone(), quantity, PARSER.converter());
}
required
}
#[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>>,
Extension(viewer): Extension<Viewer>,
Json(payload): Json<AddMenuRequest>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
check_path(&payload.path)?;
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::menu::load_menu(&entry, &recipe_path, &state.base_path, menu_scale)
.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 section in &menu.sections {
for item in section.meals.iter().flat_map(|meal| &meal.items) {
let cooklang_find::MenuItem::RecipeReference { path, scale, .. } = item else {
continue;
};
if crate::util::menu::is_outside_reference(path) {
tracing::warn!(
"Skipping recipe reference '{}' in menu '{}': it points outside the \
recipe directory",
path,
payload.path
);
continue;
}
let info = match resolve_recipe_info(&state.base_path, path) {
Ok(info) => info,
Err(e) => {
tracing::warn!("Could not resolve referenced recipe '{}': {}", path, e);
RecipeInfo::default()
}
};
recipes.push(StoredEntry {
name: recipe_display_name(path),
path: path.clone(),
scale: scale.unwrap_or(menu_scale),
included_references: Some(info.sub_refs),
included_reference_names: None,
recipes: None,
});
}
}
let added = format!(
"added menu {}{} to the shopping list",
activity::quoted(&payload.path),
scaled(menu_scale)
);
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() })),
)
})?;
activity::record(&viewer, added);
Ok(StatusCode::OK)
}
#[cfg(test)]
mod tests {
use super::CheckItemRequest;
fn ingredient(name: &str) -> Option<String> {
CheckItemRequest {
name: name.to_string(),
}
.ingredient()
.ok()
.map(str::to_string)
}
#[test]
fn a_checked_name_is_trimmed() {
assert_eq!(ingredient(" olive oil "), Some("olive oil".to_string()));
}
#[test]
fn a_blank_or_multi_line_name_is_refused() {
for name in ["", " ", "milk\n- eggs", "milk\r+ eggs"] {
assert_eq!(ingredient(name), None, "{name:?}");
}
}
}