linkulator2/linkulator.py

496 lines
16 KiB
Python
Raw Normal View History

2019-11-15 19:45:01 +00:00
#!/usr/bin/env python3
"""Linkulator"""
2019-11-15 19:45:01 +00:00
## If this script contains bugs, blame cmccabe.
import getpass
2019-11-18 17:49:54 +00:00
import signal
2019-11-18 12:22:31 +00:00
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
2019-11-15 19:45:01 +00:00
## 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
2019-11-15 19:45:01 +00:00
2019-11-18 17:49:54 +00:00
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"],
)
2019-11-16 12:21:31 +00:00
if len(out) > 0:
print(header)
print(out)
else:
print("\n There are no posts yet - enter p to post a new link\n")
2019-11-18 03:04:57 +00:00
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"
)
2019-11-22 04:15:23 +00:00
out = ""
2019-11-22 17:15:52 +00:00
link_count = 0
thread_index = {}
2019-11-22 04:15:23 +00:00
2019-11-16 12:21:31 +00:00
for line in link_data:
if line[4] == view_cat["name"]:
2019-11-22 17:15:52 +00:00
link_count += 1
thread_index[link_count] = line[0]
2019-11-24 23:58:44 +00:00
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
)
2019-11-22 04:15:23 +00:00
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
2019-11-18 03:04:57 +00:00
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")))
2019-11-24 23:58:44 +00:00
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]
2019-12-20 11:15:11 +00:00
print("{:4d} {:<15s}{:<12s}{:<13s}".format(display_index, date, author, desc))
## CONTROLS
2019-11-15 19:45:01 +00:00
2019-12-20 11:15:11 +00:00
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])
2019-11-18 12:22:31 +00:00
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])
2019-11-18 12:22:31 +00:00
def reply(parent_id):
"""Prompt for reply, validate input, save validated input to disk and update
link_data. Calls view_thread when complete."""
2019-12-08 11:40:25 +00:00
while True:
comment = input("Enter your comment (or leave empty to abort): ")
2019-12-08 11:40:25 +00:00
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,
2019-12-08 11:40:25 +00:00
)
LinkData.add(record)
2019-12-08 11:40:25 +00:00
input("Reply added. Hit [Enter] to return to thread.")
break
2019-11-16 12:21:31 +00:00
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()
2019-11-18 17:49:54 +00:00
option = input(
2019-12-20 11:15:11 +00:00
"\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()
2019-11-18 17:49:54 +00:00
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":
2019-12-20 11:15:11 +00:00
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 "
2019-12-20 11:15:11 +00:00
"search, {} to post a link, or {} to quit: ".format(
style_text("m", "underline"),
2019-12-20 11:15:11 +00:00
style_text("s", "underline"),
style_text("p", "underline"),
style_text("q", "underline"),
)
).lower()
if option == "q":
graceful_exit()
if option == "m":
return
2019-12-20 11:15:11 +00:00
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"""
2019-12-20 11:15:11 +00:00
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,
2019-12-20 11:15:11 +00:00
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
2019-12-20 11:15:11 +00:00
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")))
2019-11-18 03:04:57 +00:00
2019-11-16 12:21:31 +00:00
## 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)
2019-11-15 19:45:01 +00:00
args = sys.argv[1:]
config.init()
if not args:
print_banner()
menu_view_categories()
2019-11-15 19:45:01 +00:00
elif args[0] in ["-h", "--help", "help"]:
print(HELP_TEXT)
2019-11-15 19:45:01 +00:00
else:
print("Unknown command: {}".format(args[0]))
graceful_exit()
2019-11-15 19:45:01 +00:00
if __name__ == "__main__":
HELP_TEXT = """
options: -h or --help; or no option to browse links.
2019-11-23 18:35:12 +00:00
Linkulator is a minimalist, commandline link aggregator for small, trusting shell communities.
2019-11-15 19:45:01 +00:00
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()