diff --git a/src/aaz_dev/command/api/specs.py b/src/aaz_dev/command/api/specs.py index 4a170509..f2cedebe 100644 --- a/src/aaz_dev/command/api/specs.py +++ b/src/aaz_dev/command/api/specs.py @@ -1,4 +1,5 @@ from flask import Blueprint, jsonify, request + from utils import exceptions from utils.plane import PlaneEnum from command.controller.specs_manager import AAZSpecsManager @@ -7,6 +8,17 @@ bp = Blueprint('specs', __name__, url_prefix='/AAZ/Specs') +# modules +@bp.route("/CommandTree/Simple", methods=("GET",)) +def simple_command_tree(): + manager = AAZSpecsManager() + tree = manager.simple_tree + if not tree: + raise exceptions.ResourceNotFind("Command group not exist") + tree = tree.to_primitive() + return jsonify(tree) + + # modules @bp.route("/CommandTree/Nodes/", methods=("GET",)) def command_tree_node(node_names): @@ -38,6 +50,23 @@ def command_tree_leaf(node_names, leaf_name): return jsonify(result) +@bp.route("/CommandTree/Nodes/Leaves", methods=("POST",)) +def command_tree_leaves(): + data = request.get_json() + result = [] + manager = AAZSpecsManager() + + for command_names in data: + if command_names[0] != AAZSpecsManager.COMMAND_TREE_ROOT_NAME: + raise exceptions.ResourceNotFind(f"Command not exist: {' '.join(command_names)}") + command_names = command_names[1:] + leaf = manager.find_command(*command_names) + if not leaf: + raise exceptions.ResourceNotFind(f"Command not exist: {' '.join(command_names)}") + result.append(leaf.to_primitive()) + return jsonify(result) + + @bp.route("/CommandTree/Nodes//Leaves//Versions/", methods=("GET",)) def aaz_command_in_version(node_names, leaf_name, version_name): if node_names[0] != AAZSpecsManager.COMMAND_TREE_ROOT_NAME: diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py new file mode 100644 index 00000000..e180737d --- /dev/null +++ b/src/aaz_dev/command/controller/command_tree.py @@ -0,0 +1,626 @@ +import logging +import os +import re + +from command.model.configuration import CMDHelp, CMDCommandExample +from command.model.specs import CMDSpecsCommandGroup, CMDSpecsCommand, CMDSpecsResource, CMDSpecsCommandVersion, \ + CMDSpecsCommandTree +from command.model.specs._command_tree import CMDSpecsSimpleCommand, CMDSpecsSimpleCommandGroup, \ + CMDSpecsSimpleCommandTree +from utils import exceptions + +logger = logging.getLogger(__name__) + + +def _build_simple_command(names): + # uri = '/Commands/' + '/'.join(names[:-1]) + f'/_{names[-1]}.md' + command = CMDSpecsSimpleCommand() + command.names = names + return command + + +def _build_simple_command_group(names, aaz_path): + """ + Build Simple Command Group from directory + """ + rel_names = names + if len(names) == 1 and names[0] == 'aaz': + rel_names = [] + # uri = '/Commands/' + '/'.join(rel_names) + f'/readme.md' + full_path = os.path.join(aaz_path, 'Commands', *rel_names) + if rel_names and not os.path.exists(os.path.join(full_path, 'readme.md')): + return None + commands = {} + command_groups = {} + for dir in os.listdir(full_path): + if os.path.isfile(os.path.join(full_path, dir)): + if not dir.endswith('.md') or dir == 'readme.md': + continue + command_name = dir[1:-3] + commands[command_name] = _build_simple_command(rel_names + [command_name]) + else: + cg_name = dir + group = _build_simple_command_group(rel_names + [cg_name], aaz_path) + if group: + command_groups[cg_name] = group + cg = CMDSpecsSimpleCommandGroup() + cg.names = names + cg.commands = commands + cg.command_groups = command_groups + return cg + + +def build_simple_command_tree(aaz_path): + root = _build_simple_command_group(['aaz'], aaz_path) + tree = CMDSpecsSimpleCommandTree() + tree.root = root + tree.validate() + return tree + + +class CMDSpecsPartialCommandGroup: + def __init__(self, names, short_help, uri, aaz_path): + self.names = names + self.short_help = short_help + self.uri = uri + self.aaz_path = aaz_path + + @classmethod + def parse_command_group_info(cls, info, cg_names, aaz_path): + lines = info.splitlines(keepends=False) + + cg = CMDSpecsCommandGroup() + _, _, remaining_lines = cls._parse_title(lines) + cg.names = list(cg_names) or ["aaz"] + cg.help, remaining_lines = cls._parse_help(remaining_lines) + cg.command_groups, remaining_lines = cls._parse_groups(remaining_lines, cg_names, aaz_path) + cg.commands, _ = cls._parse_commands(remaining_lines, cg_names, aaz_path) + + return cg + + @classmethod + def _parse_title(cls, lines): + assert len(lines) > 0 and lines[0].startswith('# ') + title_line = lines[0].strip() + if not title_line.endswith('_'): # root + return None, None, lines[2:] + _, category, names = title_line.split(maxsplit=2) + return category[1: -1], names[1: -1], lines[2:] + + @classmethod + def _parse_help(cls, lines): + assert len(lines) > 0 + if lines[0].startswith('## '): # root + return None, lines + short_help, remaining_lines = cls._read_until(lines, lambda line: not line) + short_help = '\n'.join(short_help) + remaining_lines = cls._del_empty(remaining_lines) + long_help, remaining_lines = cls._read_until(remaining_lines, lambda line: line.startswith('## ')) + long_help = long_help[:-1] if long_help and not long_help[-1] else long_help # Delete last line if empty + return CMDHelp(raw_data={'short': short_help, 'lines': long_help or None}), remaining_lines + + @classmethod + def _parse_groups(cls, lines: list[str], cg_names, aaz_path): + if lines and lines[0] in ['## Groups', '## Subgroups']: + groups = [] + remaining_lines = lines[2:] + while remaining_lines and not remaining_lines[0].startswith('## '): + group, remaining_lines = cls._parse_item( + remaining_lines, CMDSpecsPartialCommandGroup, cg_names, aaz_path) + groups.append((group.names[-1], group)) + return CMDSpecsCommandGroupDict(groups), remaining_lines + return CMDSpecsCommandGroupDict([]), lines + + @classmethod + def _parse_commands(cls, lines: list[str], cg_names, aaz_path): + if lines and lines[0] in ['## Commands']: + commands = [] + remaining_lines = lines[2:] + while remaining_lines and not remaining_lines[0].startswith('## '): + command, remaining_lines = cls._parse_item( + remaining_lines, CMDSpecsPartialCommand, cg_names, aaz_path) + commands.append((command.names[-1], command)) + return CMDSpecsCommandDict(commands), remaining_lines + return CMDSpecsCommandDict([]), lines + + @classmethod + def _parse_item(cls, lines, item_cls, cg_names, aaz_path): + assert len(lines) > 1 + name_line = lines[0] + assert name_line.startswith('- [') + name = name_line[3:].split(']')[0] + uri = name_line.split('(')[-1].split(')')[0] + short_help, remaining_lines = cls._read_until(lines[1:], lambda line: not line) + remaining_lines = cls._del_empty(remaining_lines) + short_help = '\n'.join(short_help) + return item_cls(names=[*cg_names, name], short_help=short_help, uri=uri, aaz_path=aaz_path), remaining_lines + + @classmethod + def _read_until(cls, lines, predicate): + result = [] + for idx in range(0, len(lines)): + if predicate(lines[idx]): + return result, lines[idx:] + result.append(lines[idx]) + return result, [] + + @classmethod + def _del_empty(cls, lines): + for idx in range(0, len(lines)): + if lines[idx]: + return lines[idx:] + return lines + + def load(self): + with open(self.aaz_path + self.uri, "r", encoding="utf-8") as f: + content = f.read() + if self.names and self.names[0] == "aaz": + names = self.names[1:] + else: + names = self.names + cg = self.parse_command_group_info(content, names, self.aaz_path) + return cg + + +class CMDSpecsCommandDict(dict): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __getitem__(self, __key): + command = super().__getitem__(__key) + if isinstance(command, CMDSpecsPartialCommand): + command = command.load() + if command: + super().__setitem__(__key, command) + return command + + def items(self): + for key in self.keys(): + yield key, self[key] + + def values(self): + for key in self.keys(): + yield self[key] + + def get_raw_item(self, key): + return super().get(key) + + def raw_values(self): + return super().values() + + def raw_items(self): + return super().items() + + +class CMDSpecsCommandGroupDict(dict): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __getitem__(self, __key): + cg = super().__getitem__(__key) + if isinstance(cg, CMDSpecsPartialCommandGroup): + cg = cg.load() + if cg: + super().__setitem__(__key, cg) + return cg + + def items(self): + for key in self.keys(): + yield key, self[key] + + def values(self): + for key in self.keys(): + yield self[key] + + def get_raw_item(self, key): + return super().get(key) + + def raw_values(self): + return super().values() + + def raw_items(self): + return super().items() + + +class CMDSpecsPartialCommand: + _COMMAND_INFO_RE = r"# \[Command\] _(?P[A-Za-z0-9- ]+)_\n" \ + r"\n(?P(.+\n)+)\n?" \ + r"(?P\n(^[^#].*\n)+)?" \ + r"\n## Versions\n" \ + r"(?P(\n### \[(?P[a-zA-Z0-9-]+)\]\(.*\) \*\*.*\*\*\n" \ + r"\n(\n)+" \ + r"(?P\n#### examples\n" \ + r"(\n- (?P.*)\n" \ + r" ```bash\n" \ + r"( (?P.*)\n)+" \ + r" ```\n)+)?)+)" + COMMAND_INFO_RE = re.compile(_COMMAND_INFO_RE, re.MULTILINE) + _RESOURCE_INFO_RE = r"\n" + RESOURCE_INFO_RE = re.compile(_RESOURCE_INFO_RE, re.MULTILINE) + _VERSION_INFO_RE = r"### \[(?P[a-zA-Z0-9-]+)\]\(.*\) \*\*(?P.*)\*\*\n" \ + r"\n(?P(\n)+)" \ + r"(?P\n#### examples\n" \ + r"(\n- (?P.*)\n" \ + r" ```bash\n" \ + r"( (?P.*)\n)+" \ + r" ```\n)+)?" + VERSION_INFO_RE = re.compile(_VERSION_INFO_RE, re.MULTILINE) + _EXAMPLE_INFO_RE = r"- (?P.*)\n" \ + r" ```bash\n" \ + r"(?P( (.*)\n)+)" \ + r" ```\n" + EXAMPLE_INFO_RE = re.compile(_EXAMPLE_INFO_RE, re.MULTILINE) + _EXAMPLE_LINE_RE = r" (?P.*)\n" + EXAMPLE_LINE_RE = re.compile(_EXAMPLE_LINE_RE, re.MULTILINE) + + def __init__(self, names, short_help, uri, aaz_path): + self.names = names + self.short_help = short_help + self.uri = uri + self.aaz_path = aaz_path + + def load(self): + with open(self.aaz_path + self.uri, "r", encoding="utf-8") as f: + content = f.read() + command = self.parse_command_info(content, self.names) + return command + + @classmethod + def parse_command_info(cls, info, cmd_names): + command_match = re.match(cls.COMMAND_INFO_RE, info) + if not command_match: + logger.warning(f"Invalid command info markdown: \n{info}") + return None + if command_match.group("lines_help"): + lines_help = command_match.group("lines_help").strip().split("\\\n") + else: + lines_help = None + help = CMDHelp() + help.short = command_match.group("short_help").strip() + help.lines = lines_help + versions = [] + for version_match in re.finditer(cls.VERSION_INFO_RE, info): + resources = [] + for resource_match in re.finditer(cls.RESOURCE_INFO_RE, version_match.group("resources")): + resource = { + "plane": resource_match.group("plane"), + "id": resource_match.group("id"), + "version": resource_match.group("version"), + } + if resource_match.group("subresource"): + resource["subresource"] = resource_match.group("subresource") + resources.append(CMDSpecsResource(raw_data=resource)) + examples = [] + for example_match in re.finditer(cls.EXAMPLE_INFO_RE, version_match.group("examples") or ''): + example_cmd = [] + for line_match in re.finditer(cls.EXAMPLE_LINE_RE, example_match.group("example_cmds") or ''): + example_cmd.append(line_match.group("example_cmd")) + example = { + "name": example_match.group("example_desc"), + "commands": example_cmd + } + examples.append(CMDCommandExample(raw_data=example)) + version = { + "name": version_match.group("version_name"), + "resources": resources, + "examples": examples + } + if version_match.group("stage"): + version["stage"] = version_match.group("stage") + versions.append(CMDSpecsCommandVersion(raw_data=version)) + command = CMDSpecsCommand() + command.names = cmd_names + command.help = help + command.versions = sorted(versions, key=lambda v: v.name) + return command + + +class CMDSpecsPartialCommandTree: + def __init__(self, aaz_path, root=None): + self.aaz_path = aaz_path + self._root = root or CMDSpecsPartialCommandGroup(names=["aaz"], short_help='', uri="/Commands/readme.md", + aaz_path=aaz_path).load() + self._modified_command_groups = set() + self._modified_commands = set() + + @property + def root(self): + if isinstance(self._root, CMDSpecsPartialCommandGroup): + self._root = self._root.load() + return self._root + + @property + def simple_tree(self): + """ + Build and Return a Simple Command Tree from Folder Structure + """ + return build_simple_command_tree(self.aaz_path) + + def find_command_group(self, *cg_names): + """ + Find command group node by names + + :param cg_names: command group names + :return: command group node + :rtype: CMDSpecsCommandGroup | None + """ + node = self.root + idx = 0 + while idx < len(cg_names): + name = cg_names[idx] + if not node.command_groups or name not in node.command_groups: + return None + node = node.command_groups[name] + idx += 1 + return node + + def find_command(self, *cmd_names): + if len(cmd_names) < 2: + raise exceptions.InvalidAPIUsage(f"Invalid command name: '{' '.join(cmd_names)}'") + + node = self.find_command_group(*cmd_names[:-1]) + if not node: + return None + name = cmd_names[-1] + if not node.commands or name not in node.commands: + return None + leaf = node.commands[name] + return leaf + + def iter_command_groups(self, *root_cg_names): + root = self.find_command_group(*root_cg_names) + if root: + nodes = [root] + i = 0 + while i < len(nodes): + yield nodes[i] + for node in (nodes[i].command_groups or {}).values(): + nodes.append(node) + i += 1 + + def iter_commands(self, *root_node_names): + for node in self.iter_command_groups(*root_node_names): + for leaf in (node.commands or {}).values(): + yield leaf + + def create_command_group(self, *cg_names): + if len(cg_names) < 1: + raise exceptions.InvalidAPIUsage(f"Invalid Command Group name: '{' '.join(cg_names)}'") + node = self.root + idx = 0 + while idx < len(cg_names): + name = cg_names[idx] + if node.commands and name in node.commands: + raise exceptions.InvalidAPIUsage(f"Invalid Command Group name: conflict with Command name: " + f"'{' '.join(cg_names[:idx+1])}'") + if not node.command_groups or name not in node.command_groups: + if not node.command_groups: + node.command_groups = {} + names = [*cg_names[:idx+1]] + node.command_groups[name] = CMDSpecsCommandGroup({ + "names": names + }) + self._modified_command_groups.add(cg_names[:idx+1]) + node = node.command_groups[name] + idx += 1 + return node + + def update_command_group_by_ws(self, ws_node): + command_group = self.create_command_group(*ws_node.names) + if ws_node.help: + if not command_group.help: + command_group.help = CMDHelp() + if ws_node.help.short: + command_group.help.short = ws_node.help.short + if ws_node.help.lines: + command_group.help.lines = [*ws_node.help.lines] + self._modified_command_groups.add(tuple([*ws_node.names])) + return command_group + + def delete_command_group(self, *cg_names): + for _ in self.iter_commands(*cg_names): + raise exceptions.ResourceConflict("Cannot delete command group with commands") + parent = self.find_command_group(*cg_names[:-1]) + name = cg_names[-1] + if not parent or not parent.command_groups or name not in parent.command_groups: + return False + del parent.command_groups[name] + if not parent.command_groups: + parent.command_groups = None + + self._modified_command_groups.add(cg_names) + + if not parent.command_groups and not parent.commands: + # delete empty parent command group + self.delete_command_group(*cg_names[:-1]) + return True + + def create_command(self, *cmd_names): + if len(cmd_names) < 2: + raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") + node = self.create_command_group(*cmd_names[:-1]) + name = cmd_names[-1] + if node.command_groups and name in node.command_groups: + raise exceptions.InvalidAPIUsage(f"Invalid Command name: conflict with Command Group name: " + f"'{' '.join(cmd_names)}'") + if not node.commands: + node.commands = {} + elif name in node.commands: + return node.commands[name] + + command = CMDSpecsCommand() + command.names = list(cmd_names) + node.commands[name] = command + self._modified_commands.add(cmd_names) + + return command + + def delete_command(self, *cmd_names): + if len(cmd_names) < 2: + raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") + parent = self.find_command_group(*cmd_names[:-1]) + name = cmd_names[-1] + if not parent or not parent.commands or name not in parent.commands: + return False + command = parent.commands[name] + if command.versions: + raise exceptions.ResourceConflict("Cannot delete command with versions") + del parent.commands[name] + if not parent.commands: + parent.commands = None + + self._modified_commands.add(cmd_names) + + if not parent.command_groups and not parent.commands: + # delete empty parent command group + self.delete_command_group(*cmd_names[:-1]) + return True + + def delete_command_version(self, *cmd_names, version): + if len(cmd_names) < 2: + raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") + command = self.find_command(*cmd_names) + if not command or not command.versions: + return False + match_idx = None + for idx, v in enumerate(command.versions): + if v.name == version: + match_idx = idx + break + if match_idx is None: + return False + + command.versions = command.versions[:match_idx] + command.versions[match_idx+1:] + + self._modified_commands.add(cmd_names) + + if not command.versions: + # delete empty command + self.delete_command(*cmd_names) + return True + + def update_command_version(self, *cmd_names, plane, cfg_cmd): + command = self.create_command(*cmd_names) + + version = None + for v in (command.versions or []): + if v.name == cfg_cmd.version: + version = v + break + + if not version: + version = CMDSpecsCommandVersion() + version.name = cfg_cmd.version + if not command.versions: + command.versions = [] + command.versions.append(version) + + # update version resources + version.resources = [] + for r in cfg_cmd.resources: + resource = CMDSpecsResource() + resource.plane = plane + resource.id = r.id + resource.version = r.version + resource.subresource = r.subresource + version.resources.append(resource) + + self._modified_commands.add(cmd_names) + + def update_command_by_ws(self, ws_leaf): + command = self.find_command(*ws_leaf.names) + if not command: + # make sure the command exist, if command not exist, then run update_resource_cfg first + raise exceptions.InvalidAPIUsage(f"Command isn't exist: '{' '.join(ws_leaf.names)}'") + + cmd_version = None + for v in (command.versions or []): + if v.name == ws_leaf.version: + cmd_version = v + break + if not cmd_version: + raise exceptions.InvalidAPIUsage(f"Command in version isn't exist: " + f"'{' '.join(ws_leaf.names)}' '{ws_leaf.version}'") + + # compare resources + leaf_resources = {(r.id, r.version) for r in ws_leaf.resources} + cmd_version_resources = {(r.id, r.version) for r in cmd_version.resources} + if leaf_resources != cmd_version_resources: + raise exceptions.InvalidAPIUsage(f"The resources in version don't match the resources of workspace leaf: " + f"{leaf_resources} != {cmd_version_resources}") + + # update stage + cmd_version.stage = ws_leaf.stage + + # update examples + if ws_leaf.examples: + cmd_version.examples = [CMDCommandExample(e.to_primitive()) for e in ws_leaf.examples] + + # update help + if ws_leaf.help: + if not command.help: + command.help = CMDHelp() + if ws_leaf.help.short: + command.help.short = ws_leaf.help.short + if ws_leaf.help.lines: + command.help.lines = [*ws_leaf.help.lines] + + self._modified_commands.add(tuple(command.names)) + return command + + def verify_command_tree(self): + details = {} + for group in self.iter_command_groups(): + if group == self.root: + continue + if not group.help or not group.help.short: + details[' '.join(group.names)] = { + 'type': 'group', + 'help': "Miss short summary." + } + + for cmd in self.iter_commands(): + if not cmd.help or not cmd.help.short: + details[' '.join(cmd.names)] = { + 'type': 'command', + 'help': "Miss short summary." + } + if details: + raise exceptions.VerificationError(message="Invalid Command Tree", details=details) + + def verify_updated_command_tree(self): + details = {} + for group_names in self._modified_command_groups: + group = self.find_command_group(*group_names) + if group == self.root: + continue + if not group: + details[' '.join(group_names)] = { + 'type': 'group', + 'help': "Miss short summary." + } + elif not group.help or not group.help.short: + details[' '.join(group.names)] = { + 'type': 'group', + 'help': "Miss short summary." + } + + for cmd_names in self._modified_commands: + cmd = self.find_command(*cmd_names) + if not cmd: + details[' '.join(cmd_names)] = { + 'type': 'command', + 'help': "Miss short summary." + } + elif not cmd.help or not cmd.help.short: + details[' '.join(cmd.names)] = { + 'type': 'command', + 'help': "Miss short summary." + } + if details: + raise exceptions.VerificationError(message="Invalid Command Tree", details=details) + + def to_model(self): + tree = CMDSpecsCommandTree() + tree.root = self.root + return tree diff --git a/src/aaz_dev/command/controller/specs_manager.py b/src/aaz_dev/command/controller/specs_manager.py index 490262e5..c5c66888 100644 --- a/src/aaz_dev/command/controller/specs_manager.py +++ b/src/aaz_dev/command/controller/specs_manager.py @@ -1,4 +1,5 @@ import json +import logging import os import re import shutil @@ -13,6 +14,9 @@ from .cfg_reader import CfgReader from .client_cfg_reader import ClientCfgReader from .cfg_validator import CfgValidator +from .command_tree import CMDSpecsPartialCommandTree + +logger = logging.getLogger('backend') class AAZSpecsManager: @@ -27,29 +31,19 @@ def __init__(self): self.folder = Config.AAZ_PATH self.resources_folder = os.path.join(self.folder, "Resources") self.commands_folder = os.path.join(self.folder, "Commands") - self.tree = None - self._modified_command_groups = set() - self._modified_commands = set() + self._tree = None self._modified_resource_cfgs = {} self._modified_resource_client_cfgs = {} - tree_path = self.get_tree_file_path() - if not os.path.exists(tree_path): - self.tree = CMDSpecsCommandTree() - self.tree.root = CMDSpecsCommandGroup({ - "names": [self.COMMAND_TREE_ROOT_NAME] - }) - return + self._tree = CMDSpecsPartialCommandTree(self.folder) - if not os.path.isfile(tree_path): - raise ValueError(f"Invalid Command Tree file path, expect a file: {tree_path}") + @property + def tree(self): + return self._tree - try: - with open(tree_path, 'r', encoding="utf-8") as f: - data = json.load(f) - self.tree = CMDSpecsCommandTree(data) - except json.decoder.JSONDecodeError as e: - raise ValueError(f"Invalid Command Tree file: {tree_path}") from e + @property + def simple_tree(self): + return self.tree.simple_tree # Commands folder def get_tree_file_path(self): @@ -118,46 +112,17 @@ def get_resource_versions(self, plane, resource_id): versions.add(file_name[:-3]) return sorted(versions, reverse=True) - # Command Tree def find_command_group(self, *cg_names): - node = self.tree.root - idx = 0 - while idx < len(cg_names): - name = cg_names[idx] - if not node.command_groups or name not in node.command_groups: - return None - node = node.command_groups[name] - idx += 1 - return node + return self.tree.find_command_group(*cg_names) def find_command(self, *cmd_names): - if len(cmd_names) < 2: - raise exceptions.InvalidAPIUsage(f"Invalid command name: '{' '.join(cmd_names)}'") - - node = self.find_command_group(*cmd_names[:-1]) - if not node: - return None - name = cmd_names[-1] - if not node.commands or name not in node.commands: - return None - leaf = node.commands[name] - return leaf + return self.tree.find_command(*cmd_names) def iter_command_groups(self, *root_cg_names): - root = self.find_command_group(*root_cg_names) - if root: - nodes = [root] - i = 0 - while i < len(nodes): - yield nodes[i] - for node in (nodes[i].command_groups or {}).values(): - nodes.append(node) - i += 1 + yield from self.tree.iter_command_groups(*root_cg_names) def iter_commands(self, *root_node_names): - for node in self.iter_command_groups(*root_node_names): - for leaf in (node.commands or {}).values(): - yield leaf + yield from self.tree.iter_commands(*root_node_names) def load_resource_cfg_reader(self, plane, resource_id, version): key = (plane, resource_id, version) @@ -224,148 +189,34 @@ def load_resource_cfg_reader_by_command_with_version(self, cmd, version): # command tree def create_command_group(self, *cg_names): - if len(cg_names) < 1: - raise exceptions.InvalidAPIUsage(f"Invalid Command Group name: '{' '.join(cg_names)}'") - node = self.tree.root - idx = 0 - while idx < len(cg_names): - name = cg_names[idx] - if node.commands and name in node.commands: - raise exceptions.InvalidAPIUsage(f"Invalid Command Group name: conflict with Command name: " - f"'{' '.join(cg_names[:idx+1])}'") - if not node.command_groups or name not in node.command_groups: - if not node.command_groups: - node.command_groups = {} - names = [*cg_names[:idx+1]] - node.command_groups[name] = CMDSpecsCommandGroup({ - "names": names - }) - self._modified_command_groups.add(cg_names[:idx+1]) - node = node.command_groups[name] - idx += 1 - return node + return self.tree.create_command_group(*cg_names) def update_command_group_by_ws(self, ws_node): - command_group = self.create_command_group(*ws_node.names) - if ws_node.help: - if not command_group.help: - command_group.help = CMDHelp() - if ws_node.help.short: - command_group.help.short = ws_node.help.short - if ws_node.help.lines: - command_group.help.lines = [*ws_node.help.lines] - self._modified_command_groups.add(tuple([*ws_node.names])) - return command_group + return self.tree.update_command_group_by_ws(ws_node) def delete_command_group(self, *cg_names): - for _ in self.iter_commands(*cg_names): - raise exceptions.ResourceConflict("Cannot delete command group with commands") - parent = self.find_command_group(*cg_names[:-1]) - name = cg_names[-1] - if not parent or not parent.command_groups or name not in parent.command_groups: - return False - del parent.command_groups[name] - if not parent.command_groups: - parent.command_groups = None - - self._modified_command_groups.add(cg_names) - - if not parent.command_groups and not parent.commands: - # delete empty parent command group - self.delete_command_group(*cg_names[:-1]) - return True - + return self.tree.delete_command_group(*cg_names) + def create_command(self, *cmd_names): - if len(cmd_names) < 2: - raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") - node = self.create_command_group(*cmd_names[:-1]) - name = cmd_names[-1] - if node.command_groups and name in node.command_groups: - raise exceptions.InvalidAPIUsage(f"Invalid Command name: conflict with Command Group name: " - f"'{' '.join(cmd_names)}'") - if not node.commands: - node.commands = {} - elif name in node.commands: - return node.commands[name] - - command = CMDSpecsCommand() - command.names = list(cmd_names) - node.commands[name] = command - self._modified_commands.add(cmd_names) - - return command - + return self.tree.create_command(*cmd_names) + def delete_command(self, *cmd_names): - if len(cmd_names) < 2: - raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") - parent = self.find_command_group(*cmd_names[:-1]) - name = cmd_names[-1] - if not parent or not parent.commands or name not in parent.commands: - return False - command = parent.commands[name] - if command.versions: - raise exceptions.ResourceConflict("Cannot delete command with versions") - del parent.commands[name] - if not parent.commands: - parent.commands = None - - self._modified_commands.add(cmd_names) - - if not parent.command_groups and not parent.commands: - # delete empty parent command group - self.delete_command_group(*cmd_names[:-1]) - return True - + return self.tree.delete_command(*cmd_names) + def delete_command_version(self, *cmd_names, version): - if len(cmd_names) < 2: - raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") - command = self.find_command(*cmd_names) - if not command or not command.versions: - return False - match_idx = None - for idx, v in enumerate(command.versions): - if v.name == version: - match_idx = idx - break - if match_idx is None: - return False - - command.versions = command.versions[:match_idx] + command.versions[match_idx+1:] - - self._modified_commands.add(cmd_names) - - if not command.versions: - # delete empty command - self.delete_command(*cmd_names) - return True - + return self.tree.delete_command_version(*cmd_names, version=version) + def update_command_version(self, *cmd_names, plane, cfg_cmd): - command = self.create_command(*cmd_names) - - version = None - for v in (command.versions or []): - if v.name == cfg_cmd.version: - version = v - break + return self.tree.update_command_version(*cmd_names, plane=plane, cfg_cmd=cfg_cmd) + + def update_command_by_ws(self, ws_leaf): + return self.tree.update_command_by_ws(ws_leaf) + + def verify_command_tree(self): + return self.tree.verify_command_tree() - if not version: - version = CMDSpecsCommandVersion() - version.name = cfg_cmd.version - if not command.versions: - command.versions = [] - command.versions.append(version) - - # update version resources - version.resources = [] - for r in cfg_cmd.resources: - resource = CMDSpecsResource() - resource.plane = plane - resource.id = r.id - resource.version = r.version - resource.subresource = r.subresource - version.resources.append(resource) - - self._modified_commands.add(cmd_names) + def verify_updated_command_tree(self): + return self.tree.verify_updated_command_tree() def _remove_cfg(self, cfg): cfg_reader = CfgReader(cfg) @@ -400,67 +251,6 @@ def update_resource_cfg(self, cfg): key = (cfg.plane, resource.id, resource.version) self._modified_resource_cfgs[key] = cfg - def update_command_by_ws(self, ws_leaf): - command = self.find_command(*ws_leaf.names) - if not command: - # make sure the command exist, if command not exist, then run update_resource_cfg first - raise exceptions.InvalidAPIUsage(f"Command isn't exist: '{' '.join(ws_leaf.names)}'") - - cmd_version = None - for v in (command.versions or []): - if v.name == ws_leaf.version: - cmd_version = v - break - if not cmd_version: - raise exceptions.InvalidAPIUsage(f"Command in version isn't exist: " - f"'{' '.join(ws_leaf.names)}' '{ws_leaf.version}'") - - # compare resources - leaf_resources = {(r.id, r.version) for r in ws_leaf.resources} - cmd_version_resources = {(r.id, r.version) for r in cmd_version.resources} - if leaf_resources != cmd_version_resources: - raise exceptions.InvalidAPIUsage(f"The resources in version don't match the resources of workspace leaf: " - f"{leaf_resources} != {cmd_version_resources}") - - # update stage - cmd_version.stage = ws_leaf.stage - - # update examples - if ws_leaf.examples: - cmd_version.examples = [CMDCommandExample(e.to_primitive()) for e in ws_leaf.examples] - - # update help - if ws_leaf.help: - if not command.help: - command.help = CMDHelp() - if ws_leaf.help.short: - command.help.short = ws_leaf.help.short - if ws_leaf.help.lines: - command.help.lines = [*ws_leaf.help.lines] - - self._modified_commands.add(tuple(command.names)) - return command - - def verify_command_tree(self): - details = {} - for group in self.iter_command_groups(): - if group == self.tree.root: - continue - if not group.help or not group.help.short: - details[' '.join(group.names)] = { - 'type': 'group', - 'help': "Miss short summary." - } - - for cmd in self.iter_commands(): - if not cmd.help or not cmd.help.short: - details[' '.join(cmd.names)] = { - 'type': 'command', - 'help': "Miss short summary." - } - if details: - raise exceptions.VerificationError(message="Invalid Command Tree", details=details) - # client configuration def load_client_cfg_reader(self, plane): key = (plane, ) @@ -510,18 +300,15 @@ def update_client_cfg(self, cfg): self._modified_resource_client_cfgs[key] = cfg def save(self): - self.verify_command_tree() + self.verify_updated_command_tree() remove_files = [] remove_folders = [] update_files = {} command_groups = set() - tree_path = self.get_tree_file_path() - update_files[tree_path] = json.dumps(self.tree.to_primitive(), indent=2, sort_keys=True) - # command - for cmd_names in sorted(self._modified_commands): + for cmd_names in sorted(self.tree._modified_commands): cmd = self.find_command(*cmd_names) file_path = self.get_command_readme_path(*cmd_names) if not cmd: @@ -532,7 +319,7 @@ def save(self): command_groups.add(tuple(cmd_names[:-1])) - for cg_names in sorted(self._modified_command_groups): + for cg_names in sorted(self.tree._modified_command_groups): command_groups.add(tuple(cg_names)) command_groups.add(tuple(cg_names[:-1])) @@ -586,8 +373,6 @@ def save(self): with open(file_path, 'w', encoding="utf-8") as f: f.write(data) - self._modified_command_groups = set() - self._modified_commands = set() self._modified_resource_cfgs = {} self._modified_resource_client_cfgs = {} @@ -605,7 +390,7 @@ def render_command_group_readme(command_group): @staticmethod def render_command_tree_readme(tree): - assert isinstance(tree, CMDSpecsCommandTree) + assert isinstance(tree, CMDSpecsCommandTree | CMDSpecsPartialCommandTree) tmpl = get_templates()['tree'] return tmpl.render(tree=tree) diff --git a/src/aaz_dev/command/model/specs/_command_tree.py b/src/aaz_dev/command/model/specs/_command_tree.py index bab4bddd..a5315b2d 100644 --- a/src/aaz_dev/command/model/specs/_command_tree.py +++ b/src/aaz_dev/command/model/specs/_command_tree.py @@ -1,11 +1,45 @@ from command.model.configuration import CMDStageField, CMDHelp, CMDCommandExample from command.model.configuration._fields import CMDCommandNameField, CMDVersionField from schematics.models import Model +from schematics.common import NOT_NONE from schematics.types import ModelType, ListType, DictType from ._resource import CMDSpecsResource +class CMDSpecsSimpleCommand(Model): + names = ListType(field=CMDCommandNameField(), min_size=1, required=True) # full name of a command + + class Options: + serialize_when_none = False + + +class CMDSpecsSimpleCommandGroup(Model): + names = ListType(field=CMDCommandNameField(), min_size=1, required=True) # full name of a command group + command_groups = DictType( + field=ModelType("CMDSpecsSimpleCommandGroup"), + serialized_name="commandGroups", + deserialize_from="commandGroups", + export_level=NOT_NONE, + ) + commands = DictType( + field=ModelType(CMDSpecsSimpleCommand), + export_level=NOT_NONE, + ) + + class Options: + serialize_when_none = False + + +class CMDSpecsSimpleCommandTree(Model): + root = ModelType( + CMDSpecsSimpleCommandGroup + ) # the root node + + class Options: + serialize_when_none = False + + class CMDSpecsCommandVersion(Model): name = CMDVersionField(required=True) stage = CMDStageField() diff --git a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py new file mode 100644 index 00000000..bc69b044 --- /dev/null +++ b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py @@ -0,0 +1,287 @@ +import os +import unittest + +from command.controller.command_tree import CMDSpecsPartialCommand, CMDSpecsPartialCommandGroup, \ + CMDSpecsPartialCommandTree, build_simple_command_tree +from command.templates import get_templates + +COMMAND_INFO = """# [Command] _vm deallocate_ + +Deallocate a VM so that computing resources are no longer allocated (charges no longer apply). The status will change from 'Stopped' to 'Stopped (Deallocated)'. + +For an end-to-end tutorial, see https://docs.microsoft.com/azure/virtual-machines/linux/capture-image \\ +Test Second Line + +## Versions + +### [2017-03-30](/Resources/mgmt-plane/L3N1YnNjcmlwdGlvbnMve30vcmVzb3VyY2Vncm91cHMve30vcHJvdmlkZXJzL21pY3Jvc29mdC5jb21wdXRlL3ZpcnR1YWxtYWNoaW5lcy97fS9kZWFsbG9jYXRl/2017-03-30.xml) **Stable** + + + +#### examples + +- Deallocate, generalize, and capture a stopped virtual machine. + ```bash + vm deallocate -g MyResourceGroup -n MyVm + vm generalize -g MyResourceGroup -n MyVm + vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix + ``` + +- Deallocate, generalize, and capture multiple stopped virtual machines. + ```bash + vm deallocate --ids vms_ids + vm generalize --ids vms_ids + vm capture --ids vms_ids --vhd-name-prefix MyPrefix + ``` + +- Deallocate a VM. + ```bash + vm deallocate --name MyVm --no-wait --resource-group MyResourceGroup + ``` + +### [2017-12-01](/Resources/mgmt-plane/L3N1YnNjcmlwdGlvbnMve30vcmVzb3VyY2Vncm91cHMve30vcHJvdmlkZXJzL21pY3Jvc29mdC5jb21wdXRlL3ZpcnR1YWxtYWNoaW5lcy97fS9kZWFsbG9jYXRl/2017-12-01.xml) **Stable** + + + +#### examples + +- Deallocate, generalize, and capture a stopped virtual machine. + ```bash + vm deallocate -g MyResourceGroup -n MyVm + vm generalize -g MyResourceGroup -n MyVm + vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix + ``` + +- Deallocate, generalize, and capture multiple stopped virtual machines. + ```bash + vm deallocate --ids vms_ids + vm generalize --ids vms_ids + vm capture --ids vms_ids --vhd-name-prefix MyPrefix + ``` + +- Deallocate a VM. + ```bash + vm deallocate --name MyVm --no-wait --resource-group MyResourceGroup + ``` + +### [2020-06-01](/Resources/mgmt-plane/L3N1YnNjcmlwdGlvbnMve30vcmVzb3VyY2Vncm91cHMve30vcHJvdmlkZXJzL21pY3Jvc29mdC5jb21wdXRlL3ZpcnR1YWxtYWNoaW5lcy97fS9kZWFsbG9jYXRl/2020-06-01.xml) **Stable** + + + +#### examples + +- Deallocate, generalize, and capture a stopped virtual machine. + ```bash + vm deallocate -g MyResourceGroup -n MyVm + vm generalize -g MyResourceGroup -n MyVm + vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix + ``` + +- Deallocate, generalize, and capture multiple stopped virtual machines. + ```bash + vm deallocate --ids vms_ids + vm generalize --ids vms_ids + vm capture --ids vms_ids --vhd-name-prefix MyPrefix + ``` + +- Deallocate a VM. + ```bash + vm deallocate --name MyVm --no-wait --resource-group MyResourceGroup + ``` + +### [2022-11-01](/Resources/mgmt-plane/L3N1YnNjcmlwdGlvbnMve30vcmVzb3VyY2Vncm91cHMve30vcHJvdmlkZXJzL21pY3Jvc29mdC5jb21wdXRlL3ZpcnR1YWxtYWNoaW5lcy97fS9kZWFsbG9jYXRl/2022-11-01.xml) **Stable** + + + +#### examples + +- Deallocate, generalize, and capture a stopped virtual machine. + ```bash + vm deallocate -g MyResourceGroup -n MyVm + vm generalize -g MyResourceGroup -n MyVm + vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix + ``` + +- Deallocate, generalize, and capture multiple stopped virtual machines. + ```bash + vm deallocate --ids vms_ids + vm generalize --ids vms_ids + vm capture --ids vms_ids --vhd-name-prefix MyPrefix + ``` + +- Deallocate a VM. + ```bash + vm deallocate --name MyVm --no-wait --resource-group MyResourceGroup + ``` +""" + +GROUP_INFO = """# [Group] _voice-service_ + +Manage voice services + +## Subgroups + +- [gateway](/Commands/voice-service/gateway/readme.md) +: Manage communications gateway + +- [test-line](/Commands/voice-service/test-line/readme.md) +: Manage gateway test line + +## Commands + +- [check-name-availability](/Commands/voice-service/_check-name-availability.md) +: Check whether the resource name is available in the given region. +""" + +ROOT_INFO = """# Atomic Azure CLI Commands + +## Groups + +- [acat](/Commands/acat/readme.md) +: ACAT command group + +- [account](/Commands/account/readme.md) +: Manage Azure subscription information. + +- [afd](/Commands/afd/readme.md) +: Manage Azure Front Door Standard/Premium. + +- [alerts-management](/Commands/alerts-management/readme.md) +: Manage Azure Alerts Management Service Resource. + +- [amlfs](/Commands/amlfs/readme.md) +: Manage lustre file system + +- [aosm](/Commands/aosm/readme.md) +: Manage Azure Operator Service Manager resources. + +- [apic](/Commands/apic/readme.md) +: Manage Azure API Center services + +- [arc](/Commands/arc/readme.md) +: Manage Azure Arc Machines. + +- [astronomer](/Commands/astronomer/readme.md) +: Manage Azure Astronomer resources. + +- [attestation](/Commands/attestation/readme.md) +: Manage Microsoft Azure Attestation (MAA). + +- [automanage](/Commands/automanage/readme.md) +: Manage Automanage + +- [automation](/Commands/automation/readme.md) +: Manage Automation Account. + +- [billing](/Commands/billing/readme.md) +: Manage Azure Billing. + +- [billing-benefits](/Commands/billing-benefits/readme.md) +: Azure billing benefits commands + +- [blueprint](/Commands/blueprint/readme.md) +: Commands to manage blueprint. + +- [cache](/Commands/cache/readme.md) +: Azure Cache for Redis + +- [capacity](/Commands/capacity/readme.md) +: Manage capacity. + +- [cdn](/Commands/cdn/readme.md) +: Manage Azure Content Delivery Networks (CDNs). + +- [change-analysis](/Commands/change-analysis/readme.md) +: List changes for resources + +- [cloud-service](/Commands/cloud-service/readme.md) +: Manage cloud service + +- [communication](/Commands/communication/readme.md) +: Manage communication service with communication. + +- [compute](/Commands/compute/readme.md) +: Mange azure compute vm config + +- [compute-diagnostic](/Commands/compute-diagnostic/readme.md) +: Mange vm sku recommender info + +- [compute-recommender](/Commands/compute-recommender/readme.md) +: Manage sku/zone/region recommender info for compute resources + +- [confidentialledger](/Commands/confidentialledger/readme.md) +: Deploy and manage Azure confidential ledgers. + +- [confluent](/Commands/confluent/readme.md) +: Manage confluent organization + +- [connectedmachine](/Commands/connectedmachine/readme.md) +: Manage Azure Arc-Enabled Server. + +- [consumption](/Commands/consumption/readme.md) +: Manage consumption of Azure resources. +""" + + +class CommandTreeTest(unittest.TestCase): + def test_load_command(self): + command = CMDSpecsPartialCommand.parse_command_info(COMMAND_INFO, ["vm", "deallocate"]) + self.assertEqual(command.names, ["vm", "deallocate"]) + self.assertEqual(command.help.short, "Deallocate a VM so that computing resources are no longer allocated (charges no longer apply). The status will change from 'Stopped' to 'Stopped (Deallocated)'.") + self.assertListEqual(command.help.lines, [ + "For an end-to-end tutorial, see https://docs.microsoft.com/azure/virtual-machines/linux/capture-image ", + "Test Second Line" + ]) + self.assertEqual(len(command.versions), 4) + self.assertEqual(command.versions[0].name, "2017-03-30") + self.assertEqual(command.versions[0].stage, None) # Hidden when stable + self.assertEqual(len(command.versions[0].resources), 1) + self.assertEqual(command.versions[0].resources[0].plane, "mgmt-plane") + self.assertEqual(command.versions[0].resources[0].id, "/subscriptions/{}/resourcegroups/{}/providers/microsoft.compute/virtualmachines/{}/deallocate") + self.assertEqual(command.versions[0].resources[0].version, "2017-03-30") + self.assertEqual(command.versions[0].resources[0].subresource, None) + self.assertEqual(len(command.versions[0].examples), 3) + self.assertEqual(command.versions[0].examples[0].name, "Deallocate, generalize, and capture a stopped virtual machine.") + self.assertListEqual(command.versions[0].examples[0].commands, [ + "vm deallocate -g MyResourceGroup -n MyVm", + "vm generalize -g MyResourceGroup -n MyVm", + "vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix" + ]) + command.validate() + + def test_render_command(self): + command = CMDSpecsPartialCommand.parse_command_info(COMMAND_INFO, ["vm", "deallocate"]) + tmpl = get_templates()["command"] + display = tmpl.render(command=command) + self.assertEqual(display, COMMAND_INFO) + + @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") + def test_load_command_group(self): + aaz_folder = os.getenv("AAZ_FOLDER") + group = CMDSpecsPartialCommandGroup.parse_command_group_info(GROUP_INFO, ["voice-service"], aaz_folder) + self.assertIsInstance(group.command_groups.get_raw_item('gateway'), CMDSpecsPartialCommandGroup) + self.assertEqual(group.names, ["voice-service"]) + self.assertEqual(group.help.short, "Manage voice services") + self.assertEqual(group.help.lines, None) + self.assertEqual(len(group.command_groups), 2) + self.assertEqual(group.command_groups["gateway"].names, ["voice-service", "gateway"]) + self.assertEqual(group.command_groups["gateway"].help.short, "Manage communications gateway") + self.assertEqual(group.command_groups["test-line"].names, ["voice-service", "test-line"]) + self.assertEqual(group.command_groups["test-line"].help.short, "Manage gateway test line") + self.assertEqual(len(group.commands), 1) + self.assertEqual(group.commands["check-name-availability"].names, ["voice-service", "check-name-availability"]) + self.assertEqual(group.commands["check-name-availability"].help.short, "Check whether the resource name is available in the given region.") + group.validate() + + @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") + def test_partial_command_group_to_primitive(self): + aaz_folder = os.getenv("AAZ_FOLDER") + command_tree = CMDSpecsPartialCommandTree(aaz_folder) + cg = command_tree.find_command_group('acat') + self.assertIsInstance(cg.command_groups.get_raw_item('report'), CMDSpecsPartialCommandGroup) + + @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") + def test_simple_command_tree(self): + aaz_folder = os.getenv("AAZ_FOLDER") + simple_tree = build_simple_command_tree(aaz_folder) + self.assertGreater(len(simple_tree.root.command_groups), 0) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 3bb3a9f4..bbdfc269 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -8,16 +8,7 @@ import FolderIcon from "@mui/icons-material/Folder"; import EditIcon from '@mui/icons-material/Edit'; import { Box, Checkbox, FormControl, Typography, Select, MenuItem, styled, TypographyProps, InputLabel, IconButton } from "@mui/material"; import { CLIModViewCommand, CLIModViewCommandGroup, CLIModViewCommandGroups, CLIModViewCommands, CLIModViewProfile } from "./CLIModuleCommon"; - - -interface CLIModGeneratorProfileCommandTreeProps { - profileCommandTree: ProfileCommandTree, - onChange: (newProfileCommandTree: ProfileCommandTree) => void, -} - -interface CLIModGeneratorProfileCommandTreeSate { - defaultExpanded: string[], -} +import { CLISpecsCommand, CLISpecsCommandGroup, CLISpecsSimpleCommand, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommandTree } from "./CLIModuleGenerator"; const CommandGroupTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, @@ -47,221 +38,410 @@ const UnregisteredTypography = styled(SelectionTypography)(() = color: '#d9c136', })) +interface CommandItemProps { + command: ProfileCTCommand, + onUpdateCommand: (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => void, + onLoadCommand(names: string[]): Promise, +} -class CLIModGeneratorProfileCommandTree extends React.Component { - - constructor(props: CLIModGeneratorProfileCommandTreeProps) { - super(props); - this.state = { - defaultExpanded: GetDefaultExpanded(this.props.profileCommandTree) - } - } - - onSelectCommandGroup = (commandGroupId: string, selected: boolean) => { - const newTree = updateProfileCommandTree(this.props.profileCommandTree, commandGroupId, selected); - this.props.onChange(newTree); - } - - onSelectCommand = (commandId: string, selected: boolean) => { - const newTree = updateProfileCommandTree(this.props.profileCommandTree, commandId, selected); - this.props.onChange(newTree); - } - - onSelectCommandVersion = (commandId: string, version: string) => { - const newTree = updateProfileCommandTree(this.props.profileCommandTree, commandId, true, version); - this.props.onChange(newTree); - } - - onSelectCommandRegistered = (commandId: string, registered: boolean) => { - const newTree = updateProfileCommandTree(this.props.profileCommandTree, commandId, true, undefined, registered); - this.props.onChange(newTree); - } - - render() { - const { defaultExpanded } = this.state; - const renderCommand = (command: ProfileCTCommand) => { - const leafName = command.names[command.names.length - 1]; - const selected = command.selectedVersion !== undefined; - return ( - = React.memo(({ + command, + onUpdateCommand, + onLoadCommand, +}) => { + const leafName = command.names[command.names.length - 1]; + + const selectCommand = React.useCallback((selected: boolean) => { + onUpdateCommand(leafName, (oldCommand) => { + if (oldCommand.versions === undefined && selected === true) { + onLoadCommand(oldCommand.names); + } + return { + ...oldCommand, + loading: (selected && oldCommand.versions === undefined), + selected: selected, + selectedVersion: selected ? (oldCommand.selectedVersion ? oldCommand.selectedVersion : (oldCommand.versions ? oldCommand.versions[0].name : undefined)) : oldCommand.selectedVersion, + modified: true, + } + }); + }, [onUpdateCommand, onLoadCommand]); + + const selectVersion = React.useCallback((version: string) => { + onUpdateCommand(leafName, (oldCommand) => { + return { + ...oldCommand, + selectedVersion: version, + modified: true, + } + }); + }, [onUpdateCommand]); + + const selectRegistered = React.useCallback((registered: boolean) => { + onUpdateCommand(leafName, (oldCommand) => { + return { + ...oldCommand, + registered: registered, + modified: true, + } + }); + }, [onUpdateCommand]); + + return ( + + { + selectCommand(!command.selected); + event.stopPropagation(); + event.preventDefault(); + }} + /> + - { - this.onSelectCommand(command.id, !selected); - event.stopPropagation(); - event.preventDefault(); - }} - /> - {/* */} + {leafName} - {leafName} - - {!command.modified && command.selectedVersion !== undefined && { - this.onSelectCommand(command.id, true); - }} - > - - } - {command.modified && } - + {!command.modified && command.selectedVersion !== undefined && { + selectCommand(true); + }} + > + + } + {command.modified && } - {command.selectedVersion !== undefined && + {command.versions !== undefined && command.selectedVersion !== undefined && + - - - Version - - - - Command table - { + selectVersion(event.target.value); + }} + size="small" + > + {command.versions!.map((version) => ( + + {version.name} - - - } - + ))} + + + + Command table + + } - onClick={(event) => { - event.stopPropagation(); - event.preventDefault(); - }} - /> - ) - } - - const renderCommandGroup = (commandGroup: ProfileCTCommandGroup) => { - const nodeName = commandGroup.names[commandGroup.names.length - 1]; - const selected = commandGroup.selectedCommands > 0 && commandGroup.totalCommands === commandGroup.selectedCommands; - return ( - - 0} - onClick={(event) => { - this.onSelectCommandGroup(commandGroup.id, !selected); - event.stopPropagation(); - event.preventDefault(); - }} - /> - - {nodeName} + Loading... } + } + onClick={(event) => { + event.stopPropagation(); + event.preventDefault(); + }} + /> + ); +}); + +interface CommandGroupItemProps { + commandGroup: ProfileCTCommandGroup, + onUpdateCommandGroup: (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => void, + onLoadCommands: (names: string[][]) => Promise, +} + +const CommandGroupItem: React.FC = React.memo(({ + commandGroup, + onUpdateCommandGroup, + onLoadCommands, +}) => { + const nodeName = commandGroup.names[commandGroup.names.length - 1]; + const selected = commandGroup.selected ?? false; + + const onUpdateCommand = React.useCallback((name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => { + onUpdateCommandGroup(nodeName, (oldCommandGroup) => { + const commands = { + ...oldCommandGroup.commands, + [name]: updater(oldCommandGroup.commands![name]), + }; + const selected = calculateSelected(commands, oldCommandGroup.commandGroups ?? {}); + return { + ...oldCommandGroup, + commands: commands, + selected: selected, + } + }); + }, [onUpdateCommandGroup]); + + const onUpdateSubCommandGroup = React.useCallback((name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { + onUpdateCommandGroup(nodeName, (oldCommandGroup) => { + const commandGroups = { + ...oldCommandGroup.commandGroups, + [name]: updater(oldCommandGroup.commandGroups![name]), + } + const commands = oldCommandGroup.commands; + const selected = calculateSelected(commands ?? {}, commandGroups); + return { + ...oldCommandGroup, + commandGroups: commandGroups, + selected: selected, + }; + }); + }, [onUpdateCommandGroup]); + + const onLoadCommand = React.useCallback(async (names: string[]) => { + await onLoadCommands([names]); + }, [onLoadCommands]); + + const updateCommandSelected = (command: ProfileCTCommand, selected: boolean): ProfileCTCommand => { + if (selected === command.selected) { + return command; + } + return { + ...command, + selected: selected, + selectedVersion: selected ? (command.selectedVersion ? command.selectedVersion : (command.versions ? command.versions[0].name : undefined)) : command.selectedVersion, + modified: true, + } + }; + + const updateGroupSelected = (group: ProfileCTCommandGroup, selected: boolean): ProfileCTCommandGroup => { + if (selected === group.selected) { + return group; + } + const commands = group.commands ? Object.fromEntries(Object.entries(group.commands).map(([key, value]) => [key, updateCommandSelected(value, selected)]) ) : undefined; + const commandGroups = group.commandGroups ? Object.fromEntries(Object.entries(group.commandGroups).map(([key, value]) => [key, updateGroupSelected(value, selected)]) ) : undefined; + return { + ...group, + commands: commands, + commandGroups: commandGroups, + selected: selected, + } + } + + const selectCommandGroup = React.useCallback((selected: boolean) => { + onUpdateCommandGroup(nodeName, (oldCommandGroup) => { + const selectedGroup = updateGroupSelected(oldCommandGroup, selected); + const [loadingNamesList, newGroup] = prepareLoadCommandsOfCommandGroup(selectedGroup); + if (loadingNamesList.length > 0) { + onLoadCommands(loadingNamesList); + } + return newGroup; + }); + }, [onUpdateCommandGroup, onLoadCommands]); + + return ( + + { + selectCommandGroup(!selected); event.stopPropagation(); event.preventDefault(); }} - > - {commandGroup.commands !== undefined && commandGroup.commands.map((command) => renderCommand(command))} - {commandGroup.commandGroups !== undefined && commandGroup.commandGroups.map((group) => renderCommandGroup(group))} - - ) + /> + + {nodeName} + } + > + {commandGroup.commands !== undefined && Object.values(commandGroup.commands).map((command) => ( + + ))} + {commandGroup.commandGroups !== undefined && Object.values(commandGroup.commandGroups).map((group) => ( + + ))} + + ); +}); + +interface CLIModGeneratorProfileCommandTreeProps { + profile?: string, + profileCommandTree: ProfileCommandTree, + onChange: (updater: ((oldProfileCommandTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => void, + onLoadCommands: (namesList: string[][]) => Promise, +} + +const CLIModGeneratorProfileCommandTree: React.FC = ({ + profileCommandTree, + onChange, + onLoadCommands, +}) => { + const [defaultExpanded, _] = React.useState(GetDefaultExpanded(profileCommandTree)); + + const onUpdateCommandGroup = React.useCallback((name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { + onChange((profileCommandTree) => { + return { + ...profileCommandTree, + commandGroups: { + ...profileCommandTree.commandGroups, + [name]: updater(profileCommandTree.commandGroups[name]), + } + } + }); + }, [onChange]); + + const handleBatchedLoadedCommands = React.useCallback((commands: CLISpecsCommand[]) => { + onChange((profileCommandTree) => { + const newTree = commands.reduce((tree, command) => { + return genericUpdateCommand(tree, command.names, (unloadedCommand) => { + return decodeProfileCTCommand(command, unloadedCommand.selected, unloadedCommand.modified, unloadedCommand.registered); + }) ?? tree; + }, profileCommandTree); + return newTree; + }) + }, [onChange]); + + const onLoadAndDecodeCommands = React.useCallback(async (names: string[][]) => { + const commands = await onLoadCommands(names); + handleBatchedLoadedCommands(commands); + }, [onLoadCommands]); + + + React.useEffect(() => { + const [loadingNamesList, newTree] = PrepareLoadCommands(profileCommandTree); + if (loadingNamesList.length > 0) { + onChange(newTree); + onLoadCommands(loadingNamesList).then((commands) => { + handleBatchedLoadedCommands(commands); + }); } + }, [profileCommandTree]); - return ( + return ( + } - defaultExpandIcon={}> - {this.props.profileCommandTree.commandGroups.map((commandGroup) => renderCommandGroup(commandGroup))} + defaultExpandIcon={} + > + {Object.values(profileCommandTree.commandGroups).map((commandGroup) => ( + + ))} - ) - } + + ); } interface ProfileCommandTree { name: string; - commandGroups: ProfileCTCommandGroup[]; + commandGroups: ProfileCTCommandGroups; +} + +interface ProfileCTCommandGroups { + [name: string]: ProfileCTCommandGroup; +} + +interface ProfileCTCommands { + [name: string]: ProfileCTCommand; } interface ProfileCTCommandGroup { id: string; names: string[]; - help: string; + // We use simple command tree now. + // `help` is not used. + // help: string; - commandGroups?: ProfileCTCommandGroup[]; - commands?: ProfileCTCommand[]; + commandGroups?: ProfileCTCommandGroups; + commands?: ProfileCTCommands; waitCommand?: CLIModViewCommand; - totalCommands: number; - selectedCommands: number; + loading: boolean; + selected?: boolean; } interface ProfileCTCommand { id: string; names: string[]; - help: string; + // help: string; - versions: ProfileCTCommandVersion[]; + versions?: ProfileCTCommandVersion[]; selectedVersion?: string; registered?: boolean; modified: boolean; + + loading: boolean; + selected: boolean; } interface ProfileCTCommandVersion { @@ -277,35 +457,51 @@ function decodeProfileCTCommandVersion(response: any): ProfileCTCommandVersion { } -function decodeProfileCTCommand(response: any): ProfileCTCommand { - const versions = response.versions.map((value: any) => decodeProfileCTCommandVersion(value)); - return { +function decodeProfileCTCommand(response: CLISpecsCommand, selected: boolean = false, modified: boolean = false, registered: boolean | undefined = undefined): ProfileCTCommand { + const versions = response.versions?.map((value: any) => decodeProfileCTCommandVersion(value)); + const command = { id: response.names.join('/'), names: [...response.names], - help: response.help.short, + // help: response.help.short, versions: versions, - modified: false, + modified: modified, + loading: false, + selected: selected, + registered: registered, + } + if (selected) { + const selectedVersion = versions ? versions[0].name : undefined; + return { + ...command, + selectedVersion: selectedVersion, + } + } else { + return command; } } -function decodeProfileCTCommandGroup(response: any): ProfileCTCommandGroup { - const commands = response.commands !== undefined ? Object.keys(response.commands).map((name: string) => decodeProfileCTCommand(response.commands[name])) : undefined; - const commandGroups = response.commandGroups !== undefined ? Object.keys(response.commandGroups).map((name: string) => decodeProfileCTCommandGroup(response.commandGroups[name])) : undefined; - let totalCommands = commands?.length ?? 0; - totalCommands = commandGroups?.reduce((previousValue, value) => previousValue + value.totalCommands, totalCommands) ?? totalCommands; +function decodeProfileCTCommandGroup(response: CLISpecsCommandGroup, selected: boolean = false): ProfileCTCommandGroup { + const commands = response.commands !== undefined ? Object.fromEntries( + Object.entries(response.commands).map(([name, command]) => [name, decodeProfileCTCommand(command, selected, selected)]) + ) : undefined; + const commandGroups = response.commandGroups !== undefined ? Object.fromEntries( + Object.entries(response.commandGroups).map(([name, group]) => [name, decodeProfileCTCommandGroup(group, selected)]) + ) : undefined; return { id: response.names.join('/'), names: [...response.names], - help: response.help.short, + // help: response.help?.short ?? '', commandGroups: commandGroups, commands: commands, - totalCommands: totalCommands, - selectedCommands: 0, + loading: false, + selected: selected, } } -function BuildProfileCommandTree(profileName: string, response: any): ProfileCommandTree { - const commandGroups: ProfileCTCommandGroup[] = response.commandGroups !== undefined ? Object.keys(response.commandGroups).map((name: string) => decodeProfileCTCommandGroup(response.commandGroups[name])) : []; +function BuildProfileCommandTree(profileName: string, response: CLISpecsCommandGroup): ProfileCommandTree { + const commandGroups = response.commandGroups !== undefined ? Object.fromEntries( + Object.entries(response.commandGroups).map(([name, group]) => [name, decodeProfileCTCommandGroup(group)]) + ) : {}; return { name: profileName, commandGroups: commandGroups, @@ -313,170 +509,172 @@ function BuildProfileCommandTree(profileName: string, response: any): ProfileCom } function getDefaultExpandedOfCommandGroup(commandGroup: ProfileCTCommandGroup): string[] { - const expandedIds = commandGroup.commandGroups?.flatMap(value => [value.id, ...getDefaultExpandedOfCommandGroup(value)]) ?? []; + const expandedIds = commandGroup.commandGroups ? Object.values(commandGroup.commandGroups).flatMap(value => value.selected !== false ? [value.id, ...getDefaultExpandedOfCommandGroup(value)] : []) : []; return expandedIds; } - function GetDefaultExpanded(tree: ProfileCommandTree): string[] { - return tree.commandGroups.flatMap(value => { + return Object.values(tree.commandGroups).flatMap(value => { const ids = getDefaultExpandedOfCommandGroup(value); - if (value.selectedCommands > 0) { + if (value.selected !== false) { ids.push(value.id); } return ids; }); } -function updateCommand(command: ProfileCTCommand, commandId: string, selected: boolean, version: string | undefined, registered: boolean | undefined): ProfileCTCommand { - if (command.id !== commandId) { - return command; - } - - if (selected) { - const selectedVersion = version ?? command.selectedVersion ?? command.versions[0].name; - const registerCommand = registered ?? command.registered ?? true; - return { - ...command, - selectedVersion: selectedVersion, - registered: registerCommand, - modified: true, +function prepareLoadCommandsOfCommandGroup(commandGroup: ProfileCTCommandGroup): [string[][], ProfileCTCommandGroup] { + const namesList: string[][] = []; + const commands = commandGroup.commands ? Object.fromEntries(Object.entries(commandGroup.commands).map(([key, value]) => { + if (value.selected === true && value.versions === undefined && value.loading === false) { + namesList.push(value.names); + return [key, { + ...value, + loading: true, + }]; } - + return [key, value]; + })) : undefined; + const commandGroups = commandGroup.commandGroups ? Object.fromEntries(Object.entries(commandGroup.commandGroups).map(([key, value]) => { + const [namesListSub, updatedGroup] = prepareLoadCommandsOfCommandGroup(value); + namesList.push(...namesListSub); + return [key, updatedGroup]; + })) : undefined; + if (namesList.length > 0) { + return [namesList, { + ...commandGroup, + commands: commands, + commandGroups: commandGroups, + }]; } else { - return { - ...command, - selectedVersion: undefined, - registered: undefined, - modified: true, - } + return [[], commandGroup]; } } -function updateCommandGroup(commandGroup: ProfileCTCommandGroup, id: string, selected: boolean, version: string | undefined, registered: boolean | undefined): ProfileCTCommandGroup { - if (commandGroup.id !== id && !id.startsWith(`${commandGroup.id}/`)) { - return commandGroup; - } - let commands: ProfileCTCommand[] | undefined = undefined; - let commandGroups: ProfileCTCommandGroup[] | undefined = undefined; - - if (commandGroup.id === id) { - commands = commandGroup.commands?.map((value) => updateCommand(value, value.id, selected, version, registered)); - commandGroups = commandGroup.commandGroups?.map((value) => updateCommandGroup(value, value.id, selected, version, registered)); +function PrepareLoadCommands(tree: ProfileCommandTree): [string[][], ProfileCommandTree] { + const namesList: string[][] = []; + const commandGroups = Object.fromEntries(Object.entries(tree.commandGroups).map(([key, value]) => { + const [namesListSub, updatedGroup] = prepareLoadCommandsOfCommandGroup(value); + namesList.push(...namesListSub); + return [key, updatedGroup]; + })); + if (namesList.length > 0) { + return [namesList, { + ...tree, + commandGroups: commandGroups, + }]; } else { - commands = commandGroup.commands?.map((value) => updateCommand(value, id, selected, version, registered)); - commandGroups = commandGroup.commandGroups?.map((value) => updateCommandGroup(value, id, selected, version, registered)); + return [[], tree]; } +} - let selectedCommands = commands?.reduce((pre, value) => { return value.selectedVersion !== undefined ? pre + 1 : pre }, 0) ?? 0; - selectedCommands += commandGroups?.reduce((pre, value) => { return pre + value.selectedCommands }, 0) ?? 0; - - return { - ...commandGroup, +function genericUpdateCommand(tree: ProfileCommandTree, names: string[], updater: (command: ProfileCTCommand) => ProfileCTCommand | undefined): ProfileCommandTree | undefined { + const nodes: ProfileCTCommandGroup[] = []; + for (const name of names.slice(0, -1)) { + const node = nodes.length === 0 ? tree : nodes[nodes.length - 1]; + if (node.commandGroups === undefined) { + throw new Error("Invalid names: " + names.join(' ')); + } + nodes.push(node.commandGroups[name]); + } + let currentCommandGroup = nodes[nodes.length - 1]; + const updatedCommand = updater(currentCommandGroup.commands![names[names.length - 1]]); + if (updatedCommand === undefined) { + return undefined; + } + const commands = { + ...currentCommandGroup.commands, + [names[names.length - 1]]: updatedCommand, + }; + const groupSelected = calculateSelected(commands, currentCommandGroup.commandGroups!); + currentCommandGroup = { + ...currentCommandGroup, commands: commands, - commandGroups: commandGroups, - selectedCommands: selectedCommands, + selected: groupSelected, + }; + for (const node of nodes.reverse().slice(1)) { + const commandGroups = { + ...node.commandGroups, + [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, + } + const selected = calculateSelected(node.commands ?? {}, commandGroups); + currentCommandGroup = { + ...node, + commandGroups: commandGroups, + selected: selected, + } } -} - -function updateProfileCommandTree(tree: ProfileCommandTree, id: string, selected: boolean, version: string | undefined = undefined, registered: boolean | undefined = undefined): ProfileCommandTree { - const commandGroups = tree.commandGroups.map((value) => updateCommandGroup(value, id, selected, version, registered)); return { ...tree, - commandGroups: commandGroups + commandGroups: { + ...tree.commandGroups, + [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, + } } } -function updateCommandByModView(command: ProfileCTCommand, view: CLIModViewCommand): ProfileCTCommand { - if (command.id !== view.names.join('/')) { - throw new Error("Invalid command names: " + view.names.join(' ')) +function calculateSelected(commands: ProfileCTCommands, commandGroups: ProfileCTCommandGroups): boolean | undefined { + const commandsAllSelected = Object.values(commands).reduce((pre, value) => { return pre && value.selected }, true); + const commandsAllUnselected = Object.values(commands).reduce((pre, value) => { return pre && !value.selected }, true); + const commandGroupsAllSelected = Object.values(commandGroups).reduce((pre, value) => { return pre && value.selected === true }, true); + const commandGroupsAllUnselected = Object.values(commandGroups).reduce((pre, value) => { return pre && value.selected === false }, true); + if (commandsAllUnselected && commandGroupsAllUnselected) { + return false; + } else if (commandsAllSelected && commandGroupsAllSelected) { + return true; + } else { + return undefined; } +} + +function initializeCommandByModView(view: CLIModViewCommand | undefined, simpleCommand: CLISpecsSimpleCommand): ProfileCTCommand { return { - ...command, - selectedVersion: view.version, - registered: view.registered, + id: simpleCommand.names.join('/'), + names: simpleCommand.names, + modified: false, + loading: false, + selected: view !== undefined && view.version !== undefined, + selectedVersion: view !== undefined ? view.version : undefined, + registered: view !== undefined ? view.registered : undefined, } } -function updateCommandGroupByModView(commandGroup: ProfileCTCommandGroup, view: CLIModViewCommandGroup): ProfileCTCommandGroup { - if (commandGroup.id !== view.names.join('/')) { - throw new Error("Invalid command group names: " + view.names.join(' ')) +function initializeCommandGroupByModView(view: CLIModViewCommandGroup | undefined, simpleCommandGroup: CLISpecsSimpleCommandGroup): ProfileCTCommandGroup { + const commands = simpleCommandGroup.commands !== undefined ? Object.fromEntries(Object.entries(simpleCommandGroup.commands).map(([key, value]) => [key, initializeCommandByModView(view?.commands?.[key], value)]) ) : undefined; + const commandGroups = simpleCommandGroup.commandGroups !== undefined ? Object.fromEntries(Object.entries(simpleCommandGroup.commandGroups).map(([key, value]) => [key, initializeCommandGroupByModView(view?.commandGroups?.[key], value)]) ) : undefined; + const leftCommands = Object.entries(view?.commands ?? {}).filter(([key, _]) => commands?.[key] === undefined).map(([_, value]) => value.names).map((names) => '`az ' + names.join(" ") + '`'); + const leftCommandGroups = Object.entries(view?.commandGroups ?? {}).filter(([key, _]) => commandGroups?.[key] === undefined).map(([_, value]) => value.names).map((names) => '`az ' + names.join(" ") + '`'); + const errors = []; + if (leftCommands.length > 0) { + errors.push(`Miss commands in aaz: ${leftCommands.join(', ')}`); } - let commands = commandGroup.commands; - if (view.commands !== undefined) { - const keys = new Set(Object.keys(view.commands!)); - commands = commandGroup.commands?.map((value) => { - if (keys.has(value.names[value.names.length - 1])) { - keys.delete(value.names[value.names.length - 1]) - return updateCommandByModView(value, view.commands![value.names[value.names.length - 1]]) - } else { - return value; - } - }) - if (keys.size > 0) { - const commandNames: string[] = []; - keys.forEach(key => { - commandNames.push('`az ' + view.commands![key].names.join(" ") + '`') - }) - throw new Error("Miss commands in aaz: " + commandNames.join(', ')) - } + if (leftCommandGroups.length > 0) { + errors.push(`Miss command groups in aaz: ${leftCommandGroups.join(', ')}`); } - - let commandGroups = commandGroup.commandGroups; - if (view.commandGroups !== undefined) { - const keys = new Set(Object.keys(view.commandGroups!)); - commandGroups = commandGroup.commandGroups?.map((value) => { - if (keys.has(value.names[value.names.length - 1])) { - keys.delete(value.names[value.names.length - 1]) - return updateCommandGroupByModView(value, view.commandGroups![value.names[value.names.length - 1]]) - } else { - return value; - } - }) - if (keys.size > 0) { - const commandGroupNames: string[] = []; - keys.forEach(key => { - commandGroupNames.push('`az ' + view.commandGroups![key].names.join(" ") + '`') - }) - throw new Error("Miss command groups in aaz: " + commandGroupNames.join(', ')) - } + if (errors.length > 0) { + throw new Error('\n' + errors.join('\n') + '\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.'); } - - let selectedCommands = commands?.reduce((pre, value) => { return value.selectedVersion !== undefined ? pre + 1 : pre }, 0) ?? 0; - selectedCommands += commandGroups?.reduce((pre, value) => { return pre + value.selectedCommands }, 0) ?? 0; + const selected = calculateSelected(commands ?? {}, commandGroups ?? {}); return { - ...commandGroup, + id: simpleCommandGroup.names.join('/'), + names: simpleCommandGroup.names, commands: commands, commandGroups: commandGroups, - selectedCommands: selectedCommands, - waitCommand: view.waitCommand, + waitCommand: view?.waitCommand, + loading: false, + selected: selected, } } -function UpdateProfileCommandTreeByModView(tree: ProfileCommandTree, view: CLIModViewProfile): ProfileCommandTree { - let commandGroups = tree.commandGroups; - if (view.commandGroups !== undefined) { - const keys = new Set(Object.keys(view.commandGroups)); - commandGroups = tree.commandGroups.map((value) => { - if (keys.has(value.names[value.names.length - 1])) { - keys.delete(value.names[value.names.length - 1]) - return updateCommandGroupByModView(value, view.commandGroups![value.names[value.names.length - 1]]) - } else { - return value; - } - }) - if (keys.size > 0) { - const commandGroupNames: string[] = []; - keys.forEach(key => { - commandGroupNames.push('`az ' + view.commandGroups![key].names.join(" ") + '`') - }) - throw new Error("Miss command groups in aaz: " + commandGroupNames.join(', ')) - } +function InitializeCommandTreeByModView(profileName: string, view: CLIModViewProfile | null, simpleTree: CLISpecsSimpleCommandTree): ProfileCommandTree { + const commandGroups = Object.fromEntries(Object.entries(simpleTree.root.commandGroups).map(([key, value]) => [key, initializeCommandGroupByModView(view?.commandGroups?.[key], value)])); + const leftCommandGroups = Object.entries(view?.commandGroups ?? {}).filter(([key, _]) => commandGroups?.[key] === undefined).map(([_, value]) => value.names).map((names) => '`az ' + names.join(" ") + '`'); + if (leftCommandGroups.length > 0) { + throw new Error(`\nMiss command groups in aaz: ${leftCommandGroups.join(', ')}\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.`); } - return { - ...tree, - commandGroups: commandGroups + name: profileName, + commandGroups: commandGroups, } } @@ -494,7 +692,7 @@ function ExportModViewCommand(command: ProfileCTCommand): CLIModViewCommand | un } function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModViewCommandGroup | undefined { - if (commandGroup.selectedCommands === 0) { + if (commandGroup.selected === false) { return undefined } @@ -502,7 +700,7 @@ function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModV if (commandGroup.commands !== undefined) { commands = {} - commandGroup.commands!.forEach(value => { + Object.values(commandGroup.commands!).forEach(value => { const view = ExportModViewCommand(value); if (view !== undefined) { commands![value.names[value.names.length - 1]] = view; @@ -514,7 +712,7 @@ function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModV if (commandGroup.commandGroups !== undefined) { commandGroups = {} - commandGroup.commandGroups!.forEach(value => { + Object.values(commandGroup.commandGroups!).forEach(value => { const view = ExportModViewCommandGroup(value); if (view !== undefined) { commandGroups![value.names[value.names.length - 1]] = view; @@ -533,7 +731,7 @@ function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModV function ExportModViewProfile(tree: ProfileCommandTree): CLIModViewProfile { const commandGroups: CLIModViewCommandGroups = {}; - tree.commandGroups.forEach(value => { + Object.values(tree.commandGroups).forEach(value => { const view = ExportModViewCommandGroup(value); if (view !== undefined) { commandGroups[value.names[value.names.length - 1]] = view; @@ -550,4 +748,4 @@ export default CLIModGeneratorProfileCommandTree; export type { ProfileCommandTree, } -export { BuildProfileCommandTree, UpdateProfileCommandTreeByModView, ExportModViewProfile } \ No newline at end of file +export { InitializeCommandTreeByModView, BuildProfileCommandTree, ExportModViewProfile } diff --git a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx b/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx index c78d29db..7cbd5ff4 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx @@ -3,9 +3,9 @@ import Tabs from "@mui/material/Tabs"; import Tab from "@mui/material/Tab"; interface CLIModGeneratorProfileTabsProps { - value: number; + value: string; profiles: string[]; - onChange: (newValue: number) => void; + onChange: (newValue: string) => void; } @@ -18,14 +18,14 @@ class CLIModGeneratorProfileTabs extends React.Component { - onChange(newValue) + onChange(newValue); }} aria-label="Vertical tabs example" sx={{ borderRight: 1, borderColor: "divider" }} > - {profiles.map((profile, idx) => { - return ; + {profiles.map((profile, _idx) => { + return ; })} ); diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index b611a279..c007bd55 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -16,11 +16,121 @@ import { import { useParams } from "react-router"; import axios from "axios"; import CLIModGeneratorToolBar from "./CLIModGeneratorToolBar"; -import CLIModGeneratorProfileCommandTree, { BuildProfileCommandTree, ExportModViewProfile, ProfileCommandTree, UpdateProfileCommandTreeByModView } from "./CLIModGeneratorProfileCommandTree"; +import CLIModGeneratorProfileCommandTree, { ExportModViewProfile, InitializeCommandTreeByModView, ProfileCommandTree } from "./CLIModGeneratorProfileCommandTree"; import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; import { CLIModView, CLIModViewProfiles } from "./CLIModuleCommon"; +interface CLISpecsSimpleCommand { + names: string[], +} + +interface CLISpecsSimpleCommands { + [name: string]: CLISpecsSimpleCommand, +} + +interface CLISpecsSimpleCommandGroups { + [name: string]: CLISpecsSimpleCommandGroup, +} + +interface CLISpecsSimpleCommandGroup { + names: string[], + commands: CLISpecsSimpleCommands, + commandGroups: CLISpecsSimpleCommandGroups, +} + +interface CLISpecsSimpleCommandTree { + root: CLISpecsSimpleCommandGroup, +} + +interface CLISpecsHelp { + short: string, + lines?: string[], +} + +interface CLISpecsResource { + plane: string, + id: string, + version: string, + subresource?: string, +} + +interface CLISpecsCommandExample { + name: string, + commands: string[], +} + +interface CLISpecsCommandVersion { + name: string, + stage?: string, + resources: CLISpecsResource[], + examples?: CLISpecsCommandExample[], +} + +interface CLISpecsCommand { + names: string[], + help: CLISpecsHelp, + versions: CLISpecsCommandVersion[], +} + +interface CLISpecsCommandGroup { + names: string[], + help?: CLISpecsHelp, + commands?: CLISpecsCommands, + commandGroups?: CLISpecsCommandGroups, +} + +interface CLISpecsCommandGroups { + [name: string]: CLISpecsCommandGroup, +} + +interface CLISpecsCommands { + [name: string]: CLISpecsCommand, +} + +async function retrieveCommand(names: string[]): Promise { + return axios.get(`/AAZ/Specs/CommandTree/Nodes/aaz/${names.slice(0, -1).join('/')}/Leaves/${names[names.length - 1]}`).then(res => res.data); +} + +async function retrieveCommands(namesList: string[][]): Promise { + const namesListData = namesList.map(names => ['aaz', ...names]); + return axios.post(`/AAZ/Specs/CommandTree/Nodes/Leaves`, namesListData).then(res => res.data); +} + +const useSpecsCommandTree: () => (namesList: string[][]) => Promise = () => { + const commandCache = React.useRef(new Map>()); + + const fetchCommands = React.useCallback(async (namesList: string[][]) => { + const promiseResults = []; + const uncachedNamesList = []; + for (const names of namesList) { + const cachedPromise = commandCache.current.get(names.join('/')); + if (!cachedPromise) { + uncachedNamesList.push(names); + } else { + promiseResults.push(cachedPromise); + } + } + if (uncachedNamesList.length === 0) { + return await Promise.all(promiseResults); + } else if (uncachedNamesList.length === 1) { + const commandPromise = retrieveCommand(uncachedNamesList[0]); + commandCache.current.set(uncachedNamesList[0].join('/'), commandPromise); + return (await Promise.all(promiseResults.concat(commandPromise))); + } else { + const uncachedCommandsPromise = retrieveCommands(uncachedNamesList); + uncachedNamesList.forEach((names, idx) => { + commandCache.current.set(names.join('/'), uncachedCommandsPromise.then(commands => commands[idx])); + }); + return (await Promise.all(promiseResults)).concat(await uncachedCommandsPromise); + } + }, [commandCache]); + return fetchCommands; +} + +interface ProfileCommandTrees { + [name: string]: ProfileCommandTree, +} interface CLIModuleGeneratorProps { params: { @@ -29,195 +139,169 @@ interface CLIModuleGeneratorProps { }; } -interface CLIModuleGeneratorState { - loading: boolean; - invalidText?: string, - profiles: string[]; - commandTrees: ProfileCommandTree[]; - selectedProfileIdx?: number; - selectedCommandTree?: ProfileCommandTree; - showGenerateDialog: boolean; -} +const CLIModuleGenerator: React.FC = ({ params }) => { + const [loading, setLoading] = React.useState(false); + const [invalidText, setInvalidText] = React.useState(undefined); + const [profiles, setProfiles] = React.useState([]); + const [commandTrees, setCommandTrees] = React.useState({}); + const [selectedProfile, setSelectedProfile] = React.useState(undefined); + const [showGenerateDialog, setShowGenerateDialog] = React.useState(false); + const fetchCommands = useSpecsCommandTree(); -class CLIModuleGenerator extends React.Component { + React.useEffect(() => { + loadModule(); + }, []); - constructor(props: CLIModuleGeneratorProps) { - super(props); - this.state = { - loading: false, - invalidText: undefined, - profiles: [], - commandTrees: [], - selectedProfileIdx: undefined, - selectedCommandTree: undefined, - showGenerateDialog: false, - } - } - - componentDidMount() { - this.loadModule(); - } - - loadModule = async () => { + const loadModule = async () => { try { - this.setState({ - loading: true, - }); - let res = await axios.get(`/CLI/Az/Profiles`); - const profiles: string[] = res.data; + setLoading(true); + const profiles: string[] = await axios.get(`/CLI/Az/Profiles`).then(res => res.data); - res = await axios.get(`/AAZ/Specs/CommandTree/Nodes/aaz`); - const commandTrees: ProfileCommandTree[] = profiles.map((profileName) => BuildProfileCommandTree(profileName, res.data)); + const modView: CLIModView = await axios.get(`/CLI/Az/${params.repoName}/Modules/${params.moduleName}`).then(res => res.data); - res = await axios.get(`/CLI/Az/${this.props.params.repoName}/Modules/${this.props.params.moduleName}`); - const modView: CLIModView = res.data + const simpleTree: CLISpecsSimpleCommandTree = await axios.get(`/AAZ/Specs/CommandTree/Simple`).then(res => res.data); - Object.keys(modView.profiles).forEach((profile) => { + Object.keys(modView!.profiles).forEach((profile) => { const idx = profiles.findIndex(v => v === profile); if (idx === -1) { throw new Error(`Invalid profile ${profile}`); } - commandTrees[idx] = UpdateProfileCommandTreeByModView(commandTrees[idx], modView.profiles[profile]); - }) + }); - const selectedProfileIdx = profiles.length > 0 ? 0 : undefined; - const selectedCommandTree = selectedProfileIdx !== undefined ? commandTrees[selectedProfileIdx] : undefined; - this.setState({ - loading: false, - profiles: profiles, - commandTrees: commandTrees, - selectedProfileIdx: selectedProfileIdx, - selectedCommandTree: selectedCommandTree - }) + const commandTrees = Object.fromEntries(profiles.map((profile) => { + return [profile, InitializeCommandTreeByModView(profile, modView!.profiles[profile] ?? null, simpleTree)]; + })); + + const selectedProfile = profiles.length > 0 ? profiles[0] : undefined; + setProfiles(profiles); + setCommandTrees(commandTrees); + setSelectedProfile(selectedProfile); + setLoading(false); } catch (err: any) { console.error(err); if (err.response?.data?.message) { const data = err.response!.data!; - this.setState({ - invalidText: `ResponseError: ${data.message!}`, - }) + setInvalidText(`ResponseError: ${data.message!}`); } else { - this.setState({ - invalidText: `Error: ${err}`, - }) + setInvalidText(`Error: ${err}`); } } - } + }; + + const selectedCommandTree = selectedProfile ? commandTrees[selectedProfile] : undefined; - handleBackToHomepage = () => { + const handleBackToHomepage = () => { window.open('/?#/cli', "_blank"); - } + }; - handleGenerate = () => { - this.setState({ - showGenerateDialog: true - }) - } + const handleGenerate = () => { + setShowGenerateDialog(true); + }; - handleGenerationClose = () => { - this.setState({ - showGenerateDialog: false - }) - } + const handleGenerationClose = () => { + setShowGenerateDialog(false); + }; - onProfileChange = (selectedIdx: number) => { - this.setState(preState => { - return { - ...preState, - selectedProfileIdx: selectedIdx, - selectedCommandTree: preState.commandTrees[selectedIdx], - } - }) - } + const onProfileChange = React.useCallback((selectedProfile: string) => { + setSelectedProfile(selectedProfile); + }, []); - onSelectedProfileTreeUpdate = (newTree: ProfileCommandTree) => { - this.setState(preState => { - return { - ...preState, - selectedCommandTree: newTree, - commandTrees: preState.commandTrees.map((value, idx) => {return idx === preState.selectedProfileIdx ? newTree : value}), - } - }) - } + const onSelectedProfileTreeUpdate = React.useCallback((updater: ((oldTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => { + setCommandTrees((commandTrees) => { + const selectedCommandTree = commandTrees[selectedProfile!]; + const newTree = typeof updater === 'function' ? updater(selectedCommandTree!) : updater; + return { ...commandTrees, [selectedProfile!]: newTree } + }); + }, [selectedProfile]); - render() { - const { showGenerateDialog, selectedProfileIdx, selectedCommandTree, profiles, commandTrees } = this.state; - return ( - - - - - - {selectedProfileIdx !== undefined && } - - - - {selectedCommandTree !== undefined && } - + return ( + + + + + + {selectedProfile !== undefined && ( + + )} + + + + {selectedCommandTree !== undefined && ( + + )} - {showGenerateDialog && + {showGenerateDialog && ( + } - theme.zIndex.drawer + 1 }} - open={this.state.loading} - > - {this.state.invalidText !== undefined && - + )} + theme.zIndex.drawer + 1 }} + open={loading} + > + {invalidText !== undefined ? ( + { - this.setState({ - invalidText: undefined, - loading: false, - }) - }} - > - {this.state.invalidText} - - } - {this.state.invalidText === undefined && } - - - ); - } - -} + variant="filled" + severity='error' + onClose={() => { + setInvalidText(undefined); + setLoading(false); + }} + > + {invalidText} + + ) : ( + + )} + + + ); +}; function GenerateDialog(props: { repoName: string; moduleName: string; - profileCommandTrees: ProfileCommandTree[]; + profileCommandTrees: ProfileCommandTrees; open: boolean; onClose: (generated: boolean) => void; }) { @@ -232,7 +316,7 @@ function GenerateDialog(props: { const handleGenerateAll = () => { const profiles: CLIModViewProfiles = {}; - props.profileCommandTrees.forEach(tree => { + Object.values(props.profileCommandTrees).forEach(tree => { profiles[tree.name] = ExportModViewProfile(tree); }) const data = { @@ -264,7 +348,7 @@ function GenerateDialog(props: { const handleGenerateModified = () => { const profiles: CLIModViewProfiles = {}; - props.profileCommandTrees.forEach(tree => { + Object.values(props.profileCommandTrees).forEach(tree => { profiles[tree.name] = ExportModViewProfile(tree); }) const data = { @@ -322,4 +406,5 @@ const CLIModuleGeneratorWrapper = (props: any) => { return } +export type { CLISpecsCommandGroup, CLISpecsCommand, CLISpecsSimpleCommandTree, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommand }; export { CLIModuleGeneratorWrapper as CLIModuleGenerator };