From e9c1f04f42aa6da0358079ea5bbe40b898c023c0 Mon Sep 17 00:00:00 2001 From: Lephe Date: Thu, 21 Apr 2022 19:25:08 +0100 Subject: [PATCH 01/11] markdown: add a MediaExtension that allows attributes on images Supported attributes: * size=x, both being optional * pixelated In the near future it will also support audio files and videos. --- app/static/css/global.css | 3 + app/static/less/global.less | 4 + app/utils/bleach_allowlist.py | 2 +- app/utils/filters/markdown.py | 2 + app/utils/markdown_extensions/media.py | 150 +++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 app/utils/markdown_extensions/media.py diff --git a/app/static/css/global.css b/app/static/css/global.css index ef8577e..28d9824 100644 --- a/app/static/css/global.css +++ b/app/static/css/global.css @@ -45,6 +45,9 @@ a:focus { text-decoration: underline; outline: none; } +img.pixelated { + image-rendering: pixelated; +} section p { line-height: 20px; word-wrap: anywhere; diff --git a/app/static/less/global.less b/app/static/less/global.less index 7459f26..7ab5dc2 100644 --- a/app/static/less/global.less +++ b/app/static/less/global.less @@ -40,6 +40,10 @@ a { } } +img.pixelated { + image-rendering: pixelated; +} + section { p { line-height: 20px; diff --git a/app/utils/bleach_allowlist.py b/app/utils/bleach_allowlist.py index 2d0699a..5aaf75b 100644 --- a/app/utils/bleach_allowlist.py +++ b/app/utils/bleach_allowlist.py @@ -15,7 +15,7 @@ markdown_tags = [ markdown_attrs = { "*": ["id", "class"], - "img": ["src", "alt", "title"], + "img": ["src", "alt", "title", "width", "height"], "a": ["href", "alt", "title", "rel"], "form": ["action", "method", "enctype"], "input": ["id", "name", "type", "value"], diff --git a/app/utils/filters/markdown.py b/app/utils/filters/markdown.py index 900cc58..7d70104 100644 --- a/app/utils/filters/markdown.py +++ b/app/utils/filters/markdown.py @@ -11,6 +11,7 @@ from app.utils.markdown_extensions.pclinks import PCLinkExtension from app.utils.markdown_extensions.hardbreaks import HardBreakExtension from app.utils.markdown_extensions.escape_html import EscapeHtmlExtension from app.utils.markdown_extensions.linkify import LinkifyExtension +from app.utils.markdown_extensions.media import MediaExtension @app.template_filter('md') @@ -33,6 +34,7 @@ def md(text): LinkifyExtension(), TocExtension(baselevel=2), PCLinkExtension(), + MediaExtension(), ] html = markdown(text, options=options, extensions=extensions) diff --git a/app/utils/markdown_extensions/media.py b/app/utils/markdown_extensions/media.py new file mode 100644 index 0000000..ae45066 --- /dev/null +++ b/app/utils/markdown_extensions/media.py @@ -0,0 +1,150 @@ +from markdown.extensions import Extension +from markdown.inlinepatterns import LinkInlineProcessor +import xml.etree.ElementTree as etree + +class MediaExtension(Extension): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def extendMarkdown(self, md): + self.md = md + + # Override image detection + MEDIA_RE = r'\!\[' + media_processor = MediaInlineProcessor(MEDIA_RE) + media_processor.md = md + md.inlinePatterns.register(media_processor, 'media_link', 155) + +class AttributeLinkInlineProcessor(LinkInlineProcessor): + """ + A LinkInlineProcessor which additionally supports attributes after links, + with the bracket syntax `{ , , ... }` where each item is a + key/value pair with either single or double quotes: `key='value'` or + `key="value"`. + """ + + def getAttributes(self, data, index): + current_quote = "" + has_closing_brace = False + attrs = [] + current_attr_text = "" + + if index >= len(data) or data[index] != '{': + return [], index, True + index += 1 + + for pos in range(index, len(data)): + c = data[pos] + index += 1 + + # Close quote + if current_quote != "" and c == current_quote: + current_quote = "" + continue + # Open new quote + if current_quote == "" and c in ["'", '"']: + current_quote = c + continue + # Close brace + if current_quote == "" and c == "}": + has_closing_brace = True + break + + if current_quote == "" and c == " ": + if current_attr_text: + attrs.append(current_attr_text) + current_attr_text = "" + else: + current_attr_text += c + + if current_attr_text: + attrs.append(current_attr_text) + + return attrs, index, has_closing_brace + + @staticmethod + def hasAttribute(attrs, name): + return name in attrs + + @staticmethod + def getStringAttribute(attrs, name): + for attr in attrs: + if attr.startswith(name + "="): + return attr[len(name)+1:] + + @staticmethod + def getIntAttribute(attrs, name): + try: + s = AttributeLinkInlineProcessor.getStringAttribute(attrs, name) + return int(s) if s is not None else None + except ValueError: + return None + +class MediaInlineProcessor(AttributeLinkInlineProcessor): + """ Return a media element from the given match. """ + + def isVideo(self, url): + # TODO: YouTube integration + return url.endswith(".mp4") or url.endswith(".webm") + + def isAudio(self, url): + return url.endswith(".mp3") or url.endswith(".ogg") + + def getSizeAttribute(self, attrs, name): + s = self.getStringAttribute(attrs, name) + if s is None: + return None, None + dims = s.split("x", 1) + try: + if len(dims) == 1: + return int(dims[0]), None + else: + w = int(dims[0]) if dims[0] else None + h = int(dims[1]) if dims[1] else None + return w, h + except ValueError: + return None, None + + def handleMatch(self, m, data): + text, index, handled = self.getText(data, m.end(0)) + if not handled: + return None, None, None + + src, title, index, handled = self.getLink(data, index) + if not handled: + return None, None, None + + attrs, index, handled = self.getAttributes(data, index) + if not handled: + return None, None, None + print(attrs, self.getSizeAttribute(attrs, "size")) + + kind = "image" + if self.hasAttribute(attrs, "audio") or self.isAudio(src): + kind = "audio" + elif self.hasAttribute(attrs, "video") or self.isVideo(src): + kind = "video" + + # Images + + if kind == "image": + w, h = self.getSizeAttribute(attrs, "size") + pixelated = self.hasAttribute(attrs, "pixelated") + + el = etree.Element("img") + el.set("src", src) + + if title is not None: + el.set("title", title) + + if pixelated: + el.set("class", "pixelated") + if w is not None: + el.set("width", str(w)) + if h is not None: + el.set("height", str(h)) + + el.set('alt', self.unescape(text)) + return el, m.start(0), index + + return None, None, None From 48d6c1c03ce96316d1eb1cf4a0da48a7d8e25283 Mon Sep 17 00:00:00 2001 From: Lephe Date: Thu, 21 Apr 2022 20:03:22 +0100 Subject: [PATCH 02/11] markdown: allow positioning attributes on images New attributes * left, center, right: Exactly what you expect * float-left, float-right: Also just what you expect Currently there is no way to force a clear. --- app/static/css/global.css | 18 +++++ app/static/less/global.less | 19 +++++ app/templates/forum/edit_comment.html | 2 +- app/utils/markdown_extensions/media.py | 97 +++++++++++++++----------- 4 files changed, 93 insertions(+), 43 deletions(-) diff --git a/app/static/css/global.css b/app/static/css/global.css index 28d9824..e90351e 100644 --- a/app/static/css/global.css +++ b/app/static/css/global.css @@ -120,6 +120,24 @@ input[type="submit"]:focus { .bg-warn:active { background: var(--warn-active); } +img.align-left { + text-align: left; +} +img.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} +img.align-right { + display: block; + margin-left: auto; +} +.float-left { + float: left; +} +.float-right { + float: right; +} .skip-to-content-link { height: 30px; left: 50%; diff --git a/app/static/less/global.less b/app/static/less/global.less index 7ab5dc2..65ccd18 100644 --- a/app/static/less/global.less +++ b/app/static/less/global.less @@ -117,6 +117,25 @@ section { } } +img.align-left { + text-align: left; +} +img.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} +img.align-right { + display: block; + margin-left: auto; +} +.float-left { + float: left; +} +.float-right { + float: right; +} + .skip-to-content-link { height: 30px; diff --git a/app/templates/forum/edit_comment.html b/app/templates/forum/edit_comment.html index 3e0a45c..d57ec1b 100644 --- a/app/templates/forum/edit_comment.html +++ b/app/templates/forum/edit_comment.html @@ -14,7 +14,7 @@ - +
{{ widget_user.profile(comment.author) }}
{{ comment.text | md }}
{{ comment.text | md }}
diff --git a/app/utils/markdown_extensions/media.py b/app/utils/markdown_extensions/media.py index ae45066..8d0c170 100644 --- a/app/utils/markdown_extensions/media.py +++ b/app/utils/markdown_extensions/media.py @@ -15,6 +15,40 @@ class MediaExtension(Extension): media_processor.md = md md.inlinePatterns.register(media_processor, 'media_link', 155) +class AttrDict: + def __init__(self, attrs): + self.attrs = attrs + + def has(self, name): + return name in self.attrs + + def getString(self, name): + for attr in self.attrs: + if attr.startswith(name + "="): + return attr[len(name)+1:] + + def getInt(self, name): + try: + s = self.getString(name) + return int(s) if s is not None else None + except ValueError: + return None + + def getSize(self, name): + s = self.getString(name) + if s is None: + return None, None + dims = s.split("x", 1) + try: + if len(dims) == 1: + return int(dims[0]), None + else: + w = int(dims[0]) if dims[0] else None + h = int(dims[1]) if dims[1] else None + return w, h + except ValueError: + return None, None + class AttributeLinkInlineProcessor(LinkInlineProcessor): """ A LinkInlineProcessor which additionally supports attributes after links, @@ -30,7 +64,7 @@ class AttributeLinkInlineProcessor(LinkInlineProcessor): current_attr_text = "" if index >= len(data) or data[index] != '{': - return [], index, True + return AttrDict([]), index, True index += 1 for pos in range(index, len(data)): @@ -60,25 +94,7 @@ class AttributeLinkInlineProcessor(LinkInlineProcessor): if current_attr_text: attrs.append(current_attr_text) - return attrs, index, has_closing_brace - - @staticmethod - def hasAttribute(attrs, name): - return name in attrs - - @staticmethod - def getStringAttribute(attrs, name): - for attr in attrs: - if attr.startswith(name + "="): - return attr[len(name)+1:] - - @staticmethod - def getIntAttribute(attrs, name): - try: - s = AttributeLinkInlineProcessor.getStringAttribute(attrs, name) - return int(s) if s is not None else None - except ValueError: - return None + return AttrDict(attrs), index, has_closing_brace class MediaInlineProcessor(AttributeLinkInlineProcessor): """ Return a media element from the given match. """ @@ -90,21 +106,6 @@ class MediaInlineProcessor(AttributeLinkInlineProcessor): def isAudio(self, url): return url.endswith(".mp3") or url.endswith(".ogg") - def getSizeAttribute(self, attrs, name): - s = self.getStringAttribute(attrs, name) - if s is None: - return None, None - dims = s.split("x", 1) - try: - if len(dims) == 1: - return int(dims[0]), None - else: - w = int(dims[0]) if dims[0] else None - h = int(dims[1]) if dims[1] else None - return w, h - except ValueError: - return None, None - def handleMatch(self, m, data): text, index, handled = self.getText(data, m.end(0)) if not handled: @@ -117,19 +118,31 @@ class MediaInlineProcessor(AttributeLinkInlineProcessor): attrs, index, handled = self.getAttributes(data, index) if not handled: return None, None, None - print(attrs, self.getSizeAttribute(attrs, "size")) kind = "image" - if self.hasAttribute(attrs, "audio") or self.isAudio(src): + if attrs.has("audio") or self.isAudio(src): kind = "audio" - elif self.hasAttribute(attrs, "video") or self.isVideo(src): + elif attrs.has("video") or self.isVideo(src): kind = "video" # Images if kind == "image": - w, h = self.getSizeAttribute(attrs, "size") - pixelated = self.hasAttribute(attrs, "pixelated") + w, h = attrs.getSize("size") + class_ = "" + # TODO: Media converter: Find a way to clear atfer a float + if attrs.has("pixelated"): + class_ += " pixelated" + if attrs.has("left"): + class_ += " align-left" + if attrs.has("center"): + class_ += " align-center" + elif attrs.has("right"): + class_ += " align-right" + elif attrs.has("float-left"): + class_ += " float-left" + elif attrs.has("float-right"): + class_ += " float-right" el = etree.Element("img") el.set("src", src) @@ -137,8 +150,8 @@ class MediaInlineProcessor(AttributeLinkInlineProcessor): if title is not None: el.set("title", title) - if pixelated: - el.set("class", "pixelated") + if class_ != "": + el.set("class", class_) if w is not None: el.set("width", str(w)) if h is not None: From 610fe6f1fd4b653186b13086728a9b8f036d5ab9 Mon Sep 17 00:00:00 2001 From: Lephe Date: Thu, 21 Apr 2022 20:43:50 +0100 Subject: [PATCH 03/11] markdown: allow videos with size and positioning Same options as for images, except for [pixelated]. Supported sources are standard videos and YouTube, and there is basic auto-detection which avoids the need to set the [video] attribute. --- app/static/css/global.css | 6 +- app/static/less/global.less | 6 +- app/utils/bleach_allowlist.py | 6 +- app/utils/markdown_extensions/media.py | 87 +++++++++++++++++++++----- 4 files changed, 83 insertions(+), 22 deletions(-) diff --git a/app/static/css/global.css b/app/static/css/global.css index e90351e..e96db7f 100644 --- a/app/static/css/global.css +++ b/app/static/css/global.css @@ -120,15 +120,15 @@ input[type="submit"]:focus { .bg-warn:active { background: var(--warn-active); } -img.align-left { +.align-left { text-align: left; } -img.align-center { +.align-center { display: block; margin-left: auto; margin-right: auto; } -img.align-right { +.align-right { display: block; margin-left: auto; } diff --git a/app/static/less/global.less b/app/static/less/global.less index 65ccd18..cfbf990 100644 --- a/app/static/less/global.less +++ b/app/static/less/global.less @@ -117,15 +117,15 @@ section { } } -img.align-left { +.align-left { text-align: left; } -img.align-center { +.align-center { display: block; margin-left: auto; margin-right: auto; } -img.align-right { +.align-right { display: block; margin-left: auto; } diff --git a/app/utils/bleach_allowlist.py b/app/utils/bleach_allowlist.py index 5aaf75b..8a2883e 100644 --- a/app/utils/bleach_allowlist.py +++ b/app/utils/bleach_allowlist.py @@ -10,7 +10,8 @@ markdown_tags = [ "sub", "sup", "table", "thead", "tbody", "tr", "th", "td", "form", "fieldset", "input", "textarea", - "label", "progress" + "label", "progress", + "video", "source", "iframe", ] markdown_attrs = { @@ -21,4 +22,7 @@ markdown_attrs = { "input": ["id", "name", "type", "value"], "label": ["for"], "progress": ["value", "min", "max"], + "video": ["controls", "width", "height"], + "source": ["src"], + "iframe": ["src", "width", "height", "frameborder", "allowfullscreen"], } diff --git a/app/utils/markdown_extensions/media.py b/app/utils/markdown_extensions/media.py index 8d0c170..1e21d4e 100644 --- a/app/utils/markdown_extensions/media.py +++ b/app/utils/markdown_extensions/media.py @@ -1,6 +1,8 @@ from markdown.extensions import Extension from markdown.inlinepatterns import LinkInlineProcessor import xml.etree.ElementTree as etree +import urllib +import re class MediaExtension(Extension): def __init__(self, **kwargs): @@ -49,6 +51,19 @@ class AttrDict: except ValueError: return None, None + def getAlignmentClass(self): + if self.has("left"): + return "align-left" + if self.has("center"): + return "align-center" + elif self.has("right"): + return "align-right" + elif self.has("float-left"): + return "float-left" + elif self.has("float-right"): + return "float-right" + return "" + class AttributeLinkInlineProcessor(LinkInlineProcessor): """ A LinkInlineProcessor which additionally supports attributes after links, @@ -100,8 +115,11 @@ class MediaInlineProcessor(AttributeLinkInlineProcessor): """ Return a media element from the given match. """ def isVideo(self, url): - # TODO: YouTube integration - return url.endswith(".mp4") or url.endswith(".webm") + if url.endswith(".mp4") or url.endswith(".webm"): + return True + url = urllib.parse.urlparse(url) + # TODO: Better detect YouTube URLs + return url.hostname in ["youtu.be", "www.youtube.com"] def isAudio(self, url): return url.endswith(".mp3") or url.endswith(".ogg") @@ -120,29 +138,20 @@ class MediaInlineProcessor(AttributeLinkInlineProcessor): return None, None, None kind = "image" - if attrs.has("audio") or self.isAudio(src): + if attrs.has("image"): + kind = "image" + elif attrs.has("audio") or self.isAudio(src): kind = "audio" elif attrs.has("video") or self.isVideo(src): kind = "video" - # Images - if kind == "image": w, h = attrs.getSize("size") class_ = "" # TODO: Media converter: Find a way to clear atfer a float if attrs.has("pixelated"): class_ += " pixelated" - if attrs.has("left"): - class_ += " align-left" - if attrs.has("center"): - class_ += " align-center" - elif attrs.has("right"): - class_ += " align-right" - elif attrs.has("float-left"): - class_ += " float-left" - elif attrs.has("float-right"): - class_ += " float-right" + class_ += " " + attrs.getAlignmentClass() el = etree.Element("img") el.set("src", src) @@ -160,4 +169,52 @@ class MediaInlineProcessor(AttributeLinkInlineProcessor): el.set('alt', self.unescape(text)) return el, m.start(0), index + elif kind == "audio": + # TODO: Media converter: support audio files + pass + + elif kind == "video": + w, h = attrs.getSize("size") + class_ = attrs.getAlignmentClass() + url = urllib.parse.urlparse(src) + args = urllib.parse.parse_qs(url.query) + youtube_source = None + + if url.hostname == "youtu.be" and \ + re.fullmatch(r'\/[a-zA-Z0-9_-]+', url.path): + youtube_source = url.path[1:] + elif url.hostname == "www.youtube.com" and "v" in args and \ + re.fullmatch(r'[a-zA-Z0-9_-]+', args["v"][0]): + youtube_source = args["v"][0] + + if youtube_source: + if w is None and h is None: + w, h = (470, 300) if attrs.has("tiny") else (560, 340) + + el = etree.Element("iframe") + el.set("src",f"https://www.youtube.com/embed/{youtube_source}") + el.set("frameborder", "0") + el.set("allowfullscreen", "") + # + pass + else: + el = etree.Element("video") + el.set("controls", "") + source = etree.Element("source") + source.set("src", src) + el.append(source) + # + + el.set("class", class_) + if w is not None: + el.set("width", str(min(w, 560))) + if h is not None: + el.set("height", str(min(h, 340))) + + return el, m.start(0), index + return None, None, None From f53032fc882552fb44ec8d635f58d2658bd4b3e8 Mon Sep 17 00:00:00 2001 From: Lephe Date: Thu, 21 Apr 2022 22:07:49 +0100 Subject: [PATCH 04/11] markdown: add an extension for image/video galleries This will be used on program pages. Currently there is no check that list elements are images and videos. --- app/utils/filters/markdown.py | 2 ++ app/utils/markdown_extensions/gallery.py | 39 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 app/utils/markdown_extensions/gallery.py diff --git a/app/utils/filters/markdown.py b/app/utils/filters/markdown.py index 7d70104..71912b4 100644 --- a/app/utils/filters/markdown.py +++ b/app/utils/filters/markdown.py @@ -12,6 +12,7 @@ from app.utils.markdown_extensions.hardbreaks import HardBreakExtension from app.utils.markdown_extensions.escape_html import EscapeHtmlExtension from app.utils.markdown_extensions.linkify import LinkifyExtension from app.utils.markdown_extensions.media import MediaExtension +from app.utils.markdown_extensions.gallery import GalleryExtension @app.template_filter('md') @@ -35,6 +36,7 @@ def md(text): TocExtension(baselevel=2), PCLinkExtension(), MediaExtension(), + GalleryExtension(), ] html = markdown(text, options=options, extensions=extensions) diff --git a/app/utils/markdown_extensions/gallery.py b/app/utils/markdown_extensions/gallery.py new file mode 100644 index 0000000..55e7c7f --- /dev/null +++ b/app/utils/markdown_extensions/gallery.py @@ -0,0 +1,39 @@ +from markdown.extensions import Extension +from markdown.treeprocessors import Treeprocessor +import xml.etree.ElementTree as etree + +class GalleryTreeprocessor(Treeprocessor): + def run(self, doc): + for parent in doc.findall(".//ul/.."): + for idx, ul in enumerate(parent): + if ul.tag != "ul" or len(ul) == 0: + continue + has_gallery = False + + # Option 1: In raw text in the
  • + if ul[-1].text and ul[-1].text.endswith("{gallery}"): + has_gallery = True + ul[-1].text = ul[-1].text[:-9] + # Option 2: After the last child (in its tail) \ + if len(ul[-1]) and ul[-1][-1].tail and \ + ul[-1][-1].tail.endswith("{gallery}"): + has_gallery = True + ul[-1][-1].tail = ul[-1][-1].tail[:-9] + + if has_gallery: + # TODO: Manipulate the output tree + el = etree.Element("div") + p = etree.Element("p") + p.text = "" + parent.remove(ul) + el.append(p) + el.append(ul) + parent.insert(idx, el) + +class GalleryExtension(Extension): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def extendMarkdown(self, md): + md.treeprocessors.register(GalleryTreeprocessor(md), 'gallery', 8) + md.registerExtension(self) From 09c7f63b55faac21b3a0bdb86ff08de099c56297 Mon Sep 17 00:00:00 2001 From: Darks Date: Sun, 24 Apr 2022 14:06:44 +0200 Subject: [PATCH 05/11] menu.js: fixed bad design --- app/static/scripts/trigger_menu.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/static/scripts/trigger_menu.js b/app/static/scripts/trigger_menu.js index ed00cf5..7c3fcfa 100644 --- a/app/static/scripts/trigger_menu.js +++ b/app/static/scripts/trigger_menu.js @@ -69,5 +69,5 @@ var keyboard_trigger = function(event) { } } -document.onclick = mouse_trigger; -document.onkeypress = keyboard_trigger; +document.addEventListener("click", mouse_trigger); +document.addEventListener("keydown", keyboard_trigger); From 21193299977adcf45f35dca777b9b2cec2e67196 Mon Sep 17 00:00:00 2001 From: Darks Date: Sun, 24 Apr 2022 17:24:47 +0200 Subject: [PATCH 06/11] widgets: add '[[f: 123]]' pclink widget --- app/static/css/themes/default_theme.css | 8 ++++++ app/static/css/widgets.css | 35 ++++++++++++++++++++++-- app/static/less/widgets.less | 34 +++++++++++++++++++++-- app/utils/markdown_extensions/pclinks.py | 32 ++++++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/app/static/css/themes/default_theme.css b/app/static/css/themes/default_theme.css index 1c04ac1..c287756 100644 --- a/app/static/css/themes/default_theme.css +++ b/app/static/css/themes/default_theme.css @@ -125,6 +125,14 @@ div.editor-toolbar, div.CodeMirror { --text-disabled: #c0c0c0; } +.dl-button { + --link: #149641; + --link-text: #ffffff; + --link-active: #0f7331; + --meta: rgba(0, 0, 0, .15); + --meta-text: #000000; +} + /* Extra style on top of the Pygments style */ table.codehilitetable td.linenos { color: #808080; diff --git a/app/static/css/widgets.css b/app/static/css/widgets.css index 42fcd1e..0ad14fb 100644 --- a/app/static/css/widgets.css +++ b/app/static/css/widgets.css @@ -86,6 +86,9 @@ height: 64px; } } +hr.signature { + opacity: 0.2; +} .trophies { display: flex; flex-wrap: wrap; @@ -125,6 +128,34 @@ .trophy span { font-size: 80%; } -hr.signature { - opacity: 0.2; +.dl-button { + display: inline-flex; + flex-direction: row; + align-items: stretch; + border-radius: 5px; + overflow: hidden; + margin: 3px 5px; + vertical-align: middle; +} +.dl-button a { + display: flex; + align-items: center; + padding: 5px 15px; + font-size: 110%; + background: var(--link); + color: var(--link-text); +} +.dl-button a:hover, +.dl-button a:focus, +.dl-button a:active { + background: var(--link-active); + text-decoration: none; +} +.dl-button span { + display: flex; + align-items: center; + padding: 5px 8px; + background: var(--meta); + color: var(--meta-text); + font-size: 90%; } \ No newline at end of file diff --git a/app/static/less/widgets.less b/app/static/less/widgets.less index 185cb79..4af3fb9 100644 --- a/app/static/less/widgets.less +++ b/app/static/less/widgets.less @@ -109,6 +109,11 @@ } } +hr.signature { + opacity: 0.2; +} + + /* Trophies */ .trophies { display: flex; @@ -157,6 +162,31 @@ } } -hr.signature { - opacity: 0.2; + +/* Download button */ +.dl-button { + display: inline-flex; flex-direction: row; align-items: stretch; + + border-radius: 5px; overflow: hidden; + margin: 3px 5px; vertical-align: middle; + + a { + display:flex; align-items:center; + padding: 5px 15px; + + font-size: 110%; + background: var(--link); color: var(--link-text); + + &:hover, &:focus, &:active { + background: var(--link-active); + text-decoration: none; + } + } + + span { + display: flex; align-items:center; + padding: 5px 8px; + background: var(--meta); color: var(--meta-text); + font-size: 90%; + } } diff --git a/app/utils/markdown_extensions/pclinks.py b/app/utils/markdown_extensions/pclinks.py index 9fe982b..58911fd 100644 --- a/app/utils/markdown_extensions/pclinks.py +++ b/app/utils/markdown_extensions/pclinks.py @@ -16,9 +16,11 @@ from markdown.inlinepatterns import InlineProcessor import xml.etree.ElementTree as etree from flask import url_for, render_template from app.utils.unicode_names import normalize +from app.utils.filters.humanize import humanize from app.models.poll import Poll from app.models.topic import Topic from app.models.user import Member +from app.models.attachment import Attachment class PCLinkExtension(Extension): @@ -48,6 +50,7 @@ class PCLinksInlineProcessor(InlineProcessor): 'membre': handleUser, 'user': handleUser, 'u': handleUser, 'sondage': handlePoll, 'poll': handlePoll, 'topic': handleTopic, 't': handleTopic, + 'fichier': handleFile, 'file': handleFile, 'f': handleFile, } def handleMatch(self, m, data): @@ -121,3 +124,32 @@ def handleUser(content_id, context): a.set('class', 'profile-link') return a + +def handleFile(content_id, context): + try: + content_id = int(content_id) + except ValueError: + return "[Fichier invalide]" + + file = Attachment.query.get(content_id) + + if file is None: + return "[Fichier inconnu]" + + # Build element manually to avoid code injection + container = etree.Element('div') + container.set('class', 'dl-button') + + xtitle = etree.SubElement(container, 'a') + xtitle.text = file.name + xtitle.set('href', file.url) + + xsize = etree.SubElement(container, 'span') + xsize.text = humanize(file.size, unit='o') + + # + # TLTitre.g3a + # {{ file.size | humanize(unit='o') }} + # + + return container From 17f5e82a2a568ed0fb7d79883e9553ed6c80b297 Mon Sep 17 00:00:00 2001 From: Darks Date: Sun, 24 Apr 2022 17:50:46 +0200 Subject: [PATCH 07/11] pclinks: switched to <> as delimiters (#108) And some other enhancements --- app/templates/widgets/download_button.html | 8 ++++++ app/utils/markdown_extensions/pclinks.py | 32 ++++++---------------- 2 files changed, 16 insertions(+), 24 deletions(-) create mode 100644 app/templates/widgets/download_button.html diff --git a/app/templates/widgets/download_button.html b/app/templates/widgets/download_button.html new file mode 100644 index 0000000..e0f9b41 --- /dev/null +++ b/app/templates/widgets/download_button.html @@ -0,0 +1,8 @@ +{% macro download_button(file) %} + + TLTitre.g3a + {{ file.size | humanize(unit='o') }} + +{% endmacro %} + +{{ download_button(file) if file }} diff --git a/app/utils/markdown_extensions/pclinks.py b/app/utils/markdown_extensions/pclinks.py index 58911fd..f674f60 100644 --- a/app/utils/markdown_extensions/pclinks.py +++ b/app/utils/markdown_extensions/pclinks.py @@ -36,7 +36,7 @@ class PCLinkExtension(Extension): self.md = md # append to end of inline patterns - PCLINK_RE = r'\[\[([a-z]+): ?(\w+)\]\]' + PCLINK_RE = r'<([a-z]+): ?(\w+)>' pclinkPattern = PCLinksInlineProcessor(PCLINK_RE, self.getConfigs()) pclinkPattern.md = md md.inlinePatterns.register(pclinkPattern, 'pclink', 135) @@ -73,12 +73,10 @@ class PCLinksInlineProcessor(InlineProcessor): # - either an xml.etree.ElementTree def handlePoll(content_id, context): - if not context.startswith("[[") or not context.endswith("]]"): - return "[Sondage invalide]" try: id = int(content_id) except ValueError: - return "[ID du sondage invalide]" + return "[ID de sondage invalide]" poll = Poll.query.get(content_id) @@ -93,7 +91,7 @@ def handleTopic(content_id, context): try: id = int(content_id) except ValueError: - return "[ID du topic invalide]" + return "[ID de topic invalide]" topic = Topic.query.get(content_id) @@ -129,27 +127,13 @@ def handleFile(content_id, context): try: content_id = int(content_id) except ValueError: - return "[Fichier invalide]" + return "[ID de fichier invalide]" file = Attachment.query.get(content_id) if file is None: - return "[Fichier inconnu]" + return "[Fichier non trouvé]" - # Build element manually to avoid code injection - container = etree.Element('div') - container.set('class', 'dl-button') - - xtitle = etree.SubElement(container, 'a') - xtitle.text = file.name - xtitle.set('href', file.url) - - xsize = etree.SubElement(container, 'span') - xsize.text = humanize(file.size, unit='o') - - # - # TLTitre.g3a - # {{ file.size | humanize(unit='o') }} - # - - return container + html = render_template('widgets/download_button.html', file=file) + html = html.replace('\n', '') # Needed to avoid lots of
    due to etree + return etree.fromstring(html) From 13b1d29e42d9a261e62d0c8311994c56be4472fb Mon Sep 17 00:00:00 2001 From: Darks Date: Sun, 24 Apr 2022 22:55:11 +0200 Subject: [PATCH 08/11] theme: add support for download widget to FK's dark theme --- app/static/css/themes/FK_dark_theme.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/static/css/themes/FK_dark_theme.css b/app/static/css/themes/FK_dark_theme.css index 2769142..1b56d85 100644 --- a/app/static/css/themes/FK_dark_theme.css +++ b/app/static/css/themes/FK_dark_theme.css @@ -135,3 +135,11 @@ div.editor-toolbar, div.CodeMirror { --separator: #404040; --text-disabled: #262c2f; } + +.dl-button { + --link: #149641; + --link-text: #ffffff; + --link-active: #0f7331; + --meta: rgba(255, 255, 255, .15); + --meta-text: #ffffff; +} From 8eee0ad2360455590b29bdea5500fa5af58ad962 Mon Sep 17 00:00:00 2001 From: Darks Date: Sun, 24 Apr 2022 23:33:18 +0200 Subject: [PATCH 09/11] pclinks: fixed a typo --- app/templates/widgets/download_button.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/widgets/download_button.html b/app/templates/widgets/download_button.html index e0f9b41..4f004dc 100644 --- a/app/templates/widgets/download_button.html +++ b/app/templates/widgets/download_button.html @@ -1,6 +1,6 @@ {% macro download_button(file) %} - TLTitre.g3a + {{ file.name }} {{ file.size | humanize(unit='o') }} {% endmacro %} From 8098642d4b244990cf1eb7e69aeada3455e4e852 Mon Sep 17 00:00:00 2001 From: Lephe Date: Mon, 25 Apr 2022 16:45:21 +0100 Subject: [PATCH 10/11] model: add an index on Post.type This is useful to quickly browse a list of polymorphic Posts for topics, programs, etc. The main application is from Member.posts, since polymorphic collection seems both difficult and edgy. [MIGRATION] This commit contains a new version of the schema. --- app/models/post.py | 2 +- .../d2227d2479e2_add_an_index_on_post_type.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/d2227d2479e2_add_an_index_on_post_type.py diff --git a/app/models/post.py b/app/models/post.py index cac722d..85d2dea 100644 --- a/app/models/post.py +++ b/app/models/post.py @@ -10,7 +10,7 @@ class Post(db.Model): # Unique Post ID for the whole site id = db.Column(db.Integer, primary_key=True) # Post type (polymorphic discriminator) - type = db.Column(db.String(20)) + type = db.Column(db.String(20), index=True) # Creation and edition date date_created = db.Column(db.DateTime) diff --git a/migrations/versions/d2227d2479e2_add_an_index_on_post_type.py b/migrations/versions/d2227d2479e2_add_an_index_on_post_type.py new file mode 100644 index 0000000..e1839c3 --- /dev/null +++ b/migrations/versions/d2227d2479e2_add_an_index_on_post_type.py @@ -0,0 +1,28 @@ +"""Add an index on Post.type + +Revision ID: d2227d2479e2 +Revises: bcfdb271b88d +Create Date: 2022-04-25 16:44:51.241965 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd2227d2479e2' +down_revision = 'bcfdb271b88d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_post_type'), 'post', ['type'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_post_type'), table_name='post') + # ### end Alembic commands ### From db5e613f7e62f648f81ea537039888ac340b58ee Mon Sep 17 00:00:00 2001 From: Lephe Date: Mon, 25 Apr 2022 17:03:27 +0100 Subject: [PATCH 11/11] model: use methods to access a user's typed posts (#104) --- app/models/user.py | 34 +++++++++++++++++-------- app/routes/admin/account.py | 6 ++--- app/templates/account/user.html | 2 +- app/templates/admin/delete_account.html | 2 +- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/app/models/user.py b/app/models/user.py index 6e31c62..325040d 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -3,11 +3,17 @@ from flask_login import UserMixin from sqlalchemy import func as SQLfunc from os.path import isfile from PIL import Image + from app import app, db from app.models.priv import SpecialPrivilege, Group, GroupMember, \ GroupPrivilege from app.models.trophy import Trophy, TrophyMember, Title from app.models.notification import Notification +from app.models.post import Post +from app.models.comment import Comment +from app.models.topic import Topic +from app.models.program import Program + import app.utils.unicode_names as unicode_names import app.utils.ldap as ldap from app.utils.unicode_names import normalize @@ -113,9 +119,15 @@ class Member(User): # Relations trophies = db.relationship('Trophy', secondary=TrophyMember, back_populates='owners') - topics = db.relationship('Topic') - programs = db.relationship('Program') - comments = db.relationship('Comment') + + # Access to polymorphic posts + # TODO: Check that the query uses the double index on Post.{author_id,type} + def comments(self): + return db.session.query(Comment).filter(Post.author_id==self.id).all() + def topics(self): + return db.session.query(Topic).filter(Post.author_id==self.id).all() + def programs(self): + return db.session.query(Program).filter(Post.author_id==self.id).all() # Other fields populated automatically through relations: # List of unseen notifications (of type Notification) @@ -129,7 +141,7 @@ class Member(User): self.email_confirmed = not V5Config.ENABLE_EMAIL_CONFIRMATION if not V5Config.USE_LDAP: self.set_password(password) - # Workflow with LDAP enabled is User → Postgresql → LDAP → set password + # Workflow with LDAP enabled is User → PostgreSQL → LDAP → set password self.xp = 0 self.theme = 'default_theme' @@ -149,23 +161,23 @@ class Member(User): Transfers all the posts to another user. This is generally used to transfer ownership to a newly-created Guest before deleting an account. """ - for t in self.topics: + for t in self.topics(): t.author = other db.session.add(t) - for p in self.programs: + for p in self.programs(): p.author = other db.session.add(p) - for c in self.comments: + for c in self.comments(): c.author = other db.session.add(c) def delete_posts(self): """Deletes the user's posts.""" - for t in self.topics: + for t in self.topics(): t.delete() - for p in self.programs: + for p in self.programs(): p.delete() - for c in self.comments: + for c in self.comments(): c.delete() def delete(self): @@ -450,7 +462,7 @@ class Member(User): progress(levels, post_count) if context in ["new-program", None]: - program_count = len(self.programs) + program_count = len(self.programs()) levels = { 5: "Programmeur du dimanche", diff --git a/app/routes/admin/account.py b/app/routes/admin/account.py index 3cd065f..3cf49d5 100644 --- a/app/routes/admin/account.py +++ b/app/routes/admin/account.py @@ -128,9 +128,9 @@ def adm_delete_account(user_id): # TODO: Number of comments by *other* members which will be deleted stats = { - 'comments': len(user.comments), - 'topics': len(user.topics), - 'programs': len(user.programs), + 'comments': len(user.comments()), + 'topics': len(user.topics()), + 'programs': len(user.programs()), 'groups': len(user.groups), 'privs': len(user.special_privileges()), } diff --git a/app/templates/account/user.html b/app/templates/account/user.html index 1df1a06..4db27fc 100644 --- a/app/templates/account/user.html +++ b/app/templates/account/user.html @@ -68,7 +68,7 @@ Forum Création - {% for t in member.topics %} + {% for t in member.topics() %} {{ t.title }} {{ t.forum.name }} diff --git a/app/templates/admin/delete_account.html b/app/templates/admin/delete_account.html index a22b3be..4bd6fa1 100644 --- a/app/templates/admin/delete_account.html +++ b/app/templates/admin/delete_account.html @@ -11,7 +11,7 @@
    • {{ stats.comments }} commentaire{{ stats.comments | pluralize }}
    • {{ stats.topics }} topic{{ stats.topics | pluralize }}
    • -
    • {{ stats.programs }} topic{{ stats.programs | pluralize }}
    • +
    • {{ stats.programs }} programme{{ stats.programs | pluralize }}

    Les propriétés suivantes seront supprimées :