Compare commits
10 Commits
7d2f47b1a2
...
10cc9342b1
Author | SHA1 | Date | |
---|---|---|---|
![]() |
10cc9342b1 | ||
![]() |
2670af3eb7 | ||
![]() |
e2392efe40 | ||
![]() |
916a0135aa | ||
![]() |
6fd90457c8 | ||
![]() |
05f4549355 | ||
![]() |
5c58c111ac | ||
![]() |
750be015bb | ||
![]() |
fd909079a3 | ||
![]() |
52ed02b34c |
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@ -27,8 +27,9 @@ jobs:
|
|||||||
run: golangci-lint run
|
run: golangci-lint run
|
||||||
|
|
||||||
- name: Validate SQL schema
|
- name: Validate SQL schema
|
||||||
run: |
|
uses: playfulpachyderm/sqlite-lint@v0.0.3
|
||||||
sqlite3 whatever.db < pkg/db/schema.sql
|
with:
|
||||||
|
schema-file: pkg/db/schema.sql
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
sample_data/data
|
sample_data/data
|
||||||
|
*_templ.go
|
||||||
|
21
cmd/main.go
21
cmd/main.go
@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
pkg_db "recipe_book/pkg/db"
|
pkg_db "recipe_book/pkg/db"
|
||||||
|
"recipe_book/pkg/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DB_FILENAME = "food.db"
|
const DB_FILENAME = "food.db"
|
||||||
@ -27,6 +28,15 @@ func main() {
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "init":
|
case "init":
|
||||||
init_db()
|
init_db()
|
||||||
|
case "webserver":
|
||||||
|
fs := flag.NewFlagSet("", flag.ExitOnError)
|
||||||
|
should_auto_open := fs.Bool("auto-open", false, "")
|
||||||
|
addr := fs.String("addr", "localhost:3080", "port to listen on") // Random port that's probably not in use
|
||||||
|
|
||||||
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
start_webserver(*addr, *should_auto_open)
|
||||||
default:
|
default:
|
||||||
fmt.Printf(COLOR_RED+"invalid subcommand: %q\n"+COLOR_RESET, args[0])
|
fmt.Printf(COLOR_RED+"invalid subcommand: %q\n"+COLOR_RESET, args[0])
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@ -55,3 +65,14 @@ const (
|
|||||||
COLOR_GRAY = "\033[37m"
|
COLOR_GRAY = "\033[37m"
|
||||||
COLOR_WHITE = "\033[97m"
|
COLOR_WHITE = "\033[97m"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func start_webserver(addr string, should_auto_open bool) {
|
||||||
|
db, err := pkg_db.DBConnect(filepath.Join(db_path, DB_FILENAME))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(COLOR_RED + "opening database: " + err.Error() + COLOR_RESET)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := web.NewApp(db)
|
||||||
|
app.Run(addr, should_auto_open)
|
||||||
|
}
|
||||||
|
29
doc/ingredient-schema-questions.txt
Normal file
29
doc/ingredient-schema-questions.txt
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
Questions:
|
||||||
|
- should recipes have a reference to a food with its computed nutrition?
|
||||||
|
- or the other way around-- store a
|
||||||
|
- how should ingredients-that-are-recipes be linked to recipes?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Options:
|
||||||
|
1. - ingredients just store a food_id
|
||||||
|
- foods store an optional recipe_id
|
||||||
|
|
||||||
|
Problems:
|
||||||
|
- all recipes have to have an associated food entry
|
||||||
|
- foods have to have a recipe_id-- shouldn't this be the other way around?
|
||||||
|
|
||||||
|
2. - ingredients just store a food_id
|
||||||
|
- recipes store a food_id
|
||||||
|
|
||||||
|
Problems:
|
||||||
|
- all recipes have to have an associated food entry
|
||||||
|
- can't tell which ingredients are recipes without doing an indexed search
|
||||||
|
|
||||||
|
|
||||||
|
3. - ingredients store a food_id and a recipe_id, only one is valid
|
||||||
|
Implications: recipes and foods can be connected in any way
|
||||||
|
|
||||||
|
Problems:
|
||||||
|
- joining is more complicated
|
1
go.mod
1
go.mod
@ -10,6 +10,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/a-h/templ v0.2.793 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
2
go.sum
2
go.sum
@ -1,5 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
|
||||||
|
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
9
pkg/db/errors.go
Normal file
9
pkg/db/errors.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotInDB = errors.New("not in db")
|
||||||
|
)
|
@ -1,36 +1,38 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FoodID uint64
|
type FoodID uint64
|
||||||
|
|
||||||
type Food struct {
|
type Food struct {
|
||||||
ID FoodID `db:"rowid"`
|
ID FoodID `db:"rowid" json:"id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
|
|
||||||
Cals float32 `db:"cals"`
|
Cals float32 `db:"cals" json:"cals,string"`
|
||||||
Carbs float32 `db:"carbs"`
|
Carbs float32 `db:"carbs" json:"carbs,string"`
|
||||||
Protein float32 `db:"protein"`
|
Protein float32 `db:"protein" json:"protein,string"`
|
||||||
Fat float32 `db:"fat"`
|
Fat float32 `db:"fat" json:"fat,string"`
|
||||||
Sugar float32 `db:"sugar"`
|
Sugar float32 `db:"sugar" json:"sugar,string"`
|
||||||
Alcohol float32 `db:"alcohol"`
|
Alcohol float32 `db:"alcohol" json:"alcohol,string"`
|
||||||
|
|
||||||
Water float32 `db:"water"`
|
Water float32 `db:"water" json:"water,string"`
|
||||||
|
|
||||||
Potassium float32 `db:"potassium"`
|
Potassium float32 `db:"potassium" json:"potassium,string"`
|
||||||
Calcium float32 `db:"calcium"`
|
Calcium float32 `db:"calcium" json:"calcium,string"`
|
||||||
Sodium float32 `db:"sodium"`
|
Sodium float32 `db:"sodium" json:"sodium,string"`
|
||||||
Magnesium float32 `db:"magnesium"`
|
Magnesium float32 `db:"magnesium" json:"magnesium,string"`
|
||||||
Phosphorus float32 `db:"phosphorus"`
|
Phosphorus float32 `db:"phosphorus" json:"phosphorus,string"`
|
||||||
Iron float32 `db:"iron"`
|
Iron float32 `db:"iron" json:"iron,string"`
|
||||||
Zinc float32 `db:"zinc"`
|
Zinc float32 `db:"zinc" json:"zinc,string"`
|
||||||
|
|
||||||
Mass float32 `db:"mass"` // In grams
|
Mass float32 `db:"mass" json:"mass,string"` // In grams
|
||||||
Price float32 `db:"price"`
|
Price float32 `db:"price" json:"price,string"`
|
||||||
Density float32 `db:"density"`
|
Density float32 `db:"density" json:"density,string"`
|
||||||
CookRatio float32 `db:"cook_ratio"`
|
CookRatio float32 `db:"cook_ratio" json:"cook_ratio,string"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format as string
|
// Format as string
|
||||||
@ -101,5 +103,22 @@ func (db *DB) GetFoodByID(id FoodID) (ret Food, err error) {
|
|||||||
from foods
|
from foods
|
||||||
where rowid = ?
|
where rowid = ?
|
||||||
`, id)
|
`, id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Food{}, ErrNotInDB
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetAllBaseFoods() []Food {
|
||||||
|
var ret []Food
|
||||||
|
err := db.DB.Select(&ret, `
|
||||||
|
select rowid, name, cals, carbs, protein, fat, sugar, alcohol, water, potassium, calcium, sodium,
|
||||||
|
magnesium, phosphorus, iron, zinc, mass, price, density, cook_ratio
|
||||||
|
from foods
|
||||||
|
where rowid not in (select computed_food_id from recipes)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
@ -70,3 +70,15 @@ func TestFoodSaveAndLoad(t *testing.T) {
|
|||||||
t.Error(diff)
|
t.Error(diff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Should list all the base foods (i.e., ones that aren't recipes)
|
||||||
|
func TestListAllBaseFoods(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
db := get_test_db()
|
||||||
|
|
||||||
|
base_foods := db.GetAllBaseFoods()
|
||||||
|
assert.True(len(base_foods) >= 100)
|
||||||
|
for _, f := range base_foods {
|
||||||
|
assert.NotContains([]FoodID{10000, 10001}, f.ID, f) // Computed foods have ID >= 10000 in seed data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,6 +2,8 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IngredientID uint64
|
type IngredientID uint64
|
||||||
@ -84,6 +86,29 @@ func (db *DB) DeleteIngredient(i Ingredient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// func (i Ingredient) AddTo(r *Recipe) {
|
func (i Ingredient) DisplayAmount() string {
|
||||||
|
var f float32
|
||||||
// }
|
switch i.Units {
|
||||||
|
case COUNT:
|
||||||
|
f = i.Quantity
|
||||||
|
case GRAMS:
|
||||||
|
f = i.Quantity * i.Food.Mass
|
||||||
|
case LBS:
|
||||||
|
f = i.Quantity * i.Food.Mass / 454
|
||||||
|
case OZ:
|
||||||
|
f = i.Quantity * i.Food.Mass / 28
|
||||||
|
case ML:
|
||||||
|
f = i.Quantity * i.Food.Mass / i.Food.Density
|
||||||
|
case CUPS:
|
||||||
|
f = i.Quantity * i.Food.Mass / i.Food.Density / 250
|
||||||
|
case TSP:
|
||||||
|
f = i.Quantity * i.Food.Mass / i.Food.Density / 5
|
||||||
|
case TBSP:
|
||||||
|
f = i.Quantity * i.Food.Mass / i.Food.Density / 15
|
||||||
|
case FLOZ:
|
||||||
|
f = i.Quantity * i.Food.Mass / i.Food.Density / 30
|
||||||
|
default:
|
||||||
|
panic(i)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprintf("%d %s", int(math.Round(float64(f))), i.Units.Abbreviation()))
|
||||||
|
}
|
||||||
|
@ -68,3 +68,24 @@ func TestSaveAndLoadIngredient(t *testing.T) {
|
|||||||
assert.NoError(err)
|
assert.NoError(err)
|
||||||
require.Len(new_recipe.Ingredients, 0)
|
require.Len(new_recipe.Ingredients, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisplayAmount(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
db := get_test_db()
|
||||||
|
onion := get_food(db, 28)
|
||||||
|
|
||||||
|
test_cases := []struct {
|
||||||
|
Ingredient
|
||||||
|
Expected string
|
||||||
|
}{
|
||||||
|
{Ingredient{Quantity: 1, Units: COUNT, Food: onion}, "1"},
|
||||||
|
{Ingredient{Quantity: 2, Units: COUNT, Food: onion}, "2"},
|
||||||
|
{Ingredient{Quantity: 1.818, Units: GRAMS, Food: onion}, "400 g"},
|
||||||
|
{Ingredient{Quantity: 9, Units: LBS, Food: onion}, "4 lbs"},
|
||||||
|
{Ingredient{Quantity: 2, Units: ML, Food: onion}, "440 mL"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range test_cases {
|
||||||
|
assert.Equal(tc.Ingredient.DisplayAmount(), tc.Expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -43,7 +43,6 @@ type Recipe struct {
|
|||||||
func (db *DB) SaveRecipe(r *Recipe) {
|
func (db *DB) SaveRecipe(r *Recipe) {
|
||||||
if r.ID == RecipeID(0) {
|
if r.ID == RecipeID(0) {
|
||||||
// Do create
|
// Do create
|
||||||
|
|
||||||
// Create the computed food
|
// Create the computed food
|
||||||
computed_food := Food{Name: r.Name}
|
computed_food := Food{Name: r.Name}
|
||||||
db.SaveFood(&computed_food)
|
db.SaveFood(&computed_food)
|
||||||
@ -82,6 +81,9 @@ func (db *DB) SaveRecipe(r *Recipe) {
|
|||||||
}
|
}
|
||||||
for i := range r.Ingredients {
|
for i := range r.Ingredients {
|
||||||
r.Ingredients[i].InRecipeID = r.ID
|
r.Ingredients[i].InRecipeID = r.ID
|
||||||
|
if r.Ingredients[i].ListOrder == 0 {
|
||||||
|
r.Ingredients[i].ListOrder = int64(i)
|
||||||
|
}
|
||||||
db.SaveIngredient(&r.Ingredients[i])
|
db.SaveIngredient(&r.Ingredients[i])
|
||||||
}
|
}
|
||||||
// Update the computed food
|
// Update the computed food
|
||||||
@ -130,6 +132,15 @@ func (db *DB) GetRecipeByID(id RecipeID) (ret Recipe, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetAllRecipes() []Recipe {
|
||||||
|
var ret []Recipe
|
||||||
|
err := db.DB.Select(&ret, `select rowid, name, blurb, instructions, computed_food_id from recipes`)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
func (r Recipe) ComputeFood() Food {
|
func (r Recipe) ComputeFood() Food {
|
||||||
// If r.ComputedFoodID is 0, so should be the ID of returned Food
|
// If r.ComputedFoodID is 0, so should be the ID of returned Food
|
||||||
ret := Food{ID: r.ComputedFoodID, Name: r.Name}
|
ret := Food{ID: r.ComputedFoodID, Name: r.Name}
|
||||||
|
@ -52,8 +52,8 @@ func TestRecipeComputeFood(t *testing.T) {
|
|||||||
f2 := Food{0, "", 16.5, 15.5, 14.5, 13.5, 12.5, 11.5, 10.5, 9.5, 8.5, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0, 0}
|
f2 := Food{0, "", 16.5, 15.5, 14.5, 13.5, 12.5, 11.5, 10.5, 9.5, 8.5, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0, 0}
|
||||||
|
|
||||||
recipe := Recipe{Ingredients: []Ingredient{
|
recipe := Recipe{Ingredients: []Ingredient{
|
||||||
{Quantity: 1, Food: f1},
|
COUNT.Of(f1, 1),
|
||||||
{Quantity: 1, Food: f2},
|
COUNT.Of(f2, 1),
|
||||||
}}
|
}}
|
||||||
computed_food := recipe.ComputeFood()
|
computed_food := recipe.ComputeFood()
|
||||||
assert.Equal(computed_food.Cals, float32(17.5))
|
assert.Equal(computed_food.Cals, float32(17.5))
|
||||||
@ -74,8 +74,8 @@ func TestRecipeComputeFood(t *testing.T) {
|
|||||||
assert.Equal(computed_food.Price, float32(17.5))
|
assert.Equal(computed_food.Price, float32(17.5))
|
||||||
|
|
||||||
recipe2 := Recipe{Ingredients: []Ingredient{
|
recipe2 := Recipe{Ingredients: []Ingredient{
|
||||||
{Quantity: 1.5, Food: f1},
|
COUNT.Of(f1, 1.5),
|
||||||
{Quantity: 0.5, Food: f2},
|
COUNT.Of(f2, 0.5),
|
||||||
}}
|
}}
|
||||||
computed_food2 := recipe2.ComputeFood()
|
computed_food2 := recipe2.ComputeFood()
|
||||||
assert.Equal(computed_food2.Cals, float32(9.75))
|
assert.Equal(computed_food2.Cals, float32(9.75))
|
||||||
@ -94,6 +94,14 @@ func TestRecipeComputeFood(t *testing.T) {
|
|||||||
assert.Equal(computed_food2.Zinc, float32(22.75))
|
assert.Equal(computed_food2.Zinc, float32(22.75))
|
||||||
assert.Equal(computed_food2.Mass, float32(23.75))
|
assert.Equal(computed_food2.Mass, float32(23.75))
|
||||||
assert.Equal(computed_food2.Price, float32(24.75))
|
assert.Equal(computed_food2.Price, float32(24.75))
|
||||||
|
|
||||||
|
// Combine recipes in a recipe
|
||||||
|
recipe3 := Recipe{Ingredients: []Ingredient{
|
||||||
|
COUNT.Portion(recipe, 2),
|
||||||
|
COUNT.Portion(recipe2, 1),
|
||||||
|
}}
|
||||||
|
computed_food3 := recipe3.ComputeFood()
|
||||||
|
assert.Equal(computed_food3.Cals, float32(44.75))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecipeSaveComputedFood(t *testing.T) {
|
func TestRecipeSaveComputedFood(t *testing.T) {
|
||||||
@ -109,13 +117,35 @@ func TestRecipeSaveComputedFood(t *testing.T) {
|
|||||||
require.Equal(parm.Name, "parmigiano")
|
require.Equal(parm.Name, "parmigiano")
|
||||||
|
|
||||||
recipe := Recipe{Name: "pasta w/ sauce", Ingredients: []Ingredient{
|
recipe := Recipe{Name: "pasta w/ sauce", Ingredients: []Ingredient{
|
||||||
{Quantity: 2, FoodID: pasta.ID, Food: pasta, ListOrder: 0},
|
COUNT.Of(pasta, 2),
|
||||||
{Quantity: 2.5, FoodID: tomatoes.ID, Food: tomatoes, ListOrder: 1},
|
COUNT.Of(tomatoes, 2.5),
|
||||||
{Quantity: 0.5, FoodID: parm.ID, Food: parm, ListOrder: 2},
|
COUNT.Of(tomatoes, 0.5),
|
||||||
}}
|
}}
|
||||||
db.SaveRecipe(&recipe)
|
db.SaveRecipe(&recipe)
|
||||||
computed_food := get_food(db, recipe.ComputedFoodID)
|
computed_food := get_food(db, recipe.ComputedFoodID)
|
||||||
if diff := deep.Equal(recipe.ComputeFood(), computed_food); diff != nil {
|
if diff := deep.Equal(recipe.ComputeFood(), computed_food); diff != nil {
|
||||||
t.Error(diff)
|
t.Error(diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test putting a recipe in a recipe
|
||||||
|
mozza := get_food(db, 83)
|
||||||
|
require.Equal(mozza.Name, "mozzarella")
|
||||||
|
recipe2 := Recipe{Name: "baked pasta w/ mozza", Ingredients: []Ingredient{
|
||||||
|
COUNT.Portion(recipe, 0.5),
|
||||||
|
GRAMS.Of(mozza, 300),
|
||||||
|
}}
|
||||||
|
db.SaveRecipe(&recipe2)
|
||||||
|
computed_food2 := get_food(db, recipe2.ComputedFoodID)
|
||||||
|
if diff := deep.Equal(recipe2.ComputeFood(), computed_food2); diff != nil {
|
||||||
|
t.Error(diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should list all the recipes
|
||||||
|
func TestListAllRecipes(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
db := get_test_db()
|
||||||
|
|
||||||
|
recipes := db.GetAllRecipes()
|
||||||
|
assert.True(len(recipes) >= 2)
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ PRAGMA foreign_keys = on;
|
|||||||
-- =======
|
-- =======
|
||||||
|
|
||||||
create table db_version (
|
create table db_version (
|
||||||
version integer not null
|
version integer primary key
|
||||||
) strict;
|
) strict;
|
||||||
insert into db_version values(0);
|
insert into db_version values(0);
|
||||||
|
|
||||||
@ -93,8 +93,7 @@ create table iterations (rowid integer primary key,
|
|||||||
unique(derived_recipe_id)
|
unique(derived_recipe_id)
|
||||||
) strict;
|
) strict;
|
||||||
|
|
||||||
create table daily_logs (rowid integer primary key,
|
-- create table daily_logs (
|
||||||
date integer not null unique,
|
-- date integer not null unique,
|
||||||
|
-- computed_food_id integer references foods(rowid) not null
|
||||||
computed_food_id integer references foods(rowid) not null
|
-- );
|
||||||
);
|
|
||||||
|
@ -3,7 +3,7 @@ package db
|
|||||||
type Units uint64
|
type Units uint64
|
||||||
|
|
||||||
const (
|
const (
|
||||||
COUNT = Units(iota + 1)
|
COUNT = Units(iota + 1) // Start at 1 to match SQLite ID column
|
||||||
GRAMS
|
GRAMS
|
||||||
LBS
|
LBS
|
||||||
OZ
|
OZ
|
||||||
@ -15,7 +15,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var names = []string{"", "count", "grams", "pounds", "ounces", "milliliters", "cups", "teaspoons", "tablespoons", "fluid ounces"}
|
var names = []string{"", "count", "grams", "pounds", "ounces", "milliliters", "cups", "teaspoons", "tablespoons", "fluid ounces"}
|
||||||
var abbreviations = []string{"", "ct", "g", "lbs", "oz", "mL", "cups", "tsp", "tbsp", "fl-oz"}
|
var abbreviations = []string{"", "", "g", "lbs", "oz", "mL", "cups", "tsp", "tbsp", "fl-oz"}
|
||||||
|
|
||||||
func (u Units) Name() string {
|
func (u Units) Name() string {
|
||||||
return names[u]
|
return names[u]
|
||||||
@ -48,3 +48,29 @@ func (u Units) Of(f Food, n float32) Ingredient {
|
|||||||
panic(u)
|
panic(u)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u Units) Portion(r Recipe, n float32) Ingredient {
|
||||||
|
f := r.ComputeFood()
|
||||||
|
switch u {
|
||||||
|
case COUNT:
|
||||||
|
return Ingredient{RecipeID: r.ID, Quantity: n, Units: u, Food: f}
|
||||||
|
case GRAMS:
|
||||||
|
return Ingredient{RecipeID: r.ID, Quantity: n / f.Mass, Units: u, Food: f}
|
||||||
|
case LBS:
|
||||||
|
return Ingredient{RecipeID: r.ID, Quantity: n * 454 / f.Mass, Units: u, Food: f}
|
||||||
|
case OZ:
|
||||||
|
return Ingredient{RecipeID: r.ID, Quantity: n * 28 / f.Mass, Units: u, Food: f}
|
||||||
|
case ML:
|
||||||
|
return Ingredient{RecipeID: r.ID, Quantity: n * f.Density / f.Mass, Units: u, Food: f}
|
||||||
|
case CUPS:
|
||||||
|
return Ingredient{RecipeID: r.ID, Quantity: n * f.Density * 250 / f.Mass, Units: u, Food: f}
|
||||||
|
case TSP:
|
||||||
|
return Ingredient{RecipeID: r.ID, Quantity: n * f.Density * 5 / f.Mass, Units: u, Food: f}
|
||||||
|
case TBSP:
|
||||||
|
return Ingredient{RecipeID: r.ID, Quantity: n * f.Density * 15 / f.Mass, Units: u, Food: f}
|
||||||
|
case FLOZ:
|
||||||
|
return Ingredient{RecipeID: r.ID, Quantity: n * f.Density * 30 / f.Mass, Units: u, Food: f}
|
||||||
|
default:
|
||||||
|
panic(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
105
pkg/web/handler_ingredients.go
Normal file
105
pkg/web/handler_ingredients.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
. "recipe_book/pkg/db"
|
||||||
|
|
||||||
|
"recipe_book/pkg/web/tpl/pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Router: `/ingredients`
|
||||||
|
func (app *Application) Ingredients(w http.ResponseWriter, r *http.Request) {
|
||||||
|
app.traceLog.Printf("Ingredient: %s", r.URL.Path)
|
||||||
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||||
|
|
||||||
|
if parts[0] == "" {
|
||||||
|
// Index page
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
app.IngredientsIndex(w, r)
|
||||||
|
case "POST":
|
||||||
|
app.IngredientCreate(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details page
|
||||||
|
food_id, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
app.error_400(w, r, fmt.Sprintf("invalid ID: %s", parts[0]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
food, err := app.DB.GetFoodByID(FoodID(food_id))
|
||||||
|
if errors.Is(err, ErrNotInDB) {
|
||||||
|
app.error_404(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
app.IngredientDetail(food, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler: `GET /ingredients`
|
||||||
|
func (app *Application) IngredientsIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
foods := app.DB.GetAllBaseFoods()
|
||||||
|
err := pages.Base("Ingredients").Render(
|
||||||
|
templ.WithChildren(
|
||||||
|
context.Background(),
|
||||||
|
pages.IngredientsIndex(foods),
|
||||||
|
),
|
||||||
|
w)
|
||||||
|
panic_if(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler: `POST /ingredients`
|
||||||
|
func (app *Application) IngredientCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var food Food
|
||||||
|
data, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(data, &food)
|
||||||
|
if err != nil {
|
||||||
|
app.ErrorLog.Print(err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
app.DB.SaveFood(&food)
|
||||||
|
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/ingredients/%d", food.ID), 303)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler: `GET /ingredients/:id`
|
||||||
|
func (app *Application) IngredientDetail(food Food, w http.ResponseWriter, r *http.Request) {
|
||||||
|
// If it's a POST request, update the food
|
||||||
|
if r.Method == "POST" {
|
||||||
|
data, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(data, &food)
|
||||||
|
if err != nil {
|
||||||
|
app.ErrorLog.Print(err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// Save the updated food
|
||||||
|
app.DB.SaveFood(&food)
|
||||||
|
app.traceLog.Printf("POST Ingredient Detail: %#v", food)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := pages.Base(fmt.Sprintf("Ingredient: %s", food.Name)).Render(
|
||||||
|
templ.WithChildren(
|
||||||
|
context.Background(),
|
||||||
|
pages.IngredientDetail(food),
|
||||||
|
),
|
||||||
|
w)
|
||||||
|
panic_if(err)
|
||||||
|
}
|
85
pkg/web/handler_recipes.go
Normal file
85
pkg/web/handler_recipes.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
. "recipe_book/pkg/db"
|
||||||
|
|
||||||
|
"recipe_book/pkg/web/tpl/pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Router: `/ingredients`
|
||||||
|
func (app *Application) Recipes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
app.traceLog.Printf("Recipe: %s", r.URL.Path)
|
||||||
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||||
|
|
||||||
|
if parts[0] == "" {
|
||||||
|
// Index page
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
app.RecipesIndex(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details page
|
||||||
|
recipe_id, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
app.error_400(w, r, fmt.Sprintf("invalid ID: %s", parts[0]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipe, err := app.DB.GetRecipeByID(RecipeID(recipe_id))
|
||||||
|
if errors.Is(err, ErrNotInDB) {
|
||||||
|
app.error_404(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
app.RecipeDetail(recipe, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler: `GET /recipes`
|
||||||
|
func (app *Application) RecipesIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
foods := app.DB.GetAllRecipes()
|
||||||
|
err := pages.Base("Ingredients").Render(
|
||||||
|
templ.WithChildren(
|
||||||
|
context.Background(),
|
||||||
|
pages.RecipesIndex(foods),
|
||||||
|
),
|
||||||
|
w)
|
||||||
|
panic_if(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Handler: `POST /ingredients`
|
||||||
|
// func (app *Application) IngredientCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// var food Food
|
||||||
|
// data, err := io.ReadAll(r.Body)
|
||||||
|
// if err != nil {
|
||||||
|
// panic(err)
|
||||||
|
// }
|
||||||
|
// err = json.Unmarshal(data, &food)
|
||||||
|
// if err != nil {
|
||||||
|
// app.ErrorLog.Print(err)
|
||||||
|
// panic(err)
|
||||||
|
// }
|
||||||
|
// app.DB.SaveFood(&food)
|
||||||
|
|
||||||
|
// http.Redirect(w, r, fmt.Sprintf("/ingredients/%d", food.ID), 303)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Handler: `GET /ingredients/:id`
|
||||||
|
func (app *Application) RecipeDetail(recipe Recipe, w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := pages.Base(recipe.Name).Render(
|
||||||
|
templ.WithChildren(
|
||||||
|
context.Background(),
|
||||||
|
templ.Join(pages.RecipeDetail(recipe)),
|
||||||
|
),
|
||||||
|
w)
|
||||||
|
panic_if(err)
|
||||||
|
}
|
25
pkg/web/http_response_helpers.go
Normal file
25
pkg/web/http_response_helpers.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func panic_if(err error) {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) error_400(w http.ResponseWriter, r *http.Request, msg string) {
|
||||||
|
http.Error(w, fmt.Sprintf("Bad Request\n\n%s", msg), 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) error_404(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "Not Found", 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) error_500(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
panic("TODO")
|
||||||
|
}
|
||||||
|
|
50
pkg/web/middlewares.go
Normal file
50
pkg/web/middlewares.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Middlewares are wrappers around `http.Handler`s
|
||||||
|
type Middleware func(http.Handler) http.Handler
|
||||||
|
|
||||||
|
func (app *Application) WithMiddlewares() http.Handler {
|
||||||
|
var ret http.Handler = app
|
||||||
|
for i := range app.Middlewares {
|
||||||
|
ret = app.Middlewares[i](ret)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func secureHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Referrer-Policy", "same-origin")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-Frame-Options", "deny")
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) logRequest(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
duration := time.Since(t)
|
||||||
|
|
||||||
|
app.accessLog.Printf("%s - %s %s %s\t%s", r.RemoteAddr, r.Proto, r.Method, r.URL.RequestURI(), duration)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) recoverPanic(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
w.Header().Set("Connection", "close")
|
||||||
|
app.error_500(w, r, fmt.Errorf("%s", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
95
pkg/web/server.go
Normal file
95
pkg/web/server.go
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pkg_db "recipe_book/pkg/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Application struct {
|
||||||
|
accessLog *log.Logger
|
||||||
|
traceLog *log.Logger
|
||||||
|
InfoLog *log.Logger
|
||||||
|
ErrorLog *log.Logger
|
||||||
|
|
||||||
|
Middlewares []Middleware
|
||||||
|
|
||||||
|
DB pkg_db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp(db pkg_db.DB) Application {
|
||||||
|
ret := Application{
|
||||||
|
accessLog: log.New(os.Stdout, "ACCESS\t", log.Ldate|log.Ltime),
|
||||||
|
traceLog: log.New(os.Stdout, "TRACE\t", log.Ldate|log.Ltime),
|
||||||
|
InfoLog: log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime),
|
||||||
|
ErrorLog: log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile),
|
||||||
|
|
||||||
|
DB: db,
|
||||||
|
}
|
||||||
|
ret.Middlewares = []Middleware{
|
||||||
|
secureHeaders,
|
||||||
|
ret.logRequest,
|
||||||
|
ret.recoverPanic,
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual router implementation.
|
||||||
|
// I don't like the weird matching behavior of http.ServeMux, and it's not hard to write by hand.
|
||||||
|
func (app *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
app.traceLog.Printf("base handler: %s", r.URL.Path)
|
||||||
|
parts := strings.Split(r.URL.Path, "/")[1:]
|
||||||
|
switch parts[0] {
|
||||||
|
case "static":
|
||||||
|
http.StripPrefix("/static", http.HandlerFunc(app.ServeStatic)).ServeHTTP(w, r)
|
||||||
|
case "ingredients":
|
||||||
|
http.StripPrefix("/ingredients", http.HandlerFunc(app.Ingredients)).ServeHTTP(w, r)
|
||||||
|
case "recipes":
|
||||||
|
http.StripPrefix("/recipes", http.HandlerFunc(app.Recipes)).ServeHTTP(w, r)
|
||||||
|
default:
|
||||||
|
app.error_404(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) Run(address string, should_auto_open bool) {
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: address,
|
||||||
|
ErrorLog: app.ErrorLog,
|
||||||
|
Handler: app.WithMiddlewares(),
|
||||||
|
TLSConfig: &tls.Config{
|
||||||
|
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
|
||||||
|
},
|
||||||
|
IdleTimeout: time.Minute,
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.InfoLog.Printf("Starting server on %s", address)
|
||||||
|
|
||||||
|
if should_auto_open {
|
||||||
|
go func(url string) {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin": // macOS
|
||||||
|
cmd = exec.Command("open", url)
|
||||||
|
case "windows":
|
||||||
|
cmd = exec.Command("cmd", "/c", "start", url)
|
||||||
|
default: // Linux and others
|
||||||
|
cmd = exec.Command("xdg-open", url)
|
||||||
|
}
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
log.Printf("Failed to open homepage: %s", err.Error())
|
||||||
|
}
|
||||||
|
}("http://" + address)
|
||||||
|
}
|
||||||
|
err := srv.ListenAndServe()
|
||||||
|
app.ErrorLog.Fatal(err)
|
||||||
|
}
|
33
pkg/web/static.go
Normal file
33
pkg/web/static.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed "static"
|
||||||
|
var embedded_files embed.FS
|
||||||
|
|
||||||
|
var use_embedded = ""
|
||||||
|
|
||||||
|
var this_dir string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_, this_file, _, _ := runtime.Caller(0) // `this_file` is absolute path to this source file
|
||||||
|
this_dir = path.Dir(this_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static assets, either from the disk (if running in development mode), or from go:embedded files
|
||||||
|
func (app *Application) ServeStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Static files can be stored in browser cache
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
if use_embedded == "true" {
|
||||||
|
// Serve directly from the embedded files
|
||||||
|
http.FileServer(http.FS(embedded_files)).ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
// Serve from disk
|
||||||
|
http.FileServer(http.Dir(path.Join(this_dir, "static"))).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
157
pkg/web/static/styles.css
Normal file
157
pkg/web/static/styles.css
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
width: 20em;
|
||||||
|
max-width: 20%;
|
||||||
|
border-right: 1px solid #ddd;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
input#search-bar {
|
||||||
|
max-width: 96%;
|
||||||
|
width: 20em;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 10%;
|
||||||
|
padding-left: 2em;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'], select, textarea, span.select2-container {
|
||||||
|
margin: 0 0.5em 0 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.nothing {
|
||||||
|
/* Used because the Hoon type checker requires a `manx` sometimes (can't use ~) */
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons-container {
|
||||||
|
display: flex;
|
||||||
|
margin: 0em 0 1em 0.2em;
|
||||||
|
gap: 2em;
|
||||||
|
}
|
||||||
|
.share-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
button#share-this-recipe {
|
||||||
|
}
|
||||||
|
#shareLink {
|
||||||
|
width: 30em;
|
||||||
|
}
|
||||||
|
#shareLink.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.labelled-input {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
div.labelled-input label {
|
||||||
|
flex-basis: 12em;
|
||||||
|
text-align: right;
|
||||||
|
margin-right: 0.3em;
|
||||||
|
}
|
||||||
|
div.labelled-input input {
|
||||||
|
flex-basis: 30em;
|
||||||
|
}
|
||||||
|
div.labelled-input .title-text-parent {
|
||||||
|
color: #888;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ingredient-editor input[type="submit"] {
|
||||||
|
margin: 2em 0 0 41em;
|
||||||
|
}
|
||||||
|
.new-item-button {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.title-text {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
width: max-content;
|
||||||
|
border: 1px solid black;
|
||||||
|
padding: 0.2em;
|
||||||
|
background-color: white;
|
||||||
|
margin-top: 0.3em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.title-text-parent:focus .title-text, .title-text-parent:hover .title-text {
|
||||||
|
display: revert;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
padding: 0 0.3em;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
text-align: right;
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.name {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.total {
|
||||||
|
border-top: 1px solid darkgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.x-button {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
form.x-button input {
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.add-instr {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol#instructions {
|
||||||
|
padding: 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol#instructions li {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 0.3em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol#instructions li span {
|
||||||
|
margin-top: 0.2em;
|
||||||
|
}
|
||||||
|
ol#instructions li span p {
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol#instructions li .instr-number{
|
||||||
|
margin-left: 1em;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
}
|
||||||
|
img#logo {
|
||||||
|
width: 60%;
|
||||||
|
display: block;
|
||||||
|
margin: 1em auto 3em auto;
|
||||||
|
background-color: #cef;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 5px solid #8bd;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.original-author-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2em;
|
||||||
|
}
|
||||||
|
div.original-author-container form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
35
pkg/web/static/vendor/htmx-extension-json-enc.js
vendored
Normal file
35
pkg/web/static/vendor/htmx-extension-json-enc.js
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
(function() {
|
||||||
|
let api
|
||||||
|
htmx.defineExtension('json-enc', {
|
||||||
|
init: function(apiRef) {
|
||||||
|
api = apiRef
|
||||||
|
},
|
||||||
|
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
if (name === 'htmx:configRequest') {
|
||||||
|
evt.detail.headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
encodeParameters: function(xhr, parameters, elt) {
|
||||||
|
xhr.overrideMimeType('text/json')
|
||||||
|
|
||||||
|
const vals = api.getExpressionVars(elt)
|
||||||
|
const object = {}
|
||||||
|
parameters.forEach(function(value, key) {
|
||||||
|
// FormData encodes values as strings, restore hx-vals/hx-vars with their initial types
|
||||||
|
const typedValue = Object.hasOwn(vals, key) ? vals[key] : value
|
||||||
|
if (Object.hasOwn(object, key)) {
|
||||||
|
if (!Array.isArray(object[key])) {
|
||||||
|
object[key] = [object[key]]
|
||||||
|
}
|
||||||
|
object[key].push(typedValue)
|
||||||
|
} else {
|
||||||
|
object[key] = typedValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (JSON.stringify(object))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
1
pkg/web/static/vendor/htmx.min.js
vendored
Normal file
1
pkg/web/static/vendor/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
120
pkg/web/tpl/pages/IngredientDetail.templ
Normal file
120
pkg/web/tpl/pages/IngredientDetail.templ
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
. "recipe_book/pkg/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ IngredientDetail(f Food) {
|
||||||
|
<h1>{ f.Name }</h1>
|
||||||
|
|
||||||
|
<form hx-post={ fmt.Sprintf("/ingredients/%d", f.ID) } hx-ext="json-enc" hx-target="body">
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>name</label>
|
||||||
|
<input type="name" name="name" value={ f.Name }>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>calories</label>
|
||||||
|
<input type="number" name="cals" value={ fmt.Sprint(f.Cals) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>carbs</label>
|
||||||
|
<input type="number" name="carbs" value={ fmt.Sprint(f.Carbs) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>protein</label>
|
||||||
|
<input type="number" name="protein" value={ fmt.Sprint(f.Protein) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>fat</label>
|
||||||
|
<input type="number" name="fat" value={ fmt.Sprint(f.Fat) }>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>sugar</label>
|
||||||
|
<input type="number" name="sugar" value={ fmt.Sprint(f.Sugar) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>alcohol</label>
|
||||||
|
<input type="number" name="alcohol" value={ fmt.Sprint(f.Alcohol) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>water</label>
|
||||||
|
<input type="number" name="water" value={ fmt.Sprint(f.Water) }>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>potassium</label>
|
||||||
|
<input type="number" name="potassium" value={ fmt.Sprint(f.Potassium) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>calcium</label>
|
||||||
|
<input type="number" name="calcium" value={ fmt.Sprint(f.Calcium) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>sodium</label>
|
||||||
|
<input type="number" name="sodium" value={ fmt.Sprint(f.Sodium) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>magnesium</label>
|
||||||
|
<input type="number" name="magnesium" value={ fmt.Sprint(f.Magnesium) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>phosphorus</label>
|
||||||
|
<input type="number" name="phosphorus" value={ fmt.Sprint(f.Phosphorus) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>iron</label>
|
||||||
|
<input type="number" name="iron" value={ fmt.Sprint(f.Iron) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>zinc</label>
|
||||||
|
<input type="number" name="zinc" value={ fmt.Sprint(f.Zinc) }>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>
|
||||||
|
<span class="title-text-parent">(?)<div class="title-text">Mass of one serving or 'unit' of this food. in grams</div></span>
|
||||||
|
serving size (g)
|
||||||
|
</label>
|
||||||
|
<input type="number" name="mass" value={ fmt.Sprint(f.Mass) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>price</label>
|
||||||
|
<input type="number" name="price" value={ fmt.Sprint(f.Price) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>density</label>
|
||||||
|
<input type="number" name="density" value={ fmt.Sprint(f.Density) }>
|
||||||
|
</div>
|
||||||
|
<div class="labelled-input">
|
||||||
|
<label>
|
||||||
|
<span class="title-text-parent">(?)
|
||||||
|
<div class="title-text">
|
||||||
|
How much will it weigh after cooking, as a multiple of its original weight?
|
||||||
|
<br><br>
|
||||||
|
1 = no change
|
||||||
|
<br>
|
||||||
|
<1 = weighs less than before (e.g., it released some water)
|
||||||
|
<br>
|
||||||
|
>1 = weighs more than before (e.g., it soaked up water)
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
cook ratio
|
||||||
|
</label>
|
||||||
|
<input type="number" name="cook_ratio" value={ fmt.Sprint(f.CookRatio) }>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="submit" value="Save">
|
||||||
|
</form>
|
||||||
|
}
|
43
pkg/web/tpl/pages/IngredientsIndex.templ
Normal file
43
pkg/web/tpl/pages/IngredientsIndex.templ
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
. "recipe_book/pkg/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ IngredientsIndex(foods []Food) {
|
||||||
|
<h1>Ingredients</h1>
|
||||||
|
<dialog id="newIngredientDialog">
|
||||||
|
<h3>Create new ingredient</h3>
|
||||||
|
<form hx-post="/ingredients" hx-ext="json-enc" hx-target="body" hx-push-url="true">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input name="name" />
|
||||||
|
<input type="submit" value="Create" />
|
||||||
|
</form>
|
||||||
|
<button onclick="newIngredientDialog.close()">Cancel</button>
|
||||||
|
</dialog>
|
||||||
|
<button class=".new-item-button" onclick="newIngredientDialog.showModal()">New Ingredient</button>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Cals</th>
|
||||||
|
<th>Carbs</th>
|
||||||
|
<th>Protein</th>
|
||||||
|
<th>Fat</th>
|
||||||
|
<th>Sugar</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
for _, f := range foods {
|
||||||
|
<tr>
|
||||||
|
<td><a href={ templ.URL(fmt.Sprintf("/ingredients/%d", f.ID)) } hx-boost="true">{ f.Name }</a></td>
|
||||||
|
<td>{ fmt.Sprint(f.Cals) }</td>
|
||||||
|
<td>{ fmt.Sprint(f.Carbs) }</td>
|
||||||
|
<td>{ fmt.Sprint(f.Protein) }</td>
|
||||||
|
<td>{ fmt.Sprint(f.Fat) }</td>
|
||||||
|
<td>{ fmt.Sprint(f.Sugar) }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
86
pkg/web/tpl/pages/RecipeDetail.templ
Normal file
86
pkg/web/tpl/pages/RecipeDetail.templ
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
. "recipe_book/pkg/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func to_food_list(r Recipe) []Food {
|
||||||
|
ret := make([]Food, len(r.Ingredients))
|
||||||
|
for i := range r.Ingredients {
|
||||||
|
ret[i] = r.Ingredients[i].Food
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
templ RecipeDetail(recipe Recipe) {
|
||||||
|
<h1>{ recipe.Name }</h1>
|
||||||
|
<dialog id="renameRecipeDialog">
|
||||||
|
<h3>Rename recipe</h3>
|
||||||
|
<form hx-post={ fmt.Sprintf("/recipes/%d", recipe.ID) } hx-ext="json-enc" hx-target="body" hx-push-url="true">
|
||||||
|
<label for="name">New name:</label>
|
||||||
|
<input name="name" />
|
||||||
|
<input type="submit" value="Update" />
|
||||||
|
</form>
|
||||||
|
<button onclick="renameRecipeDialog.close()">Cancel</button>
|
||||||
|
</dialog>
|
||||||
|
<button class="new-item-button" onclick="renameRecipeDialog.showModal()">Rename</button>
|
||||||
|
|
||||||
|
<table class="recipe-table">
|
||||||
|
<thead>
|
||||||
|
<th></th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Ingredient</th>
|
||||||
|
<th>Calories</th>
|
||||||
|
<th>Carbs</th>
|
||||||
|
<th>Protein</th>
|
||||||
|
<th>Fat</th>
|
||||||
|
<th>Sugar</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
for _, i := range recipe.Ingredients {
|
||||||
|
<tr>
|
||||||
|
<td class="delete-button"></td>
|
||||||
|
<td class="amount">{ i.DisplayAmount() }</td>
|
||||||
|
<td class="name">{ i.Food.Name }</td>
|
||||||
|
<td class="cals">{ fmt.Sprint(int(i.Food.Cals * i.Quantity)) }</td>
|
||||||
|
<td class="carbs">{ fmt.Sprint(int(i.Food.Carbs * i.Quantity)) }</td>
|
||||||
|
<td class="protein">{ fmt.Sprint(int(i.Food.Protein * i.Quantity)) }</td>
|
||||||
|
<td class="fat">{ fmt.Sprint(int(i.Food.Fat * i.Quantity)) }</td>
|
||||||
|
<td class="sugar">{ fmt.Sprint(int(i.Food.Sugar * i.Quantity)) }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
{{ computed_food := recipe.ComputeFood() }}
|
||||||
|
<tr class="recipe-table__total-row">
|
||||||
|
<td class="delete-button"></td>
|
||||||
|
<td class="amount"></td>
|
||||||
|
<td class="name">Total</td>
|
||||||
|
<td class="cals">{ fmt.Sprint(int(computed_food.Cals)) }</td>
|
||||||
|
<td class="carbs">{ fmt.Sprint(int(computed_food.Carbs)) }</td>
|
||||||
|
<td class="protein">{ fmt.Sprint(int(computed_food.Protein)) }</td>
|
||||||
|
<td class="fat">{ fmt.Sprint(int(computed_food.Fat)) }</td>
|
||||||
|
<td class="sugar">{ fmt.Sprint(int(computed_food.Sugar)) }</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
if recipe.Blurb != "" {
|
||||||
|
<h2>Blurb</h2>
|
||||||
|
for _, line := range strings.Split(recipe.Blurb, "\n") {
|
||||||
|
<p><i>{ line }</i></p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2>Instructions</h2>
|
||||||
|
<ol class="instructions-list">
|
||||||
|
for _, instr := range recipe.Instructions {
|
||||||
|
<li>
|
||||||
|
for _, line := range strings.Split(instr, "\n") {
|
||||||
|
<p>{ line }</p>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
}
|
26
pkg/web/tpl/pages/RecipesIndex.templ
Normal file
26
pkg/web/tpl/pages/RecipesIndex.templ
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
. "recipe_book/pkg/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ RecipesIndex(recipes []Recipe) {
|
||||||
|
<h1>Recipes</h1>
|
||||||
|
<dialog id="newRecipeDialog">
|
||||||
|
<h3>Create new recipe</h3>
|
||||||
|
<form hx-post="/recipes" hx-ext="json-enc" hx-target="body" hx-push-url="true">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input name="name" />
|
||||||
|
<input type="submit" value="Create" />
|
||||||
|
</form>
|
||||||
|
<button onclick="newRecipeDialog.close()">Cancel</button>
|
||||||
|
</dialog>
|
||||||
|
<button class="new-item-button" onclick="newRecipeDialog.showModal()">New Recipe</button>
|
||||||
|
<ul>
|
||||||
|
for _, r := range recipes {
|
||||||
|
<li><a hx-post={ fmt.Sprintf("/recipes/%d", r.ID) } hx-target="body" hx-push-url="true">{ r.Name }</a></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
36
pkg/web/tpl/pages/base.templ
Normal file
36
pkg/web/tpl/pages/base.templ
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
templ Base(title string) {
|
||||||
|
<!doctype html>
|
||||||
|
<html lang='en'>
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8'>
|
||||||
|
<title>{ title } | Recipe Book</title>
|
||||||
|
|
||||||
|
// Page content
|
||||||
|
<link rel='shortcut icon' href='/static/chili-garlic.png' type='image/x-icon'>
|
||||||
|
<link rel='stylesheet' href='/static/styles.css'>
|
||||||
|
|
||||||
|
// HTMX
|
||||||
|
<script src="/static/vendor/htmx.min.js" integrity="sha384-oecSB0HeI5gdFcssloeKf3nByrZ7XjyAKxoykSkH8A4WPwT6suR+Ie4wGSLaQJBu" crossorigin="anonymous"></script>
|
||||||
|
<script src="/static/vendor/htmx-extension-json-enc.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<img id="logo" src="/static/chili-garlic.png">
|
||||||
|
<ul>
|
||||||
|
<li><a hx-get="/ingredients" hx-target="body" hx-push-url="true">Ingredients</a></li>
|
||||||
|
<li><a hx-get="/recipes" hx-target="body" hx-push-url="true">Recipes</a></li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<li><a hx-get="/about" hx-target="body" hx-push-url="true">About this app</a></li>
|
||||||
|
<li><a hx-get="/help" hx-target="body" hx-push-url="true">Help</a></li>
|
||||||
|
<li><a hx-get="/changelog" hx-target="body" hx-push-url="true">Changelog</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
mount -t tmpfs -o size=100M tmpfs sample_data/data
|
sudo mount -t tmpfs -o size=100M tmpfs sample_data/data
|
||||||
|
6
sample_data/reset.sh
Executable file
6
sample_data/reset.sh
Executable file
@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
test -e sample_data/data/food.db && rm sample_data/data/food.db
|
||||||
|
|
||||||
|
go run ./cmd init
|
||||||
|
sqlite3 sample_data/data/food.db < sample_data/seed.sql
|
@ -1,3 +1,7 @@
|
|||||||
|
-- ==========
|
||||||
|
-- Base foods
|
||||||
|
-- ==========
|
||||||
|
|
||||||
INSERT INTO foods (name,cals,carbs,protein,fat,sugar,alcohol,water,potassium,sodium,calcium,magnesium,phosphorus,iron,zinc,mass,price,density,cook_ratio) VALUES
|
INSERT INTO foods (name,cals,carbs,protein,fat,sugar,alcohol,water,potassium,sodium,calcium,magnesium,phosphorus,iron,zinc,mass,price,density,cook_ratio) VALUES
|
||||||
('bread',289.0,56.0,12.0,2.0,3.0,0.0,0.0,0.1,0.381,0.151,0.023,0.099,0.0037,0.0007,100.0,44.0,1.0,1.0),
|
('bread',289.0,56.0,12.0,2.0,3.0,0.0,0.0,0.1,0.381,0.151,0.023,0.099,0.0037,0.0007,100.0,44.0,1.0,1.0),
|
||||||
('oats',400.0,67.0,17.0,7.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,33.0,0.44,1.0),
|
('oats',400.0,67.0,17.0,7.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,33.0,0.44,1.0),
|
||||||
@ -107,12 +111,10 @@ INSERT INTO foods (name,cals,carbs,protein,fat,sugar,alcohol,water,potassium,sod
|
|||||||
('frozen mixed veggies',71.0,13.0,3.5,0.0,3.5,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,27.0,1.0,1.0),
|
('frozen mixed veggies',71.0,13.0,3.5,0.0,3.5,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,27.0,1.0,1.0),
|
||||||
('canned olives',167.0,7.0,0.0,13.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,150.0,1.0,1.0),
|
('canned olives',167.0,7.0,0.0,13.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,150.0,1.0,1.0),
|
||||||
('frozen peas',59.0,14.0,4.7,0.0,4.7,0.0,0.0,0.11,0.072,0.024,0.022,0.077,0.0035,0.0007,100.0,27.5,1.0,1.0),
|
('frozen peas',59.0,14.0,4.7,0.0,4.7,0.0,0.0,0.11,0.072,0.024,0.022,0.077,0.0035,0.0007,100.0,27.5,1.0,1.0),
|
||||||
('pecan butter tarts',360.0,49.0,3.0,17.0,24.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
|
||||||
('ravioli',220.0,23.0,14.0,8.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,119.0,1.0,1.0),
|
('ravioli',220.0,23.0,14.0,8.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,119.0,1.0,1.0),
|
||||||
('panko',366.0,77.0,10.0,2.0,7.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
('panko',366.0,77.0,10.0,2.0,7.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
||||||
('doritos',500.0,57.0,7.0,28.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
('doritos',500.0,57.0,7.0,28.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
||||||
('pepperoni',90.0,2.0,7.0,7.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.0,44.0,1.0,1.0),
|
('pepperoni',90.0,2.0,7.0,7.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.0,44.0,1.0,1.0),
|
||||||
('kirkland croissants',300.0,30.0,6.0,17.0,4.0,0.0,0.0,0.05,0.36,0.04,0.01,0.051,0.0018,0.00032,69.0,50.0,1.0,1.0),
|
|
||||||
('bacon',190.0,1.0,5.0,18.0,0.0,0.0,0.0,0.08,0.24,0.0012,0.005,0.078,0.0002,0.0005,44.0,45.0,1.0,1.0),
|
('bacon',190.0,1.0,5.0,18.0,0.0,0.0,0.0,0.08,0.24,0.0012,0.005,0.078,0.0002,0.0005,44.0,45.0,1.0,1.0),
|
||||||
('ketchup',133.0,33.0,2.0,0.0,27.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
('ketchup',133.0,33.0,2.0,0.0,27.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
||||||
('ranch',467.0,7.0,3.0,53.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
('ranch',467.0,7.0,3.0,53.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
||||||
@ -122,7 +124,7 @@ INSERT INTO foods (name,cals,carbs,protein,fat,sugar,alcohol,water,potassium,sod
|
|||||||
('fries',320.0,41.0,3.4,15.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
('fries',320.0,41.0,3.4,15.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,0.0,1.0,1.0),
|
||||||
('salt',0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.0,0.0,0.0,0.0,0.0,0.0,100.0,17.0,2.2,1.0),
|
('salt',0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.0,0.0,0.0,0.0,0.0,0.0,100.0,17.0,2.2,1.0),
|
||||||
('KCL',0.0,0.0,0.0,0.0,0.0,0.0,0.0,52.4,0.0,0.0,0.0,0.0,0.0,0.0,100.0,176.0,1.0,1.0),
|
('KCL',0.0,0.0,0.0,0.0,0.0,0.0,0.0,52.4,0.0,0.0,0.0,0.0,0.0,0.0,100.0,176.0,1.0,1.0),
|
||||||
('pepper',255.0,65.0,11.0,3.0,1.0,0.0,0.0,1.26,0.044,0.437,0.194,0.173,0.0289,0.0014,100.0,0.0,1.0,1.0),
|
('black pepper',255.0,65.0,11.0,3.0,1.0,0.0,0.0,1.26,0.044,0.437,0.194,0.173,0.0289,0.0014,100.0,0.0,1.0,1.0),
|
||||||
('oregano',265.0,48.0,9.0,4.3,4.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,162.0,1.0,1.0),
|
('oregano',265.0,48.0,9.0,4.3,4.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,100.0,162.0,1.0,1.0),
|
||||||
('garlic powder',331.0,68.0,13.0,1.0,2.0,0.0,0.0,1.101,0.026,0.08,0.058,0.417,0.0027,0.0026,100.0,107.0,1.0,1.0),
|
('garlic powder',331.0,68.0,13.0,1.0,2.0,0.0,0.0,1.101,0.026,0.08,0.058,0.417,0.0027,0.0026,100.0,107.0,1.0,1.0),
|
||||||
('onion powder',347.0,75.0,10.0,1.0,35.0,0.0,0.0,0.943,0.053,0.363,0.122,0.34,0.0026,0.0023,100.0,152.0,1.0,1.0),
|
('onion powder',347.0,75.0,10.0,1.0,35.0,0.0,0.0,0.943,0.053,0.363,0.122,0.34,0.0026,0.0023,100.0,152.0,1.0,1.0),
|
||||||
@ -147,4 +149,37 @@ INSERT INTO foods (name,cals,carbs,protein,fat,sugar,alcohol,water,potassium,sod
|
|||||||
('sage',315.0,40.0,11.0,13.0,2.0,0.0,0.0,1.07,0.011,1.652,0.428,0.0,0.028,0.0047,100.0,210.0,1.0,1.0),
|
('sage',315.0,40.0,11.0,13.0,2.0,0.0,0.0,1.07,0.011,1.652,0.428,0.0,0.028,0.0047,100.0,210.0,1.0,1.0),
|
||||||
('nutmeg',525.0,44.0,6.0,36.0,28.0,0.0,0.0,0.35,0.016,0.184,0.183,0.0,0.003,0.0021,100.0,246.0,1.0,1.0),
|
('nutmeg',525.0,44.0,6.0,36.0,28.0,0.0,0.0,0.35,0.016,0.184,0.183,0.0,0.003,0.0021,100.0,246.0,1.0,1.0),
|
||||||
('turmeric',354.0,60.0,8.0,10.0,3.0,0.0,0.0,2.525,0.038,0.183,0.193,0.268,0.041,0.0043,100.0,0.0,1.0,1.0),
|
('turmeric',354.0,60.0,8.0,10.0,3.0,0.0,0.0,2.525,0.038,0.183,0.193,0.268,0.041,0.0043,100.0,0.0,1.0,1.0),
|
||||||
('cloves',323.0,55.0,6.0,20.0,2.0,0.0,0.0,1.102,0.243,0.646,0.264,0.105,0.0087,0.0011,100.0,0.0,1.0,1.0);
|
('cloves',323.0,55.0,6.0,20.0,2.0,0.0,0.0,1.102,0.243,0.646,0.264,0.105,0.0087,0.0011,100.0,0.0,1.0,1.0),
|
||||||
|
('coffee', 0,0,0,0,0,0,100,0.047,0,0,0.003,0,0,0,100,0,1,1),
|
||||||
|
('chicken broth', 0,0,0,0,0,0,100,0,0,0,0,0,0,0,100,0,1,1);
|
||||||
|
|
||||||
|
|
||||||
|
-- =======
|
||||||
|
-- Recipes
|
||||||
|
-- =======
|
||||||
|
|
||||||
|
insert into foods (rowid, name, cals,carbs,protein,fat,sugar,alcohol,water,potassium,sodium,calcium,magnesium,phosphorus,iron,zinc,mass,price,density,cook_ratio) values (10000, 'Chicken parmigiana soup', 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);
|
||||||
|
insert into recipes (rowid, name, blurb, instructions, computed_food_id) values (1, 'Chicken parmigiana soup', 'This is a nice recipe which is basically what it sounds like: chicken parmigiana, but in soup form. It''s easy to make, hard to mess up, and simply delicious.', replace('In large pot, heat butter and oil. Cook onions to 1/2 cooked (translucent and soft, starting to turn golden-brown)\aAdd garlic, cook 1-2 minutes\aadd tomatoes and chicken broth, bring to a simmer\asalt and season the soup, tasting until seasonings are right\acut chicken into bite-sized pieces, add to soup and boil until fully cooked\aadd pasta and boil until it is cooked "al dente"; add more stock as needed\aadd half the cheese and stir it into the soup\asprinkle remaining cheese on top and serve', '\a', char(31)), 10000);
|
||||||
|
insert into ingredients(food_id, quantity, units, in_recipe_id, list_order) values
|
||||||
|
(28, 2, 1, 1, 0), -- 2 yellow onions
|
||||||
|
(78, 0.3, 2, 1, 1), -- 30g butter
|
||||||
|
(12, 0.3, 2, 1, 2), -- 30g olive oil
|
||||||
|
(32, 0.4, 2, 1, 3), -- 40g garlic
|
||||||
|
(105, 8, 5, 1, 4), -- 800mL canned tomatoes,
|
||||||
|
(62, 4.5, 2, 1, 5), -- 450g chicken breast
|
||||||
|
(3, 3, 2, 1, 6), -- 300g pasta
|
||||||
|
(83, 1.5, 2, 1, 7), -- 150g mozzarella
|
||||||
|
(86, 1.5, 2, 1, 8), -- 150g parmigiano
|
||||||
|
(120, 0.2, 2, 1, 9), -- 20g salt
|
||||||
|
(122, 0.02, 2, 1, 10), -- 2g pepper
|
||||||
|
(126, 0.05, 2, 1, 11), --5g chilis
|
||||||
|
(149, 10, 5, 1, 12); -- 1000mL chicken stock
|
||||||
|
|
||||||
|
insert into foods (rowid, name, cals,carbs,protein,fat,sugar,alcohol,water,potassium,sodium,calcium,magnesium,phosphorus,iron,zinc,mass,price,density,cook_ratio) values (10001, 'Breakfast shake', 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);
|
||||||
|
insert into recipes (rowid, name, blurb, instructions, computed_food_id) values (2, 'Breakfast shake', 'A quick breakfast-in-a-bottle you can have on the go', replace(replace('In shaker bottle, add milk, peanut butter, yogurt, *hot* coffee (in that order). Mix until the peanut butter is melted/dissolved\aKey point of previous step is to get the temperatures right:\n- Hot coffee will melt the shaker bottle or dissolve microplastics, so don''t add it directly\n- without hot coffee, peanut butter won''t dissolve\n- hot coffee will make whey protein curdle, so shake coffee with cold ingredients until it ends up warm-ish, then it''s safe to add whey\aOnce temperature has equilibrated between hot coffee and cold stuff, add whey protein, shake again, and drink', '\a', char(31)), '\n', char(10)), 10001);
|
||||||
|
insert into ingredients(food_id, quantity, units, in_recipe_id, list_order) values
|
||||||
|
(10, 0.3, 2, 2, 0), -- 30g peanut butter
|
||||||
|
(69, 3.5, 5, 2, 1), -- 350mL skim milk
|
||||||
|
(81, 2.5, 6, 2, 2), -- 1 cup yogurt
|
||||||
|
(7, 0.4, 2, 2, 3), -- 1 cup yogurt
|
||||||
|
(148, 2, 5, 2, 4); -- 200 mL coffee
|
||||||
|
Loading…
x
Reference in New Issue
Block a user