diff --git a/things2reclaim/main.py b/things2reclaim/main.py index 1de61bf..87262be 100755 --- a/things2reclaim/main.py +++ b/things2reclaim/main.py @@ -1,7 +1,7 @@ #!/opt/homebrew/Caskroom/miniconda/base/envs/things-automation/bin/python3 import sqlite3 -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Optional, Union import tomllib @@ -244,12 +244,7 @@ def stop_task(): finish_task(stopped_task) rprint(f"Finished {stopped_task.name}") - time_format = "%H:%M" - local_zone = tz.gettz() - rprint( - (f"Logged work from {current_task.start.astimezone(local_zone).strftime(time_format)} " - f"to {stop_time.astimezone(local_zone).strftime(time_format)} for {stopped_task.name}") - ) + utils.plogtime(current_task.start, stop_time, stopped_task.name) @app.command("stats") @@ -301,7 +296,7 @@ def print_time_needed(): 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')) + tasks.sort(key=lambda x: x.scheduled_start_date if x.scheduled_start_date is not None else datetime.max.replace(tzinfo=tz.tzutc())) last_task_date = tasks[-1].scheduled_start_date if last_task_date is None: # last task on todo list is not scheduled @@ -340,8 +335,35 @@ def sync_toggl_reclaim_tracking(): 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) + reclaim_time_entry_names = [reclaim_handler.get_clean_time_entry_name(time_entry.name) for time_entry in reclaim_time_entries] + missing_reclaim_entries = [time_entry for time_entry in toggl_time_entries if time_entry.description not in reclaim_time_entry_names] + if not missing_reclaim_entries: + utils.pinfo("Toggl and Reclaim time entries are synced.") + else: + for time_entry in missing_reclaim_entries: + name = time_entry.description + if not name: + raise ValueError("Toggl time entry has empty description") + reclaim_task = reclaim_handler.get_reclaim_task(name) + if not reclaim_task: + utils.pwarning(f"Couldn't find {time_entry.description} in Reclaim.") + continue + + start = toggl_handler.get_start_time(time_entry) + stop = toggl_handler.get_stop_time(time_entry) + + reclaim_handler.log_work_for_task(reclaim_task, start, stop) + utils.plogtime(start, stop, reclaim_task.name) + + +@app.command("current") +def display_current_task(): + current_task = toggl_handler.get_current_time_entry() + if current_task is None: + utils.perror("No task is currently tracked in toggl") + return + rprint(f"Current task: {current_task.description}\nStarted at: {current_task.start.astimezone(tz.gettz()).strftime("%H:%M")}") @app.command("sync") diff --git a/things2reclaim/reclaim_handler.py b/things2reclaim/reclaim_handler.py index 6d753d3..fbaa3bb 100644 --- a/things2reclaim/reclaim_handler.py +++ b/things2reclaim/reclaim_handler.py @@ -28,6 +28,13 @@ things_id_pattern : Pattern[str] = re.compile(THINGS_ID_PATTERN) ReclaimClient(token=RECLAIM_TOKEN) +def get_reclaim_task(name : str) -> Optional[ReclaimTask]: + res = ReclaimTask.search(title = name) + if not res: + return None + else: + return res[0] + def get_reclaim_tasks() -> List[ReclaimTask]: return ReclaimTask.search() @@ -60,15 +67,19 @@ def get_project(task: ReclaimTask): def get_clean_time_entry_name(name : str): return emoji.replace_emoji(name).lstrip() + +def is_task_time_entry(name: str): + decoded_name = emoji.demojize(name) + # task entries start either with a :thumbs_up: or a :check_mark_button: emoji + return decoded_name.startswith(":thumbs_up:") | decoded_name.startswith(":check_mark_button:") + + def get_task_events_since(since_days: int = 0) -> List[ReclaimTaskEvent]: date_now = datetime.now(tz.tzutc()).date() date_since = date_now - timedelta(days=since_days) 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] + return [event for event in events if is_task_time_entry(event.name)] def get_events_date_range(from_date: date, to_date: date): diff --git a/things2reclaim/toggl_handler.py b/things2reclaim/toggl_handler.py index e7c346e..969a195 100644 --- a/things2reclaim/toggl_handler.py +++ b/things2reclaim/toggl_handler.py @@ -2,6 +2,7 @@ import difflib from datetime import datetime, timedelta, date, time from pathlib import Path import tomllib +from typing import List import toggl_python from better_rich_prompts.prompt import ListPrompt @@ -28,17 +29,28 @@ time_entry_editor = toggl_python.WorkspaceTimeEntries( ) -def get_time_entry(time_entry_id: int): +def get_time_entry(time_entry_id: int) -> toggl_python.TimeEntry: return toggl_python.TimeEntries(auth=auth).retrieve(time_entry_id) +def get_start_time(time_entry : toggl_python.TimeEntry): + return time_entry.start() if callable(time_entry.start) else time_entry.start + + +def get_stop_time(time_entry: toggl_python.TimeEntry): + if time_entry.stop is None: + return get_start_time(time_entry) + timedelta(seconds=time_entry.duration) + else: + return time_entry.stop() if callable(time_entry.stop) else time_entry.stop + + def get_time_entries_date_range(from_date: date, to_date: date): return toggl_python.TimeEntries(auth=auth).list( start_date=from_date.isoformat(), end_date=to_date.isoformat() ) -def get_time_entries_since(since_days: int = 30): +def get_time_entries_since(since_days: int = 30) -> List[toggl_python.TimeEntry]: """ get time entries since days at midnight """ @@ -61,7 +73,7 @@ def get_current_time_entry() -> TimeEntry | None: return time_entries.current() -def get_tags(): +def get_tags() -> List[toggl_python.Tag]: return toggl_python.Workspaces(auth=auth).tags(_id=workspace.id) @@ -85,7 +97,7 @@ def get_approriate_tag(description: str) -> str | None: def create_task_time_entry( description: str, project: str, start: datetime | None = None, duration: int = -1 -): +) -> toggl_python.TimeEntry: """ duration is in seconds """ diff --git a/things2reclaim/utils.py b/things2reclaim/utils.py index 6e47237..e1426df 100644 --- a/things2reclaim/utils.py +++ b/things2reclaim/utils.py @@ -2,6 +2,7 @@ from datetime import datetime import re from typing import Union, Dict, TypeVar, List import difflib +from dateutil import tz from better_rich_prompts.prompt import ListPrompt from rich import print as rprint @@ -84,6 +85,23 @@ def perror(msg: str): rprint(f"[bold red]Error: {msg}[/bold red]") +def plogtime(start_time: datetime, end_time: datetime, task_name: str): + time_format = "%H:%M" + local_zone = tz.gettz() + + if start_time.tzinfo is None: + raise ValueError("start_time has to be timezone aware.") + + if end_time.tzinfo is None: + raise ValueError("end_time has to be timezone aware.") + + + rprint( + (f"Logged work from {start_time.astimezone(local_zone).strftime(time_format)} " + f"to {end_time.astimezone(local_zone).strftime(time_format)} for {task_name}") + ) + + def generate_things_id_tag(things_task) -> str: return f"things_task:{things_task["uuid"]}"