Updated project structure

This commit is contained in:
2024-06-11 05:59:37 +02:00
parent 0303938374
commit b9256204d9
11 changed files with 940 additions and 297 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3
.gitignore vendored
View File

@@ -160,3 +160,6 @@ cython_debug/
#.idea/
.reclaim.toml
things2reclaim/config/.toggl.toml
things2reclaim/config/.things2reclaim.toml
things2reclaim/data/tasks.db

366
poetry.lock generated Normal file
View File

@@ -0,0 +1,366 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "anyio"
version = "4.4.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.8"
files = [
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
]
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
trio = ["trio (>=0.23)"]
[[package]]
name = "certifi"
version = "2024.6.2"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"},
{file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"},
]
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "h11"
version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.7"
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[[package]]
name = "h2"
version = "4.1.0"
description = "HTTP/2 State-Machine based protocol implementation"
optional = false
python-versions = ">=3.6.1"
files = [
{file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"},
{file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"},
]
[package.dependencies]
hpack = ">=4.0,<5"
hyperframe = ">=6.0,<7"
[[package]]
name = "hpack"
version = "4.0.0"
description = "Pure-Python HPACK header compression"
optional = false
python-versions = ">=3.6.1"
files = [
{file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"},
{file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"},
]
[[package]]
name = "httpcore"
version = "1.0.5"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]]
name = "httpx"
version = "0.27.0"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""}
httpcore = "==1.*"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
[[package]]
name = "hyperframe"
version = "6.0.1"
description = "HTTP/2 framing layer for Python"
optional = false
python-versions = ">=3.6.1"
files = [
{file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"},
{file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"},
]
[[package]]
name = "idna"
version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.8"
files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
code-style = ["pre-commit (>=3.0,<4.0)"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins"]
profiling = ["gprof2dot"]
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "pygments"
version = "2.18.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
files = [
{file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
{file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pytz"
version = "2024.1"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
files = [
{file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"},
{file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"},
]
[[package]]
name = "reclaim-sdk"
version = "0.4.2"
description = "Unofficial Reclaim.ai Python API"
optional = false
python-versions = "*"
files = []
develop = false
[package.dependencies]
httpx = {version = "*", extras = ["http2"]}
python-dateutil = "*"
toml = "*"
[package.source]
type = "git"
url = "https://github.com/cato447/reclaim-sdk"
reference = "HEAD"
resolved_reference = "95f2042e6c6da77b6d92577fa3274961afe412dc"
[[package]]
name = "rich"
version = "13.7.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "shellingham"
version = "1.5.4"
description = "Tool to Detect Surrounding Shell"
optional = false
python-versions = ">=3.7"
files = [
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
]
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "things.py"
version = "0.0.15"
description = "A simple Python 3 library to read your Things app data."
optional = false
python-versions = ">=3.7"
files = []
develop = false
[package.source]
type = "git"
url = "https://github.com/thingsapi/things.py"
reference = "HEAD"
resolved_reference = "85975337a4119fa80bf811e326eb863fce5f4905"
[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
[[package]]
name = "typer"
version = "0.12.3"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false
python-versions = ">=3.7"
files = [
{file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"},
{file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"},
]
[package.dependencies]
click = ">=8.0.0"
rich = ">=10.11.0"
shellingham = ">=1.3.0"
typing-extensions = ">=3.7.4.3"
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "47c36617ea3af29526db5695b4d54427a61e041c2e8c0ce4340f9afceff32684"

23
pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[tool.poetry]
name = "task-automation"
version = "0.1.0"
description = ""
authors = ["Simon Bußmann <simon.bussmann@tum.de>"]
readme = "README.md"
packages = [{include = "things2reclaim"}]
include = [{path = "things2reclaim/data", format=["sdist", "wheel"]},{path = "things2reclaim/configs", format=["sdist", "wheel"]}]
[tool.poetry.dependencies]
python = "^3.12"
pytz = "^2024.1"
things-py = {git = "https://github.com/thingsapi/things.py"}
reclaim-sdk = {git = "https://github.com/cato447/reclaim-sdk"}
rich = "^13.7.1"
typer = {extras = ["all"], version = "^0.12.3"}
[tool.poetry.scripts]
task-automation = 'things2reclaim.things2reclaim:main'
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View File

@@ -1,297 +0,0 @@
#!/opt/homebrew/Caskroom/miniconda/base/envs/things-automation/bin/python3
import re
from datetime import date, datetime, timedelta
from time import sleep
from typing import Dict, List, Optional
import things
import typer
from pytz import timezone
from reclaim_sdk.models.task import ReclaimTask
from rich import print as rprint
from rich.console import Console
from rich.table import Table
from rich.text import Text
from typing_extensions import Annotated
app = typer.Typer(add_completion=False, no_args_is_help=True)
console = Console()
regex = (
r"((\d+\.?\d*) (hours|hrs|hour|hr|h))? ?((\d+\.?\d*) (mins|min|minutes|minute|m))?"
)
pattern = re.compile(regex)
def extract_uni_projects():
uni_area = next(area for area in things.areas() if area["title"] == "Uni")
return things.projects(area=uni_area["uuid"])
def get_tasks_for_project(project) -> Dict | List[Dict]:
return things.tasks(project=project["uuid"], type="to-do")
def get_task_tags(things_task: Dict) -> Dict[str, str]:
return {k: v for (k, v) in [tag.split(": ") for tag in things_task["tags"]]}
def set_default_reclaim_values(things_task, reclaim_task):
tags_dict = get_task_tags(things_task)
estimated_time = tags_dict.get("EstimatedTime")
if estimated_time is None:
raise ValueError("EstimatedTime tag is required")
estimated_time = calculate_time_on_unit(estimated_time)
reclaim_task.min_work_duration = estimated_time
reclaim_task.max_work_duration = estimated_time
reclaim_task.duration = estimated_time
if things_task.get("start_date") is not None:
reclaim_task.start_date = datetime.strptime(
f"{things_task['start_date']} 08:00", "%Y-%m-%d %H:%M"
)
if things_task.get("deadline") is not None:
reclaim_task.due_date = datetime.strptime(
f"{things_task['deadline']} 22:00", "%Y-%m-%d %H:%M"
)
def calculate_time_on_unit(tag_value) -> float | None:
# This is a regex to match time in the format of 1h 30m
# Minutes are optional if hours are present
# Hours are optional if minutes are present
# The regex will match two words when the correct format is found (format and emtpy word)
values = pattern.findall(tag_value)
time = 0
if len(values) != 2:
raise ValueError("Invalid time format")
_, hours, _, _, mins, _ = values[0]
if "" == hours and "" == mins:
raise ValueError("Regex matched empty string")
if "" != hours:
time += float(hours)
if "" != mins:
time += float(mins) / 60
return time
def map_tag_values(things_task, reclaim_task):
tags_dict = get_task_tags(things_task)
for tag in tags_dict:
match tag:
case "MinTime":
reclaim_task.min_work_duration = calculate_time_on_unit(tags_dict[tag])
case "MaxTime":
reclaim_task.max_work_duration = calculate_time_on_unit(tags_dict[tag])
case "DeadlineTime":
if things_task.get("deadline") is not None:
reclaim_task.due_date = datetime.strptime(
f"{things_task['deadline']} {tags_dict[tag]}", "%Y-%m-%d %H:%M"
)
case "StartTime":
if things_task.get("start_date") is not None:
reclaim_task.start_date = datetime.strptime(
f"{things_task['start_date']} {tags_dict[tag]}",
"%Y-%m-%d %H:%M",
)
case _:
print(f"Tag {tag} not recognized")
def generate_things_id_tag(things_task) -> str:
return f"things_task:{things_task["uuid"]}"
def things_to_reclaim(things_task, project_title):
with ReclaimTask() as reclaim_task:
reclaim_task.name = "{} {}".format(project_title, things_task["title"])
reclaim_task.description = generate_things_id_tag(things_task=things_task)
set_default_reclaim_values(things_task=things_task, reclaim_task=reclaim_task)
map_tag_values(things_task=things_task, reclaim_task=reclaim_task)
reclaim_task_pretty_print(reclaim_task)
def things_task_pretty_print(task, project_title):
print(f"\tTitle: {project_title} {task['title']}")
print(f"\tStart date: {task['start_date']}")
print(f"\tDeadline: {task['deadline']}")
print(f"\tTags: {task['tags']}")
def reclaim_task_pretty_print(task):
print(f"\tTitle: {task.name}")
print(f"\tStart date: {task.start_date}")
print(f"\tDeadline: {task.due_date}")
print(f"\tMin work duration: {task.min_work_duration}")
print(f"\tMax work duration: {task.max_work_duration}")
print(f"\tDuration: {task.duration}")
def full_name(things_task) -> str:
return f"{things_task['project_title']} {things_task['title']}"
def get_all_things_tasks() -> List:
tasks = []
projects = extract_uni_projects()
for project in projects:
tasks += get_tasks_for_project(project)
return tasks
def things_reclaim_is_equal(things_task, reclaim_task) -> bool:
return full_name(things_task=things_task) == reclaim_task.name
def get_course_names():
projects = extract_uni_projects()
return [course["title"] for course in projects]
@app.command("upload")
def upload_things_to_reclaim(verbose: bool = False):
"""
Upload things tasks to reclaim
"""
projects = extract_uni_projects()
reclaim_task_names = [task.name for task in ReclaimTask().search()]
tasks_uploaded = 0
for project in projects:
things_tasks = get_tasks_for_project(project)
for things_task in things_tasks:
full_task_name = 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, project["title"])
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 ''}")
def get_subject_reclaim_tasks(subject, tasks):
return [task for task in tasks if task.name.startswith(subject)]
@app.command("list")
def list_reclaim_tasks(subject: Annotated[Optional[str], typer.Argument()] = None):
"""
List all current tasks
"""
reclaim_tasks = ReclaimTask().search()
if subject is not None:
reclaim_tasks = get_subject_reclaim_tasks(subject, reclaim_tasks)
current_date = datetime.now().replace(tzinfo=timezone("UTC"))
table = Table("Index", "Task", "Days left", title="Task list")
for id, task in enumerate(reclaim_tasks):
if current_date > task.due_date:
days_behind = (current_date - task.due_date).days
table.add_row(
f"({id + 1})",
task.name,
Text(f"{days_behind} days overdue", style="bold red"),
)
else:
days_left = (task.due_date - current_date).days
table.add_row(
f"({id + 1})",
task.name,
Text(f"{days_left} days left", style="bold white"),
)
console.print(table)
@app.command("stats")
def show_task_stats():
"""
Show task stats
"""
current_date = datetime.now().replace(tzinfo=timezone("UTC"))
reclaim_tasks = ReclaimTask().search()
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]
fine_per_course = ["Fine"]
overdue_per_course = ["Overdue"]
course_names = get_course_names()
for course_name in course_names:
fine_per_course.append(
str(len(get_subject_reclaim_tasks(course_name, tasks_fine)))
)
overdue_per_course.append(
str(len(get_subject_reclaim_tasks(course_name, tasks_overdue)))
)
table = Table(*(["Status"] + course_names))
table.add_row(*fine_per_course)
table.add_row(*overdue_per_course)
console.print(table)
@app.command("time")
def print_time_needed():
"""
Print sum of time needed for all reclaim tasks
"""
tasks = ReclaimTask.search()
tasks.sort(key=lambda x: x.scheduled_start_date)
time_needed = 0
for task in tasks:
time_needed += task.duration
print(f"Time needed to complete {len(tasks)} Tasks: {time_needed} hrs")
print(f"Average time needed to complete a Task: {time_needed/len(tasks):.2f} hrs")
last_task_date = tasks[-1].scheduled_start_date
today = datetime.now().replace(tzinfo=timezone("UTC"))
print(
f"Last task is scheduled for {last_task_date.strftime('%d.%m.%Y')} ({last_task_date - today} till completion)"
)
@app.command("finished")
def remove_finished_tasks_from_things():
"""
Complete finished reclaim tasks in things
"""
reclaim_things_uuids = [
task.description.split(":")[1].strip() for task in ReclaimTask.search()
]
finished_someting = False
for task in get_all_things_tasks():
if task["uuid"] not in reclaim_things_uuids:
finished_someting = True
print(f"Found completed task: {full_name(things_task=task)}")
things.complete(task["uuid"])
if not finished_someting:
print("Reclaim and Things are synced")
@app.command("sync")
def sync_things_and_reclaim(verbose: bool = False):
"""
Sync tasks between things and reclaim
First updated all finished tasks in reclaim to completed in things
Then upload all new tasks from things to reclaim
"""
rprint("[bold white]Pulling from Reclaim[/bold white]")
remove_finished_tasks_from_things()
rprint("---------------------------------------------")
sleep(2)
rprint("[bold white]Pushing to Reclaim[/bold white]")
upload_things_to_reclaim(verbose=verbose)
rprint("---------------------------------------------")
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,45 @@
import sqlite3
class UploadedTasksDB(object):
def __init__(self, filename):
self.conn: sqlite3.Connection = sqlite3.connect(filename)
self.__create_tables()
def __enter__(self):
return self
def __exit__(self, type, value, traceback):
self.conn.close()
def __create_tables(self):
sql_statements = [
"""CREATE TABLE IF NOT EXISTS uploaded_tasks (
id integer primary key,
things_task_id varchar(36) NOT NULL UNIQUE
)
"""
]
cursor = self.conn.cursor()
for statement in sql_statements:
cursor.execute(statement)
self.conn.commit()
def add_uploaded_task(self, task_id: str):
insert_statement = "INSERT INTO uploaded_tasks(things_task_id) VALUES(?)"
cursor = self.conn.cursor()
cursor.execute(insert_statement, [task_id])
self.conn.commit()
def get_all_uploaded_tasks(self):
cursor = self.conn.cursor()
cursor.execute("SELECT * FROM uploaded_tasks")
rows = cursor.fetchall()
return [things_id for (_, things_id) in rows]
def remove_uploaded_task(self, task_id: str):
delete_statement = "DELETE FROM uploaded_tasks WHERE things_task_id = ?"
cursor = self.conn.cursor()
cursor.execute(delete_statement, (task_id,))
self.conn.commit()

View File

@@ -0,0 +1,34 @@
from typing import List
import tomllib
from pathlib import Path
from reclaim_sdk.models.task import ReclaimTask
from reclaim_sdk.client import ReclaimClient
CONFIG_PATH = Path("config/.reclaim.toml")
_config = {}
with open(CONFIG_PATH, "rb") as f:
_config = tomllib.load(f)
RECLAIM_TOKEN = _config["reclaim_ai"]["token"]
ReclaimClient(token=RECLAIM_TOKEN)
def get_reclaim_tasks():
return ReclaimTask.search()
def filter_for_subject(subject, tasks):
return [task for task in tasks if task.name.startswith(subject)]
def create_reaclaim_task(**params):
new_task = ReclaimTask(**params)
new_task.save()
def get_reclaim_things_ids() -> List[str]:
return [task.description.split(":")[1].strip() for task in ReclaimTask.search()]

255
things2reclaim/things2reclaim.py Executable file
View File

@@ -0,0 +1,255 @@
#!/opt/homebrew/Caskroom/miniconda/base/envs/things-automation/bin/python3
from datetime import datetime
from typing import Optional
import tomllib
from pathlib import Path
import sqlite3
import typer
from pytz import timezone
from rich import print as rprint
from rich.console import Console
from rich.table import Table
from rich.text import Text
from typing_extensions import Annotated
import utils
import things_handler
import reclaim_handler
from database_handler import UploadedTasksDB
import toggl_handler
CONFIG_PATH = Path("config/.things2reclaim.toml")
_config = {}
with open(CONFIG_PATH, "rb") as f:
_config = tomllib.load(f)
DATABASE_PATH = _config["database"]["path"]
app = typer.Typer(add_completion=False, no_args_is_help=True)
console = Console()
def complete_task_name(incomplete: str):
for name in [task.name for task in reclaim_handler.get_reclaim_tasks()]:
if name.startswith(incomplete):
yield name
def things_to_reclaim(things_task):
tags = things_handler.get_task_tags(things_task)
estimated_time = tags.get("EstimatedTime")
if estimated_time is None:
raise ValueError("EstimatedTime tag is required")
estimated_time = utils.calculate_time_on_unit(estimated_time)
params = {
"name": things_handler.full_name(things_task),
"description": utils.generate_things_id_tag(things_task),
"tags": things_handler.get_task_tags(things_task),
"min_work_duration": estimated_time,
"max_work_duration": estimated_time,
"duration": estimated_time,
}
if things_task.get("start_date"):
params["start_date"] = datetime.strptime(
f"{things_task['start_date']} 08:00", "%Y-%m-%d %H:%M"
)
if things_task.get("deadline"):
params["due_date"] = (
datetime.strptime(f"{things_task['deadline']} 22:00", "%Y-%m-%d %H:%M"),
)
utils.map_tag_values(things_task, tags, params)
reclaim_handler.create_reaclaim_task(**params)
@app.command("init")
def initialize_uploaded_database(verbose: bool = False):
"""
Initializes the uploaded tasks database
"""
reclaim_things_uuids = reclaim_handler.get_reclaim_things_ids()
added_tasks = 0
with UploadedTasksDB(DATABASE_PATH) as db:
for task_id in reclaim_things_uuids:
try:
db.add_uploaded_task(task_id)
added_tasks += 1
except sqlite3.IntegrityError as e:
if verbose:
print(
f"Task with ID {task_id} already in database | Exception: {e}"
)
else:
continue
if added_tasks == 0:
print("uploaded_tasks table is already initialized")
else:
print(
f"Added {added_tasks} task{'s' if added_tasks > 1 else ''} to uploaded_tasks table"
)
@app.command("upload")
def upload_things_to_reclaim(verbose: bool = False):
"""
Upload things tasks to reclaim
"""
projects = things_handler.extract_uni_projects()
reclaim_task_names = [task.name for task in reclaim_handler.get_reclaim_tasks()]
tasks_uploaded = 0
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 ''}")
@app.command("list")
def list_reclaim_tasks(subject: Annotated[Optional[str], typer.Argument()] = None):
"""
List all current tasks
"""
reclaim_tasks = reclaim_handler.get_reclaim_tasks()
if subject is not None:
reclaim_tasks = reclaim_handler.filter_for_subject(subject, reclaim_tasks)
current_date = datetime.now().replace(tzinfo=timezone("UTC"))
table = Table("Index", "Task", "Days left", title="Task list")
for id, task in enumerate(reclaim_tasks):
if current_date > task.due_date:
days_behind = (current_date - task.due_date).days
table.add_row(
f"({id + 1})",
task.name,
Text(f"{days_behind} days overdue", style="bold red"),
)
else:
days_left = (task.due_date - current_date).days
table.add_row(
f"({id + 1})",
task.name,
Text(f"{days_left} days left", style="bold white"),
)
console.print(table)
@app.command("start")
def start_task(
task_name: Annotated[
str, typer.Option(help="Task to start", autocompletion=complete_task_name)
],
):
print(f"Starting task: {task_name}")
@app.command("stats")
def show_task_stats():
"""
Show task stats
"""
current_date = datetime.now().replace(tzinfo=timezone("UTC"))
reclaim_tasks = reclaim_handler.get_reclaim_tasks()
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]
fine_per_course = ["Fine"]
overdue_per_course = ["Overdue"]
course_names = things_handler.get_course_names()
for course_name in course_names:
fine_per_course.append(
str(len(reclaim_handler.filter_for_subject(course_name, tasks_fine)))
)
overdue_per_course.append(
str(len(reclaim_handler.filter_for_subject(course_name, tasks_overdue)))
)
table = Table(*(["Status"] + course_names))
table.add_row(*fine_per_course)
table.add_row(*overdue_per_course)
console.print(table)
@app.command("time")
def print_time_needed():
"""
Print sum of time needed for all reclaim tasks
"""
tasks = reclaim_handler.get_reclaim_tasks()
time_needed = 0
for task in tasks:
time_needed += task.duration
print(f"Time needed to complete {len(tasks)} Tasks: {time_needed} hrs")
print(f"Average time needed to complete a Task: {time_needed/len(tasks):.2f} hrs")
try:
tasks.sort(key=lambda x: x.scheduled_start_date)
except TypeError:
print("To many to-dos on list. Not all are scheduled")
return
last_task_date = tasks[-1].scheduled_start_date
today = datetime.now().replace(tzinfo=timezone("UTC"))
print(
f"Last task is scheduled for {last_task_date.strftime('%d.%m.%Y')} ({last_task_date - today} till completion)"
)
@app.command("finished")
def remove_finished_tasks_from_things():
"""
Complete finished reclaim tasks in things
"""
reclaim_things_uuids = reclaim_handler.get_reclaim_things_ids()
finished_someting = False
with UploadedTasksDB(DATABASE_PATH) as db:
for task in things_handler.get_all_uploaded_things_tasks():
if task["uuid"] not in reclaim_things_uuids:
finished_someting = True
print(
f"Found completed task: {things_handler.full_name(things_task=task)}"
)
things_handler.complete(task["uuid"])
db.remove_uploaded_task(task["uuid"])
if not finished_someting:
print("Reclaim and Things are synced")
@app.command("sync")
def sync_things_and_reclaim(verbose: bool = False):
"""
Sync tasks between things and reclaim
First updated all finished tasks in reclaim to completed in things
Then upload all new tasks from things to reclaim
"""
rprint("[bold white]Pulling from Reclaim[/bold white]")
remove_finished_tasks_from_things()
rprint("---------------------------------------------")
rprint("[bold white]Pushing to Reclaim[/bold white]")
upload_things_to_reclaim(verbose=verbose)
rprint("---------------------------------------------")
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,61 @@
from typing import Dict, List
from pathlib import Path
import tomllib
import things
from database_handler import UploadedTasksDB
_config = {}
CONFIG_PATH = Path("config/.things2reclaim.toml")
with open(CONFIG_PATH, "rb") as f:
_config = tomllib.load(f)
DATABASE_PATH = _config["database"]["path"]
def extract_uni_projects():
uni_area = next(area for area in things.areas() if area["title"] == "Uni")
return things.projects(area=uni_area["uuid"])
def get_task(task_id: int):
return things.get(task_id)
def complete(task_id: int):
things.complete(task_id)
def get_tasks_for_project(project) -> Dict | List[Dict]:
return things.tasks(project=project["uuid"], type="to-do")
def get_all_things_tasks() -> List:
tasks = []
projects = extract_uni_projects()
for project in projects:
tasks += get_tasks_for_project(project)
return tasks
def get_all_uploaded_things_tasks() -> List:
tasks = []
with UploadedTasksDB(DATABASE_PATH) as db:
for task_id in db.get_all_uploaded_tasks():
tasks.append(get_task(task_id))
return tasks
def get_task_tags(things_task: Dict) -> Dict[str, str]:
return {k: v for (k, v) in [tag.split(": ") for tag in things_task["tags"]]}
def full_name(things_task) -> str:
return f"{things_task['project_title']} {things_task['title']}"
def get_course_names():
projects = extract_uni_projects()
return [course["title"] for course in projects]

View File

@@ -0,0 +1,74 @@
from toggl_python.entities import TimeEntry
import toggl_python
import tomllib
from pathlib import Path
from datetime import datetime, timedelta
_config = {}
CONFIG_PATH = Path("config/.toggl.toml")
with open(CONFIG_PATH, "rb") as f:
_config = tomllib.load(f)
TOKEN = _config["toggl_track"]["token"]
auth = toggl_python.TokenAuth(TOKEN)
workspace = toggl_python.Workspaces(auth=auth).list()[0]
project_dict = {
project.name: project
for project in toggl_python.Workspaces(auth=auth).projects(_id=workspace.id)
if project.active
}
time_entry_editor = toggl_python.WorkspaceTimeEntries(
auth=auth, workspace_id=workspace.id
)
def get_time_entries(since_days: int = 30):
if since_days > 90:
raise ValueError("since_days can't be more than 90 days")
time_stamp = int((datetime.now() - timedelta(days=since_days)).timestamp())
return toggl_python.TimeEntries(auth=auth).list(since=time_stamp)
def get_current_time_entry():
time_entries = toggl_python.TimeEntries(auth=auth)
time_entries.ADDITIONAL_METHODS = {
"current": {
"url": "me/time_entries/current",
"entity": toggl_python.TimeEntry,
"single_item": True,
}
}
return time_entries.current()
def create_task_time_entry(description: str, project: str):
if project not in project_dict.keys():
raise ValueError(f"{project} is not an active toggl project")
time_entry = TimeEntry(
created_with="things-automation",
wid=workspace.id,
pid=project_dict[project].id,
description=description,
duration=-1,
)
return time_entry
def start_task(description: str, project: str):
time_entry_editor.create(create_task_time_entry(description, project))
def stop_current_task():
cur_task = get_current_time_entry()
if cur_task is None:
raise RuntimeError("No time entry is currently running")
if time_entry_editor.DETAIL_URL is None:
raise ValueError("DetailURL not set")
url = time_entry_editor.BASE_URL.join(
time_entry_editor.DETAIL_URL.format(id=cur_task.id)
)
return time_entry_editor.patch(url)

79
things2reclaim/utils.py Normal file
View File

@@ -0,0 +1,79 @@
from datetime import datetime
import re
from typing import Union, Dict
import things_handler
regex = (
r"((\d+\.?\d*) (hours|hrs|hour|hr|h))? ?((\d+\.?\d*) (mins|min|minutes|minute|m))?"
)
pattern = re.compile(regex)
def calculate_time_on_unit(tag_value: str) -> float:
# This is a regex to match time in the format of 1h 30m
# Minutes are optional if hours are present
# Hours are optional if minutes are present
# The regex will match two words when the correct format is found (format and emtpy word)
values = pattern.findall(tag_value)
time = 0
if len(values) != 2:
raise ValueError("Invalid time format")
_, hours, _, _, mins, _ = values[0]
if "" == hours and "" == mins:
raise ValueError("Regex matched empty string")
if "" != hours:
time += float(hours)
if "" != mins:
time += float(mins) / 60
return time
def map_tag_values(
things_task,
tags_dict: Dict[str, str],
params: Dict[str, Union[str, datetime, float]],
):
for tag, value in tags_dict.items():
match tag:
case "MinTime":
params["min_work_duration"] = calculate_time_on_unit(value)
case "MaxTime":
params["max_work_duration "] = calculate_time_on_unit(value)
case "DeadlineTime":
if things_task.get("deadline") is not None:
params["due_date"] = datetime.strptime(
f"{things_task['deadline']} {value}", "%Y-%m-%d %H:%M"
)
case "StartTime":
if things_task.get("start_date") is not None:
params["start_date"] = datetime.strptime(
f"{things_task['start_date']} {value}",
"%Y-%m-%d %H:%M",
)
case _:
print(f"Tag {tag} not recognized")
def generate_things_id_tag(things_task) -> str:
return f"things_task:{things_task["uuid"]}"
def things_task_pretty_print(task, project_title):
print(f"\tTitle: {project_title} {task['title']}")
print(f"\tStart date: {task['start_date']}")
print(f"\tDeadline: {task['deadline']}")
print(f"\tTags: {task['tags']}")
def reclaim_task_pretty_print(task):
print(f"\tTitle: {task.name}")
print(f"\tStart date: {task.start_date}")
print(f"\tDeadline: {task.due_date}")
print(f"\tMin work duration: {task.min_work_duration}")
print(f"\tMax work duration: {task.max_work_duration}")
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