Compare commits

..

10 Commits

14 changed files with 241 additions and 44 deletions

View File

@ -25,7 +25,7 @@ Designers: Paul James Miller
## ICONCINO
Affected files: [`data/static/misc/error.svg`](./data/static/misc/error.svg) [`data/static/misc/image.svg`](./data/static/misc/image.svg) [`data/static/misc/info.svg`](./data/static/misc/info.svg) [`data/static/misc/lock.svg`](./data/static/misc/lock.svg) [`data/static/misc/sticky.svg`](./data/static/misc/sticky.svg) [`data/static/misc/warn.svg`](./data/static/misc/warn.svg)
Affected files: [`data/static/misc/error.svg`](./data/static/misc/error.svg) [`data/static/misc/image.svg`](./data/static/misc/image.svg) [`data/static/misc/info.svg`](./data/static/misc/info.svg) [`data/static/misc/lock.svg`](./data/static/misc/lock.svg) [`data/static/misc/spoiler.svg`](./data/static/misc/spoiler.svg) [`data/static/misc/sticky.svg`](./data/static/misc/sticky.svg) [`data/static/misc/warn.svg`](./data/static/misc/warn.svg)
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
Copyright: Gabriele Malaspina
Designers: Gabriele Malaspina

View File

@ -1,6 +1,6 @@
from flask import Flask, session
from dotenv import load_dotenv
from .models import Avatars, Users
from .models import Avatars, Users, PostHistory, Posts
from .auth import digest
from .routes.users import is_logged_in, get_active_user
from .routes.threads import get_post_url
@ -9,7 +9,7 @@ from .constants import (
InfoboxKind, InfoboxIcons, InfoboxHTMLClass,
REACTION_EMOJI,
)
from .lib.babycode import babycode_to_html, EMOJI
from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION
from datetime import datetime
import os
import time
@ -48,6 +48,23 @@ def create_deleted_user():
"permission": PermissionLevel.SYSTEM.value,
})
def reparse_posts():
from .db import db
post_histories = PostHistory.findall([
('markup_language', '=', 'babycode'),
('format_version', 'IS NOT', BABYCODE_VERSION)
])
if len(post_histories) == 0:
return
print('Re-parsing babycode, this may take a while...')
with db.transaction():
for ph in post_histories:
ph.update({
'content': babycode_to_html(ph['original_markup']),
'format_version': BABYCODE_VERSION,
})
print('Re-parsing done.')
def create_app():
app = Flask(__name__)
app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False)
@ -76,6 +93,8 @@ def create_app():
create_admin()
create_deleted_user()
reparse_posts()
from app.routes.app import bp as app_bp
from app.routes.topics import bp as topics_bp
from app.routes.threads import bp as threads_bp

View File

@ -202,9 +202,9 @@ class Model:
@classmethod
def findall(cls, condition):
def findall(cls, condition, operator='='):
rows = db.QueryBuilder(cls.table)\
.where(condition)\
.where(condition, operator)\
.all()
res = []
for row in rows:

View File

@ -2,6 +2,8 @@ from .babycode_parser import Parser
from markupsafe import escape
import re
BABYCODE_VERSION = 3
NAMED_COLORS = [
'black', 'silver', 'gray', 'white', 'maroon', 'red',
'purple', 'fuchsia', 'green', 'lime', 'olive', 'yellow',
@ -33,7 +35,23 @@ NAMED_COLORS = [
'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',
]
def tag_code(children, attr):
def is_tag(e, tag=None):
if e is None:
return False
if isinstance(e, str):
return False
if e['type'] != 'bbcode':
return False
if tag is None:
return True
return e['name'] == tag
def is_text(e):
return isinstance(e, str)
def tag_code(children, attr, surrounding):
is_inline = children.find('\n') == -1
if is_inline:
return f"<code class=\"inline-code\">{children}</code>"
@ -47,7 +65,7 @@ def tag_list(children):
list_body = re.sub(r"\n\n+", "\1", list_body)
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
def tag_color(children, attr):
def tag_color(children, attr, surrounding):
hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$"
potential_color = attr.lower().strip()
@ -61,25 +79,48 @@ def tag_color(children, attr):
# return just the way it was if we can't parse it
return f"[color={attr}]{children}[/color]"
def tag_spoiler(children, attr, surrounding):
spoiler_name = attr if attr else "Spoiler"
content = f"<div class='accordion-content post-accordion-content hidden'>{children}</div>"
container = f"""<div class='accordion hidden'><div class='accordion-header'><button type='button' class='accordion-toggle'>+</button><span>{spoiler_name}</span></div>{content}</div>"""
return container
def tag_image(children, attr, surrounding):
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
if not is_tag(surrounding[0], 'img'):
img = f"<div class=post-img-container>{img}"
if not is_tag(surrounding[1], 'img'):
img = f"{img}</div>"
return img
TAGS = {
"b": lambda children, attr: f"<strong>{children}</strong>",
"i": lambda children, attr: f"<em>{children}</em>",
"s": lambda children, attr: f"<del>{children}</del>",
"u": lambda children, attr: f"<u>{children}</u>",
"b": lambda children, attr, _: f"<strong>{children}</strong>",
"i": lambda children, attr, _: f"<em>{children}</em>",
"s": lambda children, attr, _: f"<del>{children}</del>",
"u": lambda children, attr, _: f"<u>{children}</u>",
"img": lambda children, attr: f"<div class=\"post-img-container\"><img class=\"block-img\" src=\"{attr}\" alt=\"{children}\"></div>",
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
"img": tag_image,
"url": lambda children, attr, _: f"<a href={attr}>{children}</a>",
"quote": lambda children, attr, _: f"<blockquote>{children}</blockquote>",
"code": tag_code,
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
"ul": lambda children, attr, _: f"<ul>{tag_list(children)}</ul>",
"ol": lambda children, attr, _: f"<ol>{tag_list(children)}</ol>",
"big": lambda children, attr: f"<span style='font-size: 2rem;'>{children}</span>",
"small": lambda children, attr: f"<span style='font-size: 0.75rem;'>{children}</span>",
"big": lambda children, attr, _: f"<span style='font-size: 2rem;'>{children}</span>",
"small": lambda children, attr, _: f"<span style='font-size: 0.75rem;'>{children}</span>",
"color": tag_color,
"center": lambda children, attr: f"<div style='text-align: center;'>{children}</div>",
"right": lambda children, attr: f"<div style='text-align: right;'>{children}</div>",
"center": lambda children, attr, _: f"<div style='text-align: center;'>{children}</div>",
"right": lambda children, attr, _: f"<div style='text-align: right;'>{children}</div>",
"spoiler": tag_spoiler,
}
# [img] is considered block for the purposes of collapsing whitespace,
# despite being potentially inline (since the resulting <img> tag is inline, but creates a block container around itself and sibling images).
# [code] has a special case in is_inline().
INLINE_TAGS = {
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url'
}
def make_emoji(name, code):
@ -138,6 +179,33 @@ def break_lines(text):
text = re.sub(r"\n\n+", "<br><br>", text)
return text
def is_inline(e):
if e is None:
return False # i think
if is_text(e):
return True
if is_tag(e):
if is_tag(e, 'code'): # special case, since [code] can be inline OR block
return '\n' not in e['children']
return e['name'] in INLINE_TAGS
return e['type'] != 'rule'
def should_collapse(text, surrounding):
if not isinstance(text, str):
return False
if not text:
return True
if not text.strip() and '\n' not in text:
return not is_inline(surrounding[0]) and not is_inline(surrounding[1])
return False
def babycode_to_html(s):
subj = escape(s.strip().replace('\r\n', '\n').replace('\r', '\n'))
parser = Parser(subj)
@ -145,10 +213,19 @@ def babycode_to_html(s):
parser.bbcode_tags_only_text_children = TEXT_ONLY
parser.valid_emotes = EMOJI.keys()
elements = parser.parse()
print(elements)
uncollapsed = parser.parse()
elements = []
for i in range(len(uncollapsed)):
e = uncollapsed[i]
surrounding = (
uncollapsed[i - 1] if i-1 >= 0 else None,
uncollapsed[i + 1] if i+1 < len(uncollapsed) else None
)
if not should_collapse(e, surrounding):
elements.append(e)
out = ""
def fold(element, nobr):
def fold(element, nobr, surrounding):
if isinstance(element, str):
if nobr:
return element
@ -157,10 +234,15 @@ def babycode_to_html(s):
match element['type']:
case "bbcode":
c = ""
for child in element['children']:
for i in range(len(element['children'])):
child = element['children'][i]
_surrounding = (
element['children'][i - 1] if i-1 >= 0 else None,
element['children'][i + 1] if i+1 < len(element['children']) else None
)
_nobr = element['name'] == "code" or element['name'] == "ul" or element['name'] == "ol"
c = c + fold(child, _nobr)
res = TAGS[element['name']](c, element['attr'])
c = c + fold(child, _nobr, _surrounding)
res = TAGS[element['name']](c, element['attr'], surrounding)
return res
case "link":
return f"<a href=\"{element['url']}\">{element['url']}</a>"
@ -168,6 +250,12 @@ def babycode_to_html(s):
return EMOJI[element['name']]
case "rule":
return "<hr>"
for e in elements:
out = out + fold(e, False)
for i in range(len(elements)):
e = elements[i]
surrounding = (
elements[i - 1] if i-1 >= 0 else None,
elements[i + 1] if i+1 < len(elements) else None
)
out = out + fold(e, False, surrounding)
return out

View File

@ -4,7 +4,7 @@ import re
PAT_EMOTE = r"[^\s:]"
PAT_BBCODE_TAG = r"\w"
PAT_BBCODE_ATTR = r"[^\s\]]"
PAT_BBCODE_ATTR = r"[^\]]"
PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]"
class Parser:

View File

@ -10,6 +10,7 @@ MIGRATIONS = [
migrate_old_avatars,
'DELETE FROM sessions', # delete old lua porom sessions
'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
]
def run_migrations():

View File

@ -2,7 +2,7 @@ from flask import (
Blueprint, redirect, url_for, flash, render_template, request
)
from .users import login_required, get_active_user
from ..lib.babycode import babycode_to_html
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
from ..constants import InfoboxKind
from ..db import db
from ..models import Posts, PostHistory, Threads, Topics
@ -11,6 +11,7 @@ bp = Blueprint("posts", __name__, url_prefix = "/post")
def create_post(thread_id, user_id, content, markup_language="babycode"):
parsed_content = babycode_to_html(content)
with db.transaction():
post = Posts.create({
"thread_id": thread_id,
@ -20,10 +21,11 @@ def create_post(thread_id, user_id, content, markup_language="babycode"):
revision = PostHistory.create({
"post_id": post.id,
"content": babycode_to_html(content),
"content": parsed_content,
"is_initial_revision": True,
"original_markup": content,
"markup_language": markup_language,
"format_version": BABYCODE_VERSION,
})
post.update({"current_revision_id": revision.id})
@ -31,14 +33,16 @@ def create_post(thread_id, user_id, content, markup_language="babycode"):
def update_post(post_id, new_content, markup_language='babycode'):
parsed_content = babycode_to_html(new_content)
with db.transaction():
post = Posts.find({'id': post_id})
new_revision = PostHistory.create({
'post_id': post.id,
'content': babycode_to_html(new_content),
'content': parsed_content,
'is_initial_revision': False,
'original_markup': new_content,
'markup_language': markup_language,
'format_version': BABYCODE_VERSION,
})
post.update({'current_revision_id': new_revision.id})

View File

@ -126,8 +126,9 @@
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code>
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
</p>
<p>Text inside the tag becomes the alt text. The attribute is the image URL.</p>
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. The text inside the tag will become the image's alt text.</p>
<p>Text inside the tag becomes the alt text. The attribute is the image URL. The text inside the tag will become the image's alt text.</p>
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. However, consecutive image tags will try to stay in one line, wrapping if necessary. Break the paragraph if you wish to keep images on their own paragraph.</p>
<p>Multiple images attached to a post can be clicked to open a dialog to view them.</p>
</section>
<section class="babycode-guide-section">
<h2 id="adding-code-blocks">Adding code blocks</h2>
@ -151,6 +152,15 @@
Will produce the following list:
{{ list | babycode | safe }}
</section>
<section class="babycode-guide-section">
<h2 id="spoilers">Spoilers</h2>
{% set spoiler = "[spoiler=Major Metal Gear Spoilers]Snake dies[/spoiler]" %}
<p>You can make a section collapsible by using the <code class="inline-code">[spoiler]</code> tag:</p>
{{ ("[code]\n%s[/code]" % spoiler) | babycode | safe }}
Will produce:
{{ spoiler | babycode | safe }}
All other tags are supported inside spoilers.
</section>
{% endset %}
{{ sections | safe }}
</div>

View File

@ -59,6 +59,7 @@
<button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><img src="/static/misc/image.svg"></button>
<button class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list">1.</button>
<button class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list">&bullet;</button>
<button class="babycode-button contain-svg full" type=button id="post-editor-spoiler" title="Insert spoiler"><img src="/static/misc/spoiler.svg"></button>
</span>
<textarea class="babycode-editor" name="{{ ta_name }}" id="babycode-content" placeholder="{{ ta_placeholder }}" {{ "required" if not optional else "" }}>{{ prefill }}</textarea>
<a href="{{ url_for("app.babycode_guide") }}" target="_blank">babycode guide</a>

View File

@ -48,6 +48,7 @@
const buttonImg = document.getElementById("post-editor-img");
const buttonOl = document.getElementById("post-editor-ol");
const buttonUl = document.getElementById("post-editor-ul");
const buttonSpoiler = document.getElementById("post-editor-spoiler");
function insertTag(tagStart, newline = false, prefill = "") {
const hasAttr = tagStart[tagStart.length - 1] === "=";
@ -130,6 +131,10 @@
e.preventDefault();
insertTag("ul", true);
})
buttonSpoiler.addEventListener("click", (e) => {
e.preventDefault();
insertTag("spoiler=", true, "hidden content");
})
const previewEndpoint = "/api/babycode-preview";
let previousMarkup = "";
@ -173,5 +178,8 @@
const json_resp = await req.json();
previewContainer.innerHTML = json_resp.html;
previewErrorsContainer.textContent = "";
const accordionRefreshEvt = new CustomEvent("refresh_accordions");
document.body.dispatchEvent(accordionRefreshEvt);
});
}

View File

@ -110,27 +110,54 @@ document.addEventListener("DOMContentLoaded", () => {
});
// accordions
const accordions = document.querySelectorAll(".accordion");
accordions.forEach(accordion => {
const handledAccordions = new Set();
function attachAccordionHandlers(accordion){
if(handledAccordions.has(accordion)) {
return;
}
handledAccordions.add(accordion)
const header = accordion.querySelector(".accordion-header");
const toggleButton = header.querySelector(".accordion-toggle");
const content = accordion.querySelector(".accordion-content");
const toggle = (e) => {
e.stopPropagation();
accordion.classList.toggle("hidden");
content.classList.toggle("hidden");
toggleButton.textContent = content.classList.contains("hidden") ? "+" : "-"
}
toggleButton.addEventListener("click", toggle);
});
}
function refreshAccordions(){
const accordions = document.querySelectorAll(".accordion");
accordions.forEach(attachAccordionHandlers);
}
refreshAccordions()
document.body.addEventListener('refresh_accordions', refreshAccordions)
//lightboxes
lightboxObj = constructLightbox();
document.body.appendChild(lightboxObj.dialog);
const postImages = document.querySelectorAll(".post-inner img.block-img");
function setImageMaxSize(img) {
const { maxWidth: origMaxWidth } = getComputedStyle(img);
if (img.naturalWidth >= parseInt(origMaxWidth)) {
return;
}
img.style.maxWidth = img.naturalWidth + "px";
}
const postImages = document.querySelectorAll(".post-inner img.post-image");
postImages.forEach(postImage => {
if (postImage.complete) {
setImageMaxSize(postImage);
} else {
postImage.addEventListener("load", () => setImageMaxSize(postImage));
}
const belongingTo = postImage.closest(".post-inner");
const images = lightboxImages.get(belongingTo) ?? [];
images.push({

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L9.87868 9.87868M20 20L14.1213 14.1213M9.87868 9.87868C9.33579 10.4216 9 11.1716 9 12C9 13.6569 10.3431 15 12 15C12.8284 15 13.5784 14.6642 14.1213 14.1213M9.87868 9.87868L14.1213 14.1213M6.76821 6.76821C4.72843 8.09899 2.96378 10.026 2 11.9998C3.74646 15.5764 8.12201 19 11.9998 19C13.7376 19 15.5753 18.3124 17.2317 17.2317M9.76138 5.34717C10.5114 5.12316 11.2649 5 12.0005 5C15.8782 5 20.2531 8.42398 22 12.0002C21.448 13.1302 20.6336 14.2449 19.6554 15.2412" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

After

Width:  |  Height:  |  Size: 814 B

View File

@ -511,10 +511,21 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
width: 100%;
}
.block-img {
.post-img-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.post-image {
object-fit: contain;
max-width: 400px;
max-height: 400px;
max-width: 300px;
max-height: 300px;
min-width: 200px;
min-height: 200px;
flex: 1 1 0%;
width: auto;
height: auto;
}
.thread-info-container {
@ -786,6 +797,12 @@ ul, ol {
display: none;
}
.post-accordion-content {
padding-top: 10px;
padding-bottom: 10px;
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
}
.inbox-container {
padding: 10px;
}

View File

@ -512,10 +512,21 @@ input[type="text"], input[type="password"], textarea, select {
}
}
.block-img {
.post-img-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.post-image {
object-fit: contain;
max-width: 400px;
max-height: 400px;
min-width: 200px;
min-height: 200px;
flex: 1 1 0%;
width: auto;
height: auto;
}
.thread-info-container {
@ -781,6 +792,12 @@ ul, ol {
display: none;
}
.post-accordion-content {
padding-top: 10px;
padding-bottom: 10px;
background-color: $main_bg;
}
.inbox-container {
padding: 10px;
}