Log work in reclaim
This commit is contained in:
0
things2reclaim/__init__.py
Normal file
0
things2reclaim/__init__.py
Normal file
@@ -1,17 +1,18 @@
|
|||||||
#!/opt/homebrew/Caskroom/miniconda/base/envs/things-automation/bin/python3
|
#!/opt/homebrew/Caskroom/miniconda/base/envs/things-automation/bin/python3
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from dateutil import tz
|
||||||
|
from typing import Optional, List, Dict
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import typer
|
import typer
|
||||||
from pytz import timezone
|
|
||||||
from rich import print as rprint
|
from rich import print as rprint
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
from rich.prompt import Confirm
|
||||||
from typing_extensions import Annotated
|
from typing_extensions import Annotated
|
||||||
|
|
||||||
import utils
|
import utils
|
||||||
@@ -28,12 +29,15 @@ with open(CONFIG_PATH, "rb") as f:
|
|||||||
|
|
||||||
DATABASE_PATH = _config["database"]["path"]
|
DATABASE_PATH = _config["database"]["path"]
|
||||||
|
|
||||||
app = typer.Typer(add_completion=False, no_args_is_help=True)
|
app = typer.Typer(
|
||||||
|
add_completion=False, no_args_is_help=True, pretty_exceptions_enable=False
|
||||||
|
)
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def complete_task_name(incomplete: str):
|
def complete_task_name(incomplete: str):
|
||||||
for name in [task.name for task in reclaim_handler.get_reclaim_tasks()]:
|
for name in [task.name for task in reclaim_handler.get_reclaim_tasks()]:
|
||||||
|
print(name)
|
||||||
if name.startswith(incomplete):
|
if name.startswith(incomplete):
|
||||||
yield name
|
yield name
|
||||||
|
|
||||||
@@ -44,6 +48,7 @@ def things_to_reclaim(things_task):
|
|||||||
if estimated_time is None:
|
if estimated_time is None:
|
||||||
raise ValueError("EstimatedTime tag is required")
|
raise ValueError("EstimatedTime tag is required")
|
||||||
estimated_time = utils.calculate_time_on_unit(estimated_time)
|
estimated_time = utils.calculate_time_on_unit(estimated_time)
|
||||||
|
del tags["EstimatedTime"]
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"name": things_handler.full_name(things_task),
|
"name": things_handler.full_name(things_task),
|
||||||
@@ -59,13 +64,13 @@ def things_to_reclaim(things_task):
|
|||||||
f"{things_task['start_date']} 08:00", "%Y-%m-%d %H:%M"
|
f"{things_task['start_date']} 08:00", "%Y-%m-%d %H:%M"
|
||||||
)
|
)
|
||||||
if things_task.get("deadline"):
|
if things_task.get("deadline"):
|
||||||
params["due_date"] = (
|
params["due_date"] = datetime.strptime(
|
||||||
datetime.strptime(f"{things_task['deadline']} 22:00", "%Y-%m-%d %H:%M"),
|
f"{things_task['deadline']} 22:00", "%Y-%m-%d %H:%M"
|
||||||
)
|
)
|
||||||
|
|
||||||
utils.map_tag_values(things_task, tags, params)
|
utils.map_tag_values(things_task, tags, params)
|
||||||
|
|
||||||
reclaim_handler.create_reaclaim_task(**params)
|
reclaim_handler.create_reaclaim_task_from_dict(params)
|
||||||
|
|
||||||
|
|
||||||
@app.command("init")
|
@app.command("init")
|
||||||
@@ -131,7 +136,7 @@ def list_reclaim_tasks(subject: Annotated[Optional[str], typer.Argument()] = Non
|
|||||||
reclaim_tasks = reclaim_handler.get_reclaim_tasks()
|
reclaim_tasks = reclaim_handler.get_reclaim_tasks()
|
||||||
if subject is not None:
|
if subject is not None:
|
||||||
reclaim_tasks = reclaim_handler.filter_for_subject(subject, reclaim_tasks)
|
reclaim_tasks = reclaim_handler.filter_for_subject(subject, reclaim_tasks)
|
||||||
current_date = datetime.now().replace(tzinfo=timezone("UTC"))
|
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 id, task in enumerate(reclaim_tasks):
|
for id, task in enumerate(reclaim_tasks):
|
||||||
if current_date > task.due_date:
|
if current_date > task.due_date:
|
||||||
@@ -153,11 +158,73 @@ def list_reclaim_tasks(subject: Annotated[Optional[str], typer.Argument()] = Non
|
|||||||
|
|
||||||
@app.command("start")
|
@app.command("start")
|
||||||
def start_task(
|
def start_task(
|
||||||
task_name: Annotated[
|
task_name_parts: Annotated[List[str], typer.Argument(help="Task to start")],
|
||||||
str, typer.Option(help="Task to start", autocompletion=complete_task_name)
|
|
||||||
],
|
|
||||||
):
|
):
|
||||||
print(f"Starting task: {task_name}")
|
task_name = (" ").join(task_name_parts)
|
||||||
|
tasks: Dict[str, reclaim_handler.ReclaimTask] = {
|
||||||
|
task.name: task for task in reclaim_handler.get_reclaim_tasks()
|
||||||
|
}
|
||||||
|
if task_name not in tasks.keys():
|
||||||
|
task = utils.get_closest_match(task_name, tasks)
|
||||||
|
if task is None:
|
||||||
|
utils.perror(f"No task with name {task_name} found")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
task = tasks[task_name]
|
||||||
|
|
||||||
|
current_task = toggl_handler.get_current_time_entry()
|
||||||
|
if current_task is not None:
|
||||||
|
utils.perror("Toggl Track is already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
toggl_handler.start_task(task.name, reclaim_handler.get_project(task))
|
||||||
|
reclaim_handler.start_task(task)
|
||||||
|
print(f"Started task {task.name}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("stop")
|
||||||
|
def stop_task():
|
||||||
|
current_task = toggl_handler.get_current_time_entry()
|
||||||
|
if current_task is None:
|
||||||
|
utils.perror("No task is currently tracked in toggl")
|
||||||
|
return
|
||||||
|
|
||||||
|
stopped_task_name = current_task.description
|
||||||
|
|
||||||
|
if stopped_task_name is None:
|
||||||
|
utils.perror("Current toggl task has no name")
|
||||||
|
return
|
||||||
|
|
||||||
|
reclaim_dict = {task.name: task for task in reclaim_handler.get_reclaim_tasks()}
|
||||||
|
|
||||||
|
if stopped_task_name in reclaim_dict.keys():
|
||||||
|
stopped_task = reclaim_dict[stopped_task_name]
|
||||||
|
else:
|
||||||
|
stopped_task = utils.get_closest_match(stopped_task_name, reclaim_dict)
|
||||||
|
|
||||||
|
if stopped_task is None:
|
||||||
|
utils.perror(f"{stopped_task} not found in reclaim")
|
||||||
|
return
|
||||||
|
stop_time = datetime.now(tz.tzutc())
|
||||||
|
if callable(current_task.start):
|
||||||
|
current_task.start = current_task.start()
|
||||||
|
|
||||||
|
toggl_handler.stop_current_task()
|
||||||
|
|
||||||
|
if stopped_task.is_scheduled:
|
||||||
|
reclaim_handler.log_work_for_task(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:
|
||||||
|
utils.pwarning("Work could not be logged in reclaim[")
|
||||||
|
time_format = "%H:%M"
|
||||||
|
local_zone = tz.gettz()
|
||||||
|
rprint(
|
||||||
|
f"Logged work from {current_task.start.astimezone(local_zone).strftime(time_format)} to {stop_time.astimezone(local_zone).strftime(time_format)} for {stopped_task.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command("stats")
|
@app.command("stats")
|
||||||
@@ -165,7 +232,7 @@ def show_task_stats():
|
|||||||
"""
|
"""
|
||||||
Show task stats
|
Show task stats
|
||||||
"""
|
"""
|
||||||
current_date = datetime.now().replace(tzinfo=timezone("UTC"))
|
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 = [task for task in reclaim_tasks if task.due_date >= current_date]
|
||||||
tasks_overdue = [task for task in reclaim_tasks if task.due_date < current_date]
|
tasks_overdue = [task for task in reclaim_tasks if task.due_date < current_date]
|
||||||
@@ -208,7 +275,7 @@ def print_time_needed():
|
|||||||
print("To many to-dos on list. Not all are scheduled")
|
print("To many to-dos on list. Not all are scheduled")
|
||||||
return
|
return
|
||||||
last_task_date = tasks[-1].scheduled_start_date
|
last_task_date = tasks[-1].scheduled_start_date
|
||||||
today = datetime.now().replace(tzinfo=timezone("UTC"))
|
today = datetime.now(tz.tzutc())
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"Last task is scheduled for {last_task_date.strftime('%d.%m.%Y')} ({last_task_date - today} till completion)"
|
f"Last task is scheduled for {last_task_date.strftime('%d.%m.%Y')} ({last_task_date - today} till completion)"
|
||||||
@@ -243,10 +310,10 @@ def sync_things_and_reclaim(verbose: bool = False):
|
|||||||
First updated all finished tasks in reclaim to completed in things
|
First updated all finished tasks in reclaim to completed in things
|
||||||
Then upload all new tasks from things to reclaim
|
Then upload all new tasks from things to reclaim
|
||||||
"""
|
"""
|
||||||
rprint("[bold white]Pulling from Reclaim[/bold white]")
|
utils.pinfo("Pulling from Reclaim")
|
||||||
remove_finished_tasks_from_things()
|
remove_finished_tasks_from_things()
|
||||||
rprint("---------------------------------------------")
|
rprint("---------------------------------------------")
|
||||||
rprint("[bold white]Pushing to Reclaim[/bold white]")
|
utils.pinfo("Pushing to Reclaim")
|
||||||
upload_things_to_reclaim(verbose=verbose)
|
upload_things_to_reclaim(verbose=verbose)
|
||||||
rprint("---------------------------------------------")
|
rprint("---------------------------------------------")
|
||||||
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
from typing import List
|
from datetime import datetime
|
||||||
import tomllib
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
from reclaim_sdk.models.task import ReclaimTask
|
import tomllib
|
||||||
|
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_event import ReclaimTaskEvent
|
||||||
|
|
||||||
CONFIG_PATH = Path("config/.reclaim.toml")
|
CONFIG_PATH = Path("config/.reclaim.toml")
|
||||||
|
|
||||||
@@ -17,7 +20,7 @@ RECLAIM_TOKEN = _config["reclaim_ai"]["token"]
|
|||||||
ReclaimClient(token=RECLAIM_TOKEN)
|
ReclaimClient(token=RECLAIM_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
def get_reclaim_tasks():
|
def get_reclaim_tasks() -> List[ReclaimTask]:
|
||||||
return ReclaimTask.search()
|
return ReclaimTask.search()
|
||||||
|
|
||||||
|
|
||||||
@@ -25,10 +28,45 @@ 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 create_reaclaim_task(**params):
|
def get_project(task: ReclaimTask):
|
||||||
new_task = ReclaimTask(**params)
|
return task.name.split(" ")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def start_task(task: ReclaimTask):
|
||||||
|
task.prioritize()
|
||||||
|
|
||||||
|
|
||||||
|
def create_reaclaim_task_from_dict(params: Dict):
|
||||||
|
new_task = ReclaimTask()
|
||||||
|
for key, value in params.items():
|
||||||
|
setattr(new_task, key, value)
|
||||||
new_task.save()
|
new_task.save()
|
||||||
|
|
||||||
|
|
||||||
|
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 RuntimeError("Task is not scheduled")
|
||||||
|
|
||||||
|
last_event: ReclaimTaskEvent = task.events[-1]
|
||||||
|
|
||||||
|
last_event.start = start.astimezone(utc)
|
||||||
|
last_event.end = end.astimezone(utc)
|
||||||
|
last_event._update()
|
||||||
|
|
||||||
|
|
||||||
|
def finish_task(task: ReclaimTask):
|
||||||
|
task.mark_complete()
|
||||||
|
|
||||||
|
|
||||||
def get_reclaim_things_ids() -> List[str]:
|
def get_reclaim_things_ids() -> List[str]:
|
||||||
return [task.description.split(":")[1].strip() for task in ReclaimTask.search()]
|
return [task.description.split(":")[1].strip() for task in ReclaimTask.search()]
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ from toggl_python.entities import TimeEntry
|
|||||||
import toggl_python
|
import toggl_python
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from dateutil import tz
|
||||||
|
import difflib
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from better_rich_prompts.prompt import ListPrompt
|
||||||
|
|
||||||
|
|
||||||
_config = {}
|
_config = {}
|
||||||
|
|
||||||
@@ -25,6 +29,10 @@ time_entry_editor = toggl_python.WorkspaceTimeEntries(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_time_entry(id: int):
|
||||||
|
return toggl_python.TimeEntries(auth=auth).retrieve(id)
|
||||||
|
|
||||||
|
|
||||||
def get_time_entries(since_days: int = 30):
|
def get_time_entries(since_days: int = 30):
|
||||||
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")
|
||||||
@@ -32,7 +40,7 @@ def get_time_entries(since_days: int = 30):
|
|||||||
return toggl_python.TimeEntries(auth=auth).list(since=time_stamp)
|
return toggl_python.TimeEntries(auth=auth).list(since=time_stamp)
|
||||||
|
|
||||||
|
|
||||||
def get_current_time_entry():
|
def get_current_time_entry() -> TimeEntry | None:
|
||||||
time_entries = toggl_python.TimeEntries(auth=auth)
|
time_entries = toggl_python.TimeEntries(auth=auth)
|
||||||
time_entries.ADDITIONAL_METHODS = {
|
time_entries.ADDITIONAL_METHODS = {
|
||||||
"current": {
|
"current": {
|
||||||
@@ -44,16 +52,57 @@ def get_current_time_entry():
|
|||||||
return time_entries.current()
|
return time_entries.current()
|
||||||
|
|
||||||
|
|
||||||
def create_task_time_entry(description: str, project: str):
|
def get_tags():
|
||||||
|
return toggl_python.Workspaces(auth=auth).tags(_id=workspace.id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_approriate_tag(description: str) -> str | None:
|
||||||
|
tag_dict = {tag.name: tag for tag in get_tags()}
|
||||||
|
parts = description.replace("VL", "Vorlesung").split(" ")
|
||||||
|
possible_tags = set()
|
||||||
|
for part in parts:
|
||||||
|
possible_tags.update(difflib.get_close_matches(part, tag_dict.keys()))
|
||||||
|
|
||||||
|
if not possible_tags:
|
||||||
|
print("Found no matching tags")
|
||||||
|
return
|
||||||
|
|
||||||
|
possible_tags = list(possible_tags)
|
||||||
|
|
||||||
|
if len(possible_tags) == 1:
|
||||||
|
return possible_tags[0]
|
||||||
|
else:
|
||||||
|
return ListPrompt.ask("Select the best fitting tag", possible_tags)
|
||||||
|
|
||||||
|
|
||||||
|
def create_task_time_entry(
|
||||||
|
description: str, project: str, start: datetime | None = None, duration: int = -1
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
duration is in seconds
|
||||||
|
"""
|
||||||
if project not in project_dict.keys():
|
if project not in project_dict.keys():
|
||||||
raise ValueError(f"{project} is not an active toggl project")
|
raise ValueError(f"{project} is not an active toggl project")
|
||||||
|
|
||||||
time_entry = TimeEntry(
|
time_entry = TimeEntry(
|
||||||
created_with="things-automation",
|
created_with="things-automation",
|
||||||
wid=workspace.id,
|
wid=workspace.id,
|
||||||
pid=project_dict[project].id,
|
pid=project_dict[project].id,
|
||||||
description=description,
|
description=description,
|
||||||
duration=-1,
|
duration=duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if start is not None:
|
||||||
|
if start.tzinfo is None:
|
||||||
|
raise ValueError("start has to be timezone aware")
|
||||||
|
else:
|
||||||
|
start = start.astimezone(tz.tzutc())
|
||||||
|
time_entry.start = start
|
||||||
|
|
||||||
|
tag = get_approriate_tag(description)
|
||||||
|
if tag:
|
||||||
|
time_entry.tags = [tag]
|
||||||
|
|
||||||
return time_entry
|
return time_entry
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import re
|
import re
|
||||||
from typing import Union, Dict
|
from typing import Union, Dict, Any, List
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
from rich import print as rprint
|
||||||
import things_handler
|
import things_handler
|
||||||
|
from better_rich_prompts.prompt import ListPrompt
|
||||||
|
|
||||||
regex = (
|
regex = (
|
||||||
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))?"
|
||||||
@@ -55,6 +59,28 @@ 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:
|
||||||
|
possible_candidates: List[str] = difflib.get_close_matches(name, candidates.keys())
|
||||||
|
if not possible_candidates:
|
||||||
|
return None
|
||||||
|
if len(possible_candidates) == 1:
|
||||||
|
return candidates[possible_candidates[0]]
|
||||||
|
else:
|
||||||
|
return candidates[ListPrompt.ask("Select a candidate", possible_candidates)]
|
||||||
|
|
||||||
|
|
||||||
|
def pinfo(msg: str):
|
||||||
|
rprint(f"[bold white]{msg}[/bold white]")
|
||||||
|
|
||||||
|
|
||||||
|
def pwarning(msg: str):
|
||||||
|
rprint(f"[bold yellow]Warning: {msg}[/bold yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
def perror(msg: str):
|
||||||
|
rprint(f"[bold red]Error: {msg}[/bold red]")
|
||||||
|
|
||||||
|
|
||||||
def generate_things_id_tag(things_task) -> str:
|
def generate_things_id_tag(things_task) -> str:
|
||||||
return f"things_task:{things_task["uuid"]}"
|
return f"things_task:{things_task["uuid"]}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user