bubble/composer.py

455 lines
16 KiB
Python
Raw Normal View History

import re
from utils import *
2023-05-10 12:20:32 +00:00
from model import Post, Segment, User, Subspace
def edit_segment(session):
if not session.user:
return 60, 'Must be signed in to edit posts'
db = session.db
req = session.req
found = re.match(r'^(edit|move)-segment/(\d+)$', req.path[len(session.path):])
seg_action = found.group(1)
seg_id = int(found.group(2))
segment = db.get_segment(seg_id)
if not segment:
return 51, 'Not found'
if is_empty_query(req) and session.is_gemini:
if seg_action =='edit':
if segment.type == Segment.LINK:
return 10, 'Edit link: (URL followed by label, separated with space)'
if segment.type == Segment.POLL:
return 10, 'Edit poll option:'
else:
if segment.type == Segment.POLL:
return 10, 'Move to which position in the poll: (X to remove)'
return 10, 'Edited text:' if seg_action == 'edit' else \
'Move to which position §? (X to remove)'
post = db.get_post(segment.post)
if not session.is_editable(post):
return 61, 'Cannot edit posts by other users'
if seg_action == 'edit':
if segment.type == Segment.LINK:
seg_url, seg_text = parse_link_segment_query(req)
db.update_segment(segment, url=seg_url, content=seg_text)
elif segment.type == Segment.TEXT:
if session.is_gemini:
seg_text = clean_query(req)
else:
seg_text = clean_text(req.content.decode('utf-8'))
db.update_segment(segment, content=seg_text)
elif segment.type == Segment.POLL:
opt_text = clean_title(clean_query(req))
db.update_segment(segment, content=opt_text)
elif segment.type == Segment.IMAGE or segment.type == Segment.ATTACHMENT:
seg_text = clean_title(clean_query(req))
db.update_segment(segment, content=seg_text)
else:
arg = clean_query(req)
if arg.upper() == 'X':
db.destroy_segment(segment)
else:
db.move_segment(post, segment, int(arg) - 1)
return 30, f'{session.server_root()}{session.path}edit/{segment.post}'
def make_composer_page(session):
"""Post composer."""
db = session.db
req = session.req
found = re.match(r'^(\d+)(/([\w-]+)(/([0-9a-zA-Z]{10}))?)?$',
req.path[len(session.path + 'edit/'):])
post_id = int(found.group(1))
post_action = found.group(3)
req_token = found.group(5)
post = db.get_post(post_id)
2023-05-10 12:20:32 +00:00
subspace = db.get_subspace(id=post.subspace)
if not session.user:
return 60, 'Must be signed in to edit posts'
if post_action == 'delete':
if not session.is_deletable(post):
return 61, 'Cannot delete post'
elif post_action == 'mod-title':
if not session.is_title_editable(post):
return 61, 'Cannot edit post title'
elif not session.is_editable(post):
return 61, 'Cannot edit posts by other users'
user_token = db.get_token(session.user)
2023-05-10 12:20:32 +00:00
is_issue_tracker = (subspace.flags & Subspace.ISSUE_TRACKER) != 0
post_type = 'Issue' if is_issue_tracker else 'Post' if not post.parent else 'Comment'
link = f'{session.path}edit/{post_id}'
2023-05-13 11:55:30 +00:00
gemini_link = f'{session.server_root()}' + link
seg_link = session.path + 'edit-segment'
2023-05-13 11:55:30 +00:00
titan_seg_link = f'titan://{session.bubble.hostname}:{session.bubble.port}' + seg_link
if post_action == 'add-text':
if not is_empty_query(req) or (session.is_titan and len(req.content)):
seg_text = clean_query(req) if session.is_gemini \
else clean_text(req.content.decode('utf-8'))
db.create_segment(post, Segment.TEXT, content=seg_text)
return 30, gemini_link
return 10, 'Add text segment:'
if post_action == 'add-link':
if not is_empty_query(req):
seg_url, seg_text = parse_link_segment_query(req)
db.create_segment(post, Segment.LINK, url=seg_url, content=seg_text)
return 30, link
return 10, 'Add link: (URL followed by label, separated with space)'
if post_action == 'add-poll':
if not is_empty_query(req):
db.create_segment(post, Segment.POLL, content=clean_title(clean_query(req)))
return 30, link
return 10, 'Add poll option:'
if post_action == 'add-file' and session.is_titan:
if len(req.content) > session.bubble.max_file_size:
return 50, f'File attachments must be less than {int(session.bubble.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(session.user, fn, mime, req.content)
is_image = mime.startswith('image/')
url_path = '/u/' + session.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' or post_action == 'mod-title':
if req.query == None:
2023-05-10 12:20:32 +00:00
return 10, f'Enter {post_type.lower()} title:'
title_text = clean_title(clean_query(req))
db.update_post(post, title=title_text)
return 30, link if post_action == 'title' else post.page_url()
if post_action == 'omit-feed':
db.update_post(post, flags=post.flags ^ Post.OMIT_FROM_FEED_FLAG)
return 30, link
if post_action == 'preview':
db.update_post_summary(post)
page = f'=> {link}/publish 📤 Publish {post_type.lower()} (in {subspace.title()})\n'
page += f'=> {link} Edit draft\n'
2023-05-10 18:46:55 +00:00
session.context = subspace
session.is_context_tracker = is_issue_tracker
if not post.parent:
page += '\n# Feed Preview\n'
page += session.feed_entry(post, session.context)
if not post.title:
2023-05-10 12:20:32 +00:00
page += f'\n# {post_type} Preview\n'
else:
page += '\n\n\n\n\n'
page += session.render_post(post)
poll = session.render_poll(post)
if poll:
if not page.endswith('\n\n'): page += '\n'
page += poll
return page
if post_action == 'publish':
2023-05-10 18:46:55 +00:00
if is_issue_tracker:
if len(post.title) == 0:
return 50, "Issues must have a title"
if len(post.summary.strip()) == 0:
return 50, "Cannot publish empty post"
db.publish_post(post)
2023-05-10 18:46:55 +00:00
return 30, post.page_url()
if post_action == 'delete':
if not db.verify_token(session.user, req_token):
return 61, "Not authorized"
if is_empty_query(req):
2023-05-10 12:20:32 +00:00
return 10, f'Delete {post_type.lower()} "{post.ymd_date()} {post.title_text()}" in {"u" if post.sub_owner else "s"}/{post.sub_name}? (Enter YES to confirm)'
if req.query.upper() == 'YES':
dst = '/dashboard' if post.is_draft else '/u/' + session.user.name
try:
if post.parent:
dst = db.get_post(post.parent).page_url()
except:
pass
db.destroy_post(post)
return 30, dst
else:
return 30, f'/edit/{post.id}'
is_draft = post.is_draft
is_comment = post.parent != 0
if is_comment:
kind = 'Comment'
elif is_draft:
kind = 'Draft'
else:
2023-05-10 12:20:32 +00:00
kind = post_type
if not is_comment:
page = f'# Edit {kind}\n'
page += session.dashboard_link()
if is_draft:
2023-05-10 12:20:32 +00:00
page += f'=> {link}/preview 👁️ Preview {post_type.lower()}\n'
# Options and metadata:
page += f'\n## {post.title_text()}\n'
2023-05-10 12:20:32 +00:00
page += f'=> {link}/title ✏️ Edit {post_type.lower()} title\n'
page += f'=> {link}/omit-feed {session.CHECKS[nonzero(post.flags & Post.OMIT_FROM_FEED_FLAG)]} Omit {post_type.lower()} from Gemini feed\n'
2023-05-10 18:46:55 +00:00
if is_issue_tracker:
page += f'=> /{subspace.title()} 🐞 Issue in: {subspace.title()}\n'
if not is_draft:
page += f'\nThis {post_type.lower()} is published as:\n'
page += session.gemini_feed_entry(post, None)
else:
page = '# Edit Comment\n'
page += session.dashboard_link()
if is_draft:
page += f'=> {link}/preview 👁️ Preview comment\n'
try:
page += '\nThis is a comment on:\n'
parent_post = db.get_post(id=post.parent)
page += session.gemini_feed_entry(parent_post, None)
except:
pass
segments = db.get_segments(post)
sid = 0
# Split out the poll options.
poll_options = list(filter(lambda s: s.type == Segment.POLL, segments))
segments = list(filter(lambda s: s.type != Segment.POLL, segments))
if len(poll_options):
page += '\n## Poll\n'
opt_num = 1
for opt in poll_options:
page += f'\n=> {seg_link}/{opt.id} ✏️ OPTION {opt_num}: {opt.content}\n'
page += f"=> {session.path}move-segment/{opt.id} ↕︎ Move/remove\n"
opt_num += 1
page += f'\n=> {link}/add-poll Add poll option\n'
if len(segments) == 1:
page += '\n## Contents\n'
for segment in segments:
sid += 1
if len(segments) > 1:
page += f'\n## — § {sid}\n\n'
else:
page += '\n'
if segment.type == Segment.TEXT:
page += segment.content + \
f"\n=> {seg_link if len(segment.content) < 850 else titan_seg_link}/{segment.id} ✏️ Edit text\n"
elif segment.type == Segment.LINK:
page += f'=> {segment.url} {segment.content}\n'
page += f"=> {seg_link}/{segment.id} ✏️ Edit link\n"
elif segment.type == Segment.IMAGE:
page += f'=> {segment.url} {segment.content}\n'
page += f'=> {seg_link}/{segment.id} ✏️ Edit caption\n'
elif segment.type == Segment.ATTACHMENT:
page += f'=> {segment.url} {segment.content}\n'
page += f'=> {seg_link}/{segment.id} ✏️ Edit label\n'
page += f"=> {session.path}move-segment/{segment.id} ↕︎ Move/remove\n"
if len(segments):
page += f'\n'
page += '## Actions\n'
page += f'=> {link}/add-text Add text\n'
page += f'=> titan://{session.bubble.hostname}/edit/{post.id}/add-text Add long text\n'
page += f'=> {link}/add-link Add link\n'
page += f'=> titan://{session.bubble.hostname}/edit/{post.id}/add-file Add image or file attachment ({int(session.bubble.max_file_size/1024)} KB)\nOptionally, you can set a filename with the token field.\n'
if not is_comment and not poll_options:
page += f'=> {link}/add-poll Add poll option\n'
page += f'\n=> {link}/delete/{user_token} ❌ Delete {kind.lower()}\n'
return page
def make_comment(session):
if not session.user:
return 60, 'Login required'
db = session.db
req = session.req
found = re.match(r'^(\d+)(/([\w-]+))?$', req.path[len(session.path + 'comment/'):])
post_id = int(found.group(1))
post = db.get_post(post_id)
if not post:
return 51, 'Not found'
special = None
if session.is_gemini:
com_text = clean_query(req)
if len(com_text) == 0:
return 10, 'New comment: (see Help for special commands)'
if com_text == '.':
special = 'draft'
com_text = ''
elif com_text == ':':
return 30, session.server_root('titan') + req.path
elif com_text.endswith('\\'):
com_text = com_text[:-1].strip()
special = 'draft'
else:
if not req.content_mime.startswith('text/'):
return 50, 'Content must be text'
com_text = req.content.decode('utf-8')
com_id = db.create_post(session.user, post.subspace, parent=post.id)
com = db.get_post(com_id, draft=True)
if com_text:
db.create_segment(com, Segment.TEXT, content=com_text)
db.update_post_summary(com)
if not special:
db.publish_post(com)
return 30, session.server_root() + post.page_url()
# Keep as a draft.
return 30, session.server_root() + session.path + f'edit/{com_id}'
def make_tags_page(session):
# Check the rights for changing tags.
if not session.user:
return 60, 'Login required'
db = session.db
req = session.req
2023-05-10 12:20:32 +00:00
found = re.search(r'/(\d+)(/(add|remove|open|close))?$', req.path)
if not found:
return 59, 'Bad request'
post_id = int(found[1])
action = found[3]
post = db.get_post(id=post_id)
if not post:
return 51, 'Not found'
if post.parent:
return 59, 'Comments cannot have tags'
if not session.is_deletable(post):
return 61, 'Not authorized to edit tags'
2023-05-10 12:20:32 +00:00
subspace = db.get_subspace(id=post.subspace)
is_issue_tracker = (subspace.flags & Subspace.ISSUE_TRACKER) != 0
edit_link = f'/edit-tags/{post.id}'
2023-05-10 12:20:32 +00:00
if is_issue_tracker:
if action == 'open':
action = 'remove'
req.query = Post.TAG_CLOSED
edit_link = post.page_url()
elif action == 'close':
action = 'add'
req.query = Post.TAG_CLOSED
edit_link = post.page_url()
if action == 'add':
if req.query is None:
return 10, "Tag to add: " + session.NAME_HINT
tag = clean_query(req)
if not is_valid_name(tag):
return 59, 'Invalid tag name'
2023-05-10 12:20:32 +00:00
if tag == Post.TAG_ANNOUNCEMENT and (session.user.role != User.ADMIN or
is_issue_tracker):
return 61, 'Not authorized'
db.modify_tags(post, tag, session.user, add=True)
# Only pinned or announcement can be used at a time.
if tag == Post.TAG_ANNOUNCEMENT:
db.modify_tags(post, Post.TAG_PINNED, session.user, add=False)
elif tag == Post.TAG_PINNED:
db.modify_tags(post, Post.TAG_ANNOUNCEMENT, session.user, add=False)
return 30, edit_link
if action == 'remove':
if req.query is None:
return 10, "Tag to remove:"
tag = clean_query(req)
if tag == Post.TAG_ANNOUNCEMENT and session.user.role != User.ADMIN:
return 61, 'Not authorized'
db.modify_tags(post, tag, session.user, add=False)
return 30, edit_link
if not req.query:
page = '# Post Tags\n\n'
page += 'Editing tags on:\n'
page += session.gemini_feed_entry(post)
tags = list(filter(lambda tag: tag != Post.TAG_POLL, db.get_tags(post)))
popular_tags = db.get_popular_tags()
if len(tags):
page += '\n\u200b' + ' '.join(map(lambda t: '#' + t, tags)) + '\n'
page += '\n## Add Tag\n'
page += f'=> {edit_link}/add New tag\n'
for tag in sorted(popular_tags[:15]):
2023-05-10 12:20:32 +00:00
if tag in (Post.TAG_PINNED, Post.TAG_ANNOUNCEMENT, Post.TAG_POLL, Post.TAG_CLOSED):
continue
if not tag in tags:
page += f'=> {edit_link}/add?{tag} 🏷️ {tag}\n'
if session.user.role == User.ADMIN or post.is_pinned == 0:
page += '\n'
if post.is_pinned != 1:
page += f'=> {edit_link}/add?{Post.TAG_PINNED} 📌 {Post.TAG_PINNED}\n'
2023-05-10 12:20:32 +00:00
if not is_issue_tracker and post.is_pinned != 2 and session.user.role == User.ADMIN:
page += f'=> {edit_link}/add?{Post.TAG_ANNOUNCEMENT} 📣 {Post.TAG_ANNOUNCEMENT}\n'
2023-05-10 12:20:32 +00:00
if is_issue_tracker and Post.TAG_CLOSED not in tags:
page += f'=> {edit_link}/add?{Post.TAG_CLOSED} ✔︎ {Post.TAG_CLOSED}\n'
if tags:
page += '\n## Remove Tag\n'
for tag in tags:
page += f'=> {edit_link}/remove?{tag}{tag}\n'
return page
return 30, post.page_url()