diff --git a/backend/tree_api/app_generator.py b/backend/tree_api/app_generator.py index 28dd1c916..2e4898884 100644 --- a/backend/tree_api/app_generator.py +++ b/backend/tree_api/app_generator.py @@ -10,78 +10,13 @@ ############################################################################## -# Zip a directory -def zipdir(path, ziph): - - for root, dirs, files in os.walk(path): - for file in files: - ziph.write( - os.path.join(root, file), - os.path.relpath(os.path.join(root, file), path), - ) - - -# Rename all the necessary files in the template -def rename_template_files(root_path, original_str, replacement_str): - - for dirpath, dirnames, filenames in os.walk(root_path, topdown=False): - - # Rename directories - for dirname in dirnames: - if original_str in dirname: - src_dir = os.path.join(dirpath, dirname) - dst_dir = os.path.join( - dirpath, dirname.replace(original_str, replacement_str) - ) - os.rename(src_dir, dst_dir) - - # Rename files - for filename in filenames: - if original_str in filename: - src_file = os.path.join(dirpath, filename) - dst_file = os.path.join( - dirpath, filename.replace(original_str, replacement_str) - ) - os.rename(src_file, dst_file) - - -# Replace a str in a file for another -def replace_contents_in_file(file_path, original_str, replacement_str): - - with open(file_path, "r") as file: - file_data = file.read() - - new_data = file_data.replace(original_str, replacement_str) - - with open(file_path, "w") as file: - file.write(new_data) - - -def get_actions_paths(dir_to_scan): - - files_to_scan = [] - for root, _, filenames in os.walk(dir_to_scan): - for filename in filenames: - if filename.endswith(".py"): - files_to_scan.append(os.path.join(root, filename)) - - return files_to_scan - - # Collect unique imports from files -def get_unique_imports(file_paths): +def get_unique_imports(actions): unique_imports = set() - for file_path in file_paths: - if not os.path.exists(file_path): - print( - f"Warning: File {file_path} does not exist. Skipping import collection for this file." - ) - continue - - with open(file_path, "r") as file: - lines = file.readlines() + for action in actions: + lines = action["content"].splitlines() for line in lines: # Using regex to find lines that don't start with '#' and have 'import ...' or 'from ... import ...' @@ -89,115 +24,4 @@ def get_unique_imports(file_paths): if match: unique_imports.add(match.group(1)) - f = open("/tmp/imports.txt", "w") - f.write(f"Action paths: {unique_imports}.") - f.close() - return list(unique_imports) - - -def update_package_xml(package_xml_path, unique_imports): - # Mapping from Python import names to ROS package names - special_imports = { - "cv2": "python3-opencv", - # Add more mappings here as needed - } - - with open(package_xml_path, "r") as file: - content = file.read() - - # Finding the position of the last tag - last_exec_depend_index = content.rfind("") + len("") - - # Replacing special import names and generating new entries - new_exec_depends = "\n".join( - [ - f" {special_imports.get(imp, imp)}" - for imp in unique_imports - ] - ) - - # Inserting the new dependencies after the last - updated_content = ( - content[:last_exec_depend_index] - + "\n" - + new_exec_depends - + content[last_exec_depend_index:] - ) - - # Writing the updated content back to package.xml - with open(package_xml_path, "w") as file: - file.write(updated_content) - - -# Setup the package with the user data -def setup_package(temp_path, action_path, user_data): - - app_name = user_data["app_name"] - template_str = "ros_template" - - # 1. Rename directories and files recursively - rename_template_files(temp_path, template_str, app_name) - - # 2. Replace the original_str with app_name in the content of relevant files - files_to_edit = ["package.xml", "setup.py", "setup.cfg", app_name + "/execute.py"] - for file_name in files_to_edit: - file_path = os.path.join(temp_path, file_name) - if os.path.exists(file_path): - replace_contents_in_file(file_path, template_str, app_name) - else: - print( - f"Warning: {file_name} not found in {temp_path}. Skipping content replacement for this file." - ) - - # 3. Get a list of unique imports from the user-defined actions - action_paths = get_actions_paths(action_path) - imports = get_unique_imports(action_paths) - - # 4. Update the template package xml so the dependencies can be installed with rosdep - package_xml_path = os.path.join(temp_path, "package.xml") - update_package_xml(package_xml_path, imports) - - -############################################################################## -# Main section -############################################################################## - - -def generate(app_tree, app_name, template_path, action_path, tree_gardener_src): - - app_path = "/tmp/" + app_name - executor_path = app_path + "/" + app_name - tree_gardener_dst = app_path + "/tree_gardener" - - # Ensure the files exist - if not os.path.exists(app_tree): - raise FileNotFoundError(f"Tree path '{app_tree}' does not exist!") - - # 1. Copy the template to a temporary directory - if os.path.exists(executor_path): - shutil.rmtree(executor_path) # Delete if it already exists - shutil.copytree(template_path, executor_path) - print(f"Template copied to {executor_path}") - - # 2. Copy the tree to the template directory - tree_location = executor_path + "/resource/app_tree.xml" - shutil.copy(app_tree, tree_location) - - # 3. Edit some files in the template - user_data = {"app_name": app_name} - setup_package(executor_path, action_path, user_data) - - # 4. Copy the tree_gardener package to the app - if os.path.exists(tree_gardener_dst): - shutil.rmtree(tree_gardener_dst) # Delete if it already exists - shutil.copytree(tree_gardener_src, tree_gardener_dst) - print(f"Tree gardener copied to {tree_gardener_dst}") - - # 3. Generate a zip file in the destination folder with a name specified by the user - dest_path = app_path + ".zip" - with zipfile.ZipFile(dest_path, "w") as zipf: - zipdir(app_path, zipf) - print(f"Directory compressed to {dest_path}") - - return dest_path diff --git a/backend/tree_api/json_translator.py b/backend/tree_api/json_translator.py index fa4b7db2c..96ab09d55 100644 --- a/backend/tree_api/json_translator.py +++ b/backend/tree_api/json_translator.py @@ -157,7 +157,7 @@ def get_start_node_id(node_models, link_models): return start_node_id -def translate(content, tree_path, raw_order): +def translate_raw(content, raw_order): # Parse the JSON data try: @@ -189,16 +189,11 @@ def translate(content, tree_path, raw_order): order, ) except Exception as e: - tree_name = os.path.splitext(os.path.basename(tree_path))[0] - raise RuntimeError(f"Failed to translate tree '{tree_name}': {e}") + raise RuntimeError(f"Failed to translate tree: {e}") # Save the xml in the specified route xml_string = prettify_xml(root) - print(xml_string) - print(tree_path) - f = open(tree_path, "w") - f.write(xml_string) - f.close() + return xml_string def translate_tree_structure(content, raw_order): diff --git a/backend/tree_api/templates.py b/backend/tree_api/templates.py index 270004542..3b82ffc01 100644 --- a/backend/tree_api/templates.py +++ b/backend/tree_api/templates.py @@ -2,32 +2,10 @@ from django.conf import settings -def create_action_from_template(file_path, filename, template): +def get_action_template(filename, template, template_path): - templates_folder_path = os.path.join(settings.BASE_DIR, "templates") - template_path = os.path.join(templates_folder_path, template) - - replacements = {"ACTION": filename[:-3]} - - if template == "empty": - with open(file_path, "w") as f: - f.write("") # Empty content - return True - elif template == "action": - with open(file_path, "w") as f: - with open(template_path, "r") as temp: - for line in temp: - for src, target in replacements.items(): - line = line.replace(src, target) - f.write(line) - return True - elif template == "io": - with open(file_path, "w") as f: - with open(template_path, "r") as temp: - for line in temp: - for src, target in replacements.items(): - line = line.replace(src, target) - f.write(line) - return True - else: - return False + with open(template_path, "r") as temp: + file_data = temp.read() + new_data = file_data.replace("ACTION", filename) + return new_data + return "" diff --git a/backend/tree_api/tree_generator.py b/backend/tree_api/tree_generator.py index 6b1120abb..1c43abaa9 100644 --- a/backend/tree_api/tree_generator.py +++ b/backend/tree_api/tree_generator.py @@ -83,17 +83,20 @@ def get_subtree_set(tree, possible_subtrees) -> set: # Add the code of the different actions -def add_actions_code(tree, actions, action_path): +def add_actions_code_(tree, actions, all_actions): code_section = ET.SubElement(tree, "Code") - # Add each actiion code to the tree + # Add each action code to the tree for action_name in actions: + action_code = None + for action in all_actions: + if action_name == action["name"]: + action_code = action["content"] - # Get the action code - action_route = action_path + "/" + action_name + ".py" - action_file = open(action_route, "r") - action_code = action_file.read() + # Verify if action code exists + if action_code == None: + continue # Add a new subelement to the code_section action_section = ET.SubElement(code_section, action_name) @@ -101,45 +104,43 @@ def add_actions_code(tree, actions, action_path): # Replaces all the subtrees in a given tree depth -def replace_subtrees_in_tree(tree, subtrees, tree_path): - - for subtree_name in subtrees: - subtree_path = os.path.join(tree_path, f"{subtree_name}.xml") - if os.path.exists(subtree_path): - with open(subtree_path, "r") as sf: - subtree_xml = sf.read() - subtree_tree = ET.fromstring(subtree_xml) - - # Find the content inside the tag in the subtree - subtree_behavior_tree = subtree_tree.find(".//BehaviorTree") - if subtree_behavior_tree is not None: - # Locate tags in the main tree that refer to this subtree - subtree_parents = tree.findall(".//" + subtree_name + "/..") - for parent in subtree_parents: - subtree_tags = parent.findall(subtree_name) - for subtree_tag in subtree_tags: - # Get the index of the original subtree tag - index = list(parent).index(subtree_tag) - - # Insert new elements from the subtree at the correct position - for i, subtree_elem in enumerate(subtree_behavior_tree): - try: - parent.insert(index + i, subtree_elem) - except Exception as e: - print(str(e)) - # Remove the original subtree tag - parent.remove(subtree_tag) +def replace_subtrees_in_tree_(tree, subtrees): + + for subtree in subtrees: + subtree_name = subtree["name"] + subtree_xml = subtree["content"] + subtree_tree = ET.fromstring(subtree_xml) + + # Find the content inside the tag in the subtree + subtree_behavior_tree = subtree_tree.find(".//BehaviorTree") + if subtree_behavior_tree is not None: + # Locate tags in the main tree that refer to this subtree + subtree_parents = tree.findall(".//" + subtree_name + "/..") + for parent in subtree_parents: + subtree_tags = parent.findall(subtree_name) + for subtree_tag in subtree_tags: + # Get the index of the original subtree tag + index = list(parent).index(subtree_tag) + + # Insert new elements from the subtree at the correct position + for i, subtree_elem in enumerate(subtree_behavior_tree): + try: + parent.insert(index + i, subtree_elem) + except Exception as e: + print(str(e)) + # Remove the original subtree tag + parent.remove(subtree_tag) # Recursively replace all subtrees in a given tree -def replace_all_subtrees(tree, tree_path, depth=0, max_depth=15): +def replace_all_subtrees_(tree, all_subtrees, depth=0, max_depth=15): # Avoid infinite recursion if depth > max_depth: return # Get the subtrees that are present in the tree - possible_trees = [file.split(".")[0] for file in os.listdir(tree_path)] + possible_trees = [x["name"] for x in all_subtrees] subtrees = get_subtree_set(tree, possible_trees) # If no subtrees are found, stop the recursion @@ -147,32 +148,25 @@ def replace_all_subtrees(tree, tree_path, depth=0, max_depth=15): return # Replace subtrees in the main tree - replace_subtrees_in_tree(tree, subtrees, tree_path) + replace_subtrees_in_tree_(tree, all_subtrees) # Recursively call the function to replace subtrees in the newly added subtrees - replace_all_subtrees(tree, tree_path, depth + 1, max_depth) + replace_all_subtrees_(tree, all_subtrees, depth + 1, max_depth) -# Read the tree and the actions and generate a formatted tree string -def parse_tree(tree_path, action_path): - - # Get the tree main XML file and read its content - main_tree_path = os.path.join(tree_path, "main.xml") - f = open(main_tree_path, "r") - tree_xml = f.read() - +def parse_tree_(tree_xml, subtrees, all_actions): # Parse the tree file tree = get_bt_structure(tree_xml) # Obtain the defined subtrees recursively - replace_all_subtrees(tree, tree_path) + replace_all_subtrees_(tree, subtrees) # Obtain the defined actions - possible_actions = [file.split(".")[0] for file in os.listdir(action_path)] + possible_actions = [x["name"] for x in all_actions] actions = get_action_set(tree, possible_actions) # Add subsections for the action code - add_actions_code(tree, actions, action_path) + add_actions_code_(tree, actions, all_actions) # Serialize the modified XML to a properly formatted string formatted_tree = prettify_xml(tree) @@ -186,20 +180,10 @@ def parse_tree(tree_path, action_path): ############################################################################## -def generate(tree_path, action_path, result_path): - - # Ensure the provided tree and action paths exist - if not os.path.exists(tree_path): - raise FileNotFoundError(f"Tree path '{tree_path}' does not exist!") - if not os.path.exists(action_path): - raise FileNotFoundError(f"Action path '{action_path}' does not exist!") +def generate(tree_xml, subtrees, actions): # Get a formatted self-contained tree string - formatted_xml = parse_tree(tree_path, action_path) - - # Store the string in a temp xml file - with open(result_path, "w") as result_file: - result_file.write(formatted_xml) + formatted_xml = parse_tree_(tree_xml, subtrees, actions) - # Return the xml string for debugging purposes + # Return the xml string return formatted_xml diff --git a/backend/tree_api/urls.py b/backend/tree_api/urls.py index 38e2f9b92..71dce4e82 100644 --- a/backend/tree_api/urls.py +++ b/backend/tree_api/urls.py @@ -11,7 +11,7 @@ path("delete_universe/", views.delete_universe, name="delete_universe"), path("add_docker_universe/", views.add_docker_universe, name="add_docker_universe"), path("get_universes_list/", views.get_universes_list, name="get_universes_list"), - path("get_universe_zip/", views.get_universe_zip, name="generate_app"), + path("get_universe_zip/", views.get_universe_zip, name="get_universe_zip"), path( "get_universe_configuration/", views.get_universe_configuration, @@ -37,9 +37,9 @@ name="get_subtree_structure", ), path( - "save_base_tree_configuration/", - views.save_base_tree_configuration, - name="save_base_tree_configuration", + "save_project_configuration/", + views.save_project_configuration, + name="save_project_configuration", ), path("get_subtree_list/", views.get_subtree_list, name="get_subtree_list"), path("create_subtree/", views.create_subtree, name="create_subtree"), @@ -51,15 +51,16 @@ path("create_file/", views.create_file, name="create_file"), path("create_folder/", views.create_folder, name="create_folder"), path("rename_file/", views.rename_file, name="rename_file"), + path("rename_folder/", views.rename_folder, name="rename_folder"), path("delete_file/", views.delete_file, name="delete_file"), + path("delete_folder/", views.delete_folder, name="delete_folder"), path("save_file/", views.save_file, name="save_file"), path("upload_code/", views.upload_code, name="upload_code"), # Actions Management path("get_actions_list/", views.get_actions_list, name="get_actions_list"), path("create_action/", views.create_action, name="create_action"), # Other - path("translate_json/", views.translate_json, name="translate_json"), - path("generate_app/", views.generate_app, name="generate_app"), + path("generate_local_app/", views.generate_local_app, name="generate_local_app"), path( "generate_dockerized_app/", views.generate_dockerized_app, diff --git a/backend/tree_api/views.py b/backend/tree_api/views.py index 1c4afcd32..14004145b 100644 --- a/backend/tree_api/views.py +++ b/backend/tree_api/views.py @@ -25,10 +25,15 @@ # PROJECT MANAGEMENT -@api_view(["GET"]) +@api_view(["POST"]) def create_project(request): + if "project_name" not in request.data: + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) - project_name = request.GET.get("project_name") + project_name = request.data.get("project_name") folder_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(folder_path, project_name) action_path = os.path.join(project_path, "code/actions") @@ -77,9 +82,14 @@ def create_project(request): ) -@api_view(["GET"]) +@api_view(["POST"]) def delete_project(request): - project_name = request.GET.get("project_name") + if "project_name" not in request.data: + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + project_name = request.data.get("project_name") folder_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(folder_path, project_name) @@ -114,7 +124,11 @@ def get_project_list(request): @api_view(["POST"]) def save_base_tree(request): - + if "project_name" not in request.data or "graph_json" not in request.data: + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) # Get the app name and the graph project_name = request.data.get("project_name") graph_json = request.data.get("graph_json") @@ -263,16 +277,21 @@ def get_subtree_structure(request): @api_view(["POST"]) -def save_base_tree_configuration(request): +def save_project_configuration(request): + if "project_name" not in request.data or "settings" not in request.data: + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) project_name = request.data.get("project_name") + content = request.data.get("settings") folder_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(folder_path, project_name) config_path = os.path.join(project_path, "config.json") try: - content = request.data.get("settings") if content is None: return Response( {"success": False, "message": "Settings are missing"}, status=400 @@ -293,6 +312,11 @@ def save_base_tree_configuration(request): @api_view(["POST"]) def create_subtree(request): + if "project_name" not in request.data or "subtree_name" not in request.data: + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) project_name = request.data.get("project_name") subtree_name = request.data.get("subtree_name") @@ -390,9 +414,6 @@ def save_subtree(request): with open(json_path, "w") as f: f.write(subtree_json) - # TOFIX: order hardcoded - # json_translator.translate(subtree_json, xml_path, "top-to-bottom") - return JsonResponse({"success": True}, status=status.HTTP_200_OK) except Exception as e: @@ -475,10 +496,15 @@ def get_subtree_list(request): # UNIVERSE MANAGEMENT -@api_view(["GET"]) +@api_view(["POST"]) def delete_universe(request): - project_name = request.GET.get("project_name") - universe_name = request.GET.get("universe_name") + if "project_name" not in request.data or "universe_name" not in request.data: + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + project_name = request.data.get("project_name") + universe_name = request.data.get("universe_name") folder_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(folder_path, project_name) @@ -555,17 +581,6 @@ def get_universe_configuration(request): return Response({"success": False, "message": "File not found"}, status=404) -@api_view(["GET"]) -def import_universe_from_zip(request): - - project_name = request.GET.get("project_name") - zip_file = request.GET.get("zip_file") - - folder_path = os.path.join(settings.BASE_DIR, "filesystem") - project_path = os.path.join(folder_path, project_name) - universes_path = os.path.join(project_path, "universes/") - - # FILE MANAGEMENT @@ -640,24 +655,39 @@ def get_file(request): return Response({"error": "Filename parameter is missing"}, status=400) -@api_view(["GET"]) +@api_view(["POST"]) def create_action(request): - + if ( + "project_name" not in request.data + or "template" not in request.data + or "filename" not in request.data + ): + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) # Get the file info - project_name = request.GET.get("project_name", None) - filename = request.GET.get("filename", None) - template = request.GET.get("template", None) + project_name = request.data.get("project_name") + filename = request.data.get("filename") + template = request.data.get("template") # Make folder path relative to Django app folder_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(folder_path, project_name) action_path = os.path.join(project_path, "code/actions") - file_path = os.path.join(action_path, filename) + file_path = os.path.join(action_path, filename + ".py") + + templates_folder_path = os.path.join(settings.BASE_DIR, "templates") + template_path = os.path.join(templates_folder_path, template) if not os.path.exists(file_path): - if templates.create_action_from_template(file_path, filename, template): + try: + with open(file_path, "w") as f: + f.write( + templates.get_action_template(filename, template, template_path) + ) return Response({"success": True}) - else: + except: return Response( {"success": False, "message": "Template does not exist"}, status=400 ) @@ -667,13 +697,21 @@ def create_action(request): ) -@api_view(["GET"]) +@api_view(["POST"]) def create_file(request): - + if ( + "project_name" not in request.data + or "location" not in request.data + or "file_name" not in request.data + ): + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) # Get the file info - project_name = request.GET.get("project_name", None) - location = request.GET.get("location", None) - filename = request.GET.get("file_name", None) + project_name = request.data.get("project_name") + location = request.data.get("location") + filename = request.data.get("file_name") # Make folder path relative to Django app folder_path = os.path.join(settings.BASE_DIR, "filesystem") @@ -683,25 +721,30 @@ def create_file(request): file_path = os.path.join(create_path, filename) if not os.path.exists(file_path): - if templates.create_action_from_template(file_path, filename, "empty"): - return Response({"success": True}) - else: - return Response( - {"success": False, "message": "Template does not exist"}, status=400 - ) + with open(file_path, "w") as f: + f.write("") + return Response({"success": True}) else: return Response( {"success": False, "message": "File already exists"}, status=400 ) -@api_view(["GET"]) +@api_view(["POST"]) def create_folder(request): - + if ( + "project_name" not in request.data + or "location" not in request.data + or "folder_name" not in request.data + ): + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) # Get the file info - project_name = request.GET.get("project_name", None) - location = request.GET.get("location", None) - folder_name = request.GET.get("folder_name", None) + project_name = request.data.get("project_name") + location = request.data.get("location") + folder_name = request.data.get("folder_name") # Make folder path relative to Django app folder_path = os.path.join(settings.BASE_DIR, "filesystem") @@ -723,13 +766,21 @@ def create_folder(request): ) -@api_view(["GET"]) +@api_view(["POST"]) def rename_file(request): - + if ( + "project_name" not in request.data + or "path" not in request.data + or "rename_to" not in request.data + ): + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) # Get the file info - project_name = request.GET.get("project_name", None) - path = request.GET.get("path", None) - rename_path = request.GET.get("rename_to", None) + project_name = request.data.get("project_name") + path = request.data.get("path") + rename_path = request.data.get("rename_to") # Make folder path relative to Django app folder_path = os.path.join(settings.BASE_DIR, "filesystem") @@ -753,25 +804,32 @@ def rename_file(request): ) -@api_view(["GET"]) -def delete_file(request): - - # Get the file info - project_name = request.GET.get("project_name", None) - path = request.GET.get("path", None) +@api_view(["POST"]) +def rename_folder(request): + if ( + "project_name" not in request.data + or "path" not in request.data + or "rename_to" not in request.data + ): + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Get the folder info + project_name = request.data.get("project_name") + path = request.data.get("path") + rename_path = request.data.get("rename_to") # Make folder path relative to Django app folder_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(folder_path, project_name) action_path = os.path.join(project_path, "code") file_path = os.path.join(action_path, path) + new_path = os.path.join(action_path, rename_path) if os.path.exists(file_path): try: - if os.path.isdir(file_path): - shutil.rmtree(file_path) - else: - os.remove(file_path) + os.rename(file_path, new_path) return JsonResponse({"success": True}) except Exception as e: return JsonResponse( @@ -785,152 +843,144 @@ def delete_file(request): @api_view(["POST"]) -def save_file(request): +def delete_file(request): + if "project_name" not in request.data or "path" not in request.data: + return Response( + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Get the file info project_name = request.data.get("project_name") - filename = request.data.get("filename") - content = request.data.get("content") + path = request.data.get("path") + # Make folder path relative to Django app folder_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(folder_path, project_name) action_path = os.path.join(project_path, "code") - file_path = os.path.join(action_path, filename) + file_path = os.path.join(action_path, path) - # If file doesn't exist simply return - if not os.path.exists(file_path): + if os.path.exists(file_path) and not os.path.isdir(file_path): + try: + os.remove(file_path) + return JsonResponse({"success": True}) + except Exception as e: + return JsonResponse( + {"success": False, "message": f"Error deleting file: {str(e)}"}, + status=500, + ) + else: return JsonResponse( {"success": False, "message": "File does not exist"}, status=404 ) - try: - with open(file_path, "w") as f: - f.write(content) - return Response({"success": True}) - except Exception as e: - return Response({"success": False, "message": str(e)}, status=400) - - -@api_view(["POST"]) -def translate_json(request): - - folder_path = os.path.join(settings.BASE_DIR, "filesystem") - bt_order = request.data.get("bt_order") - - try: - content = request.data.get("content") - if content is None: - return Response( - {"success": False, "message": "Content is missing"}, status=400 - ) - - # Pass the JSON content to the translate function - json_translator.translate(content, folder_path + "/tree.xml", bt_order) - - return Response({"success": True}) - except Exception as e: - return Response({"success": False, "message": str(e)}, status=400) - @api_view(["POST"]) -def download_data(request): - - # Check if 'name' and 'zipfile' are in the request data - if "app_name" not in request.data or "path" not in request.data: +def delete_folder(request): + if "project_name" not in request.data or "path" not in request.data: return Response( - {"error": "Incorrect request parameters"}, + {"error": "Name and zip file are required."}, status=status.HTTP_400_BAD_REQUEST, ) - # Get the request parameters - app_name = request.data.get("app_name") + # Get the folder info + project_name = request.data.get("project_name") path = request.data.get("path") # Make folder path relative to Django app folder_path = os.path.join(settings.BASE_DIR, "filesystem") - project_path = os.path.join(folder_path, app_name) + project_path = os.path.join(folder_path, project_name) action_path = os.path.join(project_path, "code") file_path = os.path.join(action_path, path) - working_folder = "/tmp/wf" - - if app_name and path: + if os.path.exists(file_path) and os.path.isdir(file_path): try: - # 1. Create the working folder - if os.path.exists(working_folder): - shutil.rmtree(working_folder) - os.mkdir(working_folder) - - # 2. Copy files to temp folder - if os.path.isdir(file_path): - copy_tree(file_path, working_folder) - else: - shutil.copy(file_path, working_folder) - - # 5. Generate the zip - zip_path = working_folder + ".zip" - with zipfile.ZipFile(zip_path, "w") as zipf: - for root, dirs, files in os.walk(working_folder): - for file in files: - zipf.write( - os.path.join(root, file), - os.path.relpath(os.path.join(root, file), working_folder), - ) - - # 6. Return the zip - zip_file = open(zip_path, "rb") - mime_type, _ = mimetypes.guess_type(zip_path) - response = HttpResponse(zip_file, content_type=mime_type) - response["Content-Disposition"] = ( - f"attachment; filename={os.path.basename(zip_path)}" - ) - - return response + shutil.rmtree(file_path) + return JsonResponse({"success": True}) except Exception as e: - return Response({"success": False, "message": str(e)}, status=400) + return JsonResponse( + {"success": False, "message": f"Error deleting file: {str(e)}"}, + status=500, + ) else: - return Response({"error": "app_name parameter is missing"}, status=500) + return JsonResponse( + {"success": False, "message": "File does not exist"}, status=404 + ) @api_view(["POST"]) -def generate_app(request): - +def save_file(request): if ( - "app_name" not in request.data - or "tree_graph" not in request.data - or "bt_order" not in request.data + "project_name" not in request.data + or "filename" not in request.data + or "content" not in request.data ): return Response( - {"error": "Incorrect request parameters"}, + {"error": "Name and zip file are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project_name = request.data.get("project_name") + filename = request.data.get("filename") + content = request.data.get("content") + + folder_path = os.path.join(settings.BASE_DIR, "filesystem") + project_path = os.path.join(folder_path, project_name) + action_path = os.path.join(project_path, "code") + file_path = os.path.join(action_path, filename) + + # If file doesn't exist simply return + if not os.path.exists(file_path): + return JsonResponse( + {"success": False, "message": "File does not exist"}, status=404 + ) + + try: + with open(file_path, "w") as f: + f.write(content) + return Response({"success": True}) + except Exception as e: + return Response({"success": False, "message": str(e)}, status=400) + + +@api_view(["POST"]) +def generate_local_app(request): + # Check if 'app_name', 'main_tree_graph', and 'bt_order' are in the request data + if "app_name" not in request.data or "bt_order" not in request.data: + return Response( + {"success": False, "message": "Missing required parameters"}, status=status.HTTP_400_BAD_REQUEST, ) - # Get the parameters + # Get the request parameters app_name = request.data.get("app_name") - main_tree_graph = request.data.get("tree_graph") bt_order = request.data.get("bt_order") - print("bt order: ", bt_order) - # Make folder path relative to Django app + # Make folder path relative to Django app base_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(base_path, app_name) action_path = os.path.join(project_path, "code/actions") - subtree_path = os.path.join(project_path, "code/trees/subtrees") - result_trees_tmp_path = os.path.join("/tmp/trees/") - self_contained_tree_path = os.path.join("/tmp/self_contained_tree.xml") - template_path = os.path.join(settings.BASE_DIR, "ros_template") - tree_gardener_src = os.path.join(settings.BASE_DIR, "tree_gardener") + tree_path = os.path.join(project_path, "code/trees/main.json") + subtree_path = os.path.join(project_path, "code/trees/subtrees/json") + + subtrees = [] + actions = [] try: - # Init the trees temp folder - if os.path.exists(result_trees_tmp_path): - shutil.rmtree(result_trees_tmp_path) - os.makedirs(result_trees_tmp_path) - # Translate the received JSON - main_tree_tmp_path = os.path.join(result_trees_tmp_path, "main.xml") - json_translator.translate(main_tree_graph, main_tree_tmp_path, bt_order) + # Check if the project exists + if os.path.exists(tree_path): + with open(tree_path, "r") as f: + graph_data = f.read() + else: + return Response( + {"success": False, "message": "Main tree not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # 1. Generate a basic tree from the JSON definition + main_tree = json_translator.translate_raw(graph_data, bt_order) - # Copy all the subtrees to the temp folder + # 2. Get all possible subtrees name and content try: for subtree_file in os.listdir(subtree_path): if subtree_file.endswith(".json"): @@ -939,53 +989,36 @@ def generate_app(request): )[0] print(os.path.join(subtree_path, subtree_file)) - xml_path = os.path.join( - project_path, "code", "trees", "subtrees", f"{subtree_name}.xml" - ) - with open(os.path.join(subtree_path, subtree_file), "r+") as f: # Reading from a file subtree_json = f.read() - json_translator.translate(subtree_json, xml_path, bt_order) - - shutil.copy(xml_path, result_trees_tmp_path) + subtree = json_translator.translate_raw(subtree_json, bt_order) + subtrees.append({"name": subtree_name, "content": subtree}) except: print("No subtrees") - # Generate a self-contained tree - tree_generator.generate( - result_trees_tmp_path, action_path, self_contained_tree_path - ) + # 3. Get all possible actions name and content + for action_file in os.listdir(action_path): + if action_file.endswith(".py"): + action_name = base = os.path.splitext(os.path.basename(action_file))[0] - # Using the self-contained tree, package the ROS 2 app - zip_file_path = app_generator.generate( - self_contained_tree_path, - app_name, - template_path, - action_path, - tree_gardener_src, - ) + with open(os.path.join(action_path, action_file), "r+") as f: + action_content = f.read() - # Confirm ZIP file exists - if not os.path.exists(zip_file_path): - return Response( - {"success": False, "message": "ZIP file not found"}, status=400 - ) + actions.append({"name": action_name, "content": action_content}) - # Return the zip file as a response - with open(zip_file_path, "rb") as zip_file: - response = HttpResponse(zip_file, content_type="application/zip") - response["Content-Disposition"] = ( - f"attachment; filename={os.path.basename(zip_file_path)}" - ) - return response + # 4. Generate a self-contained tree + final_tree = tree_generator.generate(main_tree, subtrees, actions) - return response + unique_imports = app_generator.get_unique_imports(actions) + + return JsonResponse( + {"success": True, "tree": final_tree, "dependencies": unique_imports} + ) except Exception as e: print(e) - # Also print the traceback import traceback traceback.print_exc() @@ -997,51 +1030,42 @@ def generate_app(request): @api_view(["POST"]) def generate_dockerized_app(request): - - if ( - "app_name" not in request.data - or "tree_graph" not in request.data - or "bt_order" not in request.data - ): + # Check if 'app_name', 'tree_graph', and 'bt_order' are in the request data + if "app_name" not in request.data or "bt_order" not in request.data: return Response( - {"error": "Incorrect request parameters"}, + {"success": False, "message": "Missing required parameters"}, status=status.HTTP_400_BAD_REQUEST, ) # Get the request parameters app_name = request.data.get("app_name") - main_tree_graph = request.data.get("tree_graph") bt_order = request.data.get("bt_order") - print("Dockerized bt order: ", bt_order) # Make folder path relative to Django app base_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(base_path, app_name) action_path = os.path.join(project_path, "code/actions") - - working_folder = "/tmp/wf" + tree_path = os.path.join(project_path, "code/trees/main.json") subtree_path = os.path.join(project_path, "code/trees/subtrees/json") - result_trees_tmp_path = os.path.join("/tmp/trees/") - self_contained_tree_path = os.path.join(working_folder, "self_contained_tree.xml") - tree_gardener_src = os.path.join(settings.BASE_DIR, "tree_gardener") - template_path = os.path.join(settings.BASE_DIR, "ros_template") - try: - # Init the trees temp folder - if os.path.exists(result_trees_tmp_path): - shutil.rmtree(result_trees_tmp_path) - os.makedirs(result_trees_tmp_path) + subtrees = [] + actions = [] - # 1. Create the working folder - if os.path.exists(working_folder): - shutil.rmtree(working_folder) - os.mkdir(working_folder) + try: - # 2. Generate a basic tree from the JSON definition - main_tree_tmp_path = os.path.join(result_trees_tmp_path, "main.xml") - json_translator.translate(main_tree_graph, main_tree_tmp_path, bt_order) + # Check if the project exists + if os.path.exists(tree_path): + with open(tree_path, "r") as f: + graph_data = f.read() + else: + return Response( + {"success": False, "message": "Main tree not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # 1. Generate a basic tree from the JSON definition + main_tree = json_translator.translate_raw(graph_data, bt_order) - # 3. Copy all the subtrees to the temp folder + # 2. Get all possible subtrees name and content try: for subtree_file in os.listdir(subtree_path): if subtree_file.endswith(".json"): @@ -1050,50 +1074,30 @@ def generate_dockerized_app(request): )[0] print(os.path.join(subtree_path, subtree_file)) - xml_path = os.path.join( - project_path, "code", "trees", "subtrees", f"{subtree_name}.xml" - ) - with open(os.path.join(subtree_path, subtree_file), "r+") as f: # Reading from a file subtree_json = f.read() - json_translator.translate(subtree_json, xml_path, bt_order) - - shutil.copy(xml_path, result_trees_tmp_path) + subtree = json_translator.translate_raw(subtree_json, bt_order) + subtrees.append({"name": subtree_name, "content": subtree}) except: print("No subtrees") - # 4. Generate a self-contained tree - tree_generator.generate( - result_trees_tmp_path, action_path, self_contained_tree_path - ) + # 3. Get all possible actions name and content + for action_file in os.listdir(action_path): + if action_file.endswith(".py"): + action_name = base = os.path.splitext(os.path.basename(action_file))[0] - # 5. Copy necessary files to execute the app in the RB - factory_location = tree_gardener_src + "/tree_gardener/tree_factory.py" - tools_location = tree_gardener_src + "/tree_gardener/tree_tools.py" - entrypoint_location = template_path + "/ros_template/execute_docker.py" - shutil.copy(factory_location, working_folder) - shutil.copy(tools_location, working_folder) - shutil.copy(entrypoint_location, working_folder) + with open(os.path.join(action_path, action_file), "r+") as f: + action_content = f.read() - # 6. Generate the zip - zip_path = working_folder + ".zip" - with zipfile.ZipFile(zip_path, "w") as zipf: - for root, dirs, files in os.walk(working_folder): - for file in files: - zipf.write( - os.path.join(root, file), - os.path.relpath(os.path.join(root, file), working_folder), - ) + actions.append({"name": action_name, "content": action_content}) - # 6. Return the zip file as a response - with open(zip_path, "rb") as zip_file: - response = HttpResponse(zip_file, content_type="application/zip") - response["Content-Disposition"] = ( - f"attachment; filename={os.path.basename(zip_path)}" - ) - return response + # 4. Generate a self-contained tree + final_tree = tree_generator.generate(main_tree, subtrees, actions) + + # 6. Return the files as a response + return JsonResponse({"success": True, "tree": final_tree}) except Exception as e: print(e) @@ -1177,9 +1181,9 @@ def upload_universe(request): ) # Get the name and the zip file from the request - universe_name = request.data["universe_name"] - app_name = request.data["app_name"] - zip_file = request.data["zip_file"] + universe_name = request.data.get("universe_name") + app_name = request.data.get("app_name") + zip_file = request.data.get("zip_file") # Make folder path relative to Django app base_path = os.path.join(settings.BASE_DIR, "filesystem") @@ -1242,7 +1246,7 @@ def upload_universe(request): @api_view(["POST"]) def add_docker_universe(request): - # Check if 'name' and 'zipfile' are in the request data + # Check if 'universe_name', 'app_name' and 'id' are in the request data if ( "universe_name" not in request.data or "app_name" not in request.data @@ -1253,10 +1257,10 @@ def add_docker_universe(request): status=status.HTTP_400_BAD_REQUEST, ) - # Get the name and the zip file from the request - universe_name = request.data["universe_name"] - app_name = request.data["app_name"] - id = request.data["id"] + # Get the name and the id file from the request + universe_name = request.data.get("universe_name") + app_name = request.data.get("app_name") + id = request.data.get("id") # Make folder path relative to Django app base_path = os.path.join(settings.BASE_DIR, "filesystem") @@ -1269,7 +1273,6 @@ def add_docker_universe(request): os.makedirs(universe_path) # Fill the config dictionary of the universe - ram_launch_path = "/workspace/worlds/" + universe_name + "/universe.launch.py" universe_config = {"name": universe_name, "id": id, "type": "robotics_backend"} # Generate the json config @@ -1289,8 +1292,9 @@ def upload_code(request): # Check if 'name' and 'zipfile' are in the request data if ( "project_name" not in request.data + or "file_name" not in request.data or "location" not in request.data - or "zip_file" not in request.data + or "content" not in request.data ): return Response( {"error": "Name and zip file are required."}, @@ -1298,40 +1302,30 @@ def upload_code(request): ) # Get the name and the zip file from the request - project_name = request.data["project_name"] - location = request.data["location"] - zip_file = request.data["zip_file"] + project_name = request.data.get("project_name") + file_name = request.data.get("file_name") + location = request.data.get("location") + content = request.data.get("content") # Make folder path relative to Django app base_path = os.path.join(settings.BASE_DIR, "filesystem") project_path = os.path.join(base_path, project_name) code_path = os.path.join(project_path, "code") create_path = os.path.join(code_path, location) + file_path = os.path.join(create_path, file_name) - try: - zip_file_data = base64.b64decode(zip_file) - except (TypeError, ValueError): - return Response( - {"error": "Invalid zip file data."}, status=status.HTTP_400_BAD_REQUEST + # If file exist simply return + if os.path.exists(file_path): + return JsonResponse( + {"success": False, "message": "File already exists"}, status=404 ) - # Save the zip file temporarily - temp_zip_path = os.path.join(create_path, "temp.zip") - with open(temp_zip_path, "wb") as temp_zip_file: - temp_zip_file.write(zip_file_data) - - # Unzip the file try: - with zipfile.ZipFile(temp_zip_path, "r") as zip_ref: - zip_ref.extractall(create_path) - except zipfile.BadZipFile: - return Response( - {"error": "Invalid zip file."}, status=status.HTTP_400_BAD_REQUEST - ) - finally: - # Clean up the temporary zip file - if os.path.exists(temp_zip_path): - os.remove(temp_zip_path) + with open(file_path, "wb") as f: + f.write(base64.b64decode(content)) + return Response({"success": True}) + except Exception as e: + return Response({"success": False, "message": str(e)}, status=400) @api_view(["GET"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e0fe6bce..fac959b6d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,12 +21,15 @@ const App = ({ isUnibotics }: { isUnibotics: boolean }) => { const [fileBrowserWidth, setFileBrowserWidth] = useState(300); const [editorWidth, setEditorWidth] = useState(800); const [currentFilename, setCurrentFilename] = useState(""); + const [autosaveEnabled, setAutosave] = useState(true); + const [forceSaveCurrent, setForcedSaveCurrent] = useState(false); const [currentProjectname, setCurrentProjectname] = useState(""); const [currentUniverseName, setCurrentUniverseName] = useState(""); const [actionNodesData, setActionNodesData] = useState>( {}, ); - const [modelJson, setModelJson] = useState(""); + const [saveCurrentDiagram, setSaveCurrentDiagram] = useState(false); + const [updateFileExplorer, setUpdateFileExplorer] = useState(false); const [isErrorModalOpen, setErrorModalOpen] = useState(false); const [projectChanges, setProjectChanges] = useState(false); const [gazeboEnabled, setGazeboEnabled] = useState(false); @@ -156,12 +159,11 @@ const App = ({ isUnibotics }: { isUnibotics: boolean }) => { setCurrentProjectname={setCurrentProjectname} currentUniverseName={currentUniverseName} setCurrentUniverseName={setCurrentUniverseName} - modelJson={modelJson} + setSaveCurrentDiagram={setSaveCurrentDiagram} projectChanges={projectChanges} setProjectChanges={setProjectChanges} gazeboEnabled={gazeboEnabled} setGazeboEnabled={setGazeboEnabled} - // onSetShowExecStatus={onSetShowExecStatus} manager={manager} showVNCViewer={showVNCViewer} isUnibotics={isUnibotics} @@ -191,6 +193,14 @@ const App = ({ isUnibotics }: { isUnibotics: boolean }) => { actionNodesData={actionNodesData} showAccentColor={"editorShowAccentColors"} diagramEditorReady={diagramEditorReady} + setAutosave={setAutosave} + forceSaveCurrent={forceSaveCurrent} + setForcedSaveCurrent={setForcedSaveCurrent} + forceUpdate={{ + value: updateFileExplorer, + callback: setUpdateFileExplorer, + }} + setSaveCurrentDiagram={setSaveCurrentDiagram} /> @@ -217,6 +227,9 @@ const App = ({ isUnibotics }: { isUnibotics: boolean }) => { currentProjectname={currentProjectname} setProjectChanges={setProjectChanges} isUnibotics={isUnibotics} + autosaveEnabled={autosaveEnabled} + setAutosave={setAutosave} + forceSaveCurrent={forceSaveCurrent} /> {showTerminal && } @@ -236,8 +249,9 @@ const App = ({ isUnibotics }: { isUnibotics: boolean }) => { ) : (

Loading...

diff --git a/frontend/src/api_helper/TreeWrapper.ts b/frontend/src/api_helper/TreeWrapper.ts index d33501fac..206f6c505 100644 --- a/frontend/src/api_helper/TreeWrapper.ts +++ b/frontend/src/api_helper/TreeWrapper.ts @@ -7,6 +7,13 @@ const isSuccessful = (response: AxiosResponse) => { return response.status >= 200 && response.status < 300; }; +const axiosExtra = { + headers: { + //@ts-ignore Needed for compatibility with Unibotics + "X-CSRFToken": context.csrf, + }, +}; + // File management const getFileList = async (projectName: string) => { @@ -28,6 +35,26 @@ const getFileList = async (projectName: string) => { } }; +const getFile = async (projectName: string, fileName: string) => { + if (!projectName) throw new Error("Project name is not set"); + if (!fileName) throw new Error("File name is not set"); + + const apiUrl = `/bt_studio/get_file?project_name=${encodeURIComponent(projectName)}&filename=${encodeURIComponent(fileName)}`; + + try { + const response = await axios.get(apiUrl); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to get file list."); // Response error + } + + return response.data.content; + } catch (error: unknown) { + throw error; // Rethrow + } +}; + const getActionsList = async (projectName: string) => { if (!projectName) throw new Error("Project name is not set"); @@ -47,6 +74,37 @@ const getActionsList = async (projectName: string) => { } }; +const saveFile = async ( + projectName: string, + fileName: string, + content: string +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!fileName) throw new Error("Current File name is not set"); + if (!content) throw new Error("Content does not exist"); + + const apiUrl = "/bt_studio/save_file/"; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + filename: fileName, + content: content, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to create project."); // Response error + } + } catch (error) { + throw error; // Rethrow + } +}; + // Project management const createProject = async (projectName: string) => { @@ -54,10 +112,16 @@ const createProject = async (projectName: string) => { throw new Error("Project name cannot be empty."); } - const apiUrl = `/bt_studio/create_project?project_name=${encodeURIComponent(projectName)}`; + const apiUrl = `/bt_studio/create_project/`; try { - const response = await axios.get(apiUrl); + const response = await axios.post( + apiUrl, + { + project_name: projectName, + }, + axiosExtra + ); // Handle unsuccessful response status (e.g., non-2xx status) if (!isSuccessful(response)) { @@ -68,6 +132,31 @@ const createProject = async (projectName: string) => { } }; +const deleteProject = async (projectName: string) => { + if (!projectName.trim()) { + throw new Error("Project name cannot be empty."); + } + + const apiUrl = `/bt_studio/delete_project/`; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to delete project."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + const saveBaseTree = async (modelJson: string, currentProjectname: string) => { if (!modelJson) throw new Error("Tree JSON is empty!"); if (!currentProjectname) throw new Error("Current Project name is not set"); @@ -80,12 +169,7 @@ const saveBaseTree = async (modelJson: string, currentProjectname: string) => { project_name: currentProjectname, graph_json: JSON.stringify(modelJson), }, - { - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - } + axiosExtra ); // Handle unsuccessful response status (e.g., non-2xx status) @@ -134,6 +218,33 @@ const loadProjectConfig = async ( } }; +const saveProjectConfig = async ( + currentProjectname: string, + settings: string +) => { + if (!currentProjectname) throw new Error("Current Project name is not set"); + if (!settings) throw new Error("Settings content is null"); + + const apiUrl = "/bt_studio/save_project_configuration/"; + try { + const response = await axios.post( + apiUrl, + { + project_name: currentProjectname, + settings: settings, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to create project."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + const getProjectGraph = async (currentProjectname: string) => { if (!currentProjectname) throw new Error("Current Project name is not set"); @@ -220,12 +331,35 @@ const createRoboticsBackendUniverse = async ( universe_name: universeName, id: universeId, }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to save subtree."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const deleteUniverse = async ( + projectName: string, + universeName: string, +) => { + if (!projectName) throw new Error("The project name is not set"); + if (!universeName) throw new Error("The universe name is not set"); + + const apiUrl = "/bt_studio/delete_universe/"; + + try { + const response = await axios.post( + apiUrl, { - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - } + project_name: projectName, + universe_name: universeName, + }, + axiosExtra ); // Handle unsuccessful response status (e.g., non-2xx status) @@ -253,13 +387,7 @@ const getCustomUniverseZip = async ( app_name: currentProjectname, universe_name: universeName, }, - { - responseType: "blob", // Ensure the response is treated as a Blob - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - } + axiosExtra ); // Handle unsuccessful response status (e.g., non-2xx status) @@ -276,33 +404,22 @@ const getCustomUniverseZip = async ( // App management -const generateApp = async ( - modelJson: Object, +const generateLocalApp = async ( currentProjectname: string, btOrder: string ) => { - if (!modelJson) throw new Error("Tree JSON is empty!"); if (!currentProjectname) throw new Error("Current Project name is not set"); if (!btOrder) throw new Error("Behavior Tree order is not set"); - console.log("The modelJson is: ", modelJson); - - const apiUrl = "/bt_studio/generate_app/"; + const apiUrl = `/bt_studio/generate_local_app/`; try { const response = await axios.post( apiUrl, { app_name: currentProjectname, - tree_graph: JSON.stringify(modelJson), bt_order: btOrder, }, - { - responseType: "blob", // Ensure the response is treated as a Blob - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - } + axiosExtra ); // Handle unsuccessful response status (e.g., non-2xx status) @@ -317,30 +434,21 @@ const generateApp = async ( }; const generateDockerizedApp = async ( - modelJson: Object, currentProjectname: string, btOrder: string ) => { - if (!modelJson) throw new Error("Tree JSON is empty!"); if (!currentProjectname) throw new Error("Current Project name is not set"); if (!btOrder) throw new Error("Behavior Tree order is not set"); - const apiUrl = "/bt_studio/generate_dockerized_app/"; + const apiUrl = `/bt_studio/generate_dockerized_app/`; try { const response = await axios.post( apiUrl, { app_name: currentProjectname, - tree_graph: JSON.stringify(modelJson), bt_order: btOrder, }, - { - responseType: "blob", // Ensure the response is treated as a Blob - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - } + axiosExtra ); // Handle unsuccessful response status (e.g., non-2xx status) @@ -376,12 +484,7 @@ const createSubtree = async ( project_name: currentProjectname, subtree_name: subtreeName, }, - { - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - } + axiosExtra ); // Handle unsuccessful response status (e.g., non-2xx status) @@ -451,12 +554,7 @@ const saveSubtree = async ( subtree_name: subtreeName, subtree_json: JSON.stringify(modelJson), }, - { - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - } + axiosExtra ); // Handle unsuccessful response status (e.g., non-2xx status) @@ -468,22 +566,412 @@ const saveSubtree = async ( } }; +const uploadFile = async ( + projectName: string, + fileName: string, + location: string, + content: string +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!fileName) throw new Error("File name is not set"); + if (!location) throw new Error("Location is not set"); + if (!content) throw new Error("Content is not defined"); + + const apiUrl = "/bt_studio/upload_code/"; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + file_name: fileName, + location: location, + content: content, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to upload file."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const createAction = async ( + projectName: string, + fileName: string, + template: string, +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!fileName) throw new Error("File name is not set"); + if (!template) throw new Error("Template is not set"); + + const apiUrl = "/bt_studio/create_action/"; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + filename: fileName, + template: template, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to upload file."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const createFile = async ( + projectName: string, + fileName: string, + location: string, +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!fileName) throw new Error("File name is not set"); + if (!location) throw new Error("Location is not set"); + + const apiUrl = "/bt_studio/create_file/"; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + location: location, + filename: fileName, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to upload file."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const createFolder = async ( + projectName: string, + folderName: string, + location: string, +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!folderName) throw new Error("Folder name is not set"); + if (!location) throw new Error("Location is not set"); + + const apiUrl = "/bt_studio/create_folder/"; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + location: location, + folder_name: folderName, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to upload file."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const renameFile = async ( + projectName: string, + path: string, + new_path: string, +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!path) throw new Error("Path is not set"); + if (!new_path) throw new Error("New path is not set"); + + const apiUrl = "/bt_studio/rename_file/"; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + path: path, + rename_to: new_path, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to upload file."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const renameFolder = async ( + projectName: string, + path: string, + new_path: string, +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!path) throw new Error("Path is not set"); + if (!new_path) throw new Error("New path is not set"); + + const apiUrl = "/bt_studio/rename_folder/"; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + path: path, + rename_to: new_path, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to upload file."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const deleteFile = async ( + projectName: string, + path: string, +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!path) throw new Error("Path is not set"); + + const apiUrl = "/bt_studio/delete_file/"; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + path: path, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to upload file."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const deleteFolder = async ( + projectName: string, + path: string, +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!path) throw new Error("Path is not set"); + + const apiUrl = "/bt_studio/delete_folder/"; + + try { + const response = await axios.post( + apiUrl, + { + project_name: projectName, + path: path, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to upload file."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const uploadUniverse = async ( + projectName: string, + universeName: string, + uploadedUniverse: any, +) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!universeName) throw new Error("Universe name is not set"); + if (!uploadedUniverse) throw new Error("Content is not set"); + + const apiUrl = "/bt_studio/upload_universe/"; + + try { + const response = await axios.post( + apiUrl, + { + universe_name: universeName, + zip_file: uploadedUniverse, + app_name: projectName, + }, + axiosExtra + ); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to upload file."); // Response error + } + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const listDockerUniverses = async () => { + const apiUrl = `/bt_studio/list_docker_universes`; + + try { + const response = await axios.get(apiUrl); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to get subtree."); // Response error + } + + return response.data.universes; + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const listProjects = async () => { + const apiUrl = `/bt_studio/get_project_list`; + + try { + const response = await axios.get(apiUrl); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to get subtree."); // Response error + } + + return response.data.project_list; + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const listUniverses = async (projectName:string ) => { + if (!projectName) throw new Error("Current Project name is not set"); + + const apiUrl = `/bt_studio/get_universes_list?project_name=${encodeURIComponent(projectName)}`; + + try { + const response = await axios.get(apiUrl); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to get subtree."); // Response error + } + + return response.data.universes_list; + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const getTreeStructure = async (projectName:string, btOrder: string) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!btOrder) throw new Error("Behavior Tree order is not set"); + + const apiUrl = `/bt_studio/get_tree_structure?project_name=${encodeURIComponent(projectName)}&bt_order=${encodeURIComponent(btOrder)}`; + + try { + const response = await axios.get(apiUrl); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to get subtree."); // Response error + } + + return response.data.tree_structure; + } catch (error: unknown) { + throw error; // Rethrow + } +}; + +const getSubtreeStructure = async (projectName:string, subtreeName:string, btOrder: string) => { + if (!projectName) throw new Error("Current Project name is not set"); + if (!subtreeName) throw new Error("Subtree name is not set"); + if (!btOrder) throw new Error("Behavior Tree order is not set"); + + const apiUrl = `/bt_studio/get_subtree_structure?project_name=${encodeURIComponent(projectName)}&subtree_name=${encodeURIComponent(subtreeName)}&bt_order=${encodeURIComponent(btOrder)}`; + + try { + const response = await axios.get(apiUrl); + + // Handle unsuccessful response status (e.g., non-2xx status) + if (!isSuccessful(response)) { + throw new Error(response.data.message || "Failed to get subtree."); // Response error + } + + return response.data.tree_structure; + } catch (error: unknown) { + throw error; // Rethrow + } +}; + + // Named export export { + createAction, + createFile, + createFolder, createProject, - saveBaseTree, - loadProjectConfig, - getProjectGraph, - generateApp, + createRoboticsBackendUniverse, + createSubtree, + deleteFile, + deleteFolder, + deleteProject, + deleteUniverse, generateDockerizedApp, - getUniverseConfig, - getRoboticsBackendUniversePath, + generateLocalApp, + getActionsList, getCustomUniverseZip, - createSubtree, - getSubtreeList, - getSubtree, + getFile, getFileList, - getActionsList, + getProjectGraph, + getRoboticsBackendUniversePath, + getSubtree, + getSubtreeList, + getSubtreeStructure, + getTreeStructure, + getUniverseConfig, + listDockerUniverses, + listProjects, + listUniverses, + loadProjectConfig, + renameFile, + renameFolder, + saveBaseTree, + saveFile, + saveProjectConfig, saveSubtree, - createRoboticsBackendUniverse, + uploadFile, + uploadUniverse, }; diff --git a/frontend/src/components/file_browser/FileBrowser.js b/frontend/src/components/file_browser/FileBrowser.js index 9691333ed..8493066ae 100644 --- a/frontend/src/components/file_browser/FileBrowser.js +++ b/frontend/src/components/file_browser/FileBrowser.js @@ -1,13 +1,25 @@ import React, { useEffect, useState } from "react"; -import axios from "axios"; +import JSZip from "jszip"; import "./FileBrowser.css"; import NewFileModal from "./modals/NewFileModal.jsx"; import RenameModal from "./modals/RenameModal.jsx"; -import NewFolderModal from "./modals/NewFolderModal.jsx"; +import NewFolderModal from "./modals/NewFolderModal"; import UploadModal from "./modals/UploadModal.tsx"; import DeleteModal from "./modals/DeleteModal.jsx"; import FileExplorer from "./file_explorer/FileExplorer.jsx"; +import { + getFile, + getFileList, + createAction, + createFile, + createFolder, + renameFile, + renameFolder, + deleteFile, + deleteFolder, +} from "./../../api_helper/TreeWrapper"; + import { ReactComponent as AddIcon } from "./img/add.svg"; import { ReactComponent as AddFolderIcon } from "./img/add_folder.svg"; import { ReactComponent as DeleteIcon } from "./img/delete.svg"; @@ -20,7 +32,7 @@ function getParentDir(file) { return file.path; } - var split_path = file.path.split("/"); // TODO: add for windows + var split_path = file.path.split("/"); return split_path.slice(0, split_path.length - 1).join("/"); } @@ -32,6 +44,11 @@ const FileBrowser = ({ actionNodesData, showAccentColor, diagramEditorReady, + setAutosave, + forceSaveCurrent, + setForcedSaveCurrent, + forceUpdate, + setSaveCurrentDiagram, }) => { const [fileList, setFileList] = useState(null); const [isNewFileModalOpen, setNewFileModalOpen] = useState(false); @@ -41,6 +58,7 @@ const FileBrowser = ({ const [isUploadModalOpen, setUploadModalOpen] = useState(false); const [selectedEntry, setSelectedEntry] = useState(null); const [deleteEntry, setDeleteEntry] = useState(null); + const [deleteType, setDeleteType] = useState(false); const [renameEntry, setRenameEntry] = useState(null); const [selectedLocation, setSelectedLocation] = useState(""); @@ -48,6 +66,13 @@ const FileBrowser = ({ updateSelectedLocation(null); }, [selectedEntry]); + useEffect(() => { + if (forceUpdate.value) { + forceUpdate.callback(false); + fetchFileList(); + } + }, [forceUpdate.value]); + const updateSelectedLocation = (file) => { if (file) { setSelectedLocation(getParentDir(file)); @@ -64,15 +89,9 @@ const FileBrowser = ({ console.log("Fecthing file list, the project name is:", currentProjectname); if (currentProjectname !== "") { try { - const response = await axios.get( - `/bt_studio/get_file_list?project_name=${currentProjectname}`, - ); - const files = JSON.parse(response.data.file_list); + const file_list = await getFileList(currentProjectname); + const files = JSON.parse(file_list); setFileList(files); - // if (Array.isArray(files)) { - // } else { - // console.error("API response is not an array:", files); - // } } catch (error) { console.error("Error fetching files:", error); } @@ -84,6 +103,7 @@ const FileBrowser = ({ const handleCreateFile = (file) => { updateSelectedLocation(file); setNewFileModalOpen(true); + setSaveCurrentDiagram(true); }; const handleCloseNewFileModal = () => { @@ -99,22 +119,18 @@ const FileBrowser = ({ let response; switch (data.fileType) { case "actions": - response = await axios.get( - `/bt_studio/create_action?project_name=${currentProjectname}&filename=${data.fileName}.py&template=${data.templateType}`, + await createAction( + currentProjectname, + data.fileName, + data.templateType, ); break; default: - response = await axios.get( - `/bt_studio/create_file?project_name=${currentProjectname}&location=${location}&file_name=${data.fileName}`, - ); + await createFile(currentProjectname, data.fileName, location); break; } - if (response.data.success) { - setProjectChanges(true); - fetchFileList(); // Update the file list - } else { - alert(response.data.message); - } + setProjectChanges(true); + fetchFileList(); // Update the file list } catch (error) { console.error("Error creating file:", error); } @@ -123,10 +139,12 @@ const FileBrowser = ({ ///////////////// DELETE FILES AND FOLDERS /////////////////////////////////// - const handleDeleteModal = (file_path) => { + const handleDeleteModal = (file_path, is_dir) => { if (file_path) { setDeleteEntry(file_path); + setDeleteType(is_dir); setDeleteModalOpen(true); + setSaveCurrentDiagram(true); } else { alert("No file is currently selected."); } @@ -135,26 +153,27 @@ const FileBrowser = ({ const handleCloseDeleteModal = () => { setDeleteModalOpen(false); setDeleteEntry(""); + setDeleteType(false); }; const handleSubmitDeleteModal = async () => { //currentFilename === Absolute File path if (deleteEntry) { try { - const response = await axios.get( - `/bt_studio/delete_file?project_name=${currentProjectname}&path=${deleteEntry}`, - ); - if (response.data.success) { - setProjectChanges(true); - fetchFileList(); // Update the file list - if (currentFilename === deleteEntry) { - setCurrentFilename(""); // Unset the current file - } - if (selectedEntry.path === deleteEntry) { - setSelectedEntry(null); - } + if (deleteType) { + await deleteFolder(currentProjectname, deleteEntry); } else { - alert(response.data.message); + await deleteFile(currentProjectname, deleteEntry); + } + + setProjectChanges(true); + fetchFileList(); // Update the file list + + if (currentFilename === deleteEntry) { + setCurrentFilename(""); // Unset the current file + } + if (selectedEntry.path === deleteEntry) { + setSelectedEntry(null); } } catch (error) { console.error("Error deleting file:", error); @@ -168,7 +187,8 @@ const FileBrowser = ({ const handleDeleteCurrentFile = () => { //currentFilename === Absolute File path if (currentFilename) { - handleDeleteModal(currentFilename); + handleDeleteModal(currentFilename, false); + setAutosave(false); } else { alert("No file is currently selected."); } @@ -179,6 +199,7 @@ const FileBrowser = ({ const handleCreateFolder = (file) => { updateSelectedLocation(file); setNewFolderModalOpen(true); + setSaveCurrentDiagram(true); }; const handleCloseCreateFolder = () => { @@ -189,15 +210,9 @@ const FileBrowser = ({ const handleCreateFolderSubmit = async (location, folder_name) => { if (folder_name !== "") { try { - const response = await axios.get( - `/bt_studio/create_folder?project_name=${currentProjectname}&location=${location}&folder_name=${folder_name}`, - ); - if (response.data.success) { - setProjectChanges(true); - fetchFileList(); // Update the file list - } else { - alert(response.data.message); - } + await createFolder(currentProjectname, folder_name, location); + setProjectChanges(true); + fetchFileList(); // Update the file list } catch (error) { console.error("Error creating folder:", error); } @@ -210,9 +225,14 @@ const FileBrowser = ({ if (file) { setRenameEntry(file); setRenameModalOpen(true); + setSaveCurrentDiagram(true); } else { alert("No file is currently selected."); } + + if (currentFilename === file.path) { + setForcedSaveCurrent(!forceSaveCurrent); + } }; const handleCloseRenameModal = () => { @@ -222,18 +242,19 @@ const FileBrowser = ({ const handleSubmitRenameModal = async (new_path) => { if (renameEntry) { try { - const response = await axios.get( - `/bt_studio/rename_file?project_name=${currentProjectname}&path=${renameEntry.path}&rename_to=${new_path}`, - ); - if (response.data.success) { - setProjectChanges(true); - fetchFileList(); // Update the file list - //TODO: if is a file what was renamed may need to change the path - if (currentFilename === renameEntry.name) { - setCurrentFilename(new_path); // Unset the current file - } + console.log(renameEntry); + if (renameEntry.is_dir) { + await renameFolder(currentProjectname, renameEntry.path, new_path); } else { - alert(response.data.message); + await renameFile(currentProjectname, renameEntry.path, new_path); + } + + setProjectChanges(true); + fetchFileList(); // Update the file list + + if (currentFilename === renameEntry.path) { + setAutosave(false); + setCurrentFilename(new_path); // Unset the current file } } catch (error) { console.error("Error deleting file:", error); @@ -244,11 +265,19 @@ const FileBrowser = ({ handleCloseRenameModal(); }; - const handleRenameCurrentFile = () => { - //TODO: need to obtain all file data to do this - return; + const handleRenameCurrentFile = async () => { if (currentFilename) { - handleRename(currentFilename); + setForcedSaveCurrent(!forceSaveCurrent); + const name = currentFilename.substring( + currentFilename.lastIndexOf("/") + 1, + ); + handleRename({ + is_dir: false, + name: name, + path: currentFilename, + files: [], + }); + setAutosave(false); } else { alert("No file is currently selected."); } @@ -259,6 +288,7 @@ const FileBrowser = ({ const handleUpload = (file) => { updateSelectedLocation(file); setUploadModalOpen(true); + setSaveCurrentDiagram(true); }; const handleCloseUploadModal = () => { @@ -267,42 +297,50 @@ const FileBrowser = ({ }; ///////////////// DOWNLOAD /////////////////////////////////////////////////// - const fetchDownloadData = async (file_path) => { - const api_response = await fetch("/bt_studio/download_data/", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - app_name: currentProjectname, - path: file_path, - }), - }); - - if (!api_response.ok) { - var json_response = await api_response.json(); - throw new Error(json_response.message || "An error occurred."); - } + const zipFile = async (zip, file_path, file_name) => { + var content = await getFile(currentProjectname, file_path); + zip.file(file_name, content); + }; + + const zipFolder = async (zip, file) => { + const folder = zip.folder(file.name); - return api_response.blob(); + for (let index = 0; index < file.files.length; index++) { + const element = file.files[index]; + console.log(element); + if (element.is_dir) { + await zipFolder(folder, element); + } else { + await zipFile(folder, element.path, element.name); + } + } }; const handleDownload = async (file) => { if (file) { - // Get the data as a base64 blob object - const app_blob = await fetchDownloadData(file.path); - try { - const url = window.URL.createObjectURL(app_blob); - const a = document.createElement("a"); - a.style.display = "none"; - a.href = url; - a.download = `${file.name}.zip`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); + // Create the zip with the files + const zip = new JSZip(); + + if (file.is_dir) { + await zipFolder(zip, file); + } else { + await zipFile(zip, file.path, file.name); + } + + zip.generateAsync({ type: "blob" }).then(function (content) { + // Create a download link and trigger download + const url = window.URL.createObjectURL(content); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + a.download = `${file.name.split(".")[0]}.zip`; // Set the downloaded file's name + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); // Clean up after the download + }); } catch (error) { - console.error("Error:", error); + console.error("Error downloading file: " + error); } } }; diff --git a/frontend/src/components/file_browser/file_explorer/FileExplorer.jsx b/frontend/src/components/file_browser/file_explorer/FileExplorer.jsx index 8459087dd..51d7cdc8b 100644 --- a/frontend/src/components/file_browser/file_explorer/FileExplorer.jsx +++ b/frontend/src/components/file_browser/file_explorer/FileExplorer.jsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from "react"; -import axios from "axios"; import "./FileExplorer.css"; import TreeNode from "./TreeNode.jsx"; diff --git a/frontend/src/components/file_browser/file_explorer/MoreActionsMenu.jsx b/frontend/src/components/file_browser/file_explorer/MoreActionsMenu.jsx index f5ae1d4bd..3f756d6d4 100644 --- a/frontend/src/components/file_browser/file_explorer/MoreActionsMenu.jsx +++ b/frontend/src/components/file_browser/file_explorer/MoreActionsMenu.jsx @@ -65,7 +65,7 @@ function MoreActionsMenu({
{ - onDelete(menuProps.file.path); + onDelete(menuProps.file.path, menuProps.file.is_dir); closeMenu(); }} > diff --git a/frontend/src/components/file_browser/modals/NewFileModal.jsx b/frontend/src/components/file_browser/modals/NewFileModal.jsx index b99dba2e3..661d12c95 100644 --- a/frontend/src/components/file_browser/modals/NewFileModal.jsx +++ b/frontend/src/components/file_browser/modals/NewFileModal.jsx @@ -79,7 +79,7 @@ const NewFileModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { setCreationType("plain"); setTemplate("empty"); - if (isOpen && location) { + if (isOpen) { //NOTE: One for actions and one for location createValidNamesList(location, setSearchPlainList); createValidNamesList("actions", setSearchActionsList); @@ -91,13 +91,16 @@ const NewFileModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { }, [creationType]); const createValidNamesList = (orig_path, callback) => { - var path = orig_path.split("/"); let search_list = fileList; - for (let index = 0; index < path.length; index++) { - search_list = search_list.find( - (entry) => entry.name === path[index] && entry.is_dir, - ).files; + if (orig_path) { + var path = orig_path.split("/"); + + for (let index = 0; index < path.length; index++) { + search_list = search_list.find( + (entry) => entry.name === path[index] && entry.is_dir, + ).files; + } } console.log(search_list); @@ -135,7 +138,7 @@ const NewFileModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { checkList = searchPlainList; } - if (preCheck) { + if (preCheck && checkList) { checkList.some((element) => { var name = element.name; @@ -152,7 +155,7 @@ const NewFileModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { } else { isValidName = false; } - console.log(creationType); + console.log(creationType, checkList); allowCreation(isValidName); }; diff --git a/frontend/src/components/file_browser/modals/NewFolderModal.jsx b/frontend/src/components/file_browser/modals/NewFolderModal.tsx similarity index 78% rename from frontend/src/components/file_browser/modals/NewFolderModal.jsx rename to frontend/src/components/file_browser/modals/NewFolderModal.tsx index e91caaea9..8827d3705 100644 --- a/frontend/src/components/file_browser/modals/NewFolderModal.jsx +++ b/frontend/src/components/file_browser/modals/NewFolderModal.tsx @@ -7,11 +7,30 @@ const initialNewFolderModalData = { folderName: "", }; -const NewFolderModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { - const focusInputRef = useRef(null); +interface Entry { + name: string; + is_dir: boolean; + path: string; + files: Entry[]; +} + +const NewFolderModal = ({ + onSubmit, + isOpen, + onClose, + fileList, + location, +}: { + onSubmit: Function; + isOpen: boolean; + onClose: Function; + fileList: any; + location: string; +}) => { + const focusInputRef = useRef(null); const [formState, setFormState] = useState(initialNewFolderModalData); const [isCreationAllowed, allowCreation] = useState(false); - const [searchList, setSearchList] = useState(null); + const [searchList, setSearchList] = useState([]); useEffect(() => { if (isOpen && focusInputRef.current) { @@ -20,15 +39,17 @@ const NewFolderModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { }, 0); } - if (isOpen && location) { - var path = location.split("/"); - + if (isOpen) { let search_list = fileList; - for (let index = 0; index < path.length; index++) { - search_list = search_list.find( - (entry) => entry.name === path[index] && entry.is_dir, - ).files; + if (location) { + var path = location.split("/"); + + for (let index = 0; index < path.length; index++) { + search_list = search_list.find( + (entry: Entry) => entry.name === path[index] && entry.is_dir, + ).files; + } } if (search_list) { @@ -39,7 +60,7 @@ const NewFolderModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { } }, [isOpen]); - const handleInputChange = (event) => { + const handleInputChange = (event: React.ChangeEvent) => { const { name, value } = event.target; var isValidName = true; @@ -65,7 +86,7 @@ const NewFolderModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { } }; - const handleSubmit = (event) => { + const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); onSubmit(location, formState.folderName); setFormState(initialNewFolderModalData); @@ -73,7 +94,7 @@ const NewFolderModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { onClose(); }; - const handleCancel = (event) => { + const handleCancel = (event: React.FormEvent | null) => { if (event) { event.preventDefault(); } @@ -101,7 +122,7 @@ const NewFolderModal = ({ onSubmit, isOpen, onClose, fileList, location }) => { { - handleCancel(); + handleCancel(null); }} fill={"var(--icon)"} /> diff --git a/frontend/src/components/file_browser/modals/UploadModal.tsx b/frontend/src/components/file_browser/modals/UploadModal.tsx index df86f55f9..20e3810e6 100644 --- a/frontend/src/components/file_browser/modals/UploadModal.tsx +++ b/frontend/src/components/file_browser/modals/UploadModal.tsx @@ -1,12 +1,11 @@ import React, { useState, useEffect, useRef } from "react"; -import axios from "axios"; -import JSZip from "jszip"; import "./UploadModal.css"; import Modal from "../../Modal/Modal"; import ProgressBar from "../../progress_bar/ProgressBar"; import { ReactComponent as CloseIcon } from "../../Modal/img/close.svg"; +import { uploadFile } from "../../../api_helper/TreeWrapper"; const UploadModal = ({ onSubmit, @@ -43,10 +42,6 @@ const UploadModal = ({ } }; - const onZipUpdate = (metadata: any) => { - setUploadPercentage(metadata.percent); - }; - const handleAcceptedFiles = async (files: any) => { // TODO: Redo for directory handleZipFiles(Array.from(files)); @@ -54,49 +49,37 @@ const UploadModal = ({ const handleZipFiles = async (file_array: any) => { // TODO: check if files are valid - const zip = new JSZip(); + const n_files = file_array.lenght; + var n_files_uploaded = 0; file_array.forEach((file: any, index: any) => { - zip.file(file.name, file); + var reader = new FileReader(); + + reader.onprogress = (data) => { + if (data.lengthComputable) { + const progress = Math.round((data.loaded / data.total) * 100); + setUploadPercentage(progress * (n_files_uploaded / n_files)); + } + }; + + reader.onload = (e: any) => { + const base64String = e.target.result.split(",")[1]; // Remove the data URL prefix + try { + uploadFile(currentProject, file.name, location, base64String); + console.log("Uploading file Completed"); + } catch (error) { + console.log(error); + console.log("Error uploading file"); + } + + setUploadStatus("Uploaded"); + setUploadPercentage(100 * (n_files_uploaded / n_files)); + }; + + reader.readAsDataURL(file); + n_files_uploaded++; }); - const zipContent = await zip.generateAsync({ type: "base64" }, onZipUpdate); - - try { - uploadFileToBackend(zipContent); - console.log("Uploading file Completed"); - } catch (error) { - console.log(error); - console.log("Error uploading file"); - } - }; - - const uploadFileToBackend = async (uploadedData: any) => { - console.log("Calling the saving API"); - console.log(currentProject); - - try { - const response = await axios.post( - "/bt_studio/upload_code/", - { - project_name: currentProject, - zip_file: uploadedData, - location: location, - }, - { - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - }, - ); - if (response.data.success) { - console.log("Universe saved successfully."); - } - } catch (error) { - console.error("Axios Error:", error); - } - onClose(); }; diff --git a/frontend/src/components/file_editor/FileEditor.tsx b/frontend/src/components/file_editor/FileEditor.tsx index 880af95aa..75fc3131c 100644 --- a/frontend/src/components/file_editor/FileEditor.tsx +++ b/frontend/src/components/file_editor/FileEditor.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from "react"; import AceEditor from "react-ace"; -import axios from "axios"; import "ace-builds/src-noconflict/mode-python"; import "ace-builds/src-noconflict/theme-monokai"; import "./FileEditor.css"; @@ -8,17 +7,24 @@ import "./FileEditor.css"; import { ReactComponent as SaveIcon } from "./img/save.svg"; import { ReactComponent as SplashIcon } from "./img/logo_jderobot_monocolor.svg"; import { ReactComponent as SplashIconUnibotics } from "./img/logo_unibotics_monocolor.svg"; +import { getFile, saveFile } from "../../api_helper/TreeWrapper"; const FileEditor = ({ currentFilename, currentProjectname, setProjectChanges, isUnibotics, + autosaveEnabled, + setAutosave, + forceSaveCurrent, }: { - currentFilename: any; - currentProjectname: any; - setProjectChanges: any; + currentFilename: string; + currentProjectname: string; + setProjectChanges: Function; isUnibotics: boolean; + autosaveEnabled: boolean; + setAutosave: Function; + forceSaveCurrent: boolean; }) => { const [fileContent, setFileContent] = useState(null); const [fontSize, setFontSize] = useState(14); @@ -28,10 +34,7 @@ const FileEditor = ({ const initFile = async () => { try { - const response = await axios.get( - `/bt_studio/get_file?project_name=${currentProjectname}&filename=${currentFilename}`, - ); - const content = response.data.content; + const content = await getFile(currentProjectname, currentFilename); setFileContent(content); setHasUnsavedChanges(false); // Reset the unsaved changes flag when a new file is loaded } catch (error) { @@ -41,27 +44,17 @@ const FileEditor = ({ const autoSave = async () => { console.log("Auto saving file..."); + + if (fileContent == null) { + console.log("No content to save"); + return; + } + try { - const response = await axios.post( - "/bt_studio/save_file/", - { - project_name: currentProjectname, - filename: filenameToSave, - content: fileContent, - }, - { - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - }, - ); - if (response.data.success) { - setHasUnsavedChanges(false); // Reset the unsaved changes flag - setProjectChanges(false); - } else { - alert(`Failed to save file: ${response.data.message}`); - } + await saveFile(currentProjectname, filenameToSave, fileContent); + setHasUnsavedChanges(false); // Reset the unsaved changes flag + setProjectChanges(false); + console.log("Auto save completed"); } catch (error) { console.error("Error saving file:", error); } @@ -70,9 +63,10 @@ const FileEditor = ({ useEffect(() => { if (currentFilename != "") { initFile(); - if (filenameToSave) { + if (filenameToSave && autosaveEnabled) { autoSave(); } + setAutosave(true); setFilenameToSave(currentFilename); } else { setFileContent(null); @@ -89,34 +83,28 @@ const FileEditor = ({ setFileContent(null); }, [currentProjectname]); + useEffect(() => { + handleSaveFile(); + }, [forceSaveCurrent]); + const handleSaveFile = async () => { - if (currentFilename !== "") { - try { - const response = await axios.post( - "/bt_studio/save_file/", - { - project_name: projectToSave, - filename: currentFilename, - content: fileContent, - }, - { - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - }, - ); - if (response.data.success) { - setHasUnsavedChanges(false); // Reset the unsaved changes flag - setProjectChanges(false); - } else { - alert(`Failed to save file: ${response.data.message}`); - } - } catch (error) { - console.error("Error saving file:", error); - } - } else { + if (fileContent === null) { + console.log("No content to save"); + return; + } + + if (currentFilename === "") { + console.log("No file is currently selected"); alert("No file is currently selected."); + return; + } + + try { + await saveFile(projectToSave, currentFilename, fileContent); + setHasUnsavedChanges(false); // Reset the unsaved changes flag + setProjectChanges(false); + } catch (error) { + console.error("Error saving file:", error); } }; diff --git a/frontend/src/components/header_menu/HeaderMenu.tsx b/frontend/src/components/header_menu/HeaderMenu.tsx index a596752eb..563d8a075 100644 --- a/frontend/src/components/header_menu/HeaderMenu.tsx +++ b/frontend/src/components/header_menu/HeaderMenu.tsx @@ -1,10 +1,10 @@ import { MouseEventHandler, useContext, useEffect, useState } from "react"; +import JSZip from "jszip"; import AppBar from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; import { createProject, - saveBaseTree, - generateApp, + generateLocalApp, generateDockerizedApp, getUniverseConfig, getCustomUniverseZip, @@ -29,17 +29,19 @@ import UniversesModal from "./modals/UniverseModal"; import SettingsModal from "../settings_popup/SettingsModal"; import { OptionsContext } from "../options/Options"; +import RosTemplates from "./../../templates/RosTemplates"; +import TreeGardener from "./../../templates/TreeGardener"; + const HeaderMenu = ({ currentProjectname, setCurrentProjectname, currentUniverseName, setCurrentUniverseName, - modelJson, + setSaveCurrentDiagram, projectChanges, setProjectChanges, gazeboEnabled, setGazeboEnabled, - // onSetShowExecStatus, manager, showVNCViewer, isUnibotics, @@ -48,7 +50,7 @@ const HeaderMenu = ({ setCurrentProjectname: Function; currentUniverseName: string; setCurrentUniverseName: Function; - modelJson: string; + setSaveCurrentDiagram: Function; projectChanges: boolean; setProjectChanges: Function; gazeboEnabled: boolean; @@ -179,7 +181,8 @@ const HeaderMenu = ({ return; } try { - await saveBaseTree(modelJson, currentProjectname); + //TODO: check if possible consurrency problems + setSaveCurrentDiagram(true); setProjectChanges(false); console.log("Project saved"); } catch (error) { @@ -193,22 +196,39 @@ const HeaderMenu = ({ const onDownloadApp = async () => { try { + await onSaveProject(); + // Get the blob from the API wrapper - const appBlob = await generateApp( - modelJson, + const appFiles = await generateLocalApp( + currentProjectname, + settings.btOrder.value, + ); + + // Create the zip with the files + const zip = new JSZip(); + + console.log(appFiles.dependencies); + + TreeGardener.addLocalFiles(zip); + RosTemplates.addLocalFiles( + zip, currentProjectname, - "top-to-bottom", + appFiles.tree, + appFiles.dependencies, ); - // Create a download link and trigger download - const url = window.URL.createObjectURL(appBlob); - const a = document.createElement("a"); - a.style.display = "none"; - a.href = url; - a.download = `${currentProjectname}.zip`; // Set the downloaded file's name - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); // Clean up after the download + zip.generateAsync({ type: "blob" }).then(function (content) { + // Create a download link and trigger download + const url = window.URL.createObjectURL(content); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + a.download = `${currentProjectname}.zip`; // Set the downloaded file's name + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); // Clean up after the download + }); + console.log("App downloaded successfully"); } catch (error) { if (error instanceof Error) { @@ -228,29 +248,38 @@ const HeaderMenu = ({ return; } + await onSaveProject(); + if (!appRunning) { try { // Get the blob from the API wrapper - const appBlob = await generateDockerizedApp( - modelJson, + const appFiles = await generateDockerizedApp( currentProjectname, settings.btOrder.value, ); + // Create the zip with the files + const zip = new JSZip(); + + zip.file("self_contained_tree.xml", appFiles.tree); + TreeGardener.addDockerFiles(zip); + RosTemplates.addDockerFiles(zip); + // Convert the blob to base64 using FileReader const reader = new FileReader(); reader.onloadend = async () => { const base64data = reader.result; // Get the zip in base64 - // Send the base64 encoded blob await manager.run({ type: "bt-studio", code: base64data, }); - console.log("Dockerized app started successfully"); }; - reader.readAsDataURL(appBlob); + + zip.generateAsync({ type: "blob" }).then(function (content) { + reader.readAsDataURL(content); + }); setAppRunning(true); console.log("App started successfully"); @@ -280,6 +309,7 @@ const HeaderMenu = ({ if (!gazeboEnabled) { console.error("Simulation is not ready!"); + return; } await manager.terminateApplication(); diff --git a/frontend/src/components/header_menu/modals/ProjectModal.tsx b/frontend/src/components/header_menu/modals/ProjectModal.tsx index 042a58ce7..a2cac9b02 100644 --- a/frontend/src/components/header_menu/modals/ProjectModal.tsx +++ b/frontend/src/components/header_menu/modals/ProjectModal.tsx @@ -4,7 +4,7 @@ import Modal from "../../Modal/Modal"; import { ReactComponent as BackIcon } from "../../Modal/img/back.svg"; import { ReactComponent as CloseIcon } from "../../Modal/img/close.svg"; import { ReactComponent as DeleteIcon } from "../../tree_editor/img/delete.svg"; -import axios from "axios"; +import { deleteProject, listProjects } from "../../../api_helper/TreeWrapper"; const initialProjectData = { projectName: "", @@ -34,10 +34,9 @@ const ProjectModal = ({ const [formState, setFormState] = useState(initialProjectData); const getProjects = async () => { - const listApiUrl = `/bt_studio/get_project_list`; try { - const response = await axios.get(listApiUrl); - setExistingProjects(response.data.project_list); + const response = await listProjects(); + setExistingProjects(response); setFormState(initialProjectData); } catch (error) { console.error("Error while fetching project list:", error); @@ -79,20 +78,16 @@ const ProjectModal = ({ onClose(); }; - const deleteProject = async (project: string) => { + const deleteProjectFunc = async (project: string) => { if (currentProject === project) { //TODO: change this to change project before deleting return; } - const apiUrl = `/bt_studio/delete_project?project_name=${encodeURIComponent(project)}`; - const listApiUrl = `/bt_studio/get_project_list`; // Delete and update - const response = await axios.get(apiUrl); try { - if (response.data.success) { - await getProjects(); - } + await deleteProject(project); + await getProjects(); console.log("Project deleted successfully"); } catch (error) { console.error("Error while fetching project list:", error); @@ -141,7 +136,7 @@ const ProjectModal = ({ className="bt-project-entry-delete bt-icon" title="Delete" onClick={(e) => { - deleteProject(project[1]); + deleteProjectFunc(project[1]); e.stopPropagation(); }} fill={"var(--icon)"} diff --git a/frontend/src/components/header_menu/modals/UniverseModal.tsx b/frontend/src/components/header_menu/modals/UniverseModal.tsx index 203510d38..53307ca69 100644 --- a/frontend/src/components/header_menu/modals/UniverseModal.tsx +++ b/frontend/src/components/header_menu/modals/UniverseModal.tsx @@ -4,8 +4,8 @@ import Modal from "../../Modal/Modal"; import { ReactComponent as CloseIcon } from "../../Modal/img/close.svg"; import { ReactComponent as DeleteIcon } from "../../tree_editor/img/delete.svg"; import CreatePage from "./universe/CreatePage"; -import axios from "axios"; import UniverseUploadModal from "./UniverseUploadModal"; +import { deleteUniverse, listUniverses } from "../../../api_helper/TreeWrapper"; const UniverseModal = ({ onSubmit, @@ -28,9 +28,8 @@ const UniverseModal = ({ const loadUniverseList = async () => { try { - const listApiUrl = `/bt_studio/get_universes_list?project_name=${currentProject}`; - const response = await axios.get(listApiUrl); - setUniversesProjects(response.data.universes_list); + const response = await listUniverses(currentProject); + setUniversesProjects(response); setUniverseAdded(false); } catch (error) { console.error("Error while fetching universes list:", error); @@ -59,14 +58,11 @@ const UniverseModal = ({ } }; - const deleteUniverse = async (universe_name: string) => { + const deleteUniverseFunc = async (universe_name: string) => { try { - const apiUrl = `/bt_studio/delete_universe?project_name=${currentProject}&universe_name=${universe_name}`; - const response = await axios.get(apiUrl); - if (response.data.success) { - loadUniverseList(); - console.log("Universe deleted successfully"); - } + await deleteUniverse(currentProject, universe_name); + loadUniverseList(); + console.log("Universe deleted successfully"); } catch (error: any) { if (error.response) { // The request was made and the server responded with a status code @@ -148,7 +144,7 @@ const UniverseModal = ({ className="bt-project-entry-delete bt-icon" title="Delete" onClick={(e) => { - deleteUniverse(project[1]); + deleteUniverseFunc(project[1]); e.stopPropagation(); }} fill={"var(--icon)"} diff --git a/frontend/src/components/header_menu/modals/UniverseUploadModal.tsx b/frontend/src/components/header_menu/modals/UniverseUploadModal.tsx index abafeeec1..ec056ef01 100644 --- a/frontend/src/components/header_menu/modals/UniverseUploadModal.tsx +++ b/frontend/src/components/header_menu/modals/UniverseUploadModal.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import "./UniverseUploadModal.css"; import Modal from "../../Modal/Modal"; import { ReactComponent as CloseIcon } from "../../Modal/img/close.svg"; -import axios from "axios"; +import { uploadUniverse } from "../../../api_helper/TreeWrapper"; const initialProjectData = { projectName: "", @@ -79,24 +79,9 @@ const UniverseUploadModal = ({ } try { - const response = await axios.post( - "/bt_studio/upload_universe/", - { - universe_name: universeName, - zip_file: uploadedUniverse, - app_name: currentProject, - }, - { - headers: { - //@ts-ignore Needed for compatibility with Unibotics - "X-CSRFToken": context.csrf, - }, - }, - ); - if (response.data.success) { - console.log("Universe saved successfully."); - setUniverseAdded(true); - } + await uploadUniverse(currentProject, universeName, uploadedUniverse); + console.log("Universe saved successfully."); + setUniverseAdded(true); } catch (error) { console.error("Axios Error:", error); } diff --git a/frontend/src/components/header_menu/modals/universe/CreatePage.tsx b/frontend/src/components/header_menu/modals/universe/CreatePage.tsx index 69c25194c..535d03f75 100644 --- a/frontend/src/components/header_menu/modals/universe/CreatePage.tsx +++ b/frontend/src/components/header_menu/modals/universe/CreatePage.tsx @@ -2,8 +2,10 @@ import React, { useState, useEffect, useRef, FormEventHandler } from "react"; import "./CreatePage.css"; import { ReactComponent as BackIcon } from "../../../Modal/img/back.svg"; import { ReactComponent as CloseIcon } from "../../../Modal/img/close.svg"; -import axios from "axios"; -import { createRoboticsBackendUniverse } from "../../../../api_helper/TreeWrapper"; +import { + createRoboticsBackendUniverse, + listDockerUniverses, +} from "../../../../api_helper/TreeWrapper"; const initialUniverseData = { universeName: "", @@ -31,9 +33,8 @@ const CreatePage = ({ const loadUniverseList = async () => { try { - const listApiUrl = `/bt_studio/list_docker_universes`; - const response = await axios.get(listApiUrl); - setUniversesDocker(response.data.universes); + const response = await listDockerUniverses(); + setUniversesDocker(response); } catch (error) { console.error("Error while fetching universes list:", error); openError(`An error occurred while fetching the universes list`); diff --git a/frontend/src/components/helper/TreeEditorHelper.ts b/frontend/src/components/helper/TreeEditorHelper.ts index 67701433e..6d52fc631 100644 --- a/frontend/src/components/helper/TreeEditorHelper.ts +++ b/frontend/src/components/helper/TreeEditorHelper.ts @@ -255,7 +255,9 @@ export const configureEngine = ( // Disable loose links const state: any = engine.current.getStateMachine().getCurrentState(); - state.dragNewLink.config.allowLooseLinks = false; + if (state) { + state.dragNewLink.config.allowLooseLinks = false; + } engine.current .getActionEventBus() diff --git a/frontend/src/components/settings_popup/SettingsModal.jsx b/frontend/src/components/settings_popup/SettingsModal.jsx index 80f41074a..8d92ebd77 100644 --- a/frontend/src/components/settings_popup/SettingsModal.jsx +++ b/frontend/src/components/settings_popup/SettingsModal.jsx @@ -14,6 +14,8 @@ import Checkbox from "./options/Checkbox"; import { OptionsContext } from "../options/Options"; +import { saveProjectConfig } from "./../../api_helper/TreeWrapper"; + const SettingsModal = ({ onSubmit, isOpen, onClose, currentProjectname }) => { const [color, setColor] = useColor("rgb(128 0 128)"); const [open, setOpen] = useState(false); @@ -29,7 +31,7 @@ const SettingsModal = ({ onSubmit, isOpen, onClose, currentProjectname }) => { // document.documentElement.style.setProperty("--header", "rgb("+Math.round(color.rgb.r)+","+Math.round(color.rgb.g)+","+Math.round(color.rgb.b)+")"); // }, [color]); - const handleCancel = async () => { + const handleCancel = async (settings) => { // Save settings let json_settings = { name: currentProjectname, config: {} }; @@ -37,21 +39,11 @@ const SettingsModal = ({ onSubmit, isOpen, onClose, currentProjectname }) => { json_settings.config[key] = setting.value; }); - const str = JSON.stringify(json_settings); - - console.log(str); - try { - const response = await fetch("/bt_studio/save_base_tree_configuration/", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - project_name: currentProjectname, - settings: str, - }), - }); + await saveProjectConfig( + currentProjectname, + JSON.stringify(json_settings), + ); } catch (error) { console.error("Error saving config:", error); } @@ -68,7 +60,9 @@ const SettingsModal = ({ onSubmit, isOpen, onClose, currentProjectname }) => { >
{ + handleCancel(settings); + }} style={{ display: "flex", flexDirection: "column", flexGrow: "1" }} >
@@ -82,7 +76,7 @@ const SettingsModal = ({ onSubmit, isOpen, onClose, currentProjectname }) => { { - handleCancel(); + handleCancel(settings); }} fill={"var(--icon)"} /> diff --git a/frontend/src/components/tree_editor/DiagramVisualizer.tsx b/frontend/src/components/tree_editor/DiagramVisualizer.tsx index 72dc4594b..534863a2a 100644 --- a/frontend/src/components/tree_editor/DiagramVisualizer.tsx +++ b/frontend/src/components/tree_editor/DiagramVisualizer.tsx @@ -12,6 +12,7 @@ import { BasicNodeModel } from "./nodes/basic_node/BasicNodeModel"; import { TagNodeModel } from "./nodes/tag_node/TagNodeModel"; import "./TreeEditor.css"; +import NodeMenuMinimal from "./NodeMenuMinimal"; const setTreeStatus = ( model: DiagramModel, @@ -234,13 +235,8 @@ const DiagramVisualizer = memo( return ( <> - {}} - onDeleteNode={() => {}} + {}} - hasSubtrees={false} view={view} changeView={changeView} setGoBack={setGoBack} diff --git a/frontend/src/components/tree_editor/MainTreeEditorContainer.tsx b/frontend/src/components/tree_editor/MainTreeEditorContainer.tsx index 34aa9d3bc..1f0dc4ee8 100644 --- a/frontend/src/components/tree_editor/MainTreeEditorContainer.tsx +++ b/frontend/src/components/tree_editor/MainTreeEditorContainer.tsx @@ -1,5 +1,4 @@ import React, { useRef } from "react"; -import axios from "axios"; import { useState, useEffect } from "react"; import TreeEditor from "./TreeEditor"; import { @@ -7,6 +6,8 @@ import { getSubtree, saveSubtree, saveBaseTree, + getSubtreeStructure, + getTreeStructure, } from "../../api_helper/TreeWrapper"; import { TreeViewType, findSubtree } from "../helper/TreeEditorHelper"; import { OptionsContext } from "../options/Options"; @@ -14,13 +15,15 @@ import { OptionsContext } from "../options/Options"; const MainTreeEditorContainer = ({ projectName, setProjectEdited, - setGlobalJson, - modelJson, + saveCurrentDiagram, + setSaveCurrentDiagram, + updateFileExplorer, }: { projectName: string; setProjectEdited: React.Dispatch>; - setGlobalJson: Function; - modelJson: any; + saveCurrentDiagram: boolean; + setSaveCurrentDiagram: Function; + updateFileExplorer: Function; }) => { const settings = React.useContext(OptionsContext); @@ -45,6 +48,11 @@ const MainTreeEditorContainer = ({ if (currentView.current !== TreeViewType.Editor) { return; } + + if (!json) { + return; + } + // If in subtree save subtree, else save base tree if (subtree) { await saveSubtree(json, projectName, subtree); @@ -54,6 +62,7 @@ const MainTreeEditorContainer = ({ } setProjectEdited(false); }; + // Load const load = async () => { try { @@ -80,50 +89,32 @@ const MainTreeEditorContainer = ({ // HELPERS - const getSubtreeStructure = async (name: string) => { - try { - const response = await axios.get("/bt_studio/get_subtree_structure/", { - params: { - project_name: projectName, - subtree_name: name, - bt_order: settings.btOrder.value, - }, - }); - if (response.data.success) { - return response.data.tree_structure; - } - } catch (error) { - console.error("Error fetching graph:", error); - } - }; - const getBTTree = async () => { try { - const response = await axios.get("/bt_studio/get_tree_structure/", { - params: { - project_name: projectName, - bt_order: settings.btOrder.value, - }, - }); - if (response.data.success) { - // Navigate until root using baseTree - var path: number[] = []; - var tree_structure = response.data.tree_structure; - for (let index = 0; index < treeHierarchy.length; index++) { - var nextSubtree = treeHierarchy[index]; - if (nextSubtree) { - var new_path = findSubtree(tree_structure, nextSubtree); - if (new_path) { - path = path.concat(new_path); //TODO: check if its not new_path.concat(path) - } - tree_structure = await getSubtreeStructure(nextSubtree); + var tree_structure = await getTreeStructure( + projectName, + settings.btOrder.value, + ); + // Navigate until root using baseTree + var path: number[] = []; + for (let index = 0; index < treeHierarchy.length; index++) { + var nextSubtree = treeHierarchy[index]; + if (nextSubtree) { + var new_path = findSubtree(tree_structure, nextSubtree); + if (new_path) { + path = path.concat(new_path); //TODO: check if its not new_path.concat(path) } - console.log("TreePath", path); + tree_structure = await getSubtreeStructure( + projectName, + nextSubtree, + settings.btOrder.value, + ); } - - setTreeStructure(tree_structure); - setSubTreeStructure(path); + console.log("TreePath", path); } + + setTreeStructure(tree_structure); + setSubTreeStructure(path); } catch (error) { console.error("Error fetching graph:", error); } @@ -139,15 +130,18 @@ const MainTreeEditorContainer = ({ // EFFECTS useEffect(() => { - // When no subtree is selected, set the global json - if (!subTreeName) { - setGlobalJson(resultJson); + if (saveCurrentDiagram) { + save(resultJson, subTreeName); + setSaveCurrentDiagram(false); } - }, [resultJson]); + }, [saveCurrentDiagram]); useEffect(() => { - // We can go back again now + // Reset everything setWentBack(false); + setGoBack(false); + setTreeHierarchy([]); + setSubTreeName(""); // Fetch the new subtree or project graph load(); @@ -242,6 +236,7 @@ const MainTreeEditorContainer = ({ subTreeName={subTreeName} setGoBack={setGoBack} subTreeStructure={subTreeStructure} + updateFileExplorer={updateFileExplorer} /> ) : (

Loading...

// Display a loading message until the graph is fetched diff --git a/frontend/src/components/tree_editor/NodeMenu.tsx b/frontend/src/components/tree_editor/NodeMenu.tsx index ce899d062..9e92e3b6a 100644 --- a/frontend/src/components/tree_editor/NodeMenu.tsx +++ b/frontend/src/components/tree_editor/NodeMenu.tsx @@ -17,6 +17,7 @@ import { getActionsList, } from "../../api_helper/TreeWrapper"; import { TreeViewType } from "../helper/TreeEditorHelper"; +import AddSubtreeModal from "./modals/AddSubtreeModal"; var NODE_MENU_ITEMS: Record = { Sequences: ["Sequence", "ReactiveSequence", "SequenceWithMemory"], @@ -57,6 +58,7 @@ const fetchSubtreeList = async (project_name: string) => { console.log("Subtree list:", subtreeList); if (Array.isArray(subtreeList)) { NODE_MENU_ITEMS["Subtrees"] = subtreeList; + return; } else { console.error("API response is not an array:", subtreeList); } @@ -65,6 +67,7 @@ const fetchSubtreeList = async (project_name: string) => { console.error("Error fetching subtrees:", error.message); } } + NODE_MENU_ITEMS["Subtrees"] = []; }; const NodeMenu = ({ @@ -78,6 +81,7 @@ const NodeMenu = ({ changeView, setGoBack, subTreeName, + updateFileExplorer, }: { projectName: string; onAddNode: Function; @@ -89,9 +93,12 @@ const NodeMenu = ({ changeView: Function; setGoBack: Function; subTreeName: string; + updateFileExplorer: Function; }) => { const [anchorEl, setAnchorEl] = useState(null); const [menuLabel, setMenuLabel] = useState(""); + const [isNewSubtreeModalOpen, setNewSubtreeModalOpen] = + useState(false); useEffect(() => { const fetchData = async () => { @@ -108,6 +115,9 @@ const NodeMenu = ({ ) => { setAnchorEl(event.currentTarget); setMenuLabel(label); + if (label === "Actions") { + fetchActionList(projectName); + } }; const handleClose = () => setAnchorEl(null); @@ -135,135 +145,167 @@ const NodeMenu = ({ } }; - const onCreateSubtree = async () => { - try { - const subtreeName = prompt("Enter subtree name:"); - if (subtreeName) { + const handleCreateSubtree = () => { + setNewSubtreeModalOpen(true); + }; + + const handleCloseCreateFolder = () => { + setNewSubtreeModalOpen(false); + var subtree_input = document.getElementById( + "subTreeName", + ) as HTMLInputElement; + if (subtree_input) { + subtree_input.value = ""; + } + }; + + const handleCreateFolderSubmit = async (subtreeName: string) => { + if (subtreeName !== "") { + try { const subtreeId = await createSubtree(subtreeName, projectName); console.log("Created subtree:", subtreeId); fetchSubtreeList(projectName); + updateFileExplorer(true); + } catch (error) { + console.error("Failed to create subtree:", error); } - } catch (error) { - console.error("Failed to create subtree:", error); } }; return ( -
-
- {Object.keys(NODE_MENU_ITEMS).map((label) => { - if (label === "Subtrees" && !hasSubtrees) { - return null; - } - return ( + <> +
+
+ {Object.keys(NODE_MENU_ITEMS).map((label) => { + if (label === "Subtrees" && !hasSubtrees) { + return null; + } + return ( + + ); + })} +
+ + + {NODE_MENU_ITEMS[menuLabel]?.map((item) => ( + handleSelect(item)}> + {item} + + ))} + + +
+ {hasSubtrees && ( - ); - })} -
- - - {NODE_MENU_ITEMS[menuLabel]?.map((item) => ( - handleSelect(item)}> - {item} - - ))} - - -
- {hasSubtrees && ( + )} - )} - - - - - - + + + + + +
+

{subTreeName}

-

{subTreeName}

-
+ + ); }; diff --git a/frontend/src/components/tree_editor/NodeMenuMinimal.tsx b/frontend/src/components/tree_editor/NodeMenuMinimal.tsx new file mode 100644 index 000000000..2154be450 --- /dev/null +++ b/frontend/src/components/tree_editor/NodeMenuMinimal.tsx @@ -0,0 +1,99 @@ +import React, { MouseEventHandler } from "react"; +import "./NodeMenu.css"; + +import { ReactComponent as HelpIcon } from "./img/help.svg"; +import { ReactComponent as ZoomToFitIcon } from "./img/zoom_to_fit.svg"; +import { ReactComponent as EyeOpenIcon } from "./img/eye_open.svg"; +import { ReactComponent as EyeClosedIcon } from "./img/eye_closed.svg"; +import { ReactComponent as ReturnIcon } from "./img/return.svg"; + +import { TreeViewType } from "../helper/TreeEditorHelper"; + +const NodeMenuMinimal = ({ + onZoomToFit, + view, + changeView, + setGoBack, + subTreeName, +}: { + onZoomToFit: MouseEventHandler; + view: TreeViewType; + changeView: Function; + setGoBack: Function; + subTreeName: string; +}) => { + const openInNewTab = (url: URL) => { + const newWindow = window.open(url, "_blank"); + if (newWindow) { + newWindow.focus(); + } else { + console.error("Failed to open new tab/window."); + } + }; + + return ( + <> +
+
+ + + + +
+

{subTreeName}

+
+ + ); +}; + +export default NodeMenuMinimal; diff --git a/frontend/src/components/tree_editor/TreeEditor.tsx b/frontend/src/components/tree_editor/TreeEditor.tsx index 3549715d3..c72ca37ce 100644 --- a/frontend/src/components/tree_editor/TreeEditor.tsx +++ b/frontend/src/components/tree_editor/TreeEditor.tsx @@ -6,6 +6,7 @@ import createEngine, { DefaultNodeModel, DiagramEngine, DiagramModel, + DiagramModelGenerics, NodeModel, ZoomCanvasAction, } from "@projectstorm/react-diagrams"; @@ -50,6 +51,7 @@ const TreeEditor = memo( subTreeName, setGoBack, subTreeStructure, + updateFileExplorer, }: { modelJson: any; setResultJson: Function; @@ -63,6 +65,7 @@ const TreeEditor = memo( subTreeName: string; setGoBack: Function; subTreeStructure: number[]; + updateFileExplorer: Function; }) => { const settings = React.useContext(OptionsContext); @@ -159,6 +162,7 @@ const TreeEditor = memo( setCurrentNode={setCurrentNode} setGoBack={setGoBack} subTreeName={subTreeName} + updateFileExplorer={updateFileExplorer} /> )} +
+
+
+ + ); +}; + +export default AddSubtreeModal; diff --git a/frontend/src/templates/RosTemplates.ts b/frontend/src/templates/RosTemplates.ts new file mode 100644 index 000000000..e9063ed83 --- /dev/null +++ b/frontend/src/templates/RosTemplates.ts @@ -0,0 +1,245 @@ +import JSZip from "jszip" + +const executeDocker = +`import functools +import sys +import rclpy +import py_trees +from rclpy.node import Node +import tree_factory +import os +from tree_tools import ascii_bt_to_json + + +def start_console(): + # Get all the file descriptors and choose the latest one + fds = os.listdir("/dev/pts/") + console_fd = str(max(map(int, fds[:-1]))) + + sys.stderr = open("/dev/pts/" + console_fd, "w") + sys.stdout = open("/dev/pts/" + console_fd, "w") + sys.stdin = open("/dev/pts/" + console_fd, "r") + + +def close_console(): + sys.stderr.close() + sys.stdout.close() + sys.stdin.close() + + +class TreeExecutor(Node): + def __init__(self): + super().__init__("tree_executor_node") + # Get the path to the root of the package + ws_path = "/workspace/code" + tree_path = os.path.join(ws_path, "self_contained_tree.xml") + + factory = tree_factory.TreeFactory() + self.tree = factory.create_tree_from_file(tree_path) + snapshot_visitor = py_trees.visitors.SnapshotVisitor() + self.tree.add_post_tick_handler( + functools.partial(self.post_tick_handler, snapshot_visitor) + ) + self.tree.visitors.append(snapshot_visitor) + self.tree.tick_tock(period_ms=50) + + def post_tick_handler(self, snapshot_visitor, behaviour_tree): + with open("/tmp/tree_state", "w") as f: + ascii_bt_to_json( + py_trees.display.ascii_tree( + behaviour_tree.root, + visited=snapshot_visitor.visited, + previously_visited=snapshot_visitor.visited, + ), + py_trees.display.ascii_blackboard(), + f, + ) + + def spin_tree(self): + + try: + rclpy.spin(self.tree.node) + except (KeyboardInterrupt, rclpy.executors.ExternalShutdownException): + self.tree.shutdown() + finally: + print("Shutdown completed") + + +def main(): + # Start the console + start_console() + # Init the components + rclpy.init() + executor = TreeExecutor() + # Spin the tree + executor.spin_tree() + # Close the console + close_console() + + +main() +` + +const thirdparty = `repositories: + ThirdParty/tb4_sim: + type: git + url: https://github.com/OscarMrZ/tb4_sim.git + version: main + ThirdParty/py_trees_ros_viewer: + type: git + url: https://github.com/splintered-reality/py_trees_ros_viewer.git + version: devel +` + +const setupConfig = (name:string) => { + return `[develop] +script_dir=$base/lib/ros_template +[install] +install_scripts=$base/lib/`+name+` +` +} + +const setupPython = (name:string) => { + return `from setuptools import setup +from setuptools import find_packages + +package_name = "`+name+`" + +setup( + name=package_name, + version="0.1.0", + packages=find_packages(exclude=["test"]), + data_files=[ + ("share/ament_index/resource_index/packages", ["resource/" + package_name]), + ("share/" + package_name, ["package.xml"]), + ( + "share/" + package_name + "/resource", + [ + "resource/app_tree.xml", + ], + ), + ], + install_requires=["setuptools"], + zip_safe=True, + author="", + author_email="", + maintainer="", + maintainer_email="", + keywords=["ROS2", "py_trees"], + classifiers=[ + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Topic :: Software Development", + ], + description="A ROS2 app generated with tree_translator", + license="", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "executor = ros_template.execute:main", + ], + }, +) + ` +} + +const executeLocal = (name:string) => { + return `import rclpy +from rclpy.node import Node +from tree_gardener import tree_factory +from ament_index_python.packages import get_package_share_directory +import os + + +class TreeExecutor(Node): + def __init__(self): + + super().__init__("tree_executor_node") + + # Get the path to the root of the package + pkg_share_dir = get_package_share_directory("`+name+`") + tree_path = os.path.join(pkg_share_dir, "resource", "app_tree.xml") + + factory = tree_factory.TreeFactory() + self.tree = factory.create_tree_from_file(tree_path) + self.tree.tick_tock(period_ms=50) + + def spin_tree(self): + + try: + rclpy.spin(self.tree.node) + except (KeyboardInterrupt, rclpy.executors.ExternalShutdownException): + self.tree.shutdown() + finally: + print("Shutdown completed") + + +def main(): + + # Init the components + rclpy.init() + executor = TreeExecutor() + + # Spin the tree + executor.spin_tree() + ` +} + +const packageInfo = (name:string, extraDependencies: string[]) => { + //TODO: add also more user info + var dependStr = `rclpy` + + extraDependencies.forEach(depend => { + dependStr += ` + `+ depend +`` + }); + + return ` + + `+name+` + 0.1.0 + A ROS2 app generated BT Studio + Mantainer + GPLv3 + + ` + + dependStr + + ` + + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + + ` +} + +namespace RosTemplates { + export function addDockerFiles(zip:JSZip ) { + zip.file("execute_docker.py", executeDocker); + } + + export function addLocalFiles(zip:JSZip, project_name: string, tree_xml:string, depend: string[]) { + const loc = zip.folder(project_name); + + if (loc == null) { + return + } + + loc.file("resource/"+project_name, ""); + loc.file("resource/app_tree.xml", tree_xml); + + loc.file(project_name + "/execute.py", executeLocal(project_name)); + loc.file(project_name + "/__init__.py", ""); + + loc.file("package.xml", packageInfo(project_name, depend)); + loc.file("setup.cfg", setupConfig(project_name)); + loc.file("setup.py", setupPython(project_name)); + } +} + +export default RosTemplates; \ No newline at end of file diff --git a/frontend/src/templates/TreeGardener.ts b/frontend/src/templates/TreeGardener.ts new file mode 100644 index 000000000..077d7e0ed --- /dev/null +++ b/frontend/src/templates/TreeGardener.ts @@ -0,0 +1,678 @@ +import JSZip from "jszip"; + +// NOTE: Make sure to escape \ character + +const treeTools = +`import re +import py_trees + + +class GlobalBlackboard: + + _instance = None + + @staticmethod + def get_instance(): + + if GlobalBlackboard._instance is None: + GlobalBlackboard._instance = py_trees.blackboard.Client(name="Global") + + return GlobalBlackboard._instance + + +def get_port_content(port_value): + + if port_value.startswith("{") and port_value.endswith("}"): + bb_key = port_value.strip("{}") + blackboard = GlobalBlackboard.get_instance() + + # Return the value of the blackboard entry if it exists, otherwise None + return getattr(blackboard, bb_key, "") + else: + return port_value + + +def set_port_content(port_value, value): + + if not (port_value.startswith("{") and port_value.endswith("}")): + raise ValueError( + f"'{port_value}' is a read-only port. Only ports connected to the blackboard are writable" + ) + + # Extract the key from the port_value + key = port_value.strip("{}") + + blackboard = GlobalBlackboard.get_instance() + + # Lazy creation: if key doesn't exist, register it + if not hasattr(blackboard, key): + blackboard.register_key( + key=key, access=py_trees.common.Access.WRITE, required=True + ) + + # Set the value for the key in the blackboard + setattr(blackboard, key, value) + + +########### ASCII BT STATUS TO JSON ############################################ +def ascii_state_to_state(state_raw): + letter = [x for x in state_raw] + state = letter[1] + + match state: + case "*": + return "RUNNING" + case "o": + return "SUCCESS" + case "x": + return "FAILURE" + case "-": + return "INVALID" + case _: + return "INVALID" + + +def ascii_tree_to_json(tree): + indent_levels = 4 # 4 spaces = 1 level deep + do_append_coma = False + last_indent_level = -1 + json_str = '"tree":{' + + # Remove escape chars + ansi_escape = re.compile(r"\\x1B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])") + tree = ansi_escape.sub("", tree) + + for line in iter(tree.splitlines()): + entry = line.strip().split(" ") + name = entry[1] + if len(entry) == 2: + state = "NONE" + else: + state = ascii_state_to_state(entry[2]) + + indent = int((len(line) - len(line.lstrip())) / indent_levels) + if not (indent > last_indent_level): + json_str += "}" * (last_indent_level - indent + 1) + + last_indent_level = indent + + if do_append_coma: + json_str += "," + else: + do_append_coma = True + json_str += '"' + name + '":{' + json_str += f'"state":"{state}"' + + json_str += "}" * (last_indent_level + 1) + "}" + return json_str + + +def ascii_blackboard_to_json(blackboard): + json_str = '"blackboard":{' + do_append_coma = False + + ansi_escape = re.compile(r"\\\\x1B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])") + blackboard = ansi_escape.sub("", blackboard) + + for line in iter(blackboard.splitlines()): + if "Blackboard Data" in line: + continue + if len(line.strip()) == 0: + continue + if do_append_coma: + json_str += "," + # Remove whitespaces with strip and remove / from entry + try: + [entry, value] = line.strip()[1:].split(":") + json_str += f'"{entry.strip()}":"{value.strip()}"' + do_append_coma = True + except: + pass + json_str += "}" + return json_str + + +def ascii_bt_to_json(tree, blackboard, file): + file.write("{") + file.write(f"{ascii_tree_to_json(tree)},{ascii_blackboard_to_json(blackboard)}") + # file.write(f"{ascii_tree_to_json(tree)}") + file.write("}") + file.close() +` + +const treeFactory = +`############################################################################## +# Imports +############################################################################## + +import py_trees +import py_trees_ros.trees +import py_trees.console as console +import xml.etree.ElementTree as ET +import typing +import autopep8 +import textwrap +import itertools +import rclpy.node +import time + +############################################################################## +# Tree classes +############################################################################## + + +class ReactiveSequence(py_trees.composites.Composite): + def __init__( + self, + name: str, + memory: bool, + children: typing.Optional[typing.List[py_trees.behaviour.Behaviour]] = None, + ): + super(ReactiveSequence, self).__init__(name, children) + self.memory = memory + + def tick(self) -> typing.Iterator[py_trees.behaviour.Behaviour]: + """ + Tick over the children as a reactive sequence. + """ + + self.logger.debug("%s.tick()" % self.__class__.__name__) + + # Initialize always from the start + self.current_child = self.children[0] if self.children else None + for child in self.children: + if child.status != py_trees.common.Status.INVALID: + child.stop(py_trees.common.Status.INVALID) + + # Nothing to do + if not self.children: + self.current_child = None + self.stop(py_trees.common.Status.SUCCESS) + yield self + return + + # Ticking the children + for child in self.children: + for node in child.tick(): + yield node + if node is child and node.status != py_trees.common.Status.SUCCESS: + self.status = node.status + yield self + return + + self.stop(py_trees.common.Status.SUCCESS) + yield self + + def stop( + self, new_status: py_trees.common.Status = py_trees.common.Status.INVALID + ) -> None: + """ + Ensure that children are appropriately stopped and update status. + + Args: + new_status : the composite is transitioning to this new status + """ + self.logger.debug( + f"{self.__class__.__name__}.stop()[{self.status}->{new_status}]" + ) + py_trees.composites.Composite.stop(self, new_status) + + +class SequenceWithMemory(py_trees.composites.Composite): + def __init__( + self, + name: str, + memory: bool, + children: typing.Optional[typing.List[py_trees.behaviour.Behaviour]] = None, + ): + super(SequenceWithMemory, self).__init__(name, children) + self.memory = memory + + def tick(self) -> typing.Iterator[py_trees.behaviour.Behaviour]: + """ + Tick over the children as a memory-enabled sequence. + """ + + self.logger.debug("%s.tick()" % self.__class__.__name__) + + # Get the index of the current child + index = ( + self.children.index(self.current_child) if self.current_child != None else 0 + ) + + # Nothing to do + if not self.children: + self.current_child = None + self.stop(py_trees.common.Status.SUCCESS) + yield self + return + + # Ticking the children + for child in itertools.islice(self.children, index, None): + for node in child.tick(): + yield node + if node is child: + if node.status in [ + py_trees.common.Status.RUNNING, + py_trees.common.Status.FAILURE, + ]: + self.status = node.status + self.current_child = child + yield self + return + else: + # child has returned SUCCESS, move to next child on the next tick + index += 1 + self.current_child = ( + self.children[index] if index < len(self.children) else None + ) + + # All children have returned SUCCESS + if self.current_child is None: + self.stop(py_trees.common.Status.SUCCESS) + + yield self + + def stop( + self, new_status: py_trees.common.Status = py_trees.common.Status.INVALID + ) -> None: + """ + Ensure that children are appropriately stopped and update status. + + Args: + new_status : the composite is transitioning to this new status + """ + self.logger.debug( + f"{self.__class__.__name__}.stop()[{self.status}->{new_status}]" + ) + py_trees.composites.Composite.stop(self, new_status) + + +class ReactiveFallback(py_trees.composites.Composite): + def __init__( + self, + name: str, + memory: bool, + children: typing.Optional[typing.List[py_trees.behaviour.Behaviour]] = None, + ): + super(ReactiveFallback, self).__init__(name, children) + self.memory = memory + + def tick(self) -> typing.Iterator[py_trees.behaviour.Behaviour]: + + self.logger.debug("%s.tick()" % self.__class__.__name__) + + # Initialize + if self.status != py_trees.common.Status.RUNNING: + self.logger.debug( + "%s.tick() [!RUNNING->reset current_child]" % self.__class__.__name__ + ) + self.current_child = self.children[0] if self.children else None + + # nothing to do + if not self.children: + self.current_child = None + self.stop(py_trees.common.Status.FAILURE) + yield self + return + + # always start from the first child + index = 0 + + # actual work + previous = self.current_child + for child in itertools.islice(self.children, index, None): + for node in child.tick(): + yield node + if node is child: + if ( + node.status == py_trees.common.Status.RUNNING + or node.status == py_trees.common.Status.SUCCESS + ): + self.current_child = child + self.status = node.status + if previous is None or previous != self.current_child: + # we interrupted, invalidate everything at a lower priority + passed = False + for child in self.children: + if passed: + if child.status != py_trees.common.Status.INVALID: + child.stop(py_trees.common.Status.INVALID) + passed = True if child == self.current_child else passed + yield self + return + + # all children failed, set failure ourselves and current child to the last bugger who failed us + self.status = py_trees.common.Status.FAILURE + try: + self.current_child = self.children[-1] + except IndexError: + self.current_child = None + yield self + + def stop( + self, new_status: py_trees.common.Status = py_trees.common.Status.INVALID + ) -> None: + """ + Ensure that children are appropriately stopped and update status. + + Args: + new_status : the composite is transitioning to this new status + """ + self.logger.debug( + f"{self.__class__.__name__}.stop()[{self.status}->{new_status}]" + ) + py_trees.composites.Composite.stop(self, new_status) + + +class Delay(py_trees.decorators.Decorator): + def __init__( + self, name: str, child: py_trees.behaviour.Behaviour, delay_ms: int = 0 + ): + + super(Delay, self).__init__(name=name, child=child) + self.secs = float(delay_ms / 1000) # Convert to seconds + self.start_time = None + + def initialise(self) -> None: + + self.start_time = time.monotonic() + self.secs + + def tick(self) -> typing.Iterator[py_trees.behaviour.Behaviour]: + + # Check if init is needed + if self.status != py_trees.common.Status.RUNNING: + self.initialise() + + current_time = time.monotonic() + if current_time < self.start_time: + # Return the decorator itself + for node in py_trees.behaviour.Behaviour.tick(self): + yield node + else: + # Tick the child + for node in py_trees.decorators.Decorator.tick(self): + yield node + + def update(self) -> py_trees.common.Status: + + current_time = time.monotonic() + if current_time > self.start_time: + return self.decorated.status + else: + return py_trees.common.Status.RUNNING + + def terminate(self, new_status: py_trees.common.Status) -> None: + self.decorated.stop(new_status) + + +############################################################################## +# Auxiliary variables +############################################################################## + +factory = { + "Sequence": py_trees.composites.Sequence, + "ReactiveSequence": ReactiveSequence, + "SequenceWithMemory": SequenceWithMemory, + "Fallback": py_trees.composites.Selector, + "ReactiveFallback": ReactiveFallback, + "Inverter": py_trees.decorators.Inverter, + "ForceSuccess": py_trees.decorators.FailureIsSuccess, + "ForceFailure": py_trees.decorators.SuccessIsFailure, + "Repeat": py_trees.decorators.Repeat, + "RetryUntilSuccessful": py_trees.decorators.Retry, + "KeepRunningUntilFailure": py_trees.decorators.SuccessIsRunning, + "RunOnce": py_trees.decorators.OneShot, + "Delay": Delay, +} + +############################################################################## +# Auxiliary functions +############################################################################## + + +def get_branches(element): + + class_name = element.tag + name_arg = element.get("name") + + Class = factory.get(class_name) + + if Class is None: + print(f"Class {class_name} not found") + return None + + if "Sequence" in class_name or "Fallback" in class_name: + instance = Class(name_arg, memory=True) + for child_element in element: + child_instance = get_branches(child_element) + if child_instance is not None: + instance.add_child(child_instance) + elif "RetryUntilSuccessful" in class_name: + nfailures = element.get("num_attempts") + for child_element in element: + child_instance = get_branches(child_element) + if child_instance is not None: + child = child_instance + retry_name = "Retry_" + str(nfailures) + instance = Class(name=class_name, num_failures=int(nfailures), child=child) + elif "Repeat" in class_name: + num_cycles = element.get("num_cycles") + for child_element in element: + child_instance = get_branches(child_element) + if child_instance is not None: + child = child_instance + repeat_name = "Repeat_" + str(num_cycles) + instance = Class(name=class_name, child=child, num_success=int(num_cycles)) + elif ( + "Inverter" in class_name + or "Force" in class_name + or "KeepRunningUntilFailure" in class_name + ): + for child_element in element: + child_instance = get_branches(child_element) + if child_instance is not None: + child = child_instance + instance = Class(name=class_name, child=child) + elif "RunOnce" in class_name: + for child_element in element: + child_instance = get_branches(child_element) + if child_instance is not None: + child = child_instance + instance = Class( + name=class_name, + child=child, + policy=py_trees.common.OneShotPolicy.ON_COMPLETION, + ) + elif "Delay" in class_name: + delay_ms = element.get("delay_ms") + for child_element in element: + child_instance = get_branches(child_element) + if child_instance is not None: + child = child_instance + instance = Class(name=class_name, child=child, delay_ms=int(delay_ms)) + else: + # Check if there is a port argument + ports = {} + for arg in element.attrib: + if arg != "name": + port_name = arg + port_content = element.get(arg) + + ports[port_name] = port_content + + instance = Class(name_arg, ports) + + return instance + + +def construct_behaviour_tree_from_xml(doc): + + behavior_tree_element = doc.find(".//BehaviorTree") + + if behavior_tree_element is None: + print("No BehaviorTree found in the XML") + return None + + root_behaviour = None + for child in behavior_tree_element: + root_behaviour = get_branches(child) + + return root_behaviour + + +def add_actions_to_factory(doc): + + code_element = doc.find(".//Code") + + for element in code_element: + + # Extract formatted code + class_name = element.tag + code_text = textwrap.dedent(element.text) + formatted_code = autopep8.fix_code(code_text) + + # Copy current global namespace for safety + namespace = globals().copy() + + # Execute in the copied namespace (because of closure, classes may access their imports) + exec(formatted_code, namespace) + + # Access the class from the namespace and create an instance + class_ref = namespace[class_name] + factory[class_name] = class_ref + + +############################################################################## +# Tree factory +############################################################################## + + +class TreeFactory(rclpy.node.Node): + def __init__(self): + + super().__init__("Factory") + + def create_tree_from_file(self, tree_path, timeout=1000): + + # Open the self contained xml file + self_file = open(tree_path, "r") + self_contained_tree = self_file.read() + xml_doc = ET.fromstring(self_contained_tree) + + # Add actions to factory + add_actions_to_factory(xml_doc) + + # Init tree + root = construct_behaviour_tree_from_xml(xml_doc) + self.tree = py_trees_ros.trees.BehaviourTree( + root=root, unicode_tree_debug=False + ) + + # Setup tree + try: + self.tree.setup(timeout=timeout) + except py_trees_ros.exceptions.TimedOutError as e: + console.logerror(console.red + "Failed to setup tree" + console.reset) + self.tree.shutdown() + return None + except KeyboardInterrupt: + # not a warning, nor error, usually a user-initiated shutdown + console.logerror("Tree setup interrupted") + self.tree.shutdown() + return None + + return self.tree +` + +const packageInfo = +` + + tree_gardener + 0.2.0 + BT execution engine for BT Studio + Óscar Martínez + GPLv3 + + rclpy + py_trees_ros + python3-pep8 + + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + +` + +const setupConfig = +`[develop] +script_dir=$base/lib/tree_gardener +[install] +install_scripts=$base/lib/tree_gardener +` + +const setupPython = +`from setuptools import setup +from setuptools import find_packages + +package_name = "tree_gardener" + +setup( + name=package_name, + version="0.2.0", + packages=find_packages(exclude=["test"]), + data_files=[ + ("share/ament_index/resource_index/packages", ["resource/" + package_name]), + ("share/" + package_name, ["package.xml"]), + ], + install_requires=["setuptools"], + zip_safe=True, + author="Óscar Martínez", + author_email="oscar.robotics@tutanota.com", + maintainer="Óscar Martínez", + maintainer_email="oscar.robotics@tutanota.com", + keywords=["ROS2", "py_trees"], + classifiers=[ + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Topic :: Software Development", + ], + description="BT execution engine for BT Studio", + license="GPLv3", + tests_require=["pytest"], +) + +` + + +namespace TreeGardener { + export function addDockerFiles(zip:JSZip ) { + zip.file("tree_factory.py", treeFactory); + zip.file("tree_tools.py", treeTools); + } + + export function addLocalFiles(zip:JSZip ) { + const loc = zip.folder("tree_gardener"); + + if (loc == null) { + return + } + + loc.file("resource/tree_gardener", ""); + + loc.file("tree_gardener/tree_factory.py", treeFactory); + loc.file("tree_gardener/tree_tools.py", treeTools); + loc.file("tree_gardener/__init__.py", ""); + + loc.file("package.xml", packageInfo); + loc.file("setup.cfg", setupConfig); + loc.file("setup.py", setupPython); + } +} + +export default TreeGardener; \ No newline at end of file