WIP: Fixed stop to finish tasks in databse + started implementing toggl -> reclaim sync

This commit is contained in:
2024-06-19 20:00:52 +02:00
parent a8fd28afae
commit bf7bc7177a
6 changed files with 152 additions and 80 deletions

View File

@@ -0,0 +1,6 @@
from enum import Enum
class DeadlineStatus(Enum):
FINE = 1
OVERDUE = 2
NONE = 3

View File

@@ -3,16 +3,9 @@
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional, Union
import tomllib import tomllib
import reclaim_handler
import things_handler
import toggl_handler
import utils
from database_handler import UploadedTasksDB
from dateutil import tz from dateutil import tz
from rich import print as rprint from rich import print as rprint
from rich.console import Console from rich.console import Console
@@ -22,6 +15,12 @@ from rich.text import Text
from typing_extensions import Annotated from typing_extensions import Annotated
import typer import typer
import reclaim_handler
from deadline_status import DeadlineStatus
import things_handler
import toggl_handler
import utils
from database_handler import UploadedTasksDB
CONFIG_PATH = Path("config/.things2reclaim.toml") CONFIG_PATH = Path("config/.things2reclaim.toml")
@@ -37,13 +36,6 @@ app = typer.Typer(
console = Console() console = Console()
def complete_task_name(incomplete: str):
for name in [task.name for task in reclaim_handler.get_reclaim_tasks()]:
print(name)
if name.startswith(incomplete):
yield name
def things_to_reclaim(things_task): def things_to_reclaim(things_task):
tags = things_handler.get_task_tags(things_task) tags = things_handler.get_task_tags(things_task)
estimated_time = tags.get("EstimatedTime") estimated_time = tags.get("EstimatedTime")
@@ -75,6 +67,21 @@ def things_to_reclaim(things_task):
reclaim_handler.create_reaclaim_task_from_dict(params) reclaim_handler.create_reaclaim_task_from_dict(params)
def finish_task(task : Union[reclaim_handler.ReclaimTask, str]):
"""
Finish task
"""
if isinstance(task, str):
things_id = task
elif isinstance(task, reclaim_handler.ReclaimTask):
things_id = reclaim_handler.get_things_id(task)
reclaim_handler.finish_task(task)
things_handler.complete(things_id)
with UploadedTasksDB(DATABASE_PATH) as db:
db.remove_uploaded_task(things_id)
@app.command("init") @app.command("init")
def initialize_uploaded_database(verbose: bool = False): def initialize_uploaded_database(verbose: bool = False):
""" """
@@ -111,8 +118,7 @@ def upload_things_to_reclaim(verbose: bool = False):
Upload things tasks to reclaim Upload things tasks to reclaim
""" """
projects = things_handler.extract_uni_projects() projects = things_handler.extract_uni_projects()
reclaim_task_names = [ reclaim_task_names = reclaim_handler.get_reclaim_task_names()
task.name for task in reclaim_handler.get_reclaim_tasks()]
tasks_uploaded = 0 tasks_uploaded = 0
with UploadedTasksDB(DATABASE_PATH) as db: with UploadedTasksDB(DATABASE_PATH) as db:
for project in projects: for project in projects:
@@ -150,20 +156,23 @@ def list_reclaim_tasks(subject: Annotated[Optional[str],
current_date = datetime.now(tz.tzutc()) current_date = datetime.now(tz.tzutc())
table = Table("Index", "Task", "Days left", title="Task list") table = Table("Index", "Task", "Days left", title="Task list")
for index, task in enumerate(reclaim_tasks): for index, task in enumerate(reclaim_tasks):
if current_date > task.due_date: due_date = task.due_date
days_behind = (current_date - task.due_date).days if due_date is None:
table.add_row( date_str = Text()
f"({index + 1})", else:
task.name, if current_date > due_date:
Text(f"{days_behind} days overdue", style="bold red"), days_behind = (current_date - due_date).days
) date_str = Text(f"{days_behind} days overdue")
else: date_str.stylize("bold red")
days_left = (task.due_date - current_date).days else:
table.add_row( days_left = (due_date - current_date).days
f"({index + 1})", date_str = Text(f"{days_left} days left")
task.name, date_str.stylize("bold white")
Text(f"{days_left} days left", style="bold white"), table.add_row(
) f"({index + 1})",
task.name,
date_str)
console.print(table) console.print(table)
@@ -216,29 +225,30 @@ def stop_task():
if stopped_task is None: if stopped_task is None:
utils.perror(f"{stopped_task} not found in reclaim") utils.perror(f"{stopped_task} not found in reclaim")
return return
stop_time = datetime.now(tz.tzutc()) stop_time = datetime.now(tz.tzutc())
if callable(current_task.start): if callable(current_task.start):
current_task.start = current_task.start() current_task.start = current_task.start()
toggl_handler.stop_current_task() toggl_handler.stop_current_task()
is_task_finished = Confirm.ask("Is task finished?", default=False)
if stopped_task.is_scheduled: if stopped_task.is_scheduled:
reclaim_handler.log_work_for_task( reclaim_handler.log_work_for_task(
stopped_task, current_task.start, stop_time) stopped_task, current_task.start, stop_time)
is_task_finished = Confirm.ask("Is task finished?", default=False)
if is_task_finished:
reclaim_handler.finish_task(stopped_task)
rprint(f"Finished {stopped_task.name}")
else: else:
utils.pwarning("Work could not be logged in reclaim[") utils.pwarning("Work could not be logged in reclaim!")
if is_task_finished:
finish_task(stopped_task)
rprint(f"Finished {stopped_task.name}")
time_format = "%H:%M" time_format = "%H:%M"
local_zone = tz.gettz() local_zone = tz.gettz()
rprint( rprint(
f"Logged work from \ (f"Logged work from {current_task.start.astimezone(local_zone).strftime(time_format)} "
{current_task.start.astimezone(local_zone).strftime(time_format)} to \ f"to {stop_time.astimezone(local_zone).strftime(time_format)} for {stopped_task.name}")
{stop_time.astimezone(local_zone).strftime(time_format)} \
for {stopped_task.name}"
) )
@@ -249,10 +259,9 @@ def show_task_stats():
""" """
current_date = datetime.now(tz.tzutc()) current_date = datetime.now(tz.tzutc())
reclaim_tasks = reclaim_handler.get_reclaim_tasks() reclaim_tasks = reclaim_handler.get_reclaim_tasks()
tasks_fine = [
task for task in reclaim_tasks if task.due_date >= current_date] tasks_fine = reclaim_handler.filter_for_deadline_status(current_date, DeadlineStatus.FINE, reclaim_tasks)
tasks_overdue = [ tasks_overdue = reclaim_handler.filter_for_deadline_status(current_date, DeadlineStatus.OVERDUE, reclaim_tasks)
task for task in reclaim_tasks if task.due_date < current_date]
fine_per_course = ["Fine"] fine_per_course = ["Fine"]
overdue_per_course = ["Overdue"] overdue_per_course = ["Overdue"]
@@ -283,24 +292,26 @@ def print_time_needed():
time_needed = 0 time_needed = 0
for task in tasks: for task in tasks:
time_needed += task.duration task_duration = task.duration
if task_duration:
time_needed += task_duration
print(f"Time needed to complete {len(tasks)} Tasks: {time_needed} hrs") print(f"Time needed to complete {len(tasks)} Tasks: {time_needed} hrs")
print(f"Average time needed to complete a Task: { print(f"Average time needed to complete a Task: {
time_needed/len(tasks):.2f} hrs") time_needed/len(tasks):.2f} hrs")
# sort unscheduled tasks to the end of the list
tasks.sort(key=lambda x: x.scheduled_start_date if x.scheduled_start_date is not None else float('inf'))
try:
tasks.sort(key=lambda x: x.scheduled_start_date)
except TypeError:
print("To many to-dos on list. Not all are scheduled")
return
last_task_date = tasks[-1].scheduled_start_date last_task_date = tasks[-1].scheduled_start_date
today = datetime.now(tz.tzutc()) if last_task_date is None: # last task on todo list is not scheduled
print("Too many tasks on todo list. Not all are scheduled.")
print( else:
f"""Last task is scheduled for {last_task_date.strftime('%d.%m.%Y')} today = datetime.now(tz.tzutc())
({last_task_date - today} till completion)""" print(
) f"""Last task is scheduled for {last_task_date.strftime('%d.%m.%Y')}
({last_task_date - today} till completion)"""
)
@app.command("finished") @app.command("finished")
@@ -310,21 +321,29 @@ def remove_finished_tasks_from_things():
""" """
reclaim_things_uuids = reclaim_handler.get_reclaim_things_ids() reclaim_things_uuids = reclaim_handler.get_reclaim_things_ids()
finished_someting = False finished_someting = False
with UploadedTasksDB(DATABASE_PATH) as db: for task in things_handler.get_all_uploaded_things_tasks():
for task in things_handler.get_all_uploaded_things_tasks(): if task["uuid"] not in reclaim_things_uuids:
if task["uuid"] not in reclaim_things_uuids: finished_someting = True
finished_someting = True print(
print( f"Found completed task: {
f"Found completed task: { things_handler.full_name(things_task=task)}"
things_handler.full_name(things_task=task)}" )
) finish_task(task["uuid"])
things_handler.complete(task["uuid"])
db.remove_uploaded_task(task["uuid"])
if not finished_someting: if not finished_someting:
print("Reclaim and Things are synced") print("Reclaim and Things are synced")
@app.command("tracking")
def sync_toggl_reclaim_tracking():
since_days = 0
reclaim_handler.get_reclaim_tasks()
toggl_time_entries = toggl_handler.get_time_entries_since(since_days = since_days) # end date is inclusive
reclaim_time_entries = reclaim_handler.get_task_events_since(since_days = since_days) # end date is inclusive
rprint(reclaim_time_entries)
@app.command("sync") @app.command("sync")
def sync_things_and_reclaim(verbose: bool = False): def sync_things_and_reclaim(verbose: bool = False):
""" """

View File

@@ -1,13 +1,17 @@
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from pathlib import Path from pathlib import Path
from typing import List, Dict from typing import List, Dict, Pattern, Optional
import re
import emoji
import tomllib import tomllib
from dateutil import tz from dateutil import tz
from reclaim_sdk.client import ReclaimClient from reclaim_sdk.client import ReclaimClient
from reclaim_sdk.models.task import ReclaimTask from reclaim_sdk.models.task import ReclaimTask
from reclaim_sdk.models.task_event import ReclaimTaskEvent from reclaim_sdk.models.task_event import ReclaimTaskEvent
from deadline_status import DeadlineStatus
CONFIG_PATH = Path("config/.reclaim.toml") CONFIG_PATH = Path("config/.reclaim.toml")
_config = {} _config = {}
@@ -17,6 +21,10 @@ with open(CONFIG_PATH, "rb") as f:
RECLAIM_TOKEN = _config["reclaim_ai"]["token"] RECLAIM_TOKEN = _config["reclaim_ai"]["token"]
THINGS_ID_PATTERN = "things_task:[a-zA-z0-9]+"
things_id_pattern : Pattern[str] = re.compile(THINGS_ID_PATTERN)
ReclaimClient(token=RECLAIM_TOKEN) ReclaimClient(token=RECLAIM_TOKEN)
@@ -24,18 +32,43 @@ def get_reclaim_tasks() -> List[ReclaimTask]:
return ReclaimTask.search() return ReclaimTask.search()
def get_reclaim_task_names(tasks : Optional[List[ReclaimTask]] = None):
if not tasks:
tasks = get_reclaim_tasks()
return [task.name for task in tasks]
def filter_for_subject(subject, tasks): def filter_for_subject(subject, tasks):
return [task for task in tasks if task.name.startswith(subject)] return [task for task in tasks if task.name.startswith(subject)]
def filter_for_deadline_status(cur_date : datetime, deadline_status : DeadlineStatus, tasks : List[ReclaimTask]):
# [task for task in reclaim_tasks if task.due_date >= current_date]
match deadline_status:
case DeadlineStatus.FINE:
return [task for task in tasks if (task.due_date and task.due_date >= cur_date)]
case DeadlineStatus.OVERDUE:
return [task for task in tasks if (task.due_date and task.due_date < cur_date)]
case DeadlineStatus.NONE:
return [task for task in tasks if task.due_date is None]
def get_project(task: ReclaimTask): def get_project(task: ReclaimTask):
return task.name.split(" ")[0] return task.name.split(" ")[0]
def get_events_since(since_days: int = 1): def get_clean_time_entry_name(name : str):
return emoji.replace_emoji(name).lstrip()
def get_task_events_since(since_days: int = 0) -> List[ReclaimTaskEvent]:
date_now = datetime.now(tz.tzutc()).date() date_now = datetime.now(tz.tzutc()).date()
date_since = date_now - timedelta(days=since_days) date_since = date_now - timedelta(days=since_days)
return ReclaimTaskEvent.search(date_since, date_now) date_end = date_now + timedelta(days = 1) # end date is exclusive
events = ReclaimTaskEvent.search(date_since, date_end)
task_names = get_reclaim_task_names()
print(task_names)
[print(get_clean_time_entry_name(event.name)) for event in events]
return [event for event in events if get_clean_time_entry_name(event.name) in task_names]
def get_events_date_range(from_date: date, to_date: date): def get_events_date_range(from_date: date, to_date: date):
@@ -61,10 +94,10 @@ def log_work_for_task(task: ReclaimTask, start: datetime, end: datetime):
raise ValueError("end is not timezone aware") raise ValueError("end is not timezone aware")
if not task.is_scheduled: if not task.is_scheduled:
raise RuntimeError("Task is not scheduled") raise ValueError("Task is not scheduled")
if not task.events: if not task.events:
raise RuntimeError("Event list is empty") raise ValueError("Event list is empty")
last_event: ReclaimTaskEvent = task.events[-1] last_event: ReclaimTaskEvent = task.events[-1]
@@ -77,5 +110,13 @@ def finish_task(task: ReclaimTask):
task.mark_complete() task.mark_complete()
def get_reclaim_things_ids() -> List[str]: def get_things_id(task: ReclaimTask):
return [task.description.split(":")[1].strip() for task in ReclaimTask.search()] things_id_match = things_id_pattern.match(task.description)
if things_id_match is None:
raise ValueError(f"things id could not be found in description of reclaim task {task.name}")
things_id_tag : str = things_id_match.group()
return things_id_tag.split(":")[1]
def get_reclaim_things_ids() -> List[str]:
return [get_things_id(task) for task in ReclaimTask.search()]

View File

@@ -20,11 +20,11 @@ def extract_uni_projects():
return things.projects(area=uni_area["uuid"]) return things.projects(area=uni_area["uuid"])
def get_task(task_id: int): def get_task(task_id: str):
return things.get(task_id) return things.get(task_id)
def complete(task_id: int): def complete(task_id: str):
things.complete(task_id) things.complete(task_id)

View File

@@ -1,5 +1,5 @@
import difflib import difflib
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date, time
from pathlib import Path from pathlib import Path
import tomllib import tomllib
@@ -39,9 +39,13 @@ def get_time_entries_date_range(from_date: date, to_date: date):
def get_time_entries_since(since_days: int = 30): def get_time_entries_since(since_days: int = 30):
"""
get time entries since days at midnight
"""
if since_days > 90: if since_days > 90:
raise ValueError("since_days can't be more than 90 days") raise ValueError("since_days can't be more than 90 days")
time_stamp = int((datetime.now() - timedelta(days=since_days)).timestamp()) midnight = datetime.combine(datetime.now(), time.min)
time_stamp = int((midnight - timedelta(days=since_days)).timestamp())
return toggl_python.TimeEntries(auth=auth).list(since=time_stamp) return toggl_python.TimeEntries(auth=auth).list(since=time_stamp)

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
import re import re
from typing import Union, Dict, Any, List from typing import Union, Dict, TypeVar, List
import difflib import difflib
from better_rich_prompts.prompt import ListPrompt from better_rich_prompts.prompt import ListPrompt
@@ -13,6 +13,7 @@ TIME_PATTERN = (
) )
pattern = re.compile(TIME_PATTERN) pattern = re.compile(TIME_PATTERN)
T = TypeVar("T") # generic type
def calculate_time_on_unit(tag_value: str) -> float: def calculate_time_on_unit(tag_value: str) -> float:
# This is a regex to match time in the format of 1h 30m # This is a regex to match time in the format of 1h 30m
@@ -60,8 +61,9 @@ def map_tag_values(
print(f"Tag {tag} not recognized") print(f"Tag {tag} not recognized")
def get_closest_match(name: str, candidates: Dict[str, Any]) -> Any | None: def get_closest_match(name: str, candidates: Dict[str, T]) -> T | None:
possible_candidates: List[str] = difflib.get_close_matches(name, candidates.keys()) possible_candidates: List[str] = difflib.get_close_matches(name, candidates.keys())
if not possible_candidates: if not possible_candidates:
return None return None
if len(possible_candidates) == 1: if len(possible_candidates) == 1: