forked from cmccabe/linkulator2
964 lines
31 KiB
Python
Executable File
964 lines
31 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Linkulator"""
|
|
|
|
# If this script contains bugs, blame cmccabe and asdf.
|
|
|
|
import curses
|
|
import curses.textpad as textpad
|
|
import getpass
|
|
import locale
|
|
import signal
|
|
from subprocess import call
|
|
import sys
|
|
import textwrap
|
|
from datetime import datetime
|
|
from shutil import which
|
|
from time import time
|
|
from typing import Tuple
|
|
from urllib.parse import urlparse
|
|
|
|
import config
|
|
import data
|
|
|
|
|
|
# linkdata columns:
|
|
# id (if parent), username, datestamp, parent-id, category, link-url, link-title
|
|
# LinkData = data.LinkData()
|
|
# link_data: list = LinkData.link_data
|
|
|
|
# get locale and set CODE for use in string encoding
|
|
locale.setlocale(locale.LC_ALL, "")
|
|
CODE = locale.getpreferredencoding()
|
|
|
|
|
|
# VIEWS
|
|
|
|
|
|
def view_categories(categories, cols) -> Tuple[str, list[str]]:
|
|
"""Produces categories screen display data. Returns as tuple of header and
|
|
body content."""
|
|
|
|
head = "{:>4s} New {}".format("ID#", "Category")
|
|
|
|
body: list[str] = []
|
|
newmarker = "*"
|
|
name_cols = max((cols - 11), 5)
|
|
|
|
for i, record in enumerate(categories):
|
|
index_number = i + 1
|
|
name = textwrap.shorten(record["name"], width=name_cols, placeholder="...")
|
|
newmarker = "*" if record["last_updated"] >= config.USER.lastlogin else " "
|
|
body.append(
|
|
"{:4d} {} {} ({})".format(
|
|
index_number,
|
|
newmarker,
|
|
name,
|
|
record["count"],
|
|
)
|
|
)
|
|
|
|
if not body:
|
|
head = ""
|
|
body = [
|
|
"",
|
|
"There are no posts yet - press p to post a new link",
|
|
"",
|
|
]
|
|
return head, body
|
|
|
|
|
|
def view_links(links, category_name, cols) -> Tuple[str, str, list[str]]:
|
|
"""Produces links screen display data. Accepts links list, category name and
|
|
max column width. Returns a tuple of subtitle, header and body content."""
|
|
|
|
max_author_cols = max([len(link["link_author"]) for link in links])
|
|
author_cols = max(max_author_cols, 6)
|
|
|
|
description_cols = max((cols - 18 - author_cols - 9 - 1), 5)
|
|
# description_cols calulated as: 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
|
|
|
|
subtitle = category_name.title()
|
|
|
|
head = "{:>3s} {:>10s} {:<{author_cols}s} {:<5} {:<s}".format(
|
|
"ID#",
|
|
"Date",
|
|
"Author",
|
|
"#Repl",
|
|
"Description",
|
|
author_cols=author_cols,
|
|
)
|
|
|
|
body: list[str] = []
|
|
|
|
for i, link in enumerate(links):
|
|
description = textwrap.shorten(
|
|
link["description"], width=description_cols, placeholder="..."
|
|
)
|
|
newmarker = "*" if link["has_new_replies"] else ""
|
|
date = datetime.fromtimestamp(float(link["link_timestamp"])).strftime(
|
|
"%Y-%m-%d"
|
|
)
|
|
|
|
body.append(
|
|
"{:3d} {:>10s} {:<{author_cols}s} [{:3d}] {:s}{}".format(
|
|
i + 1,
|
|
date,
|
|
link["link_author"],
|
|
link["reply_count"],
|
|
description,
|
|
newmarker,
|
|
author_cols=author_cols,
|
|
)
|
|
)
|
|
|
|
return subtitle, head, body
|
|
|
|
|
|
def view_post(post, cols) -> list[str]:
|
|
"""Produces post screen display data. Accepts post id and max column
|
|
width. Returns body content list."""
|
|
|
|
body: list = []
|
|
|
|
# set up line wrapping
|
|
line_wrapper = textwrap.TextWrapper(
|
|
initial_indent=" " * 2, subsequent_indent=" " * 21, width=cols
|
|
)
|
|
|
|
# post detail view
|
|
body.append(" {:<12}: {}".format("Title", post["title"]))
|
|
body.append(" {:<12}: {}".format("Link", post["url"]))
|
|
body.append(" {:<12}: {}".format("Category", post["category"]))
|
|
body.append(" {:<12}: {}".format("User", post["author"]))
|
|
date = datetime.fromtimestamp(float(post["timestamp"])).strftime("%c")
|
|
body.append(" {:<12}: {}".format("Date", date))
|
|
|
|
# post reply view
|
|
if post["replies"]:
|
|
body.append("\n {}:\n".format("Replies"))
|
|
for replies in post["replies"]:
|
|
reply_author = replies[1]
|
|
reply_date = datetime.fromtimestamp(float(replies[2])).isoformat(
|
|
sep=" ", timespec="minutes"
|
|
)
|
|
reply_text = replies[6]
|
|
wrapped_reply = line_wrapper.fill(
|
|
" {} {}: {}".format(reply_date, reply_author, reply_text)
|
|
).splitlines()
|
|
for line in wrapped_reply:
|
|
body.append(line)
|
|
else:
|
|
body.append("\n No replies yet. Be the first!")
|
|
return body
|
|
|
|
|
|
def view_search_results(keyword: str, search_results: list):
|
|
"""Produces search results display data. Accepts search keyword, search
|
|
results and max column width. Returns list of strings."""
|
|
# TODO: update to produce list of strings
|
|
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:
|
|
view_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"]:
|
|
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":
|
|
call([config.USER.browser, url])
|
|
|
|
|
|
def command_bar():
|
|
"""Uses defined stdscr to display a command input prompt. returns command text"""
|
|
|
|
# draw the prompt in its own window
|
|
prompt_win = curses.newwin(1, curses.COLS, curses.LINES - 1, 0)
|
|
prompt_win.addch(0, 0, ":")
|
|
prompt_win.clrtoeol()
|
|
prompt_win.refresh()
|
|
|
|
# draw the command field and return gathered text
|
|
command_win = curses.newwin(1, curses.COLS, curses.LINES - 1, 1)
|
|
command_field = textpad.Textbox(command_win)
|
|
command_field.stripspaces = True
|
|
curses.curs_set(1)
|
|
command_field.edit()
|
|
command_text = command_field.gather()
|
|
command_text = command_text.strip()
|
|
curses.curs_set(0)
|
|
return command_text
|
|
|
|
|
|
def get_reply():
|
|
# draw the prompt in its own window
|
|
reply_prompt = curses.newwin(1, curses.COLS, curses.LINES - 5, 0)
|
|
reply_prompt.addnstr(
|
|
0,
|
|
0,
|
|
"Enter your comment (or leave empty to cancel). Press enter to submit.",
|
|
curses.COLS,
|
|
)
|
|
reply_prompt.clrtoeol()
|
|
reply_prompt.refresh()
|
|
|
|
# loop until valid input can be gathered
|
|
|
|
while True:
|
|
reply_window = curses.newwin(4, curses.COLS, curses.LINES - 4, 0)
|
|
reply_field = textpad.Textbox(reply_window)
|
|
reply_field.stripspaces = True
|
|
curses.curs_set(1)
|
|
reply_field.edit(complete_on_enter)
|
|
reply_text = reply_field.gather()
|
|
reply_text = reply_text.strip()
|
|
curses.curs_set(0)
|
|
if is_valid_input(reply_text):
|
|
break
|
|
else:
|
|
reply_prompt.addnstr(
|
|
0, 0, "Sorry, replies can't contain pipes '|'", curses.COLS
|
|
)
|
|
reply_prompt.clrtoeol()
|
|
reply_prompt.refresh()
|
|
return reply_text
|
|
|
|
|
|
def complete_on_enter(char):
|
|
# https://stackoverflow.com/questions/36121802/python-curses-make-enter-key-terminate-textbox
|
|
# if enter, return CTRL+G equivalent
|
|
if char == 10:
|
|
char = 7
|
|
return char
|
|
|
|
|
|
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_new_post(categories):
|
|
"""input form for new post. returns new post data or None"""
|
|
# draw the prompt in its own window
|
|
new_post_title = curses.newwin(8, curses.COLS, 4, 0)
|
|
new_post_title.addnstr(
|
|
0, 0, "Enter the requested details, to cancel submit a blank field", curses.COLS
|
|
)
|
|
new_post_title.clrtoeol()
|
|
new_post_title.addnstr(1, 0, "URL:", curses.COLS)
|
|
new_post_title.clrtoeol()
|
|
new_post_title.addnstr(2, 0, "Title:", curses.COLS)
|
|
new_post_title.clrtoeol()
|
|
new_post_title.addnstr(3, 0, "Category:", curses.COLS)
|
|
new_post_title.clrtobot()
|
|
new_post_title.refresh()
|
|
|
|
# loop until valid input can be gathered
|
|
|
|
post_data = {}
|
|
items = ["url", "title", "category"]
|
|
|
|
for i, item in enumerate(items):
|
|
startrow = i + 5
|
|
while True:
|
|
item_window = curses.newwin(1, curses.COLS, startrow, 10)
|
|
item_field = textpad.Textbox(item_window)
|
|
item_field.stripspaces = True
|
|
curses.curs_set(1)
|
|
item_field.edit()
|
|
item_text = item_field.gather()
|
|
item_text = item_text.strip()
|
|
curses.curs_set(0)
|
|
|
|
if not item_text:
|
|
return None
|
|
if item == "category":
|
|
# TODO: some sort of category picker, who knows
|
|
pass
|
|
if is_valid_input(item_text):
|
|
break
|
|
else:
|
|
new_post_title.addnstr(
|
|
0, 0, "Sorry, {} can't contain pipes '|'".format(item), curses.COLS
|
|
)
|
|
new_post_title.clrtoeol()
|
|
new_post_title.refresh()
|
|
post_data[item] = item_text
|
|
|
|
return post_data
|
|
|
|
|
|
class Content:
|
|
"""Menu content that is output to the curses screen, plus a method to
|
|
calculate how it is displayed"""
|
|
|
|
def __init__(self):
|
|
self.subtitle = None
|
|
self.head = None
|
|
self.body = None
|
|
self.length = None
|
|
self.miny = None
|
|
self.maxy = None
|
|
self.scrollminy = 0
|
|
self.viewslice = None
|
|
|
|
def reset_dimensions(self):
|
|
self.scrollminy = 0
|
|
|
|
def calc_dimensions(self):
|
|
if not self.body:
|
|
raise ValueError("Cannot calculate - no content set")
|
|
self.length = len(self.body)
|
|
if self.head and self.subtitle:
|
|
self.miny = 3
|
|
else:
|
|
self.miny = 2
|
|
self.maxy = curses.LINES - 3
|
|
self.viewslice = slice(self.scrollminy, self.scrollminy + self.maxy)
|
|
|
|
def scroll_down(self):
|
|
if self.scrollminy <= (self.length - self.maxy):
|
|
self.scrollminy += 1
|
|
|
|
def scroll_up(self):
|
|
if self.scrollminy > 0:
|
|
self.scrollminy -= 1
|
|
|
|
|
|
class Menu:
|
|
"""Class describing the data contained in each menu level. A level is like a
|
|
menu screen in a hierarchy."""
|
|
|
|
def __init__(self, name):
|
|
self.name = name
|
|
self.data = None
|
|
self.content = Content()
|
|
self.selected_key = None
|
|
|
|
|
|
class MenuHierarchy:
|
|
"""Class containing all menus, describing their hierarchy and methods to
|
|
handle movement between levels"""
|
|
|
|
def __init__(self):
|
|
# primary menu hierarchy: categories -> links -> post
|
|
categories = Menu("categories")
|
|
links = Menu("links")
|
|
post = Menu("post")
|
|
self.primary_hierarchy = [categories, links, post]
|
|
|
|
# search menu hierarchy: search_results -> found_post
|
|
search_results = Menu("search_results")
|
|
found_post = Menu("found_post")
|
|
self.search_hierarchy = [search_results, found_post]
|
|
|
|
# help page - single page
|
|
self.user_help_page = Menu("user_help_page")
|
|
|
|
# hierarchy settings
|
|
self.is_viewing_primary_hierarchy = True
|
|
self.primary_index = 0
|
|
self.search_index = 0
|
|
self.active = self.primary_hierarchy[self.primary_index]
|
|
|
|
def back(self):
|
|
"""goes back up the menu hierarchy"""
|
|
if self.is_viewing_primary_hierarchy:
|
|
if self.primary_index > 0:
|
|
self.primary_index -= 1
|
|
self.active = self.primary_hierarchy[self.primary_index]
|
|
else:
|
|
if self.search_index > 0:
|
|
self.search_index -= 1
|
|
self.active = self.search_hierarchy[self.search_index]
|
|
else:
|
|
self.active = self.primary_hierarchy[self.primary_index]
|
|
self.is_viewing_primary_hierarchy = True
|
|
|
|
def forward(self):
|
|
"""returns forward through previously visited menu hierarchy"""
|
|
if self.is_viewing_primary_hierarchy:
|
|
if self.primary_index < len(self.primary_hierarchy) - 1:
|
|
if self.primary_hierarchy[self.primary_index + 1].data:
|
|
self.primary_index += 1
|
|
self.active = self.primary_hierarchy[self.primary_index]
|
|
else:
|
|
if self.search_index < len(self.search_hierarchy) - 1:
|
|
if self.search_hierarchy[self.search_index + 1].data:
|
|
self.search_index += 1
|
|
self.active = self.search_hierarchy[self.search_index]
|
|
|
|
def get_active_menu_data(self, LinkData):
|
|
"""get active menu data based on menu hierarchy settings"""
|
|
if self.active.name == "categories":
|
|
self.active.data = LinkData.categories
|
|
(
|
|
self.active.content.head,
|
|
self.active.content.body,
|
|
) = view_categories(self.active.data, curses.COLS)
|
|
self.active.content.subtitle = "All Categories"
|
|
|
|
elif self.active.name == "links":
|
|
# reinitialise lower-level menu
|
|
self.primary_hierarchy[self.primary_index + 1].data = None
|
|
# set data
|
|
self.active.data = LinkData.get_links_by_category_name(
|
|
self.active.selected_key
|
|
)
|
|
(
|
|
self.active.content.subtitle,
|
|
self.active.content.head,
|
|
self.active.content.body,
|
|
) = view_links(self.active.data, self.active.selected_key, curses.COLS)
|
|
|
|
elif self.active.name == "post":
|
|
self.active.data = LinkData.get_post(self.active.selected_key)
|
|
self.active.content.subtitle = "Viewing post:"
|
|
self.active.content.body = view_post(self.active.data, curses.COLS)
|
|
|
|
elif self.active.name == "search_results":
|
|
self.is_viewing_primary_hierarchy = False
|
|
pass
|
|
elif self.active.name == "search_results_thread_details":
|
|
self.active.data = LinkData.get_post(self.active.selected_key)
|
|
self.active.content.body = view_post(self.active.data, curses.COLS)
|
|
elif self.active.name == "user_help_page":
|
|
self.is_viewing_primary_hierarchy = False
|
|
self.active.content.subtitle = "Quick Reference Guide"
|
|
self.active.content.body = user_help_text(curses.COLS)
|
|
else:
|
|
raise ValueError("This shouldn't happen?")
|
|
|
|
def navigate(self, int_action, linkdata, screen):
|
|
"""Navigate to an entry by index"""
|
|
if int_action:
|
|
if self.active.name == "categories":
|
|
try:
|
|
key = self.active.data[int_action - 1]["name"]
|
|
except IndexError:
|
|
screen.status = "Sorry, that category doesn't exist"
|
|
return
|
|
elif self.active.name == "links":
|
|
try:
|
|
key = self.active.data[int_action - 1]["post_id"]
|
|
except IndexError:
|
|
screen.status = "Sorry, that link doesn't exist"
|
|
return
|
|
elif self.active.name == "search_results":
|
|
# TODO
|
|
pass
|
|
else:
|
|
# Not a navigable screen
|
|
return
|
|
self.primary_index += 1
|
|
self.active = self.primary_hierarchy[self.primary_index]
|
|
self.active.selected_key = key
|
|
self.get_active_menu_data(linkdata)
|
|
self.active.content.reset_dimensions()
|
|
self.active.content.calc_dimensions()
|
|
|
|
def go_to_post(self, post_category, post_id, linkdata):
|
|
"""Navigates directly to the specified post while also reloading
|
|
category and links pages"""
|
|
# set categories screen
|
|
self.primary_index = 0
|
|
self.active = self.primary_hierarchy[self.primary_index]
|
|
self.get_active_menu_data(linkdata)
|
|
self.active.content.calc_dimensions()
|
|
|
|
# set links screen
|
|
self.primary_index = 1
|
|
self.active = self.primary_hierarchy[self.primary_index]
|
|
self.active.selected_key = post_category
|
|
self.get_active_menu_data(linkdata)
|
|
self.active.content.calc_dimensions()
|
|
|
|
# set post screen
|
|
self.primary_index = 2
|
|
self.active = self.primary_hierarchy[self.primary_index]
|
|
self.active.selected_key = post_id
|
|
self.get_active_menu_data(linkdata)
|
|
self.active.content.calc_dimensions()
|
|
|
|
def reload_current(self, linkdata):
|
|
"""Reloads data for primary heirarchy"""
|
|
for i in range(len(self.primary_hierarchy)):
|
|
self.primary_index = i
|
|
self.active = self.primary_hierarchy[self.primary_index]
|
|
self.get_active_menu_data(linkdata)
|
|
self.active.content.calc_dimensions()
|
|
|
|
|
|
class Screen:
|
|
def __init__(self):
|
|
self.title: str = "Linkulator"
|
|
self.content = None
|
|
self.status: str = "Welcome! Press ? for keys and commands"
|
|
|
|
def set_content(self, content):
|
|
self.content = content
|
|
|
|
def print(self, stdscr):
|
|
# print title
|
|
stdscr.addnstr(0, 0, self.title, curses.COLS)
|
|
stdscr.clrtoeol()
|
|
|
|
# print subtitle
|
|
if self.content.subtitle:
|
|
stdscr.addnstr(1, 0, self.content.subtitle.encode(CODE), curses.COLS)
|
|
stdscr.clrtoeol()
|
|
|
|
# print content header
|
|
if self.content.head:
|
|
stdscr.addnstr(
|
|
self.content.miny - 1,
|
|
0,
|
|
self.content.head.encode(CODE),
|
|
curses.COLS,
|
|
)
|
|
stdscr.clrtoeol()
|
|
|
|
# print body
|
|
self.content.calc_dimensions()
|
|
|
|
count = 0
|
|
for i, line in enumerate(self.content.body[self.content.viewslice]):
|
|
# don't print beyond the screen
|
|
if i >= curses.LINES:
|
|
break
|
|
count += count
|
|
stdscr.addnstr(i + self.content.miny, 0, line.encode(CODE), curses.COLS)
|
|
stdscr.clrtoeol()
|
|
|
|
# this is meant to clear to the second last line, but...
|
|
# just erase to the bottom for now
|
|
stdscr.clrtobot()
|
|
|
|
# print status (footer)
|
|
if self.status:
|
|
stdscr.addnstr((curses.LINES - 1), 0, self.status.encode(CODE), curses.COLS)
|
|
stdscr.clrtoeol()
|
|
else:
|
|
stdscr.move(curses.LINES - 1, 0)
|
|
stdscr.clrtoeol()
|
|
# clear message for next loop
|
|
self.status = ""
|
|
|
|
|
|
def term_resize(stdscr):
|
|
"""resizes the provided stdscr to the current size"""
|
|
# TODO: ensure resize can decrement bodyscrollmin by the appropriate amount
|
|
# if there is free space available
|
|
stdscr.clear()
|
|
curses.resizeterm(*stdscr.getmaxyx())
|
|
curses.flushinp()
|
|
curses.update_lines_cols()
|
|
|
|
|
|
def control_loop(stdscr):
|
|
"""main loop of program, prints data and takes action based on user input"""
|
|
curses.use_default_colors()
|
|
curses.curs_set(0)
|
|
curses.set_escdelay(25)
|
|
|
|
linkdata = data.LinkData()
|
|
|
|
menus = MenuHierarchy()
|
|
menus.get_active_menu_data(linkdata)
|
|
menus.active.content.calc_dimensions()
|
|
|
|
screen = Screen()
|
|
|
|
while True:
|
|
curses.update_lines_cols()
|
|
|
|
screen.set_content(menus.active.content)
|
|
screen.print(stdscr)
|
|
stdscr.refresh()
|
|
|
|
try:
|
|
k = stdscr.getkey()
|
|
except (curses.error):
|
|
screen.status = "Error {} on action {}".format(str(curses.error), str(k))
|
|
k = None
|
|
continue
|
|
|
|
if k == "KEY_RESIZE":
|
|
term_resize(stdscr)
|
|
elif k in [":", " "]:
|
|
action = command_bar()
|
|
handle_command(action, screen, menus, linkdata)
|
|
else:
|
|
stdscr.erase()
|
|
stdscr.refresh()
|
|
handle_hotkey(k, screen, menus, linkdata)
|
|
|
|
|
|
def handle_command(action, screen, menus, linkdata):
|
|
int_action = None
|
|
try:
|
|
int_action = int(action)
|
|
except ValueError:
|
|
# it's not a number but that's ok
|
|
pass
|
|
|
|
if int_action:
|
|
menus.navigate(int_action, linkdata, screen)
|
|
else:
|
|
do_command(action, screen, menus, linkdata)
|
|
|
|
|
|
def do_command(action, screen, menus, linkdata):
|
|
"""Executes the specified action, updating screen status if required"""
|
|
if action in ["?", "help"]:
|
|
menus.active = menus.user_help_page
|
|
menus.get_active_menu_data(None)
|
|
menus.active.content.reset_dimensions()
|
|
menus.active.content.calc_dimensions()
|
|
elif action in ["q", "quit", "exit", "\x04"]:
|
|
graceful_exit()
|
|
elif action in ["c", "create"]:
|
|
new_post_data = get_new_post(linkdata.categories)
|
|
if new_post_data:
|
|
new_post_record = data.LinkDataRecord(
|
|
username=getpass.getuser(),
|
|
timestamp=str(time()),
|
|
category=new_post_data["category"],
|
|
link_URL=new_post_data["url"],
|
|
link_title_or_comment=new_post_data["title"],
|
|
)
|
|
|
|
new_post_id = linkdata.add(new_post_record)
|
|
|
|
if new_post_id:
|
|
menus.go_to_post(new_post_data["category"], new_post_id, linkdata)
|
|
else:
|
|
screen.status = "Error: failed to create post" # this is unlikely
|
|
else:
|
|
screen.status = "Create post cancelled"
|
|
|
|
elif action in ["s", "search"]:
|
|
screen.status = "Search not yet implemented"
|
|
# 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"]:
|
|
if menus.active.name in [
|
|
"search_result_thread_details",
|
|
"post",
|
|
]:
|
|
reply_text = get_reply()
|
|
if reply_text:
|
|
reply_record = data.LinkDataRecord(
|
|
username=getpass.getuser(),
|
|
timestamp=str(time()),
|
|
parent_id=menus.active.data["parent_id"],
|
|
link_title_or_comment=reply_text,
|
|
)
|
|
_ = linkdata.add(reply_record)
|
|
|
|
menus.reload_current(linkdata)
|
|
else:
|
|
screen.status = "Reply cancelled"
|
|
else:
|
|
screen.status = "View a post to make a reply"
|
|
|
|
elif action in ["o", "open"] and menus.active.name in [
|
|
"search_result_thread_details",
|
|
"thread_details",
|
|
]:
|
|
screen.status = "Open in browser not yet implemented"
|
|
# open_link_in_browser(thread_details["url"])
|
|
# open link in external program
|
|
# is o the right command?
|
|
elif action == "":
|
|
return
|
|
else:
|
|
screen.status = "Invalid command. Press ? for help"
|
|
|
|
|
|
def handle_hotkey(action, screen, menus, linkdata):
|
|
int_action = None
|
|
try:
|
|
int_action = int(action)
|
|
except ValueError:
|
|
# it's not a number but that's ok
|
|
pass
|
|
|
|
if int_action:
|
|
menus.navigate(int_action, linkdata, screen)
|
|
else:
|
|
do_hotkey(action, screen, menus, linkdata)
|
|
|
|
|
|
def do_hotkey(action, screen, menus, linkdata):
|
|
"""Executes the provided hotkey action"""
|
|
if action in ["j", "KEY_DOWN"]:
|
|
screen.content.scroll_down()
|
|
elif action in ["k", "KEY_UP"]:
|
|
screen.content.scroll_up()
|
|
elif action in ["b", "KEY_LEFT"]:
|
|
menus.back()
|
|
elif action in ["f", "KEY_RIGHT"]:
|
|
menus.forward()
|
|
elif action == "?":
|
|
menus.active = menus.user_help_page
|
|
menus.get_active_menu_data(None)
|
|
menus.active.content.reset_dimensions()
|
|
menus.active.content.calc_dimensions()
|
|
elif action in ["q", "\x04"]:
|
|
# \x04 is CTRL+D
|
|
graceful_exit()
|
|
elif action == "c":
|
|
new_post_data = get_new_post(linkdata.categories)
|
|
if new_post_data:
|
|
new_post_record = data.LinkDataRecord(
|
|
username=getpass.getuser(),
|
|
timestamp=str(time()),
|
|
category=new_post_data["category"],
|
|
link_URL=new_post_data["url"],
|
|
link_title_or_comment=new_post_data["title"],
|
|
)
|
|
|
|
new_post_id = linkdata.add(new_post_record)
|
|
|
|
if new_post_id:
|
|
menus.go_to_post(new_post_data["category"], new_post_id, linkdata)
|
|
else:
|
|
screen.status = "Error: failed to create post" # this is unlikely
|
|
else:
|
|
screen.status = "Create post cancelled"
|
|
|
|
elif action == "/":
|
|
screen.status = "Search not yet implemented"
|
|
# 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 == "r":
|
|
if menus.active.name in [
|
|
"search_result_thread_details",
|
|
"post",
|
|
]:
|
|
reply_text = get_reply()
|
|
if reply_text:
|
|
reply_record = data.LinkDataRecord(
|
|
username=getpass.getuser(),
|
|
timestamp=str(time()),
|
|
parent_id=menus.active.data["parent_id"],
|
|
link_title_or_comment=reply_text,
|
|
)
|
|
_ = linkdata.add(reply_record)
|
|
|
|
menus.reload_current(linkdata)
|
|
else:
|
|
screen.status = "Reply cancelled"
|
|
else:
|
|
screen.status = "View a post to make a reply"
|
|
|
|
elif action == "o" and menus.active.name in [
|
|
"search_result_thread_details",
|
|
"thread_details",
|
|
]:
|
|
screen.status = "Open in browser not yet implemented"
|
|
# open_link_in_browser(thread_details["url"])
|
|
# open link in external program
|
|
# is o the right command?
|
|
|
|
|
|
# GENERAL
|
|
|
|
|
|
def style_text(text: str, is_input: bool, *args) -> str:
|
|
"""Style input strings as specified using terminal escape sequences. Returns a styled string"""
|
|
# TODO: not sure if this function will work with curses
|
|
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 graceful_exit():
|
|
"""Prints a nice message, performs cleanup and then exits"""
|
|
config.USER.save()
|
|
curses.endwin()
|
|
print("\n\nThank you for linkulating. Goodbye.\n")
|
|
sys.exit(0)
|
|
|
|
|
|
def signal_handler(sig, frame):
|
|
"""handle signals, exiting on SIGINT"""
|
|
graceful_exit()
|
|
|
|
|
|
def main(stdscr):
|
|
"""main function - handles argument parsing and calls menu system"""
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
args = sys.argv[1:]
|
|
config.init()
|
|
if not args:
|
|
control_loop(stdscr)
|
|
elif args[0] in ["-h", "--help", "help"]:
|
|
curses.endwin()
|
|
print(help_text())
|
|
sys.exit(0)
|
|
else:
|
|
curses.endwin()
|
|
print("Unknown command: {}".format(args[0]))
|
|
sys.exit(0)
|
|
graceful_exit()
|
|
|
|
|
|
def help_text():
|
|
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.
|
|
"""
|
|
return help_text
|
|
|
|
|
|
def user_help_text(cols):
|
|
user_help_text = """- press q to quit -
|
|
- press space or : to enter a command -
|
|
- scroll the page with j/k or up and down arrows -
|
|
|
|
-keys-
|
|
Number keys - navigate to the numbered item
|
|
space or : - open the command prompt
|
|
j or down arrow - scroll down the page
|
|
k or up arrow - scroll up the page
|
|
b or left arrow - go back to the previous page
|
|
f or right arrow - go forward to a previously visited page
|
|
c - create a new post
|
|
r - reply to a post
|
|
o - open a link
|
|
/ - search all links using a keyword
|
|
q - quit the application
|
|
|
|
-commands-
|
|
q, quit, exit - quit the application
|
|
r, reply - reply to the post on screen
|
|
o, open - open the link from the post on screen in your browser
|
|
s, search - search all links using a keyword
|
|
b, back - go back to the previous page
|
|
f, forward - go forward to a previously visited page
|
|
c, create - create a new post
|
|
?, help - show this help page
|
|
"""
|
|
return user_help_text.splitlines()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
curses.wrapper(main)
|