diff --git a/scripts/addons_core/bl_pkg/bl_extension_ui.py b/scripts/addons_core/bl_pkg/bl_extension_ui.py index 55584cc398d..03b579f0432 100644 --- a/scripts/addons_core/bl_pkg/bl_extension_ui.py +++ b/scripts/addons_core/bl_pkg/bl_extension_ui.py @@ -42,7 +42,7 @@ def size_as_fmt_string(num: float, *, precision: int = 1) -> str: if abs(num) < 1024.0: return "{:3.{:d}f}{:s}".format(num, precision, unit) num /= 1024.0 - unit = "yb" + unit = "YB" return "{:.{:d}f}{:s}".format(num, precision, unit) diff --git a/scripts/addons_core/bl_pkg/cli/blender_ext.py b/scripts/addons_core/bl_pkg/cli/blender_ext.py index 1120347b712..4cfef981f58 100755 --- a/scripts/addons_core/bl_pkg/cli/blender_ext.py +++ b/scripts/addons_core/bl_pkg/cli/blender_ext.py @@ -105,6 +105,22 @@ CHUNK_SIZE_DEFAULT = 1 << 14 # Used for project tag-line & permissions values. TERSE_DESCRIPTION_MAX_LENGTH = 64 +# Default HTML for `server-generate`. +# Intentionally very basic, users may define their own `--html-template`. +HTML_TEMPLATE = '''\ + + + + + Blender Extensions + + +

Blender Extension Listing:

+${body} +

Built ${date}

+ + +''' # Standard out may be communicating with a parent process, # arbitrary prints are NOT acceptable. @@ -188,6 +204,16 @@ def force_exit_ok_enable() -> None: # ----------------------------------------------------------------------------- # Generic Functions + +def size_as_fmt_string(num: float, *, precision: int = 1) -> str: + for unit in ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB"): + if abs(num) < 1024.0: + return "{:3.{:d}f}{:s}".format(num, precision, unit) + num /= 1024.0 + unit = "YB" + return "{:.{:d}f}{:s}".format(num, precision, unit) + + def read_with_timeout(fh: IO[bytes], size: int, *, timeout_in_seconds: float) -> Optional[bytes]: # TODO: implement timeout (TimeoutError). _ = timeout_in_seconds @@ -2287,6 +2313,37 @@ def generic_arg_build_split_platforms(subparse: argparse.ArgumentParser) -> None ) +# ----------------------------------------------------------------------------- +# Argument Handlers ("server-generate" command) + +def generic_arg_server_generate_html(subparse: argparse.ArgumentParser) -> None: + subparse.add_argument( + "--html", + dest="html", + action="store_true", + default=False, + help=( + "Create a HTML file (``index.html``) as well as the repository JSON " + "to support browsing extensions online with static-hosting." + ), + ) + + +def generic_arg_server_generate_html_template(subparse: argparse.ArgumentParser) -> None: + subparse.add_argument( + "--html-template", + dest="html_template", + default="", + help=( + "Optionally override the default HTML template with your own.\n" + "\n" + "The following keys are supported.\n" + "- ``${body}`` is replaced the extensions contents.\n" + "- ``${date}`` is replaced the creation date.\n" + ), + ) + + # ----------------------------------------------------------------------------- # Generate Repository @@ -2560,11 +2617,122 @@ class subcmd_server: def __new__(cls) -> Any: raise RuntimeError("{:s} should not be instantiated".format(cls)) + @staticmethod + def _generate_html( + msg_fn: MessageFn, + *, + repo_dir: str, + repo_data: List[Dict[str, Any]], + html_template_filepath: str, + ) -> bool: + import html + import datetime + from string import Template + + import urllib + import urllib.parse + + filepath_repo_html = os.path.join(repo_dir, "index.html") + + fh = io.StringIO() + + fh.write("\n") + fh.write(" \n") + fh.write(" \n") + fh.write(" \n") + fh.write(" \n") + fh.write(" \n") + fh.write(" \n") + fh.write(" \n") + fh.write(" \n") + for manifest_dict in sorted(repo_data, key=lambda manifest: (manifest["id"], manifest["version"])): + fh.write(" \n") + + platforms = [platform for platform in manifest_dict.get("platforms", "").split(",") if platform] + + # Parse the URL and add parameters use for drag & drop. + parsed_url = urllib.parse.urlparse(manifest_dict["archive_url"]) + # We could support existing values, currently always empty. + # `query = dict(urllib.parse.parse_qsl(parsed_url.query))` + query = {"repository": "/index.json"} + if (value := manifest_dict.get("blender_version_min", "")): + query["blender_version_min"] = value + if (value := manifest_dict.get("blender_version_max", "")): + query["blender_version_max"] = value + if platforms: + query["platforms"] = ",".join(platforms) + del value + + id_and_link = "{:s}".format( + urllib.parse.urlunparse(( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + parsed_url.params, + urllib.parse.urlencode(query, doseq=True) if query else None, + parsed_url.fragment, + )), + html.escape("{:s}-{:s}".format(manifest_dict["id"], manifest_dict["version"])), + ) + + # Write the table data. + fh.write(" \n".format(id_and_link)) + fh.write(" \n".format(html.escape(manifest_dict["name"]))) + fh.write(" \n".format(html.escape(manifest_dict["tagline"] or ""))) + blender_version_min = manifest_dict.get("blender_version_min", "") + blender_version_max = manifest_dict.get("blender_version_max", "") + if blender_version_min or blender_version_max: + blender_version_str = "{:s} - {:s}".format( + blender_version_min or "~", + blender_version_max or "~", + ) + else: + blender_version_str = "all" + fh.write(" \n".format(html.escape(blender_version_str))) + fh.write(" \n".format(html.escape(", ".join(platforms) if platforms else "all"))) + fh.write(" \n".format(html.escape(size_as_fmt_string(manifest_dict["archive_size"])))) + fh.write(" \n") + + fh.write("
IDNameDescriptionBlender VersionsPlatformsSize
{:s}{:s}{:s}{:s}{:s}{:s}
\n") + + body = fh.getvalue() + del fh + + html_template_text = "" + if html_template_filepath: + try: + with open(html_template_filepath, "r", encoding="utf-8") as fh_html: + html_template_text = fh_html.read() + except Exception as ex: + message_error(msg_fn, "HTML template failed to read: {:s}".format(str(ex))) + return False + else: + html_template_text = HTML_TEMPLATE + + template = Template(html_template_text) + del html_template_text + + try: + result = template.substitute( + body=body, + date=html.escape(datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M")), + ) + except KeyError as ex: + message_error(msg_fn, "HTML template error: {:s}".format(str(ex))) + return False + del template + + with open(filepath_repo_html, "w", encoding="utf-8") as fh_html: + fh_html.write(result) + return True + @staticmethod def generate( msg_fn: MessageFn, *, repo_dir: str, + html: bool, + html_template: str, ) -> bool: if url_has_known_prefix(repo_dir): @@ -2644,6 +2812,16 @@ class subcmd_server: continue if (error := pkg_manifest_detect_duplicates(pkg_idname, pkg_items)) is not None: message_warn(msg_fn, "archive found with duplicates for id {:s}: {:s}".format(pkg_idname, error)) + + if html: + if not subcmd_server._generate_html( + msg_fn, + repo_dir=repo_dir, + repo_data=repo_data, + html_template_filepath=html_template, + ): + return False + del repo_data_idname_map filepath_repo_json = os.path.join(repo_dir, PKG_REPO_LIST_FILENAME) @@ -3635,6 +3813,8 @@ def unregister(): if not subcmd_server.generate( msg_fn_no_done, repo_dir=repo_dir, + html=True, + html_template="", ): # Error running command. return False @@ -3689,6 +3869,8 @@ def argparse_create_server_generate( ) generic_arg_repo_dir(subparse) + generic_arg_server_generate_html(subparse) + generic_arg_server_generate_html_template(subparse) if args_internal: generic_arg_output_type(subparse) @@ -3696,6 +3878,8 @@ def argparse_create_server_generate( func=lambda args: subcmd_server.generate( msg_fn_from_args(args), repo_dir=args.repo_dir, + html=args.html, + html_template=args.html_template, ), )