use super::common::{json_error, recipe_file, RecipeFile};
use crate::server::plan_text::{EditError, PlanText};
use crate::server::{activity, AppState};
use crate::web::plan::{PlanEdit, PlanView};
use crate::web::viewer::Viewer;
use axum::{
extract::{Extension, Path, State},
http::StatusCode,
Json,
};
use camino::Utf8Path;
use chrono::NaiveDate;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::sync::{Arc, LazyLock};
use unic_langid::LanguageIdentifier;
type ApiError = (StatusCode, Json<serde_json::Value>);
static EDITS: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));
pub fn version(text: &str) -> String {
Sha256::digest(text.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[derive(Deserialize)]
pub struct Slot {
date: String,
meal: String,
}
#[derive(Deserialize)]
pub struct Line {
date: String,
meal: String,
index: usize,
text: String,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
pub enum PlanOp {
Add {
#[serde(flatten)]
to: Slot,
recipe: String,
servings: Option<f64>,
},
Remove {
#[serde(flatten)]
line: Line,
},
Move {
#[serde(flatten)]
line: Line,
to: Slot,
},
Copy {
#[serde(flatten)]
line: Line,
to: Slot,
},
}
#[derive(Deserialize)]
pub struct PlanEditRequest {
version: String,
#[serde(flatten)]
op: PlanOp,
}
fn bad_request(message: impl std::fmt::Display) -> ApiError {
(StatusCode::BAD_REQUEST, json_error(message))
}
fn stale(current: &str) -> ApiError {
(
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "The plan changed since the page was loaded",
"version": current,
})),
)
}
fn parse_date(date: &str) -> Result<NaiveDate, ApiError> {
NaiveDate::parse_from_str(date, "%Y-%m-%d")
.map_err(|_| bad_request(format!("Not a date: {date:?}")))
}
fn recipe_reference(
base: &Utf8Path,
recipe: &str,
servings: Option<f64>,
) -> Result<String, ApiError> {
let recipe = recipe.replace('\\', "/");
match recipe_file(base, &recipe)? {
RecipeFile::Existing(file) if file.extension() == Some("cook") => {}
_ => return Err(bad_request(format!("No such recipe: {recipe:?}"))),
}
let name = recipe.strip_suffix(".cook").unwrap_or(&recipe);
if name.contains(['{', '}', '@', '\n', '\r']) {
return Err(bad_request(format!(
"This recipe's name cannot be written in a plan: {recipe:?}"
)));
}
let amount = match servings {
None => String::new(),
Some(servings) if servings.is_finite() && servings > 0.0 => format!(
"{}%servings",
crate::util::format::number::format_number(servings)
),
Some(_) => return Err(bad_request("Servings must be above zero")),
};
Ok(format!("@./{name}{{{amount}}}"))
}
fn edit_error(error: EditError, current: &str) -> ApiError {
match error {
EditError::Stale => stale(current),
EditError::BadMeal => bad_request("Not a meal name a plan can use"),
}
}
pub async fn plan_edit(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
Extension(lang): Extension<LanguageIdentifier>,
Json(request): Json<PlanEditRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let file_path = match recipe_file(&state.base_path, &path)? {
RecipeFile::Existing(file) if file.extension() == Some("menu") => file,
RecipeFile::Existing(_) => return Err(bad_request("Not a meal plan")),
RecipeFile::Missing(_) => {
return Err((
StatusCode::NOT_FOUND,
json_error(format!("Meal plan not found: {path}")),
))
}
};
let _one_at_a_time = EDITS.lock().await;
let text = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
tracing::error!("Failed to read {file_path}: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to read the plan: {e}")),
)
})?;
let current = version(&text);
if request.version != current {
return Err(stale(¤t));
}
let mut plan = PlanText::parse(&text);
if !crate::web::plan::is_plan(plan.section_names()) {
return Err(bad_request("Not a meal plan"));
}
let meals = plan.meal_order();
let fail = |error| edit_error(error, ¤t);
let summary = match request.op {
PlanOp::Add {
to,
recipe,
servings,
} => {
let date = parse_date(&to.date)?;
let item = recipe_reference(&state.base_path, &recipe, servings)?;
plan.add(date, &to.meal, &item, &meals, &lang)
.map_err(fail)?;
format!(
"added {} to {} on {date}",
activity::quoted(&item),
activity::quoted(&to.meal)
)
}
PlanOp::Remove { line } => {
let date = parse_date(&line.date)?;
let item = plan
.remove(date, &line.meal, line.index, &line.text)
.map_err(fail)?;
format!(
"removed {} from {} on {date}",
activity::quoted(&item),
activity::quoted(&line.meal)
)
}
PlanOp::Move { line, to } => {
let (from, date) = (parse_date(&line.date)?, parse_date(&to.date)?);
let item = plan
.remove(from, &line.meal, line.index, &line.text)
.map_err(fail)?;
plan.add(date, &to.meal, &item, &meals, &lang)
.map_err(fail)?;
format!(
"moved {} from {} on {from} to {} on {date}",
activity::quoted(&item),
activity::quoted(&line.meal),
activity::quoted(&to.meal)
)
}
PlanOp::Copy { line, to } => {
let (from, date) = (parse_date(&line.date)?, parse_date(&to.date)?);
let item = plan
.read(from, &line.meal, line.index, &line.text)
.map_err(fail)?;
plan.add(date, &to.meal, &item, &meals, &lang)
.map_err(fail)?;
format!(
"copied {} to {} on {date}",
activity::quoted(&item),
activity::quoted(&to.meal)
)
}
};
let changed = plan.render();
write_atomically(&file_path, &changed).await?;
activity::record(
&viewer,
format_args!(
"updated {}: {summary}",
activity::file(&state.base_path, &file_path)
),
);
Ok(Json(serde_json::json!({
"status": "success",
"version": version(&changed),
})))
}
async fn write_atomically(file_path: &Utf8Path, text: &str) -> Result<(), ApiError> {
let temp_path = file_path.with_extension("tmp");
let failed = |e: std::io::Error| {
tracing::error!("Failed to save {file_path}: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!(
"Failed to save the plan: {e}. Check that the recipes folder has write permissions."
)),
)
};
if let Err(e) = tokio::fs::write(&temp_path, text).await {
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(failed(e));
}
if let Err(e) =
crate::server::fs_atomic::rename_replace_async(temp_path.clone(), file_path.to_owned())
.await
{
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(failed(e));
}
Ok(())
}
pub async fn annotate(plan: &mut PlanView, base: &Utf8Path, path: &str, servings: Option<String>) {
let Ok(RecipeFile::Existing(file_path)) = recipe_file(base, path) else {
return;
};
if file_path.extension() != Some("menu") {
return;
}
let Ok(text) = tokio::fs::read_to_string(&file_path).await else {
return;
};
let parsed = PlanText::parse(&text);
let mut edit = PlanEdit {
path: file_path
.strip_prefix(base)
.unwrap_or(&file_path)
.as_str()
.replace('\\', "/"),
version: version(&text),
servings,
..PlanEdit::default()
};
for day in plan.weeks.iter().flatten().flatten() {
let Ok(date) = NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") else {
continue;
};
for meal in &day.meals {
let Some(name) = &meal.name else {
continue;
};
if !edit.meals.contains(name) {
edit.meals.push(name.clone());
}
let items = parsed.items(date, name);
if items.len() == meal.lines.len() {
edit.items.insert(format!("{}|{name}", day.date), items);
}
}
}
plan.edit = Some(edit);
}