Skip to content

Instantly share code, notes, and snippets.

@chromy
Created June 29, 2024 12:34
Show Gist options
  • Save chromy/05bec80fcffd22c4f296e7e7ff3441ad to your computer and use it in GitHub Desktop.
Save chromy/05bec80fcffd22c4f296e7e7ff3441ad to your computer and use it in GitHub Desktop.
Taskwarrior minimal web ui
import sys
from tempfile import TemporaryFile
from urllib.parse import urlparse, parse_qs, unquote
from http.server import HTTPServer, BaseHTTPRequestHandler
from .tasks_to_dot import exec_task, to_dot, exec_dot
import html
from tasklib import TaskWarrior
TASK_PAGE = """<!doctype html>
<html lang=en>
<head>
<meta charset=utf-8>
<title>Tasks</title>
<style>
body {{
padding: 4rem 0;
margin: auto;
max-width: 80ch;
font-family: sans-serif;
}}
label {{
display: block;
margin: 2rem 0;
}}
input {{
display: block;
font-size: larger;
margin-top: 1rem;
font-size: 2rem;
width: 100%;
}}
.error {{
color: red;
}}
</style>
</head>
<body>
<ul>
</ul>
<form>
<label>
Task
<input name="q" placeholder="next" value="{value}"/>
</label>
</form>
<pre class="{output_class}">
{raw}
</pre>
</body>
</html>
"""
PRETTY_PAGE = """<!doctype html>
<html lang=en>
<head>
<meta charset=utf-8>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tasks</title>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/css/tachyons.min.css"/>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@100;200;300;400&display=swap" rel="stylesheet">
<style>
.noto-sans {{
font-family: 'Noto Sans', sans-serif;
}}
</style>
</head>
<body class="noto-sans black-70 pa3 pt5-ns flex justify-center f6 f5-ns">
<div class="overflow-auto">
<table class="collapse ba br2 b--black-10 w-100 center">
{raw}
</table>
</div>
</body>
</html>
"""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if "/viz.dot" in self.path:
return self.do_viz("dot")
if "/viz.svg" in self.path:
return self.do_viz("svg")
if "/viz.png" in self.path:
return self.do_viz("png")
if "/pretty.html" in self.path:
return self.do_pretty()
return self.do_index()
def do_pretty(self):
def not_someday(task):
try:
return 'someday' not in task._data['tags']
except KeyError:
return True
def get_annotations(task):
try:
return [html.escape(a._data['description']) for a in task._data['annotations']]
except KeyError:
return []
def get_tags(task):
try:
return list(task._data['tags'])
except KeyError:
return []
def get_priority(task):
try:
return task._data['priority']
except KeyError:
return ''
def annotations_html(task):
return ''.join([f'<div class="pl2 pb2 black-40">{annotation}</div>' for annotation in get_annotations(task)])
task_warrior = TaskWarrior()
tasks = task_warrior.tasks.all()
all_pending_tasks = [t for t in tasks if t._data['status'] == 'pending']
not_someday_tasks = [t for t in all_pending_tasks if not_someday(t)]
sorted_tasks = sorted(not_someday_tasks, key=lambda t: t['urgency'], reverse=True)
task_htmls = []
task_htmls.append('<tr class="striped--light-gray ttu"><td class="ph3 pv2 tr">Urg</td><td class="ph3 pv2">Description</td><td class="ph3 pv2 tr">Tag</td><td class="ph3 pv2 tr">P</td><td class="ph3 pv2 tr">ID</td></tr>')
for task in sorted_tasks:
td_classes = ' '.join(['ph3', 'pv2', 'tr', 'v-top'])
description_cell = f'<td class="ph3 flex flex-column"><div class="pv2 v-top">{html.escape(task._data["description"])}</div>{annotations_html(task)}</td>'
urgency_cell = f'<td class="{td_classes}">{round(task._data["urgency"], 1)}</td>'
tags_cell = f'<td class="{td_classes} black-20">{" ".join(["+" + tag for tag in get_tags(task)])}</td>'
priority_cell = f'<td class="{td_classes} black-20">{get_priority(task)}</td>'
task_id_cell = f'<td class="{td_classes} black-20">{task._data["id"]}</td>'
task_htmls.append(f'<tr class="striped--light-gray">{urgency_cell}{description_cell}{tags_cell}{priority_cell}{task_id_cell}</tr>')
args = {
"raw": ''.join(task_htmls),
}
self.send_response(200)
self.end_headers()
self.wfile.write(PRETTY_PAGE.format(**args).encode("utf8"))
def do_index(self):
query_components = parse_qs(urlparse(self.path).query)
query = query_components.get("q", None)
query = query[0] if query else None
if query:
raw = exec_task(*[p for p in query.split(" ") if p])
else:
raw = exec_task("next")
output_class = "error" if raw.is_error else ""
value = query if query else ""
args = {
"output_class": output_class,
"raw": html.escape(str(raw)),
"value": value,
}
self.send_response(200)
self.end_headers()
self.wfile.write(TASK_PAGE.format(**args).encode("utf8"))
def do_viz(self, fmt):
f = TemporaryFile()
to_dot(f)
f.seek(0)
if fmt == "dot":
final = f.read()
elif fmt == "svg":
final = exec_dot("-Tsvg", fin=f).unwrap()
elif fmt == "png":
final = exec_dot("-Tpng", fin=f).unwrap()
else:
raise Exception(f"Unknown fmt {fmt}");
self.send_response(200)
self.end_headers()
self.wfile.write(final)
def main():
port = int(sys.argv[-1])
server_address = ('', port)
host = server_address[0] if server_address[0] else "localhost"
print(f"Serving on http://{host}:{port}")
httpd = HTTPServer(server_address, Handler)
httpd.serve_forever()
if __name__ == "__main__":
sys.exit(main())
import sys
import json
import subprocess
# TODO: dedupe with Result from src/finance
class Result(object):
def __init__(self, output, is_error=False):
self.output = output
self.is_error = is_error
def map(self, f):
if self.is_error:
return self
else:
return Result(f(self.output))
def unwrap(self):
assert not self.is_error
return self.output
def __str__(self):
return str(self.output)
def to_id(uuid):
return "id_" + uuid.replace("-", "")
def exec_task(*args, task_path="task"):
args = [task_path] + list(args)
try:
output = subprocess.check_output(args, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
return Result(e.output.decode("utf8"), is_error=True)
return Result(output.decode("utf8"))
def exec_dot(*args, task_path="dot", fin=None):
assert fin is not None
args = [task_path] + list(args)
try:
output = subprocess.check_output(args, stderr=subprocess.STDOUT, stdin=fin)
except subprocess.CalledProcessError as e:
return Result(e.output.decode("utf8"), is_error=True)
return Result(output)
def to_dot(f):
result = exec_task("export")
s = result.unwrap()
j = json.loads(s)
f.write("digraph g {".encode("utf8"))
tasks = [t for t in j if t["status"] in ("pending", "completed")]
for task in tasks:
id = to_id(task["uuid"])
label = task["description"]
fill = "green" if task["status"] == "completed" else "transparent"
f.write(f'{id} [shape=ellipse, label="{label}", style="filled", fillcolor="{fill}"];'.encode("utf8"))
for task in tasks:
src = to_id(task["uuid"])
for dependency in task.get("depends", []):
dest = to_id(dependency)
f.write(f"{src} -> {dest};".encode("utf8"))
f.write("}".encode("utf8"))
def main():
do_dot(sys.stdout)
return 0
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment