Implement basics

This commit is contained in:
Peter J. Holzer 2025-04-27 19:10:30 +02:00
commit 4d721ddafa
15 changed files with 603 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__
config.py

23
Notes Normal file
View File

@ -0,0 +1,23 @@
Wieder mal ein Anlauf
Eintragen muss ganz einfach gehen.
Mehrere Wege:
Webformular
CLI
Mobile?
Jede Seite bekommt ID (hash?)
ID ändert sich, wenn editiert wird, aber per default gibt es redirect
auf aktuelle Version, wenn man auf alte ID zugreift (es gibt einen
speziellen Permalink, der das verhindert).
Außerdem Zugriff über Tags: Wenn die Tags eindeutig sind, bekommt man
die eine Seite, sonst eine Liste von Seiten (und weiteren Tags)
Ich hätte außerdem gerne eindeutige (HTML-)Ids für jeden Absatz. Aber
das schlägt sich mit dem oben geschilderten dynamischen Verhalten, d das
Fragment nicht an den Server geschickt wird und daher nicht in einem
Redirekt erhalten werden kann.
Oder man kann einen Bereich markieren und bekommt dann einen Link auf
diesen Bereich? Feature-Creep-Alert!

0
README.md Normal file
View File

74
add_zettel Normal file
View File

@ -0,0 +1,74 @@
#!/usr/bin/python3
import argparse
import html
import subprocess
import sys
import tempfile
import yaml
import psycopg
import requests
from bs4 import BeautifulSoup
ap = argparse.ArgumentParser()
ap.add_argument("--author", default="hjp")
ap.add_argument("--content_type", default="text/plain")
ap.add_argument("--tags", nargs="+")
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--content")
g.add_argument("--url")
g.add_argument("--file")
g.add_argument("--editor", action="store_true")
args = ap.parse_args()
if args.content:
content = args.content
elif args.file:
with open(args.file) as fh:
content = fh.read()
elif args.url:
r = requests.get(args.url)
if r.status_code != 200:
print(f"GETting {args.url} failed with {r.status_code} {r.message}",
file=sys.stderr)
exit(1)
soup = BeautifulSoup(r.content, "lxml")
title = soup.find("title")
title = title.text
content = f"[{title}]({args.url})"
args.content_type = "text/markdown"
elif args.editor:
with tempfile.NamedTemporaryFile(delete_on_close=False) as tfh:
tfh.close()
subprocess.run(["sensible-editor", tfh.name])
with open(tfh.name, "r") as fh:
content = fh.read()
if len(content) == 0:
print("Content is empty. Aborting.", file=sys.stderr)
conn = psycopg.connect(dbname="zettel")
csr = conn.cursor(row_factory=psycopg.rows.namedtuple_row)
csr.execute("select * from role where name = %s", (args.author,))
author_id = csr.fetchone().id
csr.execute(
"""
insert into post(text, ts, content_type, author) values(%s, now(), %s, %s)
returning id
""",
(content, args.content_type, author_id,))
post_id = csr.fetchone().id
for tag in args.tags:
csr.execute("select * from tag where lower(name) = lower(%s)",
(tag,)) # XXX - use citext?
r = csr.fetchone()
if not r:
csr.execute("insert into tag(name) values(%s) returning id",
(tag,))
r = csr.fetchone()
tag_id = r.id
csr.execute("insert into tag_post(tag, post) values(%s, %s)",
(tag_id, post_id,))
csr.execute("insert into acl(post, role) values(%s, %s)", (post_id, author_id,))
conn.commit()

219
app.py Normal file
View File

@ -0,0 +1,219 @@
import base64
import email.message
import os
import smtplib
import markdown
import psycopg
from flask import Flask, render_template, g, url_for, session, request
from markupsafe import Markup
import config
app = Flask(__name__)
app.config.from_object(config)
#-----------------------------------------------------------------------
# HTTP endpoints
@app.get("/")
def index():
return show_posts("")
@app.get("/<path:p>")
def show_posts(p):
roles = get_roles()
app.logger.info(f"{p}")
tags = p.split("/")
csr = get_cursor()
csr.execute("select * from tag where lower(name) = any(%s)", (tags,))
tag_ids = [r.id for r in csr]
# XXX - Check that tag_ids has the same length as tags.
# Otherwise
# - return 404?
# - redirect?
q = "select distinct p.* from post p"
for tag_id in tag_ids:
# we got tag_id from the database so we know it's an int
q += f" join tag_post t{tag_id} on p.id = t{tag_id}.post and t{tag_id}.tag = {tag_id}"
# and the user_id is an int, too
q += f" join acl on p.id = acl.post and acl.role = any(%s)"
q += " order by p.ts desc"
csr.execute(q, (roles,))
posts = csr.fetchall()
# XXX find all tags for each post
post_ids = [p.id for p in posts]
csr.execute(
"select * from tag_post tp join tag t on tp.tag = t.id where post = any(%s) order by name",
(post_ids,))
tags_by_id = {}
tags_by_post = {}
for r in csr:
if r.id not in tags_by_id:
selected = r.id in tag_ids
if selected:
# XXX - should really use id
new_tags = [t for t in tags if t != r.name.lower()]
else:
new_tags = tags + [r.name.lower()]
link = url_for("show_posts", p="/".join(new_tags))
tags_by_id[r.id] = { "id": r.id, "name": r.name, "selected": selected, "link": link }
tags_by_post.setdefault(r.post, []).append(tags_by_id[r.id])
tags = tags_by_id.values()
return render_template(
"posts.html",
posts=posts, tags=tags, tags_by_post=tags_by_post,
)
# XXX - do we need a prefix for paths?
# Or use a separate domain for the api (no)
# Or use an impossible prefix for the api (what would that be?)
#@get("/api")
#@get("/@/add_post")
#@post("/@/add_post")
# also json
#@get("/@/tag/<tag_name_or_id>")
# plus one or more posts for operations, like editing implies, bulk
# updates, etc.
@app.get("/@/login")
def login_form():
# could duplicate as registration
return render_template("login_form.html")
@app.post("/@/login")
def login_step1():
csr = get_cursor()
email = request.form["email"].strip()
csr.execute("select * from role where email = %s", (email,))
r = csr.fetchone()
if not r:
csr.execute("insert into role(email) values(%s) returning *", (email,))
r = csr.fetchone()
user_id = r.id
key = base64.urlsafe_b64encode(os.urandom(15)).decode('us-ascii')
csr.execute("insert into confirm_token(role, token) values(%s, %s) returning *", (user_id, key,))
r = csr.fetchone()
csr.connection.commit()
send_mail(email, request.root_url + url_for("login_confirm", key=key))
return render_template("mail_sent.html")
@app.get("/@/confirm")
def login_confirm():
# XXX - Check that key isn't expired
return render_template("login_confirm.html")
@app.post("/@/confirm")
def do_confirm():
csr = get_cursor()
csr.execute("delete from confirm_token where expires <= now()")
csr.execute(
"""
select id, name from confirm_token t join role r on t.role = r.id
where token = %s
""",
(request.form["key"],))
r = csr.fetchone()
if r:
csr.execute(
"""
update role
set first_confirmed = coalesce(first_confirmed, now()),
last_confirmed = now()
where id = %s
""",
(r.id,))
session["user_id"] = r.id
session["user_name"] = r.name
return render_template("welcome.html")
else:
return render_template("no_token.html")
@app.route("/@/logout", methods=["GET", "POST"])
def logout():
del session["user_id"]
del session["user_name"]
return redirect("/", code=303)
#@get("/~user")
# maybe too oldskool? use @user?
#@get("/~user/token")
#-----------------------------------------------------------------------
# Content generation
@app.template_filter("markdown")
def markdown_to_html(m):
return Markup(markdown.markdown(m))
def send_mail(email_address, confirmation_url):
msg = email.message.EmailMessage()
msg["From"] ="noreply@hjp.at"
msg["To"] = email_address
msg["Subject"] = "Zettel confirmation"
body = confirmation_url # Really minimalistic
msg.set_content(body)
mta = smtplib.SMTP(host="localhost")
mta.send_message(msg)
#-----------------------------------------------------------------------
# Plumbing
def get_roles():
user_id = session.get("user_id")
csr = get_cursor()
if not user_id:
csr.execute("select * from role where name = %s", ("public",))
r = csr.fetchone()
user_id = session["user_id"] = r.id
session["user_name"] = r.name
assert type(user_id) == int
roles = [user_id]
csr.execute(
"""
with recursive r as (
select group_role from role_member where member_role = %s
union
select role_member.group_role from role_member join r on role_member.member_role = r.group_role
)
select * from r
""",
(user_id,))
for r in csr:
roles.append(r.group_role)
return roles
def get_cursor():
db = get_db()
csr = db.cursor(row_factory=psycopg.rows.namedtuple_row)
return csr
def get_db():
if "db" not in g:
g.db = psycopg.connect(dbname=config.dbname, user=config.dbuser)
return g.db
@app.teardown_appcontext
def teardown_db(exception):
app.logger.debug("in teardown_db: exception = %s", exception)
db = g.pop('db', None)
if db is not None:
if not exception:
try:
db.commit()
except:
pass
db.rollback()
db.close()

70
hugo2zettel Normal file
View File

@ -0,0 +1,70 @@
#!/usr/bin/python3
import argparse
import html
import yaml
import psycopg
ap = argparse.ArgumentParser()
ap.add_argument("--author",default="hjp")
ap.add_argument("file")
args = ap.parse_args()
if args.file.endswith(".md"):
file_type = "markdown"
elif args.file.endswith(".html"):
file_type = "html"
else:
file_type = "plain"
with open(args.file) as fh:
section = None
frontmatter = ""
body = ""
for ln in fh:
if section is None and ln == "---\n":
section = "frontmatter"
elif section == "frontmatter" and ln == "---\n":
section = "body"
elif section == "frontmatter":
frontmatter += ln
elif section == "body":
body += ln
else:
raise RuntimeError("oops")
frontmatter = yaml.safe_load(frontmatter)
# We don't have a dedicated title. Just prepend it to the body
if file_type == "html":
body = "<h1>" + html.escape(frontmatter["title"]) + "</h1>" + "\n" \
+ body
else:
body = frontmatter["title"] + "\n" \
+ "=" * len(frontmatter["title"]) + "\n" \
+ "\n" \
+ body
ts = frontmatter.get("lastmod") or frontmatter.get("date")
conn = psycopg.connect(dbname="zettel")
csr = conn.cursor(row_factory=psycopg.rows.namedtuple_row)
csr.execute("select * from role where name = %s", (args.author,))
author_id = csr.fetchone().id
csr.execute(
"""
insert into post(text, ts, content_type, author) values(%s, %s, %s, %s)
returning id
""",
(body, ts, "text/" + file_type, author_id,))
post_id = csr.fetchone().id
for tag in frontmatter["tags"]:
csr.execute("select * from tag where lower(name) = lower(%s)",
(tag,)) # XXX - use citext?
r = csr.fetchone()
if not r:
csr.execute("insert into tag(name) values(%s) returning id",
(tag,))
r = csr.fetchone()
tag_id = r.id
csr.execute("insert into tag_post(tag, post) values(%s, %s)",
(tag_id, post_id,))
conn.commit()

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
flask
ProcruSQL
psycopg
pyyaml
markdown
requests
beautifulsoup4
lxml

14
schema.procrusql Normal file
View File

@ -0,0 +1,14 @@
table post
column post id
table attachment
table tag
table implies
table tag_post
table role
table acl

83
templates/base.html Normal file
View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html>
<head>
<!--
Replace with local copy (11)
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Economica:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Economica:ital,wght@0,400;0,700;1,400;1,700&family=Josefin+Sans:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet">
<!-- end of (11) -->
<style>
body {
font-family: "Josefin Sans", serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
line-height: 1.5;
background-color: oklch(0.9 0.025 200);
display: grid;
}
.info {
inline-size: fit-content;
grid-row: 1/2;
grid-column: 2/3;
width: min-content;;
text-align: right;
justify-self: right;
}
h1 {
font-family: "Economica", serif;
font-weight: 400;
font-style: normal;
font-size: 3rem;
line-height: 1.0;
}
article {
/* border: 1px solid oklch(0.1 0.5 200); */
margin-block: 1rem;
border-radius: 1rem;
padding: 1rem;
background-color: oklch(1 0 0);
}
.superceded {
opacity: 0.5;
}
.tagcloud {
display: flex;
flex-wrap: wrap;
gap: 1rem;
grid-row: 1/2;
grid-column: 1/2;
align-self: start;
}
.tag {
border: 1px solid oklch(0.1 0.5 200);
border-radius: 0.5em;
padding: 0.25em 1.0em;
font-size: 0.8125rem;
text-decoration: none;
color: oklch(0.3 0.5 200);
background-color: oklch(1 0 0);
}
.tag.selected {
background-color: oklch(0.3 0.5 200);
color: oklch(0.95 0.5 200);
}
main {
grid-row: 2/3;
grid-column: 1/3;
}
p {
max-width: 40em;
}
</style>
</head>
<body>
{% block content %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block content %}
<div class="info">
{{ session }}
{{ session.user_name }}
</div>
<main>
<form method="post">
<label>
<input type="text" name="key" value="{{request.args.key}}">
</label>
<input type="submit" value="Confirm">
</form>
</main>
{% endblock %}

16
templates/login_form.html Normal file
View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block content %}
<div class="info">
{{ session }}
{{ session.user_name }}
</div>
<main>
<form method="post">
<label>
Email
<input name="email" type="email">
</label>
<input type="submit" value="Login">
</form>
</main>
{% endblock %}

11
templates/mail_sent.html Normal file
View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="info">
{{ session }}
{{ session.user_name }}
</div>
<main>
Mail sent.
</main>
{% endblock %}

15
templates/no_token.html Normal file
View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
<div class="info">
{{ session }}
{{ session.user_name }}
</div>
<main>
Token could not be found, Maybe it has already expired?
<a href="/">Go Home!</a> (J/K)
<a href="/@/login">Try again</a>
</main>
{% endblock %}

39
templates/posts.html Normal file
View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block content %}
<div class="info">
{{ session }}
{{ session.user_name }}
{% if session.user_id == 1 %}
<a href="{{url_for('login_form')}}">Login</a>
{% else %}
<a href="{{url_for('logout')}}">Logout</a>
{% endif %}
</div>
<div class="tagcloud">
{% for tag in tags %}
<a href="{{tag.link}}" class="tag {% if tag['selected'] %}selected{% endif %}">{{tag['name']}}</a>
{% endfor %}
</div>
<main>
<p>{{posts | length}} posts:</p>
{% for post in posts %}
<article class="{%if post.superceded_by%}superceded{%endif%}">
<div class="tagcloud">
{% for tag in tags_by_post[post.id] %}
<a href="{{tag.link}}" class="tag {% if tag['selected'] %}selected{% endif %}">{{tag['name']}}</a>
{% endfor %}
</div>
{% if post.content_type == "text/html" %}
<!-- for now I trust myself -->
{{ post.text | safe }}
{% elif post.content_type == "text/markdown" %}
{{post.text | markdown}}
{% else %}
<pre>{{post.text}}</pre>
{% endif %}
</article>
{% endfor %}
</main>
{% endblock content %}

13
templates/welcome.html Normal file
View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<div class="info">
{{ session }}
{{ session.user_name }}
</div>
<main>
Welcome, {{ session.user_name }}!
<a href="/">Go Home!</a> (J/K)
</main>
{% endblock %}