Further work - can now view categories, links and posts using keyboard

0-9
This commit is contained in:
asdf 2021-08-07 22:02:37 +10:00
parent 8fe9af529b
commit 1656c0fc7b
3 changed files with 372 additions and 247 deletions

29
data.py
View File

@ -176,6 +176,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]
@ -239,7 +240,7 @@ class LinkData:
return sorted(search_results, key=lambda x: x[0], reverse=True)
def list_category_details(self, selected_category: str) -> list:
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"""
@ -247,8 +248,8 @@ class LinkData:
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)
@ -272,7 +273,7 @@ class LinkData:
links.append(
{
"postid": postid,
"post_id": post_id,
"link_timestamp": timestamp,
"link_author": userid,
"reply_count": len(replies),
@ -283,23 +284,23 @@ class LinkData:
)
return sorted(links, key=lambda x: x["last_modified_timestamp"], reverse=True)
def get_thread_details(self, selected_thread) -> dict:
def get_post(self, post_id) -> 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]
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.")
output["replies"] = sorted(
[line for line in self.link_data if line[3] == output["parent_id"]],
[record for record in self.link_data if record[3] == output["parent_id"]],
key=lambda x: x[2],
)

View File

@ -14,7 +14,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
from typing import Tuple
import curses
import locale
@ -36,128 +36,117 @@ def print_page_count(pages):
print("Page {} of {}".format(pages.current, pages.count))
def print_categories(categories, cols) -> tuple[str]:
"""Prints the list of categories with an indicator for new activity"""
output: list[str] = []
def print_categories(categories, cols) -> Tuple[str, list[str]]:
"""Produces categories screen display data. Returns as tuple of header and
content."""
output.append("{:>4s} New {}".format("ID#", "Category"))
thead = "{:>4s} New {}".format("ID#", "Category")
content: list[str] = []
newmarker = "*"
namelen = cols - 11
name_cols = cols - 11
for i, record in enumerate(categories):
index_number = i + 1
name = textwrap.shorten(record["name"], width=namelen, placeholder="...")
output.append(
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 if record["last_updated"] >= config.USER.lastlogin else " ",
newmarker,
name,
record["count"],
)
)
if len(output) < 2:
output = "\n There are no posts yet - enter p to post a new link\n".splitlines()
return output
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(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
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 print_links(links, cols) -> Tuple[str, list[str]]:
"""Produces links screen display data. Accepts links list 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.upper(), False, "bold"),
max_author_cols = max([len(link["link_author"]) for link in links])
author_cols = max(max_author_cols, 6) # minimum field width is 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 = " {:>3s} {:>10s} {:<{author_cols}s} {:<5} {:<s}".format(
"ID#",
"DATE",
"AUTHOR",
"#RESP",
"DESC",
namelen=namelen,
"Date",
"Author",
"#Repl",
"Description",
author_cols=author_cols,
)
out = ""
for i, link in enumerate(category_details):
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(
i + 1,
_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)
print_page_count(pages)
else:
print("\n\nThere are no posts for this category\n")
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(thread_details, pages) -> tuple:
"""produces thread detail data, prints it to the console"""
def print_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
initial_indent=" " * 2, subsequent_indent=" " * 21, width=cols
)
# post detail view
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"])
)
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 thread_details["replies"]:
print("\n{}:\n".format(style_text("Replies", False, "underline")))
for line in thread_details["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("")
print_page_count(pages)
output.append("\n No replies yet. Be the first!")
return output
def print_search_results(keyword: str, search_results: list):
@ -347,53 +336,49 @@ def post_link() -> int:
return LinkData.add(record)
class Page:
class Output:
def __init__(self):
self.current: int = 1 # the current page
self.count: int = 0 # the total count 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
self.thead = None
self.content = None
self.length = None
self.miny = None
self.maxy = None
self.scrollminy = 0
self.viewslice = None
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)
)
def Calc(self):
if not self.content:
raise ValueError
# TODO: ???
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 Level:
def __init__(self, name):
self.name = name
self.data = None
self.pages = Page()
self.selected_index = None
self.output = Output()
self.selected_key = None
class Menu:
def __init__(self):
# main levels: categories -> category_details -> thread_details
# main levels: categories -> links -> post
categories = Level("categories")
category_details = Level("category_details")
thread_details = Level("thread_details")
self.main_levels = [categories, category_details, thread_details]
links = Level("links")
post = Level("post")
self.main_levels = [categories, links, post]
# search levels: search_results -> search_results_thread_details
# search levels: search_results -> found_post
search_results = Level("search_results")
search_results_thread_details = Level("search_results_thread_details")
self.search_levels = [search_results, search_results_thread_details]
found_post = Level("found_post")
self.search_levels = [search_results, found_post]
self.is_main_level = True
@ -402,9 +387,6 @@ class Menu:
self.current_level = self.main_levels[self.main_level_index]
def back(self):
# TODO: back should probably go to previous pages before going up a
# level? main controls could be next page and "back" which would be a
# combo of previous page and go back a level
if self.is_main_level:
if self.main_level_index > 0:
self.main_level_index -= 1
@ -428,36 +410,102 @@ class Menu:
else:
self.current_level = self.main_levels[self.main_level_index]
def get_data(self, LinkData):
"""fill out current level data based on level settings"""
if self.current_level.name == "categories":
self.current_level.data = LinkData.categories
(
self.current_level.output.thead,
self.current_level.output.content,
) = print_categories(self.current_level.data, curses.COLS)
elif self.current_level.name == "links":
self.current_level.data = LinkData.get_links_by_category_name(
self.current_level.selected_key
)
(
self.current_level.output.thead,
self.current_level.output.content,
) = print_links(self.current_level.data, curses.COLS)
elif self.current_level.name == "post":
self.current_level.data = LinkData.get_post(self.current_level.selected_key)
self.current_level.output.content = print_post(
self.current_level.data, curses.COLS
)
elif self.current_level.name == "search_results":
pass
elif self.current_level.name == "search_results_thread_details":
pass
else:
raise ValueError
# TODO: if this fails it's uninitialised or something
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 new_main_menu(stdscr):
# curses.use_default_colors()
# curses.curs_set(0)
title = "Linkulator".encode(code)
body = print_categories(LinkData.categories, curses.COLS)
menu = Menu()
menu.get_data(LinkData)
menu.current_level.output.Calc()
status = "".encode(code)
action = "waiting".encode(code)
bodyminy = 2
bodyscrollmin = 0
while True:
# stdscr.erase()
# print title
stdscr.addstr(0, 1, title)
stdscr.clrtoeol()
# TODO: subtitle?
# stdscr.addstr(1, 4, "All categories".encode(code))
# print header
if menu.current_level.output.thead:
stdscr.addstr(
menu.current_level.output.miny - 1,
0,
menu.current_level.output.thead.encode(code),
)
stdscr.clrtoeol()
# print body
bodymaxy = curses.LINES - 2
viewslice = slice(bodyscrollmin, bodyscrollmin + bodymaxy)
menu.current_level.output.Calc()
count = 0
for i, lines in enumerate(body[viewslice]):
for i, line in enumerate(
menu.current_level.output.content[menu.current_level.output.viewslice]
):
count += count
stdscr.addstr(i + bodyminy, 0, lines.encode(code))
stdscr.clrtoeol()
for i in range((bodymaxy) - count):
stdscr.addstr(i + menu.current_level.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()
# leftover = menu.current_level.output.maxy - count
# if leftover > 0:
# for i in range(
# (menu.current_level.output.maxy - menu.current_level.output.miny)
# - count
# ):
# stdscr.move(count + i + 1, 0)
# stdscr.clrtoeol()
stdscr.refresh()
# print status
@ -474,23 +522,35 @@ def new_main_menu(stdscr):
if k == "q":
graceful_exit()
elif k == "KEY_RESIZE":
stdscr.clear()
curses.resizeterm(*stdscr.getmaxyx())
curses.flushinp()
curses.update_lines_cols()
term_resize(stdscr)
action = "resize"
elif k == "j":
if (bodyscrollmin <= (len(body) - bodymaxy)):
bodyscrollmin += 1
# TODO: put controls in output class
if menu.current_level.output.scrollminy <= (
menu.current_level.output.length - menu.current_level.output.maxy
):
menu.current_level.output.scrollminy += 1
action = "down"
else:
action = "tried down"
elif k == "k":
if bodyscrollmin > 0:
bodyscrollmin -= 1
if menu.current_level.output.scrollminy > 0:
menu.current_level.output.scrollminy -= 1
action = "up"
else:
action = "tried up"
elif k == "b":
menu.back()
elif k in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]:
action = int(k)
if menu.current_level.name == "categories":
key = menu.current_level.data[action - 1]["name"]
elif menu.current_level.name == "links":
key = menu.current_level.data[action - 1]["post_id"]
menu.forward()
menu.current_level.selected_key = key
menu.get_data(LinkData)
menu.current_level.output.Calc()
status = "{}".format(action)
status.encode(code)
@ -498,59 +558,6 @@ def new_main_menu(stdscr):
stdscr.refresh()
def main_menu():
"""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 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
)
print_categories(menu.current_level.data, menu.current_level.pages)
change_level(menu)
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:

View File

@ -6,41 +6,42 @@ from unittest.mock import patch, call
import linkulator
class TestPrintSearchResults(unittest.TestCase):
"""Tests covering the print_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 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"),
]
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(""),
]
linkulator.print_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_print_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"),
# ]
# 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(""),
# ]
#
# linkulator.print_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_print_calls, mock_print.call_args_list)
class TestPrintCategories(unittest.TestCase):
def test_general(self):
def test_print_categories(self):
"""Test general output of print_categories"""
categories = [
{
@ -61,22 +62,138 @@ class TestPrintCategories(unittest.TestCase):
]
cols = 80
test_results = [
test_results = (
" ID# New Category",
" 1 * category 1 (1)",
" 2 * category 2 (2)",
" 3 * long category name that will be truncated because it's a long... (20)",
]
[
" 1 * category 1 (1)",
" 2 * category 2 (2)",
" 3 * long category name that will be truncated because it's a long... (20)",
],
)
test_output = linkulator.print_categories(categories, cols)
self.assertListEqual(test_output, test_results)
self.assertTupleEqual(test_output, test_results)
def test_empty_categories(self):
"""Test output when no categories data"""
empty_categories = []
cols = 80
test_results = [
test_results = (
"",
" There are no posts yet - enter p to post a new link",
]
["", "There are no posts yet - enter p to post a new link"],
)
test_output = linkulator.print_categories(empty_categories, cols)
self.assertTupleEqual(test_output, test_results)
class TestPrintLinks(unittest.TestCase):
def test_print_links(self):
"""Test general output of print_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",
"reply_count": 25,
"description": "a long description for the second post that should wrap i guess",
"has_new_replies": True,
"last_modified_timestamp": "1627549445.044661",
},
]
cols = 80
test_results = (
" ID# Date Author #Repl Description",
[
" 1 2021-07-29 auth 1 [ 0] description 1",
" 2 2021-07-29 author 2 [ 25] a long description for the second post...*",
],
)
test_output = linkulator.print_links(links, cols)
self.assertTupleEqual(test_output, test_results)
class TestPrintPost(unittest.TestCase):
def test_post_without_reply(self):
"""Test print_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
test_results = [
" 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!",
]
test_output = linkulator.print_post(post, cols)
self.assertListEqual(test_output, test_results)
def test_post_with_reply(self):
"""Test print_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
test_results = [
" 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",
]
test_output = linkulator.print_post(post, cols)
self.assertListEqual(test_output, test_results)