From de7098f681ce6c99175b6453d0a6e1c4e289a91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaakko=20Kera=CC=88nen?= Date: Tue, 20 Jun 2023 15:02:07 +0300 Subject: [PATCH 1/1] Added post/comment index to the Dashboard --- 50_bubble.py | 13 ++++++ composer.py | 6 +++ feeds.py | 116 ++++++++++++++++++++++++++++++--------------------- model.py | 18 +++++--- subspace.py | 1 + user.py | 77 +++++++++++++++++++++++++++++++++- utils.py | 4 ++ 7 files changed, 181 insertions(+), 54 deletions(-) diff --git a/50_bubble.py b/50_bubble.py index ab957bc..11e5c2e 100644 --- a/50_bubble.py +++ b/50_bubble.py @@ -109,6 +109,7 @@ Bubble is open source: self.token = None self.notif_count = None self.is_short_preview = False + self.is_archive = False self.tz = pytz.timezone('UTC') def set_user(self, user): @@ -570,10 +571,22 @@ The front page feed, subspaces, user feeds, and issue trackers can all be filter return """# Help ## Locked Subspace + Subspaces that have no moderators are locked into read-only mode. No new posts or comments can be made. A subspace becomes unlocked when the administrator assigns at least one moderator to it. +=> /help ๐Ÿ“– Back to Help""" + + if req.path == self.path + 'help/deleted-post': + return """# Help + +## Deleted Posts + +Deleting a post does not delete its discussion thread, too, because the post author does not have the authority to delete other users' content. After a post has been deleted, comments about it are still accessible through the Dashboard comment index. You can find your orphaned comments in the index by searching for comments about a "Deleted post". Comments about deleted posts are not included in any feed. + +Deleting a subspace will delete all posts and comments in the subspace, i.e., the full discussion threads will be deleted. + => /help ๐Ÿ“– Back to Help""" elif req.path == self.path + 'new-subspace': diff --git a/composer.py b/composer.py index c9fca38..5f7bda7 100644 --- a/composer.py +++ b/composer.py @@ -118,11 +118,17 @@ def make_composer_page(session): 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' diff --git a/feeds.py b/feeds.py index 864e70f..843fc4d 100644 --- a/feeds.py +++ b/feeds.py @@ -4,6 +4,7 @@ from model import User, Post, Segment, Subspace, Commit, Crossref, \ FOLLOW_SUBSPACE, FOLLOW_USER, FOLLOW_POST, \ MUTE_SUBSPACE, MUTE_USER, MUTE_POST from subspace import subspace_admin_actions +from user import make_user_index_page from utils import * @@ -13,7 +14,7 @@ def make_post_page_or_configure_feed(session): db = session.db req = session.req path = req.path[len(session.path):] - found = re.match(r'(u|s)/([\w%-]+)(/(post|compose|image|file|issues|admin|tag))?(/([\w\d-]+)(.*))?', path) + found = re.match(r'(u|s)/([\w%-]+)(/(post|compose|image|file|issues|admin|tag|index))?(/([\w\d-]+)(.*))?', path) if not found and not path.startswith('tag'): return 59, 'Bad request' @@ -75,6 +76,9 @@ def make_post_page_or_configure_feed(session): file = db.get_file(int(arg)) return 20, file.mimetype, file.data + if session.feed_type == 'u' and action == 'index': + return make_user_index_page(session, found[6]) + if action == 'tag': if not arg: page = f'Choose a tag for filtering {session.context.title() if session.context else "All Posts"}:\n' @@ -217,6 +221,7 @@ def make_post_page_or_configure_feed(session): def make_post_page(session, post): db = session.db user = session.user + post_id = post.id is_comment_page = post.parent != 0 display_order_desc = session.user and \ session.user.sort_cmt == User.SORT_COMMENT_NEWEST @@ -226,67 +231,79 @@ def make_post_page(session, post): # Switch to the parent post, but display it in preview mode. focused_cmt = post last_age = focused_cmt.age() - post = db.get_post(id=post.parent) - page += f'=> {post.page_url()} Comment on: "{post.title if post.title else shorten_text(strip_links(clean_title(post.summary)), 60)}" in {("u/" if post.sub_owner else "s/") + post.sub_name}\n' + post_id = post.parent + post = db.get_post(id=post_id) + if post: + page += f'=> {post.page_url()} Comment on: "{post.title if post.title else shorten_text(strip_links(clean_title(post.summary)), 60)}" in {("u/" if post.sub_owner else "s/") + post.sub_name}\n' + else: + page += f'=> /help/deleted-post ๐Ÿ”’ Comment on a deleted post (ID:{post_id})\n' page += f'=> /u/{focused_cmt.poster_name} {focused_cmt.poster_avatar} {focused_cmt.poster_name}\n' page += f'{last_age}\n\n' page += session.render_post(focused_cmt) # Comment actions. actions = [] - if session.is_editable(focused_cmt): - actions.append(f'=> /edit/{focused_cmt.id} โœ๏ธ Edit comment\n') - if session.user and not session.is_context_locked and display_order_desc: - actions.append(f'=> /comment/{post.id} ๐Ÿ’ฌ Comment\n') - if session.is_thanks_enabled() and focused_cmt.user != user.id: - actions.append(f'=> /thanks/{focused_cmt.id} ๐Ÿ™ Give thanks\n') - if session.is_deletable(focused_cmt) and not session.is_editable(focused_cmt): + if post: + if session.is_editable(focused_cmt): + actions.append(f'=> /edit/{focused_cmt.id} โœ๏ธ Edit comment\n') + if post and session.user and not session.is_context_locked and display_order_desc: + actions.append(f'=> /comment/{post.id} ๐Ÿ’ฌ Comment\n') + if session.is_thanks_enabled() and focused_cmt.user != user.id: + actions.append(f'=> /thanks/{focused_cmt.id} ๐Ÿ™ Give thanks\n') + if session.is_deletable(focused_cmt) and (not session.is_editable(focused_cmt) or not post): actions.append(f'=> /edit/{focused_cmt.id}/delete/{session.get_token()} โŒ Delete comment\n') actions.append(session.dashboard_link()) if actions: page += '\n' + ''.join(actions) - op_section = '\n## Original Post\n\n' + session.feed_entry(post) + if post: + op_section = '\n## Original Post\n\n' + session.feed_entry(post) + else: + op_section = '' + if not display_order_desc: page += op_section else: page += session.render_post(post) - # Poll options/results. - poll = session.render_poll(post, show_results=not session.user) - if poll: - # Ensure separation. - if len(page) and not page.endswith('\n\n'): - page += '\n' - page += poll - commits = [] incoming_xrefs = [] outgoing_xrefs = {} - if post.issueid: - repo = db.get_repository(subspace=session.context) - commits = db.get_commits(repo, issueid=post.issueid) - incoming_xrefs = db.get_issue_crossrefs(session.context, - incoming_to_issueid=post.issueid) - outgoing_xrefs = db.get_issue_crossrefs(session.context, - outgoing_from_issueid=post.issueid) - - # Issue and commit cross references outgoing from the post body. - if repo.view_url: - first = True - for commit in db.find_commits_by_hash(repo, parse_likely_commit_hashes(page)): - if first: - page += '\n' - first = False - page += commit.entry(repo.view_url, outgoing=True) - if outgoing_xrefs and post.id in outgoing_xrefs: - page += '\n' - for xref in outgoing_xrefs[post.id]: - page += xref.outgoing_entry() - else: - repo = None + repo = None + + # Poll options/results. + if post: + poll = session.render_poll(post, show_results=not session.user) + if poll: + # Ensure separation. + if len(page) and not page.endswith('\n\n'): + page += '\n' + page += poll + + if post.issueid: + repo = db.get_repository(subspace=session.context) + commits = db.get_commits(repo, issueid=post.issueid) + incoming_xrefs = db.get_issue_crossrefs(session.context, + incoming_to_issueid=post.issueid) + outgoing_xrefs = db.get_issue_crossrefs(session.context, + outgoing_from_issueid=post.issueid) + + # Issue and commit cross references outgoing from the post body. + if repo.view_url: + first = True + for commit in db.find_commits_by_hash(repo, parse_likely_commit_hashes(page)): + if first: + page += '\n' + first = False + page += commit.entry(repo.view_url, outgoing=True) + if outgoing_xrefs and post_id in outgoing_xrefs: + page += '\n' + for xref in outgoing_xrefs[post_id]: + page += xref.outgoing_entry() + else: + repo = None if not is_comment_page: # Post metadata. @@ -380,7 +397,7 @@ def make_post_page(session, post): page += '\n' + session.dashboard_link() - notifs = db.get_notifications(user=user, post_id=post.id) + notifs = db.get_notifications(user=user, post_id=post.id, sort_desc=True) if notifs: page += f'{len(notifs)} notification{plural_s(len(notifs))} on this page:\n' for notif in notifs: @@ -390,15 +407,18 @@ def make_post_page(session, post): page += f'=> {post.page_url()}/clear-notif/{session.get_token()} ๐Ÿงน Clear\n' # Comments, repository commits, and issue cross-references. - comments = db.get_posts(parent=post.id, + comments = db.get_posts(parent=post_id, draft=False, sort_descending=False, muted_by_user_id=(user.id if user else 0), limit=None) if is_comment_page: - # Omit comments older than the focused one. - comments = list(filter(lambda p: p.ts_created > focused_cmt.ts_created, comments)) + if post: + # Omit comments older than the focused one. + comments = list(filter(lambda p: p.ts_created > focused_cmt.ts_created, comments)) + else: + comments = list(filter(lambda p: p.id != focused_cmt.id, comments)) n = len(comments) if n > 0 or commits or incoming_xrefs: @@ -406,7 +426,7 @@ def make_post_page(session, post): dir_icon = ' โ†‘' if display_order_desc else ' โ†“' else: dir_icon = '' - page += f'\n## {n} {"Later " if is_comment_page else ""}Comment{plural_s(n)}{dir_icon}\n' + page += f'\n## {n} {"Later " if is_comment_page and post else "Other " if is_comment_page else ""}Comment{plural_s(n)}{dir_icon}\n' if commits or incoming_xrefs: # Combine commits and commits into one list. @@ -453,7 +473,7 @@ def make_post_page(session, post): not session.is_context_locked: # Actions on your own comments. age_suffix = f" ยท {comment_age}" if len(comment_age) else comment_age - if session.is_editable(cmt): + if session.is_editable(cmt) and post: src += f'=> /edit/{cmt.id} โœ๏ธ Edit{age_suffix}\n' elif session.is_deletable(cmt): src += f'=> /edit/{cmt.id}/delete/{session.get_token()} โŒ Delete{age_suffix}\n' @@ -473,7 +493,7 @@ def make_post_page(session, post): # Show the Comment action at the appropriate place wrt reading direction. if session.user and not session.is_context_locked and \ - len(comments) >= 1 and not display_order_desc: + len(comments) >= 1 and not display_order_desc and post: page += f'\n=> /comment/{post.id} ๐Ÿ’ฌ Add comment\n' return page diff --git a/model.py b/model.py index 23ffe15..5497b8e 100644 --- a/model.py +++ b/model.py @@ -1542,6 +1542,7 @@ class Database: parent=None, sort_descending=True, sort_hotness=False, filter_by_followed=None, filter_issue_status=None, filter_tag=None, gemini_feed=False, notifs_for_user_id=0, muted_by_user_id=0, + sort_by_subspace=False, sort_by_post=False, ts_range=None, limit=None, page=0): cur = self.conn.cursor() where_stm = [] @@ -1602,6 +1603,10 @@ class Database: ) DESC""" else: order_by = "p.ts_created " + ('DESC' if sort_descending else 'ASC') + if sort_by_post: + order_by = f"p.id {'DESC' if sort_descending else 'ASC'}, " + order_by + if sort_by_subspace: + order_by = "sub1.name, " + order_by if limit: limit_expr = 'LIMIT ? OFFSET ?' values.append(limit) # number of results @@ -1681,14 +1686,17 @@ class Database: subspace=None, parent_id=None, draft=False, + is_comment=None, + ignore_omit_flags=False, filter_by_followed=None, filter_issue_status=None, filter_tag=None, muted_by_user_id=0): - if not parent_id and not draft: - cond = ['p.parent=0'] # no comments - else: - cond = [] + cond = [] + if is_comment: + cond.append('p.parent!=0' if is_comment else 'p.parent=0') + elif not parent_id and not draft: + cond.append('p.parent=0') # no comments values = [] filter = '' if filter_by_followed: @@ -1712,7 +1720,7 @@ class Database: if subspace != None: cond.append('p.subspace=?') values.append(subspace.id) - elif not draft: + elif not draft and not ignore_omit_flags: # Need filter out posts from subspaces that are flagged for omission. cond.append(f'((s.flags & {Subspace.OMIT_FROM_ALL_FLAG})=0 AND (p.flags & {Post.OMIT_FROM_ALL_FLAG})=0)') if filter_issue_status != None: diff --git a/subspace.py b/subspace.py index bff379d..39c1293 100644 --- a/subspace.py +++ b/subspace.py @@ -406,6 +406,7 @@ class GempubArchive: assert self.ts_range or self.subspace is not None # Modify settion so rendered pages appear to be not logged in. + session.is_archive = True session.user = None self.site_link = session.server_root() diff --git a/user.py b/user.py index fdea442..0322ae7 100644 --- a/user.py +++ b/user.py @@ -2,7 +2,8 @@ import re import urllib.parse as urlparse from model import Notification, Segment, User, \ FOLLOW_POST, FOLLOW_SUBSPACE, FOLLOW_USER, MUTE_POST, MUTE_SUBSPACE, MUTE_USER -from utils import plural_s, clean_query, is_empty_query +from utils import plural, plural_s, clean_query, is_empty_query, shorten_text, \ + strip_links, clean_title def user_actions(session): @@ -196,6 +197,7 @@ def make_dashboard_page(session): if not session.user: return 60, "Login required" + user = session.user db = session.db page = f'# {session.user.name}: Dashboard\n' @@ -256,4 +258,77 @@ def make_dashboard_page(session): for sub in subs: page += sub.subspace_link() + page += '\n## Index\n' + page += f'=> /u/{user.name}/index/posts {plural(db.count_posts(user=user, ignore_omit_flags=True), "post")}\n' + page += f'=> /u/{user.name}/index/comments {plural(db.count_posts(user=user, is_comment=True), "comment")}\n' + return page + + +def make_user_index_page(session, mode): + db = session.db + user = session.c_user + page = '' + is_posts = not (mode == 'comments') + + page += f'# {user.name}: {"Posts" if is_posts else "Comments"}\n' + page += '=> /dashboard Back to Dashboard\n' + + if is_posts: + posts = db.get_posts(user=user, draft=False, comment=False, sort_by_subspace=True) + cur_sub = None + ymd = None + for post in posts: + # Headings. + if cur_sub != post.subspace: + cur_sub = post.subspace + ymd = None + page += f"\n## {post.sub_name}\n" + post_ymd = post.ymd_date(tz=session.tz) + if ymd != post_ymd[:7]: + ymd = post_ymd[:7] + page += f"\n### {ymd}\n" + + # List entry linking to post + title = post.title if post.title else shorten_text(strip_links(clean_title(post.summary)), 120) + if post.issueid: + title = f'[#{post.issueid}] ' + title + SEP = " ยท " + meta = [] + if post.num_cmts: + meta.append(f'๐Ÿ’ฌ {post.num_cmts}') + if post.num_likes: + meta.append(f'๐Ÿ‘ {post.num_likes}') + if post.tags: + meta.append(post.tags) + entry = f'=> {post.page_url()} {post_ymd} {title}{SEP + SEP.join(meta) if meta else ""}\n' + + page += entry + + else: + comments = db.get_posts(user=user, draft=False, comment=True, + sort_by_subspace=True, sort_by_post=True) + cur_parent = None + cur_sub = None + for cmt in comments: + # Headings. + if cur_sub != cmt.subspace: + cur_sub = cmt.subspace + ymd = None + page += f"\n## {cmt.sub_name}\n" + if cur_parent != cmt.parent: + cur_parent = cmt.parent + parent_post = db.get_post(id=cur_parent) + if parent_post: + parent_title = parent_post.title if parent_post.title else f'"{shorten_text(strip_links(clean_title(parent_post.summary)), 120)}"' + if parent_post.issueid: + parent_title = f'[#{parent_post.issueid}] ' + parent_title + else: + parent_title = f"Deleted post (ID:{cur_parent})" + page += f"\n{parent_title}\n" + cmt_ymd = cmt.ymd_date(tz=session.tz) + title = shorten_text(strip_links(clean_title(cmt.summary)), 120) + entry = f'=> {cmt.page_url()} {cmt_ymd} "{title}"\n' + page += entry + + return page \ No newline at end of file diff --git a/utils.py b/utils.py index 1b8c5bb..2669dff 100644 --- a/utils.py +++ b/utils.py @@ -47,6 +47,10 @@ def plural_s(i, suffix='s'): return '' if i == 1 else suffix +def plural(i, word, suffix='s'): + return f'{i} {word}{plural_s(i, suffix)}' + + def parse_at_names(text) -> list: names = set() pattern = re.compile(r'@([\w-]+)') -- 2.34.1