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()