#!/usr/bin/env python3 """Linkulator""" ## If this script contains bugs, blame cmccabe. import getpass import readline # pylint: disable=unused-import import signal import subprocess import sys import textwrap from time import time from urllib.parse import urlparse from datetime import datetime from shutil import which, get_terminal_size import data import config ## id (if parent), username, datestamp, parent-id, category, link-url, link-title LinkData = data.LinkData() link_data: list = LinkData.link_data categories: list = LinkData.categories def print_categories(): """Prints the list of categories with an indicator for new activity""" header = "\n{:>4s} New {:<25s}".format("ID#", "Category") out = "" for i, record in enumerate(categories): out += "{:4d} {} {} ({})\n".format( i + 1, "x" if record["last_updated"] >= config.USER.lastlogin else " ", record["name"], record["count"], ) if len(out) > 0: print(header) print(out) else: print("\n There are no posts yet - enter p to post a new link\n") def print_category_details(view_cat): """produces category detail data, prints it to the console. returns dict containing an index of threads""" columns, _ = get_terminal_size() maxnamelen = len(max(link_data, key=lambda x: len(x[1]))[1]) namelen = max(maxnamelen, 6) # minimum field width is 6 desclen = ( columns - 18 - namelen - 9 - 1 ) # length of available space for the description field. # The terminal width, minus the width of ID and Date fields and padding, # minus the max name length, minus Resp field and padding width, minus the # unread mark width header = "\n{}\n\n {:>3s} {:>10s} {:<{namelen}s} {:<5} {: 0): desc = textwrap.shorten(link["description"], width=desclen, placeholder="...") else: desc = link["description"] newmarker = ( "*" if link["last_modified_timestamp"] >= config.USER.lastlogin else "" ) _dt = datetime.fromtimestamp(float(link["link_timestamp"])).strftime("%Y-%m-%d") out += " {:3d} {:>10s} {:<{namelen}s} [{:3d}] {:s}{}\n".format( link_count, _dt, link["link_author"], link["reply_count"], desc, newmarker, namelen=namelen, ) if len(out) > 0: print(header) print("." * len(header)) print(out) else: print("\n\nThere are no posts for this category\n") return thread_index def print_thread_details(post_id) -> tuple: """produces thread detail data, prints it to the console""" # set up line wrapping columns, _ = get_terminal_size() line_wrapper = textwrap.TextWrapper( initial_indent=" " * 2, subsequent_indent=" " * 21, width=columns ) # get post data parent_id: str = "" post_url: str = "" for line in link_data: if line[0] == post_id: parent_id = "{}+{}".format(line[1], str(line[2])) post_username = line[1] post_datetime = datetime.fromtimestamp(float(line[2])).strftime("%c") post_category = line[4] post_url = line[5] post_title = line[6] # if post is not found, return empty string if parent_id == "": raise ValueError("Sorry, no thread found with that ID.") # get replies data replies = sorted( [line for line in link_data if line[3] == parent_id], key=lambda x: x[2] ) # post detail view print("\n\n{:<17}: {}".format(style_text("Title", False, "bold"), post_title)) print("{:<17}: {}".format(style_text("Link", False, "bold"), post_url)) print("{:<17}: {}".format(style_text("Category", False, "bold"), post_category)) print("{:<17}: {}".format(style_text("User", False, "bold"), post_username)) print("{:<17}: {}".format(style_text("Date", False, "bold"), post_datetime)) # post reply view if replies: print("\n{}:\n".format(style_text("Replies", False, "underline"))) for line in replies: comment_author = line[1] comment_date = datetime.fromtimestamp(float(line[2])).isoformat( sep=" ", timespec="minutes" ) comment = line[6] print( line_wrapper.fill( "{} {}: {}".format(comment_date, comment_author, comment) ) ) else: print("\nNo replies yet. Be the first!") print("") # return data used by menu control return parent_id, post_url def print_search_results(keyword: str, search_results: list): """a view for the search results - prints results to screen""" print( "\nShowing results for {}\n\n{:>4s} {:<15s}{:<12s}{:<13s}".format( keyword, "ID#", "DATE", "AUTHOR", "DESC" ) ) for display_index, record in enumerate(search_results, start=1): date = datetime.fromtimestamp(float(record[2])).strftime("%Y-%m-%d") author = record[1] desc = record[6] print("{:4d} {:<15s}{:<12s}{:<13s}".format(display_index, date, author, desc)) print("") ## CONTROLS def search(): """Control for the search function""" while True: print("") keyword = input("Enter your search (or leave empty to cancel): ") if keyword == "": print("Search cancelled\n") return search_results = LinkData.search(keyword) if not search_results: print("No results found\n") return while True: print_search_results(keyword, search_results) option = input( "Enter a post ID to see its thread, {} to start a new search, {} to go back, or {} to quit: ".format( style_text("s", False, "underline"), style_text("m", False, "underline"), style_text("q", False, "underline"), ) ).lower() if option == "q": graceful_exit() if option == "m": return if option == "s": break try: if 1 <= int(option) <= len(search_results): menu_view_thread_details(search_results[int(option) - 1][0]) else: raise IndexError("Invalid post ID") except (KeyError, ValueError, IndexError): # Catch a Post ID that is not in the thread list or is not a number print("{}".format(style_text("Invalid entry", False, "bold"))) def view_link_in_browser(url): """Attempts to view the specified URL in the configured browser""" if which(config.USER.browser) is None: print( "Sorry, " + config.USER.browser + " is not installed on your system. Ask your sysadmin to install it." ) return url_scheme = urlparse(url).scheme if url_scheme in ["gopher", "https", "http"]: subprocess.call([config.USER.browser, url]) else: print("Sorry, that url doesn't start with gopher://, http:// or https://") try_anyway = input( "Do you want to try it in {} anyway? Y/[N]".format(config.USER.browser) ).lower() if try_anyway == "y": subprocess.call([config.USER.browser, url]) def reply(parent_id): """Prompt for reply, validate input, save validated input to disk and update link_data. Calls view_thread when complete.""" while True: comment = input("Enter your comment (or leave empty to abort): ") if comment == "": input("Reply aborted. Hit [Enter] to continue.") break if not is_valid_input(comment): print( "Entries consisting of whitespace, or containing pipes, '|', are " "not valid.Please try again." ) else: record = data.LinkDataRecord( username=getpass.getuser(), timestamp=str(time()), parent_id=parent_id, link_title_or_comment=comment, ) LinkData.add(record) input("Reply added. Hit [Enter] to return to thread.") break def is_valid_input(entry: str) -> bool: """Determine validity of an input string >>> is_valid_input("valid") True >>> is_valid_input("Not|valid") False >>> is_valid_input(" ") False """ if "|" in entry: return False if entry.strip() == "": return False return True def is_correct_category(entry: str) -> bool: """Make sure the user purposefully created a new category and not by accident (mistyped, tried to use category number instead of name)""" if entry not in [record["name"] for record in categories]: question = "Do you want to create a new category '{}'? Y/[N]".format(entry) answer = input(question).lower().strip() return answer != "" and answer[0] == "y" return True def get_input(item: str) -> str: """Get user input with the specified prompt, validate and return it, or break if invalid""" while True: i: str = input("{}: ".format(style_text(item, True, "underline"))) if i == "": raise ValueError("Empty field") if i == "?": category_text = ( "No categories yet, feel free to make a new one!" if not categories else ", ".join(sorted(record["name"] for record in categories)) ) print( "Available categories: {}\n" "♻️ Please help keep Linkulator tidy".format(category_text) ) continue if is_valid_input(i): return i print( "Entries consisting of whitespace, or containing pipes, '|', are " "not valid.Please try again." ) def post_link() -> int: """Handles the link posting process""" print("\nPost a link by entering the details below.") print( "Enter {} for a list of categories. Enter an empty field to cancel.\n".format( style_text("?", False, "underline") ) ) try: url = get_input("URL") category = get_input("Category") while not is_correct_category(category): category = get_input("Category") title = get_input("Title") except ValueError: print("Post cancelled") return -1 record = data.LinkDataRecord( username=getpass.getuser(), timestamp=str(time()), category=category, link_URL=url, link_title_or_comment=title, ) return LinkData.add(record) def menu_view_categories(): """Displays list of categories, takes keyboard input and executes corresponding functions.""" while True: print_categories() option = input( "Enter a category ID, {} to post a link, {} to search, or {} to quit: ".format( style_text("p", True, "underline"), style_text("s", True, "underline"), style_text("q", True, "underline"), ) ).lower() if option == "q": return if option == "p": post_id = post_link() if post_id >= 0: menu_view_thread_details(post_id) continue if option == "s": search() continue try: cat_index = categories[int(option) - 1] menu_view_category_details(cat_index) except (IndexError, ValueError): print("Sorry, that category does not exist. Try again.") def menu_view_category_details(cat_index): """Displays category details, takes keyboard input and executes corresponding functions""" while True: thread_index = print_category_details(cat_index) option = input( "Enter a post ID to see its thread, {} to go back, {} to " "search, {} to post a link, or {} to quit: ".format( style_text("m", True, "underline"), style_text("s", True, "underline"), style_text("p", True, "underline"), style_text("q", True, "underline"), ) ).lower() if option == "q": graceful_exit() if option == "m": return if option == "s": search() continue if option == "p": post_id = post_link() if post_id >= 0: menu_view_thread_details(post_id) continue try: post_id = thread_index[int(option)] menu_view_thread_details(post_id) except (KeyError, ValueError): # Catch a Post ID that is not in the thread list or is not a number print( "{}\n\n".format(style_text("Invalid category ID/entry", False, "bold")) ) def menu_view_thread_details(post_id): """Displays thread details, handles related navigation menu""" option_text = "Type {} to reply, {} to view in {}, {} to search, {} to post a new link, {} to go back, or {} to quit: ".format( style_text("r", True, "underline"), style_text("b", True, "underline"), config.USER.browser, style_text("s", True, "underline"), style_text("p", True, "underline"), style_text("m", True, "underline"), style_text("q", True, "underline"), ) while True: parent_id, post_url = print_thread_details(post_id) option = input(option_text).lower() if option == "m": return if option == "b": view_link_in_browser(post_url) continue if option == "r": reply(parent_id) continue if option == "s": search() continue if option == "p": post_id = post_link() if post_id >= 0: continue break if option == "q": graceful_exit() print("{}\n\n".format(style_text("Invalid entry", False, "bold"))) ## GENERAL def style_text(text: str, is_input: bool, *args) -> str: """Style input strings as specified using terminal escape sequences. Returns a styled string""" styles = { "bold": "\033[1m", "dim": "\033[2m", "underline": "\033[4m", "blink": "\033[5m", # This is here if you REALLY need it...dont use it. # (ctrl+shift+esc+E to enable evil mode.) "inverse": "\033[7m", # Make fg and bg color swap "rl_left_fix": "\001" if is_input else "", "rl_right_fix": "\002" if is_input else "", } out = "" for arg in args: if arg in styles: out += styles["rl_left_fix"] out += styles[arg] out += styles["rl_right_fix"] out += text out += "{}\033[0m{}".format(styles["rl_left_fix"], styles["rl_right_fix"]) return out def print_banner(): """prints a banner""" print(" ----------") print(" LINKULATOR") print(" ----------") def graceful_exit(): """Prints a nice message, performs cleanup and then exits""" print("\n\nThank you for linkulating. Goodbye.\n") config.USER.save() sys.exit(0) def signal_handler(sig, frame): """handle signals, exiting on SIGINT""" graceful_exit() def main(): """main function - handles argument parsing and calls menu system""" signal.signal(signal.SIGINT, signal_handler) args = sys.argv[1:] config.init() if not args: print_banner() menu_view_categories() elif args[0] in ["-h", "--help", "help"]: print(HELP_TEXT) else: print("Unknown command: {}".format(args[0])) graceful_exit() if __name__ == "__main__": HELP_TEXT = """ options: -h or --help; or no option to browse links. Linkulator is a minimalist, commandline link aggregator for small, trusting shell communities. A few important points about Linkulator: * Your username is associated with everything you post. No real anonymity. * You may ignore other users by adding their username to your ~/.linkulator/ignore file, followed by an optional description of why you're ignoring them. * Link post categories are created dynamically when you submit a link. Think before you create a new category and don't litter the board. * No files are stored centrally. Each users' contributions are stored in ~/.linkulator/, meaning that you may always edit or delete your own files. Please don't use this ability to deceive others. * Link/reply threads disappear if the original link poster deletes their post; or if you put their username in your ignore file. * Your ~/.linkulator/linkulator.data file must be readable by others, or nobody else will see your contributions. * Linkulator may not work outside of Linux systems. """ main()