use anyhow::{Context, Result};
use camino::Utf8PathBuf;
use cooklang::shopping_list::{self, CheckEntry, RecipeItem, ShoppingList, ShoppingListItem};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShoppingListApiItem {
pub path: String,
pub name: String,
pub scale: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub included_references: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recipes: Option<Vec<ShoppingListApiItem>>,
}
pub struct ShoppingListStore {
list_path: Utf8PathBuf,
checked_path: Utf8PathBuf,
legacy_path: Utf8PathBuf,
}
fn to_multiplier(scale: f64) -> Option<f64> {
if (scale - 1.0).abs() < f64::EPSILON {
None
} else {
Some(scale)
}
}
impl ShoppingListStore {
pub fn new(base_path: &Utf8PathBuf) -> Self {
Self {
list_path: base_path.join(".shopping-list"),
checked_path: base_path.join(".shopping-checked"),
legacy_path: base_path.join(".shopping_list.txt"),
}
}
pub fn migrate_if_needed(&self) -> Result<bool> {
if !self.legacy_path.exists() || self.list_path.exists() {
return Ok(false);
}
tracing::info!(
"Migrating shopping list from legacy format: {}",
self.legacy_path
);
let content =
fs::read_to_string(&self.legacy_path).context("reading legacy shopping list")?;
let mut items = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let path = parts[0];
let scale: f64 = parts[2].parse().unwrap_or(1.0);
let multiplier = if (scale - 1.0).abs() < f64::EPSILON {
None
} else {
Some(scale)
};
items.push(ShoppingListItem::Recipe(RecipeItem {
path: path.to_string(),
multiplier,
children: Vec::new(),
}));
}
}
let list = ShoppingList { items };
self.save_list(&list)?;
let backup = self.legacy_path.with_extension("txt.bak");
crate::server::fs_atomic::rename_replace(
self.legacy_path.as_std_path(),
backup.as_std_path(),
)
.context("renaming legacy shopping list to .bak")?;
tracing::info!("Migration complete. Legacy file renamed to {}", backup);
Ok(true)
}
fn read_list_raw(&self) -> Result<String> {
if !self.list_path.exists() {
return Ok(String::new());
}
fs::read_to_string(&self.list_path).context("reading .shopping-list")
}
fn read_checked_raw(&self) -> Result<String> {
if !self.checked_path.exists() {
return Ok(String::new());
}
fs::read_to_string(&self.checked_path).context("reading .shopping-checked")
}
pub fn load_list(&self) -> Result<ShoppingList> {
let content = self.read_list_raw()?;
if content.is_empty() {
return Ok(ShoppingList::default());
}
shopping_list::parse(&content).map_err(|e| anyhow::anyhow!("{}", e))
}
fn save_list(&self, list: &ShoppingList) -> Result<()> {
let mut buf = Vec::new();
shopping_list::write(list, &mut buf)?;
fs::write(&self.list_path, buf).context("writing .shopping-list")
}
pub fn load(&self) -> Result<Vec<ShoppingListApiItem>> {
self.migrate_if_needed()?;
let list = self.load_list()?;
Ok(api_items_from_list(&list))
}
pub fn add(&self, item: ShoppingListApiItem) -> Result<()> {
self.migrate_if_needed()?;
let mut list = self.load_list()?;
let children = match item.included_references {
Some(refs) => refs
.into_iter()
.map(|path| {
let path = path.strip_prefix("./").unwrap_or(&path).to_string();
ShoppingListItem::Recipe(RecipeItem {
path,
multiplier: None,
children: Vec::new(),
})
})
.collect(),
None => Vec::new(),
};
list.items.push(ShoppingListItem::Recipe(RecipeItem {
path: item.path,
multiplier: to_multiplier(item.scale),
children,
}));
self.save_list(&list)
}
pub fn add_menu(
&self,
menu_path: String,
menu_scale: f64,
recipes: Vec<ShoppingListApiItem>,
) -> Result<()> {
self.migrate_if_needed()?;
let mut list = self.load_list()?;
let children: Vec<ShoppingListItem> = recipes
.into_iter()
.map(|recipe| {
let recipe_multiplier = to_multiplier(recipe.scale);
let sub_children = match recipe.included_references {
Some(refs) => refs
.into_iter()
.map(|path| {
let path = path.strip_prefix("./").unwrap_or(&path).to_string();
ShoppingListItem::Recipe(RecipeItem {
path,
multiplier: None,
children: Vec::new(),
})
})
.collect(),
None => Vec::new(),
};
ShoppingListItem::Recipe(RecipeItem {
path: recipe.path,
multiplier: recipe_multiplier,
children: sub_children,
})
})
.collect();
list.items.push(ShoppingListItem::Recipe(RecipeItem {
path: menu_path,
multiplier: to_multiplier(menu_scale),
children,
}));
self.save_list(&list)
}
pub fn remove(&self, path: &str) -> Result<()> {
self.migrate_if_needed()?;
let mut list = self.load_list()?;
if let Some(pos) = list.items.iter().position(|i| match i {
ShoppingListItem::Recipe(r) => r.path == path,
_ => false,
}) {
list.items.remove(pos);
}
self.save_list(&list)
}
pub fn clear(&self) -> Result<()> {
self.save_list(&ShoppingList::default())?;
if self.checked_path.exists() {
fs::remove_file(&self.checked_path).context("removing .shopping-checked")?;
}
Ok(())
}
pub fn checked_set(&self) -> Result<HashSet<String>> {
let content = self.read_checked_raw()?;
let entries = shopping_list::parse_checked(&content);
Ok(shopping_list::checked_set(&entries))
}
pub fn check(&self, name: &str) -> Result<()> {
self.append_check_entry(&CheckEntry::Checked(name.to_string()))
}
pub fn uncheck(&self, name: &str) -> Result<()> {
self.append_check_entry(&CheckEntry::Unchecked(name.to_string()))
}
fn append_check_entry(&self, entry: &CheckEntry) -> Result<()> {
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.checked_path)
.context("opening .shopping-checked for append")?;
shopping_list::write_check_entry(entry, &mut file)?;
Ok(())
}
pub fn compact<I, S>(&self, current_ingredients: I) -> Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let names: Vec<String> = current_ingredients
.into_iter()
.map(|s| s.as_ref().to_string())
.collect();
let content = self.read_checked_raw()?;
let entries = shopping_list::parse_checked(&content);
let compacted = shopping_list::compact_checked(&entries, names.iter().map(String::as_str));
let mut buf = Vec::new();
for entry in &compacted {
shopping_list::write_check_entry(entry, &mut buf)?;
}
let tmp_path = self.checked_path.with_file_name(".shopping-checked.tmp");
let write_result = (|| -> Result<()> {
use std::io::Write as _;
let mut tmp = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp_path)
.context("opening .shopping-checked temp file")?;
tmp.write_all(&buf).context("writing compacted temp file")?;
tmp.sync_all().context("fsync-ing compacted temp file")?;
Ok(())
})();
if let Err(e) = write_result {
let _ = fs::remove_file(&tmp_path);
return Err(e);
}
if let Err(e) = crate::server::fs_atomic::rename_replace(
tmp_path.as_std_path(),
self.checked_path.as_std_path(),
)
.context("renaming compacted temp file into place")
{
let _ = fs::remove_file(&tmp_path);
return Err(e);
}
Ok(())
}
}
fn api_items_from_list(list: &ShoppingList) -> Vec<ShoppingListApiItem> {
let mut items = Vec::new();
for item in &list.items {
if let ShoppingListItem::Recipe(r) = item {
if r.path.ends_with(".menu") {
let recipes: Vec<ShoppingListApiItem> = r
.children
.iter()
.filter_map(|c| match c {
ShoppingListItem::Recipe(cr) => {
let refs: Vec<String> = cr
.children
.iter()
.filter_map(|gc| match gc {
ShoppingListItem::Recipe(gcr) => Some(gcr.path.clone()),
_ => None,
})
.collect();
Some(ShoppingListApiItem {
path: cr.path.clone(),
name: recipe_display_name(&cr.path),
scale: cr.multiplier.unwrap_or(1.0),
included_references: Some(refs),
recipes: None,
})
}
_ => None,
})
.collect();
items.push(ShoppingListApiItem {
path: r.path.clone(),
name: recipe_display_name(&r.path),
scale: r.multiplier.unwrap_or(1.0),
included_references: None,
recipes: Some(recipes),
});
} else {
let refs: Vec<String> = r
.children
.iter()
.filter_map(|c| match c {
ShoppingListItem::Recipe(cr) => Some(cr.path.clone()),
_ => None,
})
.collect();
items.push(ShoppingListApiItem {
path: r.path.clone(),
name: recipe_display_name(&r.path),
scale: r.multiplier.unwrap_or(1.0),
included_references: Some(refs),
recipes: None,
});
}
}
}
items
}
pub fn recipe_display_name(path: &str) -> String {
let name = path.rsplit('/').next().unwrap_or(path);
let name = name.strip_suffix(".cook").unwrap_or(name);
name.strip_suffix(".menu").unwrap_or(name).to_string()
}