linkulator2/linkulator

406 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
## If this script contains bugs, blame cmccabe.
import getpass
import os
import signal
import stat
import subprocess
import sys
import time
from datetime import datetime
from glob import glob
from pathlib import Path, PurePath
from shutil import which
import posts
from config import CONFIG as config
username = getpass.getuser()
browser = "lynx"
help_text = """
options: -h or --help; -p or --post; 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.
"""
pipe_count = 4 ## A PROPERLY FORMATED LINE IN linkulator.data HAS EXACTLY FOUR PIPES.
link_data = []
## username, datestamp, parent-id, category, link-url, link-title
categories = []
category_counts = {}
ignore_names = []
# READS THE CURRENT USER'S IGNORE FILE AND ADDS ANY ENTRIES TO THE GLOBAL VARIABLE
# IGNORE NAMES.
def parse_ignore_file():
global ignore_names
if config.my_ignorefile.exists():
s = config.my_ignorefile.read_text()
l = s.splitlines()
for line in l:
name = line.split(" ")[0]
ignore_names.append(name)
def build_menu():
## WHENEVER THIS FUNCTION IS CALLED, THE DATA IS REFRESHED FROM FILES. SINCE
## DISK IO IS PROBABLY THE HEAVIEST PART OF THIS SCRIPT, DON'T DO THIS OFTEN.
files_pattern = str(
PurePath(config.all_homedir_pattern).joinpath(config.datadir, config.datafile)
)
linkulator_files = glob(files_pattern)
global link_data
linkulator_lines = []
for filename in linkulator_files:
with open(filename) as f:
# get file owner username from path
file_owner = PurePath(filename).parent.parent.name
if file_owner in ignore_names:
# ignore names found in ignore file
continue
for line in f:
if line.count("|") != pipe_count:
# ignore lines that fail validation
continue
line = line.rstrip("\n")
split_line = line.split("|")
split_line.insert(0, file_owner)
linkulator_lines.append(split_line) ## creating a list of lists
if len(linkulator_lines) == 0:
print("It looks link there are no links yet. Run 'linkulator -p' to add one.")
exit()
i = 1
for idx, line in enumerate(linkulator_lines):
if line[2] == "": # CREATE/INSERT PARENT ID:
linkulator_lines[idx].insert(0, i)
i = i + 1
else: ## NOT PARENT, SO NO PARENT ID
linkulator_lines[idx].insert(0, "")
link_data = linkulator_lines
## THIS IS SUPPOSED TO SORT ALL LINKS BY CREATION DATE. NEED TO CONFIRM THAT IT WORKS.
link_data.sort(key=lambda x: x[2])
global categories
global category_counts
categories = []
category_counts.clear() ## CLEAR SO WE DON'T DOUBLE-COUNT IF FNC RUN MORE THAN ONCE.
for line in link_data:
if line[4] not in categories and line[4] != "":
categories.append(line[4])
category_counts[line[4]] = 1
elif line[4] in categories:
category_counts[line[4]] = category_counts[line[4]] + 1
def print_categories():
print("\n{:>4s} {:<25s}".format("ID#", "Category"))
for i, cat in enumerate(categories):
print("{:4d} {} ({})".format(i + 1, cat, category_counts[cat]))
view_category_contents()
def view_category_contents():
view_cat = ""
while True:
view_cat = input(
"\nEnter category ID or {} to quit: ".format(style_text("q", "underline"))
).lower()
if view_cat == "q":
graceful_exit()
else:
try:
view_cat = categories[int(view_cat) - 1]
break
except (IndexError, ValueError):
print("Sorry, that category does not exist. Try again.")
header = "\n\n{:>4s} {:<15s}{:<12s} #RESP {:<13s}".format(
"ID#", "DATE", "AUTHOR", "DESC"
)
out = ""
link_count = 0
threads = {}
for line in link_data:
if line[4] == view_cat:
link_count += 1
threads[link_count] = str(line[0])
parent_id = line[1] + "+" + str(line[2])
replies = len([line for line in link_data if line[3] == parent_id])
dt = datetime.utcfromtimestamp(float(line[2])).strftime("%Y-%m-%d")
out += "{:4d} {:<15s}{:<12s} [{:3d}] {:s}\n".format(
link_count, dt, line[1], replies, line[6]
)
if len(out) > 0:
print(header)
print("." * len(header))
print(out)
else:
print("\n\nThere are no posts for this category\n")
while True:
pid = input(
"Enter a post ID to see its thread, {} to return to the main menu, or {} to quit: ".format(
style_text("m", "underline"), style_text("q", "underline")
)
).lower()
if pid == "q": ## HARMLESS BUT UNINTENDED
graceful_exit() ## ABILITY HERE IS THAT USERS
elif pid == "m": ## CAN PUT ANY PID IN, NOT JUST
print_categories() ## FROM WITHIN THIS CATEGORY.
break
else:
try:
link = threads[int(pid)]
view_thread(link)
break
except (
KeyError,
ValueError,
): # Catch a pid that is not in the thread list or is not a number
print("{}\n\n".format("Invalid category ID/entry", "bold"))
def view_thread(post_id):
parent_id = ""
url = ""
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.")
view_category_contents() # This should not be necessary via checks from one level up, but if they get here, send them back
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 len(replies):
for line in replies:
print("{}: {}".format(style_text(line[1], "bold"), line[6]))
else:
print("No replies yet. Be the first!")
next_text = "\nType {} to reply, {} to view in {}, {} for main menu, or {} to quit: ".format(
style_text("r", "underline"),
style_text("b", "underline"),
browser,
style_text("m", "underline"),
style_text("q", "underline"),
)
while True:
next_step = input(next_text).lower()
if next_step == "m":
print_categories()
break
elif next_step == "b":
view_link_in_browser(url, post_id)
break
elif next_step == "r":
reply(parent_user, parent_timestamp, post_id)
break
elif next_step == "q":
graceful_exit()
else:
print("{}\n\n".format("Invalid entry", "bold"))
def view_link_in_browser(url, post_id):
if which(browser) is None:
print(
"Sorry, "
+ browser
+ " is not installed on your system. Ask your sysadmin to install it."
)
view_thread(post_id)
if (
url.startswith("gopher://")
or url.startswith("https://")
or url.startswith("http://")
):
subprocess.call(["lynx", url])
else:
print("Sorry, that url doesn't start with gopher://, http:// or https://")
tryAnyway = input("Do you want to try it in", browser, "anyway? Y/[N] ")
if tryAnyway == "Y" or tryAnyway == "y":
subprocess.call(["lynx", url])
view_thread(post_id)
def reply(owner, tstamp, post_id):
global username
comment = input("Enter your comment: ")
if os.path.exists(config.my_datafile):
append_write = "a" # append if already exists
else:
append_write = "w+" # make a new file if not
with open(config.my_datafile, append_write) as file:
timestamp = str(time.time())
file.write(timestamp + "|" + owner + "+" + tstamp + "|||" + comment + "\r")
x = input("Reply added. Hit [Enter] to return to thread.")
build_menu()
view_thread(post_id)
def search(keyword):
print("Doesn't work yet. Would be searching title, category, comment for ", keyword)
## 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...
def is_readable(st_mode: int) -> bool:
"""Checks the provided mode is group and other readable, returns true if this is the case
Check if 700 is readable:
>>> is_readable(16832)
False
Check if 755 is readable:
>>> is_readable(16877)
True
"""
if bool(st_mode & stat.S_IRGRP) & bool(st_mode & stat.S_IROTH):
return True
return False
def init():
"""Performs startup checks to ensure environment is set up for use
Creates necessary data directory and data file. If they exist, no error
occurs.
Checks that the data directory and data file are group and other readable.
Sets some correct permissions if they are not.
Other errors may raise an exception.
"""
dir_p = Path(Path.home(), ".linkulator")
file_p = Path(dir_p, "linkulator.data")
dir_p.mkdir(mode=0o755, exist_ok=True)
file_p.touch(mode=0o644, exist_ok=True)
if not is_readable(dir_p.stat().st_mode):
print(
"Warning: %s is not group or other readable - changing permissions"
% str(dir_p)
)
dir_p.chmod(0o755)
if not is_readable(file_p.stat().st_mode):
print(
"Warning: %s is not group or other readable - changing permissions"
% str(file_p)
)
file_p.chmod(0o644)
def graceful_exit():
print("\n\nThank you for linkulating. Goodbye.\n")
exit(0)
def signal_handler(sig, frame):
graceful_exit()
signal.signal(signal.SIGINT, signal_handler)
def parse_command():
args = sys.argv[1:]
init()
if not len(args):
print(" ----------")
print(" LINKULATOR")
print(" ----------")
parse_ignore_file()
build_menu()
print_categories()
elif args[0] in ["-h", "--help", "help"]:
print(help_text)
elif args[0] in ["-p", "--post", "-p"] and len(sys.argv) > 2:
posts.post_link(args[1])
elif args[0] in ["-p", "--post", "-p"]:
posts.post_link()
else:
print("Unknown command: {}".format(args[0]))
def style_text(text, *args):
styles = {
"bold": "\033[1m",
"dim": "\033[2m",
"underline": "\033[4m",
"blink": "\033[5m", # This is here if you REALLY need it...dont use it
"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
if __name__ == "__main__":
parse_command()