Compare commits

...

6 Commits

4 changed files with 729 additions and 286 deletions

40
data.py
View File

@ -29,7 +29,7 @@ class LinkDataRecord(NamedTuple):
def is_well_formed_line(line: str) -> bool:
"""Checks if current line is valid or not, returns true or false respectively."""
pipe_count = (
4 ## A PROPERLY FORMATED LINE IN linkulator.data HAS EXACTLY FOUR PIPES.
4 # A PROPERLY FORMATED LINE IN linkulator.data HAS EXACTLY FOUR PIPES.
)
return line.count("|") == pipe_count
@ -175,6 +175,7 @@ class LinkData:
def generate_category_data(self):
"""generate categories list and category count from sorted link data"""
# TODO: add unread status bool to this query's results
self.categories.clear()
for record in self.link_data:
name = record[4]
@ -238,14 +239,16 @@ 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"""
def get_links_by_category_name(self, category_name: str) -> list:
"""accepts a category name. returns a sorted list of posts belonging to
the specified category"""
links = []
for record in self.link_data:
category = record[4]
if category == selected_category:
postid = record[0]
if category == category_name:
post_id = record[0]
userid = record[1]
timestamp = record[2]
parent_id = userid + "+" + str(timestamp)
@ -269,7 +272,7 @@ class LinkData:
links.append(
{
"postid": postid,
"post_id": post_id,
"link_timestamp": timestamp,
"link_author": userid,
"reply_count": len(replies),
@ -278,5 +281,28 @@ class LinkData:
"last_modified_timestamp": last_modified_timestamp,
}
)
links.sort(key=lambda x: x["last_modified_timestamp"], reverse=True)
return links
return sorted(links, key=lambda x: x["last_modified_timestamp"], reverse=True)
def get_post(self, post_id) -> dict:
output = {}
for record in self.link_data:
if record[0] == post_id:
output["parent_id"] = "{}+{}".format(record[1], str(record[2]))
output["author"] = record[1]
output["timestamp"] = record[2]
output["category"] = record[4]
output["url"] = record[5]
output["title"] = record[6]
break
if not output["parent_id"]:
raise ValueError("Sorry, no thread found with that ID.")
# TODO: this should return just the required fields
output["replies"] = sorted(
[record for record in self.link_data if record[3] == output["parent_id"]],
key=lambda x: x[2],
)
return output

View File

@ -1,164 +1,154 @@
#!/usr/bin/env python3
"""Linkulator"""
## If this script contains bugs, blame cmccabe.
# If this script contains bugs, blame cmccabe and asdf.
import curses
import curses.textpad as textpad
import getpass
import readline # pylint: disable=unused-import
import locale
import signal
import subprocess
from subprocess import call
import sys
import textwrap
from time import time
from urllib.parse import urlparse
from datetime import datetime
from shutil import which, get_terminal_size
from shutil import which
from time import time
from typing import Tuple
from urllib.parse import urlparse
import data
import config
import data
## id (if parent), username, datestamp, parent-id, category, link-url, link-title
# linkdata columns:
# 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")
# VIEWS
def view_categories(categories, cols) -> Tuple[str, list[str]]:
"""Produces categories screen display data. Returns as tuple of header and
content."""
thead = "{:>4s} New {}".format("ID#", "Category")
content: list[str] = []
newmarker = "*"
name_cols = cols - 11
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"],
index_number = i + 1
name = textwrap.shorten(record["name"], width=name_cols, placeholder="...")
newmarker = "*" if record["last_updated"] >= config.USER.lastlogin else " "
content.append(
"{:4d} {} {} ({})".format(
index_number,
newmarker,
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")
if not content:
thead = ""
content = "\nThere are no posts yet - enter p to post a new link\n".splitlines()
return thead, content
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
def view_links(links, category_name, cols) -> Tuple[str, list[str]]:
"""Produces links screen display data. Accepts links list, category name and
max column width. Returns a tuple of header and content."""
header = "\n{}\n\n {:>3s} {:>10s} {:<{namelen}s} {:<5} {:<s}".format(
style_text(view_cat["name"].upper(), False, "bold"),
max_author_cols = max([len(link["link_author"]) for link in links])
author_cols = max(max_author_cols, 6)
description_cols = cols - 18 - author_cols - 9 - 1
# description_cols calulated as: 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
thead = " {}\n {:>3s} {:>10s} {:<{author_cols}s} {:<5} {:<s}".format(
category_name.title(),
"ID#",
"DATE",
"AUTHOR",
"#RESP",
"DESC",
namelen=namelen,
"Date",
"Author",
"#Repl",
"Description",
author_cols=author_cols,
)
out = ""
link_count = 0
thread_index = {}
category_details = LinkData.list_category_details(view_cat["name"])
for link in category_details:
link_count += 1
thread_index[link_count] = link["postid"]
desc = textwrap.shorten(link["description"], width=desclen, placeholder="...")
newmarker = (
"*" if link["last_modified_timestamp"] >= config.USER.lastlogin else ""
content: list[str] = []
for i, link in enumerate(links):
description = textwrap.shorten(
link["description"], width=description_cols, placeholder="..."
)
_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,
newmarker = "*" if link["has_new_replies"] else ""
date = datetime.fromtimestamp(float(link["link_timestamp"])).strftime(
"%Y-%m-%d"
)
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
content.append(
" {:3d} {:>10s} {:<{author_cols}s} [{:3d}] {:s}{}".format(
i + 1,
date,
link["link_author"],
link["reply_count"],
description,
newmarker,
author_cols=author_cols,
)
)
return thead, content
def print_thread_details(post_id) -> tuple:
"""produces thread detail data, prints it to the console"""
def view_post(post, cols) -> list[str]:
"""Produces post screen display data. Accepts post id and max column
width. Returns content list."""
output: list = []
# 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]
initial_indent=" " * 2, subsequent_indent=" " * 21, width=cols
)
# 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))
output.append(" {:<12}: {}".format("Title", post["title"]))
output.append(" {:<12}: {}".format("Link", post["url"]))
output.append(" {:<12}: {}".format("Category", post["category"]))
output.append(" {:<12}: {}".format("User", post["author"]))
date = datetime.fromtimestamp(float(post["timestamp"])).strftime("%c")
output.append(" {:<12}: {}".format("Date", date))
# 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(
if post["replies"]:
output.append("\n {}:\n".format("Replies"))
for replies in post["replies"]:
reply_author = replies[1]
reply_date = datetime.fromtimestamp(float(replies[2])).isoformat(
sep=" ", timespec="minutes"
)
comment = line[6]
print(
reply_text = replies[6]
output.append(
line_wrapper.fill(
"{} {}: {}".format(comment_date, comment_author, comment)
" {} {}: {}".format(reply_date, reply_author, reply_text)
)
)
else:
print("\nNo replies yet. Be the first!")
print("")
# return data used by menu control
return parent_id, post_url
output.append("\n No replies yet. Be the first!")
return output
def print_search_results(keyword: str, search_results: list):
"""a view for the search results - prints results to screen"""
def view_search_results(keyword: str, search_results: list):
"""Produces search results display data. Accepts search keyword, search
results and max column width. Returns list of strings."""
# TODO: update to produce list of strings
print(
"\nShowing results for {}\n\n{:>4s} {:<15s}{:<12s}{:<13s}".format(
keyword, "ID#", "DATE", "AUTHOR", "DESC"
@ -173,7 +163,7 @@ def print_search_results(keyword: str, search_results: list):
print("")
## CONTROLS
# CONTROLS
def search():
@ -189,7 +179,7 @@ def search():
print("No results found\n")
return
while True:
print_search_results(keyword, search_results)
view_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"),
@ -213,7 +203,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(
@ -225,39 +215,44 @@ def view_link_in_browser(url):
url_scheme = urlparse(url).scheme
if url_scheme in ["gopher", "https", "http"]:
subprocess.call([config.USER.browser, url])
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])
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 new_reply(stdscr, post_id):
# TODO
pass
# 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:
@ -280,7 +275,7 @@ def is_valid_input(entry: str) -> bool:
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]:
if entry not in [record["name"] for record in LinkData.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"
@ -297,8 +292,8 @@ def get_input(item: str) -> str:
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))
if not LinkData.categories
else ", ".join(sorted(record["name"] for record in LinkData.categories))
)
print(
"Available categories: {}\n"
@ -343,110 +338,335 @@ def post_link() -> int:
return LinkData.add(record)
def menu_view_categories():
"""Displays list of categories, takes keyboard input and
executes corresponding functions."""
while True:
print_categories()
class Output:
"""Menu content that is output to the curses screen, plus a method to
calculate how it is displayed"""
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"),
def __init__(self):
self.thead = None
self.content = None
self.length = None
self.miny = None
self.maxy = None
self.scrollminy = 0
self.viewslice = None
def reset(self):
self.scrollminy = 0
def calc_dimensions(self):
if not self.content:
raise ValueError("Can't calculate nonexistant data")
self.length = len(self.content)
if self.thead:
self.miny = 3
else:
self.miny = 2
self.maxy = curses.LINES - 3
self.viewslice = slice(self.scrollminy, self.scrollminy + self.maxy)
class Menu:
"""Class describing the data contained in each menu level. A level is like a
menu screen in a hierarchy."""
def __init__(self, name):
self.name = name
self.data = None
self.output = Output()
self.selected_key = None
class MenuHierarchy:
"""Class containing all menus, describing their hierarchy and methods to
handle movement between levels"""
def __init__(self):
# primary menu hierarchy: categories -> links -> post
categories = Menu("categories")
links = Menu("links")
post = Menu("post")
self.primary_hierarchy = [categories, links, post]
# search menu hierarchy: search_results -> found_post
search_results = Menu("search_results")
found_post = Menu("found_post")
self.search_hierarchy = [search_results, found_post]
self.is_viewing_primary_hierarchy = True
self.primary_index = 0
self.search_index = 0
self.active = self.primary_hierarchy[self.primary_index]
def back(self):
"""goes back up the menu hierarchy"""
if self.is_viewing_primary_hierarchy:
if self.primary_index > 0:
self.primary_index -= 1
self.active = self.primary_hierarchy[self.primary_index]
else:
if self.search_index > 0:
self.search_index -= 1
self.active = self.search_hierarchy[self.search_index]
else:
self.active = self.primary_hierarchy[self.primary_index]
def forward(self):
"""returns forward through previously visited menu hierarchy"""
if self.is_viewing_primary_hierarchy:
if self.primary_index < len(self.primary_hierarchy) - 1:
if self.primary_hierarchy[self.primary_index + 1].data:
self.primary_index += 1
self.active = self.primary_hierarchy[self.primary_index]
else:
if self.search_index < len(self.search_hierarchy) - 1:
if self.search_hierarchy[self.search_index + 1].data:
self.search_index += 1
self.active = self.search_hierarchy[self.search_index]
def set_active_menu_data(self, LinkData):
"""get active menu data based on menu hierarchy settings"""
if self.active.name == "categories":
self.active.data = LinkData.categories
(
self.active.output.thead,
self.active.output.content,
) = view_categories(self.active.data, curses.COLS)
elif self.active.name == "links":
self.primary_hierarchy[self.primary_index + 1].data = None
self.active.data = LinkData.get_links_by_category_name(
self.active.selected_key
)
).lower()
(
self.active.output.thead,
self.active.output.content,
) = view_links(self.active.data, self.active.selected_key, curses.COLS)
elif self.active.name == "post":
self.active.data = LinkData.get_post(self.active.selected_key)
self.active.output.content = view_post(self.active.data, curses.COLS)
elif self.active.name == "search_results":
pass
elif self.active.name == "search_results_thread_details":
pass
else:
raise ValueError("This shouldn't happen?")
class Status:
def __init__(self):
self.message: str
self.action: str
def term_resize(stdscr):
"""resizes the provided stdscr to the current size"""
# TODO: ensure resize can decrement bodyscrollmin by the appropriate amount
# if there is free space available
stdscr.clear()
curses.resizeterm(*stdscr.getmaxyx())
curses.flushinp()
curses.update_lines_cols()
def menu_system(stdscr):
"""main loop of program, prints data and takes action based on user input"""
locale.setlocale(locale.LC_ALL, "")
code = locale.getpreferredencoding()
curses.use_default_colors()
curses.curs_set(0)
title = "Linkulator".encode(code)
menus = MenuHierarchy()
menus.set_active_menu_data(LinkData)
menus.active.output.calc_dimensions()
status = Status()
status.message = ""
status.action = ""
while True:
curses.update_lines_cols()
# TODO: consider stdscr.erase()
# print title
stdscr.addstr(0, 1, title)
stdscr.clrtoeol()
# TODO: print subtitle?
# stdscr.addstr(1, 4, "All categories".encode(code))
# print header
if menus.active.output.thead:
stdscr.addstr(
menus.active.output.miny - 1,
0,
menus.active.output.thead.encode(code),
)
stdscr.clrtoeol()
# print body
menus.active.output.calc_dimensions()
count = 0
for i, line in enumerate(
menus.active.output.content[menus.active.output.viewslice]
):
# guard just in case we try to print beyond the window because of
# some resize stuff
if i >= curses.LINES:
break
count += count
stdscr.addstr(i + menus.active.output.miny, 0, line.encode(code))
stdscr.clrtoeol()
# this is meant to clear to the second last line, but...
# just erase to the bottom for now
stdscr.clrtobot()
# print status
if status.message:
status_text = "{} - {}".format(status.message, status.action)
stdscr.addstr((curses.LINES - 1), 0, status_text.encode(code))
stdscr.clrtoeol()
else:
stdscr.move(curses.LINES - 1, 0)
stdscr.clrtoeol()
# clear message for next loop
status.message = ""
# refresh screen output with all changes made
stdscr.refresh()
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)
k = stdscr.getkey()
except (curses.error):
status.message = str(curses.error)
status.action = str(k)
k = None
continue
if k == "KEY_RESIZE":
term_resize(stdscr)
elif k in [":", " "]:
stdscr.addch(curses.LINES - 1, 0, ":")
stdscr.clrtoeol()
stdscr.refresh()
iwin = curses.newwin(1, curses.COLS, curses.LINES - 1, 1)
itxt = textpad.Textbox(iwin)
itxt.stripspaces = True
curses.curs_set(1)
itxt.edit()
action = itxt.gather()
handle_action(menus, action, status)
curses.curs_set(0)
del itxt
del iwin
else:
handle_action(menus, k, status)
def handle_action(menus, action, status):
int_action = None
try:
int_action = int(action)
except ValueError:
# it's not a number but that's ok
pass
if int_action:
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"))
)
navigate(menus, int_action)
except Exception as e:
status.message = str(e)
else:
try:
do_command(menus, action)
except Exception as e:
status.message = str(e)
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":
def navigate(menus, int_action):
if int_action:
if menus.active.name == "categories":
try:
key = menus.active.data[int_action - 1]["name"]
except IndexError:
raise IndexError("Sorry, that category doesn't exist")
return
elif menus.active.name == "links":
try:
key = menus.active.data[int_action - 1]["post_id"]
except IndexError:
raise IndexError("Sorry, that link doesn't exist")
return
elif menus.active.name == "search_results":
# TODO
pass
else:
# no action because it's not a valid menu
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")))
menus.primary_index += 1
menus.active = menus.primary_hierarchy[menus.primary_index]
menus.active.selected_key = key
menus.set_active_menu_data(LinkData)
menus.active.output.reset()
menus.active.output.calc_dimensions()
def do_command(menus, action):
"""???"""
if action in ["j", "KEY_DOWN"]:
# TODO: put up/down controls in output class?
if menus.active.output.scrollminy <= (
menus.active.output.length - menus.active.output.maxy
):
menus.active.output.scrollminy += 1
elif action in ["k", "KEY_UP"]:
if menus.active.output.scrollminy > 0:
menus.active.output.scrollminy -= 1
elif action in ["b", "back"]:
menus.back()
elif action in ["f", "forward"]:
menus.forward()
elif action in ["?", "help"]:
pass
# print(HELP_TEXT)
elif action in ["q", "quit", "exit"]:
graceful_exit()
elif action in ["c", "create"]:
# post_id = post_link()
# if post_id >= 0:
# 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 menus.active.name in [
"search_result_thread_details",
"thread_details",
]:
# TODO: need stdscr here
new_reply(menus.active.selected_key)
elif action in ["o", "open"] and menus.active.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?
## GENERAL
@ -454,6 +674,7 @@ def menu_view_thread_details(post_id):
def style_text(text: str, is_input: bool, *args) -> str:
"""Style input strings as specified using terminal escape sequences. Returns a styled string"""
# TODO: not sure if this function will work with curses
styles = {
"bold": "\033[1m",
"dim": "\033[2m",
@ -475,17 +696,11 @@ def style_text(text: str, is_input: bool, *args) -> str:
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()
curses.endwin()
print("\n\nThank you for linkulating. Goodbye.\n")
sys.exit(0)
@ -494,18 +709,21 @@ def signal_handler(sig, frame):
graceful_exit()
def main():
def main(stdscr):
"""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()
menu_system(stdscr)
elif args[0] in ["-h", "--help", "help"]:
curses.endwin()
print(HELP_TEXT)
sys.exit(0)
else:
curses.endwin()
print("Unknown command: {}".format(args[0]))
sys.exit(0)
graceful_exit()
@ -530,4 +748,4 @@ A few important points about Linkulator:
see your contributions.
* Linkulator may not work outside of Linux systems.
"""
main()
curses.wrapper(main)

View File

@ -25,7 +25,7 @@ class TestDataHelperFunctions(unittest.TestCase):
self.assertEqual(data.wash_line(line["Test"]), line["Result"])
def test_is_well_formed_line(self):
""" tests the data.is_well_formed_line function"""
"""tests the data.is_well_formed_line function"""
teststrings = [
{"Test": "A line of text", "Result": False},
{"Test": "1 Pipe |", "Result": False},

View File

@ -2,38 +2,237 @@
"""Tests for Linkulator views"""
import unittest
from unittest.mock import patch, call
import linkulator
# TODO: update to support list of strings output
# class TestViewSearchResults(unittest.TestCase):
# """Tests covering the view_search_results function"""
class TestPrintSearchResults(unittest.TestCase):
"""Tests covering the print_search_results function"""
# @patch("builtins.print")
# def test_print_search_results(self, mock_print):
# """tests that the search results are produced correctly"""
# test_keyword = "keyword"
# test_search_results = [
# (66, "keyword", "1576461366.5580268", "", "c", "c", "c"),
# (65, "poster6", "1576461367.5580268", "", "keyword", "c", "c"),
# (64, "poster7", "1576461368.5580268", "", "c", "keyword", "c"),
# (63, "poster8", "1576461369.5580268", "", "c", "c", "keyword"),
# ]
# expected_output = [
# call(
# "\nShowing results for keyword\n\n ID# DATE AUTHOR DESC "
# ),
# call(" 1 2019-12-16 keyword c "),
# call(" 2 2019-12-16 poster6 c "),
# call(" 3 2019-12-16 poster7 c "),
# call(" 4 2019-12-16 poster8 keyword "),
# call(""),
# ]
#
# linkulator.view_search_results(test_keyword, test_search_results)
#
# self.assertEqual(
# mock_print.call_count, 6
# ) # one count for title, 4 for the items and a blank line for formatting
#
# self.assertListEqual(test_view_calls, mock_print.call_args_list)
@patch("builtins.print")
def test_print_search_results(self, mock_print):
"""tests that the search results are printed correctly"""
test_keyword = "keyword"
test_search_results = [
(66, "keyword", "1576461366.5580268", "", "c", "c", "c"),
(65, "poster6", "1576461367.5580268", "", "keyword", "c", "c"),
(64, "poster7", "1576461368.5580268", "", "c", "keyword", "c"),
(63, "poster8", "1576461369.5580268", "", "c", "c", "keyword"),
class TestViewCategories(unittest.TestCase):
def test_view_categories(self):
"""Test general output of view_categories"""
categories = [
{
"name": "category 1",
"count": 1,
"last_updated": "10",
},
{
"name": "category 2",
"count": 2,
"last_updated": "100",
},
{
"name": "long category name that will be truncated because it's a long line, longer than the terminal width. that's for sure.",
"count": 20,
"last_updated": "1000",
},
]
test_print_calls = [
call(
"\nShowing results for keyword\n\n ID# DATE AUTHOR DESC "
),
call(" 1 2019-12-16 keyword c "),
call(" 2 2019-12-16 poster6 c "),
call(" 3 2019-12-16 poster7 c "),
call(" 4 2019-12-16 poster8 keyword "),
call(""),
cols = 80
expected_header = " ID# New Category"
expected_content = [
" 1 * category 1 (1)",
" 2 * category 2 (2)",
" 3 * long category name that will be truncated because it's a long... (20)",
]
linkulator.print_search_results(test_keyword, test_search_results)
actual_header, actual_content = linkulator.view_categories(categories, cols)
self.assertEqual(
mock_print.call_count, 6
) # one count for title, 4 for the items and a blank line for formatting
# confirm expected is equal to actual
self.assertEqual(expected_header, actual_header)
self.assertListEqual(expected_content, actual_content)
self.assertListEqual(test_print_calls, mock_print.call_args_list)
# confirm actual does not exceed cols
header_max_cols = max([len(line) for line in actual_header])
self.assertTrue(header_max_cols <= cols)
content_max_cols = max([len(line) for line in actual_content])
self.assertTrue(content_max_cols <= cols)
def test_empty_categories(self):
"""Test output when no categories data"""
empty_categories = []
cols = 80
expected_header = ""
expected_content = [
"",
"There are no posts yet - enter p to post a new link",
]
actual_header, actual_content = linkulator.view_categories(
empty_categories, cols
)
# confirm expected is equal to actual
self.assertEqual(expected_header, actual_header)
self.assertListEqual(expected_content, actual_content)
# confirm actual does not exceed cols
content_max_cols = max([len(line) for line in actual_content])
self.assertTrue(content_max_cols <= cols)
class TestViewLinks(unittest.TestCase):
def test_view_links(self):
"""Test general output of view_links"""
links = [
{
"post_id": 1,
"link_timestamp": "1627549445.044661",
"link_author": "auth 1",
"reply_count": 0,
"description": "description 1",
"has_new_replies": False,
"last_modified_timestamp": "1627549445.044661",
},
{
"post_id": 2,
"link_timestamp": "1627549445.044661",
"link_author": "author 2 with a long name",
"reply_count": 250,
"description": "a long description for the second post that should wrap i guess",
"has_new_replies": True,
"last_modified_timestamp": "1627549445.044661",
},
]
cols = 80
category_name = "Test Name"
expected_header = " Test Name\n ID# Date Author #Repl Description"
expected_content = [
" 1 2021-07-29 auth 1 [ 0] description 1",
" 2 2021-07-29 author 2 [ 25] a long description for the second post...*",
]
actual_header, actual_content = linkulator.view_links(
links, category_name, cols
)
# confirm expected is equal to actual
self.assertEqual(expected_header, actual_header)
self.assertListEqual(expected_content, actual_content)
# confirm actual does not exceed cols
header_max_cols = max([len(line) for line in actual_header])
self.assertTrue(header_max_cols <= cols)
content_max_cols = max([len(line) for line in actual_content])
self.assertTrue(content_max_cols <= cols)
class TestViewPost(unittest.TestCase):
def test_post_without_reply(self):
"""Test view_post where the post has no reply"""
post = {
"author": "post author 1",
"category": "test category 1",
"timestamp": "100",
"parent_id": "author+timestamp",
"replies": [],
"title": "A cool website",
"url": "http://asdflkjasdf",
}
cols = 80
expected_content = [
" Title : A cool website",
" Link : http://asdflkjasdf",
" Category : test category 1",
" User : post author 1",
" Date : Thu 01 Jan 1970 10:01:40",
"\n No replies yet. Be the first!",
]
actual_content = linkulator.view_post(post, cols)
# confirm expected is equal to actual
self.assertListEqual(actual_content, expected_content)
# confirm actual does not exceed cols
content_max_cols = max([len(line) for line in actual_content])
self.assertTrue(content_max_cols <= cols)
def test_post_with_reply(self):
"""Test view_post where the post has a reply"""
post = {
"author": "author2",
"category": "category 2",
"timestamp": "1000",
"parent_id": "xxxxxxxxx",
"replies": [
[
"",
"reply author 1",
"1001",
"",
"",
"",
"a reply",
],
[
"",
"reply author 2 with a long long long name, a very long name",
"1002",
"",
"",
"",
"a reply with a lot of words in it, too many to read, not going to read this",
],
],
"title": "Website 2",
"url": "asdflkjasdf",
}
cols = 80
expected_content = [
" Title : Website 2",
" Link : asdflkjasdf",
" Category : category 2",
" User : author2",
" Date : Thu 01 Jan 1970 10:16:40",
"\n Replies:\n",
" 1970-01-01 10:16 reply author 1: a reply",
" 1970-01-01 10:16 reply author 2 with a long long long name, a very long name: a reply with a lot of words in it, too many to read, not going to read this",
]
actual_content = linkulator.view_post(post, cols)
# confirm expected is equal to actual
self.assertListEqual(actual_content, expected_content)
# confirm actual does not exceed cols
content_max_cols = max([len(line) for line in actual_content])
self.assertTrue(content_max_cols <= cols)