[WIP] toggl returns all tasks ever created no matter if they got deleted
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,34 +113,22 @@ 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")
|
||||
@@ -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("---------------------------------------------")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user