Bubble [main]

Feeds: Added Atom RSS feed output mode

=> 26d23ed0861ead92b35bb6faf9608d1b45117335

diff --git a/50_bubble.py b/50_bubble.py
index 43a44ff..fff7e33 100644
--- a/50_bubble.py
+++ b/50_bubble.py
@@ -87,6 +87,7 @@ Bubble is a Gemini bulletin board system with many influences from station.marti
 Bubble is open source:
 => gemini://git.skyjake.fi/bubble/main/  Bubble Git Repository
 """
+            self.EMPTY_FEED_PLACEHOLDER = "Emptiness is a boundless canvas, an unconstrained beginning: an opportunity for the courageous to create and the curious to explore."
 
             self.bubble = bubble
             self.path = bubble.path
@@ -287,6 +288,28 @@ Bubble is open source:
             src = f'=> {self.server_root()}{post.page_url()} {post.ymd_date()} {author}{vis_title}{sub}\n'
             return src
 
+        def atom_feed_entry(self, post, context=None):
+            vis_title = post.title
+            if len(vis_title) == 0:
+                vis_title = shorten_text(clean_title(post.summary), 100)
+            if self.is_context_tracker:
+                vis_title = f'[#{post.issueid if post.issueid else 0}] ' + vis_title
+            author = post.poster_name
+            category = f'\n' if context else ''
+            page_url = self.server_root() + urlparse.quote(post.page_url())
+
+            return f"""
+
+    {vis_title}
+    {author}{self.server_root()}/u/{author}
+    
+    {page_url}{category}
+    {atom_timestamp(post.ts_created)}
+    {atom_timestamp(post.ts_edited)}
+    {atom_escaped(gemtext_to_html(self.render_post(post)))}
+
+"""
+
         def tinylog_entry(self, post):
             src = f'## {post.ymd_hm_tz()}\n\n'
 
@@ -502,7 +525,7 @@ Posts and comments are composed of "segments". There can be any number of segmen
 * File Attachment — Link to a (small) file stored in the Bubble database.
 * Poll Option
 
-When viewing the post page, each of these segments are visible in their entirety. In various out places, a shortened "feed preview" is shown instead. The composer shows a preview of both the shortened version and the full page contents before you can publish the post.
+When viewing the post page, each of these segments are visible in their entirety. In various other places, a shortened "feed preview" is shown instead. The composer shows a preview of both the shortened version and the full page contents before you can publish the post.
 
 The formatting of the post is altered for Tinylogs: all headings are converted to level 3.
 
diff --git a/feeds.py b/feeds.py
index c8198f8..103a224 100644
--- a/feeds.py
+++ b/feeds.py
@@ -530,10 +530,24 @@ def make_feed_page(session):
     user = session.user
     user_follows = session.user_follows
     user_mutes = session.user_mutes
+    query_params = req.query.split('&') if req.query else []
     page = ''
 
-    is_gemini_feed = req.query == 'feed'
-    is_tinylog = req.query == 'tinylog'
+    # Determine format of feed.
+    is_atom_feed = False
+    is_gemini_feed = False
+    is_tinylog = False
+    is_bubble_feed = False
+
+    if 'feed' in query_params:
+        is_gemini_feed = True
+    elif 'atom' in query_params:
+        is_atom_feed = True
+    elif 'tinylog' in query_params:
+        is_tinylog = True
+    else:
+        is_bubble_feed = True
+
     sort_hotness = False
     page_size = 50 if is_gemini_feed else 100 if is_tinylog else 25
     page_index = 0
@@ -542,7 +556,10 @@ def make_feed_page(session):
         return 51, "Tinylogs are only for user feeds"
 
     # Page title.
-    if is_gemini_feed or is_tinylog:
+    if is_atom_feed:
+        # Just print the entries, and add the header/footer in the end.
+        ts_last_updated = 0
+    elif is_gemini_feed or is_tinylog:
         page += f'# {session.feed_title()}\n'
     elif c_user:
         page += f'# {c_user.avatar} {context.title()}\n'
@@ -553,7 +570,9 @@ def make_feed_page(session):
 
     # Subspace description.
     topinfo = ''
-    if not context:
+    if is_atom_feed:
+        pass
+    elif not context:
         topinfo += f"{session.bubble.site_info if session.user else session.bubble.site_info_nouser}\n"
     else:
         if c_user and (c_user.info or c_user.url):
@@ -572,14 +591,15 @@ def make_feed_page(session):
             if session.is_context_locked:
                 topinfo += '=> /help/locked 🔒 Locked\n'
 
-    page += topinfo if not is_tinylog else clean_tinylog(topinfo)
-    page += '\n'
+    if topinfo:
+        page += topinfo if not is_tinylog else clean_tinylog(topinfo)
+        page += '\n'
 
     filter_by_followed = user if session.feed_mode == 'followed' else None
     filter_issue_status = True if session.feed_mode == 'open' else \
                           False if session.feed_mode == 'closed' else None
 
-    if not is_gemini_feed and not is_tinylog:
+    if is_bubble_feed:
         num_total = db.count_posts(subspace=context,
                                    draft=False,
                                    filter_by_followed=filter_by_followed,
@@ -588,13 +608,12 @@ def make_feed_page(session):
                                    muted_by_user_id=(user.id if user else 0))
         num_pages = int((num_total + page_size - 1) / page_size)
 
-    # Navigation menu.
-    if not is_gemini_feed and not is_tinylog:
         # Filter status.
         filter_mode = ''
         if session.feed_tag_filter:
             filter_mode = f' [#{session.feed_tag_filter}]'
 
+        # Navigation menu.
         if not user:
             page += f'=> /s/ {session.bubble.site_icon} Subspaces\n'
             page += session.FOOTER_MENU
@@ -625,33 +644,35 @@ def make_feed_page(session):
                 if session.feed_mode in ('open', 'closed'):
                     page += f'=> ?open&closed Show all\n'
 
-        # The feed.
+    if is_bubble_feed:
+        # Sorting mode and current page.
         sort_hotness = (user and user.sort_post == User.SORT_POST_HOTNESS)
         if not is_empty_query(req):
-            for param in req.query.split('&'):
+            for param in query_params:
                 if param == 'sort=hot':
                     sort_hotness = True
                 elif param == 'sort=new':
                     sort_hotness = False
                 elif re.match(r'p\d+', param):
                     page_index = int(param[1:]) - 1
-
         sort_mode = ' 🔥' if sort_hotness else ''
-        if session.feed_mode == 'all':
-            if is_issue_tracker:
-                page_title = 'Issues'
-            else:
-                page_title = 'All Posts' if not context else 'Posts'
-        elif session.feed_mode in ('open', 'closed'):
-            page_title = 'Open Issues' if session.feed_mode == 'open' else 'Closed Issues'
+
+    # Page title.
+    if session.feed_mode == 'all':
+        if is_issue_tracker:
+            page_title = 'Issues'
         else:
-            page_title = 'Followed'
+            page_title = 'All Posts' if not context else 'Posts'
+    elif session.feed_mode in ('open', 'closed'):
+        page_title = 'Open Issues' if session.feed_mode == 'open' else 'Closed Issues'
+    else:
+        page_title = 'Followed'
 
+    if is_bubble_feed:
         if is_issue_tracker:
             title_count = f'{num_total} '
         else:
             title_count = ''
-
         page += f'\n## {title_count}{page_title}{sort_mode}{filter_mode}\n\n'
 
     elif is_tinylog:
@@ -678,16 +699,20 @@ def make_feed_page(session):
             page += "There are no issues.\n\n"
         else:
             page += "There are no posts.\n\n\n" + \
-                "> Emptiness is a boundless canvas, an unconstrained beginning: " + \
-                "an opportunity for the courageous to create and the curious to explore.\n\n\n"
+                f"> {session.EMPTY_FEED_PLACEHOLDER}\n\n\n"
     elif is_gemini_feed:
         for post in posts:
             page += session.gemini_feed_entry(post, context)
+    elif is_atom_feed:
+        for post in posts:
+            page += session.atom_feed_entry(post, context)
+            ts_last_updated = max(ts_last_updated, post.ts_created)
     elif is_tinylog:
         for post in posts:
             page += session.tinylog_entry(post) + '\n'
     else:
         pager_feed_mode = f'&{session.feed_mode}' if session.feed_mode != 'all' else ''
+
         def page_range(n):
             return f'{n + 1} / {num_pages}'
 
@@ -704,7 +729,21 @@ def make_feed_page(session):
             page += f'Page {page_index + 1} of {num_pages}\n\n'
 
     # Footer.
-    if not is_tinylog:
+    if is_atom_feed:
+        origin_url = f"{session.server_root()}{context.title() if context else ''}"
+        atom_header = f"""
+
+{session.feed_title()}
+
+
+{session.req.url()}/
+{atom_timestamp(ts_last_updated if ts_last_updated else time.time())}
+Bubble
+"""
+        atom_footer = ""
+        return 20, 'application/atom+xml', atom_header + page + atom_footer
+
+    elif not is_tinylog:
         if not is_gemini_feed:
             page += "## Options\n"
             if sort_hotness:
diff --git a/utils.py b/utils.py
index 2669dff..0ad70c3 100644
--- a/utils.py
+++ b/utils.py
@@ -207,6 +207,95 @@ def ago_text(ts, suffix='ago', now='Now', tz=None):
     return time_delta_text(sec, ts, suffix, now, tz=tz)
 
 
+def atom_timestamp(ts):
+    return datetime.datetime.fromtimestamp(ts, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def atom_escaped(text):
+    return text.replace('&', '&').replace('<', '<').replace('>', '>').\
+        replace("'", ''').replace('"', '"')
+
+
+def gemtext_to_html(src):
+    out = []
+
+    in_list = False
+    in_quote = False
+    in_pre = False
+
+    for line in src.split('\n'):
+        rend = None
+        is_bullet = False
+        is_angle = False
+        if in_pre:
+            if line.startswith('```'):
+                in_pre = False
+                rend = '
' + else: + rend = atom_escaped(line) + else: + if line.startswith('###'): + rend = f'

{atom_escaped(line[3:].strip())}

' + elif line.startswith('##'): + rend = f'

{atom_escaped(line[2:].strip())}

' + elif line.startswith('#'): + rend = f'

{atom_escaped(line[1:].strip())}

' + elif line.startswith('>'): + is_angle = True + #if not in_quote: + # in_quote = True + # out.append('
') + rend = f'{atom_escaped(line[1:])}' + elif line.startswith('*'): + is_bullet = True + #if not in_list: + # in_list = True + # out.append('') + in_list = False + if not is_angle and in_quote: + out.append('
') + in_quote = False + if is_angle and not in_quote: + out.append('
') + in_quote = True + if is_bullet and not in_list: + out.append('