show replies below posts

This commit is contained in:
rimu 2023-10-10 22:25:37 +13:00
parent 8a18573974
commit fa53128118
14 changed files with 299 additions and 28 deletions

View file

@ -1,6 +1,3 @@
import markdown2
import werkzeug.exceptions
from app import db from app import db
from app.activitypub import bp from app.activitypub import bp
from flask import request, Response, current_app, abort, jsonify, json from flask import request, Response, current_app, abort, jsonify, json
@ -14,7 +11,8 @@ from app.models import User, Community, CommunityJoinRequest, CommunityMember, C
from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \ from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \
post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \
domain_from_url domain_from_url, markdown_to_html
import werkzeug.exceptions
INBOX = [] INBOX = []
@ -134,7 +132,7 @@ def user_profile(actor):
"content": user.about, "content": user.about,
"mediaType": "text/markdown" "mediaType": "text/markdown"
} }
actor_data['summary'] = allowlist_html(markdown2.markdown(user.about, safe_mode=True)) actor_data['summary'] = markdown_to_html(user.about)
resp = jsonify(actor_data) resp = jsonify(actor_data)
resp.content_type = 'application/activity+json' resp.content_type = 'application/activity+json'
return resp return resp
@ -250,7 +248,7 @@ def shared_inbox():
if 'source' in request_json['object']['object'] and \ if 'source' in request_json['object']['object'] and \
request_json['object']['object']['source']['mediaType'] == 'text/markdown': request_json['object']['object']['source']['mediaType'] == 'text/markdown':
post.body = request_json['object']['object']['source']['content'] post.body = request_json['object']['object']['source']['content']
post.body_html = allowlist_html(markdown2.markdown(post.body, safe_mode=True)) post.body_html = markdown_to_html(post.body)
elif 'content' in request_json['object']['object']: elif 'content' in request_json['object']['object']:
post.body_html = allowlist_html(request_json['object']['object']['content']) post.body_html = allowlist_html(request_json['object']['object']['content'])
post.body = html_to_markdown(post.body_html) post.body = html_to_markdown(post.body_html)
@ -293,7 +291,7 @@ def shared_inbox():
request_json['object']['object']['source'][ request_json['object']['object']['source'][
'mediaType'] == 'text/markdown': 'mediaType'] == 'text/markdown':
post_reply.body = request_json['object']['object']['source']['content'] post_reply.body = request_json['object']['object']['source']['content']
post_reply.body_html = allowlist_html(markdown2.markdown(post_reply.body, safe_mode=True)) post_reply.body_html = markdown_to_html(post_reply.body)
elif 'content' in request_json['object']['object']: elif 'content' in request_json['object']['object']:
post_reply.body_html = allowlist_html( post_reply.body_html = allowlist_html(
request_json['object']['object']['content']) request_json['object']['object']['content'])

View file

@ -2,7 +2,6 @@ import json
import os import os
from datetime import datetime from datetime import datetime
from typing import Union, Tuple from typing import Union, Tuple
import markdown2
from flask import current_app from flask import current_app
from sqlalchemy import text from sqlalchemy import text
from app import db, cache from app import db, cache
@ -15,7 +14,7 @@ from cryptography.hazmat.primitives.asymmetric import padding
from app.constants import * from app.constants import *
from urllib.parse import urlparse from urllib.parse import urlparse
from app.utils import get_request, allowlist_html from app.utils import get_request, allowlist_html, html_to_markdown
def public_key(): def public_key():
@ -301,7 +300,7 @@ def parse_summary(user_json) -> str:
if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown': if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown':
# Convert Markdown to HTML # Convert Markdown to HTML
markdown_text = user_json['source']['content'] markdown_text = user_json['source']['content']
html_content = markdown2.markdown(markdown_text) html_content = html_to_markdown(markdown_text)
return html_content return html_content
elif 'summary' in user_json: elif 'summary' in user_json:
return allowlist_html(user_json['summary']) return allowlist_html(user_json['summary'])

View file

@ -1,6 +1,5 @@
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import markdown2
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
from flask_login import login_user, logout_user, current_user, login_required from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _ from flask_babel import _
@ -9,11 +8,12 @@ from sqlalchemy import or_
from app import db from app import db
from app.activitypub.signature import RsaKeys, HttpSignature from app.activitypub.signature import RsaKeys, HttpSignature
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm
from app.community.util import search_for_community, community_url_exists, actor_to_community from app.community.util import search_for_community, community_url_exists, actor_to_community, post_replies
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
PostReplyVote
from app.community import bp from app.community import bp
from app.utils import get_setting, render_template, allowlist_html from app.utils import get_setting, render_template, allowlist_html, markdown_to_html
@bp.route('/add_local', methods=['GET', 'POST']) @bp.route('/add_local', methods=['GET', 'POST'])
@ -80,7 +80,7 @@ def show_community(community: Community):
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
if current_user.ignore_bots: if current_user.ignore_bots:
posts = community.posts.query.filter(Post.from_bot == False).all() posts = community.posts.filter(Post.from_bot == False).all()
else: else:
posts = community.posts posts = community.posts
@ -187,7 +187,7 @@ def add_post(actor):
if form.type.data == '' or form.type.data == 'discussion': if form.type.data == '' or form.type.data == 'discussion':
post.title = form.discussion_title.data post.title = form.discussion_title.data
post.body = form.discussion_body.data post.body = form.discussion_body.data
post.body_html = allowlist_html(markdown2.markdown(post.body, safe_mode=True)) post.body_html = markdown_to_html(post.body)
post.type = POST_TYPE_ARTICLE post.type = POST_TYPE_ARTICLE
elif form.type.data == 'link': elif form.type.data == 'link':
post.title = form.link_title.data post.title = form.link_title.data
@ -217,11 +217,28 @@ def add_post(actor):
images_disabled=images_disabled) images_disabled=images_disabled)
@bp.route('/post/<int:post_id>') @bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
def show_post(post_id: int): def show_post(post_id: int):
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)
mods = post.community.moderators() mods = post.community.moderators()
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
form = NewReplyForm() form = NewReplyForm()
if form.validate_on_submit():
reply = PostReply(user_id=current_user.id, post_id=post.id, community_id=post.community.id, body=form.body.data,
body_html=markdown_to_html(form.body.data), body_html_safe=True,
from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl)
db.session.add(reply)
db.session.commit()
reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id,
effect=1.0)
db.session.add(reply_vote)
db.session.commit()
form.body.data = ''
flash('Your comment has been added.')
# todo: flush cache
# todo: federation
replies = post_replies(post.id, 'top', show_first=reply.id)
else:
replies = post_replies(post.id, 'top')
return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator, return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator,
canonical=post.ap_id, form=form) canonical=post.ap_id, form=form, replies=replies)

View file

@ -1,8 +1,10 @@
from datetime import datetime from datetime import datetime
from typing import List
from app import db from app import db
from app.models import Community, File, BannedInstances from app.models import Community, File, BannedInstances, PostReply
from app.utils import get_request from app.utils import get_request
from sqlalchemy import desc
def search_for_community(address: str): def search_for_community(address: str):
@ -78,3 +80,24 @@ def actor_to_community(actor) -> Community:
else: else:
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
return community return community
# replies to a post, in a tree, sorted by a variety of methods
def post_replies(post_id: int, sort_by: str, show_first: int = 0) -> List[PostReply]:
comments = PostReply.query.filter_by(post_id=post_id)
if sort_by == 'hot':
comments = comments.order_by(desc(PostReply.ranking))
elif sort_by == 'top':
comments = comments.order_by(desc(PostReply.score))
elif sort_by == 'new':
comments = comments.order_by(desc(PostReply.posted_at))
comments_dict = {comment.id: {'comment': comment, 'replies': []} for comment in comments.all()}
for comment in comments:
if comment.parent_id is not None:
parent_comment = comments_dict.get(comment.parent_id)
if parent_comment:
parent_comment['replies'].append(comments_dict[comment.id])
return [comment for comment in comments_dict.values() if comment['comment'].parent_id is None]

View file

@ -198,6 +198,12 @@ class User(UserMixin, db.Model):
return self.cover.source_url return self.cover.source_url
return '' return ''
def link(self) -> str:
if self.ap_id is None:
return self.user_name
else:
return self.ap_id
def get_reset_password_token(self, expires_in=600): def get_reset_password_token(self, expires_in=600):
return jwt.encode( return jwt.encode(
{'reset_password': self.id, 'exp': time() + expires_in}, {'reset_password': self.id, 'exp': time() + expires_in},
@ -312,9 +318,11 @@ class PostReply(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), index=True) post_id = db.Column(db.Integer, db.ForeignKey('post.id'), index=True)
community_id = db.Column(db.Integer, db.ForeignKey('community.id'), index=True) community_id = db.Column(db.Integer, db.ForeignKey('community.id'), index=True)
domain_id = db.Column(db.Integer, db.ForeignKey('domain.id'), index=True)
image_id = db.Column(db.Integer, db.ForeignKey('file.id'), index=True) image_id = db.Column(db.Integer, db.ForeignKey('file.id'), index=True)
parent_id = db.Column(db.Integer) parent_id = db.Column(db.Integer)
root_id = db.Column(db.Integer) root_id = db.Column(db.Integer)
depth = db.Column(db.Integer, default=0)
body = db.Column(db.Text) body = db.Column(db.Text)
body_html = db.Column(db.Text) body_html = db.Column(db.Text)
body_html_safe = db.Column(db.Boolean, default=False) body_html_safe = db.Column(db.Boolean, default=False)
@ -327,7 +335,7 @@ class PostReply(db.Model):
from_bot = db.Column(db.Boolean, default=False) from_bot = db.Column(db.Boolean, default=False)
up_votes = db.Column(db.Integer, default=0) up_votes = db.Column(db.Integer, default=0)
down_votes = db.Column(db.Integer, default=0) down_votes = db.Column(db.Integer, default=0)
ranking = db.Column(db.Integer, default=0) ranking = db.Column(db.Integer, default=0, index=True)
language = db.Column(db.String(10)) language = db.Column(db.String(10))
edited_at = db.Column(db.DateTime) edited_at = db.Column(db.DateTime)

View file

@ -137,6 +137,14 @@
content: "\e9d7"; content: "\e9d7";
} }
.fe-arrow-up::before {
content: "\e914";
}
.fe-arrow-down::before {
content: "\e90c";
}
a.no-underline { a.no-underline {
text-decoration: none; text-decoration: none;
&:hover { &:hover {

View file

@ -136,6 +136,14 @@ nav, etc which are used site-wide */
content: "\e9d7"; content: "\e9d7";
} }
.fe-arrow-up::before {
content: "\e914";
}
.fe-arrow-down::before {
content: "\e90c";
}
a.no-underline { a.no-underline {
text-decoration: none; text-decoration: none;
} }
@ -316,6 +324,10 @@ fieldset legend {
height: auto; height: auto;
} }
.post_reply_form label {
display: none;
}
.post_list .post_teaser { .post_list .post_teaser {
border-bottom: solid 2px #ddd; border-bottom: solid 2px #ddd;
padding-top: 8px; padding-top: 8px;
@ -329,4 +341,50 @@ fieldset legend {
text-decoration: none; text-decoration: none;
} }
.comment {
clear: both;
margin-bottom: 20px;
}
.comment .comment_author img {
width: 20px;
height: 20px;
}
.comment .hide_button {
float: right;
display: block;
width: 60px;
padding: 5px;
}
.comment .hide_button a {
text-decoration: none;
}
.comment .voting_buttons {
float: right;
display: block;
width: 60px;
padding: 5px;
}
.comment .voting_buttons div {
border: solid 1px #0071CE;
}
.comment .voting_buttons .upvote_button, .comment .voting_buttons .downvote_button {
padding-left: 3px;
border-radius: 3px;
}
.comment .voting_buttons .upvote_button.digits_4, .comment .voting_buttons .downvote_button.digits_4 {
width: 68px;
}
.comment .voting_buttons .upvote_button.digits_5, .comment .voting_buttons .downvote_button.digits_5 {
width: 76px;
}
.comment .voting_buttons .upvote_button.digits_6, .comment .voting_buttons .downvote_button.digits_6 {
width: 84px;
}
.comment .voting_buttons .downvote_button {
margin-top: 5px;
}
.comment .voting_buttons a {
text-decoration: none;
}
/*# sourceMappingURL=structure.css.map */ /*# sourceMappingURL=structure.css.map */

View file

@ -111,6 +111,11 @@ nav, etc which are used site-wide */
} }
} }
.post_reply_form {
label {
display: none;
}
}
.post_list { .post_list {
.post_teaser { .post_teaser {
@ -130,3 +135,62 @@ nav, etc which are used site-wide */
padding-bottom: 8px; padding-bottom: 8px;
} }
} }
.comment {
clear: both;
margin-bottom: 20px;
.comment_author {
img {
width: 20px;
height: 20px;
}
}
.hide_button {
float: right;
display: block;
width: 60px;
padding: 5px;
a {
text-decoration: none;
}
}
.voting_buttons {
float: right;
display: block;
width: 60px;
padding: 5px;
div {
border: solid 1px $primary-colour;
}
.upvote_button, .downvote_button {
padding-left: 3px;
border-radius: 3px;
&.digits_4 {
width: 68px;
}
&.digits_5 {
width: 76px;
}
&.digits_6 {
width: 84px;
}
}
.downvote_button {
margin-top: 5px;
}
a {
text-decoration: none;
}
}
}

View file

@ -135,6 +135,14 @@
content: "\e9d7"; content: "\e9d7";
} }
.fe-arrow-up::before {
content: "\e914";
}
.fe-arrow-down::before {
content: "\e90c";
}
a.no-underline { a.no-underline {
text-decoration: none; text-decoration: none;
} }

View file

@ -79,7 +79,42 @@
{% endif %} {% endif %}
<div class="row post_replies"> <div class="row post_replies">
<div class="col"> <div class="col">
{% macro render_comment(comment) %}
<div class="comment" style="margin-left: {{ comment['comment'].depth * 20 }}px;">
<div class="voting_buttons">
<div class="upvote_button digits_{{ digits(comment['comment'].up_votes) }}"><a href="#"><span class="fe fe-arrow-up"></span>
{{ comment['comment'].up_votes }}</a>
</div>
<div class="downvote_button digits_{{ digits(comment['comment'].down_votes) }}"><a href="#"><span class="fe fe-arrow-down"></span>
{{ comment['comment'].down_votes }}</a>
</div>
</div>
<div class="hide_button"><a href="#">[-] hide</a></div>
<div class="comment_author">
{% if comment['comment'].author.avatar_id %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}">
<img src="{{ comment['comment'].author.avatar_image() }}" alt="Avatar" /></a>
{% endif %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}">
<strong>{{ comment['comment'].author.user_name}}</strong></a>
<span class="text-muted small">{{ moment(comment['comment'].posted_at).fromNow(refresh=True) }}</span>
</div>
{{ comment['comment'].body_html | safe }}
</div>
{% if comment['replies'] %}
<div class="replies">
{% for reply in comment['replies'] %}
{{ render_comment(reply) | safe }}
{% endfor %}
</div>
{% endif %}
{% endmacro %}
<div class="comments">
{% for reply in replies %}
{{ render_comment(reply) | safe }}
{% endfor %}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,3 @@
import markdown2
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
from flask_login import login_user, logout_user, current_user, login_required from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _ from flask_babel import _
@ -8,7 +6,7 @@ from app import db
from app.models import Post, Community, CommunityMember, User, PostReply from app.models import Post, Community, CommunityMember, User, PostReply
from app.user import bp from app.user import bp
from app.user.forms import ProfileForm, SettingsForm from app.user.forms import ProfileForm, SettingsForm
from app.utils import get_setting, render_template, allowlist_html from app.utils import get_setting, render_template, markdown_to_html
from sqlalchemy import desc, or_ from sqlalchemy import desc, or_
@ -19,7 +17,7 @@ def show_profile(user):
moderates = moderates.filter(Community.private_mods == False) moderates = moderates.filter(Community.private_mods == False)
post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).all() post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).all()
canonical = user.ap_public_url if user.ap_public_url else None canonical = user.ap_public_url if user.ap_public_url else None
user.about_html = allowlist_html(markdown2.markdown(user.about, safe_mode=True)) user.about_html = markdown_to_html(user.about)
return render_template('user/show_profile.html', user=user, posts=posts, post_replies=post_replies, return render_template('user/show_profile.html', user=user, posts=posts, post_replies=post_replies,
moderates=moderates.all(), canonical=canonical, title=_('Posts by %(user_name)s', moderates=moderates.all(), canonical=canonical, title=_('Posts by %(user_name)s',
user_name=user.user_name)) user_name=user.user_name))

View file

@ -1,9 +1,10 @@
import random import random
import markdown2
import math
from urllib.parse import urlparse from urllib.parse import urlparse
import flask import flask
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import html as html_module
import requests import requests
import os import os
from flask import current_app, json from flask import current_app, json
@ -84,7 +85,8 @@ def is_image_url(url):
# sanitise HTML using an allow list # sanitise HTML using an allow list
def allowlist_html(html: str) -> str: def allowlist_html(html: str) -> str:
allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h3', 'h4', 'h5'] allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h3', 'h4', 'h5', 'pre',
'code']
# Parse the HTML using BeautifulSoup # Parse the HTML using BeautifulSoup
soup = BeautifulSoup(html, 'html.parser') soup = BeautifulSoup(html, 'html.parser')
@ -100,7 +102,7 @@ def allowlist_html(html: str) -> str:
del tag[attr] del tag[attr]
# Encode the HTML to prevent script execution # Encode the HTML to prevent script execution
return html_module.escape(str(soup)) return str(soup)
# convert basic HTML to Markdown # convert basic HTML to Markdown
@ -138,6 +140,10 @@ def html_to_markdown_worker(element, indent_level=0):
return formatted_text return formatted_text
def markdown_to_html(markdown_text) -> str:
return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True))
def domain_from_url(url: str) -> Domain: def domain_from_url(url: str) -> Domain:
parsed_url = urlparse(url) parsed_url = urlparse(url)
domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first() domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first()
@ -153,3 +159,11 @@ def shorten_string(input_str, max_length=50):
def shorten_url(input: str, max_length=20): def shorten_url(input: str, max_length=20):
return shorten_string(input.replace('https://', '').replace('http://', '')) return shorten_string(input.replace('https://', '').replace('http://', ''))
# the number of digits in a number. e.g. 1000 would be 4
def digits(input: int) -> int:
if input == 0:
return 1 # Special case: 0 has 1 digit
else:
return math.floor(math.log10(abs(input))) + 1

View file

@ -0,0 +1,40 @@
"""reply depth
Revision ID: e82f86c550ac
Revises: 8c5cc19e0670
Create Date: 2023-10-10 20:51:09.662080
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e82f86c550ac'
down_revision = '8c5cc19e0670'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.add_column(sa.Column('domain_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('depth', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_post_reply_domain_id'), ['domain_id'], unique=False)
batch_op.create_index(batch_op.f('ix_post_reply_ranking'), ['ranking'], unique=False)
batch_op.create_foreign_key(None, 'domain', ['domain_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_post_reply_ranking'))
batch_op.drop_index(batch_op.f('ix_post_reply_domain_id'))
batch_op.drop_column('depth')
batch_op.drop_column('domain_id')
# ### end Alembic commands ###

View file

@ -6,7 +6,7 @@ from app import create_app, db, cli
import os, click import os, click
from flask import session, g from flask import session, g
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
from app.utils import getmtime, gibberish, shorten_string, shorten_url from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits
app = create_app() app = create_app()
cli.register(app) cli.register(app)
@ -27,6 +27,7 @@ def make_shell_context():
with app.app_context(): with app.app_context():
app.jinja_env.globals['getmtime'] = getmtime app.jinja_env.globals['getmtime'] = getmtime
app.jinja_env.globals['len'] = len app.jinja_env.globals['len'] = len
app.jinja_env.globals['digits'] = digits
app.jinja_env.globals['str'] = str app.jinja_env.globals['str'] = str
app.jinja_env.filters['shorten'] = shorten_string app.jinja_env.filters['shorten'] = shorten_string
app.jinja_env.filters['shorten_url'] = shorten_url app.jinja_env.filters['shorten_url'] = shorten_url