forked from cmccabe/linkulator2
Compare commits
6 Commits
Author | SHA1 | Date |
---|---|---|
asdf | aea4572121 | |
asdf | 33e8eca10c | |
asdf | 9aed41c8f7 | |
asdf | 1656c0fc7b | |
asdf | 8fe9af529b | |
asdf | e66d793646 |
40
data.py
40
data.py
|
@ -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
|
||||
|
|
722
linkulator.py
722
linkulator.py
|
@ -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)
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue