[WIP] toggl returns all tasks ever created no matter if they got deleted

This commit is contained in:
2024-06-21 04:01:24 +02:00
parent dedb0bc195
commit 31f5794e72
6 changed files with 150 additions and 79 deletions

View File

@@ -1,4 +1,5 @@
import sqlite3 import sqlite3
from typing import List
class UploadedTasksDB: class UploadedTasksDB:
@@ -32,7 +33,7 @@ class UploadedTasksDB:
cursor.execute(insert_statement, [task_id]) cursor.execute(insert_statement, [task_id])
self.conn.commit() self.conn.commit()
def get_all_uploaded_tasks(self): def get_all_uploaded_tasks(self) -> List[str]:
cursor = self.conn.cursor() cursor = self.conn.cursor()
cursor.execute("SELECT * FROM uploaded_tasks") cursor.execute("SELECT * FROM uploaded_tasks")
rows = cursor.fetchall() rows = cursor.fetchall()

View File

@@ -1,10 +1,10 @@
#!/opt/homebrew/Caskroom/miniconda/base/envs/things-automation/bin/python3 #!/opt/homebrew/Caskroom/miniconda/base/envs/things-automation/bin/python3
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime, time
from pathlib import Path
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
import tomllib import tomllib
import itertools
from dateutil import tz from dateutil import tz
from rich import print as rprint from rich import print as rprint
@@ -22,13 +22,13 @@ import toggl_handler
import utils import utils
from database_handler import UploadedTasksDB from database_handler import UploadedTasksDB
CONFIG_PATH = Path("config/.things2reclaim.toml") CONFIG_PATH = utils.get_project_root() / "things2reclaim/config/.things2reclaim.toml"
_config = {} _config = {}
with open(CONFIG_PATH, "rb") as f: with open(CONFIG_PATH, "rb") as f:
_config = tomllib.load(f) _config = tomllib.load(f)
DATABASE_PATH = _config["database"]["path"] DATABASE_PATH = utils.get_project_root() / _config["database"]["path"]
app = typer.Typer( app = typer.Typer(
add_completion=False, no_args_is_help=True add_completion=False, no_args_is_help=True
@@ -113,34 +113,22 @@ def initialize_uploaded_database(verbose: bool = False):
@app.command("upload") @app.command("upload")
def upload_things_to_reclaim(verbose: bool = False): def upload_things_to_reclaim():
""" """
Upload things tasks to reclaim Upload things tasks to reclaim
""" """
projects = things_handler.extract_uni_projects() tasks = things_handler.get_all_things_tasks()
reclaim_task_names = reclaim_handler.get_reclaim_task_names()
tasks_uploaded = 0
with UploadedTasksDB(DATABASE_PATH) as db: with UploadedTasksDB(DATABASE_PATH) as db:
for project in projects: uploaded_task_ids = db.get_all_uploaded_tasks()
things_tasks = things_handler.get_tasks_for_project(project) tasks_to_upload = [task for task in tasks if task["uuid"] not in uploaded_task_ids]
for things_task in things_tasks: if not tasks_to_upload:
full_task_name = things_handler.full_name( print("No new tasks were found")
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: else:
if verbose: for task in tasks_to_upload:
print( print(f"Creating task {things_handler.full_name(task)} in Reclaim")
f"Task {things_task['title']} \ things_to_reclaim(task)
already exists in Reclaim") db.add_uploaded_task(task["uuid"])
if tasks_uploaded == 0: print(f"Uploaded {len(tasks_to_upload)} task{'s' if len(tasks_to_upload) > 1 else ''}")
rprint("No new tasks were found")
elif tasks_uploaded == 1:
rprint(f"Uploaded {tasks_uploaded} task{
's' if tasks_uploaded > 1 else ''}")
@app.command("list") @app.command("list")
@@ -315,36 +303,46 @@ def remove_finished_tasks_from_things():
Complete finished reclaim tasks in things Complete finished reclaim tasks in things
""" """
reclaim_things_uuids = reclaim_handler.get_reclaim_things_ids() reclaim_things_uuids = reclaim_handler.get_reclaim_things_ids()
finished_someting = False tasks_to_be_removed = [task 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: if not tasks_to_be_removed:
finished_someting = True print("Reclaim and Things are synced!")
else:
for task in tasks_to_be_removed:
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"]) finish_task(task["uuid"])
if not finished_someting:
print("Reclaim and Things are synced")
@app.command("tracking") @app.command("tracking")
def sync_toggl_reclaim_tracking(): def sync_toggl_reclaim_tracking(since_days : Annotated[int, typer.Argument()] = 0):
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 toggl_time_entries = toggl_handler.get_time_entries_since(since_days = since_days) # end date is inclusive
if toggl_time_entries is None: 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 return
reclaim_time_entries = reclaim_handler.get_task_events_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
reclaim_time_entry_names = [reclaim_handler.get_clean_time_entry_name(time_entry.name) for time_entry in reclaim_time_entries] 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)}
missing_reclaim_entries = [time_entry for time_entry in toggl_time_entries if time_entry.description not in reclaim_time_entry_names] 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.") utils.pinfo("Toggl and Reclaim time entries are synced.")
else: else:
for time_entry in missing_reclaim_entries: for time_entry in mismatched_time_entries:
name = time_entry.description name = time_entry.description
if not name: if not name:
raise ValueError("Toggl time entry has empty description") 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.") utils.pwarning(f"Couldn't find {time_entry.description} in Reclaim.")
continue continue
start = toggl_handler.get_start_time(time_entry) toggl_start = toggl_handler.get_start_time(time_entry)
stop = toggl_handler.get_stop_time(time_entry) toggl_stop = toggl_handler.get_stop_time(time_entry)
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) 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) utils.plogtime(start, stop, reclaim_task.name)
@@ -372,7 +381,7 @@ def display_current_task():
@app.command("sync") @app.command("sync")
def sync_things_and_reclaim(verbose: bool = False): def sync_things_and_reclaim():
""" """
Sync tasks between things and reclaim Sync tasks between things and reclaim
First updated all finished tasks in reclaim to completed in things 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() remove_finished_tasks_from_things()
rprint("---------------------------------------------") rprint("---------------------------------------------")
utils.pinfo("Pushing to Reclaim") utils.pinfo("Pushing to Reclaim")
upload_things_to_reclaim(verbose=verbose) upload_things_to_reclaim()
rprint("---------------------------------------------") rprint("---------------------------------------------")

View File

@@ -1,5 +1,4 @@
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from pathlib import Path
from typing import List, Dict, Pattern, Optional from typing import List, Dict, Pattern, Optional
import re import re
@@ -11,8 +10,9 @@ 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 from deadline_status import DeadlineStatus
import utils
CONFIG_PATH = Path("config/.reclaim.toml") CONFIG_PATH = utils.get_project_root() / "things2reclaim/config/.reclaim.toml"
_config = {} _config = {}
@@ -64,10 +64,6 @@ def get_project(task: ReclaimTask):
return task.name.split(" ")[0] 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): def is_task_time_entry(name: str):
decoded_name = emoji.demojize(name) decoded_name = emoji.demojize(name)
# task entries start either with a :thumbs_up: or a :check_mark_button: emoji # 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) events = ReclaimTaskEvent.search(date_since, date_end)
return [event for event in events if is_task_time_entry(event.name)] return [event for event in events if is_task_time_entry(event.name)]
def get_events_date_range(from_date: date, to_date: date): def get_events_date_range(from_date: date, to_date: date):
return ReclaimTaskEvent.search(from_date, to_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 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: if not task.is_scheduled:
raise ValueError("Task is not 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") raise ValueError("Event list is empty")
last_event: ReclaimTaskEvent = task.events[-1] 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) def adjust_time_entry(time_entry: ReclaimTaskEvent, start: datetime, end: datetime):
last_event.save() 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): def finish_task(task: ReclaimTask):

View File

@@ -1,18 +1,18 @@
from typing import Dict, List from typing import Dict, List
from pathlib import Path
import tomllib import tomllib
import things import things
from database_handler import UploadedTasksDB from database_handler import UploadedTasksDB
import utils
_config = {} _config = {}
CONFIG_PATH = Path("config/.things2reclaim.toml") CONFIG_PATH = utils.get_project_root() / "things2reclaim/config/.things2reclaim.toml"
with open(CONFIG_PATH, "rb") as f: with open(CONFIG_PATH, "rb") as f:
_config = tomllib.load(f) _config = tomllib.load(f)
DATABASE_PATH = _config["database"]["path"] DATABASE_PATH = utils.get_project_root() / _config["database"]["path"]
def extract_uni_projects(): 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") 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: def get_all_things_tasks() -> List:
tasks = [] tasks = []
projects = extract_uni_projects() projects = extract_uni_projects()

View File

@@ -1,6 +1,5 @@
import difflib import difflib
from datetime import datetime, timedelta, date, time from datetime import datetime, timedelta, date, time
from pathlib import Path
import tomllib import tomllib
from typing import List from typing import List
@@ -9,9 +8,11 @@ from better_rich_prompts.prompt import ListPrompt
from dateutil import tz from dateutil import tz
from toggl_python.entities import TimeEntry from toggl_python.entities import TimeEntry
import utils
_config = {} _config = {}
CONFIG_PATH = Path("config/.toggl.toml") CONFIG_PATH = utils.get_project_root() / "things2reclaim/config/.toggl.toml"
with open(CONFIG_PATH, "rb") as f: with open(CONFIG_PATH, "rb") as f:
_config = tomllib.load(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: def get_time_entry(time_entry_id: int) -> toggl_python.TimeEntry:
return toggl_python.TimeEntries(auth=auth).retrieve(time_entry_id) 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): def get_start_time(time_entry : toggl_python.TimeEntry):
return time_entry.start() if callable(time_entry.start) else time_entry.start return time_entry.start() if callable(time_entry.start) else time_entry.start

View File

@@ -1,13 +1,15 @@
from datetime import datetime from datetime import datetime, timedelta
import re import re
from typing import Union, Dict, TypeVar, List from typing import Union, Dict, TypeVar, List, Optional
import difflib import difflib
from dateutil import tz from dateutil import tz
import emoji
from pathlib import Path
from better_rich_prompts.prompt import ListPrompt from better_rich_prompts.prompt import ListPrompt
from rich import print as rprint from rich import print as rprint
from toggl_python import TimeEntry
import things_handler from reclaim_sdk.models.task_event import ReclaimTaskEvent
TIME_PATTERN = ( TIME_PATTERN = (
r"((\d+\.?\d*) (hours|hrs|hour|hr|h))? ?((\d+\.?\d*) (mins|min|minutes|minute|m))?" 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 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( def map_tag_values(
things_task, things_task,
tags_dict: Dict[str, str], 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)] 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): def pinfo(msg: str):
rprint(f"[bold white]{msg}[/bold white]") rprint(f"[bold white]{msg}[/bold white]")
@@ -122,5 +178,5 @@ def reclaim_task_pretty_print(task):
print(f"\tDuration: {task.duration}") print(f"\tDuration: {task.duration}")
def things_reclaim_is_equal(things_task, reclaim_task) -> bool: def get_project_root() -> Path:
return things_handler.full_name(things_task=things_task) == reclaim_task.name return Path(__file__).parent.parent