__filename__ = "webapp_column_right.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.3.0" __maintainer__ = "Bob Mottram" __email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface Columns" import os from datetime import datetime from content import remove_long_words from content import limit_repeated_words from utils import get_fav_filename_from_url from utils import get_base_content_from_post from utils import remove_html from utils import locate_post from utils import load_json from utils import votes_on_newswire_item from utils import get_nickname_from_actor from utils import is_editor from utils import get_config_param from utils import remove_domain_port from utils import acct_dir from posts import is_moderator from newswire import get_newswire_favicon_url from webapp_utils import get_right_image_file from webapp_utils import html_header_with_external_style from webapp_utils import html_footer from webapp_utils import get_banner_file from webapp_utils import html_post_separator from webapp_utils import header_buttons_front_screen from webapp_utils import edit_text_field def _votes_indicator(total_votes: int, positive_voting: bool) -> str: """Returns an indicator of the number of votes on a newswire item """ if total_votes <= 0: return '' total_votes_str = ' ' for _ in range(total_votes): if positive_voting: total_votes_str += '✓' else: total_votes_str += '✗' return total_votes_str def get_right_column_content(base_dir: str, nickname: str, domain_full: str, http_prefix: str, translate: {}, moderator: bool, editor: bool, newswire: {}, positive_voting: bool, show_back_button: bool, timeline_path: str, show_publish_button: bool, show_publish_as_icon: bool, rss_icon_at_top: bool, publish_button_at_top: bool, authorized: bool, show_header_image: bool, theme: str, default_timeline: str, access_keys: {}) -> str: """Returns html content for the right column """ html_str = '' domain = remove_domain_port(domain_full) if authorized: # only show the publish button if logged in, otherwise replace it with # a login button title_str = translate['Publish a blog article'] if default_timeline == 'tlfeatures': title_str = translate['Publish a news article'] publish_button_str = \ ' <a href="' + \ '/users/' + nickname + '/newblog?nodropdown" ' + \ 'title="' + title_str + '" ' + \ 'accesskey="' + access_keys['menuNewBlog'] + '">' + \ '<button class="publishbtn" tabindex="4">' + \ translate['Publish'] + '</button></a>\n' else: # if not logged in then replace the publish button with # a login button publish_button_str = \ ' <a href="/login">' + \ '<button class="publishbtn" tabindex="4">' + \ translate['Login'] + '</button></a>\n' # show publish button at the top if needed if publish_button_at_top: html_str += '<center>' + publish_button_str + '</center>' # show a column header image, eg. title of the theme or newswire banner edit_image_class = '' if show_header_image: right_image_file, right_column_image_filename = \ get_right_image_file(base_dir, nickname, domain, theme) # show the image at the top of the column edit_image_class = 'rightColEdit' if os.path.isfile(right_column_image_filename): edit_image_class = 'rightColEditImage' html_str += \ '\n <center>\n' + \ ' <img class="rightColImg" ' + \ 'alt="" loading="lazy" decoding="async" src="/users/' + \ nickname + '/' + right_image_file + '" />\n' + \ ' </center>\n' if show_publish_button or editor or rss_icon_at_top: if not show_header_image: html_str += '<div class="columnIcons">' if edit_image_class == 'rightColEdit': html_str += '\n <center>\n' # whether to show a back icon # This is probably going to be osolete soon if show_back_button: html_str += \ ' <a href="' + timeline_path + '">' + \ '<button class="cancelbtn">' + \ translate['Go Back'] + '</button></a>\n' if show_publish_button and not publish_button_at_top: if not show_publish_as_icon: html_str += publish_button_str # show the edit icon if editor: if os.path.isfile(base_dir + '/accounts/newswiremoderation.txt'): # show the edit icon highlighted html_str += \ ' <a href="' + \ '/users/' + nickname + '/editnewswire" ' + \ 'accesskey="' + access_keys['menuEdit'] + \ '" tabindex="4" class="imageAnchor">' + \ '<img class="' + edit_image_class + \ '" loading="lazy" decoding="async" alt="' + \ translate['Edit newswire'] + ' | " title="' + \ translate['Edit newswire'] + '" src="/' + \ 'icons/edit_notify.png" /></a>\n' else: # show the edit icon html_str += \ ' <a href="' + \ '/users/' + nickname + '/editnewswire" ' + \ 'accesskey="' + access_keys['menuEdit'] + \ '" tabindex="4" class="imageAnchor">' + \ '<img class="' + edit_image_class + \ '" loading="lazy" decoding="async" alt="' + \ translate['Edit newswire'] + ' | " title="' + \ translate['Edit newswire'] + '" src="/' + \ 'icons/edit.png" /></a>\n' # show the RSS icons rss_icon_str = \ ' <a href="/categories.xml" tabindex="4" ' + \ 'class="imageAnchor">' + \ '<img class="' + edit_image_class + \ '" loading="lazy" decoding="async" alt="' + \ translate['Hashtag Categories RSS Feed'] + ' | " title="' + \ translate['Hashtag Categories RSS Feed'] + '" src="/' + \ 'icons/categoriesrss.png" /></a>\n' rss_icon_str += \ ' <a href="/newswire.xml" tabindex="4" class="imageAnchor">' + \ '<img class="' + edit_image_class + \ '" loading="lazy" decoding="async" alt="' + \ translate['Newswire RSS Feed'] + ' | " title="' + \ translate['Newswire RSS Feed'] + '" src="/' + \ 'icons/logorss.png" /></a>\n' if rss_icon_at_top: html_str += rss_icon_str # show publish icon at top if show_publish_button: if show_publish_as_icon: title_str = translate['Publish a blog article'] if default_timeline == 'tlfeatures': title_str = translate['Publish a news article'] html_str += \ ' <a href="' + \ '/users/' + nickname + '/newblog?nodropdown" ' + \ 'accesskey="' + access_keys['menuNewBlog'] + \ '" class="imageAnchor" tabindex="4">' + \ '<img class="' + edit_image_class + \ '" loading="lazy" decoding="async" alt="' + \ title_str + '" title="' + \ title_str + '" src="/' + \ 'icons/publish.png" /></a>\n' if edit_image_class == 'rightColEdit': html_str += ' </center>\n' else: if show_header_image: html_str += ' <br>\n' if show_publish_button or editor or rss_icon_at_top: if not show_header_image: html_str += '</div><br>' # show the newswire lines newswire_content_str = \ _html_newswire(base_dir, newswire, nickname, moderator, translate, positive_voting) html_str += newswire_content_str # show the rss icon at the bottom, typically on the right hand side if newswire_content_str and not rss_icon_at_top: html_str += '<br><div class="columnIcons">' + rss_icon_str + '</div>' return html_str def _get_broken_fav_substitute() -> str: """Substitute link used if a favicon is not available """ return " onerror=\"this.onerror=null; this.src='/newswire_favicon.ico'\"" def _html_newswire(base_dir: str, newswire: {}, nickname: str, moderator: bool, translate: {}, positive_voting: bool) -> str: """Converts a newswire dict into html """ separator_str = html_post_separator(base_dir, 'right') html_str = '' for date_str, item in newswire.items(): item[0] = remove_html(item[0]).strip() if not item[0]: continue # remove any CDATA if 'CDATA[' in item[0]: item[0] = item[0].split('CDATA[')[1] if ']' in item[0]: item[0] = item[0].split(']')[0] try: published_date = \ datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S%z") except BaseException: print('EX: _html_newswire bad date format ' + date_str) continue date_shown = published_date.strftime("%Y-%m-%d %H:%M") date_str_link = date_str.replace('T', ' ') date_str_link = date_str_link.replace('Z', '') url = item[1] favicon_url = get_newswire_favicon_url(url) favicon_link = '' if favicon_url: cached_favicon_filename = \ get_fav_filename_from_url(base_dir, favicon_url) if os.path.isfile(cached_favicon_filename): favicon_url = \ cached_favicon_filename.replace(base_dir, '') else: extensions = \ ('png', 'jpg', 'gif', 'avif', 'heic', 'svg', 'webp', 'jxl') for ext in extensions: cached_favicon_filename = \ get_fav_filename_from_url(base_dir, favicon_url) cached_favicon_filename = \ cached_favicon_filename.replace('.ico', '.' + ext) if os.path.isfile(cached_favicon_filename): favicon_url = \ cached_favicon_filename.replace(base_dir, '') favicon_link = \ '<img loading="lazy" decoding="async" ' + \ 'src="' + favicon_url + '" ' + \ 'alt="" ' + _get_broken_fav_substitute() + '/>' moderated_item = item[5] link_url = url # is this a podcast episode? if len(item) > 8: # change the link url to a podcast episode screen podcast_properties = item[8] if podcast_properties: if podcast_properties.get('image'): episode_id = date_str.replace(' ', '__') episode_id = episode_id.replace(':', 'aa') link_url = \ '/users/' + nickname + '/?podepisode=' + episode_id html_str += separator_str if moderated_item and 'vote:' + nickname in item[2]: total_votes_str = '' total_votes = 0 if moderator: total_votes = votes_on_newswire_item(item[2]) total_votes_str = \ _votes_indicator(total_votes, positive_voting) title = remove_long_words(item[0], 16, []).replace('\n', '<br>') title = limit_repeated_words(title, 6) html_str += '<p class="newswireItemVotedOn">' + \ '<a href="' + link_url + '" target="_blank" ' + \ 'rel="nofollow noopener noreferrer">' + \ '<span class="newswireItemVotedOn">' + \ favicon_link + title + '</span></a>' + total_votes_str if moderator: html_str += \ ' ' + date_shown + '<a href="/users/' + nickname + \ '/newswireunvote=' + date_str_link + '" ' + \ 'title="' + translate['Remove Vote'] + \ '" class="imageAnchor">' html_str += '<img loading="lazy" decoding="async" ' + \ 'class="voteicon" src="/' + \ 'alt="' + translate['Remove Vote'] + '" ' + \ 'icons/vote.png" /></a></p>\n' else: html_str += ' <span class="newswireDateVotedOn">' html_str += date_shown + '</span></p>\n' else: total_votes_str = '' total_votes = 0 if moderator: if moderated_item: total_votes = votes_on_newswire_item(item[2]) # show a number of ticks or crosses for how many # votes for or against total_votes_str = \ _votes_indicator(total_votes, positive_voting) title = remove_long_words(item[0], 16, []).replace('\n', '<br>') title = limit_repeated_words(title, 6) if moderator and moderated_item: html_str += '<p class="newswireItemModerated">' + \ '<a href="' + link_url + '" target="_blank" ' + \ 'rel="nofollow noopener noreferrer">' + \ favicon_link + title + '</a>' + total_votes_str html_str += ' ' + date_shown html_str += '<a href="/users/' + nickname + \ '/newswirevote=' + date_str_link + '" ' + \ 'title="' + translate['Vote'] + '" class="imageAnchor">' html_str += '<img class="voteicon" ' + \ 'alt="' + translate['Vote'] + '" ' + \ 'src="/icons/vote.png" /></a>' html_str += '</p>\n' else: html_str += '<p class="newswireItem">' + \ '<a href="' + link_url + '" target="_blank" ' + \ 'rel="nofollow noopener noreferrer">' + \ favicon_link + title + '</a>' + total_votes_str html_str += ' <span class="newswireDate">' html_str += date_shown + '</span></p>\n' if html_str: html_str = \ '<nav itemscope itemtype="http://schema.org/webFeed">\n' + \ html_str + '</nav>\n' return html_str def html_citations(base_dir: str, nickname: str, domain: str, http_prefix: str, default_timeline: str, translate: {}, newswire: {}, blog_title: str, blog_content: str, blog_image_filename: str, blog_image_attachment_media_type: str, blog_image_description: str, theme: str) -> str: """Show the citations screen when creating a blog """ html_str = '' # create a list of dates for citations # these can then be used to re-select checkboxes later citations_filename = \ acct_dir(base_dir, nickname, domain) + '/.citations.txt' citations_selected = [] if os.path.isfile(citations_filename): citations_separator = '#####' with open(citations_filename, 'r', encoding='utf-8') as fp_cit: citations = fp_cit.readlines() for line in citations: if citations_separator not in line: continue sections = line.strip().split(citations_separator) if len(sections) != 3: continue date_str = sections[0] citations_selected.append(date_str) # the css filename css_filename = base_dir + '/epicyon-profile.css' if os.path.isfile(base_dir + '/epicyon.css'): css_filename = base_dir + '/epicyon.css' instance_title = \ get_config_param(base_dir, 'instanceTitle') html_str = \ html_header_with_external_style(css_filename, instance_title, None) # top banner banner_file, _ = \ get_banner_file(base_dir, nickname, domain, theme) html_str += \ '<a href="/users/' + nickname + '/newblog" title="' + \ translate['Go Back'] + '" alt="' + \ translate['Go Back'] + '" class="imageAnchor">\n' html_str += '<img loading="lazy" decoding="async" ' + \ 'class="timeline-banner" alt="" src="' + \ '/users/' + nickname + '/' + banner_file + '" /></a>\n' html_str += \ '<form enctype="multipart/form-data" method="POST" ' + \ 'accept-charset="UTF-8" action="/users/' + nickname + \ '/citationsdata">\n' html_str += ' <center>\n' html_str += translate['Choose newswire items ' + 'referenced in your article'] + '<br>' if blog_title is None: blog_title = '' html_str += \ ' <input type="hidden" name="blogTitle" value="' + \ blog_title + '">\n' if blog_content is None: blog_content = '' html_str += \ ' <input type="hidden" name="blogContent" value="' + \ blog_content + '">\n' # submit button html_str += \ ' <input type="submit" name="submitCitations" value="' + \ translate['Publish'] + '">\n' html_str += ' </center>\n' citations_separator = '#####' # list of newswire items if newswire: ctr = 0 for date_str, item in newswire.items(): item[0] = remove_html(item[0]).strip() if not item[0]: continue # remove any CDATA if 'CDATA[' in item[0]: item[0] = item[0].split('CDATA[')[1] if ']' in item[0]: item[0] = item[0].split(']')[0] # should this checkbox be selected? selected_str = '' if date_str in citations_selected: selected_str = ' checked' published_date = \ datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S%z") date_shown = published_date.strftime("%Y-%m-%d %H:%M") title = remove_long_words(item[0], 16, []).replace('\n', '<br>') title = limit_repeated_words(title, 6) link = item[1] citation_value = \ date_str + citations_separator + \ title + citations_separator + \ link html_str += \ '<input type="checkbox" name="newswire' + str(ctr) + \ '" value="' + citation_value + '"' + selected_str + '/>' + \ '<a href="' + link + '"><cite>' + title + '</cite></a> ' html_str += '<span class="newswireDate">' + \ date_shown + '</span><br>\n' ctr += 1 html_str += '</form>\n' return html_str + html_footer() def html_newswire_mobile(base_dir: str, nickname: str, domain: str, domain_full: str, http_prefix: str, translate: {}, newswire: {}, positive_voting: bool, timeline_path: str, show_publish_as_icon: bool, authorized: bool, rss_icon_at_top: bool, icons_as_buttons: bool, default_timeline: str, theme: str, access_keys: {}) -> str: """Shows the mobile version of the newswire right column """ html_str = '' # the css filename css_filename = base_dir + '/epicyon-profile.css' if os.path.isfile(base_dir + '/epicyon.css'): css_filename = base_dir + '/epicyon.css' if nickname == 'news': editor = False moderator = False else: # is the user a moderator? moderator = is_moderator(base_dir, nickname) # is the user a site editor? editor = is_editor(base_dir, nickname) show_publish_button = editor instance_title = \ get_config_param(base_dir, 'instanceTitle') html_str = \ html_header_with_external_style(css_filename, instance_title, None) banner_file, _ = \ get_banner_file(base_dir, nickname, domain, theme) html_str += \ '<a href="/users/' + nickname + '/' + default_timeline + '" ' + \ 'accesskey="' + access_keys['menuTimeline'] + '" ' + \ 'class="imageAnchor">' + \ '<img loading="lazy" decoding="async" class="timeline-banner" ' + \ 'alt="' + translate['Timeline banner image'] + '" ' + \ 'src="/users/' + nickname + '/' + banner_file + '" /></a>\n' html_str += '<div class="col-right-mobile">\n' html_str += '<center>' + \ header_buttons_front_screen(translate, nickname, 'newswire', authorized, icons_as_buttons) + '</center>' html_str += \ get_right_column_content(base_dir, nickname, domain_full, http_prefix, translate, moderator, editor, newswire, positive_voting, False, timeline_path, show_publish_button, show_publish_as_icon, rss_icon_at_top, False, authorized, False, theme, default_timeline, access_keys) if editor and not newswire: html_str += '<br><br><br>\n' html_str += '<center>\n ' html_str += translate['Select the edit icon to add RSS feeds'] html_str += '\n</center>\n' # end of col-right-mobile html_str += '</div\n>' html_str += html_footer() return html_str def html_edit_newswire(translate: {}, base_dir: str, path: str, domain: str, port: int, http_prefix: str, default_timeline: str, theme: str, access_keys: {}, dogwhistles: {}) -> str: """Shows the edit newswire screen """ if '/users/' not in path: return '' path = path.replace('/inbox', '').replace('/outbox', '') path = path.replace('/shares', '').replace('/wanted', '') nickname = get_nickname_from_actor(path) if not nickname: return '' # is the user a moderator? if not is_moderator(base_dir, nickname): return '' css_filename = base_dir + '/epicyon-links.css' if os.path.isfile(base_dir + '/links.css'): css_filename = base_dir + '/links.css' # filename of the banner shown at the top banner_file, _ = \ get_banner_file(base_dir, nickname, domain, theme) instance_title = \ get_config_param(base_dir, 'instanceTitle') edit_newswire_form = \ html_header_with_external_style(css_filename, instance_title, None) # top banner edit_newswire_form += \ '<header>' + \ '<a href="/users/' + nickname + '/' + default_timeline + \ '" title="' + \ translate['Switch to timeline view'] + '" alt="' + \ translate['Switch to timeline view'] + '" ' + \ 'accesskey="' + access_keys['menuTimeline'] + '">\n' edit_newswire_form += \ '<img loading="lazy" decoding="async" ' + \ 'class="timeline-banner" src="' + \ '/users/' + nickname + '/' + banner_file + '" ' + \ 'alt="" /></a>\n</header>' edit_newswire_form += \ '<form enctype="multipart/form-data" method="POST" ' + \ 'accept-charset="UTF-8" action="' + path + '/newswiredata">\n' edit_newswire_form += \ ' <div class="vertical-center">\n' edit_newswire_form += \ ' <h1>' + translate['Edit newswire'] + '</h1>' edit_newswire_form += \ ' <div class="containerSubmitNewPost">\n' edit_newswire_form += \ ' <input type="submit" name="submitNewswire" value="' + \ translate['Publish'] + '" ' + \ 'accesskey="' + access_keys['submitButton'] + '">\n' edit_newswire_form += \ ' </div>\n' newswire_filename = base_dir + '/accounts/newswire.txt' newswire_str = '' if os.path.isfile(newswire_filename): with open(newswire_filename, 'r', encoding='utf-8') as fp_news: newswire_str = fp_news.read() edit_newswire_form += \ '<div class="container">' edit_newswire_form += \ ' ' + \ translate['Add RSS feed links below.'] + \ '<br>' new_feed_str = translate['New feed URL'] edit_newswire_form += \ edit_text_field(None, 'newNewswireFeed', '', new_feed_str) edit_newswire_form += \ ' <textarea id="message" name="editedNewswire" ' + \ 'style="height:80vh" spellcheck="false">' + \ newswire_str + '</textarea>' filter_str = '' filter_filename = \ base_dir + '/accounts/news@' + domain + '/filters.txt' if os.path.isfile(filter_filename): with open(filter_filename, 'r', encoding='utf-8') as filterfile: filter_str = filterfile.read() edit_newswire_form += \ ' <br><b><label class="labels">' + \ translate['Filtered words'] + '</label></b>\n' edit_newswire_form += ' <br><label class="labels">' + \ translate['One per line'] + '</label>' edit_newswire_form += ' <textarea id="message" ' + \ 'name="filteredWordsNewswire" style="height:50vh" ' + \ 'spellcheck="true">' + filter_str + '</textarea>\n' dogwhistle_str = '' for whistle, category in dogwhistles.items(): if not category: continue dogwhistle_str += whistle + ' -> ' + category + '\n' edit_newswire_form += \ ' <br><b><label class="labels">' + \ translate['Dogwhistle words'] + '</label></b>\n' edit_newswire_form += ' <br><label class="labels">' + \ translate['Content warnings will be added for the following'] + \ ':</label>' edit_newswire_form += ' <textarea id="message" ' + \ 'name="dogwhistleWords" style="height:50vh" ' + \ 'spellcheck="true">' + dogwhistle_str + '</textarea>\n' hashtag_rules_str = '' hashtag_rules_filename = \ base_dir + '/accounts/hashtagrules.txt' if os.path.isfile(hashtag_rules_filename): with open(hashtag_rules_filename, 'r', encoding='utf-8') as rulesfile: hashtag_rules_str = rulesfile.read() edit_newswire_form += \ ' <br><b><label class="labels">' + \ translate['News tagging rules'] + '</label></b>\n' edit_newswire_form += ' <br><label class="labels">' + \ translate['One per line'] + '.</label>\n' edit_newswire_form += \ ' <a href="' + \ 'https://gitlab.com/bashrc2/epicyon/-/raw/main/hashtagrules.txt' + \ '">' + translate['See instructions'] + '</a>\n' edit_newswire_form += ' <textarea id="message" ' + \ 'name="hashtagRulesList" style="height:80vh" spellcheck="false">' + \ hashtag_rules_str + '</textarea>\n' edit_newswire_form += \ '</div>' edit_newswire_form += html_footer() return edit_newswire_form def html_edit_news_post(translate: {}, base_dir: str, path: str, domain: str, port: int, http_prefix: str, postUrl: str, system_language: str) -> str: """Edits a news post on the news/features timeline """ if '/users/' not in path: return '' path_original = path nickname = get_nickname_from_actor(path) if not nickname: return '' # is the user an editor? if not is_editor(base_dir, nickname): return '' postUrl = postUrl.replace('/', '#') post_filename = locate_post(base_dir, nickname, domain, postUrl) if not post_filename: return '' post_json_object = load_json(post_filename) if not post_json_object: return '' css_filename = base_dir + '/epicyon-links.css' if os.path.isfile(base_dir + '/links.css'): css_filename = base_dir + '/links.css' instance_title = \ get_config_param(base_dir, 'instanceTitle') edit_news_post_form = \ html_header_with_external_style(css_filename, instance_title, None) edit_news_post_form += \ '<form enctype="multipart/form-data" method="POST" ' + \ 'accept-charset="UTF-8" action="' + path + '/newseditdata">\n' edit_news_post_form += \ ' <div class="vertical-center">\n' edit_news_post_form += \ ' <h1>' + translate['Edit News Post'] + '</h1>' edit_news_post_form += \ ' <div class="container">\n' edit_news_post_form += \ ' <a href="' + path_original + '/tlnews">' + \ '<button class="cancelbtn">' + translate['Go Back'] + '</button></a>\n' edit_news_post_form += \ ' <input type="submit" name="submitEditedNewsPost" value="' + \ translate['Publish'] + '">\n' edit_news_post_form += \ ' </div>\n' edit_news_post_form += \ '<div class="container">' edit_news_post_form += \ ' <input type="hidden" name="newsPostUrl" value="' + \ postUrl + '">\n' news_post_title = post_json_object['object']['summary'] edit_news_post_form += \ ' <input type="text" name="newsPostTitle" value="' + \ news_post_title + '"><br>\n' news_post_content = get_base_content_from_post(post_json_object, system_language) edit_news_post_form += \ ' <textarea id="message" name="editedNewsPost" ' + \ 'style="height:600px" spellcheck="true">' + \ news_post_content + '</textarea>' edit_news_post_form += \ '</div>' edit_news_post_form += html_footer() return edit_news_post_form