├── tests ├── __init__.py ├── test-data │ └── .gitkeep ├── test_config_parser.py ├── test_utils.py ├── test_main.py ├── test_schedule.py ├── test_scheduled_task.py ├── test_screen.py └── conftest.py ├── tests-old ├── __init__.py ├── test_data │ └── .gitkeep ├── context.py ├── test_missing_data.py ├── test_main.py ├── test_cli.py ├── test_screen.py └── test_schedule.py ├── taskschedule ├── __init__.py ├── config_parser.py ├── utils.py ├── hooks.py ├── taskwarrior.py ├── notifier.py ├── scheduled_task.py ├── schedule.py ├── main.py └── screen.py ├── .coveragerc ├── .flake8 ├── hooks └── drip.wav ├── Makefile ├── img └── screenshot.png ├── requirements.txt ├── mypy.ini ├── scripts └── taskschedule ├── .gitignore ├── .travis.yml ├── __main__.py ├── LICENSE ├── setup.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests-old/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /taskschedule/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests-old/test_data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test-data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = taskschedule 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | -------------------------------------------------------------------------------- /hooks/drip.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nnist/taskschedule/HEAD/hooks/drip.wav -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | pytest tests 3 | 4 | coverage: 5 | pytest --cov=taskschedule tests 6 | -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nnist/taskschedule/HEAD/img/screenshot.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tasklib==1.2.0 2 | isodate==0.6.0 3 | mypy==0.720 4 | cached-property==1.5.1 5 | pytest-cov==2.8.1 6 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.7 3 | warn_unused_configs = True 4 | mypy_path = taskschedule 5 | check_untyped_defs = True 6 | ignore_missing_imports = True 7 | -------------------------------------------------------------------------------- /tests-old/context.py: -------------------------------------------------------------------------------- 1 | import taskschedule 2 | import os 3 | import sys 4 | 5 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 6 | -------------------------------------------------------------------------------- /scripts/taskschedule: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | import taskschedule.main 6 | 7 | if __name__ == "__main__": 8 | taskschedule.main.main(sys.argv[1:]) 9 | -------------------------------------------------------------------------------- /tests/test_config_parser.py: -------------------------------------------------------------------------------- 1 | from taskschedule.config_parser import ConfigParser 2 | 3 | 4 | class TestConfigParser: 5 | def test_config_parser(self): 6 | parser = ConfigParser() 7 | assert parser.config() 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | lib/ 3 | lib64 4 | pyvenv.cfg 5 | *.pyc 6 | build/ 7 | dist/ 8 | *.egg-info 9 | .coverage 10 | htmlcov/ 11 | tests/test_data/.task/ 12 | tests/test_data/.taskrc 13 | .vimrc 14 | .mypy_cache/ 15 | .python-version 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - "3.7" 5 | before_install: 6 | - sudo apt-get install -y taskwarrior 7 | install: 8 | - pip install -r requirements.txt 9 | - pip install pytest 10 | - pip install codecov 11 | script: 12 | - coverage run -m pytest tests 13 | after_success: 14 | - codecov 15 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from taskschedule.utils import calculate_datetime 4 | 5 | 6 | def test_calculate_datetime(): 7 | assert calculate_datetime("2000-01-01").year == 2000 8 | assert ( 9 | calculate_datetime("today+5days").day 10 | == (datetime.today() + timedelta(days=5)).day 11 | ) 12 | -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | 6 | from taskschedule.main import Main 7 | 8 | if __name__ == "__main__": 9 | try: 10 | main = Main(sys.argv[1:]) 11 | main.main() 12 | except KeyboardInterrupt: 13 | print("Interrupted by user.") 14 | try: 15 | sys.exit(0) 16 | except SystemExit: 17 | os._exit(0) # pylint: disable=protected-access 18 | -------------------------------------------------------------------------------- /taskschedule/config_parser.py: -------------------------------------------------------------------------------- 1 | DEFAULTS: dict = { 2 | "timebox": { 3 | "time": 25, 4 | "pending_glyph": "◻", 5 | "done_glyph": "◼", 6 | "underestimated_glyph": "◆", 7 | "progress_pending_glyph": "▰", 8 | "progress_done_glyph": "▰", 9 | } 10 | } 11 | 12 | 13 | class ConfigParser: 14 | def config(self) -> dict: 15 | """Returns a default configuration.""" 16 | cfg: dict = DEFAULTS 17 | return cfg 18 | -------------------------------------------------------------------------------- /taskschedule/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from taskschedule.scheduled_task import ScheduledTask 4 | from taskschedule.taskwarrior import PatchedTaskWarrior 5 | 6 | 7 | def calculate_datetime(date_str: str) -> datetime: 8 | """Leverage the `task calc` command to convert a date-like string 9 | to a datetime object.""" 10 | 11 | tw = PatchedTaskWarrior() 12 | task = ScheduledTask(tw, description="dummy") 13 | task["due"] = date_str 14 | return task["due"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nicole Nisters 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /taskschedule/hooks.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import subprocess 4 | 5 | 6 | def run_hooks(hook_type, data={"id": -1, "description": "none"}): 7 | """Run hook scripts in the hooks directory. 8 | 9 | :param hook_type: the hook type to run. 10 | valid values: 'on-progress' 11 | :param data: the JSON data to pass as a string to stdin.""" 12 | 13 | home = os.path.expanduser("~") 14 | 15 | hooks_directory = home + "/.taskschedule/hooks" 16 | onlyfiles = [ 17 | f 18 | for f in os.listdir(hooks_directory) 19 | if os.path.isfile(os.path.join(hooks_directory, f)) 20 | ] 21 | 22 | for filename in onlyfiles: 23 | if hook_type == "on-progress" and filename.startswith("on-progress-"): 24 | input_data = json.dumps(data, ensure_ascii=False).encode("utf8") 25 | result = subprocess.run( 26 | [home + "/.taskschedule/hooks/" + filename], 27 | shell=True, 28 | stdout=subprocess.PIPE, 29 | stderr=subprocess.PIPE, 30 | input=input_data, 31 | ) 32 | # print(result.stdout.decode('utf-8')) 33 | -------------------------------------------------------------------------------- /taskschedule/taskwarrior.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tasklib import TaskWarrior 4 | from tasklib.backends import TaskWarriorException 5 | 6 | from taskschedule.scheduled_task import ScheduledTask, ScheduledTaskQuerySet 7 | 8 | 9 | class PatchedTaskWarrior(TaskWarrior): 10 | """A patched version of TaskWarrior which returns a custom queryset with a custom 11 | Task class to provide extra functionality.""" 12 | 13 | def __init__(self, *args, **kwargs): 14 | super(PatchedTaskWarrior, self).__init__(*args, **kwargs) 15 | self.tasks = ScheduledTaskQuerySet(self) 16 | 17 | def filter_tasks(self, filter_obj): 18 | self.enforce_recurrence() 19 | args = ["export"] + filter_obj.get_filter_params() 20 | tasks = [] 21 | for line in self.execute_command(args): 22 | if line: 23 | data = line.strip(",") 24 | try: 25 | filtered_task = ScheduledTask(self) 26 | filtered_task._load_data(json.loads(data)) 27 | tasks.append(filtered_task) 28 | except ValueError: 29 | raise TaskWarriorException("Invalid JSON: %s" % data) 30 | return tasks 31 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from taskschedule.main import Main 6 | from taskschedule.utils import calculate_datetime 7 | 8 | if TYPE_CHECKING: 9 | from datetime import datetime 10 | 11 | 12 | class TestMain: 13 | def test_main_init_creates_backend_and_schedule(self, tw): 14 | main = Main(["-t", "tests/test-data/.taskrc", "--no-notifications"]) 15 | 16 | backend = main.backend 17 | assert backend.taskrc_location == "tests/test-data/.taskrc" 18 | 19 | schedule = main.schedule 20 | assert schedule.backend is backend 21 | 22 | def test_main_command_args(self, tw): 23 | main = Main(["-t", "tests/test-data/.taskrc", "--no-notifications"]) 24 | backend = main.backend 25 | task_command = backend.task_command 26 | assert "task" in task_command 27 | assert "status.not:deleted" in task_command 28 | 29 | scheduled_after: datetime = calculate_datetime("today-1s") 30 | scheduled_before: datetime = calculate_datetime("tomorrow") 31 | assert f"scheduled.after:{scheduled_after}" in task_command 32 | assert f"scheduled.before:{scheduled_before}" in task_command 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup( 6 | name="taskschedule", 7 | version="0.1.0", 8 | license="MIT", 9 | author="Nicole Nisters", 10 | author_email="n.nisters@pm.me", 11 | description="A taskwarrior extension to display scheduled tasks.", 12 | long_description="A taskwarrior extension to display scheduled tasks." 13 | "It uses curses to show a table with scheduled tasks. " 14 | "The table is refreshed automatically.", 15 | url="https://github.com/nnist/taskschedule", 16 | classifiers=[ 17 | "Environment :: Console :: Curses", 18 | "Development Status :: 2 - Pre-Alpha", 19 | "Intended Audience :: End Users/Desktop", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: MacOS", 22 | "Operating System :: POSIX :: BSD", 23 | "Operating System :: POSIX :: Linux", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3.5", 26 | "Programming Language :: Python :: 3.6", 27 | "Programming Language :: Python :: 3.7", 28 | "Topic :: System :: Shells", 29 | "Topic :: Terminals", 30 | ], 31 | python_requires=">=3.5", 32 | packages=["taskschedule", "tests"], 33 | scripts=["scripts/taskschedule"], 34 | include_package_data=True, 35 | install_requires=["tasklib", "isodate"], 36 | extras_require={"dev": ["black", "flake8"]}, 37 | ) 38 | -------------------------------------------------------------------------------- /tests-old/test_missing_data.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,no-self-use 2 | 3 | import unittest 4 | import os 5 | import shutil 6 | 7 | from taskschedule.schedule import ( 8 | Schedule, 9 | UDADoesNotExistError, 10 | TaskrcDoesNotExistError, 11 | TaskDirDoesNotExistError, 12 | ) 13 | 14 | 15 | class MissingDataTest(unittest.TestCase): 16 | def setUp(self): 17 | self.taskrc_path = "tests/test_data/.taskrc" 18 | self.task_dir_path = "tests/test_data/.task" 19 | self.assertEqual(os.path.isdir(self.taskrc_path), False) 20 | self.assertEqual(os.path.isdir(self.task_dir_path), False) 21 | 22 | def tearDown(self): 23 | try: 24 | os.remove(self.taskrc_path) 25 | except FileNotFoundError: 26 | pass 27 | 28 | try: 29 | shutil.rmtree(self.task_dir_path) 30 | except FileNotFoundError: 31 | pass 32 | 33 | def create_schedule(self): 34 | schedule = Schedule( 35 | tw_data_dir=self.task_dir_path, taskrc_location=self.taskrc_path 36 | ) 37 | schedule.load_tasks() 38 | 39 | def test_no_uda_estimate_type_raises_exception(self): 40 | with open(self.taskrc_path, "w+") as file: 41 | file.write("uda.estimate.label=Est\n") 42 | os.makedirs(self.task_dir_path) 43 | self.assertRaises(UDADoesNotExistError, self.create_schedule) 44 | 45 | def test_no_uda_estimate_label_raises_exception(self): 46 | with open(self.taskrc_path, "w+") as file: 47 | file.write("uda.estimate.type=duration\n") 48 | os.makedirs(self.task_dir_path) 49 | self.assertRaises(UDADoesNotExistError, self.create_schedule) 50 | 51 | def test_no_task_dir_raises_exception(self): 52 | with open(self.taskrc_path, "w+") as file: 53 | file.write("# User Defined Attributes\n") 54 | file.write("uda.estimate.type=duration\n") 55 | file.write("uda.estimate.label=Est\n") 56 | self.assertRaises(TaskDirDoesNotExistError, self.create_schedule) 57 | os.remove(self.taskrc_path) 58 | 59 | def test_no_taskrc_raises_exception(self): 60 | os.makedirs(self.task_dir_path) 61 | self.assertRaises(TaskrcDoesNotExistError, self.create_schedule) 62 | 63 | try: 64 | shutil.rmtree(self.task_dir_path) 65 | except FileNotFoundError: 66 | pass 67 | 68 | 69 | if __name__ == "__main__": 70 | unittest.main() 71 | -------------------------------------------------------------------------------- /tests/test_schedule.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from taskschedule.utils import calculate_datetime 8 | 9 | if TYPE_CHECKING: 10 | from taskschedule.schedule import Schedule 11 | 12 | 13 | class TestSchedule: 14 | def test_get_tasks_returns_correct_tasks(self, schedule: Schedule): 15 | tasks = schedule.tasks 16 | assert len(tasks) == 7 17 | assert tasks[0]["description"] == "test_last_week" 18 | assert tasks[1]["description"] == "test_yesterday" 19 | assert tasks[2]["description"] == "test_9:00_to_10:11" 20 | assert tasks[3]["description"] == "test_14:00_to_16:00" 21 | assert tasks[4]["description"] == "test_16:10_to_16:34" 22 | assert tasks[5]["description"] == "test_tomorrow" 23 | assert tasks[6]["description"] == "test_next_week" 24 | 25 | def test_clear_cache(self, schedule: Schedule): 26 | tasks = schedule.tasks 27 | assert tasks 28 | schedule.clear_cache() 29 | with pytest.raises(KeyError): 30 | schedule.__dict__["tasks"] 31 | 32 | tasks = schedule.tasks 33 | assert tasks 34 | 35 | def test_get_time_slots_returns_correct_amount_of_days(self, schedule: Schedule): 36 | time_slots = schedule.get_time_slots() 37 | assert len(time_slots) == 7 38 | 39 | def test_get_time_slots_has_correct_tasks(self, schedule: Schedule): 40 | time_slots = schedule.get_time_slots() 41 | 42 | yesterday = calculate_datetime("yesterday").date().isoformat() 43 | today = calculate_datetime("today").date().isoformat() 44 | tomorrow = calculate_datetime("tomorrow").date().isoformat() 45 | 46 | assert time_slots[yesterday]["00"][0]["description"] == "test_yesterday" 47 | assert time_slots[today]["09"][0]["description"] == "test_9:00_to_10:11" 48 | assert time_slots[today]["14"][0]["description"] == "test_14:00_to_16:00" 49 | assert time_slots[today]["16"][0]["description"] == "test_16:10_to_16:34" 50 | assert time_slots[tomorrow]["00"][0]["description"] == "test_tomorrow" 51 | 52 | def test_get_max_length(self, schedule: Schedule): 53 | length = schedule.get_max_length("description") 54 | assert length == 19 55 | 56 | def test_get_column_offsets(self, schedule: Schedule): 57 | offsets = schedule.get_column_offsets() 58 | assert offsets == [0, 5, 7, 19, 29, 37] 59 | 60 | def test_get_next_task_returns_next_task(self, schedule: Schedule): 61 | next_task = schedule.get_next_task(schedule.tasks[2]) 62 | if next_task: 63 | assert next_task["description"] == "test_14:00_to_16:00" 64 | else: 65 | pytest.fail("A next task was expected but not returned.") 66 | 67 | def test_get_next_task_for_last_task_returns_none(self, schedule: Schedule): 68 | next_task = schedule.get_next_task(schedule.tasks[6]) 69 | assert not next_task 70 | -------------------------------------------------------------------------------- /tests-old/test_main.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,no-self-use 2 | 3 | import unittest 4 | import os 5 | import shutil 6 | 7 | from taskschedule.main import main 8 | from taskschedule.schedule import TaskDirDoesNotExistError 9 | 10 | 11 | class CLITest(unittest.TestCase): 12 | def setUp(self): 13 | self.taskrc_path = "tests/test_data/.taskrc" 14 | self.task_dir_path = "tests/test_data/.task" 15 | 16 | def create_test_files(self, taskrc=True, taskdir=True): 17 | if taskdir: 18 | self.assertEqual(os.path.isdir(self.task_dir_path), False) 19 | # Create a sample empty .task directory 20 | os.makedirs(self.task_dir_path) 21 | self.assertEqual(os.path.isdir(self.task_dir_path), True) 22 | 23 | if taskrc: 24 | self.assertEqual(os.path.isfile(self.taskrc_path), False) 25 | # Create a sample .taskrc 26 | with open(self.taskrc_path, "w+") as file: 27 | file.write("# User Defined Attributes\n") 28 | file.write("uda.estimate.type=duration\n") 29 | file.write("uda.estimate.label=Est\n") 30 | 31 | self.assertEqual(os.path.isfile(self.taskrc_path), True) 32 | 33 | def tearDown(self): 34 | if os.path.isfile(self.taskrc_path): 35 | os.remove(self.taskrc_path) 36 | 37 | if os.path.isdir(self.task_dir_path): 38 | shutil.rmtree(self.task_dir_path) 39 | 40 | def test_main(self): 41 | self.create_test_files() 42 | main( 43 | [ 44 | "-r", 45 | "-1", 46 | "--data-location", 47 | self.task_dir_path, 48 | "--taskrc-location", 49 | self.taskrc_path, 50 | ] 51 | ) 52 | 53 | def test_main_no_task_dir_exits_with_1(self): 54 | self.create_test_files(taskdir=False) 55 | try: 56 | main( 57 | [ 58 | "-r", 59 | "-1", 60 | "--data-location", 61 | self.task_dir_path, 62 | "--taskrc-location", 63 | self.taskrc_path, 64 | ] 65 | ) 66 | except SystemExit as err: 67 | if err.code == 1: 68 | pass 69 | else: 70 | self.fail() 71 | 72 | def test_main_no_taskrc_exits_with_1(self): 73 | self.create_test_files(taskrc=False) 74 | try: 75 | main( 76 | [ 77 | "-r", 78 | "-1", 79 | "--data-location", 80 | self.task_dir_path, 81 | "--taskrc-location", 82 | self.taskrc_path, 83 | ] 84 | ) 85 | except SystemExit as err: 86 | if err.code == 1: 87 | pass 88 | else: 89 | self.fail() 90 | 91 | 92 | if __name__ == "__main__": 93 | unittest.main() 94 | -------------------------------------------------------------------------------- /taskschedule/notifier.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from taskschedule.scheduled_task import ScheduledTask 5 | 6 | 7 | class SoundDoesNotExistError(Exception): 8 | ... 9 | 10 | 11 | class Notifier: 12 | def __init__(self, backend): 13 | self.backend = backend 14 | 15 | def notify(self, task: ScheduledTask): 16 | """Send a notification for the given task.""" 17 | 18 | home = os.path.expanduser("~") 19 | 20 | scheduled_time = task.scheduled_start_datetime 21 | if not scheduled_time: 22 | return 23 | 24 | scheduled_time_formatted = scheduled_time.strftime("%H:%M") 25 | 26 | task_id: str = task["id"] 27 | summary: str = f"{scheduled_time_formatted} | Task {task_id}" 28 | body: str = "{}".format(task["description"]) 29 | urgency: str = "critical" 30 | uuid: str = task["uuid"] 31 | 32 | if "termux" in str(os.getenv("PREFIX")): 33 | urgency = "max" 34 | subprocess.run( 35 | [ 36 | "termux-notification", 37 | "--title", 38 | summary, 39 | "--content", 40 | body, 41 | "--button1", 42 | "Start", 43 | "--button1-action", 44 | f"task {uuid} start", 45 | "--button2", 46 | "Stop", 47 | "--button2-action", 48 | f"task {uuid} stop", 49 | "--on-delete", 50 | "echo deleted", 51 | "--action", 52 | "echo action", 53 | "--id", 54 | f"taskschedule-{uuid}", 55 | "--vibrate", 56 | "200", 57 | "--priority", 58 | urgency, 59 | "--led-off", 60 | "200", 61 | "--led-on", 62 | "200", 63 | ] 64 | ) 65 | else: 66 | subprocess.run(["notify-send", "--urgency", urgency, summary, body]) 67 | 68 | sound_file = home + "/.taskschedule/hooks/drip.wav" 69 | if os.path.isfile(sound_file) is True: 70 | subprocess.Popen( 71 | ["aplay", sound_file], 72 | stdout=subprocess.DEVNULL, 73 | stderr=subprocess.STDOUT, 74 | ) 75 | else: 76 | raise SoundDoesNotExistError( 77 | f"The specified sound file does not exist: {sound_file}" 78 | ) 79 | 80 | def send_notifications(self): 81 | """Send notifications for scheduled tasks that should be started.""" 82 | 83 | tasks = self.backend.tasks.filter( 84 | "-ACTIVE -COMPLETED scheduled.before:now scheduled.after:today" 85 | ) 86 | 87 | for task in tasks: 88 | if not task.notified: 89 | self.notify(task) 90 | -------------------------------------------------------------------------------- /tests/test_scheduled_task.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | 5 | from taskschedule.scheduled_task import ScheduledTask 6 | 7 | 8 | def test_as_dict(tw): # noqa: F811 9 | expected_desc = "Test task" 10 | task = ScheduledTask(backend=tw, description=expected_desc) 11 | 12 | assert task.as_dict()["description"] == expected_desc 13 | 14 | 15 | def test_has_scheduled_time(tw): # noqa: F811 16 | task = ScheduledTask( 17 | backend=tw, description="Test task", scheduled=datetime(2019, 10, 12, 10, 0) 18 | ) 19 | assert task.has_scheduled_time is True 20 | task = ScheduledTask( 21 | backend=tw, description="Test task", scheduled=datetime(2019, 10, 12, 0, 1) 22 | ) 23 | assert task.has_scheduled_time is True 24 | task = ScheduledTask( 25 | backend=tw, description="Test task", scheduled=datetime(2019, 10, 12, 0, 0, 1) 26 | ) 27 | assert task.has_scheduled_time is True 28 | task = ScheduledTask( 29 | backend=tw, 30 | description="Test task", 31 | scheduled=datetime(2019, 10, 12, 0, 0, 0, 1), 32 | ) 33 | assert task.has_scheduled_time is True 34 | 35 | task = ScheduledTask( 36 | backend=tw, description="Test task", scheduled=datetime(2019, 10, 12, 0, 0) 37 | ) 38 | assert task.has_scheduled_time is False 39 | 40 | 41 | def test_scheduled_start_datetime(tw): # noqa: F811 42 | task = ScheduledTask( 43 | backend=tw, description="Test task", scheduled=datetime(2019, 10, 12, 0, 0) 44 | ) 45 | tzinfo = task["scheduled"].tzinfo 46 | expected = datetime(2019, 10, 12, 0, 0, tzinfo=tzinfo) 47 | assert task.scheduled_start_datetime == expected 48 | 49 | task = ScheduledTask(backend=tw, description="Test task") 50 | assert task.scheduled_start_datetime is None 51 | 52 | 53 | def test_scheduled_end_datetime(tw): # noqa: F811 54 | task = ScheduledTask( 55 | backend=tw, 56 | description="Test task", 57 | scheduled=datetime(2019, 10, 12, 0, 0), 58 | estimate="PT1H", 59 | ) 60 | difference = task.scheduled_end_datetime - task["scheduled"] 61 | 62 | assert difference == timedelta(seconds=3600) 63 | 64 | 65 | def test_notified(tw): # noqa: F811 66 | task = ScheduledTask( 67 | backend=tw, description="Test task", scheduled=datetime(2019, 10, 12, 0, 0) 68 | ) 69 | task.save() 70 | 71 | assert task.notified is False 72 | assert task.notified is True 73 | 74 | 75 | @pytest.mark.skip(reason="This cannot be tested because the method is currently broken") 76 | def test_should_be_active(tw): # noqa: F811 77 | # TODO Complete this test after fixing the class method 78 | ... 79 | 80 | 81 | def test_overdue(tw): # noqa: F811 82 | future_task = ScheduledTask( 83 | backend=tw, description="Test task", scheduled=datetime(2313, 10, 12, 0, 0) 84 | ) 85 | old_task = ScheduledTask( 86 | backend=tw, description="Test task", scheduled=datetime(1448, 10, 12, 0, 0) 87 | ) 88 | 89 | assert future_task.overdue is False 90 | assert old_task.overdue is True 91 | -------------------------------------------------------------------------------- /tests/test_screen.py: -------------------------------------------------------------------------------- 1 | from taskschedule.screen import Screen 2 | 3 | 4 | class TestScreen: 5 | def test_screen_scroll_up_at_top_is_blocked(self, screen): 6 | current_scroll_level = screen.scroll_level 7 | screen.scroll(-1) 8 | assert current_scroll_level == screen.scroll_level 9 | 10 | def test_screen_scroll_down_and_up(self, screen): 11 | current_scroll_level = screen.scroll_level 12 | screen.scroll(1) 13 | assert current_scroll_level + 1 == screen.scroll_level 14 | 15 | current_scroll_level = screen.scroll_level 16 | screen.scroll(-1) 17 | assert current_scroll_level - 1 == screen.scroll_level 18 | 19 | def test_prerender_footnote(self, screen: Screen): 20 | footnote = screen.prerender_footnote() 21 | count = len(screen.schedule.tasks) 22 | assert f"{count} tasks" in footnote 23 | 24 | def test_prerender_buffer(self, screen: Screen): 25 | header_buffer = screen.prerender_headers() 26 | assert "ID" in header_buffer[0][2] 27 | assert header_buffer[0][1] == 5 28 | assert "Time" in header_buffer[1][2] 29 | assert header_buffer[1][1] == 7 30 | assert "Timeboxes" in header_buffer[2][2] 31 | assert header_buffer[2][1] == 19 32 | assert "Project" in header_buffer[3][2] 33 | assert header_buffer[3][1] == 29 34 | assert "Description" in header_buffer[4][2] 35 | assert header_buffer[4][1] == 37 36 | 37 | def test_predender_divider(self, screen: Screen): 38 | divider_buffer = screen.prerender_divider("2019-12-07", 0) 39 | assert "──────" in divider_buffer[0][2] 40 | assert "Sat 07 Dec 2019" in divider_buffer[1][2] 41 | assert divider_buffer[1][1] == 6 42 | assert ( 43 | "─────────────────────────────────────────────────────────" 44 | in divider_buffer[2][2] 45 | ) 46 | assert divider_buffer[2][1] == 23 47 | 48 | def test_prerender_empty_line(self, screen: Screen): 49 | empty_line_buffer = screen.prerender_empty_line(True, 0, 22, "2019-12-08") 50 | assert " " in empty_line_buffer[0][2] 51 | assert empty_line_buffer[0][1] == 5 52 | assert empty_line_buffer[1][2] == "22" 53 | assert empty_line_buffer[1][1] == 0 54 | 55 | def test_prerender_task(self, screen: Screen): 56 | task = screen.schedule.tasks[0] 57 | task_buffer = screen.prerender_task(0, task, False, 11, 0, "2019-12-08") 58 | assert task_buffer[0][1] == 0 59 | assert "11" in task_buffer[0][2] 60 | 61 | # Glyph column 62 | assert task_buffer[1][1] == 5 63 | assert " " in task_buffer[1][2] 64 | 65 | # ID column 66 | assert task_buffer[3][1] == 5 67 | assert "1" in task_buffer[3][2] 68 | 69 | # Time column 70 | assert task_buffer[4][1] == 7 71 | assert "00:20" in task_buffer[4][2] 72 | 73 | # Project column 74 | assert task_buffer[5][1] == 29 75 | assert task_buffer[5][2] == "" 76 | 77 | # Description column 78 | assert task_buffer[6][1] == 37 79 | assert "test_last_week" in task_buffer[6][2] 80 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import shutil 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | 9 | from taskschedule.schedule import Schedule, ScheduledTask 10 | from taskschedule.screen import Screen 11 | from taskschedule.taskwarrior import PatchedTaskWarrior 12 | from taskschedule.utils import calculate_datetime 13 | 14 | if TYPE_CHECKING: 15 | from datetime import datetime 16 | 17 | 18 | @pytest.fixture(scope="module") 19 | def tw(): 20 | """Create a PatchedTaskWarrior instance with a temporary .taskrc and data 21 | location. Remove the temporary data after testing is finished.""" 22 | 23 | taskrc_path = "tests/test-data/.taskrc" 24 | task_dir_path = "tests/test-data/.task" 25 | 26 | # Create a sample .taskrc 27 | with open(taskrc_path, "w+") as file: 28 | file.write("# User Defined Attributes\n") 29 | file.write("uda.estimate.type=duration\n") 30 | file.write("uda.estimate.label=Est\n") 31 | file.write("# User Defined Attributes\n") 32 | file.write("uda.tb_estimate.type=numeric\n") 33 | file.write("uda.tb_estimate.label=Est\n") 34 | file.write("uda.tb_real.type=numeric\n") 35 | file.write("uda.tb_real.label=Real\n") 36 | 37 | # Create a sample empty .task directory 38 | os.makedirs(task_dir_path) 39 | 40 | tw = PatchedTaskWarrior( 41 | data_location=task_dir_path, create=True, taskrc_location=taskrc_path 42 | ) 43 | tw.overrides.update({"uda.estimate.type": "duration"}) 44 | tw.overrides.update({"uda.estimate.label": "Est"}) 45 | 46 | yield tw 47 | 48 | try: 49 | os.remove(taskrc_path) 50 | except FileNotFoundError: 51 | pass 52 | 53 | try: 54 | shutil.rmtree(task_dir_path) 55 | except FileNotFoundError: 56 | pass 57 | 58 | 59 | @pytest.fixture(scope="module") 60 | def schedule(tw): 61 | """Create a Schedule instance with a few tasks.""" 62 | ScheduledTask( 63 | tw, description="test_last_week", schedule="yesterday-7days", estimate="20min" 64 | ).save() 65 | ScheduledTask( 66 | tw, description="test_yesterday", schedule="yesterday", estimate="20min" 67 | ).save() 68 | ScheduledTask( 69 | tw, description="test_9:00_to_10:11", schedule="today+9hr", estimate="71min" 70 | ).save() 71 | ScheduledTask( 72 | tw, description="test_14:00_to_16:00", schedule="today+14hr", estimate="2hr" 73 | ).save() 74 | ScheduledTask( 75 | tw, 76 | description="test_16:10_to_16:34", 77 | schedule="today+16hr+10min", 78 | estimate="24min", 79 | ).save() 80 | ScheduledTask( 81 | tw, description="test_tomorrow", schedule="tomorrow", estimate="24min" 82 | ).save() 83 | ScheduledTask( 84 | tw, description="test_next_week", schedule="today+7days", estimate="20min" 85 | ).save() 86 | 87 | scheduled_after: datetime = calculate_datetime("tomorrow-3days") 88 | scheduled_before: datetime = calculate_datetime("tomorrow+3days") 89 | schedule = Schedule( 90 | backend=tw, scheduled_before=scheduled_before, scheduled_after=scheduled_after 91 | ) 92 | 93 | yield schedule 94 | 95 | 96 | @pytest.fixture(scope="module") 97 | def screen(tw, schedule): 98 | screen = Screen(schedule, schedule.scheduled_after, schedule.scheduled_before) 99 | yield screen 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status](https://travis-ci.org/nnist/taskschedule.svg?branch=master)](https://travis-ci.org/nnist/taskschedule) [![codecov](https://codecov.io/gh/nnist/taskschedule/branch/master/graph/badge.svg)](https://codecov.io/gh/nnist/taskschedule) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 2 | 3 | # Taskschedule 4 | This is a time schedule report for [taskwarrior](https://taskwarrior.org/). 5 | 6 | ![screenshot](https://github.com/nnist/taskschedule/blob/master/img/screenshot.png "Screenshot") 7 | 8 | ## Features 9 | - Hook support 10 | - Timebox support 11 | 12 | ## Getting started 13 | ### Prerequisites 14 | - taskwarrior 15 | - in `.taskrc`: 16 | ``` 17 | # User Defined Attributes 18 | uda.estimate.type=duration 19 | uda.estimate.label=Est 20 | ``` 21 | - for timebox support, in `.taskrc`: 22 | ``` 23 | # Timebox UDAs 24 | uda.tb_estimate.type=numeric 25 | uda.tb_estimate.label=Est 26 | uda.tb_real.type=numeric 27 | uda.tb_real.label=Real 28 | ``` 29 | ### Installing 30 | First, clone the repo: 31 | ```sh 32 | $ git clone https://github.com/nnist/taskschedule.git 33 | $ cd taskschedule 34 | ``` 35 | #### Method one 36 | Install the program using setup.py: 37 | ```sh 38 | $ python3 setup.py install 39 | $ taskschedule 40 | ``` 41 | #### Method two 42 | Instead of installing, you can also just run the program directly: 43 | ```sh 44 | $ pip3 install --user -r requirements.txt 45 | $ python3 __main__.py 46 | ``` 47 | ## Usage 48 | ### Basic usage 49 | 1. Start taskschedule: 50 | ``` 51 | $ taskschedule 52 | ``` 53 | 2. In a new terminal, create a scheduled task: 54 | ``` 55 | $ task add Buy cat food schedule:17:00 56 | Created task 62. 57 | ``` 58 | 3. The task will now be visible in taskschedule: 59 | ``` 60 | ID Time Description 61 | 16 62 | 17 ○ 62 17:00 Buy cat food 63 | 18 64 | ``` 65 | 4. Start the task in taskwarrior: 66 | ``` 67 | $ task 62 start 68 | ``` 69 | 5. The task is now displayed as active in taskschedule: 70 | ``` 71 | ID Time Description 72 | 16 73 | 17 ○ 62 17:00 Buy cat food <-- highlighted 74 | 18 75 | ``` 76 | 6. Mark the task as done: 77 | ``` 78 | $ task 62 done 79 | ``` 80 | ``` 81 | ID Time Description 82 | 16 83 | 17 ○ 17:00 Buy cat food <-- barely visible 84 | 18 85 | ``` 86 | ### Show tomorrow's tasks 87 | ```sh 88 | $ taskschedule -s tomorrow 89 | ``` 90 | ### Show last week's tasks 91 | ```sh 92 | $ taskschedule --from today-1week --to tomorrow 93 | ``` 94 | ### Hooks 95 | Scripts in the hook directory (default: `~/.taskschedule/hooks/`) are 96 | automatically run on certain triggers. For example, the `on-progress` hook 97 | runs all scripts starting with `on-progress`, e.g. `on-progress-notify.py`. 98 | 99 | This can be used to for things like notification pop-ups, alarm sounds, 100 | push notifications, etc. 101 | 102 | ### Timebox 103 | The timeboxing functionality relies on two new UDAs, namely `tb_estimate` and 104 | `tb_real`. Scheduled tasks with `tb_estimate` will have their completed 105 | and pending timeboxes rendered in the Timeboxes column. 106 | 107 | When a timeboxed task is started, its time is tracked and a progress bar 108 | will be shown. After the timebox time is up, the task will automatically 109 | stop, `tb_real` will be incremented and the Timeboxes column will be updated. 110 | 111 | Currently only one active timeboxed task is supported. 112 | 113 | ## Running the tests 114 | First go to the repo root, then run the tests: 115 | ```sh 116 | $ python3 -m unittest 117 | ``` 118 | ## License 119 | This project is licensed under the MIT License - see the `LICENSE` file for details. 120 | -------------------------------------------------------------------------------- /tests-old/test_cli.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,no-self-use 2 | 3 | import subprocess 4 | import unittest 5 | import os 6 | import shutil 7 | import time 8 | import sys 9 | 10 | from .context import taskschedule 11 | 12 | 13 | class Timeout(Exception): 14 | pass 15 | 16 | 17 | def run(command, timeout=10): 18 | proc = subprocess.Popen(command, bufsize=0) 19 | poll_seconds = 0.250 20 | deadline = time.time() + timeout 21 | while time.time() < deadline and proc.poll() is None: 22 | time.sleep(poll_seconds) 23 | 24 | if proc.poll() is None: 25 | if float(sys.version[:3]) >= 2.6: 26 | proc.terminate() 27 | proc.wait() 28 | 29 | raise Timeout() 30 | 31 | stdout, stderr = proc.communicate() 32 | return stdout, stderr, proc.returncode 33 | 34 | 35 | class Debug(unittest.TestCase): 36 | """This class is used to debug the testing environment.""" 37 | 38 | def test_print_term_info(self): 39 | print("TERM={}".format(os.environ["TERM"])) 40 | 41 | 42 | class CLITest(unittest.TestCase): 43 | def setUp(self): 44 | self.taskrc_path = "tests/test_data/.taskrc" 45 | self.task_dir_path = "tests/test_data/.task" 46 | self.assertEqual(os.path.isfile(self.taskrc_path), False) 47 | self.assertEqual(os.path.isdir(self.task_dir_path), False) 48 | 49 | # Create a sample .taskrc 50 | with open(self.taskrc_path, "w+") as file: 51 | file.write("# User Defined Attributes\n") 52 | file.write("uda.estimate.type=duration\n") 53 | file.write("uda.estimate.label=Est\n") 54 | 55 | # Create a sample empty .task directory 56 | os.makedirs(self.task_dir_path) 57 | 58 | self.assertEqual(os.path.isfile(self.taskrc_path), True) 59 | self.assertEqual(os.path.isdir(self.task_dir_path), True) 60 | 61 | def tearDown(self): 62 | os.remove(self.taskrc_path) 63 | shutil.rmtree(self.task_dir_path) 64 | 65 | def test_cli_valid_date_does_not_error(self): 66 | # Ensure it times out, because that means it atleast 67 | # entered the main loop 68 | try: 69 | run( 70 | ["python3", "__main__.py", "--from", "today", "--until", "tomorrow"], 71 | timeout=2, 72 | ) 73 | except Timeout: 74 | pass 75 | 76 | try: 77 | run(["python3", "__main__.py", "--scheduled", "tomorrow"], timeout=2) 78 | except Timeout: 79 | pass 80 | 81 | def test_cli_invalid_date_prints_error(self): 82 | try: 83 | process = subprocess.run( 84 | ["python3 __main__.py --from asdfafk --until tomorrow"], 85 | shell=True, 86 | timeout=10, 87 | stdout=subprocess.PIPE, 88 | stderr=subprocess.PIPE, 89 | check=True, 90 | ) 91 | output = process.stdout.split(b"\n") 92 | self.assertEqual( 93 | output[0], 94 | b"Error: time data 'asdfafk' does not match format '%Y-%m-%dT%H:%M:%S'", 95 | ) 96 | except subprocess.CalledProcessError: 97 | pass 98 | try: 99 | process = subprocess.run( 100 | ["python3 __main__.py --scheduled asdfafk"], 101 | shell=True, 102 | timeout=10, 103 | stdout=subprocess.PIPE, 104 | stderr=subprocess.PIPE, 105 | check=True, 106 | ) 107 | output = process.stdout.split(b"\n") 108 | self.assertEqual( 109 | output[0], 110 | b"Error: time data 'asdfafk' does not match format '%Y-%m-%dT%H:%M:%S'", 111 | ) 112 | except subprocess.CalledProcessError: 113 | pass 114 | 115 | def test_cli_no_args_does_not_error(self): 116 | # Ensure it times out, because that means it atleast 117 | # entered the main loop 118 | try: 119 | run(["python3", "__main__.py"], timeout=2) 120 | except Timeout: 121 | pass 122 | 123 | def test_cli_help_returns_help_message(self): 124 | process = subprocess.run( 125 | ["python3 __main__.py -h"], 126 | shell=True, 127 | timeout=10, 128 | stdout=subprocess.PIPE, 129 | stderr=subprocess.PIPE, 130 | check=True, 131 | ) 132 | output = process.stdout.split(b"\n") 133 | assert output[0].startswith(b"usage:") 134 | 135 | 136 | if __name__ == "__main__": 137 | unittest.main() 138 | -------------------------------------------------------------------------------- /taskschedule/scheduled_task.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import time 5 | from datetime import datetime as dt 6 | from typing import Dict, Optional 7 | 8 | from isodate import parse_duration 9 | from tasklib.task import Task, TaskQuerySet 10 | 11 | 12 | class ScheduledTaskQuerySet(TaskQuerySet): 13 | ... 14 | 15 | 16 | class ScheduledTask(Task): 17 | """A scheduled task.""" 18 | 19 | def __init__(self, *args, **kwargs): 20 | super(ScheduledTask, self).__init__(*args, **kwargs) 21 | # TODO Create reference to Schedule 22 | self.glyph = "○" 23 | 24 | @property 25 | def has_scheduled_time(self) -> bool: 26 | """If task's scheduled time is 00:00:00, it has been scheduled for a 27 | particular day but not for a specific time. If this is the case, 28 | return False.""" 29 | start = self.scheduled_start_datetime 30 | if start: 31 | if ( 32 | start.hour == 0 33 | and start.minute == 0 34 | and start.second == 0 35 | and start.microsecond == 0 36 | ): 37 | return False 38 | else: 39 | return True 40 | 41 | return False 42 | 43 | @property 44 | def scheduled_start_datetime(self) -> Optional[dt]: 45 | """Return the task's scheduled start datetime.""" 46 | try: 47 | return self["scheduled"] 48 | except TypeError: 49 | return None 50 | 51 | @property 52 | def scheduled_end_datetime(self) -> Optional[dt]: 53 | """Return the task's scheduled end datetime.""" 54 | try: 55 | estimate: dt = self["estimate"] 56 | duration = parse_duration(estimate) 57 | return self["scheduled"] + duration 58 | except TypeError: 59 | return None 60 | 61 | @property 62 | def notified(self) -> bool: 63 | filename = tempfile.gettempdir() + "/taskschedule" 64 | uuid = self["uuid"] 65 | 66 | # TODO Move this logic into Notifier; this is only used there 67 | 68 | min_delay = 300 # TODO Make configurable 69 | if os.path.exists(filename): 70 | mode = "r+" 71 | else: 72 | mode = "w+" 73 | 74 | with open(filename, mode) as f: 75 | raw_data = f.read() 76 | 77 | if not raw_data: 78 | raw_data = "{}" 79 | 80 | data = json.loads(raw_data) 81 | 82 | # TODO Refactor to de-duplicate code 83 | if uuid not in data: 84 | data[uuid] = time.time() 85 | f.seek(0) 86 | f.truncate(0) 87 | f.write(json.dumps(data)) 88 | return False 89 | else: 90 | if time.time() > float(data[uuid]) + min_delay: 91 | data[uuid] = time.time() 92 | f.seek(0) 93 | f.truncate(0) 94 | f.write(json.dumps(data)) 95 | return False 96 | else: 97 | return True 98 | 99 | @property 100 | def should_be_active(self) -> bool: 101 | """Return true if the task should be active.""" 102 | 103 | if self.scheduled_start_datetime is None: 104 | return False 105 | 106 | start_ts: float = dt.timestamp(self.scheduled_start_datetime) 107 | 108 | now = dt.now() 109 | now_ts = dt.timestamp(now) 110 | 111 | if self["end"] is None: 112 | # TODO Implement get_next_task differently 113 | # next_task = self.schedule.get_next_task(self) 114 | next_task = None 115 | if next_task is not None: 116 | next_task_start_ts = dt.timestamp(next_task.start) 117 | if now_ts > start_ts and next_task_start_ts > now_ts: 118 | return True 119 | else: 120 | end_ts = dt.timestamp(self["end"]) 121 | if now_ts > start_ts and end_ts > now_ts: 122 | return True 123 | 124 | return False 125 | 126 | @property 127 | def overdue(self) -> bool: 128 | """If the task is overdue (current time is past end time), 129 | return True. Else, return False.""" 130 | if not self.scheduled_start_datetime: 131 | return False 132 | 133 | now = dt.now() 134 | now_ts = dt.timestamp(now) 135 | 136 | if self["end"] is None: 137 | start_ts = dt.timestamp(self.scheduled_start_datetime) 138 | if now_ts > start_ts: 139 | return True 140 | 141 | return False 142 | 143 | end_ts = dt.timestamp(self["end"]) 144 | if now_ts > end_ts: 145 | return True 146 | 147 | return False 148 | 149 | def as_dict(self) -> Dict: 150 | data = self.export_data() 151 | return json.loads(data) 152 | -------------------------------------------------------------------------------- /tests-old/test_screen.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,no-self-use 2 | 3 | import unittest 4 | import os 5 | import shutil 6 | import curses 7 | import time 8 | 9 | from tasklib import TaskWarrior, Task 10 | 11 | from taskschedule.screen import Screen 12 | 13 | 14 | class ScreenTest(unittest.TestCase): 15 | def setUp(self): 16 | self.taskrc_path = "tests/test_data/.taskrc" 17 | self.task_dir_path = "tests/test_data/.task" 18 | self.assertEqual(os.path.isdir(self.taskrc_path), False) 19 | self.assertEqual(os.path.isdir(self.task_dir_path), False) 20 | 21 | # Create a sample .taskrc 22 | with open(self.taskrc_path, "w+") as file: 23 | file.write("# User Defined Attributes\n") 24 | file.write("uda.estimate.type=duration\n") 25 | file.write("uda.estimate.label=Est\n") 26 | file.write("# User Defined Attributes\n") 27 | file.write("uda.tb_estimate.type=numeric\n") 28 | file.write("uda.tb_estimate.label=Est\n") 29 | file.write("uda.tb_real.type=numeric\n") 30 | file.write("uda.tb_real.label=Real\n") 31 | 32 | # Create a sample empty .task directory 33 | os.makedirs(self.task_dir_path) 34 | 35 | self.screen = Screen( 36 | tw_data_dir=self.task_dir_path, 37 | taskrc_location=self.taskrc_path, 38 | scheduled="today", 39 | ) 40 | 41 | def tearDown(self): 42 | try: 43 | os.remove(self.taskrc_path) 44 | except FileNotFoundError: 45 | pass 46 | 47 | try: 48 | shutil.rmtree(self.task_dir_path) 49 | except FileNotFoundError: 50 | pass 51 | 52 | # Attempt to gracefully quit curses mode if it is active 53 | # to prevent messing up terminal 54 | try: 55 | self.screen.close() 56 | except: 57 | try: 58 | curses.endwin() 59 | except: 60 | pass 61 | 62 | # TODO Move to /tests/functional/ 63 | # def test_screen_refresh_buffer_hide_empty_lines(self): 64 | # self.assertEqual(self.screen.buffer, []) 65 | 66 | # taskwarrior = TaskWarrior( 67 | # data_location=self.task_dir_path, 68 | # create=True, 69 | # taskrc_location=self.taskrc_path) 70 | # Task(taskwarrior, description='test_yesterday', 71 | # schedule='yesterday', estimate='20min').save() 72 | # Task(taskwarrior, description='test_9:00_to_10:11', 73 | # schedule='today+9hr', estimate='71min', project='test').save() 74 | 75 | # self.screen.hide_empty = False 76 | # self.screen.refresh_buffer() 77 | # self.assertEqual(len(self.screen.buffer), 61) 78 | 79 | # TODO Move to /tests/functional/ 80 | # def test_screen_refresh_buffer_first_time_fills_buffer(self): 81 | # self.assertEqual(self.screen.buffer, []) 82 | 83 | # taskwarrior = TaskWarrior( 84 | # data_location=self.task_dir_path, 85 | # create=True, 86 | # taskrc_location=self.taskrc_path) 87 | # Task(taskwarrior, description='test_yesterday', 88 | # schedule='yesterday', estimate='20min').save() 89 | # Task(taskwarrior, description='test_9:00_to_10:11', 90 | # schedule='today+9hr', estimate='71min', project='test').save() 91 | 92 | # self.screen.refresh_buffer() 93 | # self.assertEqual(len(self.screen.buffer), 15) 94 | 95 | # TODO Move to /tests/functional/ 96 | # def test_screen_refresh_buffer_no_tasks(self): 97 | # self.assertEqual(self.screen.buffer, []) 98 | # self.screen.refresh_buffer() 99 | # self.assertEqual(self.screen.buffer, []) 100 | 101 | def test_screen_draw_no_tasks_to_display(self): 102 | self.screen.draw() 103 | 104 | def test_screen_draw(self): 105 | taskwarrior = TaskWarrior( 106 | data_location=self.task_dir_path, 107 | create=True, 108 | taskrc_location=self.taskrc_path, 109 | ) 110 | Task( 111 | taskwarrior, 112 | description="test_yesterday", 113 | schedule="yesterday", 114 | estimate="20min", 115 | ).save() 116 | Task( 117 | taskwarrior, 118 | description="test_9:00_to_10:11", 119 | schedule="today+9hr", 120 | estimate="71min", 121 | project="test", 122 | ).save() 123 | 124 | self.screen.draw() 125 | self.screen.refresh_buffer() 126 | Task( 127 | taskwarrior, 128 | description="test_14:00_to_16:00", 129 | schedule="today+14hr", 130 | estimate="2hr", 131 | ).save() 132 | time.sleep(0.1) 133 | self.screen.draw() 134 | self.screen.refresh_buffer() 135 | Task( 136 | taskwarrior, 137 | description="test_tomorrow", 138 | schedule="tomorrow", 139 | estimate="24min", 140 | ).save() 141 | time.sleep(0.1) 142 | self.screen.draw() 143 | self.screen.refresh_buffer() 144 | 145 | def test_screen_scroll_up_at_top_is_blocked(self): 146 | current_scroll_level = self.screen.scroll_level 147 | self.screen.scroll(-1) 148 | self.assertEqual(current_scroll_level, self.screen.scroll_level) 149 | 150 | def test_screen_scroll_down_and_up(self): 151 | current_scroll_level = self.screen.scroll_level 152 | self.screen.scroll(1) 153 | self.assertEqual(current_scroll_level + 1, self.screen.scroll_level) 154 | 155 | current_scroll_level = self.screen.scroll_level 156 | self.screen.scroll(-1) 157 | self.assertEqual(current_scroll_level - 1, self.screen.scroll_level) 158 | 159 | 160 | if __name__ == "__main__": 161 | unittest.main() 162 | -------------------------------------------------------------------------------- /taskschedule/schedule.py: -------------------------------------------------------------------------------- 1 | """This module provides a Schedule class, which is used for retrieving 2 | scheduled tasks from taskwarrior and displaying them in a table.""" 3 | 4 | from datetime import datetime, timedelta 5 | from typing import Dict, List, Optional 6 | 7 | from cached_property import cached_property 8 | 9 | from taskschedule.scheduled_task import ScheduledTask, ScheduledTaskQuerySet 10 | from taskschedule.taskwarrior import PatchedTaskWarrior 11 | 12 | 13 | class UDADoesNotExistError(Exception): 14 | """Raised when UDA is not found in .taskrc file.""" 15 | 16 | # pylint: disable=unnecessary-pass 17 | pass 18 | 19 | 20 | class TaskrcDoesNotExistError(Exception): 21 | """Raised when the .taskrc file has not been found.""" 22 | 23 | # pylint: disable=unnecessary-pass 24 | pass 25 | 26 | 27 | class TaskDirDoesNotExistError(Exception): 28 | """Raised when the .task directory has not been found.""" 29 | 30 | # pylint: disable=unnecessary-pass 31 | pass 32 | 33 | 34 | class Schedule: 35 | """This class provides methods to format tasks and display them in 36 | a schedule report.""" 37 | 38 | def __init__( 39 | self, 40 | backend: PatchedTaskWarrior, 41 | scheduled_after: datetime, 42 | scheduled_before: datetime, 43 | ): 44 | self.backend = backend 45 | 46 | self.scheduled_before = scheduled_before 47 | self.scheduled_after = scheduled_after 48 | 49 | self.timeboxed_task: Optional[ScheduledTask] = None 50 | 51 | def get_timebox_estimate_count(self) -> int: 52 | """"Return today's estimated timebox count.""" 53 | total = 0 54 | for task in self.tasks: 55 | if task["tb_estimate"]: 56 | total += task["tb_estimate"] 57 | return total 58 | 59 | def get_timebox_real_count(self) -> int: 60 | """"Return today's real timebox count.""" 61 | total = 0 62 | for task in self.tasks: 63 | if task["tb_real"]: 64 | total += task["tb_real"] 65 | return total 66 | 67 | def get_active_timeboxed_task(self) -> Optional[ScheduledTask]: 68 | """If a timeboxed task is currently active, return it. Otherwise, 69 | return None.""" 70 | for task in self.tasks: 71 | if task.active and task["tb_estimate"]: 72 | self.timeboxed_task = task 73 | 74 | if self.timeboxed_task: 75 | return self.timeboxed_task 76 | 77 | return None 78 | 79 | def stop_active_timeboxed_task(self): 80 | """Stop the current timeboxed task.""" 81 | timeboxed_task = self.get_active_timeboxed_task() 82 | timeboxed_task.stop() 83 | self.timeboxed_task = None 84 | 85 | def clear_cache(self): 86 | """Clear the scheduled tasks cache.""" 87 | if self.tasks: 88 | del self.__dict__["tasks"] 89 | 90 | @cached_property 91 | def tasks(self) -> ScheduledTaskQuerySet: 92 | """Retrieve scheduled tasks from taskwarrior.""" 93 | queryset: ScheduledTaskQuerySet = ScheduledTaskQuerySet(backend=self.backend) 94 | 95 | return queryset 96 | 97 | def get_time_slots(self) -> Dict: 98 | """Return a dict with dates and their tasks. 99 | >>> get_time_slots() 100 | {datetime.date(2019, 6, 27): {00: [], 01: [], ..., 23: [task, task]}, 101 | datetime.date(2019, 6, 28): {00: [], ..., 10: [task, task], ...}] 102 | """ 103 | start_time = "0:00" 104 | end_time = "23:00" 105 | slot_time = 60 106 | 107 | start_date = self.scheduled_after.date() 108 | end_date = self.scheduled_before.date() 109 | 110 | days = {} 111 | date = start_date 112 | while date <= end_date: 113 | hours = {} 114 | time = datetime.strptime(start_time, "%H:%M") 115 | end = datetime.strptime(end_time, "%H:%M") 116 | while time <= end: 117 | task_list = [] 118 | task: ScheduledTask 119 | for task in self.tasks: 120 | start = task.scheduled_start_datetime 121 | if start and start.date() == date: 122 | if start.hour == int(time.strftime("%H")): 123 | task_list.append(task) 124 | 125 | task_list = sorted(task_list, key=lambda k: k["scheduled"]) 126 | hours[time.strftime("%H")] = task_list 127 | time += timedelta(minutes=slot_time) 128 | days[date.isoformat()] = hours 129 | date += timedelta(days=1) 130 | 131 | return days 132 | 133 | def get_max_length(self, key: str) -> int: 134 | """Return the max string length of a given key's value of all tasks 135 | in the schedule. Useful for determining column widths. 136 | """ 137 | max_length = 0 138 | for task in self.tasks: 139 | length = len(str(task[key])) 140 | if length > max_length: 141 | max_length = length 142 | 143 | return max_length 144 | 145 | def get_column_offsets(self) -> List[int]: 146 | """Return the offsets for each column in the schedule for rendering 147 | a table.""" 148 | offsets = [0, 5] # Hour, glyph 149 | offsets.append(5 + self.get_max_length("id") + 1) # ID 150 | offsets.append(offsets[2] + 12) # Time 151 | offsets.append(offsets[3] + 10) # Timeboxes 152 | 153 | add_offset = self.get_max_length("project") + 1 154 | 155 | if add_offset < 8: 156 | add_offset = 8 157 | 158 | offsets.append(offsets[4] + add_offset) # Project 159 | return offsets 160 | 161 | def get_next_task(self, task: ScheduledTask) -> Optional[ScheduledTask]: 162 | """Get the next scheduled task after the given task. If there is no 163 | next scheduled task, return None.""" 164 | next_tasks = [] 165 | for task_ in self.tasks: 166 | if task_.scheduled_start_datetime > task.scheduled_start_datetime: 167 | next_tasks.append(task_) 168 | 169 | next_tasks.sort(key=lambda task: task.scheduled_start_datetime) 170 | 171 | if next_tasks: 172 | return next_tasks[0] 173 | 174 | return None 175 | -------------------------------------------------------------------------------- /taskschedule/main.py: -------------------------------------------------------------------------------- 1 | """Command line interface of taskschedule""" 2 | import argparse 3 | import os 4 | import sys 5 | import time 6 | from curses import KEY_RESIZE 7 | from curses import error as curses_error 8 | from curses import napms 9 | from datetime import datetime 10 | 11 | from tasklib import TaskWarrior 12 | 13 | from taskschedule.notifier import Notifier, SoundDoesNotExistError 14 | from taskschedule.schedule import ( 15 | Schedule, 16 | TaskDirDoesNotExistError, 17 | TaskrcDoesNotExistError, 18 | UDADoesNotExistError, 19 | ) 20 | from taskschedule.screen import Screen 21 | from taskschedule.taskwarrior import PatchedTaskWarrior 22 | from taskschedule.utils import calculate_datetime 23 | 24 | 25 | class Main: 26 | def __init__(self, argv): 27 | self.home_dir = os.path.expanduser("~") 28 | 29 | self.parse_args(argv) 30 | self.check_files() 31 | 32 | task_command_args = ["task", "status.not:deleted"] 33 | 34 | task_command_args.append(f"scheduled.after:{self.scheduled_after}") 35 | task_command_args.append(f"scheduled.before:{self.scheduled_before}") 36 | 37 | if not self.show_completed: 38 | task_command_args.append(f"status.not:{self.show_completed}") 39 | 40 | self.backend = PatchedTaskWarrior( 41 | data_location=self.data_location, 42 | create=False, 43 | taskrc_location=self.taskrc_location, 44 | task_command=" ".join(task_command_args), 45 | ) 46 | 47 | self.schedule = Schedule( 48 | self.backend, 49 | scheduled_after=self.scheduled_after, 50 | scheduled_before=self.scheduled_before, 51 | ) 52 | 53 | def check_files(self): 54 | """Check if the required files, directories and settings are present.""" 55 | # Create a temporary taskwarrior instance to read the config 56 | taskwarrior = TaskWarrior( 57 | data_location=self.data_location, 58 | create=False, 59 | taskrc_location=self.taskrc_location, 60 | ) 61 | 62 | # Disable _forcecolor because it breaks tw config output 63 | taskwarrior.overrides.update({"_forcecolor": "off"}) 64 | 65 | # Check taskwarrior directory and taskrc 66 | if os.path.isdir(self.data_location) is False: 67 | raise TaskDirDoesNotExistError(".task directory not found") 68 | if os.path.isfile(self.taskrc_location) is False: 69 | raise TaskrcDoesNotExistError(".taskrc not found") 70 | 71 | # Check if required UDAs exist 72 | if taskwarrior.config.get("uda.estimate.type") is None: 73 | raise UDADoesNotExistError( 74 | ("uda.estimate.type does not exist " "in .taskrc") 75 | ) 76 | if taskwarrior.config.get("uda.estimate.label") is None: 77 | raise UDADoesNotExistError( 78 | ("uda.estimate.label does not exist " "in .taskrc") 79 | ) 80 | 81 | # Check sound file 82 | sound_file = self.home_dir + "/.taskschedule/hooks/drip.wav" 83 | if self.show_notifications and os.path.isfile(sound_file) is False: 84 | raise SoundDoesNotExistError( 85 | f"The specified sound file does not exist: {sound_file}" 86 | ) 87 | 88 | # Create user directory if it does not exist 89 | taskschedule_dir = self.home_dir + "/.taskschedule" 90 | hooks_directory = self.home_dir + "/.taskschedule/hooks" 91 | if not os.path.isdir(taskschedule_dir): 92 | os.mkdir(taskschedule_dir) 93 | if not os.path.isdir(hooks_directory): 94 | os.mkdir(hooks_directory) 95 | 96 | def parse_args(self, argv): 97 | parser = argparse.ArgumentParser( 98 | description="""Display a schedule report for taskwarrior.""" 99 | ) 100 | parser.add_argument( 101 | "-r", "--refresh", help="refresh every n seconds", type=int, default=1 102 | ) 103 | parser.add_argument( 104 | "--from", 105 | help="scheduled from date: ex. 'today', 'tomorrow'", 106 | type=str, 107 | dest="after", 108 | default="today-1s", 109 | ) 110 | parser.add_argument( 111 | "--to", 112 | "--until", 113 | help="scheduled until date: ex. 'today', 'tomorrow'", 114 | type=str, 115 | dest="before", 116 | default="tomorrow", 117 | ) 118 | parser.add_argument( 119 | "-d", 120 | "--data-location", 121 | help="""data location (e.g. ~/.task)""", 122 | type=str, 123 | dest="data_location", 124 | default=f"{self.home_dir}/.task", 125 | ) 126 | parser.add_argument( 127 | "-t", 128 | "--taskrc-location", 129 | help="""taskrc location (e.g. ~/.taskrc)""", 130 | type=str, 131 | dest="taskrc_location", 132 | default=f"{self.home_dir}/.taskrc", 133 | ) 134 | parser.add_argument( 135 | "-a", 136 | "--all", 137 | help="show all hours, even if empty", 138 | action="store_true", 139 | default=False, 140 | ) 141 | parser.add_argument( 142 | "-c", 143 | "--completed", 144 | help="hide completed tasks", 145 | action="store_false", 146 | default=True, 147 | ) 148 | parser.add_argument( 149 | "-p", 150 | "--project", 151 | help="hide project column", 152 | action="store_true", 153 | default=False, 154 | ) 155 | parser.add_argument( 156 | "--no-notifications", 157 | help="disable notifications", 158 | action="store_false", 159 | default=True, 160 | dest="notifications", 161 | ) 162 | args = parser.parse_args(argv) 163 | 164 | if args.before and not args.after or not args.before and args.after: 165 | print( 166 | "Error: Either both --until and --from or neither options must be used." 167 | ) 168 | sys.exit(1) 169 | 170 | self.data_location = args.data_location 171 | self.taskrc_location = args.taskrc_location 172 | 173 | # Parse schedule date range 174 | self.scheduled_after: datetime = calculate_datetime(args.after) 175 | self.scheduled_before: datetime = calculate_datetime(args.before) 176 | 177 | self.show_completed = args.completed 178 | self.hide_empty = not args.all 179 | self.hide_projects = args.project 180 | self.refresh_rate = args.refresh 181 | self.show_notifications = args.notifications 182 | 183 | def main(self): 184 | """Initialize the screen and notifier, and start the main loop of 185 | the interface.""" 186 | 187 | if self.show_notifications: 188 | self.notifier = Notifier(self.backend) 189 | else: 190 | self.notifier = None 191 | 192 | self.screen = Screen( 193 | self.schedule, 194 | scheduled_after=self.scheduled_after, 195 | scheduled_before=self.scheduled_before, 196 | hide_empty=self.hide_empty, 197 | hide_projects=self.hide_projects, 198 | ) 199 | 200 | try: 201 | self.run() 202 | except TaskDirDoesNotExistError as err: 203 | print("Error: {}".format(err)) 204 | sys.exit(1) 205 | except TaskrcDoesNotExistError as err: 206 | print("Error: {}".format(err)) 207 | sys.exit(1) 208 | except KeyboardInterrupt: 209 | self.screen.close() 210 | except ValueError as err: 211 | self.screen.close() 212 | print("Error: {}".format(err)) 213 | sys.exit(1) 214 | except UDADoesNotExistError as err: 215 | self.screen.close() 216 | print("Error: {}".format(err)) 217 | sys.exit(1) 218 | except SoundDoesNotExistError as err: 219 | self.screen.close() 220 | print("Error: {}".format(err)) 221 | sys.exit(1) 222 | else: 223 | try: 224 | self.screen.close() 225 | except curses_error as err: 226 | print(err.with_traceback) 227 | 228 | def run(self): 229 | """The main loop of the interface.""" 230 | 231 | filename = f"{self.data_location}/pending.data" 232 | cached_stamp = 0.0 233 | 234 | last_refresh_time = 0.0 235 | while True: 236 | key = self.screen.stdscr.getch() 237 | if key == 113: # q 238 | break 239 | elif key == 65 or key == 107: # Up / k 240 | self.screen.scroll(-1) 241 | last_refresh_time = time.time() 242 | elif key == 66 or key == 106: # Down / j 243 | self.screen.scroll(1) 244 | last_refresh_time = time.time() 245 | elif key == 54: # Page down 246 | max_y, max_x = self.screen.get_maxyx() 247 | self.screen.scroll(max_y - 4) 248 | last_refresh_time = time.time() 249 | elif key == 53: # Page up 250 | max_y, max_x = self.screen.get_maxyx() 251 | self.screen.scroll(-(max_y - 4)) 252 | last_refresh_time = time.time() 253 | elif key == KEY_RESIZE: 254 | last_refresh_time = time.time() 255 | self.screen.refresh_buffer() 256 | self.screen.draw() 257 | elif time.time() > last_refresh_time + self.refresh_rate: 258 | if self.notifier: 259 | self.notifier.send_notifications() 260 | 261 | # Redraw if task data has changed 262 | stamp = os.stat(filename).st_mtime 263 | if stamp != cached_stamp: 264 | cached_stamp = stamp 265 | self.schedule.clear_cache() 266 | self.screen.refresh_buffer() 267 | self.screen.draw() 268 | 269 | last_refresh_time = time.time() 270 | 271 | napms(1) 272 | 273 | if self.refresh_rate < 0: 274 | break 275 | -------------------------------------------------------------------------------- /tests-old/test_schedule.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,no-self-use 2 | 3 | import unittest 4 | import os 5 | import shutil 6 | import datetime 7 | 8 | from tasklib import TaskWarrior, Task 9 | 10 | from taskschedule.schedule import Schedule, ScheduledTask 11 | 12 | 13 | class ScheduleTest(unittest.TestCase): 14 | def setUp(self): 15 | self.taskrc_path = "tests/test_data/.taskrc" 16 | self.task_dir_path = "tests/test_data/.task" 17 | self.assertEqual(os.path.isdir(self.taskrc_path), False) 18 | self.assertEqual(os.path.isdir(self.task_dir_path), False) 19 | 20 | # Create a sample .taskrc 21 | with open(self.taskrc_path, "w+") as file: 22 | file.write("# User Defined Attributes\n") 23 | file.write("uda.estimate.type=duration\n") 24 | file.write("uda.estimate.label=Est\n") 25 | file.write("# User Defined Attributes\n") 26 | file.write("uda.tb_estimate.type=numeric\n") 27 | file.write("uda.tb_estimate.label=Est\n") 28 | file.write("uda.tb_real.type=numeric\n") 29 | file.write("uda.tb_real.label=Real\n") 30 | 31 | # Create a sample empty .task directory 32 | os.makedirs(self.task_dir_path) 33 | 34 | taskwarrior = TaskWarrior( 35 | data_location="tests/test_data/.task", 36 | create=True, 37 | taskrc_location="tests/test_data/.taskrc", 38 | ) 39 | Task( 40 | taskwarrior, 41 | description="test_yesterday", 42 | schedule="yesterday", 43 | estimate="20min", 44 | ).save() 45 | Task( 46 | taskwarrior, 47 | description="test_9:00_to_10:11", 48 | schedule="today+9hr", 49 | estimate="71min", 50 | ).save() 51 | Task( 52 | taskwarrior, 53 | description="test_14:00_to_16:00", 54 | schedule="today+14hr", 55 | estimate="2hr", 56 | ).save() 57 | Task( 58 | taskwarrior, 59 | description="test_16:10_to_16:34", 60 | schedule="today+16hr+10min", 61 | estimate="24min", 62 | ).save() 63 | Task( 64 | taskwarrior, 65 | description="test_tomorrow", 66 | schedule="tomorrow", 67 | estimate="24min", 68 | ).save() 69 | 70 | self.schedule = Schedule( 71 | tw_data_dir="tests/test_data/.task", 72 | tw_data_dir_create=False, 73 | taskrc_location="tests/test_data/.taskrc", 74 | ) 75 | 76 | def tearDown(self): 77 | try: 78 | os.remove(self.taskrc_path) 79 | except FileNotFoundError: 80 | pass 81 | 82 | try: 83 | shutil.rmtree(self.task_dir_path) 84 | except FileNotFoundError: 85 | pass 86 | 87 | def test_schedule_can_be_initialized(self): 88 | schedule = Schedule( 89 | tw_data_dir="tests/test_data/.task", 90 | tw_data_dir_create=False, 91 | taskrc_location="tests/test_data/.taskrc", 92 | ) 93 | assert schedule is not None 94 | 95 | # TODO Move to /tests/functional/ 96 | # def test_get_tasks_returns_correct_tasks(self): 97 | # self.schedule.load_tasks() 98 | 99 | # date_str = datetime.datetime.now().strftime('%Y-%m-%d') 100 | 101 | # task: ScheduledTask = self.schedule.tasks[0] 102 | # assert str(task['description']) == 'test_9:00_to_10:11' 103 | # assert str(task['scheduled'])[0:-6] == '{} 09:00:00'.format(date_str) 104 | # assert str(task.scheduled_end_time)[0:-6] == '{} 10:11:00'.format(date_str) 105 | 106 | # task = self.schedule.tasks[1] 107 | # assert str(task['description']) == 'test_14:00_to_16:00' 108 | # assert str(task['start'])[0:-6] == '{} 14:00:00'.format(date_str) 109 | # assert str(task.scheduled_end_time)[0:-6] == '{} 16:00:00'.format(date_str) 110 | 111 | # task = self.schedule.tasks[2] 112 | # assert str(task['description']) == 'test_16:10_to_16:34' 113 | # assert str(task['start'])[0:-6] == '{} 16:10:00'.format(date_str) 114 | # assert str(task.scheduled_end_time)[0:-6] == '{} 16:34:00'.format(date_str) 115 | 116 | def test_get_calculated_date_returns_correct_values(self): 117 | calculated = self.schedule.get_calculated_date("today").date() 118 | expected = datetime.datetime.now().date() 119 | self.assertEqual(calculated, expected) 120 | 121 | calculated = self.schedule.get_calculated_date("now+1day").date() 122 | expected = datetime.datetime.now().date() + datetime.timedelta(days=1) 123 | self.assertEqual(calculated, expected) 124 | 125 | calculated = self.schedule.get_calculated_date("now+1week").date() 126 | expected = datetime.datetime.now().date() + datetime.timedelta(days=7) 127 | self.assertEqual(calculated, expected) 128 | 129 | def test_get_timeslots_returns_correct_amount_of_days(self): 130 | schedule = Schedule( 131 | tw_data_dir="tests/test_data/.task", 132 | tw_data_dir_create=False, 133 | taskrc_location="tests/test_data/.taskrc", 134 | scheduled_before="tomorrow+3days", 135 | scheduled_after="tomorrow-3days", 136 | scheduled=None, 137 | ) 138 | output = schedule.get_time_slots() 139 | self.assertEqual(len(output), 7) 140 | 141 | schedule = Schedule( 142 | tw_data_dir="tests/test_data/.task", 143 | tw_data_dir_create=False, 144 | taskrc_location="tests/test_data/.taskrc", 145 | scheduled_before="today+15days", 146 | scheduled_after="today", 147 | scheduled=None, 148 | ) 149 | output = schedule.get_time_slots() 150 | self.assertEqual(len(output), 16) 151 | 152 | schedule = Schedule( 153 | tw_data_dir="tests/test_data/.task", 154 | tw_data_dir_create=False, 155 | taskrc_location="tests/test_data/.taskrc", 156 | scheduled_before=None, 157 | scheduled_after=None, 158 | scheduled="today", 159 | ) 160 | output = schedule.get_time_slots() 161 | self.assertEqual(len(output), 1) 162 | 163 | # def test_format_task_returns_correct_format(self): 164 | # self.schedule.get_tasks() 165 | 166 | # task = self.schedule.tasks[0] 167 | # assert self.schedule.format_task(task) == [9, '○', 2, '09:00-10:11', 168 | # 'test_9:00_to_10:11'] 169 | 170 | # task = self.schedule.tasks[1] 171 | # assert self.schedule.format_task(task) == [14, '○', 3, '14:00-16:00', 172 | # 'test_14:00_to_16:00'] 173 | 174 | # task = self.schedule.tasks[2] 175 | # assert self.schedule.format_task(task) == [16, '○', 4, '16:10-16:34', 176 | # 'test_16:10_to_16:34'] 177 | 178 | # def test_format_as_table_returns_correct_format(self): 179 | # expected_rows = [ 180 | # ' ID Time Description', 181 | # ' 0', 182 | # ' 1', 183 | # ' 2', 184 | # ' 3', 185 | # ' 4', 186 | # ' 5', 187 | # ' 6', 188 | # ' 7', 189 | # ' 8', 190 | # ' 9 ○ 2 09:00-10:11 test_9:00_to_10:11', 191 | # '10', 192 | # '11', 193 | # '12', 194 | # '13', 195 | # '14 ○ 3 14:00-16:00 test_14:00_to_16:00', 196 | # '15', 197 | # '16 ○ 4 16:10-16:34 test_16:10_to_16:34', 198 | # '17', 199 | # '18', 200 | # '19', 201 | # '20', 202 | # '21', 203 | # '22', 204 | # '23' 205 | # ] 206 | 207 | # self.schedule.get_tasks() 208 | # table = self.schedule.format_as_table(hide_empty=False) 209 | # rows = table.split('\n') 210 | 211 | # assert rows == expected_rows 212 | 213 | # def test_format_as_table_hide_empty_returns_correct_format(self): 214 | # expected_rows = [ 215 | # ' ID Time Description', 216 | # ' 8', 217 | # ' 9 ○ 2 09:00-10:11 test_9:00_to_10:11', 218 | # '10', 219 | # '11', 220 | # '12', 221 | # '13', 222 | # '14 ○ 3 14:00-16:00 test_14:00_to_16:00', 223 | # '15', 224 | # '16 ○ 4 16:10-16:34 test_16:10_to_16:34', 225 | # '17' 226 | # ] 227 | 228 | # self.schedule.get_tasks() 229 | # table = self.schedule.format_as_table(hide_empty=True) 230 | # rows = table.split('\n') 231 | 232 | # assert rows == expected_rows 233 | 234 | # def test_cli_returns_0(self): 235 | # process = subprocess.run(['python3 taskschedule/taskschedule.py'], 236 | # shell=True, 237 | # timeout=10, 238 | # stdout=subprocess.PIPE, 239 | # stderr=subprocess.PIPE, check=True) 240 | # assert process.returncode == 0 241 | 242 | def test_get_time_slots(self): 243 | schedule = Schedule( 244 | tw_data_dir="tests/test_data/.task", 245 | tw_data_dir_create=False, 246 | taskrc_location="tests/test_data/.taskrc", 247 | ) 248 | schedule.get_time_slots() 249 | 250 | def test_align_matrix(self): 251 | rows = [ 252 | ["", "", "ID", "Time", "Description"], 253 | ["8", "", "", "", ""], 254 | ["9", "○", "2", "09:00-10:11", "test_9:00_to_10:11"], 255 | ["10", "", "", "", ""], 256 | ["11", "", "", "", ""], 257 | ["14", "○", "654", "12:00", "test_12:00"], 258 | ] 259 | returned_rows = self.schedule.align_matrix(rows) 260 | 261 | expected_rows = [ 262 | [" ", " ", "ID ", "Time ", "Description "], 263 | ["8 ", " ", " ", " ", " "], 264 | ["9 ", "○", "2 ", "09:00-10:11", "test_9:00_to_10:11"], 265 | ["10", " ", " ", " ", " "], 266 | ["11", " ", " ", " ", " "], 267 | ["14", "○", "654", "12:00 ", "test_12:00 "], 268 | ] 269 | 270 | assert returned_rows == expected_rows 271 | 272 | 273 | if __name__ == "__main__": 274 | unittest.main() 275 | -------------------------------------------------------------------------------- /taskschedule/screen.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import time 3 | from datetime import datetime 4 | from typing import List, Tuple 5 | 6 | from taskschedule.config_parser import ConfigParser 7 | from taskschedule.hooks import run_hooks 8 | from taskschedule.schedule import Schedule 9 | from taskschedule.scheduled_task import ScheduledTask 10 | from taskschedule.utils import calculate_datetime 11 | 12 | BufferType = List[Tuple[int, int, str, int]] 13 | 14 | 15 | class Screen: 16 | """This class handles the rendering of the schedule.""" 17 | 18 | def __init__( 19 | self, 20 | schedule: Schedule, 21 | scheduled_after: datetime, 22 | scheduled_before: datetime, 23 | hide_projects=False, 24 | hide_empty=False, 25 | ): 26 | self.config = ConfigParser().config() 27 | self.scheduled_before = scheduled_before 28 | self.scheduled_after = scheduled_after 29 | 30 | self.stdscr = curses.initscr() 31 | self.stdscr.nodelay(True) 32 | self.stdscr.scrollok(True) 33 | self.stdscr.idlok(True) 34 | curses.noecho() 35 | 36 | self.pad = curses.newpad(800, 800) 37 | self.scroll_level = 0 38 | 39 | self.hide_projects = hide_projects 40 | self.hide_empty = hide_empty 41 | self.buffer: BufferType = [] 42 | self.prev_buffer: BufferType = [] 43 | self.init_colors() 44 | 45 | self.current_task = None 46 | 47 | self.schedule = schedule 48 | 49 | def close(self): 50 | """Close the curses screen.""" 51 | curses.endwin() 52 | 53 | def init_colors(self): 54 | """Initialize the colors.""" 55 | curses.curs_set(0) 56 | curses.start_color() 57 | if curses.can_change_color(): 58 | curses.init_pair(1, 20, curses.COLOR_BLACK) 59 | curses.init_pair(2, 8, 0) 60 | curses.init_pair(3, 20, 234) 61 | curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_BLACK) 62 | curses.init_pair(5, curses.COLOR_GREEN, curses.COLOR_BLACK) 63 | curses.init_pair(6, 19, 234) 64 | curses.init_pair(7, 19, 0) 65 | curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_GREEN) 66 | curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_BLACK) 67 | curses.init_pair(10, curses.COLOR_GREEN, curses.COLOR_BLACK) 68 | curses.init_pair(11, curses.COLOR_YELLOW, curses.COLOR_BLACK) 69 | curses.init_pair(12, curses.COLOR_YELLOW, 234) 70 | curses.init_pair(13, curses.COLOR_GREEN, 234) 71 | curses.init_pair(14, 8, 0) 72 | curses.init_pair(15, curses.COLOR_GREEN, curses.COLOR_BLACK) 73 | curses.init_pair(16, 20, curses.COLOR_BLACK) 74 | curses.init_pair(17, curses.COLOR_BLUE, curses.COLOR_BLACK) 75 | 76 | # pylint: disable=invalid-name 77 | self.COLOR_DEFAULT = curses.color_pair(1) 78 | self.COLOR_DEFAULT_ALTERNATE = curses.color_pair(3) 79 | self.COLOR_HEADER = curses.color_pair(4) | curses.A_UNDERLINE 80 | self.COLOR_HOUR = curses.color_pair(2) 81 | self.COLOR_HOUR_CURRENT = curses.color_pair(5) 82 | self.COLOR_ACTIVE = curses.color_pair(8) 83 | self.COLOR_SHOULD_BE_ACTIVE = curses.color_pair(10) 84 | self.COLOR_SHOULD_BE_ACTIVE_ALTERNATE = curses.color_pair(13) 85 | self.COLOR_OVERDUE = curses.color_pair(11) 86 | self.COLOR_OVERDUE_ALTERNATE = curses.color_pair(12) 87 | self.COLOR_COMPLETED = curses.color_pair(7) 88 | self.COLOR_COMPLETED_ALTERNATE = curses.color_pair(6) 89 | self.COLOR_GLYPH = curses.color_pair(9) 90 | self.COLOR_DIVIDER = curses.color_pair(14) 91 | self.COLOR_DIVIDER_ACTIVE = curses.color_pair(15) 92 | self.COLOR_DIVIDER_TEXT = curses.color_pair(16) 93 | self.COLOR_BLUE = curses.color_pair(17) 94 | else: 95 | # pylint: disable=invalid-name 96 | self.COLOR_DEFAULT = curses.color_pair(0) 97 | self.COLOR_DEFAULT_ALTERNATE = curses.color_pair(0) 98 | self.COLOR_HEADER = curses.color_pair(0) 99 | self.COLOR_HOUR = curses.color_pair(0) 100 | self.COLOR_HOUR_CURRENT = curses.color_pair(0) 101 | self.COLOR_ACTIVE = curses.color_pair(0) 102 | self.COLOR_SHOULD_BE_ACTIVE = curses.color_pair(0) 103 | self.COLOR_SHOULD_BE_ACTIVE_ALTERNATE = curses.color_pair(0) 104 | self.COLOR_OVERDUE = curses.color_pair(0) 105 | self.COLOR_OVERDUE_ALTERNATE = curses.color_pair(0) 106 | self.COLOR_COMPLETED = curses.color_pair(0) 107 | self.COLOR_COMPLETED_ALTERNATE = curses.color_pair(0) 108 | self.COLOR_GLYPH = curses.color_pair(0) 109 | self.COLOR_DIVIDER = curses.color_pair(0) 110 | self.COLOR_DIVIDER_ACTIVE = curses.color_pair(0) 111 | self.COLOR_DIVIDER_TEXT = curses.color_pair(0) 112 | self.COLOR_BLUE = curses.color_pair(0) 113 | 114 | def get_task_color(self, task: ScheduledTask, alternate: bool) -> int: 115 | """Return the color for the given task.""" 116 | color = None 117 | 118 | if task.completed: 119 | if alternate: 120 | color = self.COLOR_COMPLETED_ALTERNATE 121 | else: 122 | color = self.COLOR_COMPLETED 123 | elif task.active: 124 | color = self.COLOR_ACTIVE 125 | elif task.should_be_active: 126 | if alternate: 127 | color = self.COLOR_SHOULD_BE_ACTIVE_ALTERNATE 128 | else: 129 | color = self.COLOR_SHOULD_BE_ACTIVE 130 | elif task.overdue and not task.completed: 131 | if alternate: 132 | color = self.COLOR_OVERDUE_ALTERNATE 133 | else: 134 | color = self.COLOR_OVERDUE 135 | else: 136 | if alternate: 137 | color = self.COLOR_DEFAULT_ALTERNATE 138 | else: 139 | color = self.COLOR_DEFAULT 140 | 141 | return color 142 | 143 | def get_maxyx(self) -> Tuple[int, int]: 144 | """Return the screen's maximum height and width.""" 145 | max_y, max_x = self.stdscr.getmaxyx() 146 | return max_y, max_x 147 | 148 | def scroll(self, lines: int): 149 | """Scroll the curses pad by n lines.""" 150 | max_y, max_x = self.get_maxyx() 151 | self.scroll_level += lines 152 | if self.scroll_level < 0: 153 | self.scroll_level = 0 154 | 155 | self.stdscr.refresh() 156 | self.pad.refresh(self.scroll_level + 1, 0, 1, 0, max_y - 3, max_x - 1) 157 | 158 | def prerender_footnote(self) -> str: 159 | """Pre-render the footnote.""" 160 | count = len(self.schedule.tasks) 161 | date_format = "%a %d %b %Y" 162 | before = self.scheduled_before.strftime(date_format) 163 | after = self.scheduled_after.strftime(date_format) 164 | footnote = f"{count} tasks - from {after} until {before}" 165 | 166 | return footnote 167 | 168 | def draw_footnote(self): 169 | """Draw the footnote at the bottom of the screen.""" 170 | max_y, max_x = self.get_maxyx() 171 | 172 | # Draw timebox status 173 | # timeboxed_task: ScheduledTask = self.schedule.get_active_timeboxed_task() 174 | # if timeboxed_task: 175 | # active_start_time: datetime = timeboxed_task["start"] 176 | # active_start_time.replace(tzinfo=None) 177 | # current_time = datetime.now() 178 | # active_time = current_time.timestamp() - active_start_time.timestamp() 179 | # max_duration = timedelta( 180 | # minutes=self.config["timebox"]["time"] 181 | # ).total_seconds() 182 | # progress: float = (active_time / max_duration) * 100 183 | 184 | # if progress > 99: 185 | # self.schedule.stop_active_timeboxed_task() 186 | # real = timeboxed_task["tb_real"] 187 | # if real: 188 | # timeboxed_task["tb_real"] = int(real) + 1 189 | # else: 190 | # timeboxed_task["tb_real"] = 1 191 | # timeboxed_task.save() 192 | # self.stdscr.move(max_y - 2, 0) 193 | # self.stdscr.clrtoeol() 194 | # else: 195 | # progress_done: int = math.ceil(progress / 4) 196 | # progress_remaining: int = int((100 - progress) / 4) 197 | 198 | # # Draw task id 199 | # task_id = timeboxed_task["id"] 200 | # task_id_str = f"task {task_id}: " 201 | # self.stdscr.addstr(max_y - 2, 1, task_id_str, self.COLOR_DEFAULT) 202 | 203 | # # Draw completed blocks 204 | # completed_blocks: str = self.config["timebox"][ 205 | # "progress_done_glyph" 206 | # ] * progress_done 207 | # self.stdscr.addstr( 208 | # max_y - 2, 1 + len(task_id_str), completed_blocks, self.COLOR_BLUE 209 | # ) 210 | 211 | # # Draw pending blocks 212 | # pending_blocks: str = self.config["timebox"][ 213 | # "progress_pending_glyph" 214 | # ] * progress_remaining 215 | # self.stdscr.addstr( 216 | # max_y - 2, 217 | # 1 + len(task_id_str) + len(completed_blocks), 218 | # pending_blocks, 219 | # self.COLOR_HOUR, 220 | # ) 221 | 222 | # # Draw time 223 | # time1 = timedelta(seconds=active_time) 224 | # time1_fmt = str(time1).split(".", 2)[0] 225 | # time1_minutes = str(time1_fmt).split(":", 2)[1] 226 | # time1_seconds = str(time1_fmt).split(":", 2)[2] 227 | 228 | # time2 = timedelta(minutes=self.config["timebox"]["time"]) 229 | # time2_fmt = str(time2).split(".", 2)[0] 230 | # time2_minutes = str(time2_fmt).split(":", 2)[1] 231 | # time2_seconds = str(time2_fmt).split(":", 2)[2] 232 | 233 | # progress_time: str = f"{time1_minutes}:{time1_seconds}/{time2_minutes}:{time2_seconds}" 234 | # self.stdscr.addstr( 235 | # max_y - 2, 236 | # 1 237 | # + len(task_id_str) 238 | # + len(completed_blocks) 239 | # + len(pending_blocks) 240 | # + 1, 241 | # progress_time, 242 | # self.COLOR_DEFAULT, 243 | # ) 244 | # else: 245 | # self.stdscr.addstr(max_y - 2, 1, "no active timebox", self.COLOR_DEFAULT) 246 | 247 | # estimated_count = self.schedule.get_timebox_estimate_count() 248 | # real_count = self.schedule.get_timebox_real_count() 249 | 250 | # footnote_timebox_right: str = f"total: {real_count} / {estimated_count}" 251 | 252 | # self.stdscr.addstr( 253 | # max_y - 2, 254 | # max_x - len(footnote_timebox_right) - 1, 255 | # footnote_timebox_right, 256 | # self.COLOR_DEFAULT, 257 | # ) 258 | 259 | # Draw footnote 260 | footnote = self.prerender_footnote() 261 | self.stdscr.addstr(max_y - 1, 1, footnote, self.COLOR_DEFAULT) 262 | 263 | def draw(self, force=False): 264 | """Draw the current buffer.""" 265 | max_y, max_x = self.get_maxyx() 266 | if not self.buffer: 267 | self.stdscr.clear() 268 | self.stdscr.addstr(0, 0, "No tasks to display.", self.COLOR_DEFAULT) 269 | self.draw_footnote() 270 | self.stdscr.refresh() 271 | else: 272 | if force or self.prev_buffer != self.buffer: 273 | self.pad.clear() 274 | if self.prev_buffer > self.buffer: 275 | self.stdscr.clear() 276 | self.stdscr.refresh() 277 | 278 | for line, offset, string, color in self.buffer: 279 | if line == 0: 280 | self.stdscr.addstr(line, offset, string, color) 281 | else: 282 | self.pad.addstr(line, offset, string, color) 283 | 284 | self.draw_footnote() 285 | self.pad.refresh(self.scroll_level + 1, 0, 1, 0, max_y - 3, max_x - 1) 286 | 287 | def render_timeboxes(self, task: ScheduledTask, color: int) -> List[dict]: 288 | """Render a task's timebox column.""" 289 | 290 | timeboxes: List[dict] = [] 291 | real = 0 292 | if task["tb_real"]: 293 | real = task["tb_real"] 294 | for i in range(task["tb_real"]): 295 | if i >= task["tb_estimate"]: 296 | timeboxes.append( 297 | { 298 | "char": self.config["timebox"]["underestimated_glyph"], 299 | "color": color, 300 | } 301 | ) 302 | else: 303 | timeboxes.append( 304 | {"char": self.config["timebox"]["done_glyph"], "color": color} 305 | ) 306 | if task["tb_estimate"]: 307 | for i in range(task["tb_estimate"] - real): 308 | timeboxes.append( 309 | {"char": self.config["timebox"]["pending_glyph"], "color": color} 310 | ) 311 | 312 | return timeboxes 313 | 314 | def prerender_headers(self) -> BufferType: 315 | """Pre-render the headers.""" 316 | 317 | header_buffer: BufferType = [] 318 | 319 | # Determine offsets 320 | max_y, max_x = self.get_maxyx() 321 | offsets = self.schedule.get_column_offsets() 322 | max_project_column_length = round(max_x / 8) 323 | if offsets[5] - offsets[4] > max_project_column_length: 324 | offsets[5] = offsets[4] + max_project_column_length 325 | 326 | # Draw headers 327 | headers = ["", "", "ID", "Time", "Timeboxes", "Project", "Description"] 328 | column_lengths = [2, 1] 329 | column_lengths.append(self.schedule.get_max_length("id")) 330 | column_lengths.append(11) 331 | column_lengths.append(9) 332 | column_lengths.append(max_project_column_length - 1) 333 | column_lengths.append(self.schedule.get_max_length("description")) 334 | 335 | for i, header in enumerate(headers): 336 | try: 337 | extra_length = column_lengths[i] - len(header) 338 | headers[i] += " " * extra_length 339 | except IndexError: 340 | pass 341 | 342 | header_buffer.append((0, offsets[1], headers[2], self.COLOR_HEADER)) 343 | header_buffer.append((0, offsets[2], headers[3], self.COLOR_HEADER)) 344 | header_buffer.append((0, offsets[3], headers[4], self.COLOR_HEADER)) 345 | header_buffer.append((0, offsets[4], headers[5], self.COLOR_HEADER)) 346 | 347 | if not self.hide_projects: 348 | header_buffer.append((0, offsets[5], headers[6], self.COLOR_HEADER)) 349 | 350 | return header_buffer 351 | 352 | def prerender_divider(self, day: str, current_line: int) -> BufferType: 353 | max_y, max_x = self.get_maxyx() 354 | offsets = self.schedule.get_column_offsets() 355 | divider_pt1 = "─" * (offsets[2] - 1) 356 | 357 | divider_buffer: BufferType = [] 358 | divider_buffer.append((current_line, 0, divider_pt1, self.COLOR_DIVIDER)) 359 | 360 | date_format = "%a %d %b %Y" 361 | formatted_date = calculate_datetime(day).strftime(date_format) 362 | divider_pt2 = " " + formatted_date + " " 363 | if day == datetime.now().date().isoformat(): 364 | divider_buffer.append( 365 | (current_line, len(divider_pt1), divider_pt2, self.COLOR_DIVIDER_ACTIVE) 366 | ) 367 | else: 368 | divider_buffer.append( 369 | (current_line, len(divider_pt1), divider_pt2, self.COLOR_DIVIDER_TEXT) 370 | ) 371 | 372 | divider_pt3 = "─" * (max_x - (len(divider_pt1) + len(divider_pt2))) 373 | divider_buffer.append( 374 | ( 375 | current_line, 376 | len(divider_pt1) + len(divider_pt2), 377 | divider_pt3, 378 | self.COLOR_DIVIDER, 379 | ) 380 | ) 381 | 382 | return divider_buffer 383 | 384 | def run_hook(self): 385 | # TODO This does not belong here, move it somewhere appropriate 386 | current_task = None 387 | for task_ in self.schedule.tasks: 388 | if task_.should_be_active: 389 | current_task = task_ 390 | 391 | if current_task is not None: 392 | if self.current_task is None: 393 | self.current_task = current_task 394 | if current_task["id"] != 0: 395 | run_hooks("on-progress", data=current_task.as_dict()) 396 | else: 397 | if self.current_task["id"] != current_task["id"]: 398 | self.current_task = current_task 399 | if current_task["id"] != 0: 400 | run_hooks("on-progress", data=current_task.as_dict()) 401 | 402 | def prerender_empty_line( 403 | self, alternate: bool, current_line: int, hour: int, day: str 404 | ) -> BufferType: 405 | max_y, max_x = self.get_maxyx() 406 | 407 | _buffer: BufferType = [] 408 | 409 | if alternate: 410 | color = self.COLOR_DEFAULT_ALTERNATE 411 | else: 412 | color = self.COLOR_DEFAULT 413 | 414 | # Fill line to screen length 415 | _buffer.append((current_line, 5, " " * (max_x - 5), color)) 416 | 417 | # Draw hour column, highlight current hour 418 | current_hour = time.localtime().tm_hour 419 | if int(hour) == current_hour and day == datetime.now().date().isoformat(): 420 | _buffer.append((current_line, 0, str(hour), self.COLOR_HOUR_CURRENT)) 421 | else: 422 | _buffer.append((current_line, 0, str(hour), self.COLOR_HOUR)) 423 | 424 | return _buffer 425 | 426 | def prerender_task( 427 | self, 428 | task_num: int, 429 | task: ScheduledTask, 430 | alternate: bool, 431 | hour: int, 432 | current_line: int, 433 | day: str, 434 | ) -> BufferType: 435 | """Pre-render a task.""" 436 | max_y, max_x = self.get_maxyx() 437 | offsets = self.schedule.get_column_offsets() 438 | 439 | _buffer: BufferType = [] 440 | 441 | color = self.get_task_color(task, alternate) 442 | 443 | # Only draw hour once for multiple tasks 444 | if task_num == 0: 445 | hour_ = str(hour) 446 | else: 447 | hour_ = "" 448 | 449 | # Draw hour column, highlight current hour 450 | current_hour = time.localtime().tm_hour 451 | if hour_ != "": 452 | if int(hour) == current_hour and day == datetime.now().date().isoformat(): 453 | _buffer.append((current_line, 0, hour_, self.COLOR_HOUR_CURRENT)) 454 | else: 455 | _buffer.append((current_line, 0, hour_, self.COLOR_HOUR)) 456 | 457 | # Fill line to screen length 458 | _buffer.append((current_line, 5, " " * (max_x - 5), color)) 459 | 460 | # Draw glyph column 461 | _buffer.append((current_line, 3, task.glyph, self.COLOR_GLYPH)) 462 | 463 | # Draw task id column 464 | if task["id"] != 0: 465 | _buffer.append((current_line, 5, str(task["id"]), color)) 466 | 467 | # Draw the time column. 468 | # Do not show the start time if the task is not scheduled at a 469 | # specific time, so the column is not cluttered with tasks 470 | # having start times as 00:00. 471 | start_dt = task.scheduled_start_datetime 472 | if start_dt: 473 | if not task.has_scheduled_time: 474 | if task.scheduled_end_datetime: 475 | end_time = "{}".format( 476 | task.scheduled_end_datetime.strftime("%H:%M") 477 | ) 478 | formatted_time = " {}".format(end_time) 479 | else: 480 | formatted_time = "" 481 | else: 482 | start_time = "{}".format(start_dt.strftime("%H:%M")) 483 | if task.scheduled_end_datetime is None: 484 | formatted_time = start_time 485 | else: 486 | end_time = "{}".format( 487 | task.scheduled_end_datetime.strftime("%H:%M") 488 | ) 489 | formatted_time = "{}-{}".format(start_time, end_time) 490 | else: 491 | formatted_time = "" 492 | 493 | _buffer.append((current_line, offsets[2], formatted_time, color)) 494 | 495 | # Draw timeboxes column 496 | timeboxes = self.render_timeboxes(task, color) 497 | for i, timebox in enumerate(timeboxes): 498 | _buffer.append( 499 | ( 500 | current_line, 501 | offsets[3] + i, 502 | timebox.get("char"), 503 | timebox.get("color"), 504 | ) 505 | ) 506 | 507 | # Optionally draw project column 508 | offset = 0 509 | if not self.hide_projects: 510 | if task["project"] is None: 511 | project = "" 512 | else: 513 | max_length = offsets[5] - offsets[4] - 1 514 | project = task["project"][0:max_length] 515 | 516 | _buffer.append((current_line, offsets[4], project, color)) 517 | offset = offsets[5] 518 | else: 519 | offset = offsets[4] 520 | 521 | # Draw description column 522 | description = task["description"][0 : max_x - offset] 523 | _buffer.append((current_line, offset, description, color)) 524 | 525 | return _buffer 526 | 527 | def refresh_buffer(self): 528 | """Refresh the buffer.""" 529 | max_y, max_x = self.get_maxyx() 530 | self.prev_buffer = self.buffer 531 | self.buffer = [] 532 | 533 | tasks = self.schedule.tasks 534 | 535 | if not self.schedule.tasks: 536 | return 537 | 538 | # Run on-progress hook 539 | self.run_hook() 540 | 541 | # Add the headers to the buffer 542 | header_buffer = self.prerender_headers() 543 | for header in header_buffer: 544 | self.buffer.append(header) 545 | 546 | # Draw schedule 547 | alternate = True 548 | current_line = 1 549 | 550 | # TODO Hide empty hours again 551 | # if self.hide_empty: 552 | # first_task = self.schedule.tasks[0].start 553 | # first_hour = first_task.hour 554 | # last_task = self.schedule.tasks[-1].start 555 | # last_hour = last_task.hour 556 | # else: 557 | # first_hour = 0 558 | # last_hour = 23 559 | 560 | time_slots = self.schedule.get_time_slots() 561 | for day in time_slots: 562 | 563 | # Draw divider if day has tasks 564 | day_has_tasks = False 565 | for hour in time_slots[day]: 566 | tasks = time_slots[day][hour] 567 | if tasks: 568 | day_has_tasks = True 569 | 570 | if day_has_tasks or not self.hide_empty: 571 | divider_buffer = self.prerender_divider(day, current_line) 572 | for divider_part in divider_buffer: 573 | self.buffer.append(divider_part) 574 | 575 | current_line += 1 576 | alternate = False 577 | 578 | for hour in time_slots[day]: 579 | tasks = time_slots[day][hour] 580 | if not tasks and not self.hide_empty: 581 | empty_line_buffer = self.prerender_empty_line( 582 | alternate, current_line, hour, day 583 | ) 584 | for part in empty_line_buffer: 585 | self.buffer.append(part) 586 | 587 | current_line += 1 588 | alternate = not alternate 589 | 590 | task: ScheduledTask 591 | for task_num, task in enumerate(tasks): 592 | task_buffer = self.prerender_task( 593 | task_num, task, alternate, hour, current_line, day 594 | ) 595 | for part in task_buffer: 596 | self.buffer.append(part) 597 | 598 | current_line += 1 599 | alternate = not alternate 600 | --------------------------------------------------------------------------------