From e66d7936464081e5419e12fe01543ea197d94e10 Mon Sep 17 00:00:00 2001 From: asdf Date: Tue, 3 Aug 2021 18:25:52 +1000 Subject: [PATCH] initial work on pager - major changes to menu functionality --- data.py | 28 +++- linkulator.py | 368 ++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 311 insertions(+), 85 deletions(-) diff --git a/data.py b/data.py index 4e95dd9..72ef931 100644 --- a/data.py +++ b/data.py @@ -6,6 +6,7 @@ from pathlib import PurePath from glob import glob import re import os +from datetime import datetime import config @@ -239,7 +240,9 @@ class LinkData: return sorted(search_results, key=lambda x: x[0], reverse=True) def list_category_details(self, selected_category: str) -> list: - """returns a sorted list of posts belonging to the specified category""" + """accepts a category name. returns a sorted list of posts belonging to + the specified category""" + links = [] for record in self.link_data: @@ -278,5 +281,26 @@ class LinkData: "last_modified_timestamp": last_modified_timestamp, } ) - return sorted(links, key=lambda x: x["last_modified_timestamp"], reverse=True) + + def get_thread_details(self, selected_thread) -> dict: + output = {} + for line in self.link_data: + if line[0] == selected_thread: + output["parent_id"] = "{}+{}".format(line[1], str(line[2])) + output["author"] = line[1] + output["date"] = datetime.fromtimestamp(float(line[2])).strftime("%c") + output["category"] = line[4] + output["url"] = line[5] + output["title"] = line[6] + break + + if not output["parent_id"]: + raise ValueError("Sorry, no thread found with that ID.") + + output["replies"] = sorted( + [line for line in self.link_data if line[3] == output["parent_id"]], + key=lambda x: x[2], + ) + + return output diff --git a/linkulator.py b/linkulator.py index a7b81d2..2f8863a 100755 --- a/linkulator.py +++ b/linkulator.py @@ -4,7 +4,7 @@ ## If this script contains bugs, blame cmccabe. import getpass -import readline # pylint: disable=unused-import +import readline import signal import subprocess import sys @@ -13,6 +13,7 @@ from time import time from urllib.parse import urlparse from datetime import datetime from shutil import which, get_terminal_size +from math import ceil import data import config @@ -21,15 +22,20 @@ 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 +# categories: list = LinkData.categories -def print_categories(): +def print_page_count(pages): + if pages.count > 1: + print("Page {} of {}".format(pages.current, pages.count)) + + +def print_categories(categories, pages): """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): + + for i, record in enumerate(categories[pages.current_slice]): out += "{:4d} {} {} ({})\n".format( i + 1, "x" if record["last_updated"] >= config.USER.lastlogin else " ", @@ -37,16 +43,20 @@ def print_categories(): record["count"], ) + print("\033c", end="") + print_banner() if len(out) > 0: print(header) print(out) + print_page_count(pages) else: print("\n There are no posts yet - enter p to post a new link\n") -def print_category_details(view_cat): +def print_category_details(category_details, pages): """produces category detail data, prints it to the console. returns dict containing an index of threads""" + view_cat = "category name" 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 @@ -58,7 +68,7 @@ def print_category_details(view_cat): # unread mark width header = "\n{}\n\n {:>3s} {:>10s} {:<{namelen}s} {:<5} {:= 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, + i + 1, _dt, link["link_author"], link["reply_count"], @@ -89,16 +94,17 @@ def print_category_details(view_cat): namelen=namelen, ) + print("\033c", end="") if len(out) > 0: print(header) print("." * len(header)) print(out) + print_page_count(pages) else: print("\n\nThere are no posts for this category\n") - return thread_index -def print_thread_details(post_id) -> tuple: +def print_thread_details(thread_details, pages) -> tuple: """produces thread detail data, prints it to the console""" # set up line wrapping columns, _ = get_terminal_size() @@ -106,38 +112,30 @@ def print_thread_details(post_id) -> tuple: 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("\033c", end="") + print( + "\n\n{:<17}: {}".format( + style_text("Title", False, "bold"), thread_details["title"] + ) + ) + print("{:<17}: {}".format(style_text("Link", False, "bold"), thread_details["url"])) + print( + "{:<17}: {}".format( + style_text("Category", False, "bold"), thread_details["category"] + ) + ) + print( + "{:<17}: {}".format(style_text("User", False, "bold"), thread_details["author"]) + ) + print( + "{:<17}: {}".format(style_text("Date", False, "bold"), thread_details["date"]) ) - # 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: + if thread_details["replies"]: print("\n{}:\n".format(style_text("Replies", False, "underline"))) - for line in replies: + for line in thread_details["replies"]: comment_author = line[1] comment_date = datetime.fromtimestamp(float(line[2])).isoformat( sep=" ", timespec="minutes" @@ -152,12 +150,11 @@ def print_thread_details(post_id) -> tuple: print("\nNo replies yet. Be the first!") print("") - - # return data used by menu control - return parent_id, post_url + print_page_count(pages) def print_search_results(keyword: str, search_results: list): + print("\033c", end="") """a view for the search results - prints results to screen""" print( "\nShowing results for {}\n\n{:>4s} {:<15s}{:<12s}{:<13s}".format( @@ -213,7 +210,7 @@ def search(): print("{}".format(style_text("Invalid entry", False, "bold"))) -def view_link_in_browser(url): +def open_link_in_browser(url): """Attempts to view the specified URL in the configured browser""" if which(config.USER.browser) is None: print( @@ -343,53 +340,259 @@ def post_link() -> int: return LinkData.add(record) +class Pages: + def __init__(self): + self.current: int = 1 # the current page + self.count: int = 0 # the total number of pages + self.length: int = None # the number of lines on a page + self.current_slice: slice = None # a slice to display the current page data + + def calculate_pages(self, content_len: int, boilerplate_len: int): + _, lines = get_terminal_size() + self.length = lines - boilerplate_len + self.count = ceil(content_len / self.length) + self.get_pages_current_slice() + + def next(self): + if self.current < self.count: + self.current += 1 + + def prev(self): + if self.current > 1: + self.current -= 1 + + def get_pages_current_slice(self): + self.current_slice = slice( + (self.length * (self.current - 1)), (self.length * self.current) + ) + + +class Level: + def __init__(self, name): + self.name = name + self.data = None + self.pages = Pages() + self.selected_index = None + + +class Menu: + def __init__(self): + # main levels: categories -> category_details -> thread_details + categories = Level("categories") + category_details = Level("category_details") + thread_details = Level("thread_details") + self.main_levels = [categories, category_details, thread_details] + + # search levels: search_results -> search_results_thread_details + search_results = Level("search_results") + search_results_thread_details = Level("search_results_thread_details") + self.search_levels = [search_results, search_results_thread_details] + + self.is_main_level = True + + self.main_level_index = 0 + self.search_level_index = 0 + self.current_level = self.main_levels[self.main_level_index] + + def back(self): + if self.is_main_level: + if self.main_level_index > 0: + self.main_level_index -= 1 + self.current_level = self.main_levels[self.main_level_index] + else: + if self.search_level_index > 0: + self.search_level_index -= 1 + self.current_level = self.search_levels[self.search_level_index] + else: + self.current_level = self.main_levels[self.main_level_index] + + def forward(self): + if self.is_main_level: + if self.main_level_index < len(self.main_levels) - 1: + self.main_level_index += 1 + self.current_level = self.main_levels[self.main_level_index] + else: + if self.search_level_index < len(self.search_levels) - 1: + self.search_level_index += 1 + self.current_level = self.search_levels[self.search_level_index] + else: + self.current_level = self.main_levels[self.main_level_index] + + def menu_view_categories(): """Displays list of categories, takes keyboard input and executes corresponding functions.""" + + boilerplate_len = 10 + + menu = Menu() + + # each menu level consists of: + # data (a list or dictionary) that gets printed + # this data may be based on an index chosen by the previous level + # page settings which are used to calculate the index + # input which is handled + 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"), + # print current menu level + if menu.current_level.name == "categories": + menu.current_level.data = LinkData.categories + menu.current_level.pages.calculate_pages( + len(menu.current_level.data), boilerplate_len ) - ).lower() + print_categories(menu.current_level.data, menu.current_level.pages) + change_level(menu) - if option == "q": - return - if option == "p": + elif menu.current_level.name == "category_details": + menu.current_level.data = LinkData.list_category_details( + menu.main_levels[0].data[menu.current_level.selected_index]["name"] + ) + menu.current_level.pages.calculate_pages( + len(menu.current_level.data), boilerplate_len + ) + print_category_details( + menu.current_level.data[menu.current_level.pages.current_slice], + menu.current_level.pages, + ) + change_level(menu) + + elif menu.current_level.name == "thread_details": + menu.current_level.data = LinkData.get_thread_details( + menu.main_levels[1].data[menu.current_level.selected_index]["postid"] + ) + menu.current_level.pages.calculate_pages( + len(menu.current_level.data), boilerplate_len + ) + print_thread_details(menu.current_level.data, menu.current_level.pages) + change_level(menu) + + elif menu.current_level.name == "search_results": + pass + elif menu.current_level.name == "search_results_thread_details": + pass + + +def change_level(menu) -> int: + """???""" + while True: + # get input + action = parse_input() + + # handle input + if action in ["n", "next"]: + menu.current_level.pages.next() + elif action in ["p", "prev"]: + menu.current_level.pages.prev() + elif action in ["b", "back"]: + menu.back() + elif action in ["f", "forward"]: + menu.forward() + elif action in ["?", "help"]: + print(HELP_TEXT) + elif action in ["q", "quit", "exit"]: + graceful_exit() + elif action in ["c", "create"]: 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.") + # TODO: create new post + # set category_details to the relevant category + # set the selected index to the index of the new post in category_details + # set menu level to thread index + pass + elif action in ["s", "search"]: + pass + # search() + # TODO: open search screen + # level.prior_to_search = level.current + # results are returned to a search list, displayed by setting menu level to search + + elif action in ["r", "reply"] and menu.current_level.name in [ + "search_result_thread_details", + "thread_details", + ]: + # reply(thread_details["postid"]) + pass + elif action in ["o", "open"] and menu.current_level.name in [ + "search_result_thread_details", + "thread_details", + ]: + pass + # open_link_in_browser(thread_details["url"]) + # open link in external program + # is o the right command? + + else: + try: + # numeric action + action = int(action) + except (ValueError): + print("invalid input") + continue + + selected_index = (action - 1) + ( + menu.current_level.pages.length * (menu.current_level.pages.current - 1) + ) + menu.forward() + menu.current_level.pages.current_page = 0 + menu.current_level.selected_index = selected_index + break + + # except (IndexError, ValueError): + # print("Sorry, that category does not exist. Try again.") -def menu_view_category_details(cat_index): +def parse_input() -> str: + output = input("input: ").lower() + return output + """Gets user input and processes it. Accepts a menu level of categories, + category_details or thread_details. Returns a menu page action or + nothing""" + + # def parse_input(level.current: str) -> str: + + +# if level.current == "categories": +# input_text = ( +# "Enter an ID, {}dd a link, {}earch, {}ext or {}prev page, {} or {}uit: ".format( +# style_text("a", True, "underline"), +# style_text("s", True, "underline"), +# style_text("n", True, "underline"), +# style_text("p", True, "underline"), +# style_text("?", True, "underline"), +# style_text("q", True, "underline"), +# ) +# ) +# elif level.current = "category_details" +# input_text = ( +# "Enter an ID, go {}ack, {}dd a link, {}earch, page {}p or {}own".format( +# style_text("b", True, "underline"), +# style_text("a", True, "underline"), +# style_text("s", True, "underline"), +# style_text("n", True, "underline"), +# style_text("?", True, "underline"), +# ) +# ) + +# return input(input_text).lower() + + +def menu_view_category_details(selected_category): """Displays category details, takes keyboard input and executes corresponding functions""" - + pages = Pages() + category_details = LinkData.list_category_details(selected_category) while True: - thread_index = print_category_details(cat_index) + boilerplate_len = 10 + pages = calculate_pages(len(category_details), boilerplate_len, pages) + pages = get_pages_current_slice(pages) - 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() + print_category_details( + category_details[current_page], + pages.current, + pages.count, + ) + + pages = interpret_commands(pages, "category_details") if option == "q": graceful_exit() @@ -404,7 +607,7 @@ def menu_view_category_details(cat_index): menu_view_thread_details(post_id) continue try: - post_id = thread_index[int(option)] + selected_category = category_details(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 @@ -500,7 +703,6 @@ def main(): args = sys.argv[1:] config.init() if not args: - print_banner() menu_view_categories() elif args[0] in ["-h", "--help", "help"]: print(HELP_TEXT)