linkulator2/linkulator.py

496 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Linkulator"""
## If this script contains bugs, blame cmccabe.
import getpass
import signal
import subprocess
import sys
from time import time
from urllib.parse import urlparse
from datetime import datetime
from shutil import which
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_categories():
"""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):
out += "{:4d} {} {} ({})\n".format(
i + 1,
"x" if record["last_updated"] >= config.USER.lastlogin else " ",
record["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")
def print_category_details(view_cat):
"""produces category detail data, prints it to the console. returns dict
containing an index of threads"""
header = "\n{}\n\n{:>4s} {:<15s}{:<12s} #RESP {:<13s}".format(
style_text(view_cat["name"].upper(), "bold"), "ID#", "DATE", "AUTHOR", "DESC"
)
out = ""
link_count = 0
thread_index = {}
for line in link_data:
if line[4] == view_cat["name"]:
link_count += 1
thread_index[link_count] = line[0]
parent_id = line[1] + "+" + str(line[2])
replies = [line for line in link_data if line[3] == parent_id]
new_replies = len(
[line for line in replies if line[2] >= config.USER.lastlogin]
)
newmarker = "*" if new_replies or line[2] >= config.USER.lastlogin else ""
_dt = datetime.fromtimestamp(float(line[2])).strftime("%Y-%m-%d")
out += "{:4d} {:<15s}{:<12s} [{:3d}] {:s}{}\n".format(
link_count, _dt, line[1], len(replies), line[6], newmarker
)
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
def print_thread_details(post_id) -> tuple:
"""produces thread detail data, prints it to the console"""
# get post data
parent_id: str = ""
post_url: str = ""
for line in link_data:
if line[0] == post_id:
parent_id = "{}+{}".format(line[1], str(line[2]))
post_username = line[1]
post_datetime = datetime.fromtimestamp(float(line[2])).strftime("%c")
post_category = line[4]
post_url = line[5]
post_title = line[6]
# if post is not found, return empty string
if parent_id == "":
raise ValueError("Sorry, no thread found with that ID.")
# get replies data
replies = sorted(
[line for line in link_data if line[3] == parent_id], key=lambda x: x[2]
)
# post detail view
print("\n\n{:<17}: {}".format(style_text("Title", "bold"), post_title))
print("{:<17}: {}".format(style_text("Link", "bold"), post_url))
print("{:<17}: {}".format(style_text("Category", "bold"), post_category))
print("{:<17}: {}".format(style_text("User", "bold"), post_username))
print("{:<17}: {}".format(style_text("Date", "bold"), post_datetime))
# post reply view
if replies:
print("\n{}:\n".format(style_text("Replies", "underline")))
for line in replies:
comment_author = line[1]
comment_date = datetime.fromtimestamp(float(line[2])).isoformat(
sep=" ", timespec="minutes"
)
comment = line[6]
print(
" {} {}: {}".format(
comment_date, style_text(comment_author, "bold"), comment
)
)
else:
print("\nNo replies yet. Be the first!")
# return data used by menu control
return parent_id, post_url
def print_search_results(keyword: str, search_results: list):
"""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))
## CONTROLS
def search():
"""Control for the search function"""
while True:
keyword = input("\nEnter your search (or leave empty to cancel):\n")
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(
"\nEnter a post ID to see its thread, {} to start a new search, {} to go back, or {} to quit: \n".format(
style_text("s", "underline"),
style_text("m", "underline"),
style_text("q", "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("\n{}\n".format(style_text("Invalid entry", "bold")))
def view_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 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, "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("?", "underline")
)
)
try:
url = get_input("URL")
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)
def menu_view_categories():
"""Displays list of categories, takes keyboard input and
executes corresponding functions."""
while True:
print_categories()
option = input(
"\nEnter a category ID, {} to post a link, {} to search, or {} to quit: ".format(
style_text("p", "underline"),
style_text("s", "underline"),
style_text("q", "underline"),
)
).lower()
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", "underline"),
style_text("s", "underline"),
style_text("p", "underline"),
style_text("q", "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)
continue
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", "bold")))
def menu_view_thread_details(post_id):
"""Displays thread details, handles related navigation menu"""
option_text = "\nType {} to reply, {} to view in {}, {} to search, {} to post a new link, {} to go back, or {} to quit: ".format(
style_text("r", "underline"),
style_text("b", "underline"),
config.USER.browser,
style_text("s", "underline"),
style_text("p", "underline"),
style_text("m", "underline"),
style_text("q", "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", "bold")))
## GENERAL
def style_text(text: str, *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
}
out = ""
for arg in args:
if arg in styles:
out += styles[arg]
out += text
out += "\033[0m"
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"""
low_py_ver = 3.6
py_ver = str(sys.version_info[0]) + "." + str(sys.version_info[1])
py_ver = float(py_ver)
if py_ver < low_py_ver:
raise Exception(
"Must be using Python "
+ str(low_py_ver)
+ " or higher. Instead you're using "
+ str(py_ver)
)
signal.signal(signal.SIGINT, signal_handler)
args = sys.argv[1:]
config.init()
if not args:
print_banner()
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()