diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..001c6ec Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index a4a0b19..f392133 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ cython_debug/ #.idea/ .reclaim.toml +things2reclaim/config/.toggl.toml +things2reclaim/config/.things2reclaim.toml +things2reclaim/data/tasks.db diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..e368e33 --- /dev/null +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dc72765 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[tool.poetry] +name = "task-automation" +version = "0.1.0" +description = "" +authors = ["Simon Bußmann "] +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" diff --git a/things2reclaim.py b/things2reclaim.py deleted file mode 100755 index 237a1af..0000000 --- a/things2reclaim.py +++ /dev/null @@ -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() diff --git a/things2reclaim/database_handler.py b/things2reclaim/database_handler.py new file mode 100644 index 0000000..5e8006e --- /dev/null +++ b/things2reclaim/database_handler.py @@ -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() diff --git a/things2reclaim/reclaim_handler.py b/things2reclaim/reclaim_handler.py new file mode 100644 index 0000000..e99d24b --- /dev/null +++ b/things2reclaim/reclaim_handler.py @@ -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()] diff --git a/things2reclaim/things2reclaim.py b/things2reclaim/things2reclaim.py new file mode 100755 index 0000000..6cad7ce --- /dev/null +++ b/things2reclaim/things2reclaim.py @@ -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() diff --git a/things2reclaim/things_handler.py b/things2reclaim/things_handler.py new file mode 100644 index 0000000..de0a6c3 --- /dev/null +++ b/things2reclaim/things_handler.py @@ -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] diff --git a/things2reclaim/toggl_handler.py b/things2reclaim/toggl_handler.py new file mode 100644 index 0000000..077da13 --- /dev/null +++ b/things2reclaim/toggl_handler.py @@ -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) diff --git a/things2reclaim/utils.py b/things2reclaim/utils.py new file mode 100644 index 0000000..aa9447d --- /dev/null +++ b/things2reclaim/utils.py @@ -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