Many small refactors, mainly moving the JSON import/export to one file.

This commit is contained in:
gamerdonkey 2025-09-15 05:07:51 +00:00
parent 1017f3b68c
commit ba139706bb
4 changed files with 144 additions and 43 deletions

View File

@ -1,16 +1,32 @@
# Recipes for Engineers
Store recipes in a nerdy JSON tree format and use that to generate [Cooking for Engineers](https://www.cookingforengineers.com)-style tabular recipe cards in HTML.
Store recipes in a nerdy JSON tree format and use that to generate [Cooking for
Engineers](https://www.cookingforengineers.com)-style tabular recipe cards in
HTML.
Right now this project is just a set of scripts.
I used these scripts to create my [recipes
site](https://tilde.town/~gamerdonkey/recipes/) here on town. Fun fact: you can
replace the `html` with `json` in the URLs to get my recipes in their raw JSON
format.
## Requirements
These scripts require the `anytree` Python library.
Requires the [anytree Python
library](https://anytree.readthedocs.io/en/latest/).
## `create_recipe_json.py`
## Main Scripts
This is a fairly naive script that walks you through the process of turning a recipe into a tree structure. It has you list out ingredients, and then add tasks to perform on those ingredients. You end up with a tree that is something like this:
### `create_recipe_json.py`
This is a fairly naive script that walks you through the process of turning a
recipe into a tree structure. It has you list out ingredients, and then add
tasks to perform on those ingredients.
You can add ingredients and then add tasks to perform on those ingredients.
The results of those tasks can have more tasks applied to them, until you end
up with your finished product.
The tree you build has a structure like this, with the last step at the root:
```
shake and grill for 20 more mins
@ -28,12 +44,31 @@ shake and grill for 20 more mins
+-- red potatoes
```
Leaves are raw ingredients, other nodes are tasks. The tree is output to a JSON file.
Leaves are raw ingredients, other nodes are tasks. A recipe can have multiple
tree roots. Once you are done adding ingredients and tasks, all trees are
output to a JSON file based on the name you give to the recipe.
## `recipe_json_to_html.py`
### `recipes2html.py`
This script takes that JSON file and does a recursive depth-first search to render a webpage with an HTML-table-based recipe card.
***WARNING: These scripts do not sanitize most inputs!*** Parts of the JSON can
have HTML which will be rendered out in the browser, which was very convenient
for me but a terrible idea for security. **DO NOT RUN THIS CODE ON JSON YOU
DID NOT CREATE YOURSELF!**
This script takes an argument pointing to a directory of recipe JSON files, in
the format created by the `create_recipe_json.py` script. It then does a
recursive depth-first search on each tree in that file to generate an HTML
file with a traditional recipe and a tabular-style recipe card. It also takes
an optional argument pointing to a file and, if given, the contents of that
file will be inserted into the header section of the generated `index.html`.
## Known Issues
- A single node cannot have multiple tasks done on it (nerdy reason: because a node cannot have two parents). So this cannot represent, for example, mixing cinnamon and sugar, then splitting that mixture up and doing separate subsequent operations with the two batches.
- A single node cannot have multiple tasks done on it (nerdy reason: because a
node cannot have two parents). So this cannot perfectly represent, for example,
mixing cinnamon and sugar, then splitting that mixture up and doing separate
subsequent operations with the two batches.
This has come up once in my [zucchini bread
recipe](https://tilde.town/~gamerdonkey/recipes/lemony_olive_oil_zucchini_bread.html),
and I worked around the issue by just adding an instruction to set the
ingredients aside and use them later.

View File

@ -2,6 +2,7 @@ import json
from anytree import Node, RenderTree
from anytree.exporter import DictExporter
from recipe import Recipe
def print_nodes():
@ -15,42 +16,61 @@ node = "start"
while node:
print_nodes()
node = input("Add node: ")
node = input("Add ingredient/task: ")
if node:
needs = input("List needs (comma-separated): ")
needs = ""
if nodes:
needs = input("List needs (comma-separated): ")
description = input("Detailed description: ")
if needs:
result = input("Result of node (e.g. chopped carrots): ")
new_node = Node(node, description=description, result=result)
for e in [int(key.strip()) for key in needs.split(",")]:
nodes.pop(e).parent = new_node
for key in needs.split(","):
try:
nodes.pop(int(key.strip())).parent = new_node
except KeyError:
print(f"<{key.strip()}> was not a valid ingredient or task")
else:
new_node = Node(node, description=description, result=node)
nodes[i] = new_node
i += 1
else:
for key, tree_node in nodes.items():
print(key, end=" ")
for pre, _, render_node in RenderTree(tree_node):
print(f"{pre}{render_node.name}")
root_keys = nodes.keys()
confirm = input("Done adding ingredients and tasks? (y/N): ")
for key in root_keys:
for pre, _, node in RenderTree(nodes[key]):
print(f"{pre}{node.name}")
if not confirm or not confirm.lower().startswith("y"):
node = "continue"
name = ""
try:
while not name:
name = input("Recipe name (required to save, ctrl-c to exit): ")
except KeyboardInterrupt:
print("Exiting...")
exit(0)
name = input("Recipe name: ")
description = input("Recipe description: ")
if name:
sub_recipes = []
exporter = DictExporter()
for key in root_keys:
sub_recipes.append(exporter.export(nodes[key]))
servings = input("Servings: ")
prep_time = input("Prep time: ")
cook_time = input("Cook time: ")
recipe = {"name": name, "description": description, "sub_recipes": sub_recipes}
recipe = Recipe(name, description, servings, prep_time, cook_time)
for _, node in nodes.items():
recipe.add_sub_recipe(node)
filename = name.replace(" ", "_").lower()
filename = f"{name.replace(' ', '_').lower()}.json"
with open(f"{filename}.json", 'w') as f:
f.write(json.dumps(recipe, indent=2))
print(f"Writing to {filename}...")
with open(filename, 'w') as f:
f.write(recipe.to_json())
print("Done!")

View File

@ -1,6 +1,9 @@
import html
import json
from anytree import Node
from anytree.importer import DictImporter
from anytree.exporter import DictExporter
from sys import argv
@ -14,29 +17,32 @@ class TableCell:
def to_html(self):
if self.is_header:
html = f"<th title='{self.description}'>{self.name}</th>"
output = f"<th title='{html.escape(self.description)}'>{self.name}</th>"
elif not self.name:
html = f'<td colspan="{self.colspan}" class="filler"></td>'
output = f'<td colspan="{self.colspan}" class="filler"></td>'
else:
html = f"<td rowspan='{self.rowspan}' colspan='{self.colspan}' title='{self.description}'>{self.name}</td>"
output = f"<td rowspan='{self.rowspan}' colspan='{self.colspan}' title='{html.escape(self.description)}'>{self.name}</td>"
return html
return output
class Recipe:
def __init__(self, json_filename: str):
with open(json_filename, 'r') as f:
recipe_dict = json.loads(f.read())
def __init__(self, name: str, desc: str, servings: str, prep_time: str, cook_time: str, sub_recipes: list[Node]=[]):
self.name = name
self.desc = desc
self.servings = servings
self.prep_time = prep_time
self.cook_time = cook_time
self.name = recipe_dict.get("name")
self.desc = recipe_dict.get("description")
importer = DictImporter()
self.sub_recipes = [importer.import_(data) for data in recipe_dict["sub_recipes"]]
self._sub_recipes = sub_recipes
def add_sub_recipe(self, tree_root: Node):
self._sub_recipes.append(tree_root)
def generate_ingredient_list(self):
ingredients = []
for sub_recipe in self.sub_recipes:
for sub_recipe in self._sub_recipes:
for ingredient in sub_recipe.leaves:
if ingredient.description:
ingredients.append(f"{ingredient.name} -- {ingredient.description}")
@ -48,7 +54,7 @@ class Recipe:
def generate_task_list(self):
tasks = []
for sub_recipe in self.sub_recipes:
for sub_recipe in self._sub_recipes:
tasks.extend(self.generate_steps_depth_first(sub_recipe, steps=[]))
return tasks
@ -65,7 +71,7 @@ class Recipe:
def generate_tables(self):
tables = []
for sub_recipe in self.sub_recipes:
for sub_recipe in self._sub_recipes:
tables.append(self.build_table_rows_depth_first(sub_recipe))
return tables
@ -97,6 +103,38 @@ class Recipe:
return rows
@classmethod
def from_json(cls, json_string: str):
recipe_dict = json.loads(json_string)
name = recipe_dict["name"]
desc = recipe_dict.get("description")
servings = recipe_dict.get("servings")
prep_time = recipe_dict.get("prep_time")
cook_time = recipe_dict.get("cook_time")
importer = DictImporter()
sub_recipes = [importer.import_(data) for data in recipe_dict["sub_recipes"]]
return cls(name, desc, servings, prep_time, cook_time, sub_recipes)
def to_dict(self):
sub_recipes_dict = []
exporter = DictExporter()
for sub_recipe in self._sub_recipes:
sub_recipes_dict.append(exporter.export(sub_recipe))
return {
"name": self.name,
"description": self.desc,
"servings": self.servings,
"prep_time": self.prep_time,
"cook_time": self.cook_time,
"sub_recipes": sub_recipes_dict
}
def to_json(self):
return json.dumps(self.to_dict(), indent=2)
def to_html(self):
return f"""<!DOCTYPE html>
<html lang='en'>
@ -110,6 +148,11 @@ class Recipe:
<nav><a href='./'>Recipe Index</a></nav>
<h1>{self.name}</h1>
<p>{self.desc}</p>
<p>
{f"<span><strong>Servings: </strong>{self.servings}</span><br>" if self.servings else ""}
{f"<span><strong>Preparation Time: </strong>{self.prep_time}</span><br>" if self.prep_time else ""}
{f"<span><strong>Cook Time: </strong>{self.cook_time}</span><br>" if self.cook_time else ""}
</p>
</header>
<main>
@ -149,5 +192,7 @@ if __name__ == "__main__":
print(f"usage: {argv[0]} <json file>")
exit(1)
print(Recipe(argv[1]).to_html())
with open(argv[1], 'r') as f:
print(Recipe.from_json(f.read()).to_html())

View File

@ -31,7 +31,8 @@ recipe_names = {}
for dirpath, dirnames, filenames in walk(recipe_dir):
for filename in filenames:
if filename.endswith("json"):
recipe = Recipe(path.join(dirpath, filename))
with open(path.join(dirpath, filename), 'r') as json_file:
recipe = Recipe.from_json(json_file.read())
html = recipe.to_html()
html_filepath = path.join(dirpath, f"{filename.rsplit('.', maxsplit=1)[0]}.html")