#!/usr/bin/python3 import asyncio, os, sys, tempfile from datetime import datetime import time from tabulate import tabulate from colorama import Fore, Style import api, lib.util async def add_task(task_args, server_name, port): task = { "title": None, "tags": [], "desc": None, "assign": [], "project": [], "due": None, "rank": None, "created_at": lib.util.now(), "state": "open" } # Everything that isn't an attribute is part of the title # Open text editor if desc isn't set to write desc text title_words = [] for arg in task_args: if arg[0] == "+": tag = arg if tag in task["tags"]: print(f"error: duplicate tag {tag} in task", file=sys.stderr) sys.exit(-1) task["tags"].append(tag) elif arg[0] == "@": assign = arg if assign in task["assign"]: print(f"error: duplicate assign {assign} in task", file=sys.stderr) sys.exit(-1) task["assign"].append(assign) elif ":" in arg: attr, val = arg.split(":", 1) set_task_attr(task, attr, val) else: title_words.append(arg) title = " ".join(title_words) if len(title) == 0: print("Error: Title is required") exit(-1) task["title"] = title if task["desc"] is None: task["desc"] = prompt_description_text(task) if task["desc"].strip() == '': print("Abort adding the task due to empty description.") exit(-1) if task["rank"] is not None: task["rank"] = round(task["rank"], 4) try: if task["ref_id"].strip() == '': task.pop('ref_id') if task["workspace"].strip() == '': task.pop('workspace') except KeyError: pass if await api.add_task(task, server_name, port): return title def prompt_text(comment_lines): temp = tempfile.NamedTemporaryFile() temp.write(b"\n") for line in comment_lines: temp.write(line.encode() + b"\n") temp.flush() editor = os.environ.get('EDITOR') if os.environ.get('EDITOR') else 'nano' os.system(f"{editor} {temp.name}") desc = open(temp.name, "r").read() # Remove comments and empty lines from desc cleaned = [] for line in desc.split("\n"): if line == "# ------------------------ >8 ------------------------": break if line.startswith("#"): continue cleaned.append(line) return "\n".join(cleaned) def prompt_description_text(task): return prompt_text([ "# Write task description above this line.", "# These lines will be removed.", "# An empty description aborts adding the task", "\n# ------------------------ >8 ------------------------", "# Do not modify or remove the line above.", "# Everything below it will be ignored.", f"\n{tabulate_task(task, True)}" ]) def prompt_comment_text(): return prompt_text([ "# Write comments above this line", "# These lines will be removed" ]) def set_task_attr(task, attr, val): # templ = lib.util.task_template assert attr in ["desc", "rank", "due", "project"] # assert templ[attr] != list if val.lower() == "none": task[attr] = None else: val = convert_attr_val(attr, val) task[attr] = val lib.util._enforce_task_format(task) def convert_attr_val(attr, val): templ = lib.util.task_template if attr in ["desc", "title"]: assert templ[attr] == str return val elif attr == "rank": try: return float(val) except ValueError: print(f"error: rank value {val} isn't convertable to float", file=sys.stderr) sys.exit(-1) elif attr == "due": # Other date formats not yet supported... ez to add assert len(val) == 4 date = datetime.now().date() year = int(date.strftime("%Y"))%100 try: dt = datetime.strptime(f"18:00 {val}{year}", "%H:%M %d%m%y") except ValueError: print(f"error: unknown date format {val}") sys.exit(-1) due = lib.util.datetime_to_unix(dt) return due elif attr == "project": try: return [val] except ValueError: print(f"error: project value {val} isn't convertable to list", file=sys.stderr) sys.exit(-1) else: print(f"error: unhandled attr '{attr}' = {val}") sys.exit(-1) async def show_active_tasks(workspace, server_name, port): refids = await api.get_ref_ids(server_name, port) tasks = [] for refid in refids: tasks.append(await api.fetch_task(refid, server_name, port)) list_tasks(tasks, workspace, []) async def show_deactive_tasks(month_ts, workspace, server_name, port): tasks = await api.fetch_deactive_tasks(month_ts, server_name, port) list_tasks(tasks, workspace, []) def list_tasks(tasks, workspace, filters): print(f"Workspace: {workspace}") headers = ["ID", "Title", "Status", "Project", "Tags", "assign", "Rank", "Due", "RefID"] table_rows = [] for id, task in enumerate(tasks, 1): if task is None: continue if is_filtered(task, filters): continue ref_id = task["ref_id"][:6] title = task["title"] status = task["state"] # project = task["project"] if task["project"] is not None else "" tags = " ".join(f"+{tag}" for tag in task["tags"]) assign = " ".join(f"@{assign}" for assign in task["assign"]) project = " ".join(f"{project}" for project in task["project"]) if task["due"] is None: due = "" else: dt = lib.util.unix_to_datetime(task["due"]) due = dt.strftime("%H:%M %d/%m/%y") rank = round(task["rank"], 4) if task["rank"] is not None else "" if status == "start": id = Fore.GREEN + str(id) + Style.RESET_ALL title = Fore.GREEN + str(title) + Style.RESET_ALL status = Fore.GREEN + str(status) + Style.RESET_ALL project = Fore.GREEN + str(project) + Style.RESET_ALL tags = Fore.GREEN + str(tags) + Style.RESET_ALL assign = Fore.GREEN + str(assign) + Style.RESET_ALL rank = Fore.GREEN + str(rank) + Style.RESET_ALL due = Fore.GREEN + str(due) + Style.RESET_ALL ref_id = Fore.GREEN + str(ref_id) + Style.RESET_ALL elif status == "pause": id = Fore.YELLOW + str(id) + Style.RESET_ALL title = Fore.YELLOW + str(title) + Style.RESET_ALL status = Fore.YELLOW + str(status) + Style.RESET_ALL project = Fore.YELLOW + str(project) + Style.RESET_ALL tags = Fore.YELLOW + str(tags) + Style.RESET_ALL assign = Fore.YELLOW + str(assign) + Style.RESET_ALL rank = Fore.YELLOW + str(rank) + Style.RESET_ALL due = Fore.YELLOW + str(due) + Style.RESET_ALL ref_id = Fore.YELLOW + str(ref_id) + Style.RESET_ALL elif status == "stop": id = Fore.RED + str(id) + Style.RESET_ALL title = Fore.RED + str(title) + Style.RESET_ALL status = Fore.RED + str(status) + Style.RESET_ALL project = Fore.RED + str(project) + Style.RESET_ALL tags = Fore.RED + str(tags) + Style.RESET_ALL assign = Fore.RED + str(assign) + Style.RESET_ALL rank = Fore.RED + str(rank) + Style.RESET_ALL due = Fore.RED + str(due) + Style.RESET_ALL ref_id = Fore.RED + str(ref_id) + Style.RESET_ALL else: #id = Style.DIM + str(id) + Style.RESET_ALL #title = Style.DIM + str(title) + Style.RESET_ALL #status = Style.DIM + str(status) + Style.RESET_ALL project = Style.DIM + str(project) + Style.RESET_ALL tags = Style.DIM + str(tags) + Style.RESET_ALL #assign = Style.DIM + str(assign) + Style.RESET_ALL rank = Style.DIM + str(rank) + Style.RESET_ALL due = Style.DIM + str(due) + Style.RESET_ALL #ref_id = Style.DIM + str(ref_id) + Style.RESET_ALL rank_value = task["rank"] if task["rank"] is not None else 0 row = [ id, title, status, project, tags, assign, rank, due, ref_id ] table_rows.append((rank_value, row)) table = [row for (_, row) in sorted(table_rows, key=lambda item: item[0], reverse=True)] print(tabulate(table, headers=headers)) async def show_task(refid, server_name, port): task = await api.fetch_task(refid, server_name, port) task_table(task) return 0 async def show_archive_task(ref_id, month_ts, server_name, port): task = await api.fetch_archive_task(ref_id, month_ts, server_name, port) task_table(task) return 0 def tabulate_task(task, prompt): tags = " ".join(f"+{tag}" for tag in task["tags"]) assign = " ".join(f"@{assign}" for assign in task["assign"]) project = " ".join(f"{project}" for project in task["project"]) rank = round(task["rank"], 4) if task["rank"] is not None else "" if task["due"] is None: due = "" else: dt = lib.util.unix_to_datetime(task["due"]) due = dt.strftime("%H:%M %d/%m/%y") assert task["created_at"] is not None dt = lib.util.unix_to_datetime(task["created_at"]) created_at = dt.strftime("%H:%M %d/%m/%y") if prompt: task["ref_id"] = '' task["workspace"] = '' table = [ ["RefID:", task["ref_id"]], ["Title:", task["title"]], ["Workspace:", task["workspace"]], ["Description:", task["desc"]], ["Status:", task["state"]], ["Project:", project], ["Tags:", tags], ["Assign:", assign], ["Rank:", rank], ["Due:", due], ["Created:", created_at], ] return tabulate(table, headers=["Attribute", "Value"]) def task_table(task): print(tabulate_task(task, False)) table = [] for event in task["events"]: act, who, when, args = event["action"], event["author"], event["timestamp"], event["content"] when = lib.util.unix_to_datetime(when) when = when.strftime("%H:%M %d/%m/%y") if act == "due" and when is not None: table.append([ Style.DIM + f"{who} changed {act} to {when}" + Style.RESET_ALL, "", Style.DIM + when + Style.RESET_ALL ]) elif act == "tags" or act == "assign": val = f"{args}" event = f"{who} added {val} to {act}" if val[0] == "-": event = f"{who} removed {val} from {act}" table.append([ Style.DIM + event + Style.RESET_ALL, "", Style.DIM + when + Style.RESET_ALL ]) elif act == "state": status = args if status == "pause": status_verb = "paused" elif status in ["start", "open"]: status_verb = f"{status}ed" elif status == "stop": status_verb = f"stopped" else: print(f"internal error: unhandled task state {status}", file=sys.stderr) sys.exit(-2) table.append([ f"{who} {status_verb} task", "", Style.DIM + when + Style.RESET_ALL ]) elif act == "comment": continue else: table.append([ Style.DIM + f"{who} changed {act} to {args}" + Style.RESET_ALL, "", Style.DIM + when + Style.RESET_ALL ]) print(tabulate(table)) table = [] for event in task['events']: act, who, when, args = event["action"], event["author"], event["timestamp"], event["content"] when = lib.util.unix_to_datetime(when) when = when.strftime("%H:%M %d/%m/%y") if act == "comment": comment = args table.append([ f"{who}>", wrap_comment(comment, 58), Style.DIM + when + Style.RESET_ALL ]) if len(table) > 0: print("Comments:") print(tabulate(table)) def wrap_comment(comment, width): lines = [] line_start = 0 for i, char in enumerate(comment): if char == ' ' and (i - line_start >= width): lines.append(comment[line_start:i + 1]) line_start = i + 1 if line_start < len(comment): lines.append(comment[line_start:]) return '\n'.join(lines) async def modify_task(refid, args, server_name, port): changes = {} for arg in args: # This must go before the next elif block if arg.startswith("@") or arg.startswith("-@"): changes["assign"] = [] changes["assign"].append(arg) elif arg.startswith("+") or arg.startswith("-"): changes["tags"] = [] changes["tags"].append(arg) elif ":" in arg: attr, val = arg.split(":", 1) if val.lower() == "none": if attr not in ["project", "rank", "due"]: print(f"error: invalid you cannot set {attr} to none", file=sys.stderr) return -1 val = None else: val = convert_attr_val(attr, val) changes[str(attr)] = val else: print(f"warning: unknown arg '{arg}'. Skipping...", file=sys.stderr) await api.modify_task(refid, changes, server_name, port) return 0 async def change_task_status(refid, status, server_name, port): task = await api.fetch_task(refid, server_name, port) assert task is not None title = task["title"] if not await api.change_task_status(refid, status, server_name, port): return -1 if status == "start": print(f"Started task '{title}'") elif status == "pause": print(f"Paused task '{title}'") elif status == "stop": print(f"Completed task '{title}'") elif status == "open": print(f"Opened task '{title}'") return 0 async def comment(refid, args, server_name, port): if not args: comment = prompt_comment_text() else: comment = " ".join(args) if not await api.add_task_comment(refid, comment, server_name, port): return -1 # Two json rpcs back to back cause Unexpected EOF error time.sleep(0.1) task = await api.fetch_task(refid, server_name, port) assert task is not None title = task["title"] print(f"Commented on task'{title}'") return 0 def is_filtered(task, filters): for fltr in filters: if fltr.startswith("+"): tag = fltr[1:] if tag not in task["tags"]: return True elif fltr.startswith("@"): assign = fltr[1:] if assign not in task["assign"]: return True elif ":" in fltr: attr, val = fltr.split(":", 1) if val.lower() == "none": if attr not in ["project", "rank", "due"]: print(f"error: invalid you cannot set {attr} to none", file=sys.stderr) sys.exit(-1) if task[attr] is not None: return True elif attr == "state" : if val not in ["open", "start", "pause"]: print(f"error: invalid, filter by {attr} can only be [\"open\", \"start\", \"pause\"]", file=sys.stderr) sys.exit(-1) if task["state"] != val: return True else: val = convert_attr_val(attr, val) if task[attr] != val: return True else: print(f"error: unknown arg '{fltr}'", file=sys.stderr) sys.exit(-1) return False def find_free_id(task_ids): for i in range(1, 1000): if i not in task_ids: return i 1 def map_ids(task_ids, ref_ids): return dict(zip(task_ids, ref_ids)) async def main(): val = str('127.0.0.1:23330') allowed_states = ["start", "pause", "stop", "open"] for i in range(1, len(sys.argv)): if sys.argv[i] == "-e": val = sys.argv[i+1] del sys.argv[i] del sys.argv[i] break server_name, port = val.split(':') refids = await api.get_ref_ids(server_name, port) free_ids = [] tasks = [] for refid in refids: tasks.append(await api.fetch_task(refid, server_name, port)) free_ids.append(find_free_id(free_ids)) data = map_ids(free_ids, refids) workspace = await api.get_workspace(server_name, port) if len(sys.argv) == 1: await show_active_tasks(workspace, server_name, port) return 0 if any(x in ["-h", "--help", "help"] for x in sys.argv): print('''USAGE: tau [OPTIONS] [SUBCOMMAND] OPTIONS: -h, --help Print help information SUBCOMMANDS: add Add a new task. archive Show completed tasks. comment Write comment for task by id. modify Modify an existing task by id. pause Pause task(s). start Start task(s). stop Stop task(s). switch Switch between configured workspaces. show List filtered tasks. help Show this help text. Examples: tau add task one due:0312 rank:1.022 project:zk +lol @sk desc:desc +abc +def tau add task two rank:1.044 project:cr +mol @up desc:desc2 tau add task three due:0512 project:zy +trol @kk desc:desc3 +who tau 1 modify @upgr due:1112 rank:none tau 1 modify -@up tau 1 modify -mol -xx tau 1,2 modify +dev @erto tau 1-3 start tau 1 comment "this is an awesome comment" tau 2 pause tau show @erto state:start # list started tasks that are assigned to 'erto' tau show +dev project:zk # list tasks with 'dev' tag project 'zk' tau switch darkfi # switch to configured 'darkfi' workspace tau archive # current month's completed tasks tau archive 1122 # completed tasks in Nov. 2022 tau archive 1 1122 # show info of task completed in Nov. 2022 ''') return 0 elif sys.argv[1] == "add": task_args = sys.argv[2:] title = await add_task(task_args, server_name, port) if title: print(f"Created task ({find_free_id(free_ids)}) '{title}'.") return 0 elif sys.argv[1] == "archive": if len(sys.argv) == 4: if len(sys.argv[3]) == 4: month = sys.argv[3] month_ts = lib.util.month_to_unix(month) else: print("error: month must be of format MMYY") return -1 archive_refids = await api.get_archive_ref_ids(month_ts, server_name, port) afree_ids = [] atasks = [] for arefid in archive_refids: atasks.append(await api.fetch_archive_task(arefid, month_ts, server_name, port)) afree_ids.append(find_free_id(afree_ids)) adata = map_ids(afree_ids, archive_refids) if len(sys.argv[2]) < 4: try: tid = int(sys.argv[2]) arefid = adata[tid] except (ValueError, KeyError): print("error: invalid ID", file=sys.stderr) return -1 else: print("error: invalid ID", file=sys.stderr) return -1 if (errc := await show_archive_task(arefid, month_ts, server_name, port)) < 0: return errc elif len(sys.argv) == 3: if len(sys.argv[2]) == 4: month = sys.argv[2] month_ts = lib.util.month_to_unix(month) await show_deactive_tasks(month_ts, workspace, server_name, port) elif len(sys.argv[2]) < 4: month_ts = lib.util.month_to_unix() archive_refids = await api.get_archive_ref_ids(month_ts, server_name, port) afree_ids = [] atasks = [] for arefid in archive_refids: atasks.append(await api.fetch_archive_task(arefid, month_ts, server_name, port)) afree_ids.append(find_free_id(afree_ids)) adata = map_ids(afree_ids, archive_refids) try: tid = int(sys.argv[2]) arefid = adata[tid] except (ValueError, KeyError): print("error: invalid ID", file=sys.stderr) return -1 if (errc := await show_archive_task(arefid, month_ts, server_name, port)) < 0: return errc else: print("error: usage format is: tau archive [ID] [MONTH]") return -1 else: month_ts = lib.util.month_to_unix() await show_deactive_tasks(month_ts, workspace, server_name, port) return 0 elif sys.argv[1] == "show": if len(sys.argv) > 2: filters = sys.argv[2:] list_tasks(tasks, workspace, filters) else: await show_active_tasks(workspace, server_name, port) return 0 elif sys.argv[1] == "switch": if not len(sys.argv) == 3: print("Error: you must provide workspace name") return 0 if not await api.switch_workspace(sys.argv[2], server_name, port): print(f"Error: Workspace \"{sys.argv[2]}\" is not configured.") else: print(f"You are now on \"{sys.argv[2]}\" workspace.") return 0 elif sys.argv[1] == "export": if len(sys.argv) == 2: path = "~/.local/darkfi" else: path = sys.argv[2] if await api.export_to(path, server_name, port): print(f"Exported tasks successfuly to {path}") return 0 elif sys.argv[1] == "import": if len(sys.argv) == 2: path = "~/.local/darkfi" else: path = sys.argv[2] if await api.import_from(path, server_name, port): print(f"Imported tasks successfuly from {path}") return 0 try: id = sys.argv[1] subcommands = ["modify", "comment"] if any(id in ls for ls in [allowed_states, subcommands]): user_input = input("This command has no filter, and will modify all tasks. Are you sure? [y/N] ") if user_input.lower() in ['y', 'yes']: refid = list(refids) args = sys.argv[1:] else: print("Command prevented from running.") exit(-1) elif any(id == rfid[:6] for rfid in refids): refid = [] for rid in refids: if id[:6] == rid[:6]: refid.append(rid) args = sys.argv[2:] else: lines = id.split(',') numbers = [] for line in lines: if line == '': continue elif '-' in line: t = line.split('-') numbers += range(int(t[0]), int(t[1]) + 1) else: numbers.append(int(line)) refid = [] for i in numbers: refid.append(data[i]) args = sys.argv[2:] except (ValueError, KeyError): print("error: invalid ID", file=sys.stderr) return -1 except EOFError: print('\nOperation is cancelled') return -1 if not args: for rid in refid: await show_task(rid, server_name, port) return 0 subcmd, args = args[0], args[1:] if subcmd == "modify": if not args: print("Error: modify subcommand must have at least one argument.") exit(-1) for rid in refid: if (errc := await modify_task(rid, args, server_name, port)) < 0: return errc time.sleep(0.1) await show_task(rid, server_name, port) elif subcmd in allowed_states: status = subcmd for rid in refid: if (errc := await change_task_status(rid, status, server_name, port)) < 0: return errc time.sleep(0.1) elif subcmd == "comment": for rid in refid: if (errc := await comment(rid, args, server_name, port)) < 0: return errc else: print(f"error: unknown subcommand '{subcmd}'") return -1 return 0 asyncio.run(main())