bubble/composer.py

634 lines
24 KiB
Python

import re
from utils import *
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
user = session.user
try:
found = re.match(r'^(edit|move|raw)-segment/(\d+)$', req.path[len(session.path):])
seg_action = found.group(1)
seg_id = int(found.group(2))
except:
return 59, 'Bad request'
segment = db.get_segment(seg_id)
if not segment:
return 51, 'Not found'
if session.is_gemini and is_empty_query(req) and seg_action != 'raw':
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: (see Help for special commands)' if seg_action == 'edit' else \
'Move to which § number? (Other actions: "X" to remove; "." to insert text; ":" to insert long text; "S" to split text; "M" to merge text; "/" to insert a link; "P" to insert poll)'
post = db.get_post(segment.post)
if not session.is_editable(post):
return 61, 'Cannot edit posts by other users'
if seg_action == 'raw':
if segment.type != Segment.TEXT:
return 50, 'Only text segments can be viewed in raw mode'
if session.is_gemini:
return segment.content
else:
if not req.content_mime.startswith('text/'):
return 50, 'Bad content format (must be text)'
seg_text = req.content.decode('utf-8')
db.update_segment(segment, content=seg_text)
elif 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)
if seg_text == ':':
return 30, f'{session.server_root("titan")}{session.path}edit-segment/{segment.id}'
if seg_text == '\\' or seg_text == '/':
return 30, f'{session.server_root()}{session.path}raw-segment/{segment.id}'
else:
seg_text = clean_text(req.content.decode('utf-8'))
seg_text = seg_text.rstrip()
if user.flags & User.COMPOSER_SPLIT_FLAG:
parts = split_paragraphs(seg_text)
else:
parts = [seg_text]
db.update_segment(segment, content=parts[0].strip())
# Multiple segments will be added if the paragraphs were split.
if len(parts) > 1:
pos_idx = db.get_segment_position_as_index(post, segment)
for new_part in parts[1:]:
added = db.get_segment(id=db.create_segment(post, Segment.TEXT,
content=new_part))
pos_idx += 1
db.move_segment(post, added, pos_idx)
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:
text = clean_query(req).strip()
text = remove_prefix(text, segment.url)
text = remove_prefix(text, '=> ' + segment.url)
seg_text = clean_title(text.strip())
db.update_segment(segment, content=seg_text)
else:
# Moving and other actions.
arg = clean_query(req)
cmd = arg.upper()
pos_idx = db.get_segment_position_as_index(post, segment)
if cmd == 'X':
# TODO: Need a token here?
db.destroy_segment(segment)
elif cmd == '.':
inserted = db.get_segment(id=db.create_segment(post, Segment.TEXT, ""))
db.move_segment(post, inserted, pos_idx + 1)
return 30, f'{session.path}edit-segment/{inserted.id}'
elif cmd == ':':
inserted = db.get_segment(id=db.create_segment(post, Segment.TEXT, ""))
db.move_segment(post, inserted, pos_idx + 1)
return 30, f'{session.server_root("titan")}{session.path}edit-segment/{inserted.id}'
elif cmd == 'S':
if segment.type != Segment.TEXT:
return 50, 'Only text segments can be split'
new_segments = split_paragraphs(segment.content)
if len(new_segments) > 1:
# Update the first segment and insert additional new segments as needed.
db.update_segment(segment, content=new_segments[0])
for new_content in new_segments[1:]:
inserted = db.get_segment(id=db.create_segment(post, Segment.TEXT,
content=new_content))
pos_idx += 1
db.move_segment(post, inserted, pos_idx)
return 30, f'{session.path}edit/{post.id}'
elif cmd == 'M':
all_segments = db.get_segments(post, poll=False)
try:
next_segment = all_segments[pos_idx + 1]
except:
return 50, 'Last segment cannot be merged'
if segment.type != Segment.TEXT or next_segment.type != Segment.TEXT:
return 50, 'Only text segments can be merged'
db.update_segment(segment, content=segment.content + '\n\n' + next_segment.content)
db.destroy_segment(next_segment)
return 30, f'{session.path}edit/{post.id}'
elif cmd == '/':
inserted = db.get_segment(id=db.create_segment(post, Segment.LINK))
db.move_segment(post, inserted, pos_idx + 1)
return 30, f'{session.path}edit-segment/{inserted.id}'
elif cmd == 'P':
inserted_id = db.create_segment(post, Segment.POLL)
return 30, f'{session.path}edit-segment/{inserted_id}'
else:
try:
db.move_segment(post, segment, max(1, int(arg)) - 1)
except:
return 50, 'Expected a position number or X'
return 30, f'{session.server_root()}{session.path}edit/{segment.post}'
def make_composer_page(session):
"""Post composer."""
db = session.db
req = session.req
CHECKS = session.CHECKS
found = re.match(r'^(\d+)(/([\w-]+)(/([0-9a-zA-Z]{10}))?)?$',
req.path[len(session.path + 'edit/'):])
try:
post_id = int(found.group(1))
except:
return 30, session.server_root()
post_action = found.group(3)
req_token = found.group(5)
post = db.get_post(post_id)
if not post:
return 51, 'Not found'
subspace = db.get_subspace(id=post.subspace)
is_orphan_comment = post.parent and not db.get_post(id=post.parent)
if not session.user:
return 60, 'Must be signed in to edit posts'
if is_orphan_comment and post_action != 'delete':
return 61, 'Locked comment'
if post_action == 'delete':
if not session.is_deletable(post):
return 61, 'Cannot delete post'
elif post_action == 'move':
if not session.is_movable(post):
return 61, 'Cannot move 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)
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}'
gemini_link = f'{session.server_root()}' + link
seg_link = session.path + 'edit-segment'
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'))
if session.user.flags & User.COMPOSER_SPLIT_FLAG:
parts = split_paragraphs(seg_text)
else:
parts = [seg_text.rstrip()]
for part in parts:
db.create_segment(post, Segment.TEXT, content=part)
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:
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':
if session.user.role == User.LIMITED:
return 61, "Not authorized"
db.update_post(post, flags=post.flags ^ Post.OMIT_FROM_FEED_FLAG)
return 30, link
if post_action == 'omit-all':
if session.user.role == User.LIMITED:
return 61, "Not authorized"
db.update_post(post, flags=post.flags ^ Post.OMIT_FROM_ALL_FLAG)
return 30, link
if post_action == 'featured-link':
db.update_post(post, flags=post.flags ^ Post.DISABLE_FEATURED_LINK_FLAG)
db.update_post_summary(post)
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'
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:
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':
if session.user.role == User.LIMITED and subspace.owner != session.user.id:
return 61, "Not authorized"
if is_issue_tracker:
if len(post.title) == 0 and post.parent == 0:
return 50, "Issues must have a title"
if len(post.summary.strip()) == 0:
return 50, "Cannot publish empty post"
db.publish_post(post)
if session.user.role == User.LIMITED:
db.update_post(post, flags=Post.OMIT_FROM_ALL_FLAG | Post.OMIT_FROM_FEED_FLAG)
return 30, post.page_url()
if post_action == 'unpublish':
db.unpublish_post(post)
return 30, f'/edit/{post.id}'
if post_action == 'move':
if not db.verify_token(session.user, req_token):
return 61, "Not authorized"
if session.user.role == User.LIMITED:
return 61, "Not authorized"
if is_empty_query(req):
return 10, f'Move post {post.id} to which subspace?'
subname = clean_query(req)
if subname.startswith('s/'):
subname = subname[2:]
dst_sub = db.get_subspace(name=subname)
if not dst_sub or dst_sub.id == post.subspace:
return 10, f'Move post {post.id} to which subspace?'
if dst_sub.flags & Subspace.ISSUE_TRACKER:
return 50, "Cannot move to an issue tracker subspace"
if dst_sub.owner and dst_sub.owner != post.user:
return 50, "Cannot move to another user's subspace"
oldsub_id = post.subspace
db.update_post(post, subspace_id=dst_sub.id)
db.move_comments(post, oldsub_id, dst_sub.id)
# Notify as if the post was new.
db.notify_followed_about_post(post)
post.sub_name = dst_sub.name
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):
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:
kind = post_type
if not is_comment:
page = f'# Edit {kind}\n'
page += session.dashboard_link()
if is_draft:
page += f'=> {link}/preview 👁️ Preview {post_type.lower()}\n'
# Options and metadata:
page += f'\n## {post.title_text()}\n'
page += f'=> {link}/title ✏️ Edit {post_type.lower()} title\n'
if session.user.role != User.LIMITED:
if not subspace.flags & Subspace.OMIT_FROM_ALL_FLAG:
page += f'=> {link}/omit-all {CHECKS[nonzero(post.flags & Post.OMIT_FROM_ALL_FLAG)]} Omit {post_type.lower()} from All Posts\n'
page += f'=> {link}/omit-feed {CHECKS[nonzero(post.flags & Post.OMIT_FROM_FEED_FLAG)]} Omit {post_type.lower()} from Gemini/Atom feed\n'
page += f'=> {link}/featured-link {CHECKS[is_zero(post.flags & Post.DISABLE_FEATURED_LINK_FLAG)]} Feature first link\n'
else:
page += f"=> /{subspace.title()} Limited account: post is visible only in {subspace.title()}\n"
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/actions\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}/{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/actions\n"
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\n'
actions = []
if not post.parent:
cur_tags = ': ' + post.tags if post.tags else ''
actions.append(f'=> /edit-tags/{post.id} 🏷️ Add/remove tags{cur_tags}\n')
if not is_draft and session.is_unpublishable(post):
actions.append(f'=> {link}/unpublish Unpublish {kind.lower()}\nThe {kind.lower()} is converted into a draft.\n')
actions.append(f'=> {link}/delete/{user_token} ❌ Delete {kind.lower()}\n')
page += '\n' + ''.join(actions)
return page
def make_comment(session):
if not session.user:
return 60, 'Login required'
if session.user.role == User.LIMITED:
return 61, 'Not authorized'
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'
if post.flags & Post.LOCKED_FLAG and session.user.role != User.ADMIN:
return 61, 'Post is locked'
special = None
if session.is_gemini:
com_text = clean_query(req)
if len(com_text) == 0:
return 10, 'New comment: (draft a long comment by ending with a backslash)'
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
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'
subspace = db.get_subspace(id=post.subspace)
is_issue_tracker = (subspace.flags & Subspace.ISSUE_TRACKER) != 0
session.is_context_tracker = is_issue_tracker
edit_link = f'/edit-tags/{post.id}'
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'
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:
Kind = "Issue" if is_issue_tracker else "Post"
kind = Kind.lower()
page = f'# {Kind} Tags\n\n'
page += 'Editing tags on:\n'
page += session.gemini_feed_entry(post, subspace)
tags = list(filter(lambda tag: tag != Post.TAG_POLL, db.get_tags(post)))
popular_tags = db.get_popular_tags(subspace)
#if len(tags):
# page += '### ' + ' '.join(map(lambda t: '#' + t, tags)) + '\n'
if tags:
page += f'\nCurrent tags on the {kind} (click to remove):\n'
for tag in tags:
page += f'=> {edit_link}/remove?{tag}{tag}\n'
page += '\n## Add Tag\n'
page += f'=> {edit_link}/add New tag\n'
for tag in sorted(popular_tags[:15]):
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'
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'
if is_issue_tracker and Post.TAG_CLOSED not in tags:
page += f'=> {edit_link}/add?{Post.TAG_CLOSED} ✔︎ {Post.TAG_CLOSED}\n'
return page
return 30, post.page_url()