|
""" |
|
Create a daily schedule report from Things Today tasks |
|
""" |
|
import re |
|
import sys |
|
import datetime |
|
import argparse |
|
import subprocess |
|
from typing import Optional, Tuple, List |
|
|
|
NO_TIME_ESTIMATE = -1 |
|
|
|
DT = datetime.datetime |
|
TD = datetime.timedelta |
|
|
|
def main(): |
|
parser = argparse.ArgumentParser() |
|
parser.add_argument('-s', '--start', help="Start time for schedule, default to next quarter hour") |
|
parser.add_argument('-d', '--duration', help="Default task duration, default is no duration", default=None, type=int) |
|
parser.add_argument('-b', '--buffer', help="Buffer time between tasks, default is 0 minutes", default=0, type=int) |
|
|
|
args = parser.parse_args() |
|
if not args.start: |
|
now = datetime.datetime.now() |
|
delta = 15 - now.minute % 15 |
|
start = now + datetime.timedelta(minutes=delta) |
|
else: |
|
s = args.start |
|
if not ':' in s: |
|
s += ':00' |
|
time = datetime.datetime.strptime(s, '%H:%M').time() |
|
today = datetime.date.today() |
|
start = datetime.datetime(today.year, today.month, today.day, time.hour, time.minute) |
|
duration = datetime.timedelta(minutes=args.duration) if args.duration else None |
|
buffer = datetime.timedelta(minutes=args.buffer) |
|
|
|
timed_tasks, untimed_tasks = parse_things_tasks(duration) |
|
scheduled_items = build_schedule(timed_tasks, start, buffer) |
|
create_report(scheduled_items, untimed_tasks) |
|
|
|
|
|
def parse_things_tasks(default_duration: Optional[TD]) -> Tuple[List[Tuple[TD, str]], List[str]]: |
|
timed_tasks, untimed_tasks = [], [] |
|
for task in sys.stdin: |
|
m = re.match(r'^title:(.*) tags:(.*)$', task) |
|
if m: |
|
title = m.group(1) |
|
est_mins = parse_estimated_minutes(m.group(2).split(', ')) |
|
if est_mins > 0: |
|
timed_tasks.append((datetime.timedelta(minutes=est_mins), title)) |
|
elif est_mins == NO_TIME_ESTIMATE and default_duration is not None: |
|
timed_tasks.append((default_duration, title)) |
|
else: |
|
untimed_tasks.append(title) |
|
else: |
|
print(f'unmatched: {task}') |
|
|
|
return timed_tasks, untimed_tasks |
|
|
|
|
|
def build_schedule(timed_tasks: List[Tuple[TD, str]], start: DT, buffer: TD) -> List[Tuple[DT, TD, str, str]]: |
|
today = datetime.date.today() |
|
midnight = datetime.datetime(today.year, today.month, today.day, 23, 59) |
|
|
|
time = start |
|
schedule = [] |
|
while time.date() == today and timed_tasks: |
|
if timed_tasks: |
|
sdt = midnight |
|
dur, text = timed_tasks[0] |
|
# add the task if it will fit in before the next event |
|
if time + dur + buffer <= sdt: |
|
schedule.append((time, dur, text)) |
|
timed_tasks.pop(0) |
|
time += dur + buffer |
|
continue |
|
# didn't add either, tick forward by 5 minutes |
|
time += datetime.timedelta(minutes=5) |
|
return schedule |
|
|
|
|
|
def create_report(scheduled_items, untimed_tasks): |
|
date = datetime.date.today().strftime('%A, %B %-d') |
|
|
|
lines = [] |
|
for time, dur, text in scheduled_items: |
|
lines.append(TIMED_TASK_TEMPLATE.format(time=format_time(time), duration=format_timedelta(dur), text=text)) |
|
scheduled_content = '\n'.join(lines) |
|
|
|
lines = [] |
|
for text in untimed_tasks: |
|
lines.append(UNTIMED_TASK_TEMPLATE.format(text=text)) |
|
other_content = '\n'.join(lines) |
|
|
|
filename = '_schedule_.html' |
|
with open(filename, 'w', encoding='utf-8') as f: |
|
f.write(HTML_TEMPLATE.format(date=date, scheduled_content=scheduled_content, other_content=other_content)) |
|
subprocess.call(['open', filename]) |
|
|
|
|
|
def parse_estimated_minutes(tags: List[str]) -> int: |
|
for tag in tags: |
|
if re.match(r'\d+m', tag): |
|
return int(tag[:-1]) |
|
return NO_TIME_ESTIMATE |
|
|
|
|
|
def run_command(cmd: List[str], input: str = '') -> Tuple[str, str]: |
|
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
|
stdout, stderr = p.communicate(input.encode('utf8')) |
|
out = stdout.decode('utf8') |
|
err = stderr.decode('utf8') |
|
if p.returncode != 0: |
|
raise ValueError(f"Error running script: rc={p.returncode}, out={out}, err={err}") |
|
return out, err |
|
|
|
|
|
def format_time(time: DT) -> str: |
|
return time.strftime("%I:%M%p").lower() |
|
|
|
|
|
def format_timedelta(td: TD) -> str: |
|
hrs, remainder = divmod(td.seconds, 3600) |
|
min, _ = divmod(remainder, 60) |
|
parts = [] |
|
if hrs: |
|
parts.append(f'{int(hrs)}h') |
|
if min: |
|
parts.append(f'{int(min)}m') |
|
return ' '.join(parts) |
|
|
|
|
|
TIMED_TASK_TEMPLATE = """ |
|
<tr> |
|
<td><div class="checkbox"></div></td> |
|
<td class="time">{time}</td> |
|
<td class="duration">({duration})</td> |
|
<td class="text">{text}</td> |
|
</tr> |
|
""" |
|
|
|
UNTIMED_TASK_TEMPLATE = """ |
|
<tr> |
|
<td><div class="checkbox"></div></td> |
|
<td><span class="text">{text}</span></td> |
|
</tr> |
|
""" |
|
|
|
HTML_TEMPLATE = """ |
|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8"> |
|
<title>Things Schedule</title> |
|
<style type="text/css"> |
|
body {{ font-family: -apple-system, "SegoeUI" }} |
|
table {{ padding-left: 2em; }} |
|
td {{ padding-left: 0.5em; }} |
|
.checkbox {{ |
|
display: inline-block; |
|
width: 15px; |
|
height: 15px; |
|
border-radius: 3px; |
|
border: 1px solid gray; |
|
vertical-align: middle; |
|
}} |
|
.time {{ font-weight: bold; }} |
|
.duration {{ |
|
opacity: 0.6; |
|
font-style: italic; |
|
font-size: 90%; |
|
text-align: right; |
|
}} |
|
</style> |
|
</head> |
|
<body> |
|
<h1>Schedule for {date}</h1> |
|
<h3>Scheduled Tasks</h3> |
|
<table> |
|
<tbody> |
|
{scheduled_content} |
|
</tbody> |
|
</table> |
|
<h3>Unscheduled Items</h3> |
|
<table> |
|
<tbody> |
|
{other_content} |
|
</tbody> |
|
</table> |
|
</body> |
|
</html> |
|
""" |
|
|
|
if __name__ == '__main__': |
|
main() |