iris.py/iris.py

552 lines
18 KiB
Python
Executable File

#!/usr/bin/python3
import os, sys
import json
import shutil
from uuid import uuid4
from datetime import datetime, timezone
import base64
from hashlib import sha1
import argparse
import copy
HOMEDIR = f"/home/{os.getlogin()}"
HOST = "ctrl-c.club"
class IrisError(Exception):
...
class MalformedMessageObjectError(Exception):
...
class IrisNotImplementedError(Exception):
...
class Messages:
read = []
topics = []
messages = []
topics_latest = []
messages_latest = []
def __init__(self, corpus=None):
self.__update()
def __update(self):
"""update state of `corpus`, `read`, `topics` and `messages` lists"""
corpus = []
userlist = os.listdir('/home/')
for user in userlist:
try:
if '.iris.messages' in os.listdir(f'/home/{user}/'):
with open(f'/home/{user}/.iris.messages', 'r') as messages:
try:
userdata = json.loads(messages.read())
except json.decoder.JSONDecodeError:
print(f"{user}'s messages file is malformed")
continue
if userdata != []:
corpus += userdata
except (PermissionError, NotADirectoryError):
pass
# sort corpus in ascending order by timestamp
corpus = sorted(corpus, key=lambda k: k['data']['timestamp'])
# get hashes of read messages from the user's `.iris.messages.read`
with open(f"{HOMEDIR}/.iris.messages.read", "r") as f:
self.read = json.loads(f.read())
# topics and messages lists
for item in corpus:
if item["data"]["parent"] == None:
self.topics.append(item)
else:
self.messages.append(item)
for i, message in enumerate(self.messages): # message numbers (global, for replies)
message["mindex"] = i+1
self.topics_oldest_version = [item for item in self.topics if item["edit_hash"] == None]
self.messages_oldest_version = [item for item in self.messages if item["edit_hash"] == None]
for i, topic in enumerate(self.topics_oldest_version): # topic numbers
topic["index"] = i+1
def unread_topics(self):
"""returns a list of topics with unread messages"""
output = []
for topic in self.topics_oldest_version:
try:
topic_editchain = self.__get_editchain(topic["hash"])
topic_hashes = [msg["hash"] for msg in topic_editchain]
except MalformedMessageObjectError:
continue
unread_reply_count = 0
reply_count = 0
for message in self.messages:
if message["data"]["parent"] in topic_hashes:
if message["hash"] not in self.read:
unread_reply_count += 1
reply_count += 1
if unread_reply_count > 0:
output.append(topic_editchain[-1])
output[-1]["index"] = topic["index"] # copying over the index because topics don't have them globally
output[-1]["unread"] = unread_reply_count # saving unread count in the same manner
return output
def __get_message_obj_from_hash(self, msghash):
for msg in self.messages + self.topics:
if msg["hash"] == msghash:
return msg
return None
def __get_next_message_version(self, msghash):
for msg in self.messages + self.topics:
if msg["edit_hash"] == msghash:
return msg
return None
def __get_editchain(self, msghash):
chain = []
msg = self.__get_message_obj_from_hash(msghash)
chain.append(msg)
while msg != None:
msg = self.__get_next_message_version(msghash)
if msg:
chain.append(msg)
if msg["hash"] == msghash:
raise MalformedMessageObjectError(f"this message object (id: {repr(msghash)}) by user {msg['data']['author'].split('@')[0]} seems to be malformed")
msghash = msg["hash"]
else:
break
return chain
def replies_to(self, topic_hash):
replies = []
# get hashes for all versions of the topic
topic_edit_hashes = [msg["hash"] for msg in self.__get_editchain(topic_hash)]
# get oldest versions of all replies to all versions of the message
roots = [msg for msg in self.messages_oldest_version if msg["data"]["parent"] in topic_edit_hashes]
# get latest version of each reply and append to replies array
for msg in roots:
msghash = msg["hash"]
try:
editchain = self.__get_editchain(msghash)
except MalformedMessageObjectError:
continue
if not editchain:
replies.append(msg)
continue
replies.append(editchain[-1])
# return replies
return replies
def __get_edit_history(self, item):
# getting edit chain from "outside-in"
chain = []
msg = self.__get_message_obj_from_hash(item)
chain.append(msg)
while msg != None:
msg = self.__get_message_obj_from_hash(msg["edit_hash"])
if msg:
chain.append(msg)
if msg["hash"] == msg["edit_hash"]:
raise MalformedMessageObjectError(f"this message object (id: {repr(msghash)}) by user {msg['data']['author'].split('@')[0]} seems to be malformed")
else:
break
return chain
def mark_as_read(self, hashes):
# TODO, currently broken
for item in hashes:
try:
chain = self.__get_editchain(item)
# get hashes from chain
edit_hashes = [m["hash"] for m in chain]
self.read.append(edit_hashes[-1])
except MalformedMessageObjectError:
continue
#for h in edit_hashes:
# if h not in self.read:
# self.read.append(h)
with open(f"{HOMEDIR}/.iris.messages.read", "w") as f:
f.write(json.dumps(self.read, separators=(',',':')))
def mark_all_read(self):
# TODO, currently broken
"""mark all unread topics as read"""
unread_topic_hashes = [m["hash"] for m in self.unread_topics()]
all_reply_hashes = []
for topic in unread_topic_hashes:
replies = messages.replies_to(topic)
all_reply_hashes += [obj["hash"] for obj in replies]
self.mark_as_read(all_reply_hashes)
def get_hash_from_topic_no(self, num):
"""returns hash of latest version of topic from its edit chain"""
oldest_hash = self.topics_oldest_version[num - 1]["hash"]
return self.__get_editchain(oldest_hash)[-1]["hash"]
def __write_message(self, default=None) -> str:
"""launch an editor to write a message (with optional default value) and return message string"""
tmp_filename = "irispy_tmp_" + uuid4().hex
tmp_filepath = f"{HOMEDIR}/{tmp_filename}"
# if default value passed in, write it to the file
if default:
with open(tmp_filepath, 'w') as f:
f.write(default)
else:
os.system(f"touch {tmp_filepath}")
# open $EDITOR or nano to edit the file
with open(tmp_filepath, 'r+') as f:
editor = os.environ['EDITOR']
if editor == None:
editor = "nano -t"
os.system(f"{editor} {tmp_filepath}")
with open(tmp_filepath, 'r') as f:
content = f.read()
# delete temporary file
os.system(f"rm {tmp_filepath}")
return content
def __hash_message_object(self, parent, timestamp, content):
author = f"{os.getlogin()}@{HOST}"
hash_source = json.dumps({
"author": author,
"parent": parent,
"timestamp": timestamp,
"message": content
}, separators=(',', ':')
).encode()
message_hash = base64.b64encode(sha1(hash_source).digest())
return message_hash.decode('utf-8') + '\n'
def create_post(self, parent=None):
author = f"{os.getlogin()}@{HOST}"
content = self.__write_message()
if len(content) == 0:
raise IrisError("post cannot be empty")
# GMT Timestamp
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# Message SHA1 hash from schema defined in iris reference implementation
message_hash = self.__hash_message_object(parent, timestamp, content)
# create message object, append it to .iris.messages
message_obj = {
"hash": message_hash,
"edit_hash": None,
"is_deleted": None,
"data": {
"author": author,
"parent": parent,
"timestamp": timestamp,
"message": content
}
}
# confirmation
if input("save message? [Y/n] ") in ('N','n'):
print("canceled")
sys.exit(0)
# updating .iris.messages
user_messages = []
with open(f"{HOMEDIR}/.iris.messages", "r") as f:
user_messages = json.loads(f.read())
user_messages.append(message_obj)
with open(f"{HOMEDIR}/.iris.messages", "w") as f:
f.write(json.dumps(user_messages, separators=(',',':')))
print("message saved")
def edit_message(self, msgid: str):
# load user's messages
user_messages = []
with open(f"{HOMEDIR}/.iris.messages", "r") as f:
user_messages = json.loads(f.read())
# getting latest message / topic hash
message_hash = msgid
if type(msgid) is int:
message_hash = self.get_hash_from_topic_no(msgid)
elif type(msgid) is str:
try:
msg_index = int(msgid.split('M')[1])
except IndexError:
raise IrisError("invalid argument format")
message_hash = self.messages[msg_index - 1]["hash"]
else:
raise IrisError("invalid argument type")
# get message object to edit
old_message = {}
for msg in user_messages:
if msg["hash"] == message_hash:
old_message = msg
break
# raise error if post couldn't be found
if old_message == {}:
raise IrisError("couldn't find post in user's messages file")
# create new message object
new_message = copy.deepcopy(old_message)
# edit content
new_message["data"]["message"] = self.__write_message(default=new_message["data"]["message"])
# check if no edits
if new_message["data"]["message"] == old_message["data"]["message"]:
print("no edits performed")
sys.exit(0)
# re-hash
new_hash = self.__hash_message_object(
parent=new_message["data"]["parent"],
timestamp=new_message["data"]["timestamp"],
content=new_message["data"]["message"]
)
new_message["hash"] = new_hash
new_message["edit_hash"] = old_message["hash"]
# add new version of message
user_messages.append(new_message)
# confirmation
if input("save message? [Y/n] ") in ('N','n'):
print("canceled")
sys.exit(0)
# saving .iris.messages
with open(f"{HOMEDIR}/.iris.messages", "w") as f:
f.write(json.dumps(user_messages, separators=(',',':')))
print("message saved")
class Display:
def print_unread_feed(messages):
COLS = shutil.get_terminal_size((80, 20))[0]
max_namelen = max([len(msg["data"]["author"].split('@')[0]) for msg in messages])
tstamp_len = 10
max_numlen = 0
max_numlen = len(str(max([msg["index"] for msg in messages])))
max_unread_numlen = len(str(max([msg["unread"] for msg in messages])))
for msg in messages:
author = msg["data"]["author"].split('@')[0]
content_part = msg["data"]["message"].split('\n')[0].replace('\n','').replace('\t',' ')
tstamp = msg["data"]["timestamp"].split('T')[0]
topic_no = msg["index"]
unread_no = msg["unread"]
try:
if msg["is_deleted"]:
continue
except KeyError:
print("<malformed message object>")
continue
print(f"{topic_no}{' '*(max_numlen-len(str(topic_no)))} \x1b[33m{author}{' '*(max_namelen-len(author))}\033[00m [{str(unread_no) + ' '*(max_unread_numlen-len(str(unread_no)))}] \033[96m{tstamp}\033[00m {content_part[:(COLS-max_namelen-tstamp_len-max_numlen-max_unread_numlen-8)]}")
def print_as_feed(messages):
COLS = shutil.get_terminal_size((80, 20))[0]
max_namelen = max([len(msg["data"]["author"].split('@')[0]) for msg in messages])
tstamp_len = 10
max_numlen = len(str(max([msg["index"] for msg in messages])))
for msg in messages:
author = msg["data"]["author"].split('@')[0]
content_part = msg["data"]["message"].split('\n')[0].replace('\n','').replace('\t',' ')
tstamp = msg["data"]["timestamp"].split('T')[0]
topic_no = msg["index"]
try:
if msg["is_deleted"]:
#print(f" {topic_no}{' '*(max_numlen-len(str(topic_no)))} \x1b[33m{author}{' '*(max_namelen-len(author))}\033[00m \x1b[37m{tstamp}\033[00m DELETED BY AUTHOR")
continue
except KeyError:
print("<malformed message object>")
continue
print(f"{topic_no}{' '*(max_numlen-len(str(topic_no)))} \x1b[33m{author}{' '*(max_namelen-len(author))}\033[00m \033[96m{tstamp}\033[00m {content_part[:(COLS-max_namelen-tstamp_len-max_numlen-4)]}")
def print_message(msg, linecolor=False) -> None:
COLS = shutil.get_terminal_size((80, 20))[0]
text = msg["data"]["message"]
tstamp = msg["data"]["timestamp"].split('T')[0]
author = msg["data"]["author"].split('@')[0]
edited = "edited" if msg["edit_hash"] else ""
message_id = " "
try:
if author == os.getlogin():
message_id = f"[\033[96mM{msg['mindex']}\033[00m]"
except KeyError:
pass
space = COLS - (len(author) + len(tstamp) + 1) - 3
if msg["is_deleted"]:
space = COLS - (len(author) + len(tstamp) + 1) - 3 - 7
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m {'-'*space} \x1b[33mDELETED\033[00m")
return
if linecolor:
if edited:
space = COLS - (len(author) + len(tstamp) + len(edited) + len(message_id) + 3)
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m{message_id}\033[96m{'-'*space}\033[00m \033[96m{edited}\033[00m")
else:
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m \033[96m{'-'*space}\033[00m")
else:
if edited:
space = COLS - (len(author) + len(tstamp) + (len(message_id) + 1) + 1)
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m {message_id} {'-'*space} \033[96m{edited}\033[00m")
else:
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m {'-'*space}")
print(text)
if text[-1] != '\n':
print()
def view_topic(topic_no, messages):
COLS = shutil.get_terminal_size((80, 20))[0]
topic_no = int(topic_no)
requested_topic = messages.topics_oldest_version[int(topic_no) - 1]
if requested_topic["is_deleted"] != None:
print("This topic has been deleted by its author")
sys.exit(0)
replies = messages.replies_to(requested_topic["hash"])
messages.mark_as_read([x["hash"] for x in replies])
latest_topic_object = messages._Messages__get_editchain(requested_topic["hash"])[-1]
Display.print_message(latest_topic_object, linecolor=True)
print(f"\033[96m{'='*COLS}\033[00m")
for msg in replies:
Display.print_message(msg)
if __name__ == "__main__":
# parsing arguments
parser = argparse.ArgumentParser()
parser.add_argument("-u", "--unread", help="list topics with unread messages", action="store_true")
parser.add_argument("-t", "--topics", help="list all topics", action="store_true")
parser.add_argument("-p", "--post", help="create new topic", action="store_true")
parser.add_argument("-v", "--view", help="view topic N", metavar='N', nargs=1, type=lambda x: int(x) if int(x) > 0 else False)
parser.add_argument("-r", "--reply", help="reply to topic N", metavar='N', nargs=1, type=lambda x: int(x) if int(x) > 0 else False)
parser.add_argument("-e", "--edit", help="edit topic number `X` or message number M`X`", metavar='X', nargs=1, type=lambda x: int(x) if x.isnumeric() else x)
parser.add_argument("--mark-all-read", help="mark all topics as read", action="store_true")
# default behaviour: print help text
if not len(sys.argv) > 1:
parser.print_help()
sys.exit(0)
messages = Messages()
args = parser.parse_args()
if args.unread:
topics = messages.unread_topics()
if len(topics) == 0:
print("you're all caught up!")
else:
Display.print_unread_feed(messages.unread_topics())
sys.exit(0)
if args.topics:
Display.print_as_feed(messages.topics_oldest_version)
sys.exit(0)
if args.view:
topic_no = args.view[0]
if not topic_no:
raise IrisError("invalid topic number")
Display.view_topic(topic_no, messages)
sys.exit(0)
if args.mark_all_read:
messages.mark_all_read()
print("all topics marked read")
sys.exit(0)
if args.post:
messages.create_post()
sys.exit(0)
if args.reply:
topic_no = args.reply[0]
if not topic_no:
raise IrisError("invalid topic number")
messages.create_post(messages.get_hash_from_topic_no(topic_no))
sys.exit(0)
if args.edit:
messages.edit_message(args.edit[0])
sys.exit(0)