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 # 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 ## 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 shake and grill for 20 more mins
@ -28,12 +44,31 @@ shake and grill for 20 more mins
+-- red potatoes +-- 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 ## 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 import Node, RenderTree
from anytree.exporter import DictExporter from anytree.exporter import DictExporter
from recipe import Recipe
def print_nodes(): def print_nodes():
@ -15,42 +16,61 @@ node = "start"
while node: while node:
print_nodes() print_nodes()
node = input("Add node: ") node = input("Add ingredient/task: ")
if node: if node:
needs = input("List needs (comma-separated): ") needs = ""
if nodes:
needs = input("List needs (comma-separated): ")
description = input("Detailed description: ") description = input("Detailed description: ")
if needs: if needs:
result = input("Result of node (e.g. chopped carrots): ") result = input("Result of node (e.g. chopped carrots): ")
new_node = Node(node, description=description, result=result) new_node = Node(node, description=description, result=result)
for e in [int(key.strip()) for key in needs.split(",")]: for key in needs.split(","):
nodes.pop(e).parent = new_node try:
nodes.pop(int(key.strip())).parent = new_node
except KeyError:
print(f"<{key.strip()}> was not a valid ingredient or task")
else: else:
new_node = Node(node, description=description, result=node) new_node = Node(node, description=description, result=node)
nodes[i] = new_node nodes[i] = new_node
i += 1 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: if not confirm or not confirm.lower().startswith("y"):
for pre, _, node in RenderTree(nodes[key]): node = "continue"
print(f"{pre}{node.name}")
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: ") description = input("Recipe description: ")
if name: servings = input("Servings: ")
sub_recipes = [] prep_time = input("Prep time: ")
exporter = DictExporter() cook_time = input("Cook time: ")
for key in root_keys:
sub_recipes.append(exporter.export(nodes[key]))
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: print(f"Writing to {filename}...")
f.write(json.dumps(recipe, indent=2)) with open(filename, 'w') as f:
f.write(recipe.to_json())
print("Done!")

View File

@ -1,6 +1,9 @@
import html
import json import json
from anytree import Node
from anytree.importer import DictImporter from anytree.importer import DictImporter
from anytree.exporter import DictExporter
from sys import argv from sys import argv
@ -14,29 +17,32 @@ class TableCell:
def to_html(self): def to_html(self):
if self.is_header: 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: elif not self.name:
html = f'<td colspan="{self.colspan}" class="filler"></td>' output = f'<td colspan="{self.colspan}" class="filler"></td>'
else: 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: class Recipe:
def __init__(self, json_filename: str): def __init__(self, name: str, desc: str, servings: str, prep_time: str, cook_time: str, sub_recipes: list[Node]=[]):
with open(json_filename, 'r') as f: self.name = name
recipe_dict = json.loads(f.read()) self.desc = desc
self.servings = servings
self.prep_time = prep_time
self.cook_time = cook_time
self.name = recipe_dict.get("name") self._sub_recipes = sub_recipes
self.desc = recipe_dict.get("description")
importer = DictImporter() def add_sub_recipe(self, tree_root: Node):
self.sub_recipes = [importer.import_(data) for data in recipe_dict["sub_recipes"]] self._sub_recipes.append(tree_root)
def generate_ingredient_list(self): def generate_ingredient_list(self):
ingredients = [] ingredients = []
for sub_recipe in self.sub_recipes: for sub_recipe in self._sub_recipes:
for ingredient in sub_recipe.leaves: for ingredient in sub_recipe.leaves:
if ingredient.description: if ingredient.description:
ingredients.append(f"{ingredient.name} -- {ingredient.description}") ingredients.append(f"{ingredient.name} -- {ingredient.description}")
@ -48,7 +54,7 @@ class Recipe:
def generate_task_list(self): def generate_task_list(self):
tasks = [] 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=[])) tasks.extend(self.generate_steps_depth_first(sub_recipe, steps=[]))
return tasks return tasks
@ -65,7 +71,7 @@ class Recipe:
def generate_tables(self): def generate_tables(self):
tables = [] 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)) tables.append(self.build_table_rows_depth_first(sub_recipe))
return tables return tables
@ -97,6 +103,38 @@ class Recipe:
return rows 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): def to_html(self):
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang='en'> <html lang='en'>
@ -110,6 +148,11 @@ class Recipe:
<nav><a href='./'>Recipe Index</a></nav> <nav><a href='./'>Recipe Index</a></nav>
<h1>{self.name}</h1> <h1>{self.name}</h1>
<p>{self.desc}</p> <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> </header>
<main> <main>
@ -149,5 +192,7 @@ if __name__ == "__main__":
print(f"usage: {argv[0]} <json file>") print(f"usage: {argv[0]} <json file>")
exit(1) 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 dirpath, dirnames, filenames in walk(recipe_dir):
for filename in filenames: for filename in filenames:
if filename.endswith("json"): 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 = recipe.to_html()
html_filepath = path.join(dirpath, f"{filename.rsplit('.', maxsplit=1)[0]}.html") html_filepath = path.join(dirpath, f"{filename.rsplit('.', maxsplit=1)[0]}.html")