use crate::server::activity;
use crate::web::viewer::Viewer;
use crate::{
server::{
handlers::{
common::{check_path, normalize_tags, recipe_file, RecipeFile},
recipe_rename::RECIPE_FILES,
},
AppState,
},
util::PARSER,
};
use axum::{
extract::{Extension, Path, Query, State},
http::StatusCode,
Json,
};
use camino::{Utf8Path, Utf8PathBuf};
use cooklang_find;
use serde::{Deserialize, Serialize};
use serde_json;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
#[derive(Deserialize)]
pub struct RecipeQuery {
scale: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
q: String,
}
fn json_error(msg: impl std::fmt::Display) -> Json<serde_json::Value> {
Json(serde_json::json!({ "error": msg.to_string() }))
}
pub async fn all_recipes(
State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let recipes = 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 recipes = serde_json::to_value(recipes).map_err(|e| {
tracing::error!("Failed to serialize recipes: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, json_error(&e))
})?;
normalize_tree_tags(&mut recipes);
relative_tree_paths(&mut recipes, &state.base_path);
Ok(Json(recipes))
}
fn relative_tree_paths(node: &mut serde_json::Value, base: &Utf8Path) {
for pointer in ["/path", "/recipe/source/path"] {
if let Some(path) = node.pointer_mut(pointer) {
if let Some(text) = path.as_str() {
*path = relative_path(text, base).into();
}
}
}
if let Some(children) = node.get_mut("children").and_then(|c| c.as_object_mut()) {
for child in children.values_mut() {
relative_tree_paths(child, base);
}
}
}
fn relative_path(path: &str, base: &Utf8Path) -> String {
let path = Utf8Path::new(path);
match path.strip_prefix(base) {
Ok(inside) => inside.as_str().replace('\\', "/"),
Err(_) => path.file_name().unwrap_or_default().to_string(),
}
}
fn normalize_tree_tags(node: &mut serde_json::Value) {
if let Some(metadata) = node.pointer_mut("/recipe/metadata") {
normalize_tags(metadata);
}
if let Some(children) = node.get_mut("children").and_then(|c| c.as_object_mut()) {
for child in children.values_mut() {
normalize_tree_tags(child);
}
}
}
pub async fn recipe(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
Query(query): Query<RecipeQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
check_path(&path)?;
let entry = cooklang_find::get_recipe(vec![&state.base_path], &Utf8PathBuf::from(&path))
.map_err(|e| {
tracing::error!("Recipe not found: {path}");
(
StatusCode::NOT_FOUND,
json_error(format!("Recipe not found: {path}: {e}")),
)
})?;
let recipe =
crate::util::parse_recipe_from_entry(&entry, query.scale.unwrap_or(1.0)).map_err(|e| {
tracing::error!("Failed to parse recipe: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, json_error(&e))
})?;
let image_path = entry.title_image().clone().and_then(|img_path| {
crate::web::builders::get_image_path(&state.base_path, &state.url_prefix, img_path)
});
#[derive(Serialize)]
struct ApiRecipe {
#[serde(flatten)]
recipe: Arc<cooklang::Recipe>,
grouped_ingredients: Vec<serde_json::Value>,
}
let grouped_ingredients = recipe
.group_ingredients(PARSER.converter())
.into_iter()
.map(|entry| {
serde_json::json!({
"index": entry.index,
"quantities": crate::util::format::quantity::ordered_components(&entry.quantity)
})
})
.collect();
let api_recipe = ApiRecipe {
recipe,
grouped_ingredients,
};
let mut value = serde_json::json!({
"recipe": api_recipe,
"image": image_path,
"scale": query.scale.unwrap_or(1.0),
});
if let Some(metadata) = value.pointer_mut("/recipe/metadata/map") {
normalize_tags(metadata);
}
Ok(Json(value))
}
pub async fn recipe_raw(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
let RecipeFile::Existing(file_path) = recipe_file(&state.base_path, &path)? else {
tracing::error!("Recipe file not found: {path}");
return Err((
StatusCode::NOT_FOUND,
json_error(format!("Recipe file not found: {path}")),
));
};
tokio::fs::read_to_string(&file_path).await.map_err(|e| {
tracing::error!("Failed to read recipe file {}: {}", file_path, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to read recipe file: {e}")),
)
})
}
pub async fn recipe_save(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
body: String,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let _writing = RECIPE_FILES.lock().await;
let (file_path, created) = match recipe_file(&state.base_path, &path)? {
RecipeFile::Existing(file_path) => (file_path, false),
RecipeFile::Missing(file_path) => (file_path, true),
};
let temp_path = file_path.with_extension("tmp");
let mut temp_file = tokio::fs::File::create(&temp_path).await.map_err(|e| {
tracing::error!("Failed to create temp file {}: {}", temp_path, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!(
"Failed to save recipe: {e}. Check that the recipes folder has write permissions."
)),
)
})?;
temp_file.write_all(body.as_bytes()).await.map_err(|e| {
tracing::error!("Failed to write to temp file {}: {}", temp_path, e);
let temp_path_clone = temp_path.clone();
tokio::spawn(async move {
let _ = tokio::fs::remove_file(&temp_path_clone).await;
});
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to write recipe: {e}")),
)
})?;
crate::server::fs_atomic::rename_replace_async(temp_path.clone(), file_path.clone())
.await
.map_err(|e| {
tracing::error!("Failed to rename temp file to {}: {}", file_path, e);
let temp_path_clone = temp_path.clone();
tokio::spawn(async move {
let _ = tokio::fs::remove_file(&temp_path_clone).await;
});
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to save recipe: {e}")),
)
})?;
activity::record(
&viewer,
format_args!(
"{} {}",
if created { "created" } else { "updated" },
activity::file(&state.base_path, &file_path)
),
);
Ok(Json(serde_json::json!({
"status": "success",
"path": path
})))
}
pub async fn reload() -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!("Reload requested - recipes will be refreshed from disk on next request");
Ok(Json(serde_json::json!({
"status": "success",
"message": "Recipes will be refreshed from disk on next request"
})))
}
pub async fn search(
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let hits = cookcli_core::search::search(
&cookcli_core::Context::new(state.base_path.clone()),
cookcli_core::search::SearchRequest {
query: query.q.clone(),
base_dir: None,
},
)
.map_err(|e| {
tracing::error!("Failed to search recipes: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, json_error(&e))
})?
.into_value();
let results = hits
.into_iter()
.map(|hit| {
serde_json::json!({
"name": hit.name,
"path": hit.relative_path.to_string()
})
})
.collect();
Ok(Json(results))
}
pub async fn recipe_delete(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let _writing = RECIPE_FILES.lock().await;
let RecipeFile::Existing(file_path) = recipe_file(&state.base_path, &path)? else {
tracing::error!("Recipe file not found for deletion: {path}");
return Err((
StatusCode::NOT_FOUND,
json_error(format!("Recipe file not found: {path}")),
));
};
tokio::fs::remove_file(&file_path).await.map_err(|e| {
tracing::error!("Failed to delete recipe file {}: {}", file_path, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to delete recipe: {e}")),
)
})?;
activity::record(
&viewer,
format_args!("deleted {}", activity::file(&state.base_path, &file_path)),
);
Ok(Json(serde_json::json!({
"status": "success",
"path": path
})))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tree_paths_become_relative_to_the_collection() {
let base = Utf8Path::new("/srv/cook/recipes");
let mut tree = json!({
"name": "recipes",
"path": "/srv/cook/recipes",
"recipe": null,
"children": {
"Breakfast": {
"name": "Breakfast",
"path": "/srv/cook/recipes/Breakfast",
"recipe": null,
"children": {
"Pancakes": {
"name": "Pancakes",
"path": "/srv/cook/recipes/Breakfast/Pancakes.cook",
"recipe": {
"metadata": {},
"source": {
"path": "/srv/cook/recipes/Breakfast/Pancakes.cook",
"source_type": "Path"
}
},
"children": {}
}
}
}
}
});
relative_tree_paths(&mut tree, base);
assert_eq!(tree["path"], "");
let breakfast = &tree["children"]["Breakfast"];
assert_eq!(breakfast["path"], "Breakfast");
let pancakes = &breakfast["children"]["Pancakes"];
assert_eq!(pancakes["path"], "Breakfast/Pancakes.cook");
assert_eq!(
pancakes["recipe"]["source"]["path"],
"Breakfast/Pancakes.cook"
);
assert_eq!(pancakes["recipe"]["source"]["source_type"], "Path");
assert!(!tree.to_string().contains("/srv/cook"), "{tree}");
}
#[test]
fn a_path_outside_the_collection_keeps_only_its_name() {
let base = Utf8Path::new("/srv/cook/recipes");
assert_eq!(relative_path("/home/someone/Soup.cook", base), "Soup.cook");
assert_eq!(
relative_path("/srv/cook/recipes-old/Soup.cook", base),
"Soup.cook"
);
}
}