diff --git a/app/__init__.py b/app/__init__.py
index 2b6297e..8183374 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -3,15 +3,15 @@ from dotenv import load_dotenv
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads
from .auth import digest
from .routes.users import is_logged_in, get_active_user, get_prefers_theme
-from .routes.threads import get_post_url
from .constants import (
PermissionLevel, permission_level_string,
InfoboxKind, InfoboxHTMLClass,
REACTION_EMOJI, MOTD_BANNED_TAGS,
SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
)
-from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION
-from datetime import datetime
+from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
+from datetime import datetime, timezone
+from flask_caching import Cache
import os
import time
import secrets
@@ -55,6 +55,18 @@ def reparse_babycode():
print('Re-parsing babycode, this may take a while...')
from .db import db
from .constants import MOTD_BANNED_TAGS
+
+ post_histories_without_rss = PostHistory.findall([
+ ('markup_language', '=', 'babycode'),
+ ('content_rss', 'IS', None),
+ ])
+
+ with db.transaction():
+ for ph in post_histories_without_rss:
+ ph.update({
+ 'content_rss': babycode_to_rssxml(ph['original_markup']),
+ })
+
post_histories = PostHistory.findall([
('markup_language', '=', 'babycode'),
('format_version', 'IS NOT', BABYCODE_VERSION)
@@ -65,6 +77,7 @@ def reparse_babycode():
for ph in post_histories:
ph.update({
'content': babycode_to_html(ph['original_markup']).result,
+ 'content_rss': babycode_to_rssxml(ph['original_markup']),
'format_version': BABYCODE_VERSION,
})
print('Re-parsing posts done.')
@@ -125,6 +138,8 @@ def bind_default_badges(path):
})
+cache = Cache()
+
def create_app():
app = Flask(__name__)
app.config['SITE_NAME'] = 'Pyrom'
@@ -133,6 +148,10 @@ def create_app():
app.config['USERS_CAN_INVITE'] = False
app.config['ADMIN_CONTACT_INFO'] = ''
app.config['GUIDE_DESCRIPTION'] = ''
+
+ app.config['CACHE_TYPE'] = 'FileSystemCache'
+ app.config['CACHE_DEFAULT_TIMEOUT'] = 300
+
try:
app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False)
except FileNotFoundError:
@@ -142,6 +161,7 @@ def create_app():
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
app.debug = True
app.config["DB_PATH"] = "data/db/db.dev.sqlite"
+ app.config["SERVER_NAME"] = "localhost:8080"
load_dotenv()
else:
app.config["DB_PATH"] = "data/db/db.prod.sqlite"
@@ -156,6 +176,13 @@ def create_app():
os.makedirs(os.path.dirname(app.config["DB_PATH"]), exist_ok = True)
os.makedirs(os.path.dirname(app.config["BADGES_UPLOAD_PATH"]), exist_ok = True)
+ if app.config['CACHE_TYPE'] == 'FileSystemCache':
+ cache_dir = app.config.get('CACHE_DIR', 'data/_cached')
+ os.makedirs(cache_dir, exist_ok = True)
+ app.config['CACHE_DIR'] = cache_dir
+
+ cache.init_app(app)
+
css_dir = 'data/static/css/'
allowed_themes = []
for f in os.listdir(css_dir):
@@ -229,10 +256,12 @@ def create_app():
@app.context_processor
def inject_funcs():
+ from .routes.threads import get_post_url
return {
'get_post_url': get_post_url,
'get_prefers_theme': get_prefers_theme,
'get_motds': MOTD.get_all,
+ 'get_time_now': lambda: int(time.time()),
}
@app.template_filter("ts_datetime")
@@ -308,4 +337,8 @@ def create_app():
def fromjson(subject: str):
return json.loads(subject)
+ @app.template_filter('iso8601')
+ def unix_to_iso8601(subject: str):
+ return datetime.fromtimestamp(int(subject), timezone.utc).isoformat()
+
return app
diff --git a/app/lib/babycode.py b/app/lib/babycode.py
index 9961c99..ea0eca6 100644
--- a/app/lib/babycode.py
+++ b/app/lib/babycode.py
@@ -190,7 +190,7 @@ class RSSXMLRenderer(BabycodeRenderer):
def __init__(self, fragment=False):
super().__init__(RSS_TAGS, VOID_TAGS, RSS_EMOJI, fragment)
- def make_mention(self, element):
+ def make_mention(self, e):
from ..models import Users
from flask import url_for, current_app
with current_app.test_request_context('/'):
@@ -410,10 +410,28 @@ def tag_code_rss(children, attr):
else:
return f'
{children} '
+
+def tag_url_rss(children, attr):
+ if attr.startswith('/'):
+ from flask import current_app
+ uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
+ return f"{children} "
+
+ return f"{children} "
+
+def tag_image_rss(children, attr):
+ if attr.startswith('/'):
+ from flask import current_app
+ uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
+ return f' '
+
+ return f' '
+
RSS_TAGS = {
**TAGS,
- 'img': lambda children, attr: f' ',
- 'spoiler': lambda children, attr: f'{attr or "Spoiler"} {children} ',
+ 'img': tag_image_rss,
+ 'url': tag_url_rss,
+ 'spoiler': lambda children, attr: f'{attr or "Spoiler"} (click to reveal) {children} ',
'code': tag_code_rss,
'big': lambda children, attr: f'{children} ',
@@ -424,6 +442,7 @@ VOID_TAGS = {
'lb': lambda attr: '[',
'rb': lambda attr: ']',
'@': lambda attr: '@',
+ '-': lambda attr: '-',
}
# [img] is considered block for the purposes of collapsing whitespace,
@@ -544,7 +563,7 @@ def babycode_ast(s: str, banned_tags=[]):
return elements
-def babycode_to_html(s: str, banned_tags=[], fragment=False):
+def babycode_to_html(s: str, banned_tags=[], fragment=False) -> BabycodeRenderResult:
"""
transforms a string of babycode into html.
@@ -561,7 +580,7 @@ def babycode_to_html(s: str, banned_tags=[], fragment=False):
return r.render(ast)
-def babycode_to_rssxml(s: str, banned_tags=[], fragment=False):
+def babycode_to_rssxml(s: str, banned_tags=[], fragment=False) -> str:
"""
transforms a string of babycode into rss-compatible x/html.
diff --git a/app/lib/render_atom.py b/app/lib/render_atom.py
new file mode 100644
index 0000000..5053d7c
--- /dev/null
+++ b/app/lib/render_atom.py
@@ -0,0 +1,10 @@
+from flask import make_response, render_template, request
+
+def render_atom_template(template, *args, **kwargs):
+ injects = {
+ **kwargs,
+ '__current_page': request.url,
+ }
+ r = make_response(render_template(template, *args, **injects))
+ r.mimetype = 'application/xml'
+ return r
diff --git a/app/models.py b/app/models.py
index 509d260..5c08f27 100644
--- a/app/models.py
+++ b/app/models.py
@@ -230,6 +230,38 @@ class Topics(Model):
return db.query(q, self.id, per_page, (page - 1) * per_page)
+ def get_threads_with_op_rss(self):
+ q = """
+ SELECT
+ threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
+ users.username AS started_by,
+ users.display_name AS started_by_display_name,
+ ph.content_rss AS original_post_content,
+ posts.id AS original_post_id
+ FROM
+ threads
+ JOIN users ON users.id = threads.user_id
+ JOIN (
+ SELECT
+ posts.thread_id,
+ posts.id,
+ posts.user_id,
+ posts.created_at,
+ posts.current_revision_id,
+ ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at ASC) AS rn
+ FROM
+ posts
+ ) posts ON posts.thread_id = threads.id AND posts.rn = 1
+ JOIN
+ post_history ph ON ph.id = posts.current_revision_id
+ JOIN
+ users u ON u.id = posts.user_id
+ WHERE
+ threads.topic_id = ?
+ ORDER BY threads.created_at DESC"""
+
+ return db.query(q, self.id)
+
class Threads(Model):
table = "threads"
@@ -238,6 +270,10 @@ class Threads(Model):
q = Posts.FULL_POSTS_QUERY + " WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?"
return db.query(q, self.id, limit, offset)
+ def get_posts_rss(self):
+ q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ?'
+ return db.query(q, self.id)
+
def locked(self):
return bool(self.is_locked)
@@ -265,7 +301,7 @@ class Posts(Model):
SELECT
posts.id, posts.created_at,
- post_history.content, post_history.edited_at,
+ post_history.content, post_history.edited_at, post_history.content_rss,
users.username, users.display_name, users.status,
avatars.file_path AS avatar_path, posts.thread_id,
users.id AS user_id, post_history.original_markup,
diff --git a/app/routes/app.py b/app/routes/app.py
index 5db25cd..9802d86 100644
--- a/app/routes/app.py
+++ b/app/routes/app.py
@@ -1,7 +1,18 @@
from flask import Blueprint, redirect, url_for, render_template
+from app import cache
+from datetime import datetime
bp = Blueprint("app", __name__, url_prefix = "/")
@bp.route("/")
def index():
return redirect(url_for("topics.all_topics"))
+
+@bp.route("/cache-test")
+def cache_test():
+ test_value = cache.get('test')
+ if test_value is None:
+ test_value = 'cached_value_' + str(datetime.now())
+ cache.set('test', test_value, timeout=10)
+ return f"set cache: {test_value}"
+ return f"cached: {test_value}"
diff --git a/app/routes/threads.py b/app/routes/threads.py
index 80d143b..0f6f933 100644
--- a/app/routes/threads.py
+++ b/app/routes/threads.py
@@ -1,31 +1,35 @@
from flask import (
Blueprint, render_template, request, redirect, url_for, flash,
- abort,
+ abort, current_app,
)
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..db import db
from ..models import Threads, Topics, Posts, Subscriptions, Reactions
from ..constants import InfoboxKind
+from ..lib.render_atom import render_atom_template
from .posts import create_post
from slugify import slugify
+from app import cache
import math
import time
bp = Blueprint("threads", __name__, url_prefix = "/threads/")
-def get_post_url(post_id, _anchor=False):
+def get_post_url(post_id, _anchor=False, external=False):
post = Posts.find({'id': post_id})
if not post:
return ""
thread = Threads.find({'id': post.thread_id})
- res = url_for('threads.thread', slug=thread.slug, after=post_id)
- if not _anchor:
- return res
+ anchor = None if not _anchor else f'post-{post_id}'
- return f"{res}#post-{post_id}"
+ return url_for('threads.thread', slug=thread.slug, after=post_id, _external=external, _anchor=anchor)
+ # if not _anchor:
+ # return res
+
+ # return f"{res}#post-{post_id}"
@bp.get("/")
@@ -80,9 +84,25 @@ def thread(slug):
is_subscribed = is_subscribed,
Reactions = Reactions,
unread_count = unread_count,
+ __feedlink = url_for('.thread_atom', slug=slug, _external=True),
+ __feedtitle = f'replies to {thread.title}',
)
+@bp.get("//feed.atom")
+@cache.cached(timeout=5 * 60, unless=lambda: current_app.config.get('DEBUG', False))
+def thread_atom(slug):
+ thread = Threads.find({"slug": slug})
+ if not thread:
+ abort(404) # TODO throw an atom friendly 404
+ return
+
+ topic = Topics.find({'id': thread.topic_id})
+ posts = thread.get_posts_rss()
+
+ return render_atom_template('threads/thread.atom', thread=thread, topic=topic, posts=posts, get_post_url=get_post_url)
+
+
@bp.post("/")
@login_required
def reply(slug):
diff --git a/app/routes/topics.py b/app/routes/topics.py
index 31f170f..729a615 100644
--- a/app/routes/topics.py
+++ b/app/routes/topics.py
@@ -1,11 +1,13 @@
from flask import (
Blueprint, render_template, request, redirect, url_for, flash, session,
- abort,
+ abort, current_app
)
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..models import Users, Topics, Threads, Subscriptions
from ..constants import InfoboxKind
+from ..lib.render_atom import render_atom_template
from slugify import slugify
+from app import cache
import time
import math
@@ -80,10 +82,27 @@ def topic(slug):
subscriptions = subscriptions,
topic = target_topic,
current_page = page,
- page_count = page_count
+ page_count = page_count,
+ __feedlink = url_for('.topic_atom', slug=slug, _external=True),
+ __feedtitle = f'latest threads in {target_topic.name}',
)
+@bp.get('//feed.atom')
+@cache.cached(timeout=10 * 60, unless=lambda: current_app.config.get('DEBUG', False))
+def topic_atom(slug):
+ target_topic = Topics.find({
+ "slug": slug
+ })
+ if not target_topic:
+ abort(404) # TODO throw an atom friendly 404
+ return
+
+ threads_list = target_topic.get_threads_with_op_rss()
+
+ return render_atom_template('topics/topic.atom', threads_list=threads_list, target_topic=target_topic)
+
+
@bp.get("//edit")
@login_required
@mod_only(".topic", slug = lambda slug: slug)
diff --git a/app/templates/base.atom b/app/templates/base.atom
new file mode 100644
index 0000000..4cf9630
--- /dev/null
+++ b/app/templates/base.atom
@@ -0,0 +1,20 @@
+
+
+ {% if self.title() %}
+ {% block title %}{% endblock %}
+ {% else %}
+ {{ config.SITE_NAME }}
+ {% endif %}
+ {% if self.feed_updated() %}
+ {% block feed_updated %}{% endblock %}
+ {% else %}
+ {{ get_time_now() | iso8601 }}
+ {% endif %}
+ {{ __current_page }}
+
+
+ {% if self.feed_author() %}
+ {% block feed_author %}{% endblock %}
+ {% endif %}
+ {% block content %}{% endblock %}
+
diff --git a/app/templates/base.html b/app/templates/base.html
index 6792ad8..262163c 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -11,6 +11,9 @@
+ {% if __feedlink %}
+
+ {% endif %}
diff --git a/app/templates/common/icons.html b/app/templates/common/icons.html
index 4582473..6502b1b 100644
--- a/app/templates/common/icons.html
+++ b/app/templates/common/icons.html
@@ -53,3 +53,9 @@
{%- endmacro %}
+
+{% macro icn_rss(width=24) %}
+
+
+
+{% endmacro %}
diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html
index 5241971..194499a 100644
--- a/app/templates/common/macros.html
+++ b/app/templates/common/macros.html
@@ -1,4 +1,4 @@
-{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone %}
+{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone, icn_rss %}
{% macro pager(current_page, page_count) %}
{% set left_start = [1, current_page - 5] | max %}
{% set right_end = [page_count, current_page + 5] | min %}
@@ -359,3 +359,11 @@
{% endmacro %}
+
+{% macro rss_html_content(html) %}
+{{ html }}
+{% endmacro %}
+
+{% macro rss_button(feed) %}
+
+{% endmacro %}
diff --git a/app/templates/guides/user-guides/99-babycode.html b/app/templates/guides/user-guides/99-babycode.html
index ca9e9a0..05d1d0e 100644
--- a/app/templates/guides/user-guides/99-babycode.html
+++ b/app/templates/guides/user-guides/99-babycode.html
@@ -167,9 +167,18 @@
Your display name
Mentioning a user does not notify them. It is simply a way to link to their profile in your posts.
+
+ {% set hr_example = "some section\n---\nanother section" %}
+ Horizontal rules
+ The special --- markup inserts a horizontal separator, also known as a horizontal rule:
+ {{ ("[code]%s[/code]" % hr_example) | babycode | safe }}
+ Will become
+ {{ hr_example | babycode(true) | safe}}
+ Horizontal rules will always break the current paragraph.
+
- The special void tags [lb], [rb], and [@] will appear as the literal characters [, ], and @ respectively. Unlike other tags, they are self-contained and have no closing equivalent.
+ The special void tags [lb], [rb], [-] and [@] will appear as the literal characters [, ], and @ respectively. Unlike other tags, they are self-contained and have no closing equivalent.
{% set lbrb = "[color=red]This text will be red[/color]\n\n[lb]color=red[rb]This text won't be red[lb]/color[rb]" %}
[lb] and [rb] allow you to use square brackets without them being interpreted as Babycode:
@@ -178,6 +187,7 @@
{{ lbrb | babycode | safe }}
The [@] tag allows you to use the @ symbol without it being turned into a mention.
+ The [-] tag allows you to use the - (dash) symbol without it being turned into a rule.
{% endblock %}
diff --git a/app/templates/threads/thread.atom b/app/templates/threads/thread.atom
new file mode 100644
index 0000000..607c5d4
--- /dev/null
+++ b/app/templates/threads/thread.atom
@@ -0,0 +1,19 @@
+{% extends 'base.atom' %}
+{% from 'common/macros.html' import rss_html_content %}
+{% block title %}replies to {{thread.title}}{% endblock %}
+{% block canonical_link %}{{url_for('threads.thread', slug=thread.slug, _external=true)}}{% endblock %}
+{% block content %}
+ {% for post in posts %}
+ {% set post_url = get_post_url(post.id, _anchor=true, external=true) %}
+
+ Re: {{ thread.title }}
+
+ {{ post_url }}
+ {{ post.edited_at | iso8601 }}
+ {{rss_html_content(post.content_rss)}}
+
+ {{ post.display_name }} @{{ post.username }}
+
+
+ {% endfor %}
+{% endblock %}
diff --git a/app/templates/threads/thread.html b/app/templates/threads/thread.html
index 7b154e9..641ae4e 100644
--- a/app/templates/threads/thread.html
+++ b/app/templates/threads/thread.html
@@ -1,4 +1,4 @@
-{% from 'common/macros.html' import pager, babycode_editor_form, full_post, bookmark_button %}
+{% from 'common/macros.html' import pager, babycode_editor_form, full_post, bookmark_button, rss_button %}
{% from 'common/icons.html' import icn_bookmark %}
{% extends "base.html" %}
{% block title %}{{ thread.title }}{% endblock %}
@@ -53,6 +53,7 @@
{% endif %}
+ {{ rss_button(url_for('threads.thread_atom', slug=thread.slug)) }}
{% for post in posts %}
diff --git a/app/templates/topics/topic.atom b/app/templates/topics/topic.atom
new file mode 100644
index 0000000..53d4cbf
--- /dev/null
+++ b/app/templates/topics/topic.atom
@@ -0,0 +1,20 @@
+{% extends 'base.atom' %}
+{% from 'common/macros.html' import rss_html_content %}
+{% block title %}latest threads in {{target_topic.name}}{% endblock %}
+{% block canonical_link %}{{url_for('topics.topic', slug=target_topic.slug, _external=true)}}{% endblock %}
+{% block content %}
+{{ target_topic.description }}
+ {% for thread in threads_list %}
+
+ [new thread] {{ thread.title | escape }}
+
+
+ {{ url_for('threads.thread', slug=thread.slug, _external=true)}}
+ {{ thread.created_at | iso8601 }}
+ {{rss_html_content(thread.original_post_content)}}
+
+ {{thread.started_by_display_name}} @{{ thread.started_by }}
+
+
+ {% endfor %}
+{% endblock %}
diff --git a/app/templates/topics/topic.html b/app/templates/topics/topic.html
index 1254cb7..b114e63 100644
--- a/app/templates/topics/topic.html
+++ b/app/templates/topics/topic.html
@@ -1,4 +1,4 @@
-{% from 'common/macros.html' import pager, timestamp, motd %}
+{% from 'common/macros.html' import pager, timestamp, motd, rss_button %}
{% from 'common/icons.html' import icn_lock, icn_sticky %}
{% extends "base.html" %}
{% block title %}browsing topic {{ topic['name'] }}{% endblock %}
@@ -6,7 +6,7 @@
All threads in "{{topic['name']}}"
{{topic['description']}}
-
+
{% if active_user %}
{% if not (topic['is_locked']) | int or active_user.is_mod() %}
New thread
@@ -18,6 +18,7 @@
Delete
+ {{ rss_button(url_for('topics.topic_atom', slug=topic.slug)) }}
{% endif %}
{% endif %}