diff --git a/.dockerignore b/.dockerignore index 1a3ffdd..1e5499b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,5 @@ !Cargo.* !crates !.env +!items.json +!list.json diff --git a/.gitignore b/.gitignore index 409e195..5b745f4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,9 @@ target/ # These are backup files generated by rustfmt **/*.rs.bk -# Local JSON 'groceries.json' and 'list.json' files +# Local JSON 'items.json' and 'list.json' files *.json -/sqlite.db \ No newline at end of file +*.yaml + +/sqlite.db diff --git a/Cargo.lock b/Cargo.lock index 8f10fa4..0788348 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "serde_yaml", "thiserror", "tokio", "tracing", @@ -296,7 +297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -340,7 +341,7 @@ dependencies = [ "diesel_table_macro_syntax", "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -360,7 +361,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" dependencies = [ - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -541,7 +542,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -1082,7 +1083,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -1149,7 +1150,6 @@ dependencies = [ "dotenvy", "futures", "insta", - "question", "r2d2", "serde", "serde_derive", @@ -1219,7 +1219,7 @@ dependencies = [ "phf_shared 0.11.2", "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -1306,9 +1306,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.69" +version = "1.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +checksum = "2de98502f212cfcea8d0bb305bd0f49d7ebdd75b64ba0a68f937d888f4e0d6db" dependencies = [ "unicode-ident", ] @@ -1321,9 +1321,9 @@ checksum = "acbb3ede7a8f9a8ab89e714637f2cf40001b58f21340d4242b2f11533e65fa8d" [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -1578,22 +1578,22 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.189" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537" +checksum = "0b114498256798c94a0689e1a15fec6005dee8ac1f41de56404b67afc2a4b773" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.189" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5" +checksum = "a3385e45322e8f9931410f01b3031ec534c3947d0e94c18049af4d9f9907d4e0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -1628,6 +1628,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1bf28c79a99f70ee1f1d83d10c875d2e70618417fda01ad1785e027579d9d38" +dependencies = [ + "indexmap 2.0.2", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "servo_arc" version = "0.3.0" @@ -1753,9 +1766,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.38" +version = "2.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" +checksum = "89456b690ff72fddcecf231caedbe615c59480c93358a93dfae7fc29e3ebbf0e" dependencies = [ "proc-macro2", "quote", @@ -1830,7 +1843,7 @@ checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -1914,7 +1927,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -2000,7 +2013,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", ] [[package]] @@ -2088,6 +2101,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "unsafe-libyaml" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b" + [[package]] name = "url" version = "2.4.1" @@ -2175,7 +2194,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", "wasm-bindgen-shared", ] @@ -2209,7 +2228,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.38", + "syn 2.0.46", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/Cargo.toml b/Cargo.toml index 3c5c913..e04d6c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ scraper = "0.18.1" serde = { version = "*", features = ["derive"] } serde_derive = "*" serde_json = "*" +serde_yaml = "0.9.30" thiserror = "1.0.48" tokio = { version = "1", features = ["full"] } tracing = "0.1.37" diff --git a/Makefile b/Makefile index 14bcac8..a7e8c38 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build help fetch read add list clear +.PHONY: build help fetch read add list export clear build: docker build --tag gust --file Dockerfile . @@ -32,5 +32,21 @@ add: list: docker run --rm -v gust:/app gust read list +export: + if [ ! -f "$(PWD)/items.yaml" ]; then \ + echo "Error: items.yaml not found in $(PWD)."; \ + exit 1; \ + fi + if [ ! -f "$(PWD)/list.yaml" ]; then \ + echo "Error: list.yaml not found in $(PWD)."; \ + exit 1; \ + fi + docker run --rm \ + -v gust_data:/app \ + -v $(PWD)/items.yaml:/app/items.yaml \ + -v $(PWD)/list.yaml:/app/list.yaml \ + gust \ + export + clear: docker run --rm -v gust:/app gust update list clear diff --git a/README.md b/README.md index 83f3606..69440db 100644 --- a/README.md +++ b/README.md @@ -29,17 +29,19 @@ All you need is to [install Docker](https://docs.docker.com/install/). - [help](./docs/cli.md#help) - [fetching recipes](./docs/cli.md#fetching-recipes) +- [importing/exporting data](./docs/cli.md#importing-and-exporting-data) ### [database](./docs/database.md) - [storage options](./docs/database.md#storage-options) - - [json](./docs/database.md#json) - [sqlite](./docs/database.md#sqlite) + - [postgres](./docs/database.md#postgresql) ### [docker](./docs/docker.md) -- [data volumes](./docker.md#creating-a-gust_data-volume) -- [migrating from JSON to SQLite](./docker.md#migrate-a-json-gust-store-to-sqlite) +- [data volumes](./docs/docker.md#creating-a-gust_data-volume) +- [migrating from JSON to SQLite](./docs/docker.md#migrate-a-json-gust-store-to-sqlite) +- [exporting data to YAML](./docs/docker.md#export-data-to-yaml) --- ## getting started diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 6d2301e..4972c95 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -2,10 +2,11 @@ use std::fmt::{self, Display}; use common::{ commands::ApiCommand, - item::{Item, Name, Section}, + item::{Item, Name}, items::Items, list::List, recipes::{Ingredients, Recipe}, + section::Section, }; use persistence::store::{Store, StoreDispatch, StoreError, StoreResponse, StoreType}; @@ -117,10 +118,11 @@ pub enum ApiResponse { Checklist(Vec), DeletedRecipe(Recipe), DeletedChecklistItem(Name), + Exported(Vec, List), FetchedRecipe((Recipe, Ingredients)), ItemAlreadyAdded(Name), Items(Items), - JsonToSqlite, + ImportToSqlite, List(List), NothingReturned(ApiCommand), Recipes(Vec), @@ -149,6 +151,17 @@ impl Display for ApiResponse { } Self::DeletedChecklistItem(name) => writeln!(f, "\ndeleted from checklist: \n{name}"), Self::DeletedRecipe(recipe) => writeln!(f, "\ndeleted recipe: \n{recipe}"), + Self::Exported(items, list) => { + writeln!(f, "\nexported items:")?; + for item in items { + writeln!(f, " {item}")?; + } + writeln!(f, "\nexported list:")?; + for item in list.items() { + writeln!(f, " {item}")?; + } + Ok(()) + } Self::FetchedRecipe((recipe, ingredients)) => { writeln!(f, "\n{recipe}:")?; for ingredient in ingredients.iter() { @@ -159,12 +172,12 @@ impl Display for ApiResponse { Self::ItemAlreadyAdded(item) => writeln!(f, "\nitem already added: {item}"), Self::Items(items) => { writeln!(f)?; - for item in items.collection() { + for item in items.collection_iter() { writeln!(f, "{item}")?; } Ok(()) } - Self::JsonToSqlite => writeln!(f, "\nJSON to SQLite data store migration successful"), + Self::ImportToSqlite => writeln!(f, "\nImport successful"), Self::List(list) => { writeln!(f)?; for item in list.items() { @@ -213,10 +226,11 @@ impl From for ApiResponse { StoreResponse::Checklist(item) => Self::Checklist(item), StoreResponse::DeletedRecipe(item) => Self::DeletedRecipe(item), StoreResponse::DeletedChecklistItem(item) => Self::DeletedChecklistItem(item), + StoreResponse::Exported(items, list) => Self::Exported(items, list), StoreResponse::FetchedRecipe(item) => Self::FetchedRecipe(item), StoreResponse::ItemAlreadyAdded(item) => Self::ItemAlreadyAdded(item), StoreResponse::Items(item) => Self::Items(item), - StoreResponse::JsonToSqlite => Self::JsonToSqlite, + StoreResponse::ImportToSqlite => Self::ImportToSqlite, StoreResponse::List(item) => Self::List(item), StoreResponse::NothingReturned(item) => Self::NothingReturned(item), StoreResponse::Recipes(item) => Self::Recipes(item), diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 08e6968..4896cc5 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -15,6 +15,7 @@ scraper = { workspace = true } serde = { workspace = true } serde_derive = { workspace = true } serde_json = { workspace = true } +serde_yaml = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/crates/common/src/commands.rs b/crates/common/src/commands.rs index 6eb1a2a..5c401da 100644 --- a/crates/common/src/commands.rs +++ b/crates/common/src/commands.rs @@ -1,16 +1,18 @@ use url::Url; use crate::{ - item::{Name, Section}, + item::Name, recipes::{Ingredients, Recipe}, + section::Section, }; #[derive(Debug)] pub enum ApiCommand { Add(Add), Delete(Delete), + Export, FetchRecipe(Url), - MigrateJsonDbToSqlite, + ImportFromJson, Read(Read), Update(Update), } diff --git a/crates/common/src/export.rs b/crates/common/src/export.rs new file mode 100644 index 0000000..b4025cb --- /dev/null +++ b/crates/common/src/export.rs @@ -0,0 +1,34 @@ +use std::{fs::File, path::Path}; + +use serde::Serialize; +use thiserror::Error; + +use crate::{item::Item, list::List}; + +pub const ITEMS_YAML_PATH: &str = "items.yaml"; +pub const LIST_YAML_PATH: &str = "list.yaml"; + +#[derive(Error, Debug)] +pub enum ExportError { + #[error("file error: {0}")] + FileError(#[from] std::io::Error), + + #[error("'serde-yaml' error: {0}")] + SerdeYamlError(#[from] serde_yaml::Error), +} + +pub trait YamlSerializable { + fn serialize_to_yaml_and_write

(&self, path: P) -> Result<(), ExportError> + where + P: AsRef, + Self: Serialize, + { + let file = File::create(path)?; + serde_yaml::to_writer(file, self)?; + + Ok(()) + } +} + +impl YamlSerializable for Vec {} +impl YamlSerializable for List {} diff --git a/crates/common/src/input.rs b/crates/common/src/input.rs index a585bba..c28db3e 100644 --- a/crates/common/src/input.rs +++ b/crates/common/src/input.rs @@ -1,37 +1,6 @@ -use crate::{ - item::{Item, SECTIONS}, - recipes::Recipe, -}; +use crate::item::Item; use question::{Answer, Question}; -pub fn user_wants_to_add_item() -> Answer { - Question::new("Add an item to our library?") - .default(question::Answer::NO) - .show_defaults() - .confirm() -} - -pub fn user_wants_to_print_list() -> Answer { - Question::new("Print shopping list?") - .default(question::Answer::NO) - .show_defaults() - .confirm() -} - -pub fn user_wants_to_add_more_recipe_ingredients_to_list() -> Answer { - Question::new("Add more recipe ingredients to our list?") - .default(question::Answer::NO) - .show_defaults() - .confirm() -} - -pub fn user_wants_to_add_items_to_list() -> Answer { - Question::new("Add items to list?") - .default(question::Answer::NO) - .show_defaults() - .confirm() -} - // Returns `None` in case user wishes to skip being asked further. pub fn user_wants_to_add_item_to_list(item: &Item) -> Option { let res = Question::new(&format!( @@ -50,59 +19,6 @@ pub fn user_wants_to_add_item_to_list(item: &Item) -> Option { } } -pub fn user_wants_to_save_list() -> Answer { - Question::new("Save current list?") - .default(question::Answer::NO) - .show_defaults() - .confirm() -} - -// Returns `None` in case user wishes to skip being asked further. -pub fn user_wants_to_add_recipe_to_list(recipe: &Recipe) -> Option { - let res = Question::new(&format!( - "Shall we add {recipe}? (*y*, *n* for next recipe, *s* to skip to end of recipes)", - )) - .acceptable(vec!["y", "n", "s"]) - .until_acceptable() - .default(Answer::RESPONSE("n".to_string())) - .ask(); - - match res { - Some(Answer::RESPONSE(res)) if &res == "y" => Some(true), - Some(Answer::RESPONSE(res)) if &res == "s" => None, - _ => Some(false), - } -} - -pub fn item_from_user() -> String { - let ans = Question::new( - "What is the item?\n\ - e.g. 'bread'", - ) - .ask(); - - if let Some(Answer::RESPONSE(ans)) = ans { - ans - } else { - item_from_user() - } -} - -pub fn section_from_user() -> String { - if let Some(Answer::RESPONSE(ans)) = Question::new( - "What is the section?\n\ - e.g. 'bread'", - ) - .acceptable(SECTIONS.to_vec()) - .until_acceptable() - .ask() - { - ans - } else { - section_from_user() - } -} - pub fn item_matches(item: &Item) -> Answer { Question::new(&format!("is *{item}* a match?")) .default(question::Answer::NO) diff --git a/crates/common/src/item.rs b/crates/common/src/item.rs index 4682831..b3021fc 100644 --- a/crates/common/src/item.rs +++ b/crates/common/src/item.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; -use crate::recipes::Recipe; +use crate::{recipes::Recipe, section::Section}; /// An item used in recipes or bought separately /// @@ -37,25 +37,14 @@ impl Item { self.recipes.as_ref() } - pub fn add_recipe(&mut self, recipe: &str) { - let recipe = recipe.into(); - if let Some(recipes) = &mut self.recipes { - if !recipes.contains(&recipe) { - recipes.push(recipe); - } - } else { - self.recipes = Some(vec![recipe]); - } - } - pub fn delete_recipe(&mut self, name: &str) { if let Some(vec) = self.recipes.as_mut() { vec.retain(|x| x.as_str() != name) } } - pub fn with_section(mut self, section: impl Into) -> Self { - self.section = Some(Section(section.into())); + pub fn with_section(mut self, section: &str) -> Self { + self.section = Some(section.into()); self } @@ -63,10 +52,6 @@ impl Item { self.recipes = Some(recipes.to_vec()); self } - - pub(crate) fn matches(&self, s: impl Into) -> bool { - s.into().split(' ').all(|word| !self.name.0.contains(word)) - } } impl fmt::Display for Item { @@ -101,34 +86,3 @@ impl Name { &self.0 } } - -pub const SECTIONS: [&str; 5] = ["fresh", "pantry", "protein", "dairy", "freezer"]; - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] -pub struct Section(String); - -impl From<&str> for Section { - fn from(value: &str) -> Self { - Self(value.trim().to_lowercase()) - } -} - -impl fmt::Display for Section { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Section { - pub fn new(sec: impl Into) -> Self { - Self(sec.into()) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn contains(&self, s: &Section) -> bool { - self.0.contains(s.as_str()) - } -} diff --git a/crates/common/src/items.rs b/crates/common/src/items.rs index b5a0288..a038665 100644 --- a/crates/common/src/items.rs +++ b/crates/common/src/items.rs @@ -1,10 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{ - item::{Item, Section}, - recipes::{Ingredients, Recipe}, - Load, -}; +use crate::{item::Item, load::Load, recipes::Recipe, section::Section}; #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] pub struct Items { @@ -33,16 +29,12 @@ impl Items { Self::default() } - pub fn collection(&self) -> impl Iterator { - self.collection.iter() + pub fn collection(&self) -> &[Item] { + &self.collection } - pub fn get_item_matches(&self, name: &str) -> impl Iterator { - self.collection - .iter() - .filter(|item| item.matches(name)) - .collect::>() - .into_iter() + pub fn collection_iter(&self) -> impl Iterator { + self.collection.iter() } pub fn add_item(&mut self, item: Item) { @@ -50,67 +42,4 @@ impl Items { self.collection.push(item); } } - - pub fn delete_item(&mut self, name: &str) { - self.collection = self - .collection - .drain(..) - .filter(|item| item.name().as_str() != name) - .collect(); - } - - pub fn items(&self) -> impl Iterator { - self.sections - .iter() - .flat_map(|section| { - self.collection.iter().filter(|item| { - item.section() - .map_or(false, |item_section| item_section.contains(section)) - }) - }) - .collect::>() - .into_iter() - } - - pub fn recipes(&self) -> impl Iterator { - self.recipes.iter() - } - - pub fn add_recipe(&mut self, name: &str, ingredients: &str) { - let ingredients = Ingredients::from_input_string(ingredients); - - ingredients - .iter() - .for_each(|ingredient| self.add_item(ingredient.into())); - - self.collection - .iter_mut() - .filter(|item| ingredients.contains(item.name())) - .for_each(|item| item.add_recipe(name)); - - self.recipes.push(name.into()); - } - - pub fn delete_recipe(&mut self, name: &str) { - self.recipes = self - .recipes - .drain(..) - .filter(|recipe| recipe.as_str() != name) - .collect(); - - for item in &mut self.collection { - item.delete_recipe(name); - } - } - - pub fn recipe_ingredients(&self, recipe: &Recipe) -> impl Iterator { - self.collection - .iter() - .filter(|item| { - item.recipes() - .map_or(false, |recipes| recipes.contains(recipe)) - }) - .collect::>() - .into_iter() - } } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 254cb71..a9c7de2 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,43 +1,11 @@ pub mod commands; +pub mod export; pub mod fetcher; pub mod input; pub mod item; pub mod items; pub mod list; +pub mod load; pub mod recipes; +pub mod section; pub mod telemetry; - -use std::{ - io::{self}, - path::Path, -}; - -use serde::Deserialize; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum LoadError { - #[error("load error: {0}")] - FileError(#[from] std::io::Error), - - #[error("'serde-json' error: {0}")] - SerdeJsonError(#[from] serde_json::Error), -} - -pub trait Load { - type T: for<'a> Deserialize<'a>; - - fn from_json>(path: P) -> Result { - let reader = Self::reader(path)?; - Ok(Self::from_reader(&reader)?) - } - - fn reader>(path: P) -> Result { - let file = std::fs::read_to_string(path)?; - Ok(file) - } - - fn from_reader(reader: &str) -> Result { - serde_json::from_str(reader) - } -} diff --git a/crates/common/src/list.rs b/crates/common/src/list.rs index fc8c81f..9001715 100644 --- a/crates/common/src/list.rs +++ b/crates/common/src/list.rs @@ -1,10 +1,4 @@ -use crate::{ - input::user_wants_to_add_item_to_list, - item::{Item, SECTIONS}, - items::Items, - recipes::Recipe, - Load, -}; +use crate::{item::Item, load::Load, recipes::Recipe}; use serde::{Deserialize, Serialize}; #[derive(Default, Serialize, Deserialize, Debug, Clone)] @@ -44,142 +38,11 @@ impl List { self } - pub fn with_items(mut self, items: Vec) -> Self { - self.items.extend(items); - self - } - - pub fn checklist(&self) -> &Vec { - &self.checklist - } - - pub fn recipes(&self) -> impl Iterator { - self.recipes.iter() - } - pub fn items(&self) -> &Vec { &self.items } - pub fn print(&self) { - if !self.checklist.is_empty() { - println!("Check if we need:"); - - self.checklist.iter().for_each(|item| { - println!("\t{}", item.name()); - }); - } - if !self.recipes.is_empty() { - println!("recipes:"); - - self.recipes.iter().for_each(|recipe| { - println!("\t{recipe}"); - }); - } - if !self.items.is_empty() { - println!("groceries:"); - - self.items.iter().for_each(|item| { - println!("\t{}", item.name()); - }); - } - } - - pub fn add_groceries(&mut self, groceries: &Items) { - // move everything off list to temp list - let list_items: Vec = self.items.drain(..).collect(); - let sections = SECTIONS; - let groceries_by_section: Vec> = { - sections - .into_iter() - .map(|section| { - let mut a: Vec = list_items - .iter() - .filter(|item| item.section().is_some()) - .filter(|item| { - item.section() - .map_or(false, |item_sec| item_sec.as_str() == section) - }) - .cloned() - .collect(); - - let b: Vec = groceries - .collection() - .filter(|item| { - item.section().map_or(false, |item_sec| { - item_sec.as_str() == section && !a.contains(item) - }) - }) - .cloned() - .collect(); - a.extend(b); - a - }) - .collect() - }; - for section in groceries_by_section { - if !section.is_empty() { - for item in §ion { - if !self.items.contains(item) { - if let Some(recipes) = &item.recipes() { - if recipes.iter().any(|recipe| self.recipes.contains(recipe)) { - self.add_item(item.clone()); - } - } - } - } - for item in section { - if !self.items.contains(&item) { - let res = user_wants_to_add_item_to_list(&item); - - match res { - Some(true) => { - if !self.items.contains(&item) { - self.add_item(item.clone()); - } - } - Some(false) => continue, - None => break, - } - } - } - } - } - } - pub fn add_item(&mut self, item: Item) { self.items.push(item); } - - pub fn delete_groceries_item(&mut self, name: &str) { - self.items = self - .items - .drain(..) - .filter(|item| item.name().as_str() != name) - .collect(); - } - - pub fn add_checklist_item(&mut self, item: Item) { - self.checklist.push(item); - } - - pub fn delete_checklist_item(&mut self, name: &str) { - self.checklist = self - .checklist - .drain(..) - .filter(|item| item.name().as_str() != name) - .collect(); - } - - pub fn add_recipe(&mut self, recipe: Recipe) { - self.recipes.push(recipe); - } - - pub fn delete_recipe(&mut self, name: &str) { - self.recipes = self - .recipes - .drain(..) - .filter(|recipe| recipe.as_str() != name) - .collect(); - } } diff --git a/crates/common/src/load.rs b/crates/common/src/load.rs new file mode 100644 index 0000000..8e1af10 --- /dev/null +++ b/crates/common/src/load.rs @@ -0,0 +1,43 @@ +use std::{ + io::{self}, + path::Path, +}; + +use serde::Deserialize; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum LoadError { + #[error("load error: {0}")] + FileError(#[from] std::io::Error), + + #[error("'serde-json' error: {0}")] + SerdeJsonError(#[from] serde_json::Error), +} + +pub trait Load { + type T: for<'a> Deserialize<'a>; + + fn from_json>(path: P) -> Result + where + Self: for<'a> Deserialize<'a>, + { + let reader = Self::reader(path)?; + Ok(Self::from_reader(&reader)?) + } + + fn reader>(path: P) -> Result + where + Self: for<'a> Deserialize<'a>, + { + let file = std::fs::read_to_string(path)?; + Ok(file) + } + + fn from_reader(reader: &str) -> Result + where + Self: for<'a> Deserialize<'a>, + { + serde_json::from_str(reader) + } +} diff --git a/crates/common/src/recipes.rs b/crates/common/src/recipes.rs index 7af48b2..6326e9b 100644 --- a/crates/common/src/recipes.rs +++ b/crates/common/src/recipes.rs @@ -37,6 +37,12 @@ impl From<&str> for Recipe { } } +impl From for Recipe { + fn from(s: String) -> Self { + Self(s.trim().to_lowercase()) + } +} + #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] pub struct Ingredients(Vec); diff --git a/crates/common/src/section.rs b/crates/common/src/section.rs new file mode 100644 index 0000000..11840ea --- /dev/null +++ b/crates/common/src/section.rs @@ -0,0 +1,32 @@ +use core::fmt; + +use serde::{Deserialize, Serialize}; + +pub const SECTIONS: [&str; 5] = ["fresh", "pantry", "protein", "dairy", "freezer"]; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct Section(String); + +impl Section { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for Section { + fn from(value: &str) -> Self { + Self(value.trim().to_lowercase()) + } +} + +impl From for Section { + fn from(value: String) -> Self { + Self(value.trim().to_lowercase()) + } +} + +impl fmt::Display for Section { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/gust/src/cli.rs b/crates/gust/src/cli.rs index a511c01..8228f86 100644 --- a/crates/gust/src/cli.rs +++ b/crates/gust/src/cli.rs @@ -188,10 +188,25 @@ fn update() -> Command { .subcommand(list().subcommand(refresh_list())) } -fn migrate() -> Command { - Command::new("migrate-json-store") +fn import() -> Command { + Command::new("import") .subcommand_required(false) - .about("migrate JSON store to Sqlite database") + .about("import from 'items.json' and 'list.json' files") +} + +fn export() -> Command { + Command::new("export") + .subcommand_required(false) + .about("export items to 'items.yaml' and list to 'list.yaml' files") +} + +fn store() -> Arg { + Arg::new("database") + .long("database") + .num_args(1) + .value_parser(["sqlite", "sqlite-inmem"]) + .default_value("sqlite") + .help("which database to use") } pub fn cli() -> Command { @@ -204,13 +219,7 @@ pub fn cli() -> Command { .subcommand(fetch()) .subcommand(read()) .subcommand(update()) - .subcommand(migrate()) - .arg( - Arg::new("store") - .long("database") - .num_args(1) - .value_parser(["json", "sqlite", "sqlite-inmem"]) - .default_value("sqlite") - .help("which database to use"), - ) + .subcommand(import()) + .subcommand(export()) + .arg(store()) } diff --git a/crates/gust/src/command.rs b/crates/gust/src/command.rs index 8b84d69..cf1c6e3 100644 --- a/crates/gust/src/command.rs +++ b/crates/gust/src/command.rs @@ -1,7 +1,8 @@ use common::{ commands::{Add, ApiCommand, Delete, Read, Update}, - item::{Name, Section}, + item::Name, recipes::{Ingredients, Recipe}, + section::Section, }; use clap::ArgMatches; @@ -9,21 +10,22 @@ use url::Url; use crate::CliError; -pub enum GustCommand { +pub enum UserCommand { Add(Add), Delete(Delete), + Export, FetchRecipe(Url), - MigrateJsonDbToSqlite, + ImportFromJson, Read(Read), Update(Update), } -impl TryFrom for GustCommand { +impl TryFrom for UserCommand { type Error = CliError; fn try_from(matches: ArgMatches) -> Result { match matches.subcommand() { - Some(("add", matches)) => Ok(GustCommand::Add( + Some(("add", matches)) => Ok(UserCommand::Add( if let (Some(recipe), Some(ingredients)) = ( matches.get_one::("recipe"), matches.get_one::("ingredients"), @@ -62,7 +64,7 @@ impl TryFrom for GustCommand { } }, )), - Some(("delete", matches)) => Ok(GustCommand::Delete( + Some(("delete", matches)) => Ok(UserCommand::Delete( if let Some(name) = matches.get_one::("recipe") { Delete::recipe_from_name(name.as_str().into()) } else if let Some(name) = matches.get_one::("item") { @@ -84,9 +86,9 @@ impl TryFrom for GustCommand { unreachable!("Providing a URL is required") }; let url: Url = Url::parse(url)?; - Ok(GustCommand::FetchRecipe(url)) + Ok(UserCommand::FetchRecipe(url)) } - Some(("read", matches)) => Ok(GustCommand::Read( + Some(("read", matches)) => Ok(UserCommand::Read( if let Some(name) = matches.get_one::("recipe") { Read::recipe_from_name(name.as_str().into()) } else if let Some(name) = matches.get_one::("item") { @@ -102,7 +104,7 @@ impl TryFrom for GustCommand { } }, )), - Some(("update", matches)) => Ok(GustCommand::Update(match matches.subcommand() { + Some(("update", matches)) => Ok(UserCommand::Update(match matches.subcommand() { Some(("recipe", matches)) => { let Some(name) = matches.get_one::("recipe") else { todo!() @@ -117,21 +119,23 @@ impl TryFrom for GustCommand { } _ => unimplemented!(), })), - Some(("migrate-json-store", _)) => Ok(GustCommand::MigrateJsonDbToSqlite), + Some(("import", _)) => Ok(UserCommand::ImportFromJson), + Some(("export", _)) => Ok(UserCommand::Export), _ => unreachable!(), } } } -impl From for ApiCommand { - fn from(command: GustCommand) -> Self { +impl From for ApiCommand { + fn from(command: UserCommand) -> Self { match command { - GustCommand::Add(cmd) => Self::Add(cmd), - GustCommand::Delete(cmd) => Self::Delete(cmd), - GustCommand::FetchRecipe(cmd) => Self::FetchRecipe(cmd), - GustCommand::MigrateJsonDbToSqlite => Self::MigrateJsonDbToSqlite, - GustCommand::Read(cmd) => Self::Read(cmd), - GustCommand::Update(cmd) => Self::Update(cmd), + UserCommand::Add(cmd) => Self::Add(cmd), + UserCommand::Delete(cmd) => Self::Delete(cmd), + UserCommand::Export => Self::Export, + UserCommand::FetchRecipe(cmd) => Self::FetchRecipe(cmd), + UserCommand::ImportFromJson => Self::ImportFromJson, + UserCommand::Read(cmd) => Self::Read(cmd), + UserCommand::Update(cmd) => Self::Update(cmd), } } } diff --git a/crates/gust/src/startup.rs b/crates/gust/src/startup.rs index 360e89e..271dd4b 100644 --- a/crates/gust/src/startup.rs +++ b/crates/gust/src/startup.rs @@ -1,4 +1,4 @@ -use crate::{cli, command::GustCommand, CliError}; +use crate::{cli, command::UserCommand, CliError}; use api::{Api, ApiError}; use tracing::instrument; @@ -8,14 +8,14 @@ pub async fn run() -> Result<(), CliError> { let api = Api::init( matches - .get_one::("store") - .expect("'store' has a default setting") + .get_one::("database") + .expect("'database' has a default setting") .parse() .map_err(ApiError::from)?, ) .await?; - let command: GustCommand = matches.try_into()?; + let command: UserCommand = matches.try_into()?; let response = api.dispatch(command.into()).await?; diff --git a/crates/persistence/Cargo.toml b/crates/persistence/Cargo.toml index 22872cb..dd1564c 100644 --- a/crates/persistence/Cargo.toml +++ b/crates/persistence/Cargo.toml @@ -14,7 +14,6 @@ diesel = { workspace = true } diesel_migrations = { workspace = true } dotenvy = { workspace = true } futures = { workspace = true } -question = { workspace = true } r2d2 = { workspace = true } serde = { workspace = true } serde_derive = { workspace = true } diff --git a/crates/persistence/src/import_store/mod.rs b/crates/persistence/src/import_store/mod.rs new file mode 100644 index 0000000..fe19464 --- /dev/null +++ b/crates/persistence/src/import_store/mod.rs @@ -0,0 +1,46 @@ +use std::{ + fs::{self}, + path::PathBuf, +}; + +use common::{items::Items, list::List, load::Load}; + +use crate::store::StoreError; + +pub const ITEMS_JSON_PATH: &str = "items.json"; +pub const LIST_JSON_PATH: &str = "list.json"; + +#[derive(Clone)] +pub struct ImportStore { + items: PathBuf, + list: PathBuf, +} + +impl Default for ImportStore { + fn default() -> Self { + Self { + items: PathBuf::from(ITEMS_JSON_PATH), + list: PathBuf::from(LIST_JSON_PATH), + } + } +} + +impl ImportStore { + pub fn items(&self) -> Result { + Ok(Items::from_json(&self.items)?) + } + + pub fn list(&self) -> Result { + Ok(List::from_json(&self.list)?) + } + + pub fn export_items(&self, object: impl serde::Serialize) -> Result<(), StoreError> { + let s = serde_json::to_string(&object)?; + Ok(fs::write(&self.items, s)?) + } + + pub fn export_list(&self, object: impl serde::Serialize) -> Result<(), StoreError> { + let s = serde_json::to_string(&object)?; + Ok(fs::write(&self.list, s)?) + } +} diff --git a/crates/persistence/src/json/mod.rs b/crates/persistence/src/json/mod.rs deleted file mode 100644 index 35e2157..0000000 --- a/crates/persistence/src/json/mod.rs +++ /dev/null @@ -1,830 +0,0 @@ -pub mod migrate; - -use std::{ - collections::HashSet, - fs::{self}, - path::{Path, PathBuf}, -}; - -use common::{ - input::item_matches, - item::{Item, Name}, - items::Items, - list::List, - recipes::{Ingredients, Recipe}, - Load, -}; -use question::Answer; - -use crate::store::{Storage, StoreError, StoreResponse}; - -pub const ITEMS_JSON_PATH: &str = "groceries.json"; - -pub const LIST_JSON_PATH: &str = "list.json"; - -#[derive(Clone)] -pub struct JsonStore { - items: PathBuf, - list: PathBuf, -} - -impl Default for JsonStore { - fn default() -> Self { - Self { - items: PathBuf::from(ITEMS_JSON_PATH), - list: PathBuf::from(LIST_JSON_PATH), - } - } -} - -impl JsonStore { - pub fn new() -> Self { - Self::default() - } - - pub fn with_items_path(mut self, path: &Path) -> Self { - self.items = path.to_path_buf(); - self - } - - pub fn with_list_path(mut self, path: &Path) -> Self { - self.list = path.to_path_buf(); - self - } - - pub fn save_items(&self, object: impl serde::Serialize) -> Result<(), StoreError> { - let s = serde_json::to_string(&object)?; - - Ok(fs::write(&self.items, s)?) - } - // TODO: I don't think it makes much sense to have these saved as separate JSON files. - pub fn save_list(&self, object: impl serde::Serialize) -> Result<(), StoreError> { - let s = serde_json::to_string(&object)?; - - Ok(fs::write(&self.list, s)?) - } -} - -impl Storage for JsonStore { - async fn add_item(&self, item: &Name) -> Result { - let mut groceries = Items::from_json(&self.items)?; - - if groceries - .get_item_matches(item.as_str()) - .any(|item| matches!(item_matches(item), Answer::YES)) - { - eprintln!("Item already in library"); - Ok(StoreResponse::ItemAlreadyAdded(item.clone())) - } else { - let new_item = Item::new(item.as_str()); - groceries.add_item(new_item); - Ok(StoreResponse::AddedItem(item.clone())) - } - } - - async fn add_checklist_item(&self, _item: &Name) -> Result { - todo!() - } - - async fn add_list_item(&self, _item: &Name) -> Result { - todo!() - } - - async fn add_list_recipe(&self, _recipe: &Recipe) -> Result { - todo!() - } - - async fn add_recipe( - &self, - _recipe: &Recipe, - _ingredients: &Ingredients, - ) -> Result { - todo!() - } - - async fn checklist(&self) -> Result { - todo!() - } - - async fn delete_checklist_item(&self, _item: &Name) -> Result { - todo!() - } - - async fn delete_recipe(&self, _recipe: &Recipe) -> Result { - todo!() - } - - async fn items(&self) -> Result { - Ok(StoreResponse::Items(Items::from_json(&self.items)?)) - } - - async fn list(&self) -> Result { - Ok(StoreResponse::List(List::from_json(&self.list)?)) - } - - async fn refresh_list(&self) -> Result { - todo!() - } - - async fn recipe_ingredients(&self, recipe: &Recipe) -> Result { - let items = Items::from_json(&self.items)?; - let ingredients: Ingredients = items - .recipe_ingredients(recipe) - .map(|item| item.name()) - .cloned() - .collect(); - - Ok(StoreResponse::RecipeIngredients(Some(ingredients))) - } - - async fn sections(&self) -> Result { - todo!() - } - - async fn recipes(&self) -> Result { - let mut recipes: HashSet = HashSet::new(); - - { - let groceries = Items::from_json(&self.items)?; - - for item in groceries.collection() { - if let Some(item_recipes) = item.recipes() { - for recipe in item_recipes.iter().cloned() { - recipes.insert(recipe); - } - } - } - - for recipe in groceries.recipes().cloned() { - recipes.insert(recipe); - } - } - - { - let list = List::from_json(&self.list)?; - - for recipe in list.recipes().cloned() { - recipes.insert(recipe); - } - } - Ok(StoreResponse::Recipes(recipes.into_iter().collect())) - } -} - -#[cfg(test)] -pub mod test { - use super::*; - - use assert_fs::prelude::*; - - fn test_json_file() -> Result> { - let file = assert_fs::NamedTempFile::new("test1.json")?; - file.write_str( - r#" - { - "sections": [ - "fresh", - "pantry", - "protein", - "dairy", - "freezer" - ], - "collection": [ - { - "name": "eggs", - "section": "dairy", - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] - } - ], - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] - }"#, - )?; - Ok(file) - } - - async fn items() -> Items { - let file = test_json_file().unwrap(); - let store = JsonStore::new().with_items_path(file.path()); - let StoreResponse::Items(items) = store.items().await.unwrap() else { - todo!() - }; - items - } - - #[test] - fn test_groceries_default() -> Result<(), Box> { - let default_items = Items::default(); - insta::assert_json_snapshot!(default_items, @r#" - { - "sections": [], - "collection": [], - "recipes": [] - } - "#); - Ok(()) - } - - #[tokio::test] - async fn test_save_items() -> Result<(), Box> { - let store = JsonStore::new().with_items_path(&PathBuf::from("test_groceries.json")); - let items = Items::default(); - insta::assert_json_snapshot!(items, @r#" - { - "sections": [], - "collection": [], - "recipes": [] - } - "#); - store.save_items(items)?; - match store.items().await.unwrap() { - StoreResponse::Items(items) => { - insta::assert_json_snapshot!(items, @r#" - { - "sections": [], - "collection": [], - "recipes": [] - } - "#); - } - _ => panic!(), - } - std::fs::remove_file(store.items)?; - Ok(()) - } - - #[tokio::test] - async fn test_delete_recipe() -> Result<(), Box> { - let mut items = items().await; - insta::assert_json_snapshot!(items.recipes().collect::>(), @r###" - [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] - "###); - items.delete_recipe("oatmeal chocolate chip cookies"); - insta::assert_json_snapshot!(items.recipes().collect::>(), @r###" - [ - "fried eggs for breakfast" - ] - "###); - Ok(()) - } - - #[tokio::test] - async fn test_delete_item() -> Result<(), Box> { - let mut items = items().await; - insta::assert_json_snapshot!(items.collection().collect::>(), @r###" - [ - { - "name": "eggs", - "section": "dairy", - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] - } - ] - "###); - items.delete_item("eggs"); - insta::assert_json_snapshot!(items.collection().collect::>(), @"[]"); - Ok(()) - } - - #[tokio::test] - async fn test_groceries() -> Result<(), Box> { - let mut items = items().await; - insta::assert_json_snapshot!(items, @r###" - { - "sections": [ - "fresh", - "pantry", - "protein", - "dairy", - "freezer" - ], - "collection": [ - { - "name": "eggs", - "section": "dairy", - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] - } - ], - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] - } - "###); - - let recipe = "cumquat chutney"; - - let item = Item::new("cumquats") - .with_section("fresh") - .with_recipes(&[Recipe::from(recipe)]); - - let ingredients = "cumquats, carrots, dried apricots, dried cranberries, chili, onion, garlic, cider vinegar, granulated sugar, honey, kosher salt, cardamom, cloves, coriander, ginger, black peppercorns"; - - items.add_item(item); - items.add_recipe(recipe, ingredients); - - insta::assert_json_snapshot!(items, @r###" - { - "sections": [ - "fresh", - "pantry", - "protein", - "dairy", - "freezer" - ], - "collection": [ - { - "name": "eggs", - "section": "dairy", - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] - }, - { - "name": "cumquats", - "section": "fresh", - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "carrots", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "dried apricots", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "dried cranberries", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "chili", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "onion", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "garlic", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "cider vinegar", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "granulated sugar", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "honey", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "kosher salt", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "cardamom", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "cloves", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "coriander", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "ginger", - "section": null, - "recipes": [ - "cumquat chutney" - ] - }, - { - "name": "black peppercorns", - "section": null, - "recipes": [ - "cumquat chutney" - ] - } - ], - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast", - "cumquat chutney" - ] - } - "###); - - Ok(()) - } - - #[tokio::test] - async fn test_delete_item_from_list() -> Result<(), Box> { - let file = create_test_checklist_json_file().unwrap(); - let store = JsonStore::new().with_list_path(file.path()); - - let StoreResponse::List(mut shopping_list) = store.list().await.unwrap() else { - todo!() - }; - - let item = Item::new("kumquats").with_section("fresh"); - - shopping_list.add_item(item); - insta::assert_json_snapshot!(shopping_list.items(), @r###" - [ - { - "name": "garlic", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "tomatoes", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "basil", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "pasta", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "olive oil", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "parmigiana", - "section": "dairy", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "kumquats", - "section": "fresh", - "recipes": null - } - ] - "###); - shopping_list.delete_groceries_item("kumquats"); - insta::assert_json_snapshot!(shopping_list.items(), @r###" - [ - { - "name": "garlic", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "tomatoes", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "basil", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "pasta", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "olive oil", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "parmigiana", - "section": "dairy", - "recipes": [ - "tomato pasta" - ] - } - ] - "###); - Ok(()) - } - - fn create_test_checklist_json_file( - ) -> Result> { - let file = assert_fs::NamedTempFile::new("test3.json")?; - file.write_str( - r#" - {"checklist":[],"recipes":["tomato pasta"],"items":[{"name":"garlic","section":"fresh","is_ingredient":true,"recipes":["tomato pasta"]},{"name":"tomatoes","section":"fresh","is_ingredient":true,"recipes":["tomato pasta"]},{"name":"basil","section":"fresh","is_ingredient":true,"recipes":["tomato pasta"]},{"name":"pasta","section":"pantry","is_ingredient":true,"recipes":["tomato pasta"]},{"name":"olive oil","section":"pantry","is_ingredient":true,"recipes":["tomato pasta"]},{"name":"parmigiana","section":"dairy","is_ingredient":true,"recipes":["tomato pasta"]}]} - "# - )?; - Ok(file) - } - - async fn checklist() -> List { - let file = create_test_checklist_json_file().unwrap(); - let store = JsonStore::new().with_list_path(file.path()); - let StoreResponse::List(list) = store.list().await.unwrap() else { - todo!() - }; - list - } - - #[tokio::test] - async fn test_delete_checklist_item() { - let mut shopping_list = checklist().await; - let item = Item::new("kumquats").with_section("fresh"); - shopping_list.add_checklist_item(item); - insta::assert_json_snapshot!(shopping_list.checklist(), @r###" - [ - { - "name": "kumquats", - "section": "fresh", - "recipes": null - } - ] - "###); - shopping_list.delete_checklist_item("kumquats"); - insta::assert_json_snapshot!(shopping_list.checklist(), @"[]"); - } - - #[tokio::test] - async fn test_delete_recipe_from_list() { - let mut shopping_list = checklist().await; - insta::assert_json_snapshot!(shopping_list.recipes().collect::>(), @r#" - [ - "tomato pasta" - ] - "#); - shopping_list.delete_recipe("tomato pasta"); - insta::assert_json_snapshot!(shopping_list.recipes().collect::>(), @"[]"); - } - - #[tokio::test] - async fn json_from_file() -> Result<(), Box> { - let list = checklist().await; - - insta::assert_json_snapshot!(list, @r###" - { - "checklist": [], - "recipes": [ - "tomato pasta" - ], - "items": [ - { - "name": "garlic", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "tomatoes", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "basil", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "pasta", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "olive oil", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "parmigiana", - "section": "dairy", - "recipes": [ - "tomato pasta" - ] - } - ] - } - "###); - Ok(()) - } - - #[tokio::test] - async fn test_add_groceries_item_and_add_recipe() -> Result<(), Box> { - let mut list = checklist().await; - insta::assert_json_snapshot!(list, @r###" - { - "checklist": [], - "recipes": [ - "tomato pasta" - ], - "items": [ - { - "name": "garlic", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "tomatoes", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "basil", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "pasta", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "olive oil", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "parmigiana", - "section": "dairy", - "recipes": [ - "tomato pasta" - ] - } - ] - } - "###); - - let recipe = Recipe::from("cumquat chutney"); - - let item = Item::new("cumquats") - .with_section("fresh") - .with_recipes(&[recipe.clone()]); - - list.add_item(item); - list.add_recipe(recipe); - insta::assert_json_snapshot!(list, @r###" - { - "checklist": [], - "recipes": [ - "tomato pasta", - "cumquat chutney" - ], - "items": [ - { - "name": "garlic", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "tomatoes", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "basil", - "section": "fresh", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "pasta", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "olive oil", - "section": "pantry", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "parmigiana", - "section": "dairy", - "recipes": [ - "tomato pasta" - ] - }, - { - "name": "cumquats", - "section": "fresh", - "recipes": [ - "cumquat chutney" - ] - } - ] - } - "###); - - Ok(()) - } -} diff --git a/crates/persistence/src/lib.rs b/crates/persistence/src/lib.rs index a582af7..1066268 100644 --- a/crates/persistence/src/lib.rs +++ b/crates/persistence/src/lib.rs @@ -1,4 +1,4 @@ -pub mod json; +pub mod import_store; pub mod models; pub mod schema; pub mod sqlite; diff --git a/crates/persistence/src/models.rs b/crates/persistence/src/models.rs index 87c4a81..dc594d5 100644 --- a/crates/persistence/src/models.rs +++ b/crates/persistence/src/models.rs @@ -77,9 +77,9 @@ impl ItemInfo for Section { } } -impl From

for common::item::Section { - fn from(value: Section) -> common::item::Section { - common::item::Section::new(value.name) +impl From
for common::section::Section { + fn from(value: Section) -> common::section::Section { + value.name.into() } } diff --git a/crates/persistence/src/sqlite/connection.rs b/crates/persistence/src/sqlite/connection.rs new file mode 100644 index 0000000..54efeec --- /dev/null +++ b/crates/persistence/src/sqlite/connection.rs @@ -0,0 +1,70 @@ +use std::{env, ops::Deref}; + +use diesel::{r2d2::ConnectionManager, SqliteConnection}; +use r2d2::Pool; + +use crate::store::StoreError; + +pub struct DbUri(String); + +impl Default for DbUri { + fn default() -> Self { + Self::new() + } +} + +impl From<&str> for DbUri { + fn from(value: &str) -> Self { + Self(value.to_string()) + } +} + +impl Deref for DbUri { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DbUri { + pub fn new() -> Self { + dotenvy::dotenv().ok(); + env::var("DATABASE_URL") + .expect("DATABASE_URL must be set") + .as_str() + .into() + } + + pub fn inmem() -> Self { + Self::from(":memory:") + } +} + +pub type ConnectionPool = Pool>; + +pub(crate) trait Connection { + async fn try_connect(&self) -> Result; +} + +pub(crate) struct DatabaseConnector { + db_uri: DbUri, +} + +impl DatabaseConnector { + pub(crate) fn new(db_uri: DbUri) -> Self { + Self { db_uri } + } +} + +impl Connection for DatabaseConnector { + async fn try_connect(&self) -> Result { + use diesel::Connection; + SqliteConnection::establish(&self.db_uri)?; + Ok( + Pool::builder().build(ConnectionManager::::new( + self.db_uri.deref(), + ))?, + ) + } +} diff --git a/crates/persistence/src/json/migrate.rs b/crates/persistence/src/sqlite/import.rs similarity index 80% rename from crates/persistence/src/json/migrate.rs rename to crates/persistence/src/sqlite/import.rs index 8b66055..88e9a15 100644 --- a/crates/persistence/src/json/migrate.rs +++ b/crates/persistence/src/sqlite/import.rs @@ -1,4 +1,4 @@ -use common::{item::SECTIONS, items::Items, recipes::Recipe}; +use common::{items::Items, section::SECTIONS}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection}; use crate::{ @@ -7,7 +7,7 @@ use crate::{ store::StoreError, }; -pub fn migrate_sections(connection: &mut SqliteConnection) -> Result<(), StoreError> { +pub fn import_sections(connection: &mut SqliteConnection) -> Result<(), StoreError> { use crate::schema::sections; let sections = SECTIONS; @@ -24,32 +24,12 @@ pub fn migrate_sections(connection: &mut SqliteConnection) -> Result<(), StoreEr Ok(()) } -pub fn migrate_recipes( - connection: &mut SqliteConnection, - recipes: Vec, -) -> Result<(), StoreError> { - use crate::schema::recipes; - - for recipe in recipes { - let recipe = NewRecipe { - name: &recipe.to_string().to_lowercase(), - }; - - diesel::insert_into(recipes::table) - .values(&recipe) - .on_conflict_do_nothing() - .execute(connection)?; - } - - Ok(()) -} - -pub fn groceries(connection: &mut SqliteConnection, groceries: Items) -> Result<(), StoreError> { +pub fn import_items(connection: &mut SqliteConnection, items: Items) -> Result<(), StoreError> { let items_table = schema::items::table; let recipes_table = schema::recipes::table; let sections_table = schema::sections::table; - for item in groceries.collection() { + for item in items.collection_iter() { // add the item to the item table let new_item = NewItem { name: item.name().as_str(), @@ -102,7 +82,7 @@ pub fn groceries(connection: &mut SqliteConnection, groceries: Items) -> Result< } } - if let Some(item_section) = &item.section() { + if let Some(item_section) = item.section() { // log the item_id in items_sections let results = sections_table .filter(schema::sections::dsl::name.eq(item_section.to_string())) diff --git a/crates/persistence/src/sqlite/migrations.rs b/crates/persistence/src/sqlite/migrations.rs new file mode 100644 index 0000000..41e3b83 --- /dev/null +++ b/crates/persistence/src/sqlite/migrations.rs @@ -0,0 +1,16 @@ +use diesel::sqlite::Sqlite; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; + +use crate::store::StoreError; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations"); + +pub fn run_migrations(connection: &mut impl MigrationHarness) -> Result<(), StoreError> { + // This will run the necessary migrations. + // + // See the documentation for `MigrationHarness` for + // all available methods. + connection.run_pending_migrations(MIGRATIONS)?; + + Ok(()) +} diff --git a/crates/persistence/src/sqlite/mod.rs b/crates/persistence/src/sqlite/mod.rs index b4d4535..1f49e1b 100644 --- a/crates/persistence/src/sqlite/mod.rs +++ b/crates/persistence/src/sqlite/mod.rs @@ -1,98 +1,32 @@ -use std::{env, ops::Deref}; +pub(crate) mod connection; +mod import; +mod migrations; use common::{ + export::{YamlSerializable, ITEMS_YAML_PATH, LIST_YAML_PATH}, item::Name, + items::Items, list::List, recipes::{Ingredients, Recipe}, }; -use diesel::{prelude::*, r2d2::ConnectionManager, sqlite::Sqlite, SqliteConnection}; -use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; -use r2d2::{Pool, PooledConnection}; +use diesel::{prelude::*, r2d2::ConnectionManager, SqliteConnection}; +use r2d2::PooledConnection; use crate::{ + import_store::ImportStore, models::{ - self, Item, ItemInfo, NewChecklistItem, NewItem, NewItemRecipe, NewListItem, NewListRecipe, - NewRecipe, RecipeModel, Section, + self, Item, ItemInfo, NewChecklistItem, NewItem, NewItemRecipe, NewItemSection, + NewListItem, NewListRecipe, NewRecipe, NewSection, RecipeModel, Section, }, schema, store::{Storage, StoreError, StoreResponse}, }; -pub struct DbUri(String); - -impl Default for DbUri { - fn default() -> Self { - Self::new() - } -} - -impl From<&str> for DbUri { - fn from(value: &str) -> Self { - Self(value.to_string()) - } -} - -impl Deref for DbUri { - type Target = String; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DbUri { - pub fn new() -> Self { - dotenvy::dotenv().ok(); - env::var("DATABASE_URL") - .expect("DATABASE_URL must be set") - .as_str() - .into() - } - - pub fn inmem() -> Self { - Self::from(":memory:") - } -} - -pub type ConnectionPool = Pool>; - -pub(crate) trait Connection { - async fn try_connect(&self) -> Result; -} - -pub(crate) struct DatabaseConnector { - db_uri: DbUri, -} - -impl DatabaseConnector { - pub(crate) fn new(db_uri: DbUri) -> Self { - Self { db_uri } - } -} - -impl Connection for DatabaseConnector { - async fn try_connect(&self) -> Result { - use diesel::Connection; - SqliteConnection::establish(&self.db_uri)?; - Ok( - Pool::builder().build(ConnectionManager::::new( - self.db_uri.deref(), - ))?, - ) - } -} - -pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations"); - -fn run_migrations(connection: &mut impl MigrationHarness) -> Result<(), StoreError> { - // This will run the necessary migrations. - // - // See the documentation for `MigrationHarness` for - // all available methods. - connection.run_pending_migrations(MIGRATIONS)?; - - Ok(()) -} +use self::{ + connection::{Connection, ConnectionPool, DatabaseConnector, DbUri}, + import::{import_items, import_sections}, + migrations::run_migrations, +}; #[derive(Clone)] pub struct SqliteStore { @@ -119,7 +53,6 @@ impl SqliteStore { } fn get_or_insert_item( - &self, connection: &mut SqliteConnection, name: &str, ) -> Result { @@ -135,25 +68,38 @@ impl SqliteStore { .first(connection)?) } + fn get_recipe_id( + connection: &mut SqliteConnection, + recipe: &str, + ) -> Result, StoreError> { + Ok(schema::recipes::table + .filter(schema::recipes::dsl::name.eq(recipe)) + .select(schema::recipes::dsl::id) + .first(connection) + .optional()?) + } + fn get_or_insert_recipe( - &self, connection: &mut SqliteConnection, name: &str, ) -> Result { - diesel::insert_into(schema::recipes::table) - .values(NewRecipe { name }) - .on_conflict_do_nothing() - .execute(connection)?; - - let recipe_query = schema::recipes::table.filter(schema::recipes::dsl::name.eq(name)); + match Self::get_recipe_id(connection, name)? { + Some(id) => Ok(id), + None => { + diesel::insert_into(schema::recipes::table) + .values(NewRecipe { name }) + .on_conflict_do_nothing() + .execute(connection)?; - Ok(recipe_query - .select(schema::recipes::dsl::id) - .first(connection)?) + Ok(schema::recipes::table + .filter(schema::recipes::dsl::name.eq(name)) + .select(schema::recipes::dsl::id) + .first(connection)?) + } + } } fn insert_item_recipe( - &self, connection: &mut SqliteConnection, item_id: i32, recipe_id: i32, @@ -165,28 +111,72 @@ impl SqliteStore { Ok(()) } - async fn list_items(&self) -> Result { + fn get_section_id( + connection: &mut SqliteConnection, + section: &str, + ) -> Result, StoreError> { + Ok(schema::sections::table + .filter(schema::sections::dsl::name.eq(section)) + .select(schema::sections::dsl::id) + .first(connection) + .optional()?) + } + + fn get_or_insert_section( + connection: &mut SqliteConnection, + section: &str, + ) -> Result { + match Self::get_section_id(connection, section)? { + Some(id) => Ok(id), + None => { + diesel::insert_into(schema::sections::table) + .values(NewSection { name: section }) + .on_conflict_do_nothing() + .execute(connection)?; + + Ok(schema::sections::table + .filter(schema::sections::dsl::name.eq(section)) + .select(schema::sections::dsl::id) + .first(connection)?) + } + } + } + + fn insert_item_section( + connection: &mut SqliteConnection, + item_id: i32, + section_id: i32, + ) -> Result<(), StoreError> { + diesel::insert_into(schema::items_sections::table) + .values(NewItemSection { + item_id, + section_id, + }) + .on_conflict_do_nothing() + .execute(connection)?; + Ok(()) + } + + async fn get_list(&self) -> Result { let store = self.clone(); tokio::task::spawn_blocking(move || { let mut connection = store.connection()?; connection.immediate_transaction(|connection| { - Ok(StoreResponse::List( - schema::items::table - .filter( - schema::items::dsl::id - .eq_any(schema::list::table.select(schema::list::dsl::id)), - ) - .load::(connection)? - .into_iter() - .map(Into::into) - .collect::(), - )) + Ok(schema::items::table + .filter( + schema::items::dsl::id + .eq_any(schema::list::table.select(schema::list::dsl::id)), + ) + .load::(connection)? + .into_iter() + .map(Into::into) + .collect::()) }) }) .await? } - async fn list_recipes(&self) -> Result, StoreError> { + async fn get_list_recipes(&self) -> Result, StoreError> { let store = self.clone(); tokio::task::spawn_blocking(move || { let mut connection = store.connection()?; @@ -206,18 +196,13 @@ impl SqliteStore { .await? } - fn load_item( - &self, - connection: &mut SqliteConnection, - item_id: i32, - ) -> Result, StoreError> { + fn load_item(connection: &mut SqliteConnection, item_id: i32) -> Result, StoreError> { Ok(schema::items::table .filter(schema::items::dsl::id.eq(&item_id)) .load::(connection)?) } fn get_recipe( - &self, connection: &mut SqliteConnection, recipe: &str, ) -> Result>, StoreError> { @@ -226,6 +211,39 @@ impl SqliteStore { .load::(connection) .optional()?) } + + fn get_section_for_item( + connection: &mut SqliteConnection, + item_id: i32, + ) -> Result, StoreError> { + use crate::schema::{items_sections, sections}; + + Ok(items_sections::table + .filter(items_sections::item_id.eq(item_id)) + .left_join(sections::table.on(sections::id.eq(items_sections::section_id))) + .select(sections::name.nullable()) + .first::>(connection) + .optional()? + .flatten()) + } + + fn get_item_recipes( + connection: &mut SqliteConnection, + item_id: i32, + ) -> Result, StoreError> { + use crate::schema::{items_recipes, recipes}; + + Ok(items_recipes::table + .filter(items_recipes::item_id.eq(item_id)) + .left_join(recipes::table.on(recipes::id.eq(items_recipes::recipe_id))) + .select(recipes::name.nullable()) + .load::>(connection) + .optional()? + .into_iter() + .flatten() + .flatten() + .collect::>()) + } } impl Storage for SqliteStore { @@ -235,7 +253,7 @@ impl Storage for SqliteStore { tokio::task::spawn_blocking(move || { let mut connection = store.connection()?; connection.immediate_transaction(|connection| { - let id = store.get_or_insert_item(connection, item.as_str())?; + let id = Self::get_or_insert_item(connection, item.as_str())?; let query = { diesel::insert_into(schema::checklist::table) .values(NewChecklistItem { id }) @@ -248,14 +266,23 @@ impl Storage for SqliteStore { .await? } - async fn add_item(&self, item: &Name) -> Result { + async fn add_item( + &self, + item: &Name, + section: &Option, + ) -> Result { let store = self.clone(); let item = item.clone(); + let section = section.clone(); tokio::task::spawn_blocking(move || { let mut connection = store.connection()?; connection.immediate_transaction(|connection| { let item_name = item.to_string(); - let _ = store.get_or_insert_item(connection, &item_name); + let item_id = Self::get_or_insert_item(connection, &item_name)?; + if let Some(section) = section { + let section_id = Self::get_or_insert_section(connection, section.as_str())?; + Self::insert_item_section(connection, item_id, section_id)?; + } Ok(StoreResponse::AddedItem(item)) }) }) @@ -268,7 +295,7 @@ impl Storage for SqliteStore { tokio::task::spawn_blocking(move || { let mut connection = store.connection()?; connection.immediate_transaction(|connection| { - let id = store.get_or_insert_item(connection, item.as_str())?; + let id = Self::get_or_insert_item(connection, item.as_str())?; let query = diesel::insert_into(schema::list::table) .values(NewListItem { id }) .on_conflict_do_nothing(); @@ -292,13 +319,13 @@ impl Storage for SqliteStore { tokio::task::spawn_blocking(move || { let mut connection = store.connection()?; connection.immediate_transaction(|connection| { - let id = store.get_or_insert_recipe(connection, recipe.as_str())?; + let id = Self::get_or_insert_recipe(connection, recipe.as_str())?; diesel::insert_into(schema::list_recipes::table) .values(NewListRecipe { id }) .on_conflict_do_nothing() .execute(connection)?; for item in ingredients.iter() { - let item_id = store.get_or_insert_item(connection, item.as_str())?; + let item_id = Self::get_or_insert_item(connection, item.as_str())?; let query = diesel::insert_into(schema::list::table) .values(NewListItem { id: item_id }) .on_conflict_do_nothing(); @@ -331,14 +358,14 @@ impl Storage for SqliteStore { let mut connection: PooledConnection> = store.connection()?; connection.immediate_transaction(|connection| { - let recipe_id = store.get_or_insert_recipe(connection, recipe.as_str())?; + let recipe_id = Self::get_or_insert_recipe(connection, recipe.as_str())?; let item_ids = ingredients .iter() - .map(|ingredient| store.get_or_insert_item(connection, ingredient.as_str())) + .map(|ingredient| Self::get_or_insert_item(connection, ingredient.as_str())) .collect::, _>>()?; for item_id in item_ids { - store.insert_item_recipe(connection, item_id, recipe_id)?; + Self::insert_item_recipe(connection, item_id, recipe_id)?; } Ok(StoreResponse::AddedRecipe(recipe)) }) @@ -369,10 +396,8 @@ impl Storage for SqliteStore { } async fn list(&self) -> Result { - let StoreResponse::List(mut list) = self.list_items().await? else { - todo!() - }; - list = list.with_recipes(self.list_recipes().await?); + let mut list = self.get_list().await?; + list = list.with_recipes(self.get_list_recipes().await?); let StoreResponse::Checklist(checklist) = self.checklist().await? else { todo!() }; @@ -440,20 +465,68 @@ impl Storage for SqliteStore { .await? } - async fn items(&self) -> Result { - use schema::items::dsl::items; + async fn export(&self) -> Result { + let items = self.items().await?; + let StoreResponse::List(list) = self.list().await? else { + todo!() + }; + + let items = items.collection().to_vec(); + + items.serialize_to_yaml_and_write(ITEMS_YAML_PATH)?; + list.serialize_to_yaml_and_write(LIST_YAML_PATH)?; + + Ok(StoreResponse::Exported(items, list)) + } + + async fn import_from_json(&self) -> Result { + let import_store = ImportStore::default(); + let mut connection = self.connection()?; + let items = import_store.items()?; + tokio::task::spawn_blocking(move || { + connection.immediate_transaction(|connection| { + import_sections(connection)?; + import_items(connection, items)?; + Ok(StoreResponse::ImportToSqlite) + }) + }) + .await? + } + + async fn items(&self) -> Result { + use crate::schema::items; let store = self.clone(); tokio::task::spawn_blocking(move || { let mut connection = store.connection()?; connection.immediate_transaction(|connection| { - Ok(StoreResponse::Items( - items - .load::(connection)? - .into_iter() - .map(Into::into) - .collect(), - )) + let all_items: Vec = items::dsl::items.load::(connection)?; + + all_items + .into_iter() + .map(|item| { + let section = Self::get_section_for_item(connection, item.id)?; + let item_recipes = Self::get_item_recipes(connection, item.id)?; + + let mut item: common::item::Item = item.into(); + + if let Some(section) = section { + item = item.with_section(§ion); + } + + if !item_recipes.is_empty() { + item = item.with_recipes( + item_recipes + .into_iter() + .map(Into::into) + .collect::>() + .as_slice(), + ); + } + + Ok(item) + }) + .collect::>() }) }) .await? @@ -477,7 +550,7 @@ impl Storage for SqliteStore { tokio::task::spawn_blocking(move || { let mut connection = store.connection()?; connection.immediate_transaction(|connection| { - let Some(results) = store.get_recipe(connection, recipe.as_str())? else { + let Some(results) = Self::get_recipe(connection, recipe.as_str())? else { return Ok(StoreResponse::RecipeIngredients(None)); }; @@ -492,7 +565,7 @@ impl Storage for SqliteStore { let ingredients = results .iter() - .map(|item_recipe| store.load_item(connection, item_recipe.item_id)) + .map(|item_recipe| Self::load_item(connection, item_recipe.item_id)) .collect::>, _>>()? .into_iter() .flatten() @@ -521,7 +594,7 @@ impl Storage for SqliteStore { .load::
(connection)? .into_iter() .map(|sec| sec.name().into()) - .collect(), + .collect::>(), )) }) }) @@ -565,7 +638,7 @@ mod tests { store } - fn test_item() -> Name { + fn test_item_name() -> Name { Name::from("test item") } @@ -573,7 +646,7 @@ mod tests { async fn test_add_checklist_item() { let store = inmem_sqlite_store().await; - let item_name = test_item(); + let item_name = test_item_name(); store.add_checklist_item(&item_name).await.unwrap(); let StoreResponse::Checklist(list) = store.checklist().await.unwrap() else { @@ -587,21 +660,21 @@ mod tests { async fn test_add_item() { let store = inmem_sqlite_store().await; - let item_name = test_item(); - store.add_item(&item_name).await.unwrap(); + let item_name = test_item_name(); + store.add_item(&item_name, &None).await.unwrap(); - let StoreResponse::Items(items) = store.items().await.unwrap() else { - todo!() - }; + let items = store.items().await.unwrap(); - assert!(items.collection().any(|item| item.name() == &item_name)); + assert!(items + .collection_iter() + .any(|item| item.name() == &item_name)); } #[tokio::test] async fn test_add_list_item() { let store = inmem_sqlite_store().await; - let item_name = test_item(); + let item_name = test_item_name(); store.add_list_item(&item_name).await.unwrap(); let StoreResponse::List(list) = store.list().await.unwrap() else { @@ -686,7 +759,7 @@ mod tests { async fn test_delete_checklist_item() { let store = inmem_sqlite_store().await; - let item_name = test_item(); + let item_name = test_item_name(); store.add_checklist_item(&item_name).await.unwrap(); let StoreResponse::Checklist(checklist) = store.checklist().await.unwrap() else { @@ -774,4 +847,70 @@ mod tests { }; assert_eq!(list.items().len(), 0); } + + #[tokio::test] + async fn test_items() { + let store = inmem_sqlite_store().await; + + let item1 = Name::from("item 1"); + let item2 = Name::from("item 2"); + let section1 = common::section::Section::from("section 1"); + let section2 = common::section::Section::from("section 2"); + store.add_item(&item1, &Some(section1)).await.unwrap(); + store.add_item(&item2, &Some(section2)).await.unwrap(); + + let ingredients = Ingredients::from_iter(vec![Name::from("item 1"), Name::from("item 2")]); + let recipe = Recipe::new("test recipe"); + + store.add_recipe(&recipe, &ingredients).await.unwrap(); + + let items = store.items().await.unwrap(); + + assert_eq!(items.collection().len(), 2); + assert!(items.collection_iter().any(|item| item.name() == &item1)); + assert!(items.collection_iter().any(|item| item.name() == &item2)); + + insta::assert_debug_snapshot!(items, @r###" + Items { + sections: [], + collection: [ + Item { + name: Name( + "item 1", + ), + section: Some( + Section( + "section 1", + ), + ), + recipes: Some( + [ + Recipe( + "test recipe", + ), + ], + ), + }, + Item { + name: Name( + "item 2", + ), + section: Some( + Section( + "section 2", + ), + ), + recipes: Some( + [ + Recipe( + "test recipe", + ), + ], + ), + }, + ], + recipes: [], + } + "###); + } } diff --git a/crates/persistence/src/store.rs b/crates/persistence/src/store.rs index 518e703..2c3d783 100644 --- a/crates/persistence/src/store.rs +++ b/crates/persistence/src/store.rs @@ -1,11 +1,13 @@ use common::{ commands::{Add, ApiCommand, Delete, Read, Update}, + export::ExportError, fetcher::{FetchError, Fetcher}, - item::{Item, Name, Section}, + item::{Item, Name}, items::Items, list::List, + load::LoadError, recipes::{Ingredients, Recipe}, - LoadError, + section::Section, }; use futures::FutureExt; use thiserror::Error; @@ -18,13 +20,7 @@ use url::Url; use std::{error::Error, fmt::Debug, fmt::Display, str::FromStr}; -use crate::{ - json::{ - migrate::{groceries, migrate_recipes, migrate_sections}, - JsonStore, - }, - sqlite::{DbUri, SqliteStore}, -}; +use crate::sqlite::{connection::DbUri, SqliteStore}; #[derive(Error, Debug)] pub enum StoreError { @@ -40,6 +36,9 @@ pub enum StoreError { #[error("invalid JSON file: {0}")] DeserializingError(#[from] serde_json::Error), + #[error("Export error: {0}")] + ExportError(#[from] ExportError), + #[error("fetch error: {0}")] FetchError(#[from] FetchError), @@ -67,7 +66,6 @@ pub enum StoreError { #[derive(Debug)] pub enum StoreType { - Json, Sqlite, SqliteInMem, } @@ -75,7 +73,6 @@ pub enum StoreType { impl Display for StoreType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - StoreType::Json => write!(f, "json"), StoreType::Sqlite => write!(f, "sqlite"), StoreType::SqliteInMem => write!(f, "sqlite-inmem"), } @@ -87,12 +84,10 @@ impl FromStr for StoreType { fn from_str(s: &str) -> Result { match s { - "json" => Ok(Self::Json), "sqlite" => Ok(Self::Sqlite), "sqlite-inmem" => Ok(Self::SqliteInMem), _ => Err(StoreError::ParseStoreType( - "Store types are currently limited to 'sqlite', 'sqlite-inmem', and 'json'." - .to_string(), + "Store types are currently limited to 'sqlite' and 'sqlite-inmem'.".to_string(), )), } } @@ -100,7 +95,6 @@ impl FromStr for StoreType { #[derive(Clone)] pub enum Store { - Json(JsonStore), Sqlite(SqliteStore), } @@ -108,7 +102,6 @@ impl Store { pub async fn from_store_type(store_type: StoreType) -> Result { use StoreType::*; match store_type { - Json => Ok(Self::Json(JsonStore::default())), Sqlite => Ok(Self::Sqlite(SqliteStore::new(DbUri::new()).await?)), SqliteInMem => Ok(Self::Sqlite(SqliteStore::new(DbUri::inmem()).await?)), } @@ -149,7 +142,6 @@ impl Store { async fn execute_transaction(&self, command: ApiCommand) -> Result { match self { - Self::Json(store) => store.execute_transaction(command).await, Self::Sqlite(store) => store.execute_transaction(command).await, } } @@ -195,10 +187,11 @@ pub enum StoreResponse { Checklist(Vec), DeletedRecipe(Recipe), DeletedChecklistItem(Name), + Exported(Vec, List), FetchedRecipe((Recipe, Ingredients)), + ImportToSqlite, ItemAlreadyAdded(Name), Items(Items), - JsonToSqlite, List(List), NothingReturned(ApiCommand), Recipes(Vec), @@ -212,8 +205,9 @@ pub(crate) trait Storage: Send + Sync + 'static { match command { ApiCommand::Add(cmd) => self.add(cmd).await, ApiCommand::Delete(cmd) => self.delete(cmd).await, + ApiCommand::Export => self.export().await, ApiCommand::FetchRecipe(url) => self.fetch_recipe(url).await, - ApiCommand::MigrateJsonDbToSqlite => self.migrate_json_store_to_sqlite().await, + ApiCommand::ImportFromJson => self.import_from_json().await, ApiCommand::Read(cmd) => self.read(cmd).await, ApiCommand::Update(cmd) => self.update(cmd).await, } @@ -222,7 +216,7 @@ pub(crate) trait Storage: Send + Sync + 'static { async fn add(&self, cmd: Add) -> Result { match cmd { Add::ChecklistItem(name) => self.add_checklist_item(&name).await, - Add::Item { name, .. } => self.add_item(&name).await, + Add::Item { name, section } => self.add_item(&name, §ion).await, Add::ListItem(name) => self.add_list_item(&name).await, Add::ListRecipe(name) => self.add_list_recipe(&name).await, Add::Recipe { @@ -234,7 +228,7 @@ pub(crate) trait Storage: Send + Sync + 'static { async fn read(&self, cmd: Read) -> Result { match cmd { - Read::All => self.items().await, + Read::All => Ok(StoreResponse::Items(self.items().await?)), Read::Checklist => self.checklist().await, Read::Item(_name) => todo!(), Read::List => self.list().await, @@ -264,6 +258,8 @@ pub(crate) trait Storage: Send + Sync + 'static { } } + async fn export(&self) -> Result; + async fn fetch_recipe(&self, url: Url) -> Result { let fetcher = Fetcher::from(url); let (recipe, ingredients) = fetcher.fetch_recipe().await?; @@ -272,28 +268,14 @@ pub(crate) trait Storage: Send + Sync + 'static { Ok(StoreResponse::FetchedRecipe((recipe, ingredients))) } - async fn migrate_json_store_to_sqlite(&self) -> Result { - let sqlite_store = SqliteStore::new(DbUri::new()).await?; - let mut connection = sqlite_store.connection()?; - let StoreResponse::Items(grocery_items) = self.items().await? else { - todo!() - }; - let StoreResponse::Recipes(recipes) = self.recipes().await? else { - todo!() - }; - tokio::task::spawn_blocking(move || { - connection.immediate_transaction(|connection| { - migrate_sections(connection)?; - migrate_recipes(connection, recipes)?; - groceries(connection, grocery_items)?; - Ok(StoreResponse::JsonToSqlite) - }) - }) - .await? - } + async fn import_from_json(&self) -> Result; // Create - async fn add_item(&self, item: &Name) -> Result; + async fn add_item( + &self, + item: &Name, + section: &Option
, + ) -> Result; async fn add_checklist_item(&self, item: &Name) -> Result; @@ -312,7 +294,7 @@ pub(crate) trait Storage: Send + Sync + 'static { async fn list(&self) -> Result; - async fn items(&self) -> Result; + async fn items(&self) -> Result; async fn recipes(&self) -> Result; diff --git a/docs/cli.md b/docs/cli.md index 2f83df0..256792e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,11 +25,12 @@ Commands: delete delete stuff read read stuff update update stuff - migrate-json-store migrate JSON store to Sqlite database + import import from 'items.json' and 'list.json' files + export export items to 'items.yaml' and list to 'list.yaml' files help Print this message or the help of the given subcommand(s) Options: - --database which database to use [default: json] [possible values: json, sqlite] + --database which database to use [default: sqlite] [possible values: sqlite, sqlite-inmem] -h, --help Print help ``` @@ -54,3 +55,8 @@ Scrambled egg and toast with smoked salmon: 2 slices smoked salmon: salt and freshly ground black pepper: ``` + +## Importing and Exporting Data + +See the Gust [Docker documentation](docker.md#) for instructions on how to [import](docker.md#import-from-json-files-to-sqlite) +and [export](docker.md#export-data-to-yaml) your grocery items and shopping list data. diff --git a/docs/database.md b/docs/database.md index 57e24ba..3c17f64 100644 --- a/docs/database.md +++ b/docs/database.md @@ -3,164 +3,29 @@ ## Contents - [Storage Options](#storage-options) -- [Migrating from JSON to SQLite](#migrating-from-json-to-sqlite) +- [Importing from files](#importing-from-files) ## Storage Options -There are two supported storage options: +Here are the supported storage options: -- [JSON](#json) -- [SQLite](#sqlite) - -### JSON - -To use the application with JSON as the database, use -the `--database` option flag. For example, - -```bash -cargo run -- --database json read recipes -``` - -Here's an example of a very small example JSON store -with just two recipes: - -```json -{ - "sections": [ - "fresh", - "pantry", - "protein", - "dairy", - "freezer" - ], - "collection": [ - { - "name": "eggs", - "section": "dairy", - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] - }, - { - "name": "milk", - "section": "dairy", - "recipes": [] - }, - { - "name": "spinach", - "section": "fresh", - "recipes": [ - "fried eggs for breakfast" - ] - }, - { - "name": "beer", - "section": "dairy", - "recipes": [] - }, - { - "name": "unsalted butter", - "section": "dairy", - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] - }, - { - "name": "bread", - "section": "pantry", - "recipes": [ - "fried eggs for breakfast" - ] - }, - { - "name": "old fashioned rolled oats", - "section": "pantry", - "recipes": [ - "oatmeal chocolate chip cookies" - ] - }, - { - "name": "chocolate chips", - "section": "pantry", - "recipes": [ - "oatmeal chocolate chip cookies" - ] - }, - { - "name": "baking powder", - "section": "pantry", - "recipes": [ - "oatmeal chocolate chip cookies" - ] - }, - { - "name": "baking soda", - "section": "pantry", - "recipes": [ - "oatmeal chocolate chip cookies" - ] - }, - { - "name": "salt", - "section": "pantry", - "recipes": [ - "oatmeal chocolate chip cookies" - ] - }, - { - "name": "white sugar", - "section": "pantry", - "recipes": [ - "oatmeal chocolate chip cookies" - ] - }, - { - "name": "vanilla extract", - "section": "pantry", - "recipes": [ - "oatmeal chocolate chip cookies" - ] - }, - { - "name": "whole-wheat flour", - "section": "pantry", - "recipes": [ - "oatmeal chocolate chip cookies" - ] - }, - { - "name": "1/2 & 1/2", - "section": "dairy", - "recipes": [ - "fried eggs for breakfast" - ] - }, - { - "name": "feta", - "section": "dairy", - "recipes": [ - "fried eggs for breakfast" - ] - } - ], - "recipes": [ - "oatmeal chocolate chip cookies", - "fried eggs for breakfast" - ] -} -``` +- [x] [SQLite](#sqlite) +- [ ] Postgres ### SQLite SQLite is the default storage option. +### PostgreSQL + +Coming! + --- -## Migrating from JSON to SQLite +## Importing from files -You can migrate a JSON store to an Sqlite database by running +You can import from JSON files, for now named by default `items.json` and `list.json` +to SQLite by running ```bash -cargo run -- --database sqlite migrate-json-store +cargo run -- --database sqlite import ``` diff --git a/docs/diagrams/design.puml b/docs/diagrams/design.puml index 409be93..ef0761e 100644 --- a/docs/diagrams/design.puml +++ b/docs/diagrams/design.puml @@ -4,11 +4,11 @@ skinparam classFontColor darkSlateGray package "gust" { - [API {\n + Store\n}] *-down-* [Store (JSON | SQLite)] + [API {\n + Store\n}] *-down-* [Store (SQLite)] [CLI] <---> [API {\n + Store\n}] : API commands\n& responses [common types\n and methods] .right. [API {\n + Store\n}] [common types\n and methods] .right. [CLI] - [common types\n and methods] .right. [Store (JSON | SQLite)] + [common types\n and methods] .right. [Store (SQLite)] [API {\n + Store\n}] o-right-o [RecipeFetcher] } interface "\t\t\tcooking sites\n\t\t\t(e.g. NYT Cooking,\n\t\t\tBBC Food)" as ext diff --git a/docs/diagrams/design.svg b/docs/diagrams/design.svg index b52bdaf..3d33d86 100644 --- a/docs/diagrams/design.svg +++ b/docs/diagrams/design.svg @@ -1,29 +1,29 @@ -gustAPI {+ Store}Store (JSON | SQLite)CLIcommon typesand methodsRecipeFetchercooking sites(e.g. NYT Cooking,BBC Food)API {+ Store}Store (SQLite)CLIcommon typesand methodsRecipeFetchercooking sites(e.g. NYT Cooking,BBC Food)API commands& responses