diff --git a/things2reclaim/database_handler.py b/things2reclaim/database_handler.py index d98d979..9fad9d6 100644 --- a/things2reclaim/database_handler.py +++ b/things2reclaim/database_handler.py @@ -1,4 +1,5 @@ import sqlite3 +from typing import List class UploadedTasksDB: @@ -32,7 +33,7 @@ class UploadedTasksDB: cursor.execute(insert_statement, [task_id]) self.conn.commit() - def get_all_uploaded_tasks(self): + def get_all_uploaded_tasks(self) -> List[str]: cursor = self.conn.cursor() cursor.execute("SELECT * FROM uploaded_tasks") rows = cursor.fetchall() diff --git a/things2reclaim/main.py b/things2reclaim/main.py index 24d0dc8..baa4046 100755 --- a/things2reclaim/main.py +++ b/things2reclaim/main.py @@ -1,10 +1,10 @@ #!/opt/homebrew/Caskroom/miniconda/base/envs/things-automation/bin/python3 import sqlite3 -from datetime import datetime -from pathlib import Path +from datetime import datetime, time from typing import Dict, List, Optional, Union import tomllib +import itertools from dateutil import tz from rich import print as rprint @@ -22,13 +22,13 @@ import toggl_handler import utils from database_handler import UploadedTasksDB -CONFIG_PATH = Path("config/.things2reclaim.toml") +CONFIG_PATH = utils.get_project_root() / "things2reclaim/config/.things2reclaim.toml" _config = {} with open(CONFIG_PATH, "rb") as f: _config = tomllib.load(f) -DATABASE_PATH = _config["database"]["path"] +DATABASE_PATH = utils.get_project_root() / _config["database"]["path"] app = typer.Typer( add_completion=False, no_args_is_help=True @@ -113,36 +113,24 @@ def initialize_uploaded_database(verbose: bool = False): @app.command("upload") -def upload_things_to_reclaim(verbose: bool = False): +def upload_things_to_reclaim(): """ Upload things tasks to reclaim """ - projects = things_handler.extract_uni_projects() - reclaim_task_names = reclaim_handler.get_reclaim_task_names() - tasks_uploaded = 0 + tasks = things_handler.get_all_things_tasks() with UploadedTasksDB(DATABASE_PATH) as db: - for project in projects: - things_tasks = things_handler.get_tasks_for_project(project) - for things_task in things_tasks: - full_task_name = things_handler.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) - db.add_uploaded_task(things_task["uuid"]) - 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 ''}") - + uploaded_task_ids = db.get_all_uploaded_tasks() + tasks_to_upload = [task for task in tasks if task["uuid"] not in uploaded_task_ids] + if not tasks_to_upload: + print("No new tasks were found") + else: + for task in tasks_to_upload: + print(f"Creating task {things_handler.full_name(task)} in Reclaim") + things_to_reclaim(task) + db.add_uploaded_task(task["uuid"]) + print(f"Uploaded {len(tasks_to_upload)} task{'s' if len(tasks_to_upload) > 1 else ''}") + @app.command("list") def list_reclaim_tasks(subject: Annotated[Optional[str], typer.Argument()] = None): @@ -315,36 +303,46 @@ def remove_finished_tasks_from_things(): Complete finished reclaim tasks in things """ reclaim_things_uuids = reclaim_handler.get_reclaim_things_ids() - finished_someting = False - for task in things_handler.get_all_uploaded_things_tasks(): - if task["uuid"] not in reclaim_things_uuids: - finished_someting = True + tasks_to_be_removed = [task for task in things_handler.get_all_uploaded_things_tasks() + if task["uuid"] not in reclaim_things_uuids] + if not tasks_to_be_removed: + print("Reclaim and Things are synced!") + else: + for task in tasks_to_be_removed: print( f"Found completed task: { things_handler.full_name(things_task=task)}" ) finish_task(task["uuid"]) - if not finished_someting: - print("Reclaim and Things are synced") - - @app.command("tracking") -def sync_toggl_reclaim_tracking(): - since_days = 0 - reclaim_handler.get_reclaim_tasks() +def sync_toggl_reclaim_tracking(since_days : Annotated[int, typer.Argument()] = 0): toggl_time_entries = toggl_handler.get_time_entries_since(since_days = since_days) # end date is inclusive if toggl_time_entries is None: - utils.pwarning("No tasks tracked today in Toggl") + utils.pwarning(f"No tasks tracked in Toggl since {since_days} days") return reclaim_time_entries = reclaim_handler.get_task_events_since(since_days = since_days) # end date is inclusive - 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] + reclaim_time_entries_dict = {utils.get_clean_time_entry_name(k) : list(g) for k,g in itertools.groupby(reclaim_time_entries, lambda r : r.name)} + non_existent_time_entries = [time_entry for time_entry in toggl_time_entries if time_entry.description not in reclaim_time_entries_dict.keys()] + # add all existing mismatched_time_entries + time_entries_to_adjust : Dict[toggl_handler.TimeEntry, reclaim_handler.ReclaimTaskEvent] = {} + for toggl_time_entry in toggl_time_entries: + if toggl_time_entry.description in reclaim_time_entries_dict.keys(): + nearest_entry = utils.nearest_time_entry(reclaim_time_entries_dict.get(toggl_time_entry.description), toggl_time_entry) #pyright: ignore + if toggl_time_entry in time_entries_to_adjust: + time_entries_to_adjust[toggl_time_entry] = utils.nearest_time_entry([time_entries_to_adjust[toggl_time_entry], nearest_entry], toggl_time_entry) #pyright: ignore + elif nearest_entry is not None: + time_entries_to_adjust[toggl_time_entry] = nearest_entry - if not missing_reclaim_entries: + rprint(non_existent_time_entries) + rprint(time_entries_to_adjust) + return + + + if not mismatched_time_entries: utils.pinfo("Toggl and Reclaim time entries are synced.") else: - for time_entry in missing_reclaim_entries: + for time_entry in mismatched_time_entries: name = time_entry.description if not name: raise ValueError("Toggl time entry has empty description") @@ -353,10 +351,21 @@ def sync_toggl_reclaim_tracking(): 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) + toggl_start = toggl_handler.get_start_time(time_entry) + toggl_stop = toggl_handler.get_stop_time(time_entry) - reclaim_handler.log_work_for_task(reclaim_task, start, stop) + if toggl_start.tzinfo is None: + raise ValueError("Toggl start time is not timezone aware") + if toggl_stop.tzinfo is None: + raise ValueError("Toggl stop time is not timezone aware") + + + + reclaim_time_entry = reclaim_time_entries_dict[reclaim_task.name] + if reclaim_time_entry is None: + reclaim_handler.log_work_for_task(reclaim_task, start, stop) + else: + reclaim_handler.adjust_time_entry(reclaim_time_entry, start, stop) utils.plogtime(start, stop, reclaim_task.name) @@ -372,7 +381,7 @@ def display_current_task(): @app.command("sync") -def sync_things_and_reclaim(verbose: bool = False): +def sync_things_and_reclaim(): """ Sync tasks between things and reclaim First updated all finished tasks in reclaim to completed in things @@ -382,7 +391,7 @@ def sync_things_and_reclaim(verbose: bool = False): remove_finished_tasks_from_things() rprint("---------------------------------------------") utils.pinfo("Pushing to Reclaim") - upload_things_to_reclaim(verbose=verbose) + upload_things_to_reclaim() rprint("---------------------------------------------") diff --git a/things2reclaim/reclaim_handler.py b/things2reclaim/reclaim_handler.py index 9cb70ed..a51286c 100644 --- a/things2reclaim/reclaim_handler.py +++ b/things2reclaim/reclaim_handler.py @@ -1,5 +1,4 @@ from datetime import datetime, date, timedelta -from pathlib import Path from typing import List, Dict, Pattern, Optional import re @@ -11,8 +10,9 @@ from reclaim_sdk.models.task import ReclaimTask from reclaim_sdk.models.task_event import ReclaimTaskEvent from deadline_status import DeadlineStatus +import utils -CONFIG_PATH = Path("config/.reclaim.toml") +CONFIG_PATH = utils.get_project_root() / "things2reclaim/config/.reclaim.toml" _config = {} @@ -64,10 +64,6 @@ def get_project(task: ReclaimTask): return task.name.split(" ")[0] -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 @@ -81,7 +77,6 @@ def get_task_events_since(since_days: int = 0) -> List[ReclaimTaskEvent]: events = ReclaimTaskEvent.search(date_since, date_end) return [event for event in events if is_task_time_entry(event.name)] - def get_events_date_range(from_date: date, to_date: date): return ReclaimTaskEvent.search(from_date, to_date) @@ -97,13 +92,6 @@ def log_work_for_task(task: ReclaimTask, start: datetime, end: datetime): """ start and end are in Europe/Berlin timezone """ - utc = tz.tzutc() - if start.tzinfo is None: - raise ValueError("start is not timezone aware") - - if end.tzinfo is None: - raise ValueError("end is not timezone aware") - if not task.is_scheduled: raise ValueError("Task is not scheduled") @@ -111,10 +99,20 @@ def log_work_for_task(task: ReclaimTask, start: datetime, end: datetime): raise ValueError("Event list is empty") last_event: ReclaimTaskEvent = task.events[-1] + adjust_time_entry(last_event, start, end) - last_event.start = start.astimezone(utc) - last_event.end = end.astimezone(utc) - last_event.save() + +def adjust_time_entry(time_entry: ReclaimTaskEvent, start: datetime, end: datetime): + utc = tz.tzutc() + if start.tzinfo is None: + raise ValueError("start is not timezone aware") + + if end.tzinfo is None: + raise ValueError("end is not timezone aware") + + time_entry.start = start.astimezone(utc) + time_entry.end = end.astimezone(utc) + time_entry.save() def finish_task(task: ReclaimTask): diff --git a/things2reclaim/things_handler.py b/things2reclaim/things_handler.py index 9ebf35a..ed9b3de 100644 --- a/things2reclaim/things_handler.py +++ b/things2reclaim/things_handler.py @@ -1,18 +1,18 @@ from typing import Dict, List -from pathlib import Path import tomllib import things from database_handler import UploadedTasksDB +import utils _config = {} -CONFIG_PATH = Path("config/.things2reclaim.toml") +CONFIG_PATH = utils.get_project_root() / "things2reclaim/config/.things2reclaim.toml" with open(CONFIG_PATH, "rb") as f: _config = tomllib.load(f) -DATABASE_PATH = _config["database"]["path"] +DATABASE_PATH = utils.get_project_root() / _config["database"]["path"] def extract_uni_projects(): @@ -32,6 +32,10 @@ def get_tasks_for_project(project) -> Dict | List[Dict]: return things.tasks(project=project["uuid"], type="to-do") +def is_equal_fullname(things_task : Dict, name : str): + return full_name(things_task=things_task) == name + + def get_all_things_tasks() -> List: tasks = [] projects = extract_uni_projects() diff --git a/things2reclaim/toggl_handler.py b/things2reclaim/toggl_handler.py index f8917a4..e2650c3 100644 --- a/things2reclaim/toggl_handler.py +++ b/things2reclaim/toggl_handler.py @@ -1,6 +1,5 @@ import difflib from datetime import datetime, timedelta, date, time -from pathlib import Path import tomllib from typing import List @@ -9,9 +8,11 @@ from better_rich_prompts.prompt import ListPrompt from dateutil import tz from toggl_python.entities import TimeEntry +import utils + _config = {} -CONFIG_PATH = Path("config/.toggl.toml") +CONFIG_PATH = utils.get_project_root() / "things2reclaim/config/.toggl.toml" with open(CONFIG_PATH, "rb") as f: _config = tomllib.load(f) @@ -32,6 +33,8 @@ time_entry_editor = toggl_python.WorkspaceTimeEntries( def get_time_entry(time_entry_id: int) -> toggl_python.TimeEntry: return toggl_python.TimeEntries(auth=auth).retrieve(time_entry_id) +def delete_time_entry(time_entry_id: int) -> bool: + return time_entry_editor.delete_timeentry(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 diff --git a/things2reclaim/utils.py b/things2reclaim/utils.py index e1426df..62c78da 100644 --- a/things2reclaim/utils.py +++ b/things2reclaim/utils.py @@ -1,13 +1,15 @@ -from datetime import datetime +from datetime import datetime, timedelta import re -from typing import Union, Dict, TypeVar, List +from typing import Union, Dict, TypeVar, List, Optional import difflib from dateutil import tz +import emoji +from pathlib import Path from better_rich_prompts.prompt import ListPrompt from rich import print as rprint - -import things_handler +from toggl_python import TimeEntry +from reclaim_sdk.models.task_event import ReclaimTaskEvent TIME_PATTERN = ( r"((\d+\.?\d*) (hours|hrs|hour|hr|h))? ?((\d+\.?\d*) (mins|min|minutes|minute|m))?" @@ -36,6 +38,21 @@ def calculate_time_on_unit(tag_value: str) -> float: return time +def get_start_time(toggl_time_entry: TimeEntry): + return toggl_time_entry.start() if callable(toggl_time_entry.start) else toggl_time_entry.start + + +def get_stop_time(time_entry: 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_clean_time_entry_name(name : str): + return emoji.replace_emoji(name).lstrip() + + def map_tag_values( things_task, tags_dict: Dict[str, str], @@ -73,6 +90,45 @@ def get_closest_match(name: str, candidates: Dict[str, T]) -> T | None: return candidates[ListPrompt.ask("Select a candidate", possible_candidates)] +def nearest_time_entry(items: Optional[List[ReclaimTaskEvent]], pivot: TimeEntry) -> Optional[ReclaimTaskEvent]: + if not items: + return None + return min(items, key=lambda x: abs(x.start - get_start_time(pivot))) + + +def is_matching_time_entry(toggl_time_entry : Optional[TimeEntry], reclaim_time_entry : Optional[ReclaimTaskEvent]): + if toggl_time_entry is None or reclaim_time_entry is None: + print("One is none") + return False + if toggl_time_entry.description != get_clean_time_entry_name(reclaim_time_entry.name): + print(f"toggl title: {toggl_time_entry.description}") + print(f"reclaim title: {get_clean_time_entry_name(reclaim_time_entry.name)}") + return False + + toggl_start = get_start_time(toggl_time_entry) + toggl_stop = get_stop_time(toggl_time_entry) + reclaim_start = reclaim_time_entry.start + reclaim_end = reclaim_time_entry.end + + if toggl_start.tzinfo is None: + raise ValueError("Toggl start is not timezone aware.") + if toggl_stop.tzinfo is None: + raise ValueError("Toggl stop is not timezone aware.") + if reclaim_start.tzinfo is None: + raise ValueError("Reclaim start is not timezone aware.") + if reclaim_end.tzinfo is None: + raise ValueError("Reclaim stop is not timezone aware.") + + if toggl_start.astimezone(tz.tzutc()) != reclaim_start.astimezone(tz.tzutc()): + print(f"toggl_start: {toggl_start.isoformat()}") + print(f"reclaim_start: {reclaim_start.isoformat()}") + return False + if toggl_stop.astimezone(tz.tzutc()) != reclaim_end.astimezone(tz.tzutc()): + print(f"toggl_end: {toggl_stop.isoformat()}") + print(f"reclaim_end: {reclaim_end.isoformat()}") + return False + return True + def pinfo(msg: str): rprint(f"[bold white]{msg}[/bold white]") @@ -122,5 +178,5 @@ def reclaim_task_pretty_print(task): print(f"\tDuration: {task.duration}") -def things_reclaim_is_equal(things_task, reclaim_task) -> bool: - return things_handler.full_name(things_task=things_task) == reclaim_task.name +def get_project_root() -> Path: + return Path(__file__).parent.parent