mirror of https://git.skyjake.fi/gemini/bubble.git
Working on post preview; file attachments
This commit is contained in:
parent
efb8645afa
commit
5bafb9116a
|
@ -1,3 +1,5 @@
|
||||||
/__pycache__
|
/__pycache__
|
||||||
bubble.ini
|
bubble.ini
|
||||||
server.sh
|
server.sh
|
||||||
|
.certs/
|
||||||
|
.vscode/
|
||||||
|
|
365
50_bubble.py
365
50_bubble.py
|
@ -1,7 +1,9 @@
|
||||||
"""Bubble - Bulletin Boards for Gemini"""
|
"""Bubble - Bulletin Boards for Gemini"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import mariadb
|
import mariadb
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import urllib.parse as urlparse
|
import urllib.parse as urlparse
|
||||||
|
|
||||||
|
|
||||||
|
@ -90,11 +92,26 @@ class Post:
|
||||||
return self.title
|
return self.title
|
||||||
else:
|
else:
|
||||||
return '(untitled post)'
|
return '(untitled post)'
|
||||||
|
|
||||||
|
def ymd_date(self):
|
||||||
|
dt = datetime.datetime.fromtimestamp(self.ts_created)
|
||||||
|
return dt.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
|
||||||
|
class File:
|
||||||
|
def __init__(self, id, segment, user, name, mimetype, data):
|
||||||
|
self.id = id
|
||||||
|
self.segment = segment
|
||||||
|
self.user = user
|
||||||
|
self.name = name
|
||||||
|
self.mimetype = mimetype
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
def __init__(self, cfg):
|
def __init__(self, cfg):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
|
self.max_summary = 500
|
||||||
self.conn = mariadb.connect(
|
self.conn = mariadb.connect(
|
||||||
user=cfg.get('db.user'),
|
user=cfg.get('db.user'),
|
||||||
password=cfg.get('db.password'),
|
password=cfg.get('db.password'),
|
||||||
|
@ -118,6 +135,7 @@ class Database:
|
||||||
db.execute("DROP TABLE IF EXISTS segments")
|
db.execute("DROP TABLE IF EXISTS segments")
|
||||||
db.execute("DROP TABLE IF EXISTS notifs")
|
db.execute("DROP TABLE IF EXISTS notifs")
|
||||||
db.execute("DROP TABLE IF EXISTS follow")
|
db.execute("DROP TABLE IF EXISTS follow")
|
||||||
|
db.execute("DROP TABLE IF EXISTS files")
|
||||||
|
|
||||||
db.execute("""CREATE TABLE users (
|
db.execute("""CREATE TABLE users (
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
@ -195,6 +213,15 @@ class Database:
|
||||||
UNIQUE KEY (user, type, target)
|
UNIQUE KEY (user, type, target)
|
||||||
)""")
|
)""")
|
||||||
|
|
||||||
|
db.execute("""CREATE TABLE files (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
segment INT,
|
||||||
|
user INT NOT NULL,
|
||||||
|
name VARCHAR(1000) DEFAULT '',
|
||||||
|
mimetype VARCHAR(1000),
|
||||||
|
data MEDIUMBLOB
|
||||||
|
)""")
|
||||||
|
|
||||||
db.execute('INSERT INTO users (name, avatar, role) VALUES (?, ?, ?)',
|
db.execute('INSERT INTO users (name, avatar, role) VALUES (?, ?, ?)',
|
||||||
('admin', '🚀', User.ADMIN))
|
('admin', '🚀', User.ADMIN))
|
||||||
db.execute('INSERT INTO subspaces (name, info, owner) VALUES (?, ?, 1)',
|
db.execute('INSERT INTO subspaces (name, info, owner) VALUES (?, ?, 1)',
|
||||||
|
@ -222,13 +249,47 @@ class Database:
|
||||||
def destroy_user():
|
def destroy_user():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def create_file(self, user: User, name: str, mimetype: str, data: bytes) -> int:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO files (segment, user, name, mimetype, data)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""", (0, user.id, name, mimetype, data))
|
||||||
|
self.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
def get_file(self, id):
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT segment, user, name, mimetype, data
|
||||||
|
FROM files
|
||||||
|
WHERE id=?""", (id,))
|
||||||
|
|
||||||
|
for (segment, user, name, mimetype, data) in cur:
|
||||||
|
return File(id, segment, user, name, mimetype, data)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_file_segment(self, file_id, segment_id):
|
||||||
|
print('set_file_segment', segment_id, file_id)
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute("UPDATE files SET segment=? WHERE id=?", (segment_id, file_id))
|
||||||
|
self.commit()
|
||||||
|
|
||||||
|
def destroy_post(self, post):
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute('DELETE FROM files WHERE segment IN (SELECT id FROM segments WHERE post=?)',
|
||||||
|
(post.id,))
|
||||||
|
cur.execute('DELETE FROM segments WHERE post=?', (post.id,))
|
||||||
|
cur.execute('DELETE FROM posts WHERE id=?', (post.id,))
|
||||||
|
self.commit()
|
||||||
|
|
||||||
def create_subspace():
|
def create_subspace():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def destroy_subspace():
|
def destroy_subspace():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def create_post(self, user, subspace_id, title=''):
|
def create_post(self, user: User, subspace_id: int, title=''):
|
||||||
assert type(user) is User
|
assert type(user) is User
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
|
@ -238,7 +299,48 @@ class Database:
|
||||||
self.commit()
|
self.commit()
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
|
|
||||||
def create_segment(self, post, type, content=None, url=None):
|
def update_post_summary(self, post):
|
||||||
|
# Render a concise version of the segments to be displayed in feeds,
|
||||||
|
# save in the `render` field.
|
||||||
|
segments = self.get_segments(post)
|
||||||
|
render = ''
|
||||||
|
|
||||||
|
# Use only the first link/attachment.
|
||||||
|
for seg in filter(lambda s: s.type in [Segment.LINK,
|
||||||
|
Segment.ATTACHMENT], segments):
|
||||||
|
render += f'=> {seg.url} — {seg.content}\n'
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(post.title):
|
||||||
|
render += post.title
|
||||||
|
|
||||||
|
first = True
|
||||||
|
for text in filter(lambda s: s.type == Segment.TEXT, segments):
|
||||||
|
str = clean_title(text.content)
|
||||||
|
if len(str) == 0: continue
|
||||||
|
if len(post.title) and first:
|
||||||
|
# Separate title from the body text.
|
||||||
|
render += ' — '
|
||||||
|
first = False
|
||||||
|
if len(str) > self.max_summary:
|
||||||
|
render += str[:self.max_summary] + "..."
|
||||||
|
break
|
||||||
|
render += str + ' '
|
||||||
|
|
||||||
|
for seg in filter(lambda s: s.type == Segment.IMAGE, segments):
|
||||||
|
render += f'\n=> {seg.url} {seg.content}\n'
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(render) and not render.endswith('\n'):
|
||||||
|
render += '\n'
|
||||||
|
|
||||||
|
post.render = render
|
||||||
|
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
cur.execute('UPDATE posts SET render=? WHERE id=?', (render, post.id))
|
||||||
|
self.commit()
|
||||||
|
|
||||||
|
def create_segment(self, post, type, content=None, url=None) -> int:
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO segments (post, type, content, url)
|
INSERT INTO segments (post, type, content, url)
|
||||||
|
@ -249,17 +351,22 @@ class Database:
|
||||||
cur.execute("UPDATE segments SET pos=(SELECT MAX(pos) FROM segments) + 1 WHERE id=?",
|
cur.execute("UPDATE segments SET pos=(SELECT MAX(pos) FROM segments) + 1 WHERE id=?",
|
||||||
(seg_id,))
|
(seg_id,))
|
||||||
self.commit()
|
self.commit()
|
||||||
return cur.lastrowid
|
return seg_id
|
||||||
|
|
||||||
def get_user(self, identity=None, name=None):
|
def get_user(self, id=None, identity=None, name=None):
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cond = 'fp_cert=?' if identity else 'name=?'
|
if id != None:
|
||||||
cond_value = identity.fp_cert if identity else name
|
cond = 'id=?'
|
||||||
|
cond_value = id
|
||||||
|
else:
|
||||||
|
cond = 'fp_cert=?' if identity else 'name=?'
|
||||||
|
cond_value = identity.fp_cert if identity else name
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT
|
SELECT
|
||||||
id, name, info, avatar, role, flags,
|
id, name, info, avatar, role, flags,
|
||||||
notif, email, email_notif,
|
notif, email, email_notif,
|
||||||
ts_created, ts_active,
|
UNIX_TIMESTAMP(ts_created),
|
||||||
|
UNIX_TIMESTAMP(ts_active),
|
||||||
num_notifs, sort_post, sort_cmt
|
num_notifs, sort_post, sort_cmt
|
||||||
FROM users
|
FROM users
|
||||||
WHERE {cond}""", (cond_value,))
|
WHERE {cond}""", (cond_value,))
|
||||||
|
@ -271,7 +378,7 @@ class Database:
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_posts(self, id=None, user=None, draft=False):
|
def get_posts(self, id=None, user=None, draft=None):
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
where_stm = []
|
where_stm = []
|
||||||
values = []
|
values = []
|
||||||
|
@ -281,18 +388,19 @@ class Database:
|
||||||
if user != None:
|
if user != None:
|
||||||
where_stm.append('user=?')
|
where_stm.append('user=?')
|
||||||
values.append(user.id)
|
values.append(user.id)
|
||||||
where_stm.append('draft=?')
|
if draft != None:
|
||||||
values.append(draft)
|
where_stm.append('is_draft=?')
|
||||||
|
values.append(draft)
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT
|
SELECT
|
||||||
subspace, parent, user, title, is_draft, is_pinned, ts_created, render
|
id, subspace, parent, user, title, is_draft, is_pinned, UNIX_TIMESTAMP(ts_created), render
|
||||||
FROM posts
|
FROM posts
|
||||||
WHERE {' AND '.join(where_stm)}
|
WHERE {' AND '.join(where_stm)}
|
||||||
ORDER BY ts_created
|
ORDER BY ts_created
|
||||||
""", tuple(values))
|
""", tuple(values))
|
||||||
|
|
||||||
posts = []
|
posts = []
|
||||||
for (subspace, parent, user, title, is_draft, is_pinned, ts_created, render) in cur:
|
for (id, subspace, parent, user, title, is_draft, is_pinned, ts_created, render) in cur:
|
||||||
posts.append(Post(id, subspace, parent, user, title, is_draft, is_pinned,
|
posts.append(Post(id, subspace, parent, user, title, is_draft, is_pinned,
|
||||||
ts_created, render))
|
ts_created, render))
|
||||||
return posts
|
return posts
|
||||||
|
@ -361,8 +469,9 @@ class Database:
|
||||||
|
|
||||||
return segments
|
return segments
|
||||||
|
|
||||||
def delete_segment(self, segment):
|
def destroy_segment(self, segment):
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
|
cur.execute('DELETE FROM files WHERE segment=?', (segment.id,))
|
||||||
cur.execute('DELETE FROM segments WHERE id=?', (segment.id,))
|
cur.execute('DELETE FROM segments WHERE id=?', (segment.id,))
|
||||||
self.commit()
|
self.commit()
|
||||||
|
|
||||||
|
@ -389,7 +498,7 @@ class Database:
|
||||||
cond_value = name
|
cond_value = name
|
||||||
|
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT id, name, info, flags, owner, ts_created, ts_active
|
SELECT id, name, info, flags, owner, UNIX_TIMESTAMP(ts_created), UNIX_TIMESTAMP(ts_active)
|
||||||
FROM subspaces
|
FROM subspaces
|
||||||
WHERE {cond}""",
|
WHERE {cond}""",
|
||||||
(cond_value,))
|
(cond_value,))
|
||||||
|
@ -406,6 +515,10 @@ def is_valid_name(name):
|
||||||
return re.match(r'^[\w-]+$', name) != None
|
return re.match(r'^[\w-]+$', name) != None
|
||||||
|
|
||||||
|
|
||||||
|
def plural_s(i):
|
||||||
|
return '' if i == 1 else 's'
|
||||||
|
|
||||||
|
|
||||||
def clean_text(text):
|
def clean_text(text):
|
||||||
# TODO: Clean up the text. No => links, # H1s.
|
# TODO: Clean up the text. No => links, # H1s.
|
||||||
|
|
||||||
|
@ -417,11 +530,28 @@ def clean_text(text):
|
||||||
|
|
||||||
|
|
||||||
def clean_title(title):
|
def clean_title(title):
|
||||||
title = title.replace('\n', ' ').replace('\t', ' ').strip()
|
# Strip `=>` and other Gemini syntax.
|
||||||
if len(title) and title[0] in '>#*':
|
cleaned = []
|
||||||
title = title[1:].strip()
|
syntax = re.compile(r'^(\s*=>\s*|\* |>\s*|##?#?)')
|
||||||
if len(title) and title[:2] == '=>':
|
pre = False
|
||||||
title = title[2:].strip()
|
for line in title.split('\n'):
|
||||||
|
if line[:3] == '```':
|
||||||
|
if not pre:
|
||||||
|
pre_label = line[3:].strip()
|
||||||
|
if len(pre_label) == 0:
|
||||||
|
pre_label = 'preformatted'
|
||||||
|
line = f'[{pre_label}]'
|
||||||
|
cleaned.append(line)
|
||||||
|
pre = not pre
|
||||||
|
continue
|
||||||
|
if pre:
|
||||||
|
continue
|
||||||
|
found = syntax.match(line)
|
||||||
|
if found:
|
||||||
|
line = line[found.end():]
|
||||||
|
line = line.replace('\t', ' ')
|
||||||
|
cleaned.append(line)
|
||||||
|
title = ' '.join(cleaned).strip()
|
||||||
return title
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
@ -450,9 +580,11 @@ class Bubble:
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.path = path # ends with /
|
self.path = path # ends with /
|
||||||
|
|
||||||
|
# TODO: Read these from the configuration.
|
||||||
self.site_name = 'Geminispace.org'
|
self.site_name = 'Geminispace.org'
|
||||||
self.site_info = 'A Small Social Hub'
|
self.site_info = 'A Small Social Hub'
|
||||||
self.site_icon = '🌒'
|
self.site_icon = '🌒'
|
||||||
|
self.max_file_size = 100 * 1024
|
||||||
|
|
||||||
def __call__(self, req):
|
def __call__(self, req):
|
||||||
db = Database(self.cfg)
|
db = Database(self.cfg)
|
||||||
|
@ -460,6 +592,64 @@ class Bubble:
|
||||||
db.close()
|
db.close()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def dashboard_link(self, db, user):
|
||||||
|
#notifs = ' — 0 notifications'
|
||||||
|
notifs = ''
|
||||||
|
|
||||||
|
num_drafts = db.count_posts(user=user, draft=True)
|
||||||
|
if num_drafts > 0:
|
||||||
|
notifs += f' — ✏️ {num_drafts} draft{plural_s(num_drafts)}'
|
||||||
|
|
||||||
|
return f'=> /dashboard {user.avatar} {user.name}{notifs}\n'
|
||||||
|
|
||||||
|
def feed_entry(self, db, post, user, context=None):
|
||||||
|
poster = db.get_user(id=post.user) # Pre-fetch the poster info.
|
||||||
|
|
||||||
|
src = f'=> /u/{poster.name} {poster.avatar} {poster.name}\n'
|
||||||
|
src += post.render
|
||||||
|
cmt = 'Comment'
|
||||||
|
|
||||||
|
if post.is_draft:
|
||||||
|
age = 'Now'
|
||||||
|
else:
|
||||||
|
age_seconds = max(0, int(time.time()) - post.ts_created)
|
||||||
|
age = f'{age_seconds} seconds'
|
||||||
|
|
||||||
|
sub = ''
|
||||||
|
if not context or context.id != post.subspace:
|
||||||
|
sub = ' · ' + db.get_subspace(id=post.subspace).title()
|
||||||
|
|
||||||
|
src += f'=> /u/{poster.name}/{post.id} 💬 {cmt}{sub} · {age}\n'
|
||||||
|
|
||||||
|
# TODO: Show if there if a notification related to this.
|
||||||
|
|
||||||
|
return src
|
||||||
|
|
||||||
|
def render_post(self, db, post):
|
||||||
|
"""Render the final full presentation of the post, with all segments included."""
|
||||||
|
src = ''
|
||||||
|
if len(post.title):
|
||||||
|
src += f'# {post.title}\n\n'
|
||||||
|
# TODO: Metadata?
|
||||||
|
|
||||||
|
last_type = None
|
||||||
|
for segment in db.get_segments(post):
|
||||||
|
# Optionally, separate by newline.
|
||||||
|
if last_type != None:
|
||||||
|
if last_type != segment.type or (last_type == Segment.TEXT and
|
||||||
|
segment.type == Segment.TEXT):
|
||||||
|
src += '\n'
|
||||||
|
|
||||||
|
if segment.type == Segment.TEXT:
|
||||||
|
src += segment.content + '\n'
|
||||||
|
last_type = segment.type
|
||||||
|
|
||||||
|
elif segment.type in [Segment.LINK, Segment.IMAGE, Segment.ATTACHMENT]:
|
||||||
|
src += f'=> {segment.url} {segment.content}\n'
|
||||||
|
last_type = segment.type
|
||||||
|
|
||||||
|
return src
|
||||||
|
|
||||||
def respond(self, db, req):
|
def respond(self, db, req):
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
|
@ -521,7 +711,7 @@ class Bubble:
|
||||||
if not user:
|
if not user:
|
||||||
return 60, 'Must be signed in to edit posts'
|
return 60, 'Must be signed in to edit posts'
|
||||||
|
|
||||||
found = re.match(r'^(edit|move)-segment/(\d)$', req.path[len(self.path):])
|
found = re.match(r'^(edit|move)-segment/(\d+)$', req.path[len(self.path):])
|
||||||
seg_action = found.group(1)
|
seg_action = found.group(1)
|
||||||
seg_id = int(found.group(2))
|
seg_id = int(found.group(2))
|
||||||
segment = db.get_segment(seg_id)
|
segment = db.get_segment(seg_id)
|
||||||
|
@ -548,11 +738,15 @@ class Bubble:
|
||||||
seg_text = clean_text(urlparse.unquote(req.query))
|
seg_text = clean_text(urlparse.unquote(req.query))
|
||||||
else:
|
else:
|
||||||
seg_text = clean_text(req.content.decode('utf-8'))
|
seg_text = clean_text(req.content.decode('utf-8'))
|
||||||
db.update_segment(segment, content=seg_text)
|
db.update_segment(segment, content=seg_text)
|
||||||
|
|
||||||
|
elif segment.type == Segment.IMAGE or segment.type == Segment.ATTACHMENT:
|
||||||
|
seg_text = clean_title(urlparse.unquote(req.query))
|
||||||
|
db.update_segment(segment, content=seg_text)
|
||||||
else:
|
else:
|
||||||
arg = urlparse.unquote(req.query).strip()
|
arg = urlparse.unquote(req.query).strip()
|
||||||
if arg.upper() == 'X':
|
if arg.upper() == 'X':
|
||||||
db.delete_segment(segment)
|
db.destroy_segment(segment)
|
||||||
else:
|
else:
|
||||||
db.move_segment(post, segment, int(arg) - 1)
|
db.move_segment(post, segment, int(arg) - 1)
|
||||||
|
|
||||||
|
@ -568,7 +762,7 @@ class Bubble:
|
||||||
|
|
||||||
elif req.path.startswith(self.path + 'edit/'):
|
elif req.path.startswith(self.path + 'edit/'):
|
||||||
try:
|
try:
|
||||||
found = re.match(r'^(\d)(/([\w-]+))?$', req.path[len(self.path + 'edit/'):])
|
found = re.match(r'^(\d+)(/([\w-]+))?$', req.path[len(self.path + 'edit/'):])
|
||||||
post_id = int(found.group(1))
|
post_id = int(found.group(1))
|
||||||
post_action = found.group(3)
|
post_action = found.group(3)
|
||||||
post = db.get_post(post_id)
|
post = db.get_post(post_id)
|
||||||
|
@ -600,18 +794,76 @@ class Bubble:
|
||||||
return 30, link
|
return 30, link
|
||||||
return 10, 'Add link: (URL followed by label, separated with space)'
|
return 10, 'Add link: (URL followed by label, separated with space)'
|
||||||
|
|
||||||
|
if post_action == 'add-file' and is_titan:
|
||||||
|
if len(req.content) > self.max_file_size:
|
||||||
|
return 50, f'File attachments must be less than {int(self.max_file_size / 1024)} KB'
|
||||||
|
if req.content_token:
|
||||||
|
fn = req.content_token.strip()
|
||||||
|
else:
|
||||||
|
fn = ''
|
||||||
|
mime = 'application/octet-stream'
|
||||||
|
if req.content_mime:
|
||||||
|
mime = req.content_mime.lower().split(';')[0]
|
||||||
|
file_id = db.create_file(user, fn, mime, req.content)
|
||||||
|
#fn = str(file_id)
|
||||||
|
is_image = mime.startswith('image/')
|
||||||
|
#if is_image:
|
||||||
|
|
||||||
|
url_path = '/u/' + user.name + '/'
|
||||||
|
url_path += 'image' if is_image else 'file'
|
||||||
|
url_path += f'/{file_id}'
|
||||||
|
if len(fn):
|
||||||
|
# TODO: Clean up the filename.
|
||||||
|
url_path += '/' + fn
|
||||||
|
EXTENSIONS = {
|
||||||
|
'image/jpeg': '.jpeg',
|
||||||
|
'image/jpg': '.jpg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/gif': '.gif'
|
||||||
|
}
|
||||||
|
if len(fn) == 0 and mime in EXTENSIONS:
|
||||||
|
url_path += EXTENSIONS[mime]
|
||||||
|
segment_id = db.create_segment(post,
|
||||||
|
Segment.IMAGE if is_image else Segment.ATTACHMENT,
|
||||||
|
url=url_path, content=fn)
|
||||||
|
db.set_file_segment(file_id, segment_id)
|
||||||
|
return 30, gemini_link
|
||||||
|
|
||||||
if post_action == 'title':
|
if post_action == 'title':
|
||||||
if len(req.query):
|
if len(req.query):
|
||||||
title_text = clean_title(urlparse.unquote(req.query))
|
title_text = clean_title(urlparse.unquote(req.query))
|
||||||
db.update_post(post, title=title_text)
|
db.update_post(post, title=title_text)
|
||||||
return 30, link
|
return 30, link
|
||||||
return 10, 'Enter post title:'
|
return 10, 'Enter post title:'
|
||||||
|
|
||||||
|
if post_action == 'preview':
|
||||||
|
db.update_post_summary(post)
|
||||||
|
page = f'=> {link}/publish ➠ Publish now\n'
|
||||||
|
page += f'=> {link} Edit draft\n'
|
||||||
|
page += '\n# Feed Preview\n'
|
||||||
|
page += self.feed_entry(db, post, user)
|
||||||
|
if not post.title:
|
||||||
|
page += '\n# Post Preview\n'
|
||||||
|
else:
|
||||||
|
page += '\n\n'
|
||||||
|
page += self.render_post(db, post)
|
||||||
|
return page
|
||||||
|
|
||||||
|
if post_action == 'delete':
|
||||||
|
if req.query.upper() == 'YES':
|
||||||
|
dst = '/dashboard' if post.is_draft else '/u/' + user.name
|
||||||
|
db.destroy_post(post)
|
||||||
|
return 30, dst
|
||||||
|
elif len(req.query) > 0:
|
||||||
|
return 30, f'/edit/{post.id}'
|
||||||
|
return 10, f'Delete post {post.id}: {post.ymd_date()} {post.title_text()}? (Enter YES to confirm)'
|
||||||
|
|
||||||
# The Post Composer.
|
# The Post Composer.
|
||||||
page = f'# {post.title_text()}\n'
|
page = self.dashboard_link(db, user)
|
||||||
page += f'=> {link}/title Edit title\n'
|
page += f'=> {link}/preview 👁️ Preview post\n'
|
||||||
page += f'=> /{subspace.title()} {subspace.title()}\n\n'
|
page += f'\n# {post.title_text()}\n'
|
||||||
|
page += f'=> {link}/title Edit post title\n'
|
||||||
|
|
||||||
# Segments.
|
# Segments.
|
||||||
##SEPARATOR = '\n\n'
|
##SEPARATOR = '\n\n'
|
||||||
segments = db.get_segments(post)
|
segments = db.get_segments(post)
|
||||||
|
@ -621,7 +873,7 @@ class Bubble:
|
||||||
sid += 1
|
sid += 1
|
||||||
|
|
||||||
if len(segments) > 1:
|
if len(segments) > 1:
|
||||||
page += f'\n—— § {sid} ——\n\n'
|
page += f'\n## — § {sid} —\n\n'
|
||||||
else:
|
else:
|
||||||
page += '\n'
|
page += '\n'
|
||||||
|
|
||||||
|
@ -634,12 +886,12 @@ class Bubble:
|
||||||
page += f"=> {seg_link}/{segment.id} Edit link\n"
|
page += f"=> {seg_link}/{segment.id} Edit link\n"
|
||||||
|
|
||||||
elif segment.type == Segment.IMAGE:
|
elif segment.type == Segment.IMAGE:
|
||||||
page += '\n(image)\n'
|
page += f'=> {segment.url} {segment.content}\n'
|
||||||
page += f"=> titan://{self.hostname}/{seg_link}/{segment.id} Replace image\n"
|
page += f'=> {seg_link}/{segment.id} Edit caption\n'
|
||||||
|
|
||||||
elif segment.type == Segment.ATTACHMENT:
|
elif segment.type == Segment.ATTACHMENT:
|
||||||
page += '\n(attachment\n'
|
page += f'=> {segment.url} {segment.content}\n'
|
||||||
page += f"=> titan://{self.hostname}/{seg_link}/{segment.id} Replace attachment\n"
|
page += f'=> {seg_link}/{segment.id} Edit label\n'
|
||||||
|
|
||||||
elif segment.type == Segment.POLL:
|
elif segment.type == Segment.POLL:
|
||||||
page += f'\n* {segment.content}\n'
|
page += f'\n* {segment.content}\n'
|
||||||
|
@ -654,11 +906,9 @@ class Bubble:
|
||||||
page += f'=> {link}/add-text Add text\n'
|
page += f'=> {link}/add-text Add text\n'
|
||||||
page += f'=> titan://{self.hostname}/edit/{post.id}/add-text Add long text\n'
|
page += f'=> titan://{self.hostname}/edit/{post.id}/add-text Add long text\n'
|
||||||
page += f'=> {link}/add-link Add link\n'
|
page += f'=> {link}/add-link Add link\n'
|
||||||
page += f'=> titan://{self.hostname}/edit/{post.id}/add-image Add image\n'
|
page += f'=> titan://{self.hostname}/edit/{post.id}/add-file Add image or file attachment\nOptionally, you can set a filename with the token field.\n'
|
||||||
page += f'=> titan://{self.hostname}/edit/{post.id}/add-file Add attachment\n'
|
page += f'=> {link}/add-poll Add poll option\n'
|
||||||
page += f'=> {link}/add-link Add poll option\n'
|
|
||||||
|
|
||||||
page += f'\n=> {link}/publish Preview and publish\n'
|
|
||||||
page += f'\n=> {link}/delete ❌ Delete post\n'
|
page += f'\n=> {link}/delete ❌ Delete post\n'
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
@ -671,26 +921,27 @@ class Bubble:
|
||||||
return 51, 'Not found'
|
return 51, 'Not found'
|
||||||
|
|
||||||
elif req.path == self.path + 'dashboard':
|
elif req.path == self.path + 'dashboard':
|
||||||
page = f'# {user.name}\n'
|
page = f'# {user.avatar} {user.name}\n'
|
||||||
page += f'\n## Notifications\n'
|
|
||||||
page += 'No notifications.\n'
|
page += f'\n## 0 Notifications\n'
|
||||||
page += f'\n## Drafts\n'
|
|
||||||
drafts = db.get_posts(user=user, draft=True)
|
drafts = db.get_posts(user=user, draft=True)
|
||||||
|
n = len(drafts)
|
||||||
|
page += f'\n## {n} Draft{plural_s(n)}\n'
|
||||||
if len(drafts) > 0:
|
if len(drafts) > 0:
|
||||||
for post in drafts:
|
for post in drafts:
|
||||||
page += f'=> /edit/{post.id} {post.ymd_date()} {post.title_text()}\n'
|
page += f'=> /edit/{post.id} {post.ymd_date()} {post.title_text()}\n'
|
||||||
else:
|
page += f'\n=> /u/{user.name} u/{user.name}\n'
|
||||||
page += 'No drafts.\n'
|
|
||||||
page += f'=> /u/{user.name} u/{user.name}\n'
|
|
||||||
return page
|
return page
|
||||||
|
|
||||||
elif req.path.startswith(self.path + 'u/'):
|
elif req.path.startswith(self.path + 'u/'):
|
||||||
try:
|
try:
|
||||||
# User feed.
|
# User feed.
|
||||||
path = req.path[len(self.path):]
|
path = req.path[len(self.path):]
|
||||||
found = re.match(r'u/([\w-]+)(/(post))?', path)
|
found = re.match(r'u/([\w-]+)(/(post|image|file))?(/(\d+).*)?', path)
|
||||||
u_name = found.group(1)
|
u_name = found.group(1)
|
||||||
u_action = found.group(3)
|
u_action = found.group(3)
|
||||||
|
arg = found.group(5)
|
||||||
c_user = db.get_user(name=u_name)
|
c_user = db.get_user(name=u_name)
|
||||||
context = db.get_subspace(owner=c_user.id)
|
context = db.get_subspace(owner=c_user.id)
|
||||||
|
|
||||||
|
@ -698,6 +949,10 @@ class Bubble:
|
||||||
draft_id = db.create_post(user, context.id)
|
draft_id = db.create_post(user, context.id)
|
||||||
return 30, '/edit/%d' % draft_id
|
return 30, '/edit/%d' % draft_id
|
||||||
|
|
||||||
|
if u_action == 'image' or u_action == 'file':
|
||||||
|
file = db.get_file(int(arg))
|
||||||
|
return 20, file.mimetype, file.data
|
||||||
|
|
||||||
page += f'# {context.title()}\n'
|
page += f'# {context.title()}\n'
|
||||||
|
|
||||||
except Exception as x:
|
except Exception as x:
|
||||||
|
@ -735,14 +990,7 @@ class Bubble:
|
||||||
page += f'=> /s/ {self.site_icon} Subspaces\n'
|
page += f'=> /s/ {self.site_icon} Subspaces\n'
|
||||||
page += FOOTER_MENU
|
page += FOOTER_MENU
|
||||||
else:
|
else:
|
||||||
#notifs = ' — 0 notifications'
|
page += self.dashboard_link(db, user)
|
||||||
notifs = ''
|
|
||||||
|
|
||||||
num_drafts = db.count_posts(user=user, draft=True)
|
|
||||||
if num_drafts > 0:
|
|
||||||
notifs += f' — ✏️ {num_drafts} draft{"s" if num_drafts != 1 else ""}'
|
|
||||||
|
|
||||||
page += f'=> /u/{user.name}/dashboard {user.avatar} {user.name}{notifs}\n'
|
|
||||||
if c_user and c_user.id == user.id:
|
if c_user and c_user.id == user.id:
|
||||||
page += f'=> /u/{user.name}/post ✏️ New post\n'
|
page += f'=> /u/{user.name}/post ✏️ New post\n'
|
||||||
elif context and context.owner == 0:
|
elif context and context.owner == 0:
|
||||||
|
@ -775,16 +1023,15 @@ class Bubble:
|
||||||
def init(capsule):
|
def init(capsule):
|
||||||
cfg = capsule.config()
|
cfg = capsule.config()
|
||||||
try:
|
try:
|
||||||
mod_cfg = cfg.section('bubble')
|
mod_cfg = cfg.section('bubble')
|
||||||
|
|
||||||
hostname = mod_cfg.get('host', fallback=cfg.hostnames()[0])
|
path = mod_cfg.get('path', fallback='/')
|
||||||
path = mod_cfg.get('path', fallback='/')
|
|
||||||
|
|
||||||
if not path.endswith('/'): path += '/'
|
if not path.endswith('/'): path += '/'
|
||||||
|
|
||||||
responder = Bubble(hostname, path, mod_cfg)
|
for hostname in cfg.hostnames():
|
||||||
capsule.add(path + '*', responder, hostname, protocol='gemini')
|
responder = Bubble(hostname, path, mod_cfg)
|
||||||
capsule.add(path + '*', responder, hostname, protocol='titan')
|
capsule.add(path + '*', responder, hostname, protocol='gemini')
|
||||||
|
capsule.add(path + '*', responder, hostname, protocol='titan')
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
Loading…
Reference in New Issue