linkulator2/linkulator

406 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Linkulator"""
## If this script contains bugs, blame cmccabe.
import getpass
import signal
import subprocess
import sys
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"""
print("\n{:>4s} New {:<25s}".format("ID#", "Category"))
for i, record in enumerate(categories):
print(
"{:4d} {} {} ({})".format(
i + 1,
"x" if record["last_updated"] >= config.USER.lastlogin else " ",
record["name"],
record["count"],
)
)
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{:>4s} {:<15s}{:<12s} #RESP {:<13s}".format(
"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] = str(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.utcfromtimestamp(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):
"""produces thread detail data, prints it to the console"""
parent_id = ""
url: str = ""
for line in link_data:
if str(line[0]) == post_id:
parent_id = line[1] + "+" + str(line[2])
parent_user = line[1]
parent_timestamp = line[2]
url = line[5]
if parent_id == "":
print("Sorry, no thread found with that ID.")
return ""
for line in link_data:
if line[1] == parent_user and line[2] == parent_timestamp:
ftime = time.strftime(
"%b %d %Y", time.gmtime(float(parent_timestamp))
) ## UGGHH...
print("\n\n{:<15}: {}".format(style_text("Title", "bold"), line[6]))
print("{:<15}: {}".format(style_text("Link", "bold"), line[5]))
print("{:<15}: {}".format(style_text("User", "bold"), line[1]))
print("{:<15}: {}".format(style_text("Date", "bold"), ftime))
print("\n{}:\n".format(style_text("Replies", "underline")))
replies = [line for line in link_data if line[3] == parent_id]
if replies:
for line in replies:
print("{}: {}".format(style_text(line[1], "bold"), line[6]))
else:
print("No replies yet. Be the first!")
return parent_user, parent_timestamp, url
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_user, parent_timestamp):
"""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.time()),
parent_id="{}+{}".format(parent_user, parent_timestamp),
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 is_valid_input(i):
return i
print(
"Entries consisting of whitespace, or containing pipes, '|', are "
"not valid.Please try again."
)
def post_link():
"""Handles the link posting process"""
category_text = (
"None"
if not categories
else ", ".join(sorted(record["name"] for record in categories))
)
print("\nEnter link information here. Leaving any field blank aborts " "post.\n")
try:
url = get_input("URL")
print(
"Available categories: {}\n"
"♻️ Please help keep Linkulator tidy".format(category_text)
)
category = get_input("Category")
title = get_input("Title")
except ValueError:
print("Post cancelled")
return
record = data.LinkDataRecord(
username=getpass.getuser(),
timestamp=str(time.time()),
category=category,
link_URL=url,
link_title_or_comment=title,
)
LinkData.add(record)
def search(keyword):
"""Search function - not yet complete"""
raise NotImplementedError
## PSEUDOCODE:
## results_found = ""
## for line in link_data:
## if keyword in link_data[title] or keyword in link_data[category] or
## keyword in link_data[comment]: ## results_found = "yes"
## if line is parent post:
## print line
## elif line is reply:
## get parentID
## print(line[parentID])
## if results_found == "":
## print("No results found")
## else:
## next_step = input("Enter ID to view thread, "M" for main menu, or [Enter] to quit: ")
## if next_step...
## CONTROLS
def menu_view_categories():
"""Displays list of categories, takes keyboard input and
executes corresponding functions."""
while True:
print_categories()
view_cat = input(
"\nEnter a category ID, {} to post a link, or {} to quit: ".format(
style_text("p", "underline"), style_text("q", "underline")
)
).lower()
if view_cat == "q":
return
if view_cat == "p":
# pass a copy of categories so it is not modified
post_link()
else:
try:
cat_index = categories[int(view_cat) - 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 "
"post a link, or {} to quit: ".format(
style_text("m", "underline"),
style_text("p", "underline"),
style_text("q", "underline"),
)
).lower()
if option == "q":
graceful_exit()
if option == "m":
return
if option == "p":
post_link()
else:
try:
link = thread_index[int(option)]
menu_view_thread_details(link)
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"""
next_text = "\nType {} to reply, {} to view in {}, {} to go back, or {} to quit: ".format(
style_text("r", "underline"),
style_text("b", "underline"),
config.USER.browser,
style_text("m", "underline"),
style_text("q", "underline"),
)
while True:
parent_user, parent_timestamp, url = print_thread_details(post_id)
option = input(next_text).lower()
if option == "m":
return
if option == "b":
view_link_in_browser(url)
elif option == "r":
reply(parent_user, parent_timestamp)
elif option == "q":
graceful_exit()
else:
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"""
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()