main.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. #!/usr/bin/python3
  2. import asyncio, json, os, sys, tempfile
  3. from datetime import datetime
  4. from tabulate import tabulate
  5. from colorama import Fore, Back, Style
  6. import api, lib.util
  7. # USERNAME = lib.config.get("username", "Anonymous")
  8. USERNAME = "Anonymous"
  9. async def add_task(task_args):
  10. task = {
  11. "title": None,
  12. "tags": [],
  13. "desc": None,
  14. "assign": [],
  15. "project": [],
  16. "due": None,
  17. "rank": None,
  18. "created_at": lib.util.now(),
  19. "state": "open"
  20. }
  21. # Everything that isn't an attribute is part of the title
  22. # Open text editor if desc isn't set to write desc text
  23. title_words = []
  24. for arg in task_args:
  25. if arg[0] == "+":
  26. tag = arg[1:]
  27. if tag in task["tags"]:
  28. print(f"error: duplicate tag +{tag} in task", file=sys.stderr)
  29. sys.exit(-1)
  30. task["tags"].append(tag)
  31. elif arg[0] == "@":
  32. assign = arg[1:]
  33. if assign in task["assign"]:
  34. print(f"error: duplicate assign @{assign} in task", file=sys.stderr)
  35. sys.exit(-1)
  36. task["assign"].append(assign)
  37. elif ":" in arg:
  38. attr, val = arg.split(":", 1)
  39. set_task_attr(task, attr, val)
  40. else:
  41. title_words.append(arg)
  42. title = " ".join(title_words)
  43. if len(title) == 0:
  44. print("Error: Title is required")
  45. exit(-1)
  46. task["title"] = title
  47. if task["desc"] is None:
  48. task["desc"] = prompt_description_text(task)
  49. if task["desc"].strip() == '':
  50. print("Abort adding the task due to empty description.")
  51. exit(-1)
  52. id = await api.add_task(task)
  53. print(f"Created task {id}.")
  54. def prompt_text(comment_lines):
  55. temp = tempfile.NamedTemporaryFile()
  56. temp.write(b"\n")
  57. for line in comment_lines:
  58. temp.write(line.encode() + b"\n")
  59. temp.flush()
  60. editor = os.environ.get('EDITOR') if os.environ.get('EDITOR') else 'nano'
  61. os.system(f"{editor} {temp.name}")
  62. desc = open(temp.name, "r").read()
  63. # Remove comments and empty lines from desc
  64. cleaned = []
  65. for line in desc.split("\n"):
  66. if line == "# ------------------------ >8 ------------------------":
  67. break
  68. if line.startswith("#"):
  69. continue
  70. cleaned.append(line)
  71. return "\n".join(cleaned)
  72. def prompt_description_text(task):
  73. return prompt_text([
  74. "# Write task description above this line.",
  75. "# These lines will be removed.",
  76. "# An empty description aborts adding the task",
  77. "\n# ------------------------ >8 ------------------------",
  78. "# Do not modify or remove the line above.",
  79. "# Everything below it will be ignored.",
  80. f"\n{tabulate_task(task)}"
  81. ])
  82. def prompt_comment_text():
  83. return prompt_text([
  84. "# Write comments above this line",
  85. "# These lines will be removed"
  86. ])
  87. def set_task_attr(task, attr, val):
  88. # templ = lib.util.task_template
  89. assert attr in ["desc", "rank", "due", "project"]
  90. print(attr)
  91. # assert templ[attr] != list
  92. if val.lower() == "none":
  93. task[attr] = None
  94. else:
  95. val = convert_attr_val(attr, val)
  96. task[attr] = val
  97. lib.util._enforce_task_format(task)
  98. def convert_attr_val(attr, val):
  99. templ = lib.util.task_template
  100. if attr in ["desc", "title"]:
  101. assert templ[attr] == str
  102. return val
  103. elif attr == "rank":
  104. try:
  105. return float(val)
  106. except ValueError:
  107. print(f"error: rank value {val} isn't convertable to float",
  108. file=sys.stderr)
  109. sys.exit(-1)
  110. elif attr == "due":
  111. # Other date formats not yet supported... ez to add
  112. assert len(val) == 4
  113. date = datetime.now().date()
  114. year = int(date.strftime("%Y"))%100
  115. try:
  116. dt = datetime.strptime(f"18:00 {val}{year}", "%H:%M %d%m%y")
  117. except ValueError:
  118. print(f"error: unknown date format {val}")
  119. sys.exit(-1)
  120. due = lib.util.datetime_to_unix(dt)
  121. return due
  122. elif attr == "project":
  123. try:
  124. return [val]
  125. except ValueError:
  126. print(f"error: project value {val} isn't convertable to list",
  127. file=sys.stderr)
  128. sys.exit(-1)
  129. else:
  130. print(f"error: unhandled attr '{attr}' = {val}")
  131. sys.exit(-1)
  132. async def show_active_tasks():
  133. refids = await api.get_ref_ids()
  134. tasks = []
  135. for refid in refids:
  136. tasks.append(await api.get_task_by_ref_id(refid))
  137. list_tasks(tasks, [])
  138. async def show_deactive_tasks(month):
  139. tasks = await api.fetch_deactive_tasks(month)
  140. list_tasks(tasks, [])
  141. def list_tasks(tasks, filters):
  142. headers = ["ID", "Title", "Status", "Project",
  143. "Tags", "assign", "Rank", "Due"]
  144. table_rows = []
  145. for id, task in enumerate(tasks, 1):
  146. if task is None:
  147. continue
  148. if is_filtered(task, filters):
  149. continue
  150. title = task["title"]
  151. status = task["state"]
  152. # project = task["project"] if task["project"] is not None else ""
  153. tags = " ".join(f"+{tag}" for tag in task["tags"])
  154. assign = " ".join(f"@{assign}" for assign in task["assign"])
  155. project = " ".join(f"{project}" for project in task["project"])
  156. if task["due"] is None:
  157. due = ""
  158. else:
  159. dt = lib.util.unix_to_datetime(task["due"])
  160. due = dt.strftime("%H:%M %d/%m/%y")
  161. rank = round(task["rank"], 4) if task["rank"] is not None else ""
  162. if status == "start":
  163. id = Fore.GREEN + str(id) + Style.RESET_ALL
  164. title = Fore.GREEN + str(title) + Style.RESET_ALL
  165. status = Fore.GREEN + str(status) + Style.RESET_ALL
  166. project = Fore.GREEN + str(project) + Style.RESET_ALL
  167. tags = Fore.GREEN + str(tags) + Style.RESET_ALL
  168. assign = Fore.GREEN + str(assign) + Style.RESET_ALL
  169. rank = Fore.GREEN + str(rank) + Style.RESET_ALL
  170. due = Fore.GREEN + str(due) + Style.RESET_ALL
  171. elif status == "pause":
  172. id = Fore.YELLOW + str(id) + Style.RESET_ALL
  173. title = Fore.YELLOW + str(title) + Style.RESET_ALL
  174. status = Fore.YELLOW + str(status) + Style.RESET_ALL
  175. project = Fore.YELLOW + str(project) + Style.RESET_ALL
  176. tags = Fore.YELLOW + str(tags) + Style.RESET_ALL
  177. assign = Fore.YELLOW + str(assign) + Style.RESET_ALL
  178. rank = Fore.YELLOW + str(rank) + Style.RESET_ALL
  179. due = Fore.YELLOW + str(due) + Style.RESET_ALL
  180. else:
  181. #id = Style.DIM + str(id) + Style.RESET_ALL
  182. #title = Style.DIM + str(title) + Style.RESET_ALL
  183. #status = Style.DIM + str(status) + Style.RESET_ALL
  184. project = Style.DIM + str(project) + Style.RESET_ALL
  185. tags = Style.DIM + str(tags) + Style.RESET_ALL
  186. #assign = Style.DIM + str(assign) + Style.RESET_ALL
  187. rank = Style.DIM + str(rank) + Style.RESET_ALL
  188. due = Style.DIM + str(due) + Style.RESET_ALL
  189. rank_value = task["rank"] if task["rank"] is not None else 0
  190. row = [
  191. id,
  192. title,
  193. status,
  194. project,
  195. tags,
  196. assign,
  197. rank,
  198. due,
  199. ]
  200. table_rows.append((rank_value, row))
  201. table = [row for (_, row) in
  202. sorted(table_rows, key=lambda item: item[0], reverse=True)]
  203. print(tabulate(table, headers=headers))
  204. async def show_task(refid):
  205. task = await api.get_task_by_ref_id(refid)
  206. task_table(task)
  207. return 0
  208. async def show_archive_task(id, month):
  209. task = await api.fetch_archive_task(id, month)
  210. task_table(task)
  211. return 0
  212. def tabulate_task(task):
  213. tags = " ".join(f"+{tag}" for tag in task["tags"])
  214. assign = " ".join(f"@{assign}" for assign in task["assign"])
  215. rank = round(task["rank"], 4) if task["rank"] is not None else ""
  216. if task["due"] is None:
  217. due = ""
  218. else:
  219. dt = lib.util.unix_to_datetime(task["due"])
  220. due = dt.strftime("%H:%M %d/%m/%y")
  221. assert task["created_at"] is not None
  222. dt = lib.util.unix_to_datetime(task["created_at"])
  223. created_at = dt.strftime("%H:%M %d/%m/%y")
  224. table = [
  225. ["Title:", task["title"]],
  226. ["Description:", task["desc"]],
  227. ["Status:", task["state"]],
  228. ["Project:", task["project"]],
  229. ["Tags:", tags],
  230. ["assign:", assign],
  231. ["Rank:", rank],
  232. ["Due:", due],
  233. ["Created:", created_at],
  234. ]
  235. return tabulate(table, headers=["Attribute", "Value"])
  236. def task_table(task):
  237. print(tabulate_task(task))
  238. table = []
  239. for event in task["events"]:
  240. cmd, when, args = event[0], event[1], event[2:]
  241. when = lib.util.unix_to_datetime(when)
  242. when = when.strftime("%H:%M %d/%m/%y")
  243. if cmd == "set":
  244. who, attr, val = args
  245. if attr == "due" and val is not None:
  246. val = lib.util.unix_to_datetime(val)
  247. val = val.strftime("%H:%M %d/%m/%y")
  248. table.append([
  249. Style.DIM + f"{who} changed {attr} to {val}" + Style.RESET_ALL,
  250. "",
  251. Style.DIM + when + Style.RESET_ALL
  252. ])
  253. elif cmd == "append":
  254. who, attr, val = args
  255. if attr == "tags":
  256. val = f"+{val}"
  257. elif attr == "assign":
  258. val = f"@{val}"
  259. table.append([
  260. Style.DIM + f"{who} added {val} to {attr}" + Style.RESET_ALL,
  261. "",
  262. Style.DIM + when + Style.RESET_ALL
  263. ])
  264. elif cmd == "remove":
  265. who, attr, val = args
  266. if attr == "tags":
  267. val = f"+{val}"
  268. elif attr == "assign":
  269. val = f"@{val}"
  270. table.append([
  271. Style.DIM + f"{who} removed {val} from {attr}" + Style.RESET_ALL,
  272. "",
  273. Style.DIM + when + Style.RESET_ALL
  274. ])
  275. elif cmd == "state":
  276. who, status = args
  277. if status == "pause":
  278. status_verb = "paused"
  279. elif status in ["start", "cancel"]:
  280. status_verb = f"{status}ed"
  281. elif status == "stop":
  282. status_verb = f"stopped"
  283. else:
  284. print(f"internal error: unhandled task state {status}",
  285. file=sys.stderr)
  286. sys.exit(-2)
  287. table.append([
  288. f"{who} {status_verb} task",
  289. "",
  290. Style.DIM + when + Style.RESET_ALL
  291. ])
  292. print(tabulate(table))
  293. table = []
  294. for event in task['events']:
  295. cmd, when, args = event[0], event[1], event[2:]
  296. when = lib.util.unix_to_datetime(when)
  297. when = when.strftime("%H:%M %d/%m/%y")
  298. if cmd == "comment":
  299. who, comment = args
  300. table.append([
  301. f"{who}>",
  302. wrap_comment(comment, 58),
  303. Style.DIM + when + Style.RESET_ALL
  304. ])
  305. if len(table) > 0:
  306. print("Comments:")
  307. print(tabulate(table))
  308. def wrap_comment(comment, width):
  309. lines = []
  310. line_start = 0
  311. for i, char in enumerate(comment):
  312. if char == ' ' and (i - line_start >= width):
  313. lines.append(comment[line_start:i + 1])
  314. line_start = i + 1
  315. if line_start < len(comment):
  316. lines.append(comment[line_start:])
  317. return '\n'.join(lines)
  318. async def modify_task(id, args):
  319. changes = []
  320. for arg in args:
  321. if arg[0] == "+":
  322. tag = arg[1:]
  323. changes.append(("append", "tags", tag))
  324. # This must go before the next elif block
  325. elif arg.startswith("-@"):
  326. assign = arg[2:]
  327. changes.append(("remove", "assign", assign))
  328. elif arg[0] == "-":
  329. tag = arg[1:]
  330. changes.append(("remove", "tags", tag))
  331. elif arg[0] == "@":
  332. assign = arg[1:]
  333. changes.append(("append", "assign", assign))
  334. elif ":" in arg:
  335. attr, val = arg.split(":", 1)
  336. if val.lower() == "none":
  337. if attr not in ["project", "rank", "due"]:
  338. print(f"error: invalid you cannot set {attr} to none",
  339. file=sys.stderr)
  340. return -1
  341. val = None
  342. else:
  343. val = convert_attr_val(attr, val)
  344. changes.append(("set", attr, val))
  345. else:
  346. print(f"warning: unknown arg '{arg}'. Skipping...", file=sys.stderr)
  347. await api.modify_task(USERNAME, id, changes)
  348. return 0
  349. async def change_task_status(id, status):
  350. task = await api.fetch_task(id)
  351. assert task is not None
  352. title = task["title"]
  353. if not await api.change_task_status(USERNAME, id, status):
  354. return -1
  355. if status == "start":
  356. print(f"Started task {id} '{title}'")
  357. elif status == "pause":
  358. print(f"Paused task {id} '{title}'")
  359. elif status == "stop":
  360. print(f"Completed task {id} '{title}'")
  361. elif status == "cancel":
  362. print(f"Cancelled task {id} '{title}'")
  363. return 0
  364. async def comment(id, args):
  365. if not args:
  366. comment = prompt_comment_text()
  367. else:
  368. comment = " ".join(args)
  369. if not await api.add_task_comment(USERNAME, id, comment):
  370. return -1
  371. task = await api.fetch_task(id)
  372. assert task is not None
  373. title = task["title"]
  374. print(f"Commented on task {id} '{title}'")
  375. return 0
  376. def is_filtered(task, filters):
  377. for fltr in filters:
  378. if fltr.startswith("+"):
  379. tag = fltr[1:]
  380. if tag not in task["tags"]:
  381. return True
  382. elif fltr.startswith("@"):
  383. assign = fltr[1:]
  384. if assign not in task["assign"]:
  385. return True
  386. elif ":" in fltr:
  387. attr, val = fltr.split(":", 1)
  388. if val.lower() == "none":
  389. if attr not in ["project", "rank", "due"]:
  390. print(f"error: invalid you cannot set {attr} to none",
  391. file=sys.stderr)
  392. sys.exit(-1)
  393. if task[attr] is not None:
  394. return True
  395. elif attr == "state" :
  396. if val not in ["open", "start", "pause"]:
  397. print(f"error: invalid, filter by {attr} can only be [\"open\", \"start\", \"pause\"]",
  398. file=sys.stderr)
  399. sys.exit(-1)
  400. if task["state"] != val:
  401. return True
  402. elif attr == "project":
  403. if task["project"] is None:
  404. return True
  405. if not task["project"].startswith(val):
  406. return True
  407. else:
  408. val = convert_attr_val(attr, val)
  409. if task[attr] != val:
  410. return True
  411. else:
  412. print(f"error: unknown arg '{fltr}'", file=sys.stderr)
  413. sys.exit(-1)
  414. return False
  415. def find_free_id(task_ids):
  416. for i in range(1, 1000):
  417. if i not in task_ids:
  418. return i
  419. 1
  420. def map_ids(task_ids, ref_ids):
  421. return dict(zip(task_ids, ref_ids))
  422. async def main():
  423. refids = await api.get_ref_ids()
  424. free_ids = []
  425. tasks = []
  426. for refid in refids:
  427. tasks.append(await api.get_task_by_ref_id(refid))
  428. free_ids.append(find_free_id(free_ids))
  429. data = map_ids(free_ids, refids)
  430. if len(sys.argv) == 1:
  431. await show_active_tasks()
  432. return 0
  433. if sys.argv[1] in ["-h", "--help", "help"]:
  434. print('''USAGE:
  435. tau [OPTIONS] [SUBCOMMAND]
  436. OPTIONS:
  437. -h, --help Print help information
  438. SUBCOMMANDS:
  439. add Add a new task.
  440. archive Show completed tasks.
  441. comment Write comment for task by id.
  442. modify Modify an existing task by id.
  443. pause Pause task(s).
  444. start Start task(s).
  445. stop Stop task(s).
  446. help Show this help text.
  447. Example:
  448. tau add task one due:0312 rank:1.022 project:zk +lol @sk desc:desc +abc +def
  449. tau add task two rank:1.044 project:cr +mol @up desc:desc2
  450. tau add task three due:0512 project:zy +trol @kk desc:desc3 +who
  451. tau 1 modify @upgr due:1112 rank:none
  452. tau 1 modify -mol -xx
  453. tau 2 start
  454. tau 1 comment "this is an awesome comment"
  455. tau 2 pause
  456. tau archive # current month's completed tasks
  457. tau archive 1122 # completed tasks in Nov. 2022
  458. tau 0 archive 1122 # show info of task completed in Nov. 2022
  459. ''')
  460. return 0
  461. elif sys.argv[1] == "add":
  462. task_args = sys.argv[2:]
  463. await add_task(task_args)
  464. return 0
  465. elif sys.argv[1] == "archive":
  466. if len(sys.argv) > 2:
  467. if len(sys.argv[2]) == 4:
  468. month = sys.argv[2]
  469. else:
  470. print("error: month must be of format MMYY")
  471. return -1
  472. else:
  473. month = lib.util.current_month()
  474. await show_deactive_tasks(month)
  475. return 0
  476. elif sys.argv[1] == "show":
  477. if len(sys.argv) > 2:
  478. filters = sys.argv[2:]
  479. list_tasks(tasks, filters)
  480. else:
  481. await show_active_tasks()
  482. return 0
  483. try:
  484. id = int(sys.argv[1])
  485. refid = data[id]
  486. except ValueError:
  487. print("error: invalid ID", file=sys.stderr)
  488. return -1
  489. args = sys.argv[2:]
  490. if not args:
  491. return await show_task(refid)
  492. subcmd, args = args[0], args[1:]
  493. if subcmd == "modify":
  494. if (errc := await modify_task(id, args)) < 0:
  495. return errc
  496. return await show_task(id)
  497. elif subcmd in ["start", "pause", "stop", "cancel"]:
  498. status = subcmd
  499. if (errc := await change_task_status(id, status)) < 0:
  500. return errc
  501. elif subcmd == "comment":
  502. if (errc := await comment(id, args)) < 0:
  503. return errc
  504. elif subcmd == "archive":
  505. if len(args) == 1:
  506. if len(args[0]) == 4:
  507. month = args[0]
  508. else:
  509. print("Error: month must be of format MMYY")
  510. return -1
  511. else:
  512. month = lib.util.current_month()
  513. if (errc := await show_archive_task(id, month)) < 0:
  514. return errc
  515. else:
  516. print(f"error: unknown subcommand '{subcmd}'")
  517. return -1
  518. return 0
  519. asyncio.run(main())