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(" | ID | \n")
+ fh.write(" Name | \n")
+ fh.write(" Description | \n")
+ fh.write(" Blender Versions | \n")
+ fh.write(" Platforms | \n")
+ fh.write(" Size | \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(" | {:s} | \n".format(id_and_link))
+ fh.write(" {:s} | \n".format(html.escape(manifest_dict["name"])))
+ fh.write(" {:s} | \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(" {:s} | \n".format(html.escape(blender_version_str)))
+ fh.write(" {:s} | \n".format(html.escape(", ".join(platforms) if platforms else "all")))
+ fh.write(" {:s} | \n".format(html.escape(size_as_fmt_string(manifest_dict["archive_size"]))))
+ fh.write("
\n")
+
+ fh.write("
\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,
),
)