gallery: rebase from dev

This commit is contained in:
Darks 2022-04-26 00:29:07 +02:00
commit 7b66e1ec20
Signed by: Darks
GPG Key ID: 7515644268BE1433
20 changed files with 485 additions and 30 deletions

View File

@ -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)

View File

@ -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:
# <notifications> 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",

View File

@ -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()),
}

View File

@ -45,6 +45,9 @@ a:focus {
text-decoration: underline;
outline: none;
}
img.pixelated {
image-rendering: pixelated;
}
section p {
line-height: 20px;
word-wrap: anywhere;
@ -117,6 +120,24 @@ input[type="submit"]:focus {
.bg-warn:active {
background: var(--warn-active);
}
.align-left {
text-align: left;
}
.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
.align-right {
display: block;
margin-left: auto;
}
.float-left {
float: left;
}
.float-right {
float: right;
}
.skip-to-content-link {
height: 30px;
left: 50%;

View File

@ -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;
}

View File

@ -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;

View File

@ -86,6 +86,9 @@
height: 64px;
}
}
hr.signature {
opacity: 0.2;
}
.trophies {
display: flex;
flex-wrap: wrap;
@ -125,6 +128,7 @@
.trophy span {
font-size: 80%;
}
hr.signature {
opacity: 0.2;
}
@ -173,4 +177,34 @@ hr.signature {
}
.gallery-spot {
margin: 10px auto;
}
.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%;
}

View File

@ -40,6 +40,10 @@ a {
}
}
img.pixelated {
image-rendering: pixelated;
}
section {
p {
line-height: 20px;
@ -113,6 +117,25 @@ section {
}
}
.align-left {
text-align: left;
}
.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
.align-right {
display: block;
margin-left: auto;
}
.float-left {
float: left;
}
.float-right {
float: right;
}
.skip-to-content-link {
height: 30px;

View File

@ -109,6 +109,11 @@
}
}
hr.signature {
opacity: 0.2;
}
/* Trophies */
.trophies {
display: flex;
@ -157,8 +162,33 @@
}
}
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%;
}
}
/* Gallery without Javascript */

View File

@ -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);

View File

@ -68,7 +68,7 @@
<th>Forum</th>
<th>Création</th>
</tr>
{% for t in member.topics %}
{% for t in member.topics() %}
<tr>
<td><a href="{{ url_for('forum_topic', f=t.forum, page=(t, 1)) }}">{{ t.title }}</a></td>
<td><a href="{{ url_for('forum_page', f=t.forum) }}">{{ t.forum.name }}</a></td>

View File

@ -11,7 +11,7 @@
<ul>
<li>{{ stats.comments }} commentaire{{ stats.comments | pluralize }}</li>
<li>{{ stats.topics }} topic{{ stats.topics | pluralize }}</li>
<li>{{ stats.programs }} topic{{ stats.programs | pluralize }}</li>
<li>{{ stats.programs }} programme{{ stats.programs | pluralize }}</li>
</ul>
<p>Les propriétés suivantes seront supprimées :</p>
<ul>

View File

@ -14,7 +14,7 @@
<table class="thread topcomment">
<tr>
<td class="author">{{ widget_user.profile(comment.author) }}</td>
<td><div>{{ comment.text | md }}</div></td>
<td class="message"><div>{{ comment.text | md }}</div></td>
</tr>
</table>

View File

@ -0,0 +1,8 @@
{% macro download_button(file) %}
<span class="dl-button">
<a href="{{ file.url }}">{{ file.name }}</a>
<span>{{ file.size | humanize(unit='o') }}</span>
</span>
{% endmacro %}
{{ download_button(file) if file }}

View File

@ -10,15 +10,19 @@ markdown_tags = [
"sub", "sup",
"table", "thead", "tbody", "tr", "th", "td",
"form", "fieldset", "input", "textarea",
"label", "progress"
"label", "progress",
"video", "source", "iframe",
]
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"],
"label": ["for"],
"progress": ["value", "min", "max"],
"video": ["controls", "width", "height"],
"source": ["src"],
"iframe": ["src", "width", "height", "frameborder", "allowfullscreen"],
}

View File

@ -11,6 +11,8 @@ 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
from app.utils.markdown_extensions.gallery import GalleryExtension
@app.template_filter('md')
@ -33,6 +35,8 @@ def md(text):
LinkifyExtension(),
TocExtension(baselevel=2),
PCLinkExtension(),
MediaExtension(),
GalleryExtension(),
]
html = markdown(text, options=options, extensions=extensions)

View File

@ -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 <li>
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 = "<There is a gallery here:>"
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)

View File

@ -0,0 +1,220 @@
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):
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 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
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,
with the bracket syntax `{ <item>, <item>, ... }` 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 AttrDict([]), 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 AttrDict(attrs), index, has_closing_brace
class MediaInlineProcessor(AttributeLinkInlineProcessor):
""" Return a media element from the given match. """
def isVideo(self, url):
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")
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
kind = "image"
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"
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"
class_ += " " + attrs.getAlignmentClass()
el = etree.Element("img")
el.set("src", src)
if title is not None:
el.set("title", title)
if class_ != "":
el.set("class", class_)
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
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", "")
# <iframe
# src="https://www.youtube.com/embed/{url.path}"
# frameborder="0" allowfullscreen></iframe>
pass
else:
el = etree.Element("video")
el.set("controls", "")
source = etree.Element("source")
source.set("src", src)
el.append(source)
# <video controls
# <source src="{url}">
# </video>
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

View File

@ -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):
@ -34,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)
@ -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):
@ -70,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)
@ -90,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)
@ -121,3 +122,18 @@ 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 "[ID de fichier invalide]"
file = Attachment.query.get(content_id)
if file is None:
return "[Fichier non trouvé]"
html = render_template('widgets/download_button.html', file=file)
html = html.replace('\n', '') # Needed to avoid lots of <br> due to etree
return etree.fromstring(html)

View File

@ -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 ###