diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1993e07d75f1c5cccda481be642cefe1a1589c8c..0aafbf19259783dcbdec758a4cdc65320bb8cc12 100755 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,46 +1,33 @@ -image: python:3.11-slim - variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" - cache: paths: - .cache/pip - - venv/ stages: - - build - test - release -build: - stage: build - script: - - ci/virtual-env.sh - - source venv/bin/activate - - ci/build-install.sh - - pip -V - - pip show -f vja - artifacts: - paths: - - dist/*.whl - rules: - - when: always - pylint: stage: test + image: python:3.11-slim allow_failure: true script: - - source venv/bin/activate + - pip install -r requirements_dev.txt + - pip uninstall -y vja && python3 setup.py sdist bdist_wheel && pip install dist/*.whl - flake8 --max-line-length=120 vja/*.py || true - pylint -d C0103,C0114,C0115,C0116,C0301 --fail-under=9 vja/*.py rules: + - if: $CI_COMMIT_BRANCH == "beta" + when: on_success + - if: $CI_COMMIT_BRANCH && $CI_COMMIT_REF_SLUG !~ /renovate.*/ + when: manual - when: on_success integration-test: stage: test image: - name: docker:stable + name: docker:23.0-cli services: - name: docker:dind variables: @@ -48,17 +35,15 @@ integration-test: DOCKER_TLS_CERTDIR: "" script: - docker --version - - apk add --no-cache bash curl python3-dev py3-pip docker-compose - - pip3 install wheel - - pip3 install pytest - - pip3 install coverage + - apk add --no-cache bash curl python3 py3-pip - python3 --version - - pip install dist/*.whl + - pip install -r requirements_dev.txt + - pip uninstall -y vja && python3 setup.py sdist bdist_wheel && pip install dist/*.whl - pip show vja - which vja - - docker-compose -f tests/docker-compose.yml up -d api + - docker compose -f tests/docker-compose.yml up -d api - curl --retry 5 --retry-all-errors --retry-delay 1 "http://docker:3456/api/v1/info" - export VJA_CONFIGDIR=tests/.vjatest_dind @@ -66,15 +51,18 @@ integration-test: - coverage run --omit='*test*' -m pytest - coverage report --omit='*test*' - coverage xml --omit='*test*' - - docker-compose -f tests/docker-compose.yml down + - docker compose -f tests/docker-compose.yml down coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/' - cache: {} artifacts: reports: coverage_report: coverage_format: cobertura path: coverage.xml rules: + - if: $CI_COMMIT_BRANCH == "beta" + when: on_success + - if: $CI_COMMIT_BRANCH && $CI_COMMIT_REF_SLUG !~ /renovate.*/ + when: manual - when: on_success release: @@ -90,7 +78,9 @@ release: script: - python --version - semantic-release - cache: {} rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: on_success + - if: $CI_COMMIT_BRANCH == "beta" + when: manual + diff --git a/.releaserc.yml b/.releaserc.yml index 9f59c412bc318bfe11d44cf386fccb0bae6a34f7..cb441b8550917889b80e3037a7ad67c85cbcb1b0 100644 --- a/.releaserc.yml +++ b/.releaserc.yml @@ -1,9 +1,10 @@ -# spec: https://semantic-release.gitbook.io/semantic-release/usage/configuration tagFormat: "${version}" -branches: [main, next] +branches: + - main + - { name: beta, prerelease: true } + - { name: rc, prerelease: true } + plugins: - # Determine the type of release by analyzing commits. - # ie: Major, Minor or Patch - - "@semantic-release/commit-analyzer" - preset: conventionalcommits releaseRules: @@ -14,11 +15,13 @@ plugins: - { type: perf, release: patch } - { type: docs, release: patch } - { type: refactor, release: patch } - - { type: style, release: patch } - { type: build, release: patch } - - { type: ci, release: patch } - { type: test, release: patch } + - { type: tests, release: patch } - { type: update, release: patch } + - { type: chore, release: false } + - { type: ci, release: false } + - { scope: no-release, release: false } - - "@semantic-release/release-notes-generator" - preset: conventionalcommits presetConfig: @@ -26,12 +29,11 @@ plugins: - { type: feat, section: "Features" } - { type: new, section: "Features" } - { type: fix, section: "Bug Fixes" } - - { type: chore, section: "Misc" } - { type: doc, section: "Documentation" } - { type: docs, section: "Documentation" } - - { type: style, section: "Improvements" } - - { type: refactor, section: "Improvements" } - - { type: perf, section: "Improvements" } + - { type: chore, section: "Misc" } + - { type: refactor, section: "Misc" } + - { type: perf, section: "Misc" } - { type: test, section: "Automation" } - { type: tests, section: "Automation" } - { type: ci, section: "Automation" } diff --git a/.vjacli/vja.rc b/.vjacli/vja.rc index 579cff5744f468e54ced5f9009d15a9e7c95c366..3e8e3d5fa4a5c9168f5e91ba0c606bb37240213d 100755 --- a/.vjacli/vja.rc +++ b/.vjacli/vja.rc @@ -8,7 +8,7 @@ api_url=https://try.vikunja.io/api/v1 # Be careful: Any code which is entered here gets executed at runtime (python eval()). # Do not use --custom-format if you feel uncomfortable with that. -tasklist_long={x.id:5} ({x.priority}) {"*" if x.is_favorite else " "} {x.title:50.50} {x.due_date.strftime("%a %d.%m %H:%M") if x.due_date else "":15.15} {"R" if x.reminder_dates else " "} {x.tasklist.title:20.20} {x.label_titles:20.20} {x.urgency:3} +tasklist_long={x.id:5} ({x.priority}) {"*" if x.is_favorite else " "} {x.title:50.50} {x.due_date.strftime("%a %d.%m %H:%M") if x.due_date else "":15.15} {"R" if x.reminders else " "} {x.project.title:20.20} {x.label_titles:20.20} {x.urgency:3} tasklist_short={x.id:5} {x.title:50.50} ids_only={x.id} @@ -16,9 +16,9 @@ ids_only={x.id} due_date_weight=1.0 priority_weight=2.0 favorite_weight=1.0 -list_weight=1.0 +project_weight=1.0 label_weight=1.0 [urgency_keywords] -list_keywords=next, test-list +project_keywords=next, test-project label_keywords=next,my_tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c625d85c7ff19f6f1f541b49f581520d0a9bb24..2b15d24f85ea3e477482bce8352033080ce290c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,171 @@ -## [1.5.1](https://gitlab.com/ce72/vja/compare/1.5.0...1.5.1) (2023-04-27) +## [2.0.0-beta.1](https://gitlab.com/ce72/vja/compare/1.5.1...2.0.0-beta.1) (2023-06-02) + + +### ⚠ BREAKING CHANGES + +* remove namespaces +* **api:** remove reminder_dates +* **api:** New projects api (only backend side) +* Preliminary support for new reminder array + +### Features + +* **api:** New projects api (only backend side) ([b1df704](https://gitlab.com/ce72/vja/commit/b1df70496a070b827c7431c68181d4ec18aa5190)) +* **api:** remove reminder_dates ([6f18cff](https://gitlab.com/ce72/vja/commit/6f18cff9ffae2e95dafd9c936400b5e216b44008)) +* **cli:** alias --urgent for --urgency ([3701ab2](https://gitlab.com/ce72/vja/commit/3701ab25ddeebc7f07959c0ac2bdca9833b5d15f)) +* defer reminder ([aec5f53](https://gitlab.com/ce72/vja/commit/aec5f53ed530a3e1a486b7cad56a9c1613ae100a)) +* **defer task:** defer passed due date relative to now ([f6a70e5](https://gitlab.com/ce72/vja/commit/f6a70e58506104f5c0fd6eb421baf9017f31feb9)) +* fetch long_token when logging in (with a ttl of 30 days) ([1e8a7b8](https://gitlab.com/ce72/vja/commit/1e8a7b86ae4781b812909132d1574380965e99d3)) +* Preliminary support for new reminder array ([fe2c332](https://gitlab.com/ce72/vja/commit/fe2c33281206fcf4e121875164ab36ea78369987)) +* **reminders:** Set relative reminders like "1h before due" ([18534b4](https://gitlab.com/ce72/vja/commit/18534b4921a04e603ad4003a4a3f3ad9196de10c)) +* remove namespaces ([3c027b0](https://gitlab.com/ce72/vja/commit/3c027b09b996dcfc8ff650b75c565d6500a124b4)) +* rename lists to project within vja ([54b0b0a](https://gitlab.com/ce72/vja/commit/54b0b0a46cfaaa20236fe79580a9a28909056d7c)) +* Support for new reminder array (read) ([5caff10](https://gitlab.com/ce72/vja/commit/5caff109bf444087ec83f8f6e3257d5ffddefaa1)) + + +### Bug Fixes + +* **clone:** clone labels too ([0516908](https://gitlab.com/ce72/vja/commit/05169086c09ff4b77d597799defff00b01f4b591)) +* **clone:** do not clone positions and have --bucket ([15fac96](https://gitlab.com/ce72/vja/commit/15fac96ec4f34fbde4906eef925646fef8ba0d6e)) +* **clone:** do not clone positions and have --bucket ([9e0a9aa](https://gitlab.com/ce72/vja/commit/9e0a9aa8ce9e8d6b845063888c22ece7f40b1028)) +* do not unset reminder if missing in vja edit ([7b8c922](https://gitlab.com/ce72/vja/commit/7b8c922acaf726060c9de5ad574da585bc8e2e9a)) +* sharpen test ([c5dbf43](https://gitlab.com/ce72/vja/commit/c5dbf43d582b244f8b17787ed12a06d160a20856)) + + +### Documentation + +* Add table of contents to Features.md ([e394ca5](https://gitlab.com/ce72/vja/commit/e394ca5e3959a77e1a8bb39285f4dc348d997cbd)) +* describe env variable VJA_CONFIGDIR ([ef0c2bd](https://gitlab.com/ce72/vja/commit/ef0c2bd9b6b38414a091787c3d641cc9e9799f9a)) +* **filters:** improve documentation (and tests) ([2c2b967](https://gitlab.com/ce72/vja/commit/2c2b967f736055f6ab65a3ef7f03c05cc6798344)) ### Misc -* **deps:** update all dependencies ([546d995](https://gitlab.com/ce72/vja/commit/546d9953f3fba1289760b9d5b6bcd6f6ac4e2c99)) +* **release:** Release 2.0.0-beta.1 update changelog [skip ci] ([a96579c](https://gitlab.com/ce72/vja/commit/a96579c02a01f66008283e4178aa2a389e1d7da1)) +* **release:** Release 2.0.0-beta.1 update changelog [skip ci] ([8745e3a](https://gitlab.com/ce72/vja/commit/8745e3a9374a5185c1fd884411ae2e338e77720d)) +* **release:** Release 2.0.0-beta.1 update changelog [skip ci] ([8fa7747](https://gitlab.com/ce72/vja/commit/8fa7747305cff1e22934dc260534deda890d8c54)) +* **release:** Release 2.0.0-beta.1 update changelog [skip ci] ([9d27ea2](https://gitlab.com/ce72/vja/commit/9d27ea2639c0ffec7582199f004508006479385e)) +* **release:** Release 2.0.0-beta.2 update changelog [skip ci] ([ed78554](https://gitlab.com/ce72/vja/commit/ed78554db8f5adaec000fca4358d709755ee20a5)) +* **release:** Release 2.0.0-beta.3 update changelog [skip ci] ([1d10ce6](https://gitlab.com/ce72/vja/commit/1d10ce675ab4dd4305196e7048261b018c4dd9f7)) +* **release:** Release 2.0.0-beta.4 update changelog [skip ci] ([65b7fbe](https://gitlab.com/ce72/vja/commit/65b7fbe811cd2d2e3ab78cdc54e8f7c0980731f8)) +* Remove redundant test ([719943a](https://gitlab.com/ce72/vja/commit/719943a9d1b77a7663d87abac602f8d869c246b1)) + + +### Automation + +* automate beta releases ([a325254](https://gitlab.com/ce72/vja/commit/a32525446577421f95b51cb578390d2c72d114c3)) +* automate beta releases ([a3fed80](https://gitlab.com/ce72/vja/commit/a3fed80753d817a8026d1ac8eb65291576c5a10f)) +* automate beta releases ([f2dc40a](https://gitlab.com/ce72/vja/commit/f2dc40ae6d731b96946c999f3f9d639b38f2b601)) +* automate beta releases ([c57f4bc](https://gitlab.com/ce72/vja/commit/c57f4bcd40fcbad44b1d364884864bf764b012e9)) +* capture click output ([727c282](https://gitlab.com/ce72/vja/commit/727c2823f2adab62e4e91835ed39219f2b274fe2)) +* cleanup gitlab-ci ([cb6f743](https://gitlab.com/ce72/vja/commit/cb6f743f14ab85afd8059c8c9f4baa68e492b215)) +* cleanup gitlab-ci ([cab2530](https://gitlab.com/ce72/vja/commit/cab2530d7bdfe9261acf9ae4f30d304e8279b6aa)) +* cleanup gitlab-ci ([0716eb9](https://gitlab.com/ce72/vja/commit/0716eb94ebd32ebe4c6fbea7b75f35d66d9e2439)) +* cleanup gitlab-ci ([017cf44](https://gitlab.com/ce72/vja/commit/017cf444633a7f800c6f8d1960ea84e093a79c65)) +* cleanup test setup ([c8a3872](https://gitlab.com/ce72/vja/commit/c8a387244ee86728241970d86e8b59d259a90c40)) +* fix version number ([a985a5e](https://gitlab.com/ce72/vja/commit/a985a5e28f2b1212a4a4f48aa41eadb0dacebcd3)) +* fix version number ([4518c3f](https://gitlab.com/ce72/vja/commit/4518c3fa5343762c2eaaa05ab2e60c61294b9b6a)) +* fix version number ([cc346c7](https://gitlab.com/ce72/vja/commit/cc346c7aea5b2486a79be2f142801c209adf82d7)) +* install coverage from requirements_dev.txt ([9b03ba1](https://gitlab.com/ce72/vja/commit/9b03ba180ceb1a9d8125a4713a5e2799777e4e36)) +* pipeline on branches only manual ([9b05c2d](https://gitlab.com/ce72/vja/commit/9b05c2ddaf778f179a7ddd16a56fa1d5d9b5b5fc)) +* pipeline on branches only manual ([3a936d9](https://gitlab.com/ce72/vja/commit/3a936d9a0425ca3c6c87c61b526e87b7474398c3)) +* remove build stage ([8e4f36c](https://gitlab.com/ce72/vja/commit/8e4f36cee4ebaff4fcf3c703e6e1b265d955d302)) +* remove build stage ([e4858e3](https://gitlab.com/ce72/vja/commit/e4858e363aed6369ef29ef0fe3ed56115083a11b)) +* remove build stage ([e27df75](https://gitlab.com/ce72/vja/commit/e27df75b220380148aab79e75af0e5194041d381)) +* remove build stage ([49d2fe2](https://gitlab.com/ce72/vja/commit/49d2fe2d4261fa993aa06365fd004876d8fff055)) +* remove build stage ([1661721](https://gitlab.com/ce72/vja/commit/166172141e63f3ef22c576196aacaf7de5bea227)) +* remove redundant apk packages ([bf6c0ea](https://gitlab.com/ce72/vja/commit/bf6c0eae179929555ecba1315d19683f052a046e)) +* remove redundant apk packages ([a34a24d](https://gitlab.com/ce72/vja/commit/a34a24d396bb508d4635bc1cb405d3ab9d6bbdc7)) +* update semantic-release configuration ([de4df86](https://gitlab.com/ce72/vja/commit/de4df867ec80fbead7e8831edbc8d38fd46e0a76)) +* wait for running api container ([60d6915](https://gitlab.com/ce72/vja/commit/60d6915845050c5b5b8e528feea69a42f8bbe5d3)) + +## [2.0.0-beta.4](https://gitlab.com/ce72/vja/compare/2.0.0-beta.3...2.0.0-beta.4) (2023-04-24) + + +### Bug Fixes + +* **clone:** clone labels too ([22bf1c0](https://gitlab.com/ce72/vja/commit/22bf1c075888d931ceefc989d50eb49282adccf5)) +* **clone:** do not clone positions and have --bucket ([bc382c2](https://gitlab.com/ce72/vja/commit/bc382c2aa9ca4b8109e62b8fce6f792b49c18fce)) +* **clone:** do not clone positions and have --bucket ([5edeaba](https://gitlab.com/ce72/vja/commit/5edeaba7cad2695fdf5470f6568bd4048d23c568)) + + +### Documentation + +* describe env variable VJA_CONFIGDIR ([11e1a97](https://gitlab.com/ce72/vja/commit/11e1a979dbfb169b5559145adc735df65ef09838)) + +## [2.0.0-beta.3](https://gitlab.com/ce72/vja/compare/2.0.0-beta.2...2.0.0-beta.3) (2023-04-10) + + +### Features + +* fetch long_token when logging in (with a ttl of 30 days) ([04b8932](https://gitlab.com/ce72/vja/commit/04b8932288b8072ad85a6614147e3b835a4f8bff)) + +## [2.0.0-beta.2](https://gitlab.com/ce72/vja/compare/2.0.0-beta.1...2.0.0-beta.2) (2023-04-02) + + +### Features + +* rename lists to project within vja ([f757117](https://gitlab.com/ce72/vja/commit/f75711797d887ffee8cd5b3ab7ec7edb8044494e)) + +## [2.0.0-beta.1](https://gitlab.com/ce72/vja/compare/1.5.0...2.0.0-beta.1) (2023-04-01) + + +### ⚠ BREAKING CHANGES + +* **api:** remove reminder_dates +* **api:** New projects api (only backend side) +* Preliminary support for new reminder array + +### Features + +* **api:** New projects api (only backend side) ([157aeb4](https://gitlab.com/ce72/vja/commit/157aeb483fa2b5d3b1d733f7682c97c8e449aebc)) +* **api:** remove reminder_dates ([76ffb20](https://gitlab.com/ce72/vja/commit/76ffb2007cf77d87e41b8cdb8bf16a0f9d4edf26)) +* defer reminder ([c493b07](https://gitlab.com/ce72/vja/commit/c493b07266affc2f6976a5682b1b33170c90e51b)) +* Preliminary support for new reminder array ([eb7e819](https://gitlab.com/ce72/vja/commit/eb7e819b08b897eeb55e0a18320a03a11442ce91)) +* **reminders:** Set relative reminders like "1h before due" ([f05438d](https://gitlab.com/ce72/vja/commit/f05438d9f8c61ee04cb5432b2e1e0d0a56ff2175)) +* Support for new reminder array (read) ([c323c76](https://gitlab.com/ce72/vja/commit/c323c76fcb52541a00e35d1e5c31a668eb061405)) + + +### Bug Fixes + +* do not unset reminder if missing in vja edit ([a3b81b0](https://gitlab.com/ce72/vja/commit/a3b81b0bae08841fd92e204d5557cc1ad46a59b3)) +* sharpen test ([4c59738](https://gitlab.com/ce72/vja/commit/4c59738a19bb4dabd1b871d54ba40f8833b2fe10)) + + +### Misc + +* Remove redundant test ([2901848](https://gitlab.com/ce72/vja/commit/29018480b6a11c2ec3495ce6e44c4955fe6b13ec)) + + +### Documentation + +* Add table of contents to Features.md ([3a87061](https://gitlab.com/ce72/vja/commit/3a87061f39b2bf4d2a1194197f5cfef3c8d7d46e)) ### Automation -* wait for running api container ([fca176d](https://gitlab.com/ce72/vja/commit/fca176dfb72c8b3e7ef07a048093691c5c74f8c3)) +* automate beta releases ([8f19e48](https://gitlab.com/ce72/vja/commit/8f19e48db1312db3e6d27716a692b95805ef82d1)) +* automate beta releases ([1c74fda](https://gitlab.com/ce72/vja/commit/1c74fdab9e9b02fc116a1532b081b562e9dbf832)) +* automate beta releases ([8f7bb4f](https://gitlab.com/ce72/vja/commit/8f7bb4f9802ec6e199d5f7dd60790341550a1785)) +* automate beta releases ([68bd7d8](https://gitlab.com/ce72/vja/commit/68bd7d8ee3b3579c3f48b3b8d13d2780f5194641)) +* capture click output ([9aafa2c](https://gitlab.com/ce72/vja/commit/9aafa2c426757b2068a1199410db58899356eacd)) +* cleanup gitlab-ci ([450d13f](https://gitlab.com/ce72/vja/commit/450d13fe43987c3cf33c72bfa2303e452ee7be8b)) +* cleanup gitlab-ci ([543a479](https://gitlab.com/ce72/vja/commit/543a47928a9539982a91507ece4e9d3605bb2dbb)) +* cleanup gitlab-ci ([7145451](https://gitlab.com/ce72/vja/commit/714545181a76b15c4341df3a4ff6830416ddf897)) +* cleanup gitlab-ci ([9584cac](https://gitlab.com/ce72/vja/commit/9584cac6e18e81480231b34fabd46990249a2760)) +* cleanup test setup ([5e5040a](https://gitlab.com/ce72/vja/commit/5e5040a3254b8cc5dc75f0391352e0e1358fdeaa)) +* install coverage from requirements_dev.txt ([eab4b0f](https://gitlab.com/ce72/vja/commit/eab4b0f4ef879c64859c9796f2562b09eaa53320)) +* pipeline on branches only manual ([43fa7df](https://gitlab.com/ce72/vja/commit/43fa7df6ec8fe69eb8d9d27589b65b9541b0b71a)) +* pipeline on branches only manual ([8689f5e](https://gitlab.com/ce72/vja/commit/8689f5e0a36a536187a9df97dd38a2a0ece9ed01)) +* remove build stage ([3294b83](https://gitlab.com/ce72/vja/commit/3294b83e1b75b2b6826333104d91e71ee1b8d478)) +* remove build stage ([8bca866](https://gitlab.com/ce72/vja/commit/8bca866c60f3ed2cf62eeb87cd10dad06f9563e6)) +* remove build stage ([4638743](https://gitlab.com/ce72/vja/commit/46387430fe4c92a02b818e200672c86ed4190e1a)) +* remove build stage ([5b32e53](https://gitlab.com/ce72/vja/commit/5b32e53277c3ee95464eb1d60f2c270dccb5bf4b)) +* remove build stage ([7dd6dcb](https://gitlab.com/ce72/vja/commit/7dd6dcbf9f8c373a9d8b33b8c17d29c559cc1075)) +* remove redundant apk packages ([22a3f48](https://gitlab.com/ce72/vja/commit/22a3f48391bc8434fcb208d31e4db50f701b8c68)) +* remove redundant apk packages ([efb7016](https://gitlab.com/ce72/vja/commit/efb701685d11bad90eb844ac8636117225ab2f7c)) +* sleep 1s after starting api ([649a806](https://gitlab.com/ce72/vja/commit/649a8063a39f76f91196c0f6f8261f5033f95d52)) +* update semantic-release configuration ([572f519](https://gitlab.com/ce72/vja/commit/572f519a10bc97e9badb017f90b5e723eecc8e42)) ## [1.5.0](https://gitlab.com/ce72/vja/compare/1.4.1...1.5.0) (2023-03-31) diff --git a/Features.md b/Features.md index 18e56d55f7146582cc941b96c1f4d201f10cebfc..5666fa12f706dfd72e9db35aab5e6774e6d8487a 100644 --- a/Features.md +++ b/Features.md @@ -1,18 +1,41 @@ # Features + +* [Features](#features) + * [Create Task](#create-task) + * [Clone](#clone) + * [List tasks](#list-tasks) + * [Urgency](#urgency) + * [Filter](#filter) + * [Sort](#sort) + * [Select](#select) + * [Show single task by id](#show-single-task-by-id) + * [Modify tasks](#modify-tasks) + * [Defer task](#defer-task) + * [Reminders](#reminders) + * [Batch editing](#batch-editing) + * [Open Vikunja in browser](#open-vikunja-in-browser) + * [Manage projects, labels, buckets](#manage-projects-labels-buckets) + * [Manage projects](#manage-projects) + * [Manage kanban buckets](#manage-kanban-buckets) + * [Manage labels](#manage-labels) + * [Output format](#output-format) + * [Terminate session](#terminate-session) + + ## Create Task -`vja add ` allows to quickly add a new task to the default list. Several options exist to provide more +`vja add ` allows to quickly add a new task to the default project. Several options exist to provide more context: ```shell -vja add Make things work --note="find out how" -priority=3 --favorite=True --due="tomorrow at 11:00" --reminder --tag=@work +vja add Getting things done --note="find out how" -priority=3 --favorite=True --due="tomorrow at 11:00" --reminder --label=@work ``` or more concise ```shell -vja add One more task -l 1 -p 4 -t "Label1" -n "my note" -d "23:00" -f True +vja add One more task -o 1 -p 4 -l "Label1" -n "my note" -d "23:00" -f True ``` See @@ -22,18 +45,22 @@ vja add --help ``` for more. + ### Clone + Another option to create a new task is cloning an existing task + ```shell -vja clone 1 Clone a new task +vja clone 1 Clone a new task ``` +By default, the kanban bucket is not cloned so that cloned tasks should appear in the leftmost Kanban column of the project. Call `vja clone 1 New task --bucket` to clone the Kanban bucket too. See + ```shell vja clone --help ``` - ## List tasks List all active tasks @@ -44,31 +71,46 @@ vja ls --json ``` ### Urgency -By default tasks are sorted (amongst others) by their urgency, which is displayed in the last column. Urgency is calculated by regarding due_date, priority and is_favorite of the task, as well as the occurence of keywords in the list title or the label titles. The weights of each factor and the keywords can be specified in the configuration file ~/.vjacli/vja.rc. See Configuration section in [Readme.md](Readme.md). See [.vjacli/vja.rc](.vjacli/vja.rc) for an example. + +By default, tasks are sorted (amongst others) by their urgency, which is displayed in the last column. Urgency is +calculated by regarding due_date, priority and is_favorite of the task, as well as the occurence of keywords in the +project title or the label titles. The weights of each factor and the keywords can be specified in the configuration +file ~/.vjacli/vja.rc. See Configuration section in [Readme.md](Readme.md). See [.vjacli/vja.rc](.vjacli/vja.rc) for an +example. ### Filter -The displayed tasks may be filtered by several arguments like list id or title, namespace and label +The displayed tasks may be filtered by several arguments like project or title, namespace and label ```shell +vja ls --project=1 +vja ls --base-project=myproject +vja ls --bucket_id=1 +vja ls --due-date="before today" +vja ls --due-date="ge in 0 days" --due-date="before 5 days" +vja ls --favorite=True vja ls --label=@work -vja ls --title=ask vja ls --priority="gt 3" vja ls --priority="eq 5" -vja ls --due-date="before today" -vja ls --due-date="ge in 0 days" --due-date="before 5 days" -vja ls -u # show Tasks with minimum urgency -vja ls --urgency=8 # show quite urgent tasks +vja ls --title=ask # matches regex string +vja ls -u # show Tasks with minimum urgency of 3 +vja ls --urgency=8 # show only quite urgent tasks ``` In addition to these shortcut filters, more general filtering can be done by `--filter= `: ```shell -vja ls --filter="priority gt 2" -vja ls --filter="title contains clean up" -vja ls --filter="labels contains @work" vja ls --filter="created after 2 days ago" vja ls --filter="due_date before today in 7 days" +vja ls --filter="label_titles contains @work" +vja ls --filter="label_titles ne @work" +vja ls --filter="priority gt 2" +vja ls --filter="title contains clean up" +``` + +All filters can be combined (and operation): +```shell +vja ls --filter="label_titles ne @work" --project=1 --urgent ``` See `vja ls --help` for more. @@ -85,7 +127,7 @@ vja ls --sort=-id # reverse Sort criteria can be combined. The default sort order of vja is the same as ```shell -vja ls --sort='done, -urgency, due_date, -priority, tasklist.title, title' +vja ls --sort='done, -urgency, due_date, -priority, project.title, title' ``` See `vja ls --help` for more. @@ -118,39 +160,80 @@ Set new due_date and set reminder=due_date vja edit 1 --due="in 4 days at 15:00" -r ``` -Toggle tag (=label). Use with --force to create new label: +Toggle label. Use with --force to create new label: ```shell -vja edit 1 -t @work +vja edit 1 -l @work ``` + Mark as done + ```shell vja edit 1 --done="true" vja check 1 # Shortcut to toggle the done flag of task 1 ``` +See + +```shell +vja edit --help +``` + +for more. + ### Defer task + There is a shortcut for setting a delay on a task by giving a timedelta expression. + ```shell vja defer 1 1d vja defer --help ``` -This command moves the due_date (and later the reminder) ahead in time. -Multiple edits are possible by giving more task ids +This command moves the due_date and the first reminder ahead in time. + +### Reminders + +vja manages only the first reminder of the task. That is the earliest reminder on the server. + +Set reminder to an absolute time ```shell -vja edit 1 5 8 --due="next monday 14:00" -vja defer 1 2 3 1d +vja edit 1 -r "next sunday at 11:00" +vja edit 1 --reminder="in 3 days at 11:00" ``` -See +Set reminder equal to due date ```shell -vja edit --help +vja edit 1 -r +vja edit 1 --reminder ``` -for more. +Set reminder relative to due date (only due date is supported by vja for relative reminders) + +```shell +vja edit --reminder="1h before due_date" +vja edit -r "10m before due" +``` + +Remove the earliest reminder + +```shell +vja edit 1 -r "" +vja edit 1 --reminder="" +``` + +The same goes for `vja add`. + +### Batch editing + +Multiple edits and defers are possible by giving more task ids. Take care though, there is no confirmation request. + +```shell +vja edit 1 5 8 --due="next monday 14:00" +vja defer 1 2 3 1d +``` ## Open Vikunja in browser @@ -166,36 +249,34 @@ Open task 42 in browser vja open 42 ``` -## Manage lists, namespaces, labels, buckets +## Manage projects, labels, buckets -### Manage namespaces +There is only a very basic support for managing entities other than tasks. I believe it is better to use the frontend. -```shell -vja namespace ls -``` +### Manage projects -### Manage lists (projects) +Projects can be added and be shown but not be modified: ```shell -vja list add New List +vja project add New Project ``` ```shell -vja list add -n 2 Create list in namespace with index 2 +vja project add -n 2 Create project in namespace with index 2 ``` ```shell -vja list ls +vja project ls ``` ```shell -vja list show 1 +vja project show 1 ``` ### Manage kanban buckets ```shell -vja bucket ls --list-id=1 +vja bucket ls --project-id=1 ``` ### Manage labels @@ -221,7 +302,7 @@ Do not use `--custom-format` if you feel uncomfortable with that. ## Terminate session -You may remove your traces by logging out. This will remove the local access token so that during subsequent execution +You may remove your traces by logging out. This will remove the local access token so that at a subsequent execution vja will prompt you again. ```shell diff --git a/Readme.md b/Readme.md index f9c08b25339f83400bb546830aeb50b61df4b004..1a7d29386ae1234388252cb5bdc7bc3f7cc64982 100755 --- a/Readme.md +++ b/Readme.md @@ -10,6 +10,19 @@ This is a simple CLI for Vikunja > [The todo app to organize your life.](https:/ It provides a command line interface for adding, viewing and editing todo tasks on a Vikunja Server. The goal is to support a command line based task workflow ~ similar to taskwarrior. +> #### Breaking changes in vja 2.0 +> vja 2.0 supports (and requires) the most recent Vikunja API > 0.20.4. +> In the wake of this transition the following breaking modifications to the vja command line interface have been +> introduced: +> - Labels: Are now given with `-l` (`-label`). (`-t` and `--tag` are no longer supported). +> - "Namespaces": Vikunja removed namespaces in favor of nested projects. `-n` (`--namespace`) was removed as option + from `vja ls`. +> - Projects (former "lists"): Must be given with `-o` (`--project`). `vja ls -u` may be used to filter on the project + or an upper project. This more or less resembles the old namespaces. +> +> Examples and more details can be found in the +> updated [Features.md](https://gitlab.com/ce72/vja/-/blob/main/Features.md) + ## Installation - Install from pypi: @@ -35,8 +48,11 @@ Before using vja you must provide a configuration. (If you cloned from git, you may copy the folder .vjacli to your `$HOME` directory instead.) - Adjust to your needs. `frontend_url` and `api_url` must point to your own Vikunja server. - Especially, the api_url must be reachable from your client. This can be verified, for example - by `curl https://mydomain.com/api/v1/info` + Especially, the api_url must be reachable from your client. This can be verified, for example, + by `curl https://mydomain.com/api/v1/info`. + +You may change the location of the configuration directory with an environment variable +like `VJA_CONFIGDIR=/not/my/home` ### Description of configuration @@ -49,17 +65,17 @@ Before using vja you must provide a configuration. #### Optional options -| Section | Option | Description | -|------------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [output] | arbitrary_name | Python format strings which may be referenced on the command line by `--custom-format=`. May contain any valid python f-Format string.
Take care: The format string may provide code which will be executed at runtime! Do not use `--custom-format` if you are unsure.
Default: missing | -| [output] | another_format | Multiple formats can be defined for reference. (see above) | -| [urgency_coefficients] | due_date_weight | Weight of dueness in urgency score. Default: 1.0 | -| [urgency_coefficients] | priority_weight | Weight of priority in urgency score. Default: 1.0 | -| [urgency_coefficients] | favorite_weight | Weight of is_favorite in urgency score. Default: 1.0 | -| [urgency_coefficients] | list_weight | Weight of keyword occurrence in list title in urgency score. Default: 1.0 | -| [urgency_coefficients] | label_weight | Weight of keyword occurrence in label title in urgency score. Default: 1.0 | -| [urgency_keywords] | list_keywords | Tasks in lists with a title containing these keywords are considered more urgent. Default: None | -| [urgency_keywords] | label_keywords | Tasks labeled with one of these keywords are considered more urgent. Default: None | +| Section | Option | Description | +|------------------------|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [output] | arbitrary_name | Python format strings which may be referenced on the command line by `--custom-format=`. May contain any valid python f-Format string.
Take care: The format string may provide code which will be executed at runtime! Do not use `--custom-format` if you are unsure.
Default: missing | +| [output] | another_format | Multiple formats can be defined for reference. (see above) | +| [urgency_coefficients] | due_date_weight | Weight of dueness in urgency score. Default: 1.0 | +| [urgency_coefficients] | priority_weight | Weight of priority in urgency score. Default: 1.0 | +| [urgency_coefficients] | favorite_weight | Weight of is_favorite in urgency score. Default: 1.0 | +| [urgency_coefficients] | project_weight | Weight of keyword occurrence in project title in urgency score. Default: 1.0 | +| [urgency_coefficients] | label_weight | Weight of keyword occurrence in label title in urgency score. Default: 1.0 | +| [urgency_keywords] | lisproject_keywords | Tasks in projects with a title containing these keywords are considered more urgent. Default: None | +| [urgency_keywords] | label_keywords | Tasks labeled with one of these keywords are considered more urgent. Default: None | ## Usage @@ -105,7 +121,6 @@ Run integration test (requires docker and docker-compose) ```shell docker-compose -f tests/docker-compose.yml up -d -./run.sh VJA_CONFIGDIR=tests/.vjatest pytest docker-compose -f tests/docker-compose.yml down ``` diff --git a/ci/build-install.sh b/ci/build-install.sh deleted file mode 100755 index ddb6dbebae1c1d812fb6982c4a6cd8a8d1c1eaa6..0000000000000000000000000000000000000000 --- a/ci/build-install.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -euo pipefail - -pip install -r requirements_dev.txt -pip uninstall -y vja -python setup.py sdist bdist_wheel -pip install dist/*.whl diff --git a/ci/virtual-env.sh b/ci/virtual-env.sh deleted file mode 100755 index 27d493432dd4fb86d5c87063afd25cb503ba17c3..0000000000000000000000000000000000000000 --- a/ci/virtual-env.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -euo pipefail - -python --version -pip install virtualenv -virtualenv venv - diff --git a/requirements_dev.txt b/requirements_dev.txt index 07d76432405eb5c3073bae3e8124df35c422599d..c45e59434fc94410d5e1fe43756a5fa32d062654 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,3 +4,4 @@ twine==4.0.2 pylint==2.17.2 flake8==6.0.0 pytest==7.3.1 +coverage==7.2.2 diff --git a/setup.cfg b/setup.cfg index 5643264aa51231b26c4be486722e7c02d7022c7d..c8a9fe3bcafed808b54773b87b8abcfa943a08bc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,3 @@ [metadata] -version = 1.5.1 +version = 2.0.0b1 diff --git a/tests/.vjatest/vja.rc b/tests/.vjatest/vja.rc index 4e35eae1cf9e1290179c19280e2287752646704b..ebcaf22bdf9c758293a30b55255bc4f57dad04ae 100644 --- a/tests/.vjatest/vja.rc +++ b/tests/.vjatest/vja.rc @@ -8,7 +8,7 @@ api_url=http://localhost:3456/api/v1 # Be careful: Any code which is entered here gets executed at runtime (python eval()). # Do not use --custom-format if you feel uncomfortable with that. -tasklist_long={x.id:5} ({x.priority}) {"*" if x.is_favorite else " "} {x.title:50.50} {x.due_date.strftime("%a %d.%m %H:%M") if x.due_date else "":15.15} {"R" if x.reminder_dates else " "} {x.tasklist.title:20.20} {x.label_titles:20.20} {x.urgency:3} +tasklist_long={x.id:5} ({x.priority}) {"*" if x.is_favorite else " "} {x.title:50.50} {x.due_date.strftime("%a %d.%m %H:%M") if x.due_date else "":15.15} {"R" if x.reminders else " "} {x.project.title:20.20} {x.label_titles:20.20} {x.urgency:3} tasklist_short={x.id:5} {x.title:50.50} ids_only={x.id} @@ -16,9 +16,9 @@ ids_only={x.id} due_date_weight=1.0 priority_weight=2.0 favorite_weight=1.0 -list_weight=1.0 +project_weight=1.0 label_weight=1.0 [urgency_keywords] -list_keywords=next, test-list +project_keywords=next, test-project label_keywords=next,my_tag diff --git a/tests/.vjatest_dind/vja.rc b/tests/.vjatest_dind/vja.rc index 61a36fdbf16bc37055011baed9462039890522a3..f8b778803f14bbd181801e19467bfc0caba3e819 100644 --- a/tests/.vjatest_dind/vja.rc +++ b/tests/.vjatest_dind/vja.rc @@ -8,7 +8,7 @@ api_url=http://docker:3456/api/v1 # Be careful: Any code which is entered here gets executed at runtime (python eval()). # Do not use --custom-format if you feel uncomfortable with that. -tasklist_long={x.id:5} ({x.priority}) {"*" if x.is_favorite else " "} {x.title:50.50} {x.due_date.strftime("%a %d.%m %H:%M") if x.due_date else "":15.15} {"R" if x.reminder_dates else " "} {x.tasklist.title:20.20} {x.label_titles:20.20} {x.urgency:3} +tasklist_long={x.id:5} ({x.priority}) {"*" if x.is_favorite else " "} {x.title:50.50} {x.due_date.strftime("%a %d.%m %H:%M") if x.due_date else "":15.15} {"R" if x.reminders else " "} {x.project.title:20.20} {x.label_titles:20.20} {x.urgency:3} tasklist_short={x.id:5} {x.title:50.50} ids_only={x.id} @@ -16,9 +16,9 @@ ids_only={x.id} due_date_weight=1.0 priority_weight=2.0 favorite_weight=1.0 -list_weight=1.0 +project_weight=1.0 label_weight=1.0 [urgency_keywords] -list_keywords=next, test-list +project_keywords=next, test-project label_keywords=next,my_tag diff --git a/tests/conftest.py b/tests/conftest.py index 12f5b7be476fc4869985e8786f02f607ed21e3a0..89e675af97c82e16667efa4a0a8d8505d007b93e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import logging import os import subprocess import sys @@ -7,51 +8,48 @@ from click.testing import CliRunner from vja.cli import cli +logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) + @pytest.fixture(name='runner', scope='session') def setup_runner(): return CliRunner() -def invoke(runner, command, return_code=0, user_input=None, catch_exceptions=True): +def invoke(runner, command, return_code=0, user_input=None, catch_exceptions=False): if isinstance(command, str): command = command.split() res = runner.invoke(cli, command, input=user_input, catch_exceptions=catch_exceptions) + sys.stdout.write(res.output) + if res.stderr_bytes: + sys.stdout.write(res.stderr_bytes) + if res.exception: + logging.warning(res.exception) assert res.exit_code == return_code, res return res def _login_as_test_user(): - result = subprocess.run('vja logout'.split(), check=False) - if result.returncode: - print(result.stdout) - print(result.stderr) - return result.returncode + run_vja('vja logout') + run_vja('vja --username=test --password=test user show') - result = subprocess.run('vja --username=test --password=test user show'.split(), check=False) - if result.returncode: - print(result.stdout) - print(result.stderr) - return result.returncode +def _create_project_and_task(): + run_vja('vja project add test-project') + run_vja('vja project add child --parent-project-id=1') + run_vja('vja project add grand-child --parent-project-id=2') + run_vja('vja add At least one task --force-create --priority=5 --due-date=today --label=my_tag --favorite=True --project-id=1') + run_vja('vja add Task in subproject --force-create --project-id=3') + run_vja('vja add A task without a label --force-create') -def _create_list_and_task(): - result = subprocess.run('vja list add test-list'.split(), check=False) - if result.returncode: - print(result.stdout) - print(result.stderr) - return result.returncode - result = subprocess.run('vja add At least one task --force-create ' - '--priority=5 --due-date=today --tag=my_tag --favorite=True'.split(), check=False) - if result.returncode: - print(result.stdout) - print(result.stderr) - return result.returncode - result = subprocess.run('vja add A task without a label --force-create'.split(), check=False) + +def run_vja(command): + result = subprocess.run(command.split(), capture_output=True, check=False) if result.returncode: - print(result.stdout) - print(result.stderr) - return result.returncode + print(f'!!! Non-zero result ({result.returncode}) from command {command}') + sys.stdout.write(result.stdout.decode('utf-8')) + sys.stdout.write(result.stderr.decode('utf-8')) + sys.exit(1) def pytest_configure(): @@ -59,10 +57,6 @@ def pytest_configure(): print('!!! Precondition not met. You must set VJA_CONFIGDIR in environment variables !!!') sys.exit(1) - if _login_as_test_user() > 0: - print('!!! Precondition not met. Cannot connect to Vikunja with user test/test') - sys.exit(1) + _login_as_test_user() - if _create_list_and_task() > 0: - print('!!! Unable to create default list') - sys.exit(1) + _create_project_and_task() diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index d8bf998f190d09925b4cd1b04aff0f88d00cda13..351f10915aea71c8b451e115dd68faa35aaaf8fe 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -2,21 +2,22 @@ version: '3' services: api: - image: vikunja/api:0.20.4 + image: vikunja/api:unstable ports: - "3456:3456" environment: VIKUNJA_SERVICE_FRONTENDURL: http://localhost:8080/ VIKUNJA_RATELIMIT_LIMIT: 10000 + #VIKUNJA_LOG_LEVEL: DEBUG + #VIKUNJA_LOG_DATABASELEVEL: DEBUG entrypoint: > /bin/sh -c " /app/vikunja/vikunja user create -u test -p test -e test@test.test exec /app/vikunja/vikunja " frontend: - image: vikunja/frontend:0.20.5 + image: vikunja/frontend:unstable ports: - "8080:80" environment: VIKUNJA_API_URL: http://localhost:3456/api/v1 - diff --git a/tests/run.sh b/tests/run.sh index eb3fb8ed3886c34480a0f4da4a2e47cdc43d3934..45bcd5258f66e859c168a9a1b45334064c7524c2 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -7,18 +7,18 @@ run-test () { vja --help vja -u test -p test ls vja user show - vja list add Work - vja list add Home - vja list ls - vja list show 1 + vja project add Work + vja project add Home + vja project ls + vja project show 1 vja label add Next Action vja label ls - vja add "Look around" --prio=2 --tag="Next Action" --note="my note" --due="next monday at 15:00" --favorite=True --reminder="friday 12:00" - vja add "Stay at home" -p 4 -t "Next Action" -n "my note" -d "23:00" -f True --list=2 + vja add "Look around" --prio=2 --label="Next Action" --note="my note" --due="next monday at 15:00" --favorite=True --reminder="friday 12:00" + vja add "Stay at home" -p 4 -l "Next Action" -n "my note" -d "23:00" -f True --project=2 vja add Go home vja ls - vja ls --list=1 - vja ls --list=Work + vja ls --project=1 + vja ls --project=Work vja ls --label="Next Action" vja ls --title="home" vja ls --bucket=2 @@ -31,13 +31,12 @@ run-test () { vja show 1 --json vja show 1 --jsonvja vja toggle 1 - vja list show 1 --json - vja bucket ls -l 1 + vja project show 1 --json + vja bucket ls -o 1 vja label ls - vja namespace ls vja -v logout vja -v -u test -p test ls - vja -v list ls + vja -v project ls vja -v edit 1 --prio=5 --favorite=True vja -v show 1 vja -v ls diff --git a/tests/test_command.py b/tests/test_command.py index 50b3a5a05a877af16b17ec6e1d538d9509b0772f..5aee06c04f40b3e65bcf56f5ad29f1de11cfc2df 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -5,43 +5,43 @@ import re from tests.conftest import invoke from vja.cli import cli -ADD_SUCCESS_PATTERN = re.compile(r'.*Created task (\d+) in list .*') -TODAY = datetime.datetime.now() -TOMORROW = (datetime.datetime.now() + datetime.timedelta(days=1)) +ADD_SUCCESS_PATTERN = re.compile(r'.*Created task (\d+) in project .*') +TODAY = datetime.datetime.now().replace(microsecond=0) TODAY_ISO = TODAY.isoformat() +YESTERDAY = TODAY + datetime.timedelta(days=-1) +YESTERDAY_ISO = YESTERDAY.isoformat() +TOMORROW = TODAY + datetime.timedelta(days=1) TOMORROW_ISO = TOMORROW.isoformat() +DATE_1 = TODAY + datetime.timedelta(days=10) +DATE_2 = DATE_1 + datetime.timedelta(days=1) +DATE_1_ISO = DATE_1.isoformat() +DATE_2_ISO = DATE_2.isoformat() class TestAddTask: - def test_list_id(self, runner): - res = invoke(runner, 'add title of new task --force --list=1') + def test_project_id(self, runner): + res = invoke(runner, 'add title of new task --force --project=1') after = json_for_created_task(runner, res.output) - assert after['tasklist']['id'] == 1 + assert after['project']['id'] == 1 - def test_list_title(self, runner): - res = invoke(runner, 'add title of new task --force --list=test-list') + def test_project_title(self, runner): + res = invoke(runner, 'add title of new task --force --project=test-project') after = json_for_created_task(runner, res.output) - assert after['tasklist']['title'] == 'test-list' + assert after['project']['title'] == 'test-project' def test_duplicate_task_title_rejected(self, runner): - invoke(runner, 'add title of new task', 1) - - def test_positions_not_null(self, runner): - res = invoke(runner, 'add any other new task --force') - after = json_for_created_task(runner, res.output) - assert after['kanban_position'] > 0 - assert after['position'] > 0 + invoke(runner, 'add title of new task', 1, catch_exceptions=True) def test_default_reminder_uses_due(self, runner): - res = invoke(runner, 'add title of new task --force --list=test-list --due=today --reminder') + res = invoke(runner, 'add title of new task --force --project=test-project --due=today --reminder') after = json_for_created_task(runner, res.output) - assert after['due_date'] == after['reminder_dates'][0] + assert after['reminders'][0]['relative_period'] == 0 + assert after['reminders'][0]['relative_to'] == 'due_date' - def test_default_reminder_with_missing_due_uses_tomorrow(self, runner): - res = invoke(runner, 'add title of new task --force --list=test-list --reminder') + def test_default_reminder_with_absolute_time(self, runner): + res = invoke(runner, f'add title of new task --force --project=test-project --reminder={DATE_2_ISO}') after = json_for_created_task(runner, res.output) - assert after['due_date'] is None - assert TOMORROW_ISO[0:10] in after['reminder_dates'][0] + assert after['reminders'][0]['reminder'] == DATE_2_ISO class TestCloneTask: @@ -49,38 +49,53 @@ class TestCloneTask: before = json_for_task_id(runner, 1) res = invoke(runner, 'clone 1 title of new task cloned from 1') after = json_for_created_task(runner, res.output) - assert after['tasklist'] == before['tasklist'] + assert after['project'] == before['project'] assert after['due_date'] == before['due_date'] + assert after['labels'] == before['labels'] assert after['title'] != before['title'] assert after['id'] != before['id'] assert after['created'] != before['created'] + assert after['position'] != before['position'] + assert after['kanban_position'] != before['kanban_position'] +# assert after['bucket_id'] != before['bucket_id'] # TODO: Need to create a second bucket to test this + + + def test_clone_task_keeping_bucket(self, runner): + before = json_for_task_id(runner, 1) + res = invoke(runner, 'clone 1 --bucket title of new task with labels cloned from 1') + after = json_for_created_task(runner, res.output) + assert after['bucket_id'] == before['bucket_id'] class TestEditGeneral: def test_edit_title(self, runner): before = json_for_task_id(runner, 1) new_title = f'{before["title"]}42' - res = runner.invoke(cli, ['edit', '1', '-i', f'{new_title}']) - assert res.exit_code == 0, res + invoke(runner, ['edit', '1', '-i', f'{new_title}']) after = json_for_task_id(runner, 1) assert after['title'] == new_title assert after['updated'] > before['updated'] # other attributes remain in place assert after['due_date'] == before['due_date'] - assert after['reminder_dates'] == before['reminder_dates'] + assert after['reminders'] == before['reminders'] assert after['position'] == before['position'] - assert after['tasklist']['id'] == before['tasklist']['id'] + assert after['project']['id'] == before['project']['id'] assert after['created'] == before['created'] - def test_edit_due_date(self, runner): - before = json_for_task_id(runner, 1) + def test_edit_due_date_without_time(self, runner): + + invoke(runner, 'edit 1 --due=tomorrow') - invoke(runner, 'edit 1 --due=today') + after = json_for_task_id(runner, 1) + assert after['due_date'] == (TOMORROW.replace(hour=0, minute=0, second=0)).isoformat() + + def test_edit_due_date_with_time(self, runner): + + invoke(runner, ['edit', '1', '--due=tomorrow 15:00']) after = json_for_task_id(runner, 1) - assert datetime.date.today().isoformat()[0:10] in after['due_date'] - assert after['updated'] >= before['updated'] + assert after['due_date'] == (TOMORROW.replace(hour=15, minute=0, second=0)).isoformat() def test_unset_due_date(self, runner): before = json_for_task_id(runner, 1) @@ -90,13 +105,12 @@ class TestEditGeneral: after = json_for_task_id(runner, 1) assert after['due_date'] is None - assert after['updated'] >= before['updated'] def test_toggle_label(self, runner): labels_0 = json_for_task_id(runner, 1)['labels'] - invoke(runner, 'edit 1 --tag=tag1 --force-create') + invoke(runner, 'edit 1 --label=tag1 --force-create') labels_1 = json_for_task_id(runner, 1)['labels'] - invoke(runner, 'edit 1 --tag=tag1') + invoke(runner, 'edit 1 --label=tag1') labels_2 = json_for_task_id(runner, 1)['labels'] assert labels_0 != labels_1 @@ -112,15 +126,15 @@ class TestEditGeneral: assert note_1 == 'line1' assert note_2 == 'line1\nline2' - def test_edit_list(self, runner): - invoke(runner, 'list add another list') - invoke(runner, 'edit 1 --list-id=1') - list_1 = json_for_task_id(runner, 1)['tasklist']['id'] - invoke(runner, 'edit 1 -l 2') - list_2 = json_for_task_id(runner, 1)['tasklist']['id'] + def test_edit_project(self, runner): + invoke(runner, 'project add another project') + invoke(runner, 'edit 1 --project-id=1') + project_1 = json_for_task_id(runner, 1)['project']['id'] + invoke(runner, 'edit 1 -o 2') + project_2 = json_for_task_id(runner, 1)['project']['id'] - assert list_1 == 1 - assert list_2 == 2 + assert project_1 == 1 + assert project_2 == 2 @staticmethod def _has_label_with_title(labels, title): @@ -129,61 +143,57 @@ class TestEditGeneral: class TestEditReminder: - def test_set_default_reminder_to_remote_due(self, runner): - invoke(runner, f'edit 2 --due-date={TODAY_ISO}') - invoke(runner, f'edit 2 --reminder={TOMORROW_ISO}') + def test_set_reminder_to_absolute_value(self, runner): + invoke(runner, f'edit 2 --reminder={DATE_1_ISO}') before = json_for_task_id(runner, 2) - invoke(runner, 'edit 2 --reminder') + invoke(runner, f'edit 2 --due={DATE_1_ISO} --reminder={DATE_2_ISO}') + after = json_for_task_id(runner, 2) - assert before['reminder_dates'][0][:10] == TOMORROW.date().isoformat() - assert after['reminder_dates'][0][:10] == TODAY.date().isoformat() + assert before['reminders'][0]['reminder'] == DATE_1_ISO + assert after['reminders'][0]['reminder'] == DATE_2_ISO + assert not after['reminders'][0]['relative_to'] - def test_set_default_reminder_to_tomorrow(self, runner): - invoke(runner, 'edit 2 --due-date=') - invoke(runner, f'edit 2 --reminder={TODAY_ISO}') + def test_set_reminder_to_due(self, runner): + invoke(runner, f'edit 2 --reminder={DATE_1_ISO}') before = json_for_task_id(runner, 2) - invoke(runner, 'edit 2 --reminder') + invoke(runner, f'edit 2 --due={DATE_2_ISO} --reminder=due') + after = json_for_task_id(runner, 2) - assert before['reminder_dates'][0][:10] == TODAY.date().isoformat() - assert after['reminder_dates'][0][:10] == TOMORROW.date().isoformat() + assert before['reminders'][0]['reminder'] == DATE_1_ISO + assert after['reminders'][0]['reminder'] == DATE_2_ISO + assert after['reminders'][0]['relative_period'] == 0 + assert after['reminders'][0]['relative_to'] == 'due_date' - def test_unset_reminder(self, runner): - invoke(runner, f'edit 2 --reminder={TODAY_ISO}') - before = json_for_task_id(runner, 2) + def test_set_reminder_to_due_empty_option(self, runner): + invoke(runner, f'edit 2 --reminder={DATE_1_ISO}') + + invoke(runner, f'edit 2 --due={DATE_2_ISO} --reminder') - invoke(runner, 'edit 2 --reminder=') after = json_for_task_id(runner, 2) - assert before['reminder_dates'][0][:10] == TODAY.date().isoformat() - assert not after['reminder_dates'] + assert after['reminders'][0]['reminder'] == DATE_2_ISO + assert after['reminders'][0]['relative_period'] == 0 + assert after['reminders'][0]['relative_to'] == 'due_date' - def test_set_reminder_to_due(self, runner): - invoke(runner, f'edit 2 --reminder={TODAY_ISO}') - before = json_for_task_id(runner, 2) + def test_set_reminder_to_relative_value(self, runner): + invoke(runner, f'edit 2 --reminder={DATE_1_ISO}') + + invoke(runner, ['edit', '2', '--due={DATE_2_ISO}', '--reminder=1h1m before due_date']) - invoke(runner, f'edit 2 --due={TOMORROW_ISO} --reminder=due') after = json_for_task_id(runner, 2) - assert before['reminder_dates'][0][:10] == TODAY.date().isoformat() - assert after['reminder_dates'][0][:10] == TOMORROW.date().isoformat() + assert after['reminders'][0]['relative_period'] == -3660 + assert after['reminders'][0]['relative_to'] == 'due_date' - def test_set_reminder_to_value(self, runner): - invoke(runner, f'edit 2 --reminder={TODAY_ISO}') + def test_unset_reminder(self, runner): + invoke(runner, f'edit 2 --reminder={DATE_1_ISO}') before = json_for_task_id(runner, 2) - invoke(runner, f'edit 2 --due={TODAY_ISO} --reminder={TOMORROW_ISO}') - after = json_for_task_id(runner, 2) - assert before['reminder_dates'][0][:10] == TODAY.date().isoformat() - assert after['reminder_dates'][0][:10] == TOMORROW.date().isoformat() - - def test_set_reminder_to_value_new_reminder(self, runner): invoke(runner, 'edit 2 --reminder=') - before = json_for_task_id(runner, 2) - invoke(runner, f'edit 2 --reminder={TOMORROW_ISO}') after = json_for_task_id(runner, 2) - assert not before['reminder_dates'] - assert after['reminder_dates'][0][:10] == TOMORROW.date().isoformat() + assert before['reminders'][0]['reminder'] == DATE_1_ISO + assert not after['reminders'] class TestToggleDoneTask: @@ -207,15 +217,32 @@ class TestToggleDoneTask: class TestDeferTask: - def test_defer_task(self, runner): - invoke(runner, f'edit 2 --due-date={TODAY_ISO}') + def test_defer_due_date_and_reminder(self, runner): + invoke(runner, f'edit 2 --due-date={DATE_1_ISO} --reminder={DATE_1_ISO}') invoke(runner, 'defer 2 1d') after = json_for_task_id(runner, 2) - assert after['due_date'][:10] == TOMORROW.date().isoformat() - assert after['reminder_dates'][0][:10] == TOMORROW.date().isoformat() + assert after['due_date'] == DATE_2_ISO + assert after['reminders'][0]['reminder'] == DATE_2_ISO + + def test_dont_defer_relative_reminder(self, runner): + invoke(runner, f'edit 2 --due-date={DATE_1_ISO} -r') + invoke(runner, 'defer 2 1d') + + after = json_for_task_id(runner, 2) + assert after['due_date'] == DATE_2_ISO + assert after['reminders'][0]['relative_period'] == 0 + assert after['reminders'][0]['relative_to'] == 'due_date' + + def test_defer_past_due_realtive_to_now(self, runner): + invoke(runner, f'edit 2 --due-date={YESTERDAY_ISO}') + + invoke(runner, 'defer 2 1d') + + after = json_for_task_id(runner, 2) + assert after['due_date'][:10] == TOMORROW_ISO[:10] class TestMultipleTasks: def test_edit_three_tasks(self, runner): diff --git a/tests/test_query.py b/tests/test_query.py index df2b1c9d9f27fe9c741d9317820df6943f3002e6..e330441fe391f66a6f6590a65adf61795876c2b5 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -18,47 +18,28 @@ class TestUser: assert json.loads(res.output)['username'] == 'test' -class TestNamespace: - def test_namespace_ls(self, runner): - res = invoke(runner, 'namespace ls') - assert re.search(r'test\'s namespace', res.output) +class TestProject: + def test_project_ls(self, runner): + res = invoke(runner, 'project ls') + assert re.search(r'test-project', res.output) - def test_namespace_ls_json(self, runner): - res = invoke(runner, 'namespace ls --json') - assert json.loads(res.output)[1]['title'] == 'test' - - def test_namespace_ls_jsonvja(self, runner): - res = invoke(runner, 'namespace ls --jsonvja') - assert json.loads(res.output)[1]['title'] == 'test' - - def test_namespace_ls_custom_format(self, runner): - res = invoke(runner, 'namespace ls --custom-format=ids_only') - for line in res.output: - assert re.match(r'^-?\d*$', line) - - -class TestList: - def test_list_ls(self, runner): - res = invoke(runner, 'list ls') - assert re.search(r'test-list', res.output) - - def test_list_show(self, runner): - res = invoke(runner, 'list show 1') + def test_project_show(self, runner): + res = invoke(runner, 'project show 1') assert len(res.output) > 0 - def test_list_ls_custom_format(self, runner): - res = invoke(runner, 'list ls --custom-format=ids_only') + def test_project_ls_custom_format(self, runner): + res = invoke(runner, 'project ls --custom-format=ids_only') for line in res.output: - assert re.match(r'^\d*$', line) + assert re.match(r'^-?\d*$', line) class TestBucket: def test_bucket_ls(self, runner): - res = invoke(runner, 'bucket ls --list-id=1') + res = invoke(runner, 'bucket ls --project-id=1') assert re.search(r'Backlog', res.output) def test_bucket_ls_custom_format(self, runner): - res = invoke(runner, 'bucket ls --list-id=1 --custom-format=ids_only') + res = invoke(runner, 'bucket ls --project-id=1 --custom-format=ids_only') for line in res.output: assert re.match(r'^\d*$', line) @@ -91,12 +72,12 @@ class TestSingleTask: assert data['position'] is not None assert data['kanban_position'] is not None assert data['bucket_id'] is not None - assert data['tasklist']['id'] is not None + assert data['project']['id'] is not None assert data['created'] is not None assert data['updated'] is not None -class TestTaskList: +class TestTaskLs: def test_task_ls(self, runner): res = invoke(runner, 'ls') assert re.search(r'At least one task', res.output) @@ -119,7 +100,7 @@ class TestTaskList: assert data[0]['id'] > 1 def test_sort_combined(self, runner): - res = invoke(runner, ['ls', '--jsonvja', '--sort=due_date, is_favorite, -priority, tasklist.title']) + res = invoke(runner, ['ls', '--jsonvja', '--sort=due_date, is_favorite, -priority, project.title']) data = json.loads(res.output) assert data[0]['due_date'] is not None assert data[-1]['due_date'] is None @@ -130,7 +111,7 @@ class TestTaskList: assert re.match(r'^\d*$', line) -class TestTaskListFilter: +class TestTaskLsFilter: def test_task_filter_bucket(self, runner): res = invoke(runner, ['ls', '--jsonvja', '--bucket=1']) data = json.loads(res.output) @@ -166,7 +147,7 @@ class TestTaskListFilter: data = json.loads(res.output) assert len(data) > 0 assert data[0]['labels'][0]['title'] == 'my_tag' - res = invoke(runner, ['ls', '--jsonvja', '--label=Not created']) + res = invoke(runner, ['ls', '--jsonvja', '--label=unknown_label']) data = json.loads(res.output) assert len(data) == 0 @@ -176,21 +157,12 @@ class TestTaskListFilter: assert len(data) > 0 assert all(len(i['labels']) == 0 for i in data) - def test_task_filter_list(self, runner): - res = invoke(runner, ['ls', '--jsonvja', '--list=test-list']) + def test_task_filter_project(self, runner): + res = invoke(runner, ['ls', '--jsonvja', '--project=test-project']) data = json.loads(res.output) assert len(data) > 0 - assert all(i['tasklist']['title'] == 'test-list' for i in data) - res = invoke(runner, ['ls', '--jsonvja', '--list=Not created']) - data = json.loads(res.output) - assert len(data) == 0 - - def test_task_filter_namespace(self, runner): - res = invoke(runner, ['ls', '--jsonvja', '--namespace=test']) - data = json.loads(res.output) - assert len(data) > 0 - assert all(i['tasklist']['namespace']['title'] == 'test' for i in data) - res = invoke(runner, ['ls', '--jsonvja', '--namespace=Not created']) + assert all(i['project']['title'] == 'test-project' for i in data) + res = invoke(runner, ['ls', '--jsonvja', '--project=Not created']) data = json.loads(res.output) assert len(data) == 0 @@ -226,14 +198,6 @@ class TestTaskListFilter: assert len(data) == 0 def test_task_filter_general(self, runner): - res = invoke(runner, ['ls', '--jsonvja', '--filter=title contains At least one']) - assert len(json.loads(res.output)) > 0 - res = invoke(runner, ['ls', '--jsonvja', '--filter=title contains TASK_NOT_CREATED']) - assert len(json.loads(res.output)) == 0 - res = invoke(runner, ['ls', '--jsonvja', '--filter=priority gt 2']) - assert len(json.loads(res.output)) > 0 - res = invoke(runner, ['ls', '--jsonvja', '--filter=priority gt 5']) - assert len(json.loads(res.output)) == 0 res = invoke(runner, ['ls', '--jsonvja', '--filter=due_date after 2 days ago']) assert len(json.loads(res.output)) > 0 res = invoke(runner, ['ls', '--jsonvja', '--filter=due_date after 200 days']) @@ -244,9 +208,31 @@ class TestTaskListFilter: data = json.loads(res.output) assert len(data) > 0 assert all(i['is_favorite'] for i in data) + res = invoke(runner, ['ls', '--jsonvja', '--filter=priority gt 2']) + assert len(json.loads(res.output)) > 0 + res = invoke(runner, ['ls', '--jsonvja', '--filter=priority gt 5']) + assert len(json.loads(res.output)) == 0 + res = invoke(runner, ['ls', '--jsonvja', '--filter=title contains At least one']) + assert len(json.loads(res.output)) > 0 + res = invoke(runner, ['ls', '--jsonvja', '--filter=title contains TASK_NOT_CREATED']) + assert len(json.loads(res.output)) == 0 + + def test_task_filter_general_label(self, runner): + res = invoke(runner, ['ls', '--json', '--filter=label_titles contains my_tag']) + data = json.loads(res.output) + assert len(data) > 0 + assert all('my_tag' in _labels_from_task_json(task) for task in data) + res = invoke(runner, ['ls', '--jsonvja', '--filter=label_titles ne my_tag']) + data = json.loads(res.output) + assert len(data) > 0 + assert all('my_tag' not in _labels_from_task_json(task) for task in data) def test_task_filter_general_combined(self, runner): res = invoke(runner, ['ls', '--jsonvja', '--filter=id gt 0', '--filter=id lt 2']) data = json.loads(res.output) assert len(data) == 1 assert all(i['id'] == 1 for i in data) + + +def _labels_from_task_json(task): + return ' '.join(map(lambda label: label['title'], task['labels'] or [])) diff --git a/vja/apiclient.py b/vja/apiclient.py index c40e4e849c6a95cce43316cdb854be64c2ddd9db..7f4a7926139096511fdf8a67014dc2a52df9686c 100755 --- a/vja/apiclient.py +++ b/vja/apiclient.py @@ -41,7 +41,7 @@ class ApiClient: def __init__(self, api_url, token_file): logger.debug('Connecting to api_url %s', api_url) self._api_url = api_url - self._cache = {'lists': None, 'labels': None, 'namespaces': None, 'tasks': None} + self._cache = {'projects': None, 'labels': None, 'tasks': None} self._login = Login(api_url, token_file) def _create_url(self, path): @@ -111,25 +111,20 @@ class ApiClient: def get_user(self): return self._get_json(self._create_url('/user')) - def get_namespaces(self): - if self._cache['namespaces'] is None: - self._cache['namespaces'] = self._get_json(self._create_url('/namespaces')) or [] - return self._cache['namespaces'] + def get_projects(self): + if self._cache['projects'] is None: + self._cache['projects'] = self._get_json(self._create_url('/projects')) or [] + return self._cache['projects'] - def get_lists(self): - if self._cache['lists'] is None: - self._cache['lists'] = self._get_json(self._create_url('/lists')) or [] - return self._cache['lists'] + def get_project(self, project_id): + return self._get_json(self._create_url(f'/projects/{str(project_id)}')) - def get_list(self, list_id): - return self._get_json(self._create_url(f'/lists/{str(list_id)}')) + def put_project(self, parent_project_id, title): + payload = {'title': title, 'parent_project_id': parent_project_id} + return self._put_json(self._create_url('/projects'), payload=payload) - def put_list(self, namespace_id, title): - payload = {'title': title} - return self._put_json(self._create_url(f'/namespaces/{str(namespace_id)}/lists'), payload=payload) - - def get_buckets(self, list_id): - return self._get_json(self._create_url(f'/lists/{str(list_id)}/buckets')) + def get_buckets(self, project_id): + return self._get_json(self._create_url(f'/projects/{str(project_id)}/buckets')) def get_labels(self): if self._cache['labels'] is None: @@ -151,8 +146,8 @@ class ApiClient: url = self._create_url(f'/tasks/{str(task_id)}') return self._get_json(url) - def put_task(self, list_id, payload): - return self._put_json(self._create_url(f'/lists/{str(list_id)}'), payload=payload) + def put_task(self, project_id, payload): + return self._put_json(self._create_url(f'/projects/{str(project_id)}'), payload=payload) def post_task(self, task_id, payload): return self._post_json(self._create_url(f'/tasks/{str(task_id)}'), payload=payload) diff --git a/vja/authenticate.py b/vja/authenticate.py index dd42a91af6fc3f1fd297a91ae58f95c9e7ab7030..d7f3b14b9b753985c881219ad6bec5aa8731bc90 100644 --- a/vja/authenticate.py +++ b/vja/authenticate.py @@ -64,7 +64,8 @@ class Login: def _post_login_request(self, username, password, totp_passcode): login_url = self._create_url('/login') - payload = {'username': username, + payload = {'long_token': True, + 'username': username, 'password': password, 'totp_passcode': totp_passcode} return requests.post(login_url, json=payload, timeout=30) diff --git a/vja/cli.py b/vja/cli.py index cd23c2b5f9db0ba1f6b55ea5f5435b67c96abb7a..44b356f0cd085c02f1d0a6d8201be3ff1dd1c842 100755 --- a/vja/cli.py +++ b/vja/cli.py @@ -9,8 +9,8 @@ from click_aliases import ClickAliasedGroup from vja.apiclient import ApiClient from vja.config import VjaConfiguration -from vja.list_service import ListService from vja.output import Output +from vja.project_service import ProjectService from vja.service_command import CommandService from vja.service_query import QueryService from vja.task_service import TaskService @@ -23,11 +23,11 @@ class Application: def __init__(self): self._configuration = VjaConfiguration() api_client = ApiClient(self._configuration.get_api_url(), self._configuration.get_token_file()) - list_service = ListService(api_client) + project_service = ProjectService(api_client) urgency_service = Urgency.from_config(self._configuration) - task_service = TaskService(list_service, urgency_service) - self._command_service = CommandService(list_service, task_service, api_client) - self._query_service = QueryService(list_service, task_service, api_client) + task_service = TaskService(project_service, urgency_service) + self._command_service = CommandService(project_service, task_service, api_client) + self._query_service = QueryService(project_service, task_service, api_client) self._output = Output() @property @@ -93,43 +93,23 @@ def user_show(application, is_json=False, is_jsonvja=False): application.query_service.find_current_user(), is_json, is_jsonvja) -# namespaces -@cli.group('namespace', help='Subcommand: namespace (see help)') -def namespace_group(): +# projects +@cli.group('project', help='Subcommand: project (see help)') +def project_group(): pass -@namespace_group.command('ls', help='Print namespaces ... (id; title; description)') -@click.option('is_json', '--json', default=False, is_flag=True, - help='Print as Vikunja json') -@click.option('is_jsonvja', '--jsonvja', default=False, is_flag=True, - help='Print as vja application json') -@click.option('custom_format', '--custom-format', - help='Format with template from .vjacli/vja.rc') -@with_application -def namespace_ls(application, is_json, is_jsonvja, custom_format): - if custom_format: - custom_format = application.configuration.get_custom_format_string(custom_format) - application.output.namespace_array( - application.query_service.find_all_namespaces(), is_json, is_jsonvja, custom_format) - - -# lists -@cli.group('list', help='Subcommand: list (see help)') -def list_group(): - pass - - -@list_group.command('add', help='Add list with title') -@click.option('namespace_id', '-n', '--namespace-id', help='Create list in namespace, default: first list found') +@project_group.command('add', help='Add project with title') +@click.option('parent_project_id', '-o', '--parent-project-id', '--parent_project_id', type=click.INT, + help='Create project as child of parent project id') @click.argument('title', nargs=-1, required=True) @with_application -def list_add(application, title, namespace_id=None): - tasklist = application.command_service.add_list(namespace_id, " ".join(title)) - click.echo(f'Created list {tasklist.id}') +def project_add(application, title, parent_project_id=None): + project = application.command_service.add_project(parent_project_id, " ".join(title)) + click.echo(f'Created project {project.id}') -@list_group.command('ls', help='Print lists ... (id; title; description; namespace; namespace_id)') +@project_group.command('ls', help='Print projects ... (id; title; description; parent_project_id)') @click.option('is_json', '--json', default=False, is_flag=True, help='Print as Vikunja json') @click.option('is_jsonvja', '--jsonvja', default=False, is_flag=True, @@ -137,23 +117,23 @@ def list_add(application, title, namespace_id=None): @click.option('custom_format', '--custom-format', help='Format with template from .vjacli/vja.rc') @with_application -def list_ls(application, is_json, is_jsonvja, custom_format): +def project_ls(application, is_json, is_jsonvja, custom_format): if custom_format: custom_format = application.configuration.get_custom_format_string(custom_format) - application.output.list_array( - application.query_service.find_all_lists(), is_json, is_jsonvja, custom_format) + application.output.project_array( + application.query_service.find_all_projects(), is_json, is_jsonvja, custom_format) -@list_group.command('show', help='Show list details') -@click.argument('list_id', required=True, type=click.INT) +@project_group.command('show', help='Show project details') +@click.argument('project_id', required=True, type=click.INT) @click.option('is_json', '--json', default=False, is_flag=True, help='Print as Vikunja json') @click.option('is_jsonvja', '--jsonvja', default=False, is_flag=True, help='Print as vja application json') @with_application -def list_show(application, list_id, is_json, is_jsonvja): - application.output.list( - application.query_service.find_list_by_id(list_id), is_json, is_jsonvja) +def project_show(application, project_id, is_json, is_jsonvja): + application.output.project( + application.query_service.find_project_by_id(project_id), is_json, is_jsonvja) # buckets @@ -162,9 +142,9 @@ def bucket_group(): pass -@bucket_group.command('ls', help='Print kanban buckets of given list ... (id; title; is_done; limit; count tasks)') -@click.option('list_id', '-l', '--list', '--list-id', '--list_id', required=True, type=click.INT, - help='Show buckets of list with id') +@bucket_group.command('ls', help='Print kanban buckets of given project ... (id; title; is_done; limit; count tasks)') +@click.option('project_id', '-o', '--project', '--project-id', '--project_id', required=True, type=click.INT, + help='Show buckets of project with id') @click.option('is_json', '--json', default=False, is_flag=True, help='Print as Vikunja json') @click.option('is_jsonvja', '--jsonvja', default=False, is_flag=True, @@ -172,11 +152,11 @@ def bucket_group(): @click.option('custom_format', '--custom-format', help='Format with template from .vjacli/vja.rc') @with_application -def bucket_ls(application, list_id, is_json, is_jsonvja, custom_format): +def bucket_ls(application, project_id, is_json, is_jsonvja, custom_format): if custom_format: custom_format = application.configuration.get_custom_format_string(custom_format) application.output.bucket_array( - application.query_service.find_all_buckets_in_list(list_id), is_json, is_jsonvja, custom_format) + application.query_service.find_all_buckets_in_project(project_id), is_json, is_jsonvja, custom_format) # labels @@ -212,8 +192,8 @@ def label_add(application, title): @cli.command('add', aliases=['create'], help='Add new task') @click.argument('title', required=True, nargs=-1) -@click.option('list_id', '-l', '--folder', '--project', '--list', - help='List (id or name), defaults to list from user settings, than to first favorite list') +@click.option('project_id', '-o', '--project', '--project-id', '--project_id', + help='Project (id or name), defaults to project from user settings, than to first favorite project') @click.option('note', '-n', '--note', '--description', help='Set description (note)') @click.option('prio', '-p', '--prio', '--priority', @@ -222,10 +202,12 @@ def label_add(application, title): help='Set due date (supports parsedatetime expressions)') @click.option('favorite', '-f', '--star', '--favorite', type=click.BOOL, help='Mark as favorite') -@click.option('tag', '-t', '--tag', '--label', +@click.option('label', '-l', '--label', help='Set label (label must exist on server)') @click.option('reminder', '-r', '--alarm', '--remind', '--reminder', is_flag=False, flag_value='due', - help='Set reminder (supports parsedatetime expressions). Leave empty to set to due date.') + help='Set reminder (supports parsedatetime and timedelta expressions). ' + 'Absolute: "in 3 days at 18:00" or relative: "1h before due_date" or just -r to set equal to ' + 'due date.') @click.option('force_create', '--force-create', '--force', is_flag=True, help='Force creation of non existing label') @with_application @@ -233,7 +215,7 @@ def label_add(application, title): def task_add(ctx, application, title, **args): args_present = {k: v for k, v in args.items() if v is not None} task = application.command_service.add_task(" ".join(title), args_present.copy()) - click.echo(f'Created task {task.id} in list {task.tasklist.id}') + click.echo(f'Created task {task.id} in project {task.project.id}') ctx.invoke(task_show, tasks=[task.id]) @@ -241,11 +223,13 @@ def task_add(ctx, application, title, **args): help='Clone task with given task_id. Set the new title') @click.argument('task_id', required=True, type=click.INT) @click.argument('title', required=True, nargs=-1) +@click.option('is_clone_bucket', '-b', '--bucket', is_flag=True, + help='Clone kanban bucket too. Default: False') @with_application @click.pass_context -def task_clone(ctx, application, task_id, title): - task = application.command_service.clone_task(task_id, " ".join(title)) - click.echo(f'Created task {task.id} in list {task.tasklist.id} as clone from {task_id}') +def task_clone(ctx, application, task_id, title, is_clone_bucket=False): + task = application.command_service.clone_task(task_id, " ".join(title), is_clone_bucket) + click.echo(f'Created task {task.id} in project {task.project.id} as clone from {task_id}') ctx.invoke(task_show, tasks=[task.id]) @@ -257,13 +241,13 @@ def task_clone(ctx, application, task_id, title): @click.option('note', '-n', '--note', '--description', help='Set description (note)') @click.option('note_append', '-a', '--note-append', '--append-note', '--description-append', '--append-description', - help='Append description to existing note separated by new line') + help='Append note to existing description separated by new line') @click.option('prio', '-p', '--prio', '--priority', type=click.INT, help='Set priority') -@click.option('list_id', '-l', '--folder-id', '--project-id', '--list-id', '--list_id', type=click.INT, - help='Move to list with id') -@click.option('position', '--list-position', '--list_position', '--position', type=click.INT, - help='Set list position') +@click.option('project_id', '-o', '--project', '--project-id', '--project_id', + help='Move to project with id') +@click.option('position', '--project-position', '--project_position', '--position', type=click.INT, + help='Set project position') @click.option('bucket_id', '--bucket-id', '--bucket_id', type=click.INT, help='Set bucket id') @click.option('kanban_position', '--kanban-position', '--kanban_position', type=click.INT, @@ -274,10 +258,12 @@ def task_clone(ctx, application, task_id, title): help='Mark as favorite') @click.option('completed', '-c', '--completed', '--done', type=click.BOOL, help='Mark as completed') -@click.option('tag', '-t', '--tag', '--label', +@click.option('label', '-l', '--label', help='Set label (label must exist on server unless called with --force-create)') @click.option('reminder', '-r', '--alarm', '--remind', '--reminder', is_flag=False, flag_value='due', - help='Set reminder (supports parsedatetime expressions). Leave empty to set to due date.') + help='Set reminder (supports parsedatetime and timedelta expressions). ' + 'Absolute: "in 3 days at 18:00" or relative: "1h30m before due_date" or just -r to set equal to ' + 'due date.') @click.option('force_create', '--force-create', '--force', is_flag=True, default=None, help='Force creation of non existing label') @with_application @@ -287,7 +273,7 @@ def task_edit(ctx, application, task_ids, **args): for task_id in task_ids: if args_present: task = application.command_service.edit_task(task_id, args_present.copy()) - click.echo(f'Modified task {task.id} in list {task.tasklist.id}') + click.echo(f'Modified task {task.id} in project {task.project.id}') ctx.invoke(task_show, tasks=[task.id]) else: application.open_browser(task_id) @@ -299,12 +285,12 @@ def task_edit(ctx, application, task_ids, **args): @click.pass_context def task_toggle(ctx, application, task_id): task = application.command_service.toggle_task_done(task_id) - click.echo(f'Modified task {task.id} in list {task.tasklist.id}') + click.echo(f'Modified task {task.id} in project {task.project.id}') ctx.invoke(task_show, tasks=[task_id]) @cli.command('defer', aliases=['delay'], help='Shortcut for moving the due_date and the reminders of the task. ' - 'Valid delay values are 2d, 1h30m.') + 'Examples for valid delay values are 2d, 1h30m.') @click.argument('task_ids', required=True, type=click.INT, nargs=-1) @click.argument('delay_by', required=True) @with_application @@ -312,12 +298,12 @@ def task_toggle(ctx, application, task_id): def task_defer(ctx, application, task_ids, delay_by): for task_id in task_ids: task = application.command_service.defer_task(task_id, delay_by) - click.echo(f'Modified task {task.id} in list {task.tasklist.id}') + click.echo(f'Modified task {task.id} in project {task.project.id}') ctx.invoke(task_show, tasks=[task_id]) @cli.command('ls', help='List tasks ... (task-id; priority; is_favorite; title; due_date; ' - 'has reminder; namespace; list; labels; urgency)') + 'has reminder; parent-project; project; labels; urgency)') @click.option('is_json', '--json', default=False, is_flag=True, help='Print as Vikunja json') @click.option('is_jsonvja', '--jsonvja', default=False, is_flag=True, @@ -340,18 +326,19 @@ def task_defer(ctx, application, task_ids, delay_by): help='General filter. Must be like e.g. --filter="priority ge 2" ' 'where in (eq, ne, gt, lt, ge, le, before, after, contains). ' 'Multiple occurrences of --filter are allowed and will be combined with logical AND.') -@click.option('label_filter', '-t', '--tag', '--label', +@click.option('label_filter', '-l', '--label', help='Filter by label (name or id)') -@click.option('list_filter', '-l', '--list', - help='Filter by list (name or id)') -@click.option('namespace_filter', '-n', '--namespace', - help='Filter by namespace (name or id)') +@click.option('project_filter', '-o', '--project', '--project-id', '--project_id', + help='Filter by project (name or id)') +@click.option('upper_project_filter', '-u', '--base-project', '--base', '--upper-project', + help='Filter by base project (name or id). ' + 'All tasks whose project has the given argument as a ancestor are considered.') @click.option('priority_filter', '-p', '--prio', '--priority', help='Filter by priority. The TEXT value must be like , ' 'Shortcut for --filter="priority "') @click.option('title_filter', '-i', '--title', help='Filter title (regex)') -@click.option('urgency_filter', '-u', '--urgency', is_flag=False, flag_value=3, type=click.INT, +@click.option('urgency_filter', '-u', '--urgent', '--urgency', is_flag=False, flag_value=3, type=click.INT, help='Filter by minimum urgency. Shortcut for --filter="urgency ge "') @with_application def task_ls(application, is_json, is_jsonvja, custom_format, include_completed, sort_string=None, **filter_args): diff --git a/vja/config.py b/vja/config.py index 1feecfeccde423279387a5f81ffe164c6e19d843..05898a242330f6e532be2dbed6fc6f31b60f083c 100755 --- a/vja/config.py +++ b/vja/config.py @@ -34,8 +34,8 @@ class VjaConfiguration: def get_custom_format_string(self, template_key): return self._parser.get('output', template_key, fallback=None) - def get_urgency_list_keywords(self): - return self._parser.get('urgency_keywords', 'list_keywords', fallback=None) + def get_urgency_project_keywords(self): + return self._parser.get('urgency_keywords', 'project_keywords', fallback=None) def get_urgency_label_keywords(self): return self._parser.get('urgency_keywords', 'label_keywords', fallback=None) diff --git a/vja/filter.py b/vja/filter.py index a65f924968f53fc7deaab130a963c5bcf897b686..8d51456caec0819ca1010ec7933fb24f851ac0c0 100644 --- a/vja/filter.py +++ b/vja/filter.py @@ -29,7 +29,7 @@ def _create_single_general_filter(value: str): field = arguments[0] operation = _operators[arguments[1]] value = arguments[2] - logger.debug("filter %s: %s %s", field, operation.__name__, value) + logger.debug("general filter %s: %s %s", field, operation.__name__, value) return lambda x: _general_filter(x, field, operation, value) @@ -63,16 +63,16 @@ def _create_label_filter(value): return lambda x: any(label.title == value for label in x.labels) -def _create_list_filter(value): +def _create_project_filter(value): if str(value).isdigit(): - return _create_general_filter([f'tasklist.id eq {value}']) - return _create_general_filter([f'tasklist.title eq {value}']) + return _create_general_filter([f'project.id eq {value}']) + return _create_general_filter([f'project.title eq {value}']) -def _create_namespace_filter(value): +def _create_upper_project_filter(value): if str(value).isdigit(): - return _create_general_filter([f'tasklist.namespace.id eq {value}']) - return _create_general_filter([f'tasklist.namespace.title eq {value}']) + return lambda x: x.id == int(value) or any(ancestor.id == int(value) for ancestor in x.ancestor_projects) + return lambda x: x.title == value or any(ancestor.title == int(value) for ancestor in x.ancestor_projects) def _create_title_filter(value): @@ -105,8 +105,8 @@ _filter_mapping = { 'favorite_filter': _create_favorite_filter, 'general_filter': _create_general_filter, 'label_filter': _create_label_filter, - 'list_filter': _create_list_filter, - 'namespace_filter': _create_namespace_filter, + 'project_filter': _create_project_filter, + 'upper_project_filter': _create_upper_project_filter, 'title_filter': _create_title_filter, 'priority_filter': _create_priority_filter, 'urgency_filter': _create_urgency_filter, diff --git a/vja/list_service.py b/vja/list_service.py deleted file mode 100644 index 8ff6cac199d4307133fb322faf6d288aefa93ee2..0000000000000000000000000000000000000000 --- a/vja/list_service.py +++ /dev/null @@ -1,57 +0,0 @@ -import logging -from typing import Optional - -from vja import VjaError -from vja.apiclient import ApiClient -from vja.model import Namespace, List, User - -logger = logging.getLogger(__name__) - - -class ListService: - def __init__(self, api_client: ApiClient): - self._api_client = api_client - self._namespace_by_id: Optional[dict] = None - self._list_by_id: Optional[dict] = None - - def find_namespace_by_id(self, namespace_id: int) -> Namespace: - if not self._namespace_by_id: - self._namespace_by_id = {x['id']: Namespace.from_json(x) for x in self._api_client.get_namespaces()} - namespace_object = self._namespace_by_id.get(namespace_id) - if not namespace_object: - logger.warning( - 'Inconsistent data: namespace_id %s is referring to non existing cached Namespace.', str(namespace_id)) - return namespace_object - - def find_list_by_id(self, list_id: int) -> List: - if not self._list_by_id: - self._list_by_id = {x['id']: self.convert_list_json(x) for x in self._api_client.get_lists()} - return self._list_by_id.get(list_id) - - def find_list_by_title(self, title): - list_objects = [self.convert_list_json(x) for x in self._api_client.get_lists()] - if not list_objects: - raise VjaError('No lists exist. Go and create at least one.') - list_found = [x for x in list_objects if x.title == title] - if not list_found: - raise VjaError(f'List with title {title} does not exist.') - return list_found[0] - - def get_default_list(self) -> List: - user = User.from_json(self._api_client.get_user()) - list_found = self.find_list_by_id(user.default_list_id) - if not list_found: - list_objects = [self.convert_list_json(x) for x in self._api_client.get_lists()] - if not list_objects: - raise VjaError('No lists exist. Go and create at least one.') - list_objects.sort(key=lambda x: x.id) - favorite_lists = [x for x in list_objects if x.is_favorite] - if favorite_lists: - list_found = favorite_lists[0] - else: - list_found = list_objects[0] - return list_found - - def convert_list_json(self, list_json: dict) -> List: - namespace = self.find_namespace_by_id(list_json['namespace_id']) - return List.from_json(list_json, namespace) diff --git a/vja/model.py b/vja/model.py index 31d51e9d0ede71bc027269766eafdad29442827a..1d27d96ae164d289a499613ebc27c458eb25c759 100755 --- a/vja/model.py +++ b/vja/model.py @@ -14,7 +14,11 @@ def custom_output(cls): if attribute.name != 'json' and getattr(self, attribute.name)) def _str_value(attribute_value): - return [_str_value(x) for x in attribute_value] if isinstance(attribute_value, list) else str(attribute_value) + if isinstance(attribute_value, datetime): + return attribute_value.isoformat() + if isinstance(attribute_value, list): + return [_str_value(x) for x in attribute_value] + return str(attribute_value) setattr(cls, '__str__', str_function) return cls @@ -25,6 +29,8 @@ def data_dict(cls): return {k: _transform_value(v) for k, v in self.__dict__.items() if k != 'json'} def _transform_value(v): + if isinstance(v, datetime): + return v.isoformat() if _is_data_dict(v): return v.data_dict() if isinstance(v, list): @@ -45,50 +51,34 @@ class User: id: int username: str name: str - default_list_id: int + default_project_id: int @classmethod def from_json(cls, json): - return cls(json, json['id'], json['username'], json['name'], json['settings']['default_list_id']) + return cls(json, json['id'], json['username'], json['name'], json['settings']['default_project_id']) @dataclass(frozen=True) @data_dict -class Namespace: - json: dict = field(repr=False) - id: int - title: str - description: str - is_archived: bool - - @classmethod - def from_json(cls, json): - return cls(json, json['id'], json['title'], json['description'], json['is_archived']) - - @classmethod - def from_json_array(cls, json_array): - return [Namespace.from_json(x) for x in json_array or []] - - -@dataclass(frozen=True) -@data_dict -class List: +class Project: json: dict = field(repr=False) id: int title: str description: str is_favorite: bool is_archived: bool - namespace: Namespace + parent_project_id: int + ancestor_projects: typing.List['Project'] @classmethod - def from_json(cls, json, namespace): + def from_json(cls, json, ancestor_projects): return cls(json, json['id'], json['title'], json['description'], json['is_archived'], json['is_favorite'], - namespace) + json['parent_project_id'], + ancestor_projects) @classmethod - def from_json_array(cls, json_array, namespace): - return [List.from_json(x, namespace) for x in json_array or []] + def from_json_array(cls, json_array, ancestor_projects): + return [Project.from_json(x, ancestor_projects) for x in json_array or []] @dataclass(frozen=True) @@ -131,6 +121,25 @@ class Label: return [Label.from_json(x) for x in json_array or []] +@dataclass +@custom_output +@data_dict +# pylint: disable=too-many-instance-attributes +class TaskReminder: + json: dict = field(repr=False) + reminder: datetime + relative_period: int + relative_to: str + + @classmethod + def from_json(cls, json): + return cls(json, parse_json_date(json['reminder']), json['relative_period'], json['relative_to']) + + @classmethod + def from_json_array(cls, json_array): + return [TaskReminder.from_json(x) for x in json_array or []] + + @dataclass @custom_output @data_dict @@ -143,7 +152,7 @@ class Task: priority: int is_favorite: bool due_date: datetime - reminder_dates: typing.List[datetime] + reminders: typing.List[TaskReminder] repeat_mode: int repeat_after: timedelta start_date: datetime @@ -152,7 +161,7 @@ class Task: done: bool done_at: datetime labels: typing.List[Label] - tasklist: List + project: Project position: int bucket_id: int kanban_position: int @@ -165,12 +174,12 @@ class Task: return ",".join(map(lambda label: label.title, self.labels or [])) @classmethod - def from_json(cls, json, list_object, labels): + def from_json(cls, json, project_object, labels): return cls(json, json['id'], json['title'], json['description'], json['priority'], json['is_favorite'], parse_json_date(json['due_date']), - [parse_json_date(reminder) for reminder in json['reminder_dates'] or []], + TaskReminder.from_json_array(json["reminders"]), json['repeat_mode'], timedelta(seconds=json['repeat_after']), parse_json_date(json['start_date']), @@ -179,7 +188,7 @@ class Task: json['done'], parse_json_date(json['done_at']), labels, - list_object, + project_object, json['position'], json['bucket_id'], json['kanban_position'], diff --git a/vja/output.py b/vja/output.py index 754cfd8d54db4841061f4ad671332d82c790f361..241b5106759d7db1d7d2ec0d80c61f62c79e8dfc 100644 --- a/vja/output.py +++ b/vja/output.py @@ -3,12 +3,10 @@ import logging import click -from vja.model import User, List, Task +from vja.model import User, Task, Project -NAMESPACE_LIST_FORMAT_DEFAULT = '{x.id:5} {x.title:20.20} {x.description:20.20}' - -LIST_LIST_FORMAT_DEFAULT = '{x.id:5} {x.title:20.20} {x.description:20.20} ' \ - '{x.namespace.title:20.20} {x.namespace.id:5}' +PROJECT_LIST_FORMAT_DEFAULT = '{x.id:5} {x.title:20.20} {x.description:20.20} ' \ + '{x.parent_project_id:5} ' BUCKET_LIST_FORMAT_DEFAULT = '{x.id:5} {x.title:20.20} {x.is_done_bucket:2} {x.limit:3} {x.count_tasks:5}' @@ -16,7 +14,7 @@ LABEL_LIST_FORMAT_DEFAULT = '{x.id:5} {x.title:20.20}' TASK_LIST_FORMAT_DEFAULT = '{x.id:5} ({x.priority}) {"*" if x.is_favorite else " "} {x.title:50.50} ' \ '{x.due_date.strftime("%a %d.%m %H:%M") if x.due_date else "":15.15} ' \ - '{"R" if x.reminder_dates else " "} {x.tasklist.title:20.20} ' \ + '{"R" if x.reminders else " "} {x.project.title:20.20} ' \ '{x.label_titles:20.20} {x.urgency:3.1f}' logger = logging.getLogger(__name__) @@ -27,18 +25,14 @@ class Output: def user(self, user: User, is_json, is_jsonvja): self._dump(user, is_json, is_jsonvja) - def list(self, task_list: List, is_json, is_jsonvja): - self._dump(task_list, is_json, is_jsonvja) + def project(self, project: Project, is_json, is_jsonvja): + self._dump(project, is_json, is_jsonvja) def task(self, task: Task, is_json, is_jsonvja): self._dump(task, is_json, is_jsonvja) - def namespace_array(self, object_array, is_json, is_jsonvja, custom_format=None): - line_format = custom_format or NAMESPACE_LIST_FORMAT_DEFAULT - self._dump_array(object_array, line_format, is_json, is_jsonvja) - - def list_array(self, object_array, is_json, is_jsonvja, custom_format=None): - line_format = custom_format or LIST_LIST_FORMAT_DEFAULT + def project_array(self, object_array, is_json, is_jsonvja, custom_format=None): + line_format = custom_format or PROJECT_LIST_FORMAT_DEFAULT self._dump_array(object_array, line_format, is_json, is_jsonvja) def bucket_array(self, object_array, is_json, is_jsonvja, custom_format=None): diff --git a/vja/parse.py b/vja/parse.py index 288156a840021897ba1b2f849dbb2c802911f79f..5694767bb8f28a2f976b14507d660780ec30e95c 100644 --- a/vja/parse.py +++ b/vja/parse.py @@ -29,10 +29,10 @@ def parse_date_arg_to_datetime(text: str) -> Optional[datetime]: def parse_date_arg_to_iso(text: str) -> str: - return format_datetime_to_json(parse_date_arg_to_datetime(text)) + return datetime_to_isoformat(parse_date_arg_to_datetime(text)) -def format_datetime_to_json(date: datetime) -> str: +def datetime_to_isoformat(date: datetime) -> str: return date.astimezone(tz.tzlocal()).isoformat() if date else None diff --git a/vja/project_service.py b/vja/project_service.py new file mode 100644 index 0000000000000000000000000000000000000000..a50ad99f6fec4412853dd440de00de11feaee062 --- /dev/null +++ b/vja/project_service.py @@ -0,0 +1,60 @@ +import logging +from typing import Optional + +from vja import VjaError +from vja.apiclient import ApiClient +from vja.model import Project, User + +logger = logging.getLogger(__name__) + + +class ProjectService: + def __init__(self, api_client: ApiClient): + self._api_client = api_client + self._project_by_id: Optional[dict] = None + + def find_project_by_id(self, project_id: int) -> Project: + if not self._project_by_id: + self._project_by_id = {x['id']: self.convert_project_json(x) for x in self._api_client.get_projects()} + return self._project_by_id.get(project_id) + + def find_project_by_title(self, title): + project_objects = [self.convert_project_json(x) for x in self._api_client.get_projects()] + if not project_objects: + raise VjaError('No projects exist. Go and create at least one.') + project_found = [x for x in project_objects if x.title == title] + if not project_found: + raise VjaError(f'Project with title {title} does not exist.') + return project_found[0] + + def get_default_project(self) -> Project: + user = User.from_json(self._api_client.get_user()) + project_found = self.find_project_by_id(user.default_project_id) + if not project_found: + project_objects = [self.convert_project_json(x) for x in self._api_client.get_projects()] + if not project_objects: + raise VjaError('No projects exist. Go and create at least one.') + project_objects.sort(key=lambda x: x.id) + favorite_projects = [x for x in project_objects if x.is_favorite] + if favorite_projects: + project_found = favorite_projects[0] + else: + project_found = project_objects[0] + return project_found + + def convert_project_json(self, project_json: dict) -> Project: + ancestor_projects = [] + # project_id = project_json['id'] + # parent_project_id = project_json['parent_project_id'] + # if parent_project_id != id find parent and add it to the ancestors and proceed with parent the same way + # ancestor = self.get_ancestor_project(project_id, parent_project_id) + # while ancestor: + # ancestor_projects.append(ancestor) + # ancestor = self.get_ancestor_project(ancestor.id, ancestor.parent_project_id) + return Project.from_json(project_json, ancestor_projects) + + def get_ancestor_project(self, project_id, parent_project_id) -> Optional[Project]: + if project_id == parent_project_id or parent_project_id == 0 or project_id == 0: + return None + ancestor = self.find_project_by_id(parent_project_id) + return ancestor diff --git a/vja/service_command.py b/vja/service_command.py index 45fbadab105e04838e1ef5269b3cf3b9a9273b5f..810099ea4d773f6b9497ffd3b581c9254c5092b4 100644 --- a/vja/service_command.py +++ b/vja/service_command.py @@ -1,18 +1,19 @@ +import datetime import logging from vja import VjaError from vja.apiclient import ApiClient -from vja.list_service import ListService from vja.model import Label -from vja.parse import parse_date_arg_to_iso, parse_json_date, parse_date_arg_to_timedelta, format_datetime_to_json +from vja.parse import parse_date_arg_to_iso, parse_json_date, parse_date_arg_to_timedelta, datetime_to_isoformat +from vja.project_service import ProjectService from vja.task_service import TaskService logger = logging.getLogger(__name__) class CommandService: - def __init__(self, list_service: ListService, task_service: TaskService, api_client: ApiClient): - self._list_service = list_service + def __init__(self, project_service: ProjectService, task_service: TaskService, api_client: ApiClient): + self._project_service = project_service self._task_service = task_service self._api_client = api_client @@ -23,13 +24,10 @@ class CommandService: self._api_client.logout() logger.info('Logged out') - # list - def add_list(self, namespace_id, title): - if not namespace_id: - namespaces = self._api_client.get_namespaces() - namespace_id = min(namespace['id'] if namespace['id'] > 0 else 99999 for namespace in namespaces) - list_json = self._api_client.put_list(namespace_id, title) - return self._list_service.convert_list_json(list_json) + # project + def add_project(self, parent_project_id, title): + project_json = self._api_client.put_project(parent_project_id, title) + return self._project_service.convert_project_json(project_json) # label def add_label(self, title): @@ -44,10 +42,10 @@ class CommandService: 'favorite': {'field': 'is_favorite', 'mapping': bool}, 'completed': {'field': 'done', 'mapping': bool}, 'position': {'field': 'position', 'mapping': int}, - 'list_id': {'field': 'list_id', 'mapping': int}, + 'project_id': {'field': 'project_id', 'mapping': int}, 'bucket_id': {'field': 'bucket_id', 'mapping': int}, 'kanban_position': {'field': 'kanban_position', 'mapping': int}, - 'reminder': {'field': 'reminder_dates', 'mapping': (lambda x: x)} + 'reminder': {'field': 'reminders', 'mapping': (lambda x: x)} } def _args_to_payload(self, args: dict): @@ -59,48 +57,54 @@ class CommandService: def add_task(self, title, args: dict): args.update({'title': title}) - if args.get('list_id'): - list_arg = args.pop('list_id') - if str(list_arg).isdigit(): - list_id = list_arg + if args.get('project_id'): + project_arg = args.pop('project_id') + if str(project_arg).isdigit(): + project_id = project_arg else: - list_id = self._list_service.find_list_by_title(list_arg).id + project_id = self._project_service.find_project_by_title(project_arg).id else: - list_id = self._list_service.get_default_list().id - tag_name = args.pop('tag') if args.get('tag') else None + project_id = self._project_service.get_default_project().id + label_name = args.pop('label') if args.get('label') else None is_force = args.pop('force_create') if args.get('force_create') is not None else False - if args.get('reminder') == 'due': - args.update({'reminder': args.get('due') or 'tomorrow'}) - if args.get('reminder'): - args.update({'reminder': [parse_date_arg_to_iso(args.get('reminder'))]}) + + self._parse_reminder_arg(args.get('reminder'), args) payload = self._args_to_payload(args) if not is_force: - self._validate_add_task(title, tag_name) + self._validate_add_task(title, label_name) logger.debug('put task: %s', payload) - task_json = self._api_client.put_task(list_id, payload) + task_json = self._api_client.put_task(project_id, payload) task = self._task_service.task_from_json(task_json) - label = self._label_from_name(tag_name, is_force) if tag_name else None + label = self._label_from_name(label_name, is_force) if label_name else None if label: self._api_client.add_label_to_task(task.id, label.id) return task - def clone_task(self, task_id: int, title): + def clone_task(self, task_id: int, title, is_clone_bucket): task_remote = self._api_client.get_task(task_id) task_remote.update({'id': None}) task_remote.update({'title': title}) + task_remote.update({'position': 0}) + task_remote.update({'kanban_position': 0}) + if is_clone_bucket: + task_remote.update({'bucket_id': 0}) + # make sure we do not send back the old reminder_dates + task_remote.pop("reminder_dates", None) logger.debug('put task: %s', task_remote) - task_json = self._api_client.put_task(task_remote['list_id'], task_remote) + task_json = self._api_client.put_task(task_remote['project_id'], task_remote) task = self._task_service.task_from_json(task_json) + for label in task_remote['labels']: + self._api_client.add_label_to_task(task.id, label['id']) return task def edit_task(self, task_id: int, args: dict): task_remote = self._api_client.get_task(task_id) - tag_name = args.pop('tag') if args.get('tag') else None + label_name = args.pop('label') if args.get('label') else None is_force = args.pop('force_create') if args.get('force_create') is not None else False self._update_reminder(args, task_remote) @@ -111,12 +115,15 @@ class CommandService: }) payload = self._args_to_payload(args) - logger.debug('post task: %s', payload) + logger.debug('update fields: %s', payload) task_remote.update(payload) + # make sure we do not send back the old reminder_dates + task_remote.pop("reminder_dates", None) + logger.debug('post task: %s', task_remote) task_json = self._api_client.post_task(task_id, task_remote) task_new = self._task_service.task_from_json(task_json) - label = self._label_from_name(tag_name, is_force) if tag_name else None + label = self._label_from_name(label_name, is_force) if label_name else None if label: if task_new.has_label(label): self._api_client.remove_label_from_task(task_new.id, label.id) @@ -126,42 +133,78 @@ class CommandService: @staticmethod def _update_reminder(args, task_remote): - if args.get('reminder') == 'due': - if args.get('due'): - args.update({'reminder': args.get('due')}) # reminder = cli argument --due - else: - if parse_json_date(task_remote['due_date']): - args.update({'reminder': task_remote['due_date']}) # reminder = due_date - else: - args.update({'reminder': 'tomorrow'}) # reminder default - if args.get('reminder') is not None: - # replace the first existing reminder - new_reminder = parse_date_arg_to_iso(args.pop('reminder')) - old_reminders = task_remote['reminder_dates'] + reminder_arg = args.get('reminder') + CommandService._parse_reminder_arg(reminder_arg, args) + + # replace the first existing reminder with our entry + new_reminder = args.pop('reminder')[0] if reminder_arg else None + if new_reminder is not None: + old_reminders = task_remote['reminders'] if old_reminders and len(old_reminders) > 0: if new_reminder: - old_reminders[0] = new_reminder # overwrite first remote reminder_date + old_reminders[0] = new_reminder # overwrite first remote reminder else: - old_reminders.pop(0) # remove first remote reminder_date + old_reminders.pop(0) # remove first remote reminder else: if new_reminder: - old_reminders = [new_reminder] # create single reminder_date + old_reminders = [new_reminder] # create single reminder args.update({'reminder': old_reminders}) + @staticmethod + def _parse_reminder_arg(reminder_arg, args): + if reminder_arg is None: + return + if reminder_arg == 'due': + args.update( + {'reminder': [{'relative_to': 'due_date', 'relative_period': 0}]}) # --reminder=due or --reminder + elif 'due' in reminder_arg: + reminder_due_args = reminder_arg.split(" ", 2) + duration = int(parse_date_arg_to_timedelta(reminder_due_args[0]).total_seconds()) + sign = -1 if reminder_due_args[1] == 'before' else 1 + args.update( + {'reminder': [{'relative_to': 'due_date', + 'relative_period': sign * duration}]}) # --reminder="1h before due_date" + elif reminder_arg == '': + args.update( + {'reminder': None}) # --reminder="" + else: + args.update( + {'reminder': [{'reminder': parse_date_arg_to_iso(reminder_arg)}]}) + def toggle_task_done(self, task_id): task_remote = self._api_client.get_task(task_id) + # make sure we do not send back the old reminder_dates + task_remote.pop("reminder_dates", None) task_remote.update({'done': not task_remote['done']}) task_json = self._api_client.post_task(task_id, task_remote) return self._task_service.task_from_json(task_json) def defer_task(self, task_id, delay_by): - task_remote = self._api_client.get_task(task_id) timedelta = parse_date_arg_to_timedelta(delay_by) + args = {} + + task_remote = self._api_client.get_task(task_id) due_date = parse_json_date(task_remote['due_date']) if due_date: - due_date = format_datetime_to_json(due_date + timedelta) - task_remote.update({'due_date': due_date}) - # TODO update absolute reminders + now = datetime.datetime.now().replace(microsecond=0) + if due_date < now: + args.update({'due': datetime_to_isoformat(now + timedelta)}) + else: + args.update({'due': datetime_to_isoformat(due_date + timedelta)}) + + old_reminders = task_remote['reminders'] + if old_reminders and len(old_reminders) > 0: + reminder_date = parse_json_date(old_reminders[0]['reminder']) + is_absolute_reminder = not old_reminders[0]['relative_to'] + if reminder_date and is_absolute_reminder: + args.update({'reminder': datetime_to_isoformat(reminder_date + timedelta)}) + self._update_reminder(args, task_remote) + + payload = self._args_to_payload(args) + logger.debug('update fields: %s', payload) + task_remote.update(payload) + # make sure we do not send back the old reminder_dates + task_remote.pop("reminder_dates", None) task_json = self._api_client.post_task(task_id, task_remote) return self._task_service.task_from_json(task_json) @@ -177,12 +220,12 @@ class CommandService: return None return label_found[0] - def _validate_add_task(self, title, tag_name): + def _validate_add_task(self, title, label_name): tasks_remote = self._api_client.get_tasks(exclude_completed=True) if any(task for task in tasks_remote if task['title'] == title): raise VjaError("Task with title does exist. You may want to run with --force-create.") - if tag_name: + if label_name: labels_remote = Label.from_json_array(self._api_client.get_labels()) - if not any(label for label in labels_remote if label.title == tag_name): + if not any(label for label in labels_remote if label.title == label_name): raise VjaError( "Label does not exist. You may want to execute \"label add\" or run with --force-create.") diff --git a/vja/service_query.py b/vja/service_query.py index 1a26b36bf07e5862a3e2e6ad1f01a8f51bd2d18e..ac19243df0b886a4ef62c02dc66f35b1580a2621 100644 --- a/vja/service_query.py +++ b/vja/service_query.py @@ -2,18 +2,18 @@ import logging from vja.apiclient import ApiClient from vja.filter import create_filters -from vja.list_service import ListService -from vja.model import Namespace, Label, User, Bucket +from vja.model import Label, User, Bucket from vja.parse import rgetattr +from vja.project_service import ProjectService from vja.task_service import TaskService logger = logging.getLogger(__name__) -DEFAULT_SORT_STRING = 'done, -urgency, due_date, -priority, tasklist.title, title' +DEFAULT_SORT_STRING = 'done, -urgency, due_date, -priority, project.title, title' class QueryService: - def __init__(self, list_service: ListService, task_service: TaskService, api_client: ApiClient): - self._list_service = list_service + def __init__(self, project_service: ProjectService, task_service: TaskService, api_client: ApiClient): + self._project_service = project_service self._task_service = task_service self._api_client = api_client @@ -21,20 +21,17 @@ class QueryService: def find_current_user(self): return User.from_json(self._api_client.get_user()) - # namespace - def find_all_namespaces(self): - return Namespace.from_json_array(self._api_client.get_namespaces()) + # project + def find_all_projects(self): + return [self._project_service.convert_project_json(project_json) for project_json in + (self._api_client.get_projects())] - # list - def find_all_lists(self): - return [self._list_service.convert_list_json(list_json) for list_json in (self._api_client.get_lists())] - - def find_list_by_id(self, list_id): - return self._list_service.convert_list_json(self._api_client.get_list(list_id)) + def find_project_by_id(self, project_id): + return self._project_service.convert_project_json(self._api_client.get_project(project_id)) # bucket - def find_all_buckets_in_list(self, list_id): - return Bucket.from_json_array(self._api_client.get_buckets(list_id)) + def find_all_buckets_in_project(self, project_id): + return Bucket.from_json_array(self._api_client.get_buckets(project_id)) # label def find_all_labels(self): @@ -71,7 +68,7 @@ class QueryService: def sortable_task_value(task, field): field_name = field - if field in ('label', 'labels', 'tag', 'tags'): + if field in ('label', 'labels'): field_name = 'label_titles' field_value = rgetattr(task, field_name) if isinstance(field_value, str): diff --git a/vja/task_service.py b/vja/task_service.py index a006abdfa950f3e4ac223d171d758037c2d9e695..55862140e02f2b5eb1fbdfe7039501275776e36a 100644 --- a/vja/task_service.py +++ b/vja/task_service.py @@ -1,20 +1,20 @@ import logging -from vja.list_service import ListService from vja.model import Task, Label +from vja.project_service import ProjectService from vja.urgency import Urgency logger = logging.getLogger(__name__) class TaskService: - def __init__(self, list_service: ListService, urgency: Urgency): - self._list_service = list_service + def __init__(self, project_service: ProjectService, urgency: Urgency): + self._project_service = project_service self._urgency = urgency def task_from_json(self, task_json: dict) -> Task: - list_object = self._list_service.find_list_by_id(task_json['list_id']) + project_object = self._project_service.find_project_by_id(task_json['project_id']) labels = Label.from_json_array(task_json['labels']) - task = Task.from_json(task_json, list_object, labels) + task = Task.from_json(task_json, project_object, labels) task.urgency = self._urgency.compute_for(task) return task diff --git a/vja/urgency.py b/vja/urgency.py index a568fd6c49f0f410141676d958a7eadb370d76de..897ea7e48dd09e72efcb158cca3c07727160e60d 100644 --- a/vja/urgency.py +++ b/vja/urgency.py @@ -6,9 +6,9 @@ from vja.model import Task class Urgency: - def __init__(self, urgency_coefficients: dict, list_keywords, label_keywords): + def __init__(self, urgency_coefficients: dict, project_keywords, label_keywords): self._urgency_coefficients = urgency_coefficients - self._list_keywords = list_keywords + self._project_keywords = project_keywords self._label_keywords = label_keywords def compute_for(self, task: Task): @@ -17,20 +17,20 @@ class Urgency: due_date_score = self._get_due_date_score(task) * self._urgency_coefficients.get('due_date_weight', 1.0) priority_score = task.priority * self._urgency_coefficients.get('priority_weight', 1.0) favorite_score = int(task.is_favorite) * self._urgency_coefficients.get('favorite_weight', 1.0) - list_name_score = self._get_list_score(task) * self._urgency_coefficients.get('list_keyword', 1.0) + project_name_score = self._get_project_score(task) * self._urgency_coefficients.get('project_keyword', 1.0) lable_name_score = self._get_label_score(task) * self._urgency_coefficients.get('label_keyword', 1.0) - return 1 + due_date_score + priority_score + favorite_score + list_name_score + lable_name_score + return 1 + due_date_score + priority_score + favorite_score + project_name_score + lable_name_score def _get_label_score(self, task): task_label_title = task.label_titles.lower() return int(any(label_name.lower() in task_label_title for label_name in self._label_keywords)) if self._label_keywords else 0 - def _get_list_score(self, task): - task_list_title = task.tasklist.title.lower() - return int(any(list_name.lower() in task_list_title for list_name in - self._list_keywords)) if self._list_keywords else 0 + def _get_project_score(self, task): + task_project_title = task.project.title.lower() + return int(any(project_name.lower() in task_project_title for project_name in + self._project_keywords)) if self._project_keywords else 0 @staticmethod def _get_due_date_score(task: Task): @@ -58,10 +58,10 @@ class Urgency: @classmethod def from_config(cls, config: VjaConfiguration): - config_list_keywords = config.get_urgency_list_keywords() - list_keywords = [x.strip() for x in config_list_keywords.split(',')] if config_list_keywords else [] + config_project_keywords = config.get_urgency_project_keywords() + project_keywords = [x.strip() for x in config_project_keywords.split(',')] if config_project_keywords else [] config_label_keywords = config.get_urgency_label_keywords() label_keywords = [x.strip() for x in config_label_keywords.split(',')] if config_label_keywords else [] urgency_coefficients = {k: float(v) for k, v in config.get_urgency_coefficients().items()} - return cls(urgency_coefficients, list_keywords, label_keywords) + return cls(urgency_coefficients, project_keywords, label_keywords)