#!/usr/bin/env python3 """Linkulator""" ## If this script contains bugs, blame cmccabe. import getpass import signal import subprocess import sys from time import time from urllib.parse import urlparse from datetime import datetime from shutil import which 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""" header = "\n{}\n\n{:>4s} {:<15s}{:<12s} #RESP {:<13s}".format( style_text(view_cat["name"].upper(), "bold"), "ID#", "DATE", "AUTHOR", "DESC" ) out = "" link_count = 0 thread_index = {} for line in link_data: if line[4] == view_cat["name"]: link_count += 1 thread_index[link_count] = line[0] parent_id = line[1] + "+" + str(line[2]) replies = [line for line in link_data if line[3] == parent_id] new_replies = len( [line for line in replies if line[2] >= config.USER.lastlogin] ) newmarker = "*" if new_replies or line[2] >= config.USER.lastlogin else "" _dt = datetime.fromtimestamp(float(line[2])).strftime("%Y-%m-%d") out += "{:4d} {:<15s}{:<12s} [{:3d}] {:s}{}\n".format( link_count, _dt, line[1], len(replies), line[6], newmarker ) 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""" # 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", "bold"), post_title)) print("{:<17}: {}".format(style_text("Link", "bold"), post_url)) print("{:<17}: {}".format(style_text("Category", "bold"), post_category)) print("{:<17}: {}".format(style_text("User", "bold"), post_username)) print("{:<17}: {}".format(style_text("Date", "bold"), post_datetime)) # post reply view if replies: print("\n{}:\n".format(style_text("Replies", "underline"))) for line in replies: comment_author = line[1] comment_date = datetime.fromtimestamp(float(line[2])).isoformat( sep=" ", timespec="minutes" ) comment = line[6] print( " {} {}: {}".format( comment_date, style_text(comment_author, "bold"), comment ) ) else: print("\nNo replies yet. Be the first!") # 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)) ## CONTROLS def search(): """Control for the search function""" while True: keyword = input("\nEnter your search (or leave empty to cancel):\n") 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( "\nEnter a post ID to see its thread, {} to start a new search, {} to go back, or {} to quit: \n".format( style_text("s", "underline"), style_text("m", "underline"), style_text("q", "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("\n{}\n".format(style_text("Invalid entry", "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 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, "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("?", "underline") ) ) try: url = get_input("URL") 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( "\nEnter a category ID, {} to post a link, {} to search, or {} to quit: ".format( style_text("p", "underline"), style_text("s", "underline"), style_text("q", "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", "underline"), style_text("s", "underline"), style_text("p", "underline"), style_text("q", "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", "bold"))) def menu_view_thread_details(post_id): """Displays thread details, handles related navigation menu""" option_text = "\nType {} to reply, {} to view in {}, {} to search, {} to post a new link, {} to go back, or {} to quit: ".format( style_text("r", "underline"), style_text("b", "underline"), config.USER.browser, style_text("s", "underline"), style_text("p", "underline"), style_text("m", "underline"), style_text("q", "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", "bold"))) ## GENERAL def style_text(text: str, *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 } out = "" for arg in args: if arg in styles: out += styles[arg] out += text out += "\033[0m" 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""" low_py_ver = 3.6 py_ver = str(sys.version_info[0]) + "." + str(sys.version_info[1]) py_ver = float(py_ver) if py_ver < low_py_ver: raise Exception( "Must be using Python " + str(low_py_ver) + " or higher. Instead you're using " + str(py_ver) ) 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()