diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5f129e7b841ae2f9da7b00e1cbf25b9ae7947bd0..842073a37186a622a8e3cedf391ae4416a91b101 100755 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,46 +1,31 @@ -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 && $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,31 +33,32 @@ 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 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 + - sleep 1s + - export VJA_CONFIGDIR=tests/.vjatest_dind - tests/run.sh 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 && $CI_COMMIT_REF_SLUG !~ /renovate.*/ + when: manual - when: on_success release: @@ -88,7 +74,6 @@ release: script: - python --version - semantic-release - cache: { } rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: on_success diff --git a/.releaserc.yml b/.releaserc.yml index 9f59c412bc318bfe11d44cf386fccb0bae6a34f7..6d05e0a3a55f5e4ed60294c9ea5599b8e846bcc0 100644 --- a/.releaserc.yml +++ b/.releaserc.yml @@ -1,6 +1,6 @@ # spec: https://semantic-release.gitbook.io/semantic-release/usage/configuration tagFormat: "${version}" -branches: [main, next] +branches: [ main, next ] plugins: # Determine the type of release by analyzing commits. # ie: Major, Minor or Patch @@ -14,11 +14,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 +28,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..ce3994a7d131ab2a69775f2720bcc0905eaf07b9 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.tasklist.title:20.20} {x.label_titles:20.20} {x.urgency:3} tasklist_short={x.id:5} {x.title:50.50} ids_only={x.id} diff --git a/Features.md b/Features.md index 18e56d55f7146582cc941b96c1f4d201f10cebfc..6e559c42bc515ac9b61c9c6a9f462ca26de0ba49 100644 --- a/Features.md +++ b/Features.md @@ -1,12 +1,36 @@ # 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 lists, namespaces, labels, buckets](#manage-lists-namespaces-labels-buckets) + * [Manage namespaces](#manage-namespaces) + * [Manage lists (projects)](#manage-lists-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 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 --tag=@work ``` or more concise @@ -22,18 +46,21 @@ 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 ``` See + ```shell vja clone --help ``` - ## List tasks List all active tasks @@ -44,7 +71,12 @@ 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 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. ### Filter @@ -123,21 +155,62 @@ Toggle tag (=label). Use with --force to create new label: ```shell vja edit 1 -t @work ``` + Mark as done + ```shell vja edit 1 --done="true" vja check 1 # Shortcut to toggle the done flag of task 1 ``` ### 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 -r "next sunday at 11:00" +vja edit 1 --reminder="in 3 days at 11:00" +``` + +Set reminder equal to due date + +```shell +vja edit 1 -r +vja edit 1 --reminder +``` + +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 ```shell vja edit 1 5 8 --due="next monday 14:00" 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 af94e17c3492bcdd17b49cb89ec9c6918081c2f6..f1fef9865be2ef2065a8b155fbc59623f02449a3 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,3 +4,4 @@ twine==4.0.2 pylint==2.17.1 flake8==6.0.0 pytest==7.2.2 +coverage==7.2.2 diff --git a/tests/.vjatest/vja.rc b/tests/.vjatest/vja.rc index 4e35eae1cf9e1290179c19280e2287752646704b..5aa2fc18fff9c285380b3f1d8ba15df3c2720558 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.tasklist.title:20.20} {x.label_titles:20.20} {x.urgency:3} tasklist_short={x.id:5} {x.title:50.50} ids_only={x.id} diff --git a/tests/.vjatest_dind/vja.rc b/tests/.vjatest_dind/vja.rc index 61a36fdbf16bc37055011baed9462039890522a3..428f7dcb09c6fe27b60a4f495175c380b2a7875d 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.tasklist.title:20.20} {x.label_titles:20.20} {x.urgency:3} tasklist_short={x.id:5} {x.title:50.50} ids_only={x.id} diff --git a/tests/conftest.py b/tests/conftest.py index 12f5b7be476fc4869985e8786f02f607ed21e3a0..e42439990f345a3137e69417d192d4438ebbb5e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import logging import os import subprocess import sys @@ -7,51 +8,45 @@ 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 - - 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 + run_vja('vja logout') + run_vja('vja --username=test --password=test user show') 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) + run_vja('vja list add test-list') + run_vja('vja add At least one task --force-create --priority=5 --due-date=today --tag=my_tag --favorite=True') + run_vja('vja add A task without a label --force-create') + + +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 +54,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_list_and_task() diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index d8bf998f190d09925b4cd1b04aff0f88d00cda13..544e009665378428ceb1aa2e9140a550c633953c 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: api: - image: vikunja/api:0.20.4 + image: vikunja/api:unstable ports: - "3456:3456" environment: @@ -14,7 +14,7 @@ services: exec /app/vikunja/vikunja " frontend: - image: vikunja/frontend:0.20.5 + image: vikunja/frontend:unstable ports: - "8080:80" environment: diff --git a/tests/test_command.py b/tests/test_command.py index 50b3a5a05a877af16b17ec6e1d538d9509b0772f..0065b1118d06fd5e8fa5af1f0f24591b65850f77 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -6,10 +6,12 @@ from tests.conftest import invoke from vja.cli import cli ADD_SUCCESS_PATTERN = re.compile(r'.*Created task (\d+) in list .*') +DATE_1 = datetime.datetime(2023, 3, 30, 15, 0, 0, 0) +DATE_2 = DATE_1 + datetime.timedelta(days=1) +DATE_1_ISO = DATE_1.isoformat() +DATE_2_ISO = DATE_2.isoformat() TODAY = datetime.datetime.now() -TOMORROW = (datetime.datetime.now() + datetime.timedelta(days=1)) TODAY_ISO = TODAY.isoformat() -TOMORROW_ISO = TOMORROW.isoformat() class TestAddTask: @@ -24,24 +26,18 @@ class TestAddTask: assert after['tasklist']['title'] == 'test-list' 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') 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 --list=test-list --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: @@ -68,7 +64,7 @@ class TestEditGeneral: 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['created'] == before['created'] @@ -79,7 +75,7 @@ class TestEditGeneral: invoke(runner, 'edit 1 --due=today') after = json_for_task_id(runner, 1) - assert datetime.date.today().isoformat()[0:10] in after['due_date'] + assert after['due_date'][:10] == TODAY_ISO[:10] assert after['updated'] >= before['updated'] def test_unset_due_date(self, runner): @@ -129,61 +125,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,14 +199,24 @@ 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'] == DATE_2_ISO + assert after['reminders'][0]['reminder'] == DATE_2_ISO + + def test_dont_modify_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'][:10] == TOMORROW.date().isoformat() - assert after['reminder_dates'][0][:10] == TOMORROW.date().isoformat() + assert after['due_date'] == DATE_2_ISO + assert after['reminders'][0]['relative_period'] == 0 + assert after['reminders'][0]['relative_to'] == 'due_date' class TestMultipleTasks: diff --git a/vja/apiclient.py b/vja/apiclient.py index c40e4e849c6a95cce43316cdb854be64c2ddd9db..132ff825f2ec4e7dd1d3886b99ee5a6574085bc9 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, 'namespaces': None, 'tasks': None} self._login = Login(api_url, token_file) def _create_url(self, path): @@ -117,19 +117,19 @@ class ApiClient: return self._cache['namespaces'] 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'] + if self._cache['projects'] is None: + self._cache['projects'] = self._get_json(self._create_url('/projects')) or [] + return self._cache['projects'] def get_list(self, list_id): - return self._get_json(self._create_url(f'/lists/{str(list_id)}')) + return self._get_json(self._create_url(f'/projects/{str(list_id)}')) 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) + return self._put_json(self._create_url(f'/namespaces/{str(namespace_id)}/projects'), payload=payload) def get_buckets(self, list_id): - return self._get_json(self._create_url(f'/lists/{str(list_id)}/buckets')) + return self._get_json(self._create_url(f'/projects/{str(list_id)}/buckets')) def get_labels(self): if self._cache['labels'] is None: @@ -152,7 +152,7 @@ class ApiClient: 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) + return self._put_json(self._create_url(f'/projects/{str(list_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/cli.py b/vja/cli.py index cd23c2b5f9db0ba1f6b55ea5f5435b67c96abb7a..0f1789ac436c1b26f3b6a3068120f05706805a3b 100755 --- a/vja/cli.py +++ b/vja/cli.py @@ -225,7 +225,9 @@ def label_add(application, title): @click.option('tag', '-t', '--tag', '--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 @@ -257,7 +259,7 @@ 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, @@ -277,7 +279,9 @@ def task_clone(ctx, application, task_id, title): @click.option('tag', '-t', '--tag', '--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 @@ -304,7 +308,7 @@ def task_toggle(ctx, application, 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 diff --git a/vja/model.py b/vja/model.py index 31d51e9d0ede71bc027269766eafdad29442827a..0a8dcc3e4a9077064996b3415b6474ecf97f9b19 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): @@ -49,7 +55,7 @@ class User: @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) @@ -131,6 +137,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 +168,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 @@ -170,7 +195,7 @@ class Task: 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']), diff --git a/vja/output.py b/vja/output.py index 754cfd8d54db4841061f4ad671332d82c790f361..485abad24619ac54fdce9a4826e080df89f727bf 100644 --- a/vja/output.py +++ b/vja/output.py @@ -16,7 +16,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.tasklist.title:20.20} ' \ '{x.label_titles:20.20} {x.urgency:3.1f}' logger = logging.getLogger(__name__) 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/service_command.py b/vja/service_command.py index 45fbadab105e04838e1ef5269b3cf3b9a9273b5f..5514933ccbc2207a98ad3f9da3d4d30efbf96a39 100644 --- a/vja/service_command.py +++ b/vja/service_command.py @@ -4,7 +4,7 @@ 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.task_service import TaskService logger = logging.getLogger(__name__) @@ -44,10 +44,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}, + 'list_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): @@ -69,10 +69,8 @@ class CommandService: list_id = self._list_service.get_default_list().id tag_name = args.pop('tag') if args.get('tag') 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) @@ -92,8 +90,10 @@ class CommandService: task_remote.update({'id': None}) task_remote.update({'title': title}) + # 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) return task @@ -111,8 +111,11 @@ 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) @@ -126,42 +129,73 @@ 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 + 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) diff --git a/vja/task_service.py b/vja/task_service.py index a006abdfa950f3e4ac223d171d758037c2d9e695..6e127ba6b302a57b3f934d14193250f851489bf5 100644 --- a/vja/task_service.py +++ b/vja/task_service.py @@ -13,7 +13,7 @@ class TaskService: 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']) + list_object = self._list_service.find_list_by_id(task_json['project_id']) labels = Label.from_json_array(task_json['labels']) task = Task.from_json(task_json, list_object, labels) task.urgency = self._urgency.compute_for(task)