diff --git a/docs/readme-tw-caldav.md b/docs/readme-tw-caldav.md index 6e662f0..1699a80 100644 --- a/docs/readme-tw-caldav.md +++ b/docs/readme-tw-caldav.md @@ -1,12 +1,16 @@ -# [Taskwarrior](https://taskwarrior.org/) ⬄ Caldav Server +# [Taskwarrior](https://taskwarrior.org/) ⬄ CalDAV Server ## Description -Synchronize Taskwarrior tasks to a generic caldav server. -This service has been tested using a self-hosted [nextcloud](https://nextcloud.com/) server as well as , but should theoretically work with any server that implements the [caldav specification](https://www.rfc-editor.org/rfc/rfc4791) +Synchronize Taskwarrior tasks to a generic CalDAV server. +This service has been tested using a self-hosted +[nextcloud](https://nextcloud.com/) server as well as , but should theoretically +work with any server that implements the [CalDAV +specification](https://www.rfc-editor.org/rfc/rfc4791) Upon execution, `tw_caldav_sync` will synchronize, and on subsequent runs of the -program keep synchronized, the following attributes (tw entries will be converted to a vCard format and visa versa): +program keep synchronized, the following attributes (TW entries will be +converted to a vCard format and visa versa): ## Demo - first run - populating tasklist in Nextcloud @@ -14,33 +18,38 @@ program keep synchronized, the following attributes (tw entries will be converte ### Mappings -TW <-> Caldav will make the following mappings between items: - -- `description` <-> `SUMMARY` -- `status` <-> `STATUS` - - `pending`, `waiting` <-> `NEEDS-ACTION` - - `completed` <-> `COMPLETED` - - `deleted` <-> `CANCELLED` -- TW `entry` <-> `CREATED` -- TW `end` <-> `COMPLETED` -- TW `modified` <-> `LAST-MODIFIED` -- TW `prioriy` <-> `PRIORITY` - - `""` <-> `None` - - `L` <-> 9 - - `M` <-> 5 - - `H` <-> 1 -- TW `annotations` <-> `DESCRIPTION` (one annotation <-> one line in description) -- TW `uuid` <-> `X-SYNCALL-TW-UUID` -- TW `tags` <-> `CATEGORIES` +`tw_caldav_sync` will make the following mappings between items: + +- `description` ↔ `SUMMARY` +- `status` ↔ `STATUS` + - `pending`, `waiting` ↔ `NEEDS-ACTION` + - `completed` ↔ `COMPLETED` / `CANCELLED` (using a custom `UDA` + `caldav_completion_status`, in Taskwarrior to mark cancelled items) + - `deleted` ↔ (deletion of CalDAV item) + +Regarding timestamps: + +- TW `entry` ↔ `CREATED` +- TW `end` ↔ `COMPLETED` +- TW `modified` ↔ `LAST-MODIFIED` +- TW `prioriy` ↔ `PRIORITY` + - `""` ↔ `None` + - `L` ↔ 9 + - `M` ↔ 5 + - `H` ↔ 1 +- TW `annotations` ↔ `DESCRIPTION` (one annotation ↔ one line in description) +- TW `uuid` ↔ `X-SYNCALL-TW-UUID` +- TW `tags` ↔ `CATEGORIES` ### Current limitations -- No specific support for "waiting" tasks in Taskwarrior, they will be treated like any other "needs-action" caldav task +- No specific support for "waiting" tasks in Taskwarrior, they will be treated + like any other "needs-action" CalDAV task - No support for recurring tasks sync in either direction ## Installation -Install the `syncall` package from PyPI, enabling the `caldav` and `Taskwarrior` +Install the `syncall` package from PyPI, enabling the CalDAV and `Taskwarrior` extra: ```sh @@ -58,19 +67,19 @@ project. Use `--taskwarrior-tags ...` or `--taskwarrior-project` respectively for the above -### Caldav +### CalDAV In order to successfully run a sync, you will need the following flags set (mandatory): -- `--caldav-url`: URL where the caldav calendar is hosted at (including `/dav` if applicable) -- `--caldav-user`: Username required to authenticate your caldav instance +- `--caldav-url`: URL where the CalDAV calendar is hosted at (including `/dav` if applicable) +- `--caldav-user`: Username required to authenticate your CalDAV instance - Can also be provided via the `CALDAV_USERNAME` environment variable - `--caldav-passwd`, `--caldav-passwd-pass-path`: Path to your password `.gpg` file in your [password store](https://wiki.archlinux.org/title/Pass) - Alternatively, the password can be provided directly via the `CALDAV_PASSWD` environment variable The following flag is optional: -- `--calendar`: Name of the caldav Calendar to sync (will be created if not there), will default to `Personal` if not set +- `--calendar`: Name of the CalDAV Calendar to sync (will be created if not there), will default to `Personal` if not set ### Example Usage @@ -88,8 +97,11 @@ CALDAV_USERNAME=myUser CALDAV_PASSWD=myPass tw_caldav_sync --caldav-url https:// ## Future Work -- [ ] See if we can handle TW "waiting" tasks a little better (possibly by setting the caldav `start` field to when the wait expires) -- [ ] Consider how to refactor out extra steps in conversion, and just store caldav items in their vTodo formats (though this will make test files much uglier) +- [ ] See if we can handle TW "waiting" tasks a little better (possibly by + setting the CalDAV `start` field to when the wait expires) +- [ ] Consider how to refactor out extra steps in conversion, and just store + CalDAV items in their vTodo formats (though this will make test files much + uglier) ## See also diff --git a/poetry.lock b/poetry.lock index 486b64d..77c49a6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -605,21 +605,6 @@ files = [ [package.extras] test = ["pytest (>=6)"] -[[package]] -name = "fancycompleter" -version = "0.9.1" -description = "colorful TAB completion for Python prompt" -optional = false -python-versions = "*" -files = [ - {file = "fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080"}, - {file = "fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272"}, -] - -[package.dependencies] -pyreadline = {version = "*", markers = "platform_system == \"Windows\""} -pyrepl = ">=0.8.2" - [[package]] name = "filelock" version = "3.15.4" @@ -705,13 +690,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] [[package]] name = "google-api-python-client" -version = "2.140.0" +version = "2.141.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" files = [ - {file = "google_api_python_client-2.140.0-py2.py3-none-any.whl", hash = "sha256:aeb4bb99e9fdd241473da5ff35464a0658fea0db76fe89c0f8c77ecfc3813404"}, - {file = "google_api_python_client-2.140.0.tar.gz", hash = "sha256:0bb973adccbe66a3d0a70abe4e49b3f2f004d849416bfec38d22b75649d389d8"}, + {file = "google_api_python_client-2.141.0-py2.py3-none-any.whl", hash = "sha256:43c05322b91791204465291b3852718fae38d4f84b411d8be847c4f86882652a"}, + {file = "google_api_python_client-2.141.0.tar.gz", hash = "sha256:0f225b1f45d5a6f8c2a400f48729f5d6da9a81138e81e0478d61fdd8edf6563a"}, ] [package.dependencies] @@ -723,17 +708,17 @@ uritemplate = ">=3.0.1,<5" [[package]] name = "google-api-python-client-stubs" -version = "1.26.0" +version = "1.27.0" description = "Type stubs for google-api-python-client" optional = false python-versions = "<4.0,>=3.7" files = [ - {file = "google_api_python_client_stubs-1.26.0-py3-none-any.whl", hash = "sha256:0614b0cef5beac43e6ab02418f07e64ee66dc99ae4e377d54a155ac261533987"}, - {file = "google_api_python_client_stubs-1.26.0.tar.gz", hash = "sha256:f3b38b46f7b5cf4f6e7cc63ca554a2d23096d49c841f38b9ea553a5237074b56"}, + {file = "google_api_python_client_stubs-1.27.0-py3-none-any.whl", hash = "sha256:3c1f9f2a7cac8d1e9a7e84ed24e6c29cf4c643b0f94e39ed09ac1b7e91ab239a"}, + {file = "google_api_python_client_stubs-1.27.0.tar.gz", hash = "sha256:148e16613e070969727f39691e23a73cdb87c65a4fc8133abd4c41d17b80b313"}, ] [package.dependencies] -google-api-python-client = ">=2.130.0" +google-api-python-client = ">=2.141.0" types-httplib2 = ">=0.22.0.2" typing-extensions = ">=3.10.0" @@ -950,21 +935,21 @@ files = [ [[package]] name = "importlib-resources" -version = "6.4.0" +version = "6.4.2" description = "Read resources from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_resources-6.4.0-py3-none-any.whl", hash = "sha256:50d10f043df931902d4194ea07ec57960f66a80449ff867bfe782b4c486ba78c"}, - {file = "importlib_resources-6.4.0.tar.gz", hash = "sha256:cdb2b453b8046ca4e3798eb1d84f3cce1446a0e8e7b5ef4efb600f19fc398145"}, + {file = "importlib_resources-6.4.2-py3-none-any.whl", hash = "sha256:8bba8c54a8a3afaa1419910845fa26ebd706dc716dd208d9b158b4b6966f5c5c"}, + {file = "importlib_resources-6.4.2.tar.gz", hash = "sha256:6cbfbefc449cc6e2095dd184691b7a12a04f40bc75dd4c55d31c34f174cdf57a"}, ] [package.dependencies] zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["jaraco.test (>=5.4)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)", "zipp (>=3.17)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)", "zipp (>=3.17)"] [[package]] name = "iniconfig" @@ -1420,26 +1405,6 @@ files = [ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] -[[package]] -name = "pdbpp" -version = "0.10.3" -description = "pdb++, a drop-in replacement for pdb" -optional = false -python-versions = "*" -files = [ - {file = "pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1"}, - {file = "pdbpp-0.10.3.tar.gz", hash = "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5"}, -] - -[package.dependencies] -fancycompleter = ">=0.8" -pygments = "*" -wmctrl = "*" - -[package.extras] -funcsigs = ["funcsigs"] -testing = ["funcsigs", "pytest"] - [[package]] name = "pkgutil-resolve-name" version = "1.3.10" @@ -1636,20 +1601,6 @@ files = [ {file = "pyfakefs-5.6.0.tar.gz", hash = "sha256:7a549b32865aa97d8ba6538285a93816941d9b7359be2954ac60ec36b277e879"}, ] -[[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 = "pylint" version = "2.17.7" @@ -1693,35 +1644,15 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] -[[package]] -name = "pyreadline" -version = "2.1" -description = "A python implmementation of GNU readline." -optional = false -python-versions = "*" -files = [ - {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, -] - -[[package]] -name = "pyrepl" -version = "0.9.0" -description = "A library for building flexible command line interfaces" -optional = false -python-versions = "*" -files = [ - {file = "pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775"}, -] - [[package]] name = "pyright" -version = "1.1.375" +version = "1.1.376" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.375-py3-none-any.whl", hash = "sha256:4c5e27eddeaee8b41cc3120736a1dda6ae120edf8523bb2446b6073a52f286e3"}, - {file = "pyright-1.1.375.tar.gz", hash = "sha256:7765557b0d6782b2fadabff455da2014476404c9e9214f49977a4e49dec19a0f"}, + {file = "pyright-1.1.376-py3-none-any.whl", hash = "sha256:0f2473b12c15c46b3207f0eec224c3cea2bdc07cd45dd4a037687cbbca0fbeff"}, + {file = "pyright-1.1.376.tar.gz", hash = "sha256:bffd63b197cd0810395bb3245c06b01f95a85ddf6bfa0e5644ed69c841e954dd"}, ] [package.dependencies] @@ -2150,18 +2081,18 @@ files = [ [[package]] name = "setuptools" -version = "72.1.0" +version = "72.2.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = true python-versions = ">=3.8" files = [ - {file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"}, - {file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"}, + {file = "setuptools-72.2.0-py3-none-any.whl", hash = "sha256:f11dd94b7bae3a156a95ec151f24e4637fb4fa19c878e4d191bfb8b2d82728c4"}, + {file = "setuptools-72.2.0.tar.gz", hash = "sha256:80aacbf633704e9c8bfa1d99fa5dd4dc59573efcf9e4042c13d3bcef91ac2ef9"}, ] [package.extras] core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -2227,13 +2158,13 @@ files = [ [[package]] name = "tomlkit" -version = "0.13.0" +version = "0.13.2" description = "Style preserving TOML library" optional = false python-versions = ">=3.8" files = [ - {file = "tomlkit-0.13.0-py3-none-any.whl", hash = "sha256:7075d3042d03b80f603482d69bf0c8f345c2b30e41699fd8883227f89972b264"}, - {file = "tomlkit-0.13.0.tar.gz", hash = "sha256:08ad192699734149f5b97b45f1f18dad7eb1b6d16bc72ad0c2335772650d7b72"}, + {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, + {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, ] [[package]] @@ -2437,23 +2368,6 @@ files = [ [package.extras] dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] -[[package]] -name = "wmctrl" -version = "0.5" -description = "A tool to programmatically control windows inside X" -optional = false -python-versions = ">=2.7" -files = [ - {file = "wmctrl-0.5-py2.py3-none-any.whl", hash = "sha256:ae695c1863a314c899e7cf113f07c0da02a394b968c4772e1936219d9234ddd7"}, - {file = "wmctrl-0.5.tar.gz", hash = "sha256:7839a36b6fe9e2d6fd22304e5dc372dbced2116ba41283ea938b2da57f53e962"}, -] - -[package.dependencies] -attrs = "*" - -[package.extras] -test = ["pytest"] - [[package]] name = "wrapt" version = "1.16.0" @@ -2653,4 +2567,4 @@ tw = ["taskw-ng", "xdg"] [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<=3.12.5" -content-hash = "c57f41a8d920cac92ac46a1cece8040463c2eef04dd477cd5de9739227201cce" +content-hash = "eba5002826c17b8831db5486299b466f8673c50f5bc900d8d3dcfe6530eb510b" diff --git a/pyproject.toml b/pyproject.toml index f6d8c9f..6dd357c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,6 @@ identify = "^2.6.0" isort = "^5.13.2" mock = "*" mypy = "*" -pdbpp = "^0.10.3" pre-commit = "^2.21.0" pyfakefs = [ { version = "^4.7.0", python = "<3.12" }, diff --git a/syncall/caldav/caldav_side.py b/syncall/caldav/caldav_side.py index ecdec3b..53ed368 100644 --- a/syncall/caldav/caldav_side.py +++ b/syncall/caldav/caldav_side.py @@ -6,6 +6,8 @@ from caldav.lib.error import NotFoundError from icalendar.prop import vCategory, vDatetime, vText +from syncall.tw_caldav_utils import SYNCALL_TW_UUID, SYNCALL_TW_WAITING + if TYPE_CHECKING: import caldav from item_synchronizer.types import ID @@ -27,7 +29,7 @@ class CaldavSide(SyncSide): "status", "summary", "due", - "x-syncall-tw-uuid", + SYNCALL_TW_WAITING, ) _date_keys: tuple[str] = ("end", "start", "last-modified") @@ -140,12 +142,13 @@ def add_item(self, item): summary=item.get("summary"), priority=item.get("priority"), description=item.get("description"), - status=item.get("status").upper(), + status=item["status"].upper(), due=item.get("due"), categories=item.get("categories"), created=item.get("created"), completed=item.get("completed"), - x_syncall_tw_uuid=item.get("x-syncall-tw-uuid"), + x_syncall_tw_uuid=item.get(SYNCALL_TW_UUID), + x_syncall_tw_waiting=item.get(SYNCALL_TW_WAITING), ) return map_ics_to_item(icalendar_component(todo)) diff --git a/syncall/scripts/tw_caldav_sync.py b/syncall/scripts/tw_caldav_sync.py index 819b4df..bb0961d 100644 --- a/syncall/scripts/tw_caldav_sync.py +++ b/syncall/scripts/tw_caldav_sync.py @@ -14,6 +14,7 @@ ) from syncall.app_utils import confirm_before_proceeding, inform_about_app_extras +from syncall.taskwarrior.taskwarrior_side import TW_CONFIG_DEFAULT_OVERRIDES try: from syncall.caldav.caldav_side import CaldavSide @@ -32,14 +33,18 @@ register_teardown_handler, ) from syncall.cli import opts_caldav, opts_miscellaneous, opts_tw_filtering -from syncall.tw_caldav_utils import convert_caldav_to_tw, convert_tw_to_caldav +from syncall.tw_caldav_utils import ( + CALDAV_TASK_CANCELLED_UDA, + convert_caldav_to_tw, + convert_tw_to_caldav, +) @click.command() @opts_caldav() @opts_tw_filtering() @opts_miscellaneous("TW", "Caldav") -def main( +def main( # noqa: PLR0915 caldav_calendar: str, caldav_url: str, caldav_user: str | None, @@ -155,10 +160,17 @@ def main( # initialize sides ------------------------------------------------------------------------ # tw + tw_config_overrides = {} + tw_config_overrides["uda"] = TW_CONFIG_DEFAULT_OVERRIDES["uda"] + tw_config_overrides["uda"][CALDAV_TASK_CANCELLED_UDA] = { + "type": "string", + "label": "Task cancelled in Caldav true|false", + } tw_side = TaskWarriorSide( tw_filter=" ".join(tw_filter_li), tags=tw_tags, project=tw_project, + config_overrides=tw_config_overrides, ) # caldav diff --git a/syncall/taskwarrior/taskwarrior_side.py b/syncall/taskwarrior/taskwarrior_side.py index 594b06a..1da0cd2 100644 --- a/syncall/taskwarrior/taskwarrior_side.py +++ b/syncall/taskwarrior/taskwarrior_side.py @@ -25,7 +25,7 @@ "urgency", ] -tw_config_default_overrides = { +TW_CONFIG_DEFAULT_OVERRIDES = { "context": "none", "uda": {tw_duration_key: {"type": "duration", "label": "Syncall Duration"}}, } @@ -64,14 +64,14 @@ def __init__( to sync :param config_file: Path to the taskwarrior RC file :param config_overrides: Dictionary of taskrc key, values to override. See also - tw_config_default_overrides + TW_CONFIG_DEFAULT_OVERRIDES """ super().__init__(name="Tw", fullname="Taskwarrior", **kargs) self._tags: set[str] = set(tags) self._project: str = project or "" self._tw_filter: str = tw_filter - config_overrides_ = tw_config_default_overrides.copy() + config_overrides_ = TW_CONFIG_DEFAULT_OVERRIDES.copy() config_overrides_.update(config_overrides) # determine config file diff --git a/syncall/tw_caldav_utils.py b/syncall/tw_caldav_utils.py index 5bbd237..8efcf3e 100644 --- a/syncall/tw_caldav_utils.py +++ b/syncall/tw_caldav_utils.py @@ -1,23 +1,18 @@ +from __future__ import annotations + from datetime import timedelta +from typing import TYPE_CHECKING, Literal from uuid import UUID -from item_synchronizer.types import Item +if TYPE_CHECKING: + from item_synchronizer.types import Item from syncall.caldav.caldav_utils import parse_caldav_item_desc -aliases_tw_caldav_status = { - "completed": "completed", - "pending": "needs-action", - "waiting": "needs-action", - "deleted": "cancelled", -} +CALDAV_TASK_CANCELLED_UDA = "caldav_completion_status" +SYNCALL_TW_WAITING = "x-syncall-tw-waiting" +SYNCALL_TW_UUID = "x-syncall-tw-uuid" -aliases_caldav_tw_status = { - "completed": "completed", - "needs-action": "pending", - "in-process": "pending", - "cancelled": "deleted", -} aliases_tw_caldav_priority = { "l": 9, @@ -28,6 +23,50 @@ aliases_caldav_tw_priority = {v: k for k, v in aliases_tw_caldav_priority.items()} +def _determine_caldav_status(tw_item: Item) -> tuple[str, Literal["true", "false"] | None]: + tw_status = tw_item["status"] + if tw_status == "pending": + caldav_status = "needs-action" + tw_waiting_ical_val = "false" + elif tw_status == "waiting": + caldav_status = "needs-action" + tw_waiting_ical_val = "true" + + elif tw_status == "completed": + if tw_item.get(CALDAV_TASK_CANCELLED_UDA, "false") == "true": + caldav_status = "cancelled" + else: + caldav_status = "completed" + tw_waiting_ical_val = None + elif tw_status == "deleted": + caldav_status = "" # shouldn't matter + tw_waiting_ical_val = None + else: + raise ValueError(f"Unknown status: {tw_status}") + + return caldav_status, tw_waiting_ical_val + + +def _determine_tw_status(caldav_item: Item) -> tuple[str, Literal["true", "false"] | None]: + caldav_status = caldav_item["status"] + if caldav_status in ("needs-action", "in-process"): + if caldav_item.get(SYNCALL_TW_WAITING, "false") == "true": + tw_status = "waiting" + else: + tw_status = "pending" + task_cancelled_uda_val = None + elif caldav_status == "completed": + tw_status = "completed" + task_cancelled_uda_val = "false" + elif caldav_status == "cancelled": + tw_status = "completed" + task_cancelled_uda_val = "true" + else: + raise ValueError(f"Unknown caldav status: {caldav_status}") + + return tw_status, task_cancelled_uda_val + + def convert_tw_to_caldav(tw_item: Item) -> Item: assert all( i in tw_item for i in ("description", "status", "uuid") @@ -37,14 +76,17 @@ def convert_tw_to_caldav(tw_item: Item) -> Item: caldav_item["summary"] = tw_item["description"] # description + caldav_item["description"] = "" if "annotations" in tw_item.keys(): caldav_item["description"] = "\n".join(tw_item["annotations"]) # uuid - caldav_item["x-syncall-tw-uuid"] = f'{tw_item["uuid"]}' + caldav_item[SYNCALL_TW_UUID] = tw_item["uuid"] # Status - caldav_item["status"] = aliases_tw_caldav_status[tw_item["status"]] + caldav_item["status"], caldav_item[SYNCALL_TW_WAITING] = _determine_caldav_status( + tw_item=tw_item, + ) # Priority if "priority" in tw_item: @@ -96,11 +138,13 @@ def convert_caldav_to_tw(caldav_item: Item) -> Item: tw_item["annotations"] = [ line.strip() for line in caldav_item["description"].split("\n") if line ] - if "x-syncall-tw-uuid" in caldav_item.keys(): - tw_item["uuid"] = UUID(caldav_item["x-syncall-tw-uuid"]) + if SYNCALL_TW_UUID in caldav_item.keys(): + tw_item["uuid"] = UUID(caldav_item[SYNCALL_TW_UUID]) - # Status - tw_item["status"] = aliases_caldav_tw_status[caldav_item["status"]] + # Status + task cancelled UDA + tw_item["status"], tw_item[CALDAV_TASK_CANCELLED_UDA] = _determine_tw_status( + caldav_item=caldav_item, + ) # Priority if prio := aliases_caldav_tw_priority.get(caldav_item["priority"]):