linkulator2/linkulator.py

736 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""Linkulator"""
## If this script contains bugs, blame cmccabe.
import getpass
import readline
import signal
import subprocess
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 math import ceil
import data
import config
## id (if parent), username, datestamp, parent-id, category, link-url, link-title
LinkData = data.LinkData()
link_data: list = LinkData.link_data
# categories: list = LinkData.categories
def print_page_count(pages):
if pages.count > 1:
print("Page {} of {}".format(pages.current, pages.count))
def print_categories(categories, pages):
"""Prints the list of categories with an indicator for new activity"""
header = "\n{:>4s} New {:<25s}".format("ID#", "Category")
out = ""
for i, record in enumerate(categories[pages.current_slice]):
out += "{:4d} {} {} ({})\n".format(
i + 1,
"x" if record["last_updated"] >= config.USER.lastlogin else " ",
record["name"],
record["count"],
)
print("\033c", end="")
print_banner()
if len(out) > 0:
print(header)
print(out)
print_page_count(pages)
else:
print("\n There are no posts yet - enter p to post a new link\n")
def print_category_details(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
header = "\n{}\n\n {:>3s} {:>10s} {:<{namelen}s} {:<5} {:<s}".format(
style_text(view_cat.upper(), False, "bold"),
"ID#",
"DATE",
"AUTHOR",
"#RESP",
"DESC",
namelen=namelen,
)
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 ""
)
_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,
)
print("\033c", end="")
if len(out) > 0:
print(header)
print("." * len(header))
print(out)
print_page_count(pages)
else:
print("\n\nThere are no posts for this category\n")
def print_thread_details(thread_details, pages) -> tuple:
"""produces thread detail data, prints it to the console"""
# set up line wrapping
columns, _ = get_terminal_size()
line_wrapper = textwrap.TextWrapper(
initial_indent=" " * 2, subsequent_indent=" " * 21, width=columns
)
# post detail view
print("\033c", end="")
print(
"\n\n{:<17}: {}".format(
style_text("Title", False, "bold"), thread_details["title"]
)
)
print("{:<17}: {}".format(style_text("Link", False, "bold"), thread_details["url"]))
print(
"{:<17}: {}".format(
style_text("Category", False, "bold"), thread_details["category"]
)
)
print(
"{:<17}: {}".format(style_text("User", False, "bold"), thread_details["author"])
)
print(
"{:<17}: {}".format(style_text("Date", False, "bold"), thread_details["date"])
)
# post 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(
sep=" ", timespec="minutes"
)
comment = line[6]
print(
line_wrapper.fill(
"{} {}: {}".format(comment_date, comment_author, comment)
)
)
else:
print("\nNo replies yet. Be the first!")
print("")
print_page_count(pages)
def print_search_results(keyword: str, search_results: list):
print("\033c", end="")
"""a view for the search results - prints results to screen"""
print(
"\nShowing results for {}\n\n{:>4s} {:<15s}{:<12s}{:<13s}".format(
keyword, "ID#", "DATE", "AUTHOR", "DESC"
)
)
for display_index, record in enumerate(search_results, start=1):
date = datetime.fromtimestamp(float(record[2])).strftime("%Y-%m-%d")
author = record[1]
desc = record[6]
print("{:4d} {:<15s}{:<12s}{:<13s}".format(display_index, date, author, desc))
print("")
## CONTROLS
def search():
"""Control for the search function"""
while True:
print("")
keyword = input("Enter your search (or leave empty to cancel): ")
if keyword == "":
print("Search cancelled\n")
return
search_results = LinkData.search(keyword)
if not search_results:
print("No results found\n")
return
while True:
print_search_results(keyword, search_results)
option = input(
"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"),
style_text("m", False, "underline"),
style_text("q", False, "underline"),
)
).lower()
if option == "q":
graceful_exit()
if option == "m":
return
if option == "s":
break
try:
if 1 <= int(option) <= len(search_results):
menu_view_thread_details(search_results[int(option) - 1][0])
else:
raise IndexError("Invalid post ID")
except (KeyError, ValueError, IndexError):
# Catch a Post ID that is not in the thread list or is not a number
print("{}".format(style_text("Invalid entry", False, "bold")))
def open_link_in_browser(url):
"""Attempts to view the specified URL in the configured browser"""
if which(config.USER.browser) is None:
print(
"Sorry, "
+ config.USER.browser
+ " is not installed on your system. Ask your sysadmin to install it."
)
return
url_scheme = urlparse(url).scheme
if url_scheme in ["gopher", "https", "http"]:
subprocess.call([config.USER.browser, url])
else:
print("Sorry, that url doesn't start with gopher://, http:// or https://")
try_anyway = input(
"Do you want to try it in {} anyway? Y/[N]".format(config.USER.browser)
).lower()
if try_anyway == "y":
subprocess.call([config.USER.browser, url])
def reply(parent_id):
"""Prompt for reply, validate input, save validated input to disk and update
link_data. Calls view_thread when complete."""
while True:
comment = input("Enter your comment (or leave empty to abort): ")
if comment == "":
input("Reply aborted. Hit [Enter] to continue.")
break
if not is_valid_input(comment):
print(
"Entries consisting of whitespace, or containing pipes, '|', are "
"not valid.Please try again."
)
else:
record = data.LinkDataRecord(
username=getpass.getuser(),
timestamp=str(time()),
parent_id=parent_id,
link_title_or_comment=comment,
)
LinkData.add(record)
input("Reply added. Hit [Enter] to return to thread.")
break
def is_valid_input(entry: str) -> bool:
"""Determine validity of an input string
>>> is_valid_input("valid")
True
>>> is_valid_input("Not|valid")
False
>>> is_valid_input(" ")
False
"""
if "|" in entry:
return False
if entry.strip() == "":
return False
return True
def 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]:
question = "Do you want to create a new category '{}'? Y/[N]".format(entry)
answer = input(question).lower().strip()
return answer != "" and answer[0] == "y"
return True
def get_input(item: str) -> str:
"""Get user input with the specified prompt, validate and return it, or
break if invalid"""
while True:
i: str = input("{}: ".format(style_text(item, True, "underline")))
if i == "":
raise ValueError("Empty field")
if i == "?":
category_text = (
"No categories yet, feel free to make a new one!"
if not categories
else ", ".join(sorted(record["name"] for record in categories))
)
print(
"Available categories: {}\n"
"♻️ Please help keep Linkulator tidy".format(category_text)
)
continue
if is_valid_input(i):
return i
print(
"Entries consisting of whitespace, or containing pipes, '|', are "
"not valid.Please try again."
)
def post_link() -> int:
"""Handles the link posting process"""
print("\nPost a link by entering the details below.")
print(
"Enter {} for a list of categories. Enter an empty field to cancel.\n".format(
style_text("?", False, "underline")
)
)
try:
url = get_input("URL")
category = get_input("Category")
while not is_correct_category(category):
category = get_input("Category")
title = get_input("Title")
except ValueError:
print("Post cancelled")
return -1
record = data.LinkDataRecord(
username=getpass.getuser(),
timestamp=str(time()),
category=category,
link_URL=url,
link_title_or_comment=title,
)
return LinkData.add(record)
class Pages:
def __init__(self):
self.current: int = 1 # the current page
self.count: int = 0 # the total number of pages
self.length: int = None # the number of lines on a page
self.current_slice: slice = None # a slice to display the current page data
def calculate_pages(self, content_len: int, boilerplate_len: int):
_, lines = get_terminal_size()
self.length = lines - boilerplate_len
self.count = ceil(content_len / self.length)
self.get_pages_current_slice()
def next(self):
if self.current < self.count:
self.current += 1
def prev(self):
if self.current > 1:
self.current -= 1
def get_pages_current_slice(self):
self.current_slice = slice(
(self.length * (self.current - 1)), (self.length * self.current)
)
class Level:
def __init__(self, name):
self.name = name
self.data = None
self.pages = Pages()
self.selected_index = None
class Menu:
def __init__(self):
# main levels: categories -> category_details -> thread_details
categories = Level("categories")
category_details = Level("category_details")
thread_details = Level("thread_details")
self.main_levels = [categories, category_details, thread_details]
# search levels: search_results -> search_results_thread_details
search_results = Level("search_results")
search_results_thread_details = Level("search_results_thread_details")
self.search_levels = [search_results, search_results_thread_details]
self.is_main_level = True
self.main_level_index = 0
self.search_level_index = 0
self.current_level = self.main_levels[self.main_level_index]
def back(self):
if self.is_main_level:
if self.main_level_index > 0:
self.main_level_index -= 1
self.current_level = self.main_levels[self.main_level_index]
else:
if self.search_level_index > 0:
self.search_level_index -= 1
self.current_level = self.search_levels[self.search_level_index]
else:
self.current_level = self.main_levels[self.main_level_index]
def forward(self):
if self.is_main_level:
if self.main_level_index < len(self.main_levels) - 1:
self.main_level_index += 1
self.current_level = self.main_levels[self.main_level_index]
else:
if self.search_level_index < len(self.search_levels) - 1:
self.search_level_index += 1
self.current_level = self.search_levels[self.search_level_index]
else:
self.current_level = self.main_levels[self.main_level_index]
def menu_view_categories():
"""Displays list of categories, takes keyboard input and
executes corresponding functions."""
boilerplate_len = 10
menu = Menu()
# each menu level consists of:
# data (a list or dictionary) that gets printed
# this data may be based on an index chosen by the previous level
# page settings which are used to calculate the index
# input which is handled
while True:
# print 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:
# get input
action = parse_input()
# handle input
if action in ["n", "next"]:
menu.current_level.pages.next()
elif action in ["p", "prev"]:
menu.current_level.pages.prev()
elif action in ["b", "back"]:
menu.back()
elif action in ["f", "forward"]:
menu.forward()
elif action in ["?", "help"]:
print(HELP_TEXT)
elif action in ["q", "quit", "exit"]:
graceful_exit()
elif action in ["c", "create"]:
post_id = post_link()
if post_id >= 0:
# TODO: create new post
# set category_details to the relevant category
# set the selected index to the index of the new post in category_details
# set menu level to thread index
pass
elif action in ["s", "search"]:
pass
# search()
# TODO: open search screen
# level.prior_to_search = level.current
# results are returned to a search list, displayed by setting menu level to search
elif action in ["r", "reply"] and menu.current_level.name in [
"search_result_thread_details",
"thread_details",
]:
# reply(thread_details["postid"])
pass
elif action in ["o", "open"] and menu.current_level.name in [
"search_result_thread_details",
"thread_details",
]:
pass
# open_link_in_browser(thread_details["url"])
# open link in external program
# is o the right command?
else:
try:
# numeric action
action = int(action)
except (ValueError):
print("invalid input")
continue
selected_index = (action - 1) + (
menu.current_level.pages.length * (menu.current_level.pages.current - 1)
)
menu.forward()
menu.current_level.pages.current_page = 0
menu.current_level.selected_index = selected_index
break
# except (IndexError, ValueError):
# print("Sorry, that category does not exist. Try again.")
def parse_input() -> str:
output = input("input: ").lower()
return output
"""Gets user input and processes it. Accepts a menu level of categories,
category_details or thread_details. Returns a menu page action or
nothing"""
# def parse_input(level.current: str) -> str:
# if level.current == "categories":
# input_text = (
# "Enter an ID, {}dd a link, {}earch, {}ext or {}prev page, {} or {}uit: ".format(
# style_text("a", True, "underline"),
# style_text("s", True, "underline"),
# style_text("n", True, "underline"),
# style_text("p", True, "underline"),
# style_text("?", True, "underline"),
# style_text("q", True, "underline"),
# )
# )
# elif level.current = "category_details"
# input_text = (
# "Enter an ID, go {}ack, {}dd a link, {}earch, page {}p or {}own".format(
# style_text("b", True, "underline"),
# style_text("a", True, "underline"),
# style_text("s", True, "underline"),
# style_text("n", True, "underline"),
# style_text("?", True, "underline"),
# )
# )
# return input(input_text).lower()
def menu_view_category_details(selected_category):
"""Displays category details, takes keyboard input and executes
corresponding functions"""
pages = Pages()
category_details = LinkData.list_category_details(selected_category)
while True:
boilerplate_len = 10
pages = calculate_pages(len(category_details), boilerplate_len, pages)
pages = get_pages_current_slice(pages)
print_category_details(
category_details[current_page],
pages.current,
pages.count,
)
pages = interpret_commands(pages, "category_details")
if option == "q":
graceful_exit()
if option == "m":
return
if option == "s":
search()
continue
if option == "p":
post_id = post_link()
if post_id >= 0:
menu_view_thread_details(post_id)
continue
try:
selected_category = category_details(option)
menu_view_thread_details(post_id)
except (KeyError, ValueError):
# Catch a Post ID that is not in the thread list or is not a number
print(
"{}\n\n".format(style_text("Invalid category ID/entry", False, "bold"))
)
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":
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")))
## GENERAL
def style_text(text: str, is_input: bool, *args) -> str:
"""Style input strings as specified using terminal escape sequences. Returns a styled string"""
styles = {
"bold": "\033[1m",
"dim": "\033[2m",
"underline": "\033[4m",
"blink": "\033[5m", # This is here if you REALLY need it...dont use it.
# (ctrl+shift+esc+E to enable evil mode.)
"inverse": "\033[7m", # Make fg and bg color swap
"rl_left_fix": "\001" if is_input else "",
"rl_right_fix": "\002" if is_input else "",
}
out = ""
for arg in args:
if arg in styles:
out += styles["rl_left_fix"]
out += styles[arg]
out += styles["rl_right_fix"]
out += text
out += "{}\033[0m{}".format(styles["rl_left_fix"], styles["rl_right_fix"])
return out
def print_banner():
"""prints a banner"""
print(" ----------")
print(" LINKULATOR")
print(" ----------")
def graceful_exit():
"""Prints a nice message, performs cleanup and then exits"""
print("\n\nThank you for linkulating. Goodbye.\n")
config.USER.save()
sys.exit(0)
def signal_handler(sig, frame):
"""handle signals, exiting on SIGINT"""
graceful_exit()
def main():
"""main function - handles argument parsing and calls menu system"""
signal.signal(signal.SIGINT, signal_handler)
args = sys.argv[1:]
config.init()
if not args:
menu_view_categories()
elif args[0] in ["-h", "--help", "help"]:
print(HELP_TEXT)
else:
print("Unknown command: {}".format(args[0]))
graceful_exit()
if __name__ == "__main__":
HELP_TEXT = """
options: -h or --help; or no option to browse links.
Linkulator is a minimalist, commandline link aggregator for small, trusting shell communities.
A few important points about Linkulator:
* Your username is associated with everything you post. No real anonymity.
* You may ignore other users by adding their username to your ~/.linkulator/ignore file,
followed by an optional description of why you're ignoring them.
* Link post categories are created dynamically when you submit a link. Think before you
create a new category and don't litter the board.
* No files are stored centrally. Each users' contributions are stored in ~/.linkulator/,
meaning that you may always edit or delete your own files. Please don't use this ability
to deceive others.
* Link/reply threads disappear if the original link poster deletes their post; or if you
put their username in your ignore file.
* Your ~/.linkulator/linkulator.data file must be readable by others, or nobody else will
see your contributions.
* Linkulator may not work outside of Linux systems.
"""
main()