Refactored category data, updated display after post is added, tests
This commit is contained in:
parent
a846d8788f
commit
4d7cc67876
172
data.py
172
data.py
|
@ -1,17 +1,33 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""This module takes input and returns link_data, the data structure linkulator works from"""
|
"""This module takes input and returns link_data, the data structure linkulator works from"""
|
||||||
from time import time
|
from time import time
|
||||||
|
from typing import NamedTuple
|
||||||
from pathlib import PurePath
|
from pathlib import PurePath
|
||||||
from glob import glob
|
from glob import glob
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
# regex for removing escape characters from https://stackoverflow.com/a/14693789
|
# regex for removing escape characters from https://stackoverflow.com/a/14693789
|
||||||
ESCAPE_CHARS = re.compile(r"\x1B[@-_][0-?]*[ -/]*[@-~]")
|
ESCAPE_CHARS = re.compile(r"\x1B[@-_][0-?]*[ -/]*[@-~]")
|
||||||
BAD_CHARS = re.compile(r"[\t\r\n\f\v]*")
|
BAD_CHARS = re.compile(r"[\t\r\n\f\v]*")
|
||||||
|
|
||||||
|
|
||||||
|
class LinkDataRecord(NamedTuple):
|
||||||
|
"""Represents a record in LinkData.link_data"""
|
||||||
|
|
||||||
|
ID_if_parent: str = ""
|
||||||
|
username: str = ""
|
||||||
|
timestamp: str = ""
|
||||||
|
parent_id: str = ""
|
||||||
|
category: str = ""
|
||||||
|
link_URL: str = ""
|
||||||
|
link_title_or_comment: str = ""
|
||||||
|
|
||||||
|
|
||||||
def is_well_formed_line(line: str) -> bool:
|
def is_well_formed_line(line: str) -> bool:
|
||||||
"""Checks if current line is valid or not, returns true and false respectively."""
|
"""Checks if current line is valid or not, returns true or false respectively."""
|
||||||
pipe_count = (
|
pipe_count = (
|
||||||
4 ## A PROPERLY FORMATED LINE IN linkulator.data HAS EXACTLY FOUR PIPES.
|
4 ## A PROPERLY FORMATED LINE IN linkulator.data HAS EXACTLY FOUR PIPES.
|
||||||
)
|
)
|
||||||
|
@ -19,7 +35,7 @@ def is_well_formed_line(line: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def is_valid_time(timestamp: str) -> bool:
|
def is_valid_time(timestamp: str) -> bool:
|
||||||
"""identifies future dated timestamps - returns true if valid time, false is invalid"""
|
"""identifies future dated timestamps - returns true if valid time, false if invalid"""
|
||||||
return float(timestamp) < time()
|
return float(timestamp) < time()
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,70 +46,128 @@ def wash_line(line: str) -> str:
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
def process(line: str, file_owner: str):
|
def process(line: str, file_owner: str) -> list:
|
||||||
"""Takes a line, returns a list based on the delimeter pipe character"""
|
"""Takes a line, returns a list based on the delimeter pipe character"""
|
||||||
if not is_well_formed_line(line):
|
if not is_well_formed_line(line):
|
||||||
raise ValueError("Not a well formed record")
|
raise ValueError("Not a well formed record")
|
||||||
line = wash_line(line)
|
line = wash_line(line)
|
||||||
split_line = line.split("|")
|
split_line: list = line.split("|")
|
||||||
if split_line[0] and not is_valid_time(split_line[0]):
|
if split_line[0] and not is_valid_time(split_line[0]):
|
||||||
raise ValueError("Invalid date")
|
raise ValueError("Invalid date")
|
||||||
split_line.insert(0, file_owner)
|
split_line.insert(0, file_owner)
|
||||||
return split_line
|
return split_line
|
||||||
|
|
||||||
|
|
||||||
def get(config, ignore_names):
|
def parse_ignore_file() -> list:
|
||||||
"""reads data files for non-ignored users and returns valid data in linkulator formats"""
|
"""reads the current user's ignore file, returns a list of usernames to ignore"""
|
||||||
link_data = []
|
ignore_names: list = []
|
||||||
## username, datestamp, parent-id, category, link-url, link-title
|
if config.USER.ignorefile.exists():
|
||||||
categories = []
|
_s = config.USER.ignorefile.read_text()
|
||||||
category_counts = {}
|
_l = _s.splitlines()
|
||||||
ignore_names = []
|
for line in _l:
|
||||||
|
name = line.split(" ")[0]
|
||||||
|
ignore_names.append(name)
|
||||||
|
return ignore_names
|
||||||
|
|
||||||
## 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(
|
class LinkData:
|
||||||
PurePath(config.PATHS.all_homedir_pattern).joinpath(
|
"""Class that contains link_data, categories and categories count tables,
|
||||||
config.PATHS.datadir, config.PATHS.datafile
|
plus methods to generate and update these items"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.link_data: list = []
|
||||||
|
self.categories: list = []
|
||||||
|
|
||||||
|
self.get()
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""reads data files for non-ignored users, sets valid data in
|
||||||
|
linkulator formats
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
ignore_names = parse_ignore_file()
|
||||||
|
|
||||||
|
files_pattern = str(
|
||||||
|
PurePath(config.PATHS.all_homedir_pattern).joinpath(
|
||||||
|
config.PATHS.datadir, config.PATHS.datafile
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
linkulator_files = glob(files_pattern)
|
||||||
linkulator_files = glob(files_pattern)
|
|
||||||
|
|
||||||
id_iterator = 1
|
id_iterator = 1
|
||||||
|
|
||||||
for filename in linkulator_files:
|
for filename in linkulator_files:
|
||||||
with open(filename) as cfile:
|
with open(filename) as cfile:
|
||||||
# get file owner username from path
|
# get file owner username from path
|
||||||
file_owner = PurePath(filename).parent.parent.name
|
file_owner = PurePath(filename).parent.parent.name
|
||||||
if file_owner in ignore_names:
|
if file_owner in ignore_names:
|
||||||
# ignore names found in ignore file
|
# ignore names found in ignore file
|
||||||
continue
|
|
||||||
for line in cfile:
|
|
||||||
try:
|
|
||||||
split_line = process(line, file_owner)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
continue
|
||||||
|
for line in cfile:
|
||||||
|
try:
|
||||||
|
split_line = process(line, file_owner)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
# assign parent items (links) an ID
|
# assign parent items (links) an ID
|
||||||
if split_line[2] == "":
|
if split_line[2] == "":
|
||||||
split_line.insert(0, id_iterator)
|
split_line.insert(0, id_iterator)
|
||||||
id_iterator += 1
|
id_iterator += 1
|
||||||
else:
|
else:
|
||||||
split_line.insert(0, "")
|
split_line.insert(0, "")
|
||||||
|
|
||||||
link_data.append(split_line)
|
self.link_data.append(split_line)
|
||||||
|
|
||||||
# sort links by creation date
|
self.sort_link_data()
|
||||||
link_data.sort(key=lambda x: x[2], reverse=True)
|
self.generate_category_data()
|
||||||
|
|
||||||
# generate categories list and category count from sorted link data
|
def sort_link_data(self):
|
||||||
for record in link_data:
|
"""sort link_data by creation date"""
|
||||||
cat = record[4]
|
self.link_data.sort(key=lambda x: x[2], reverse=True)
|
||||||
if cat not in categories and cat != "":
|
|
||||||
categories.append(cat)
|
|
||||||
category_counts[cat] = 1
|
|
||||||
elif cat in categories:
|
|
||||||
category_counts[cat] += 1
|
|
||||||
|
|
||||||
return link_data, categories, category_counts
|
def add(self, record):
|
||||||
|
"""Add a record to the data file, and to link_data"""
|
||||||
|
if os.path.exists(config.USER.datafile):
|
||||||
|
append_write = "a" # append if already exists
|
||||||
|
else:
|
||||||
|
append_write = "w+" # make a new file if not
|
||||||
|
with open(config.USER.datafile, append_write) as file:
|
||||||
|
file.write(
|
||||||
|
"{}|{}|{}|{}|{}\n".format(
|
||||||
|
record.timestamp,
|
||||||
|
record.parent_id,
|
||||||
|
record.category,
|
||||||
|
record.link_URL,
|
||||||
|
record.link_title_or_comment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if record.category:
|
||||||
|
record._replace(
|
||||||
|
ID_if_parent=max([record[0] for record in self.link_data if record[0]])
|
||||||
|
)
|
||||||
|
self.link_data.insert(0, list(record))
|
||||||
|
self.generate_category_data()
|
||||||
|
else:
|
||||||
|
self.link_data.insert(0, list(record))
|
||||||
|
|
||||||
|
def generate_category_data(self):
|
||||||
|
"""generate categories list and category count from sorted link data"""
|
||||||
|
self.categories.clear()
|
||||||
|
i = (record for record in self.link_data if record[4] != "")
|
||||||
|
for record in i:
|
||||||
|
name = record[4]
|
||||||
|
timestamp = record[2]
|
||||||
|
if name not in [cat_record["name"] for cat_record in self.categories]:
|
||||||
|
self.categories.append(
|
||||||
|
{"name": name, "count": 1, "last_updated": timestamp}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for cat_record in self.categories:
|
||||||
|
if cat_record["name"] == name:
|
||||||
|
cat_record["count"] += 1
|
||||||
|
if cat_record["last_updated"] < timestamp:
|
||||||
|
cat_record["last_updated"] = timestamp
|
||||||
|
|
223
linkulator
223
linkulator
|
@ -4,7 +4,6 @@
|
||||||
## If this script contains bugs, blame cmccabe.
|
## If this script contains bugs, blame cmccabe.
|
||||||
|
|
||||||
import getpass
|
import getpass
|
||||||
import os
|
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
@ -15,60 +14,25 @@ from shutil import which
|
||||||
|
|
||||||
import data
|
import data
|
||||||
import config
|
import config
|
||||||
import posts
|
|
||||||
from styling import style_text
|
|
||||||
|
|
||||||
|
|
||||||
## id (if parent), username, datestamp, parent-id, category, link-url, link-title
|
## id (if parent), username, datestamp, parent-id, category, link-url, link-title
|
||||||
link_data: list = []
|
LinkData = data.LinkData()
|
||||||
categories: list = []
|
link_data: list = LinkData.link_data
|
||||||
category_counts: dict = {}
|
categories: list = LinkData.categories
|
||||||
|
|
||||||
|
|
||||||
def graceful_exit():
|
def print_categories():
|
||||||
"""Prints a nice message, performs cleanup and then exits"""
|
|
||||||
print("\n\nThank you for linkulating. Goodbye.\n")
|
|
||||||
config.USER.save()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_ignore_file() -> list:
|
|
||||||
"""reads the current user's ignore file, returns a list of usernames to ignore"""
|
|
||||||
ignore_names: list = []
|
|
||||||
if config.USER.ignorefile.exists():
|
|
||||||
_s = config.USER.ignorefile.read_text()
|
|
||||||
_l = _s.splitlines()
|
|
||||||
for line in _l:
|
|
||||||
name = line.split(" ")[0]
|
|
||||||
ignore_names.append(name)
|
|
||||||
return ignore_names
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_data(ignore_names):
|
|
||||||
"""fetches data from the file store, assigning it to the relevant global variables"""
|
|
||||||
global link_data
|
|
||||||
global categories
|
|
||||||
global category_counts
|
|
||||||
|
|
||||||
link_data, categories, category_counts = data.get(config, ignore_names)
|
|
||||||
if len(link_data) == 0:
|
|
||||||
print("It looks link there are no links yet. Run 'linkulator -p' to add one.")
|
|
||||||
graceful_exit()
|
|
||||||
|
|
||||||
|
|
||||||
def print_categories(categories):
|
|
||||||
"""Prints the list of categories with an indicator for new activity"""
|
"""Prints the list of categories with an indicator for new activity"""
|
||||||
print("\n{:>4s} New {:<25s}".format("ID#", "Category"))
|
print("\n{:>4s} New {:<25s}".format("ID#", "Category"))
|
||||||
for i, cat in enumerate(categories):
|
|
||||||
new_links = [
|
for i, record in enumerate(categories):
|
||||||
1
|
|
||||||
for line in link_data
|
|
||||||
if line[2] >= config.USER.lastlogin and cat == line[4]
|
|
||||||
]
|
|
||||||
count = len(new_links)
|
|
||||||
print(
|
print(
|
||||||
"{:4d} {} {} ({})".format(
|
"{:4d} {} {} ({})".format(
|
||||||
i + 1, "x" if count else " ", cat, category_counts[cat]
|
i + 1,
|
||||||
|
"x" if record["last_updated"] >= config.USER.lastlogin else " ",
|
||||||
|
record["name"],
|
||||||
|
record["count"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -84,7 +48,7 @@ def print_category_details(view_cat):
|
||||||
thread_index = {}
|
thread_index = {}
|
||||||
|
|
||||||
for line in link_data:
|
for line in link_data:
|
||||||
if line[4] == view_cat:
|
if line[4] == view_cat["name"]:
|
||||||
link_count += 1
|
link_count += 1
|
||||||
thread_index[link_count] = str(line[0])
|
thread_index[link_count] = str(line[0])
|
||||||
parent_id = line[1] + "+" + str(line[2])
|
parent_id = line[1] + "+" + str(line[2])
|
||||||
|
@ -164,7 +128,7 @@ def view_link_in_browser(url):
|
||||||
subprocess.call([config.USER.browser, url])
|
subprocess.call([config.USER.browser, url])
|
||||||
|
|
||||||
|
|
||||||
def reply(owner, tstamp):
|
def reply(parent_user, parent_timestamp):
|
||||||
"""Prompt for reply, validate input, save validated input to disk and update
|
"""Prompt for reply, validate input, save validated input to disk and update
|
||||||
link_data. Calls view_thread when complete."""
|
link_data. Calls view_thread when complete."""
|
||||||
while True:
|
while True:
|
||||||
|
@ -172,42 +136,92 @@ def reply(owner, tstamp):
|
||||||
if comment == "":
|
if comment == "":
|
||||||
input("Reply aborted. Hit [Enter] to continue.")
|
input("Reply aborted. Hit [Enter] to continue.")
|
||||||
break
|
break
|
||||||
elif not posts.is_valid(comment):
|
if not is_valid_input(comment):
|
||||||
print(
|
print(
|
||||||
"Entries consisting of whitespace, or containing pipes, '|', are "
|
"Entries consisting of whitespace, or containing pipes, '|', are "
|
||||||
"not valid.Please try again."
|
"not valid.Please try again."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if os.path.exists(config.USER.datafile):
|
record = data.LinkDataRecord(
|
||||||
append_write = "a" # append if already exists
|
username=getpass.getuser(),
|
||||||
else:
|
timestamp=str(time.time()),
|
||||||
append_write = "w+" # make a new file if not
|
parent_id="{}+{}".format(parent_user, parent_timestamp),
|
||||||
with open(config.USER.datafile, append_write) as file:
|
link_title_or_comment=comment,
|
||||||
timestamp = str(time.time())
|
|
||||||
file.write(
|
|
||||||
timestamp + "|" + owner + "+" + tstamp + "|||" + comment + "\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
link_data.insert(
|
|
||||||
0,
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
getpass.getuser(),
|
|
||||||
timestamp,
|
|
||||||
owner + "+" + tstamp,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
comment,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
LinkData.add(record)
|
||||||
input("Reply added. Hit [Enter] to return to thread.")
|
input("Reply added. Hit [Enter] to return to thread.")
|
||||||
break
|
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):
|
def search(keyword):
|
||||||
"""Search function - not yet complete"""
|
"""Search function - not yet complete"""
|
||||||
print("Doesn't work yet. Would be searching title, category, comment for ", keyword)
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
## PSEUDOCODE:
|
## PSEUDOCODE:
|
||||||
|
@ -227,11 +241,14 @@ def search(keyword):
|
||||||
## if next_step...
|
## if next_step...
|
||||||
|
|
||||||
|
|
||||||
def menu_view_categories(categories):
|
## CONTROLS
|
||||||
|
|
||||||
|
|
||||||
|
def menu_view_categories():
|
||||||
"""Displays list of categories, takes keyboard input and
|
"""Displays list of categories, takes keyboard input and
|
||||||
executes corresponding functions."""
|
executes corresponding functions."""
|
||||||
while True:
|
while True:
|
||||||
print_categories(categories)
|
print_categories()
|
||||||
|
|
||||||
view_cat = input(
|
view_cat = input(
|
||||||
"\nEnter a category ID, {} to post a link, or {} to quit: ".format(
|
"\nEnter a category ID, {} to post a link, or {} to quit: ".format(
|
||||||
|
@ -243,7 +260,7 @@ def menu_view_categories(categories):
|
||||||
return
|
return
|
||||||
if view_cat == "p":
|
if view_cat == "p":
|
||||||
# pass a copy of categories so it is not modified
|
# pass a copy of categories so it is not modified
|
||||||
posts.post_link(categories[:])
|
post_link()
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
cat_index = categories[int(view_cat) - 1]
|
cat_index = categories[int(view_cat) - 1]
|
||||||
|
@ -259,7 +276,7 @@ def menu_view_category_details(cat_index):
|
||||||
while True:
|
while True:
|
||||||
thread_index = print_category_details(cat_index)
|
thread_index = print_category_details(cat_index)
|
||||||
|
|
||||||
pid = input(
|
option = input(
|
||||||
"Enter a post ID to see its thread, {} to go back, {} to "
|
"Enter a post ID to see its thread, {} to go back, {} to "
|
||||||
"post a link, or {} to quit: ".format(
|
"post a link, or {} to quit: ".format(
|
||||||
style_text("m", "underline"),
|
style_text("m", "underline"),
|
||||||
|
@ -268,18 +285,18 @@ def menu_view_category_details(cat_index):
|
||||||
)
|
)
|
||||||
).lower()
|
).lower()
|
||||||
|
|
||||||
if pid == "q":
|
if option == "q":
|
||||||
graceful_exit()
|
graceful_exit()
|
||||||
if pid == "m":
|
if option == "m":
|
||||||
return
|
return
|
||||||
if pid == "p":
|
if option == "p":
|
||||||
posts.post_link(categories)
|
post_link()
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
link = thread_index[int(pid)]
|
link = thread_index[int(option)]
|
||||||
menu_view_thread_details(link)
|
menu_view_thread_details(link)
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
# Catch a pid that is not in the thread list or is not a number
|
# 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")))
|
print("{}\n\n".format(style_text("Invalid category ID/entry", "bold")))
|
||||||
|
|
||||||
|
|
||||||
|
@ -295,19 +312,41 @@ def menu_view_thread_details(post_id):
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
parent_user, parent_timestamp, url = print_thread_details(post_id)
|
parent_user, parent_timestamp, url = print_thread_details(post_id)
|
||||||
next_step = input(next_text).lower()
|
option = input(next_text).lower()
|
||||||
if next_step == "m":
|
if option == "m":
|
||||||
return
|
return
|
||||||
if next_step == "b":
|
if option == "b":
|
||||||
view_link_in_browser(url)
|
view_link_in_browser(url)
|
||||||
elif next_step == "r":
|
elif option == "r":
|
||||||
reply(parent_user, parent_timestamp)
|
reply(parent_user, parent_timestamp)
|
||||||
elif next_step == "q":
|
elif option == "q":
|
||||||
graceful_exit()
|
graceful_exit()
|
||||||
else:
|
else:
|
||||||
print("{}\n\n".format(style_text("Invalid entry", "bold")))
|
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():
|
def print_banner():
|
||||||
"""prints a banner"""
|
"""prints a banner"""
|
||||||
print(" ----------")
|
print(" ----------")
|
||||||
|
@ -315,6 +354,13 @@ def print_banner():
|
||||||
print(" ----------")
|
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):
|
def signal_handler(sig, frame):
|
||||||
"""handle signals, exiting on SIGINT"""
|
"""handle signals, exiting on SIGINT"""
|
||||||
graceful_exit()
|
graceful_exit()
|
||||||
|
@ -327,8 +373,7 @@ def main():
|
||||||
config.init()
|
config.init()
|
||||||
if not args:
|
if not args:
|
||||||
print_banner()
|
print_banner()
|
||||||
fetch_data(parse_ignore_file())
|
menu_view_categories()
|
||||||
menu_view_categories(categories)
|
|
||||||
elif args[0] in ["-h", "--help", "help"]:
|
elif args[0] in ["-h", "--help", "help"]:
|
||||||
print(HELP_TEXT)
|
print(HELP_TEXT)
|
||||||
else:
|
else:
|
||||||
|
|
95
posts.py
95
posts.py
|
@ -1,95 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""handle the linkulator post process"""
|
|
||||||
import getpass
|
|
||||||
import os
|
|
||||||
from time import time
|
|
||||||
|
|
||||||
import config
|
|
||||||
from styling import style_text
|
|
||||||
|
|
||||||
USERNAME = getpass.getuser()
|
|
||||||
|
|
||||||
|
|
||||||
class Link:
|
|
||||||
"""represents a single link's data"""
|
|
||||||
|
|
||||||
url: str
|
|
||||||
category: str
|
|
||||||
title: str
|
|
||||||
timestamp: str
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid(entry: str) -> bool:
|
|
||||||
"""Determine validity of an input string
|
|
||||||
|
|
||||||
>>> is_valid("valid")
|
|
||||||
True
|
|
||||||
>>> is_valid("Not|valid")
|
|
||||||
False
|
|
||||||
>>> is_valid(" ")
|
|
||||||
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(i):
|
|
||||||
return i
|
|
||||||
print(
|
|
||||||
"Entries consisting of whitespace, or containing pipes, '|', are "
|
|
||||||
"not valid.Please try again."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def save_link(link):
|
|
||||||
"""Saves the specified link data to the user's data file"""
|
|
||||||
if os.path.exists(config.USER.datafile):
|
|
||||||
append_write = "a" # append if already exists
|
|
||||||
else:
|
|
||||||
append_write = "w+" # make a new file if not
|
|
||||||
with open(config.USER.datafile, append_write) as file:
|
|
||||||
file.write(
|
|
||||||
link.timestamp
|
|
||||||
+ "||"
|
|
||||||
+ link.category
|
|
||||||
+ "|"
|
|
||||||
+ link.url
|
|
||||||
+ "|"
|
|
||||||
+ link.title
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
print("Link added!")
|
|
||||||
|
|
||||||
|
|
||||||
def post_link(categories: list):
|
|
||||||
"""Handles the link posting process"""
|
|
||||||
|
|
||||||
link = Link()
|
|
||||||
category_text = "None" if not categories else ", ".join(sorted(categories))
|
|
||||||
|
|
||||||
print("\nEnter link information here. Leaving any field blank aborts " "post.\n")
|
|
||||||
|
|
||||||
try:
|
|
||||||
link.url = get_input("URL")
|
|
||||||
print(
|
|
||||||
"Available categories: {}\n"
|
|
||||||
"♻️ Please help keep Linkulator tidy".format(category_text)
|
|
||||||
)
|
|
||||||
link.category = get_input("Category")
|
|
||||||
link.title = get_input("Title")
|
|
||||||
except ValueError:
|
|
||||||
print("Post cancelled")
|
|
||||||
return
|
|
||||||
link.timestamp = str(time())
|
|
||||||
|
|
||||||
save_link(link)
|
|
19
styling.py
19
styling.py
|
@ -1,19 +0,0 @@
|
||||||
"""Handles terminal text styles"""
|
|
||||||
|
|
||||||
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. (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
|
|
||||||
|
|
||||||
|
|
109
tests/data_test.py
Normal file
109
tests/data_test.py
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
"""unit tests for the data module"""
|
||||||
|
import unittest
|
||||||
|
from time import time
|
||||||
|
import data
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataHelperFunctions(unittest.TestCase):
|
||||||
|
"""Tests that cover helper functions, mostly handling string validation"""
|
||||||
|
def test_wash_line(self):
|
||||||
|
"""tests the data.wash_line function"""
|
||||||
|
teststrings = [
|
||||||
|
{"Test": "A line of text", "Result": "A line of text"},
|
||||||
|
{"Test": "A line of text\n", "Result": "A line of text"},
|
||||||
|
{"Test": "\033[95mPink text\033[0m", "Result": "Pink text"},
|
||||||
|
{"Test": "🚅\t\n", "Result": "🚅"},
|
||||||
|
{
|
||||||
|
"Test": "gemini://gemini.circumlunar.space",
|
||||||
|
"Result": "gemini://gemini.circumlunar.space",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for line in teststrings:
|
||||||
|
self.assertEqual(data.wash_line(line["Test"]), line["Result"])
|
||||||
|
|
||||||
|
def test_is_well_formed_line(self):
|
||||||
|
""" tests the data.is_well_formed_line function"""
|
||||||
|
teststrings = [
|
||||||
|
{"Test": "A line of text", "Result": False},
|
||||||
|
{"Test": "1 Pipe |", "Result": False},
|
||||||
|
{"Test": "4 Pipes ||||", "Result": True},
|
||||||
|
{"Test": "5 Pipes |||||", "Result": False},
|
||||||
|
{"Test": "|P|I|P|E|H|E|A|V|E|N||||||||||", "Result": False},
|
||||||
|
]
|
||||||
|
for line in teststrings:
|
||||||
|
self.assertEqual(data.is_well_formed_line(line["Test"]), line["Result"])
|
||||||
|
|
||||||
|
def test_is_valid_time(self):
|
||||||
|
"""tests the data.is_valid_time function"""
|
||||||
|
teststrings = [
|
||||||
|
{"Test": "946645140.0", "Result": True}, # 1999
|
||||||
|
{"Test": str(time() + 10), "Result": False},
|
||||||
|
]
|
||||||
|
for line in teststrings:
|
||||||
|
self.assertEqual(data.is_valid_time(line["Test"]), line["Result"])
|
||||||
|
|
||||||
|
def test_process(self):
|
||||||
|
"""tests the data.process function"""
|
||||||
|
# wash line
|
||||||
|
self.assertEqual(
|
||||||
|
data.process("\t|\033[95mPink text\033[0m|||\n", ""),
|
||||||
|
["", "", "Pink text", "", "", ""],
|
||||||
|
)
|
||||||
|
|
||||||
|
# is well formed line
|
||||||
|
with self.assertRaises(ValueError, msg="Not a well formed line"):
|
||||||
|
data.process("|||||\n", "")
|
||||||
|
|
||||||
|
# is valid date
|
||||||
|
with self.assertRaises(ValueError, msg="Invalid date"):
|
||||||
|
data.process("{}||||".format(str(time() + 10)), "")
|
||||||
|
|
||||||
|
# real data tests
|
||||||
|
teststrings_input = [
|
||||||
|
"1576123922.106229|user+1576032469.7391915|||a new reply\n",
|
||||||
|
"1576137798.4647715|user+1576032469.7391915|||Here is another new reply\n",
|
||||||
|
"575968281.7483418||tildes|gopher://tilde.town|Tilde Town\n",
|
||||||
|
"1575969313.8278663||tildes|http://tilde.team|Tilde Team\n",
|
||||||
|
]
|
||||||
|
teststrings_output = [
|
||||||
|
[
|
||||||
|
"username0",
|
||||||
|
"1576123922.106229",
|
||||||
|
"user+1576032469.7391915",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"a new reply",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"username1",
|
||||||
|
"1576137798.4647715",
|
||||||
|
"user+1576032469.7391915",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"Here is another new reply",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"username2",
|
||||||
|
"575968281.7483418",
|
||||||
|
"",
|
||||||
|
"tildes",
|
||||||
|
"gopher://tilde.town",
|
||||||
|
"Tilde Town",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"username3",
|
||||||
|
"1575969313.8278663",
|
||||||
|
"",
|
||||||
|
"tildes",
|
||||||
|
"http://tilde.team",
|
||||||
|
"Tilde Team",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
for i, item in enumerate(teststrings_input):
|
||||||
|
self.assertEqual(
|
||||||
|
data.process(item, "username{}".format(i)), teststrings_output[i]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
Loading…
Reference in New Issue
Block a user