draw the rest of the owl

This commit is contained in:
2025-12-14 07:14:00 +03:00
parent 0898c56a51
commit d4e3d7cded
16 changed files with 258 additions and 22 deletions

View File

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

View File

@@ -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'<pre><code>{children}</code></pre>'
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"<a href={uri}>{children}</a>"
return f"<a href={attr}>{children}</a>"
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'<img src="{uri}" alt={children} />'
return f'<img src="{attr}" alt={children} />'
RSS_TAGS = {
**TAGS,
'img': lambda children, attr: f'<img src="{attr}" alt={children} />',
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"}</summary>{children}</details>',
'img': tag_image_rss,
'url': tag_url_rss,
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>',
'code': tag_code_rss,
'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>',
@@ -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.

10
app/lib/render_atom.py Normal file
View File

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

View File

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

View File

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

View File

@@ -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("/<slug>")
@@ -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("/<slug>/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("/<slug>")
@login_required
def reply(slug):

View File

@@ -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('/<slug>/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("/<slug>/edit")
@login_required
@mod_only(".topic", slug = lambda slug: slug)

20
app/templates/base.atom Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
{% if self.title() %}
<title>{% block title %}{% endblock %}</title>
{% else %}
<title>{{ config.SITE_NAME }}</title>
{% endif %}
{% if self.feed_updated() %}
<updated>{% block feed_updated %}{% endblock %}</updated>
{% else %}
<updated>{{ get_time_now() | iso8601 }}</updated>
{% endif %}
<id>{{ __current_page }}</id>
<link rel="self" href="{{ __current_page }}" />
<link href="{% block canonical_link %}{% endblock %}" />
{% if self.feed_author() %}
<author>{% block feed_author %}{% endblock %}</author>
{% endif %}
{% block content %}{% endblock %}
</feed>

View File

@@ -11,6 +11,9 @@
<link rel="stylesheet" href="{{ ("/static/css/%s.css" % get_prefers_theme()) | cachebust }}">
<link rel="icon" type="image/png" href="/static/favicon.png">
<script src="{{ '/static/js/vnd/bitty-7.0.0-rc1.min.js' | cachebust }}" type="module"></script>
{% if __feedlink %}
<link rel="alternate" type="application/atom+xml" href="{{ __feedlink }}" title="{{ __feedtitle }}">
{% endif %}
</head>
<body>
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }}">

View File

@@ -53,3 +53,9 @@
<path d="M6 18V14M6 14H8L13 17V7L8 10H5C3.89543 10 3 10.8954 3 12V12C3 13.1046 3.89543 14 5 14H6ZM17 7L19 5M17 17L19 19M19 12H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}
{% macro icn_rss(width=24) %}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11C9.41828 11 13 14.5817 13 19M5 5C12.732 5 19 11.268 19 19M7 18C7 18.5523 6.55228 19 6 19C5.44772 19 5 18.5523 5 18C5 17.4477 5.44772 17 6 17C6.55228 17 7 17.4477 7 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endmacro %}

View File

@@ -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 @@
</div>
</bitty-7-0>
{% endmacro %}
{% macro rss_html_content(html) %}
<content type="html">{{ html }}</content>
{% endmacro %}
{% macro rss_button(feed) %}
<a class="linkbutton contain-svg inline icon rss-button" href="{{feed}}" title="it&#39;s actually atom, don&#39;t tell anyone &#59;&#41;">{{ icn_rss(20) }} Subscribe via RSS</a>
{% endmacro %}

View File

@@ -167,9 +167,18 @@
<a class="mention display me" href="#mentions" title="@your-username">Your display name</a>
<p>Mentioning a user does not notify them. It is simply a way to link to their profile in your posts.</p>
</section>
<section class="guide-section">
{% set hr_example = "some section\n---\nanother section" %}
<h2 id="rule">Horizontal rules</h2>
<p>The special <code class="inline-code">---</code> markup inserts a horizontal separator, also known as a horizontal rule:</p>
{{ ("[code]%s[/code]" % hr_example) | babycode | safe }}
Will become
{{ hr_example | babycode(true) | safe}}
<p>Horizontal rules will always break the current paragraph.</p>
</section>
<section class="guide-section">
<h2 id="void-tags">Void tags</h2>
<p>The special void tags <code class="inline-code">[lb]</code>, <code class="inline-code">[rb]</code>, and <code class="inline-code">[@]</code> will appear as the literal characters <code class="inline-code">[</code>, <code class="inline-code">]</code>, and <code class="inline-code">@</code> respectively. Unlike other tags, they are self-contained and have no closing equivalent.</p>
<p>The special void tags <code class="inline-code">[lb]</code>, <code class="inline-code">[rb]</code>, <code class="inline-code">[-]</code> and <code class="inline-code">[@]</code> will appear as the literal characters <code class="inline-code">[</code>, <code class="inline-code">]</code>, and <code class="inline-code">@</code> respectively. Unlike other tags, they are self-contained and have no closing equivalent.</p>
<ul class="guide-list">
{% 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]" %}
<li><code class="inline-code">[lb]</code> and <code class="inline-code">[rb]</code> allow you to use square brackets without them being interpreted as Babycode:
@@ -178,6 +187,7 @@
{{ lbrb | babycode | safe }}
</li>
<li>The <code class="inline-code">[@]</code> tag allows you to use the @ symbol without it being turned into a mention.</li>
<li>The <code class="inline-code">[-]</code> tag allows you to use the - (dash) symbol without it being turned into a rule.</li>
</ul>
</section>
{% endblock %}

View File

@@ -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) %}
<entry>
<title>Re: {{ thread.title }}</title>
<link href="{{ post_url }}"/>
<id>{{ post_url }}</id>
<updated>{{ post.edited_at | iso8601 }}</updated>
{{rss_html_content(post.content_rss)}}
<author>
<name>{{ post.display_name }} @{{ post.username }}</name>
</author>
</entry>
{% endfor %}
{% endblock %}

View File

@@ -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 @@
<input class="warn" type="submit" value="Move thread">
</form>
{% endif %}
{{ rss_button(url_for('threads.thread_atom', slug=thread.slug)) }}
</div>
</nav>
{% for post in posts %}

View File

@@ -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 %}
<subtitle>{{ target_topic.description }}</subtitle>
{% for thread in threads_list %}
<entry>
<title>[new thread] {{ thread.title | escape }}</title>
<link href="{{ url_for('threads.thread', slug=thread.slug, _external=true)}}" />
<link rel="replies" type="application/atom+xml" href="{{ url_for('threads.thread_atom', slug=thread.slug, _external=true)}}" />
<id>{{ url_for('threads.thread', slug=thread.slug, _external=true)}}</id>
<updated>{{ thread.created_at | iso8601 }}</updated>
{{rss_html_content(thread.original_post_content)}}
<author>
<name>{{thread.started_by_display_name}} @{{ thread.started_by }}</name>
</author>
</entry>
{% endfor %}
{% endblock %}

View File

@@ -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 @@
<nav class="darkbg">
<h1 class="thread-title">All threads in "{{topic['name']}}"</h1>
<span>{{topic['description']}}</span>
<div>
<div class="thread-actions">
{% if active_user %}
{% if not (topic['is_locked']) | int or active_user.is_mod() %}
<a class="linkbutton" href="{{ url_for("threads.create", topic_id=topic['id']) }}">New thread</a>
@@ -18,6 +18,7 @@
<input class="warn" type="submit" id="lock" value="{{"Unlock topic" if topic['is_locked'] else "Lock topic"}}">
</form>
<button type="button" class="critical" id="topic-delete-dialog-open">Delete</button>
{{ rss_button(url_for('topics.topic_atom', slug=topic.slug)) }}
{% endif %}
{% endif %}
</div>