298 lines
9.9 KiB
Python
Executable File
298 lines
9.9 KiB
Python
Executable File
#!/opt/homebrew/Caskroom/miniconda/base/envs/things-automation/bin/python3
|
|
|
|
import re
|
|
from datetime import date, datetime, timedelta
|
|
from time import sleep
|
|
from typing import Dict, List, Optional
|
|
|
|
import things
|
|
import typer
|
|
from pytz import timezone
|
|
from reclaim_sdk.models.task import ReclaimTask
|
|
from rich import print as rprint
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
from typing_extensions import Annotated
|
|
|
|
app = typer.Typer(add_completion=False, no_args_is_help=True)
|
|
console = Console()
|
|
|
|
regex = (
|
|
r"((\d+\.?\d*) (hours|hrs|hour|hr|h))? ?((\d+\.?\d*) (mins|min|minutes|minute|m))?"
|
|
)
|
|
pattern = re.compile(regex)
|
|
|
|
|
|
def extract_uni_projects():
|
|
uni_area = next(area for area in things.areas() if area["title"] == "Uni")
|
|
return things.projects(area=uni_area["uuid"])
|
|
|
|
|
|
def get_tasks_for_project(project) -> Dict | List[Dict]:
|
|
return things.tasks(project=project["uuid"], type="to-do")
|
|
|
|
|
|
def get_task_tags(things_task: Dict) -> Dict[str, str]:
|
|
return {k: v for (k, v) in [tag.split(": ") for tag in things_task["tags"]]}
|
|
|
|
|
|
def set_default_reclaim_values(things_task, reclaim_task):
|
|
tags_dict = get_task_tags(things_task)
|
|
estimated_time = tags_dict.get("EstimatedTime")
|
|
if estimated_time is None:
|
|
raise ValueError("EstimatedTime tag is required")
|
|
estimated_time = calculate_time_on_unit(estimated_time)
|
|
reclaim_task.min_work_duration = estimated_time
|
|
reclaim_task.max_work_duration = estimated_time
|
|
reclaim_task.duration = estimated_time
|
|
if things_task.get("start_date") is not None:
|
|
reclaim_task.start_date = datetime.strptime(
|
|
f"{things_task['start_date']} 08:00", "%Y-%m-%d %H:%M"
|
|
)
|
|
if things_task.get("deadline") is not None:
|
|
reclaim_task.due_date = datetime.strptime(
|
|
f"{things_task['deadline']} 22:00", "%Y-%m-%d %H:%M"
|
|
)
|
|
|
|
|
|
def calculate_time_on_unit(tag_value) -> float | None:
|
|
# This is a regex to match time in the format of 1h 30m
|
|
# Minutes are optional if hours are present
|
|
# Hours are optional if minutes are present
|
|
# The regex will match two words when the correct format is found (format and emtpy word)
|
|
values = pattern.findall(tag_value)
|
|
time = 0
|
|
if len(values) != 2:
|
|
raise ValueError("Invalid time format")
|
|
_, hours, _, _, mins, _ = values[0]
|
|
if "" == hours and "" == mins:
|
|
raise ValueError("Regex matched empty string")
|
|
if "" != hours:
|
|
time += float(hours)
|
|
if "" != mins:
|
|
time += float(mins) / 60
|
|
|
|
return time
|
|
|
|
|
|
def map_tag_values(things_task, reclaim_task):
|
|
tags_dict = get_task_tags(things_task)
|
|
for tag in tags_dict:
|
|
match tag:
|
|
case "MinTime":
|
|
reclaim_task.min_work_duration = calculate_time_on_unit(tags_dict[tag])
|
|
case "MaxTime":
|
|
reclaim_task.max_work_duration = calculate_time_on_unit(tags_dict[tag])
|
|
case "DeadlineTime":
|
|
if things_task.get("deadline") is not None:
|
|
reclaim_task.due_date = datetime.strptime(
|
|
f"{things_task['deadline']} {tags_dict[tag]}", "%Y-%m-%d %H:%M"
|
|
)
|
|
case "StartTime":
|
|
if things_task.get("start_date") is not None:
|
|
reclaim_task.start_date = datetime.strptime(
|
|
f"{things_task['start_date']} {tags_dict[tag]}",
|
|
"%Y-%m-%d %H:%M",
|
|
)
|
|
case _:
|
|
print(f"Tag {tag} not recognized")
|
|
|
|
|
|
def generate_things_id_tag(things_task) -> str:
|
|
return f"things_task:{things_task["uuid"]}"
|
|
|
|
|
|
def things_to_reclaim(things_task, project_title):
|
|
with ReclaimTask() as reclaim_task:
|
|
reclaim_task.name = "{} {}".format(project_title, things_task["title"])
|
|
reclaim_task.description = generate_things_id_tag(things_task=things_task)
|
|
set_default_reclaim_values(things_task=things_task, reclaim_task=reclaim_task)
|
|
map_tag_values(things_task=things_task, reclaim_task=reclaim_task)
|
|
reclaim_task_pretty_print(reclaim_task)
|
|
|
|
|
|
def things_task_pretty_print(task, project_title):
|
|
print(f"\tTitle: {project_title} {task['title']}")
|
|
print(f"\tStart date: {task['start_date']}")
|
|
print(f"\tDeadline: {task['deadline']}")
|
|
print(f"\tTags: {task['tags']}")
|
|
|
|
|
|
def reclaim_task_pretty_print(task):
|
|
print(f"\tTitle: {task.name}")
|
|
print(f"\tStart date: {task.start_date}")
|
|
print(f"\tDeadline: {task.due_date}")
|
|
print(f"\tMin work duration: {task.min_work_duration}")
|
|
print(f"\tMax work duration: {task.max_work_duration}")
|
|
print(f"\tDuration: {task.duration}")
|
|
|
|
|
|
def full_name(things_task) -> str:
|
|
return f"{things_task['project_title']} {things_task['title']}"
|
|
|
|
|
|
def get_all_things_tasks() -> List:
|
|
tasks = []
|
|
projects = extract_uni_projects()
|
|
for project in projects:
|
|
tasks += get_tasks_for_project(project)
|
|
return tasks
|
|
|
|
|
|
def things_reclaim_is_equal(things_task, reclaim_task) -> bool:
|
|
return full_name(things_task=things_task) == reclaim_task.name
|
|
|
|
|
|
def get_course_names():
|
|
projects = extract_uni_projects()
|
|
return [course["title"] for course in projects]
|
|
|
|
|
|
@app.command("upload")
|
|
def upload_things_to_reclaim(verbose: bool = False):
|
|
"""
|
|
Upload things tasks to reclaim
|
|
"""
|
|
projects = extract_uni_projects()
|
|
reclaim_task_names = [task.name for task in ReclaimTask().search()]
|
|
tasks_uploaded = 0
|
|
for project in projects:
|
|
things_tasks = get_tasks_for_project(project)
|
|
for things_task in things_tasks:
|
|
full_task_name = full_name(things_task=things_task)
|
|
if full_task_name not in reclaim_task_names:
|
|
tasks_uploaded += 1
|
|
print(f"Creating task {full_task_name} in Reclaim")
|
|
things_to_reclaim(things_task, project["title"])
|
|
else:
|
|
if verbose:
|
|
print(f"Task {things_task['title']} already exists in Reclaim")
|
|
if tasks_uploaded == 0:
|
|
rprint("No new tasks were found")
|
|
elif tasks_uploaded == 1:
|
|
rprint(f"Uploaded {tasks_uploaded} task{'s' if tasks_uploaded > 1 else ''}")
|
|
|
|
|
|
def get_subject_reclaim_tasks(subject, tasks):
|
|
return [task for task in tasks if task.name.startswith(subject)]
|
|
|
|
|
|
@app.command("list")
|
|
def list_reclaim_tasks(subject: Annotated[Optional[str], typer.Argument()] = None):
|
|
"""
|
|
List all current tasks
|
|
"""
|
|
reclaim_tasks = ReclaimTask().search()
|
|
if subject is not None:
|
|
reclaim_tasks = get_subject_reclaim_tasks(subject, reclaim_tasks)
|
|
current_date = datetime.now().replace(tzinfo=timezone("UTC"))
|
|
table = Table("Index", "Task", "Days left", title="Task list")
|
|
for id, task in enumerate(reclaim_tasks):
|
|
if current_date > task.due_date:
|
|
days_behind = (current_date - task.due_date).days
|
|
table.add_row(
|
|
f"({id + 1})",
|
|
task.name,
|
|
Text(f"{days_behind} days overdue", style="bold red"),
|
|
)
|
|
else:
|
|
days_left = (task.due_date - current_date).days
|
|
table.add_row(
|
|
f"({id + 1})",
|
|
task.name,
|
|
Text(f"{days_left} days left", style="bold white"),
|
|
)
|
|
console.print(table)
|
|
|
|
|
|
@app.command("stats")
|
|
def show_task_stats():
|
|
"""
|
|
Show task stats
|
|
"""
|
|
current_date = datetime.now().replace(tzinfo=timezone("UTC"))
|
|
reclaim_tasks = ReclaimTask().search()
|
|
tasks_fine = [task for task in reclaim_tasks if task.due_date >= current_date]
|
|
tasks_overdue = [task for task in reclaim_tasks if task.due_date < current_date]
|
|
|
|
fine_per_course = ["Fine"]
|
|
overdue_per_course = ["Overdue"]
|
|
course_names = get_course_names()
|
|
for course_name in course_names:
|
|
fine_per_course.append(
|
|
str(len(get_subject_reclaim_tasks(course_name, tasks_fine)))
|
|
)
|
|
overdue_per_course.append(
|
|
str(len(get_subject_reclaim_tasks(course_name, tasks_overdue)))
|
|
)
|
|
|
|
table = Table(*(["Status"] + course_names))
|
|
table.add_row(*fine_per_course)
|
|
table.add_row(*overdue_per_course)
|
|
|
|
console.print(table)
|
|
|
|
|
|
@app.command("time")
|
|
def print_time_needed():
|
|
"""
|
|
Print sum of time needed for all reclaim tasks
|
|
"""
|
|
tasks = ReclaimTask.search()
|
|
tasks.sort(key=lambda x: x.scheduled_start_date)
|
|
|
|
time_needed = 0
|
|
|
|
for task in tasks:
|
|
time_needed += task.duration
|
|
|
|
print(f"Time needed to complete {len(tasks)} Tasks: {time_needed} hrs")
|
|
print(f"Average time needed to complete a Task: {time_needed/len(tasks):.2f} hrs")
|
|
|
|
last_task_date = tasks[-1].scheduled_start_date
|
|
today = datetime.now().replace(tzinfo=timezone("UTC"))
|
|
|
|
print(
|
|
f"Last task is scheduled for {last_task_date.strftime('%d.%m.%Y')} ({last_task_date - today} till completion)"
|
|
)
|
|
|
|
|
|
@app.command("finished")
|
|
def remove_finished_tasks_from_things():
|
|
"""
|
|
Complete finished reclaim tasks in things
|
|
"""
|
|
reclaim_things_uuids = [
|
|
task.description.split(":")[1].strip() for task in ReclaimTask.search()
|
|
]
|
|
finished_someting = False
|
|
for task in get_all_things_tasks():
|
|
if task["uuid"] not in reclaim_things_uuids:
|
|
finished_someting = True
|
|
print(f"Found completed task: {full_name(things_task=task)}")
|
|
things.complete(task["uuid"])
|
|
|
|
if not finished_someting:
|
|
print("Reclaim and Things are synced")
|
|
|
|
|
|
@app.command("sync")
|
|
def sync_things_and_reclaim(verbose: bool = False):
|
|
"""
|
|
Sync tasks between things and reclaim
|
|
First updated all finished tasks in reclaim to completed in things
|
|
Then upload all new tasks from things to reclaim
|
|
"""
|
|
rprint("[bold white]Pulling from Reclaim[/bold white]")
|
|
remove_finished_tasks_from_things()
|
|
rprint("---------------------------------------------")
|
|
sleep(2)
|
|
rprint("[bold white]Pushing to Reclaim[/bold white]")
|
|
upload_things_to_reclaim(verbose=verbose)
|
|
rprint("---------------------------------------------")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|