From ba139706bb1a216d46b03f30f671f771cade88d7 Mon Sep 17 00:00:00 2001 From: gamerdonkey Date: Mon, 15 Sep 2025 05:07:51 +0000 Subject: [PATCH] Many small refactors, mainly moving the JSON import/export to one file. --- README.md | 53 ++++++++++++++++++++++++------ create_recipe_json.py | 56 +++++++++++++++++++++----------- recipe.py | 75 ++++++++++++++++++++++++++++++++++--------- recipes2html.py | 3 +- 4 files changed, 144 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 138aa45..5a7e47c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/create_recipe_json.py b/create_recipe_json.py index 1d5f651..dd33af2 100644 --- a/create_recipe_json.py +++ b/create_recipe_json.py @@ -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!") diff --git a/recipe.py b/recipe.py index 40752f9..5223b8e 100644 --- a/recipe.py +++ b/recipe.py @@ -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"{self.name}" + output = f"{self.name}" elif not self.name: - html = f'' + output = f'' else: - html = f"{self.name}" + output = f"{self.name}" - 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""" @@ -110,6 +148,11 @@ class Recipe:

{self.name}

{self.desc}

+

+{f"Servings: {self.servings}
" if self.servings else ""} +{f"Preparation Time: {self.prep_time}
" if self.prep_time else ""} +{f"Cook Time: {self.cook_time}
" if self.cook_time else ""} +

@@ -149,5 +192,7 @@ if __name__ == "__main__": print(f"usage: {argv[0]} ") exit(1) - print(Recipe(argv[1]).to_html()) + + with open(argv[1], 'r') as f: + print(Recipe.from_json(f.read()).to_html()) diff --git a/recipes2html.py b/recipes2html.py index 7949d80..f360381 100644 --- a/recipes2html.py +++ b/recipes2html.py @@ -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")