Add API to provide descriptions as Minetest hypertext markup

This commit is contained in:
rubenwardy 2023-04-19 18:27:28 +01:00
parent dfe829d59e
commit 0a06e41497
4 changed files with 322 additions and 27 deletions

@ -36,6 +36,7 @@ from . import bp
from .auth import is_api_authd from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \ from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
api_order_screenshots, api_edit_package, api_set_cover_image api_order_screenshots, api_edit_package, api_set_cover_image
from ...utils.minetest_hypertext import html_to_minetest
def cors_allowed(f): def cors_allowed(f):
@ -84,6 +85,16 @@ def package(package):
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"])) return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
@bp.route("/api/packages/<author>/<name>/hypertext/")
@is_package_page
@cors_allowed
def package_hypertext(package):
formspec_version = request.args["formspec_version"]
include_images = isYes(request.args.get("include_images", "true"))
html = render_markdown(package.desc)
return jsonify(html_to_minetest(html, formspec_version, include_images))
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"]) @bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
@csrf.exempt @csrf.exempt
@is_package_page @is_package_page

@ -92,6 +92,18 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `video_url`: URL to a video. * `video_url`: URL to a video.
* `donate_url`: URL to a donation page. * `donate_url`: URL to a donation page.
* `game_support`: Array of game support information objects. Not currently documented, as subject to change. * `game_support`: Array of game support information objects. Not currently documented, as subject to change.
* GET `/api/packages/<author>/<name>/hypertext/`
* Converts the long description to [Minetest Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
to be used in a `hypertext` formspec element.
* Query arguments:
* `formspec_version`: Required, maximum supported formspec version.
* `include_images`: Optional, defaults to true.
* Returns JSON dictionary with following key:
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
* `body`: markup for long description.
* `links`: dictionary of anchor name to link URL.
* `images`: dictionary of img name to image URL
* `image_tooltips`: dictionary of img name to tooltip text.
* GET `/api/packages/<username>/<name>/dependencies/` * GET `/api/packages/<username>/<name>/dependencies/`
* Returns dependencies, with suggested candidates * Returns dependencies, with suggested candidates
* If query argument `only_hard` is present, only hard deps will be returned. * If query argument `only_hard` is present, only hard deps will be returned.

@ -0,0 +1,95 @@
from app.utils.minetest_hypertext import html_to_minetest
conquer_html = """
<p>
Welcome to <b>Conquer</b>, a mod that adds RTS gameplay. It allows players to start
Conquer sub-games, where they can place buildings, train units, and
fight other players.
</p>
<h2>Starting or joining a session</h2>
<p>
You can list running sessions by typing:
</p>
<pre><code>/conquer list
new line
another new line</code></pre>
<p>
You'll switch into Conquer playing mode, where you will be given buildings that you can place.
You'll need to place a keep, which you must protect at all costs.
</p>
<p>
You may leave a game and return to normal playing mode at anytime by typing:
</p>
<h2>Conquer GUI</h2>
<p>
The Conquer GUI is the central place for monitoring your kingdom.
Once in a session, you can view it by pressing the inventory key (I),
or by punching/right-clicking the keep node.
</p>
"""
conquer_expected = """
Welcome to <b>Conquer</b>, a mod that adds RTS gameplay. It allows players to start Conquer sub-games, where they can place buildings, train units, and fight other players.
<big>Starting or joining a session</big>
You can list running sessions by typing:
<code>/conquer list
new line
another new line</code>
You'll switch into Conquer playing mode, where you will be given buildings that you can place. You'll need to place a keep, which you must protect at all costs.
You may leave a game and return to normal playing mode at anytime by typing:
<big>Conquer GUI</big>
The Conquer GUI is the central place for monitoring your kingdom. Once in a session, you can view it by pressing the inventory key (I), or by punching/right-clicking the keep node.
"""
def test_conquer():
assert html_to_minetest(conquer_html)["body"].strip() == conquer_expected.strip()
def test_images():
html = """
<img src="/path/to/img.png">
"""
expected = "<img name=image_0 width=128 height=128>"
result = html_to_minetest(html)
assert result["body"].strip() == expected.strip()
assert len(result["images"]) == 1
assert result["images"]["image_0"] == "/path/to/img.png"
def test_bullets():
html = """
<ul>
<li>One</li>
<li>two three</li>
<li>four</li>
</ul>
"""
expected = "• One\n• two three\n• four\n"
result = html_to_minetest(html)
assert result["body"].strip() == expected.strip()
def test_inline():
html = """
<b>One <i>two</i> three</b>
"""
expected = "<b>One <i>two</i> three</b>"
result = html_to_minetest(html)
assert result["body"].strip() == expected.strip()

@ -0,0 +1,177 @@
from html.parser import HTMLParser
import re
import sys
def normalize_whitespace(x):
return re.sub(r"\s+", " ", x)
assert normalize_whitespace(" one three\nfour\n\n") == " one three four "
# Styles and custom tags
HEAD = normalize_whitespace("""
<tag name=code color=#7bf font=mono>
<tag name=action color=#77f hovercolor=#aaf>
""").strip()
def get_attributes(attrs):
retval = {}
for attr in attrs:
retval[attr[0]] = attr[1]
return retval
class MinetestHTMLParser(HTMLParser):
def __init__(self, include_images):
super().__init__()
self.include_images = include_images
self.text_buffer = ""
self.has_line_started = False
self.links = {}
self.images = {}
self.image_tooltips = {}
self.is_preserving = False
self.remove_until = None
def handle_starttag(self, tag, attrs):
if self.is_preserving or self.remove_until:
return
print("OPEN", tag, file=sys.stderr)
self.has_line_started = True
if tag == "p":
self.has_line_started = False
elif tag == "pre":
self.text_buffer += "<code>"
self.is_preserving = True
self.has_line_started = False
elif tag == "table":
# Tables are currently unsupported and removed
self.remove_until = "table"
self.text_buffer += "<i>(table removed)</i>\n"
elif tag == "br":
self.text_buffer += "\n"
self.has_line_started = False
elif tag == "h1" or tag == "h2":
self.text_buffer += "\n<big>"
elif tag == "h3" or tag == "h4" or tag == "h5":
self.text_buffer += "\n<b>"
elif tag == "a":
for attr in attrs:
if attr[0] == "href":
name = f"link_{len(self.links)}"
self.links[name] = attr[1]
self.text_buffer += f"<action name={name}><u>"
break
else:
self.text_buffer += "<action><u>"
elif tag == "img":
attr_by_value = get_attributes(attrs)
if "src" in attr_by_value and self.include_images:
name = f"image_{len(self.images)}"
self.images[name] = attr_by_value["src"]
width = attr_by_value.get("width", 128)
height = attr_by_value.get("height", 128)
self.text_buffer += f"<img name={name} width={width} height={height}>"
if "alt" in attr_by_value:
self.image_tooltips[name] = attr_by_value["alt"]
elif tag == "b" or tag == "strong":
self.text_buffer += "<b>"
elif tag == "i" or tag == "em":
self.text_buffer += "<i>"
elif tag == "u":
self.text_buffer += "<u>"
elif tag == "li":
self.has_line_started = False
self.text_buffer += ""
elif tag == "code":
self.text_buffer += "<code>"
elif tag == "span" or tag == "ul":
pass
else:
print("UNKNOWN TAG ", tag, attrs, file=sys.stderr)
def handle_endtag(self, tag):
if self.remove_until:
if self.remove_until == tag:
self.remove_until = None
return
print("CLOSE", tag, file=sys.stderr)
if tag == "pre":
self.text_buffer = self.text_buffer.rstrip()
self.text_buffer += "</code>\n"
self.is_preserving = False
self.has_line_started = False
elif self.is_preserving:
return
elif tag == "p":
self.text_buffer = self.text_buffer.rstrip()
self.text_buffer += "\n"
self.has_line_started = False
elif tag == "h1" or tag == "h2":
self.text_buffer += "</big>\n"
self.has_line_started = False
elif tag == "h3" or tag == "h4" or tag == "h5":
self.text_buffer += "</b>\n"
self.has_line_started = False
elif tag == "a":
self.text_buffer += "</u></action>"
elif tag == "code":
self.text_buffer += "</code>"
elif tag == "b" or tag == "strong":
self.text_buffer += "</b>"
elif tag == "i" or tag == "em":
self.text_buffer += "</i>"
elif tag == "u":
self.text_buffer += "</u>"
elif tag == "li":
self.text_buffer += "\n"
# else:
# print("END", tag, file=sys.stderr)
def handle_data(self, data):
print(f"DATA \"{data}\"", file=sys.stderr)
if self.remove_until:
return
if not self.is_preserving:
data = normalize_whitespace(data)
if not self.has_line_started:
data = data.lstrip()
self.text_buffer += data
self.has_line_started = True
def handle_entityref(self, name):
to_value = {
"lt": "\\<",
"gr": "\\>",
"amp": "&",
"quot": "\"",
"apos": "'",
}
if name in to_value:
self.text_buffer += to_value[name]
else:
self.text_buffer += f"&{name};"
def html_to_minetest(html, formspec_version=6, include_images=True):
parser = MinetestHTMLParser(include_images)
parser.feed(html)
return {
"head": HEAD,
"body": parser.text_buffer.strip() + "\n\n",
"links": parser.links,
"images": parser.images,
"image_tooltips": parser.image_tooltips,
}