diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c05ecb5..78f0f00 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -17,7 +17,7 @@ jobs: continue-on-error: true outputs: pytestOutput: ${{ steps.unit-tests.outputs.test }} - mypyOutput: ${{ steps.type-checks.outputs.test }} + pyrightOutput: ${{ steps.type-checks.outputs.test }} strategy: matrix: python-version: ["3.9", "3.10", "3.11"] @@ -59,4 +59,4 @@ jobs: - name: Type checking id: type-checks run: | - poetry run mypy --namespace-packages -p today + poetry run pyright diff --git a/README.md b/README.md index 0236c1f..04e2035 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The task files are the only source of truth! `today` is a read-only utility to display what's planned for today. To mark a task complete, edit `tasks.md` and tick its Markdown checkbox. -## Detailed Docs +## Docs ### Task Files @@ -62,16 +62,40 @@ Ideally each project should be tightly scoped and not drag on forever. - You can save Markdown files on disk in any way you want. Add nested folders to encode hierarchy. - You can group tasks within a task file any way you want. Add nested Markdown headings to encode hierarchy. -### Task Definitions +### Defining Tasks Tasks are defined with a list item that starts with a Markdown checkbox. +Mark a task as completed by checking its checkbox. -A task can have a created, reminder, due, and finished date by placing it in square brackets with the prefix `c:`, `r:`, `d:`, or `f:`. +```markdown +- [x] A completed task +- [ ] An example task +``` + +#### Task Description + +You can add a description for a task underneath the task title. +It can consist of any Markdown you want (*except headings*). -- The date is in `month`/`day`/`year` format -- `t` is a shorthand date for today. For example, if a task should be due today, use: `[d:t]` +#### Task Attributes -You can add a description for a task underneath the task title. It can consist of any Markdown you want (*except headings*). +Task attributes are written in the same line as the task title. + +- Date attributes + - Created date: `[c:]` + - Reminder date: `[r]` + - Due date: `[d:]` + - Finished date: `[f:]` + - Date is in `month`/`day`/`year` format + - `t` is a shorthand date for today + - For example, if a task should be due today, write `[d:t]` +- Assignment attribute + - `[@]` assigns a task to the given `username` +- Priority attribute + - `[!0]` sets the priority of a task to `0`. `0` is the highest priority, followed by `1`, `2`, ... + - Tasks without a priority attribute are printed separately from tasks marked with a priority + +#### Subtasks Subtasks are specified with a nested list of checkboxes under the main task. @@ -79,9 +103,7 @@ Subtasks are specified with a nested list of checkboxes under the main task. - If the main task has a created/reminder/due date, it will apply for all subtasks automatically, unless otherwise specified - Only one level of subtasks is supported -A task can be marked complete just by checking its checkbox. You can optionally specify a completion time with a finish `f` date. - -Here is a complete example: +### Example ```markdown - [x] Pay the electricity bill [d:t] [f:2/20/2023] @@ -90,7 +112,7 @@ Here is a complete example: - [ ] Wipe the countertops - [ ] Throw the trash -Any text here will be part of the main task's description. +Any text here will be part of the main task's ("Home Cleaning") description. - Some bullets - A nested bullet diff --git a/example/chores.md b/example/chores.md index 273a110..e31776f 100644 --- a/example/chores.md +++ b/example/chores.md @@ -3,7 +3,7 @@ ## Cleaning *italics* - [ ] Clean the *countertops* [d:1/11/2022] -- [ ] ***Wash my car*** [d:1/10/2022] +- [ ] ***Wash my car*** [d:1/10/2022] [!0] - [x] Sweep the floors [d:1/5/2022] [f:1/4/2022] - [ ] `Another task` [d:t] diff --git a/poetry.lock b/poetry.lock index c31fb4e..af420b5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -83,66 +83,30 @@ files = [ ] [[package]] -name = "mypy" -version = "0.991" -description = "Optional static typing for Python" +name = "more-itertools" +version = "10.2.0" +description = "More routines for operating on iterables, beyond itertools" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mypy-0.991-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab"}, - {file = "mypy-0.991-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d"}, - {file = "mypy-0.991-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6"}, - {file = "mypy-0.991-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb"}, - {file = "mypy-0.991-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305"}, - {file = "mypy-0.991-cp310-cp310-win_amd64.whl", hash = "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c"}, - {file = "mypy-0.991-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372"}, - {file = "mypy-0.991-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f"}, - {file = "mypy-0.991-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33"}, - {file = "mypy-0.991-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05"}, - {file = "mypy-0.991-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad"}, - {file = "mypy-0.991-cp311-cp311-win_amd64.whl", hash = "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297"}, - {file = "mypy-0.991-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813"}, - {file = "mypy-0.991-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711"}, - {file = "mypy-0.991-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd"}, - {file = "mypy-0.991-cp37-cp37m-win_amd64.whl", hash = "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef"}, - {file = "mypy-0.991-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a"}, - {file = "mypy-0.991-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93"}, - {file = "mypy-0.991-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf"}, - {file = "mypy-0.991-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135"}, - {file = "mypy-0.991-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70"}, - {file = "mypy-0.991-cp38-cp38-win_amd64.whl", hash = "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243"}, - {file = "mypy-0.991-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d"}, - {file = "mypy-0.991-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5"}, - {file = "mypy-0.991-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3"}, - {file = "mypy-0.991-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648"}, - {file = "mypy-0.991-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476"}, - {file = "mypy-0.991-cp39-cp39-win_amd64.whl", hash = "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461"}, - {file = "mypy-0.991-py3-none-any.whl", hash = "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb"}, - {file = "mypy-0.991.tar.gz", hash = "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06"}, + {file = "more-itertools-10.2.0.tar.gz", hash = "sha256:8fccb480c43d3e99a00087634c06dd02b0d50fbf088b380de5a41a015ec239e1"}, + {file = "more_itertools-10.2.0-py3-none-any.whl", hash = "sha256:686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684"}, ] -[package.dependencies] -mypy-extensions = ">=0.4.3" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=3.10" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -install-types = ["pip"] -python2 = ["typed-ast (>=1.4.0,<2)"] -reports = ["lxml"] - [[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" optional = false -python-versions = ">=3.5" +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, ] +[package.dependencies] +setuptools = "*" + [[package]] name = "packaging" version = "24.0" @@ -294,15 +258,33 @@ files = [ plugins = ["importlib-metadata"] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyright" +version = "1.1.354" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyright-1.1.354-py3-none-any.whl", hash = "sha256:f28d61ae8ae035fc52ded1070e8d9e786051a26a4127bbd7a4ba0399b81b37b5"}, + {file = "pyright-1.1.354.tar.gz", hash = "sha256:b1070dc774ff2e79eb0523fe87f4ba9a90550de7e4b030a2bc9e031864029a1f"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" + +[package.extras] +all = ["twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] + [[package]] name = "pytest" -version = "7.4.4" +version = "8.1.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, ] [package.dependencies] @@ -310,11 +292,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +pluggy = ">=1.4,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "rich" @@ -334,6 +316,22 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "setuptools" +version = "69.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, + {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "tomli" version = "2.0.1" @@ -359,4 +357,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "dfdec76d39d0f469be30b56cc3f7f3f6c5785db2f07525fdbc399e1457d88a60" +content-hash = "1af5bf01f5a1421702bb0d4b34812a95f8ae950edf6a62f5343fb1e43227e3ef" diff --git a/pyproject.toml b/pyproject.toml index 6d25d43..2d4e0d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,10 +18,11 @@ start = "today.scripts.start:main" python = "^3.9" rich = "^13.7" pydantic = "^2.6.4" +more-itertools = "^10.2" [tool.poetry.dev-dependencies] -pytest = "^7.1" -mypy = "^0.991" +pytest = "^8.1" +pyright = "^1.1" [[tool.poetry.source]] name = "PyPI" diff --git a/tests/test_parser.py b/tests/test_parser.py index 262ebde..997922e 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,11 +1,20 @@ import pytest from datetime import date -from today.task import Task -from today.parser import parse_heading, handle_headings_stack, parse_markdown, extract_date_defns,\ - parse_task_title, Heading +from today.task import DateAttribute, Task, TaskAttributes, date_relative_to_today +from today.parser import ( + parse_heading, + handle_headings_stack, + parse_markdown, + extract_task_attrs, + parse_task_title, + Heading, +) + class TestParser: + today = date.today() + def test_parse_heading(self) -> None: assert parse_heading("# Title") == Heading(1, "Title") assert parse_heading("### **title 2**") == Heading(3, "**title 2**") @@ -19,8 +28,7 @@ def test_handle_headings_stack(self) -> None: # Test deepening hierarchy assert handle_headings_stack([], "# h1") == ["h1"] - assert handle_headings_stack(['h1', 'h2'], "### h3") == \ - ["h1", "h2", "h3"] + assert handle_headings_stack(["h1", "h2"], "### h3") == ["h1", "h2", "h3"] with pytest.raises(ValueError): handle_headings_stack(["h1", "h2"], "#### h4") @@ -29,34 +37,87 @@ def test_handle_headings_stack(self) -> None: # Test pulling out of hierarchy assert handle_headings_stack(["h1", "h2"], "# h1prime") == ["h1prime"] - assert handle_headings_stack(["h1", "h2", "h3"], "## h2prime") == ["h1", "h2prime"] - assert handle_headings_stack(["h1", "h2", "h3", "h4"], "## h2prime") == ["h1", "h2prime"] - assert handle_headings_stack(["h1", "h2", "h3", "h4"], "# h1prime") == ["h1prime"] + assert handle_headings_stack(["h1", "h2", "h3"], "## h2prime") == [ + "h1", + "h2prime", + ] + assert handle_headings_stack(["h1", "h2", "h3", "h4"], "## h2prime") == [ + "h1", + "h2prime", + ] + assert handle_headings_stack(["h1", "h2", "h3", "h4"], "# h1prime") == [ + "h1prime" + ] - def test_extract_date_defns(self) -> None: - date_defns, title = extract_date_defns("[c:33] things #tag [d:233] [f:5] asdf [c:99]") - assert date_defns == ["c:33", "d:233", "f:5", "c:99"] + def test_extract_task_attrs(self) -> None: + # Simple attributes + attrs, title = extract_task_attrs( + raw_task_title="[c:3/4/2024] things #tag [d:3/8/2024] [f:t] asdf", + today=self.today, + ) + assert attrs.date_attr.created_date == date(2024, 3, 4) + assert attrs.date_attr.due_date == date(2024, 3, 8) + assert attrs.date_attr.finished_date == self.today assert title == "things #tag asdf" - date_defns2, title2 = extract_date_defns("[d:3/3] title [link](http://link.org) [f:3]") - assert date_defns2 == ["d:3/3", "f:3"] + # Difficult attributes + attrs2, title2 = extract_task_attrs( + raw_task_title="title [d:3/3] [link](http://link.org) [@vighneshiyer] [!2]", + today=self.today, + ) + assert attrs2.date_attr.due_date == date(self.today.year, 3, 3) + assert attrs2.assn_attr + assert attrs2.assn_attr.assigned_to == "vighneshiyer" + assert attrs2.priority_attr + assert attrs2.priority_attr.priority == 2 assert title2 == "title [link](http://link.org)" + # No attributes + attrs3, title3 = extract_task_attrs( + raw_task_title="things #tag", + today=self.today, + ) + assert attrs3.priority_attr is None + assert attrs3.date_attr == DateAttribute() # empty attribute class + assert attrs3.assn_attr is None + assert title3 == "things #tag" + def test_parse_task_title(self) -> None: - assert parse_task_title("[d:1/1/2022] task *title* #tag [c:2/2/2022] other [r:1/4/2022] [f:1/5/2022]", today=date.today()) == \ - Task(title="task *title* #tag other", due_date=date(2022, 1, 1), - created_date=date(2022, 2, 2), reminder_date=date(2022, 1, 4), finished_date=date(2022, 1, 5)) - with pytest.raises(ValueError): + assert parse_task_title( + "[d:1/1/2022] task *title* #tag [c:2/2/2022] other [r:1/4/2022] [f:1/5/2022]", + today=date.today(), + ) == Task( + title="task *title* #tag other", + attrs=TaskAttributes( + date_attr=DateAttribute( + created_date=date(2022, 2, 2), + due_date=date(2022, 1, 1), + reminder_date=date(2022, 1, 4), + finished_date=date(2022, 1, 5), + ) + ), + ) + with pytest.raises(RuntimeError): parse_task_title("task [k:1/2/2023]", date.today()) - assert parse_task_title("task [d:t] [r:t]", date.today()) == \ - Task(title="task", due_date=date.today(), reminder_date=date.today()) + assert parse_task_title("task [d:t] [r:t]", date.today()) == Task( + title="task", + attrs=TaskAttributes( + date_attr=DateAttribute( + due_date=date.today(), reminder_date=date.today() + ) + ), + ) def test_basic_task_parsing(self) -> None: - assert parse_markdown(["- [ ] Task 1"]) == [Task(path=[], title="Task 1", done=False, line_number=1)] + assert parse_markdown(["- [ ] Task 1"]) == [ + Task(path=[], title="Task 1", done=False, line_number=1) + ] assert parse_markdown(["# h1", "", "## h2", "", "- [ ] Task 1"]) == [ Task(path=["h1", "h2"], title="Task 1", done=False, line_number=5) ] - assert parse_markdown(["# h1", "", "## h2", "", "- [ ] Task 1", "## h2prime", "", "- [x] Task 2"]) == [ + assert parse_markdown( + ["# h1", "", "## h2", "", "- [ ] Task 1", "## h2prime", "", "- [x] Task 2"] + ) == [ Task(path=["h1", "h2"], title="Task 1", done=False, line_number=5), Task(path=["h1", "h2prime"], title="Task 2", done=True, line_number=8), ] @@ -72,11 +133,17 @@ def test_basic_task_parsing(self) -> None: - [x] Task 2 """ - result = parse_markdown(tasks_with_desc.split('\n'), date.today()) + result = parse_markdown(tasks_with_desc.split("\n"), date.today()) assert result == [ - Task(path=["Tasks"], title="Task 1", done=False, line_number=3, - description="Description for task 1\n\n> quote block\n>\n> another line", due_date=date(2023, 2, 3)), - Task(path=["Tasks"], title="Task 2", done=True, line_number=11) + Task( + path=["Tasks"], + title="Task 1", + done=False, + line_number=3, + description="Description for task 1\n\n> quote block\n>\n> another line", + attrs=TaskAttributes(DateAttribute(due_date=date(2023, 2, 3))), + ), + Task(path=["Tasks"], title="Task 2", done=True, line_number=11), ] def test_bullet_task_parsing(self) -> None: @@ -90,12 +157,18 @@ def test_bullet_task_parsing(self) -> None: - [ ] Task 4 """ - result = parse_markdown(bullet_tasks.split('\n'), date.today()) + result = parse_markdown(bullet_tasks.split("\n"), date.today()) assert result == [ Task(path=["Tasks"], title="Task 1", done=False, line_number=2), Task(path=["Tasks"], title="Task 2", done=True, line_number=3), - Task(path=["Tasks"], title="Task 3", done=False, description="- Bullets\n - Nested bullets", line_number=4), - Task(path=["Tasks"], title="Task 4", done=False, line_number=9) + Task( + path=["Tasks"], + title="Task 3", + done=False, + description="- Bullets\n - Nested bullets", + line_number=4, + ), + Task(path=["Tasks"], title="Task 4", done=False, line_number=9), ] def test_subtask_parsing(self) -> None: @@ -108,14 +181,43 @@ def test_subtask_parsing(self) -> None: Description """ today = date.today() - result = parse_markdown(subtasks.split('\n'), today) + result = parse_markdown(subtasks.split("\n"), today) assert result == [ - Task(path=["Tasks"], title="Main task", done=False, line_number=3, - description="Description", due_date=date(2022, 1, 10), reminder_date=date(2022, 1, 3), - subtasks=[ - Task(path=["Tasks"], title="Subtask 1", done=False, line_number=4, - due_date=today, reminder_date=date(2022, 1, 3)), - Task(path=["Tasks"], title="Subtask 2", done=False, line_number=5, - due_date=date(2022, 1, 10), reminder_date=date(2022, 1, 3)) - ]) + Task( + path=["Tasks"], + title="Main task", + done=False, + line_number=3, + description="Description", + attrs=TaskAttributes( + DateAttribute( + due_date=date(2022, 1, 10), reminder_date=date(2022, 1, 3) + ) + ), + subtasks=[ + Task( + path=["Tasks"], + title="Subtask 1", + done=False, + line_number=4, + attrs=TaskAttributes( + DateAttribute( + due_date=today, reminder_date=date(2022, 1, 3) + ) + ), + ), + Task( + path=["Tasks"], + title="Subtask 2", + done=False, + line_number=5, + attrs=TaskAttributes( + DateAttribute( + due_date=date(2022, 1, 10), + reminder_date=date(2022, 1, 3), + ) + ), + ), + ], + ) ] diff --git a/tests/test_task.py b/tests/test_task.py index ffa3a8e..8793b15 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -1,8 +1,15 @@ from datetime import date import unicodedata import functools +from dataclasses import replace -from today.task import Task, task_sorter +from today.task import ( + DateAttribute, + PriorityAttribute, + Task, + TaskAttributes, + task_sorter, +) def remove_control_characters(s): @@ -11,82 +18,118 @@ def remove_control_characters(s): class TestTask: def test_task_is_displayed(self) -> None: - assert Task(due_date=date(2022, 1, 5)).is_displayed(date(2022, 1, 4)) is False - assert Task(due_date=date(2022, 1, 5)).is_displayed(date(2022, 1, 5)) is True - assert Task(due_date=date(2022, 1, 5)).is_displayed(date(2022, 1, 6)) is True - assert Task(due_date=date(2022, 1, 5), done=True).is_displayed(date(2022, 1, 6)) is False - assert Task(due_date=date(2022, 1, 5), reminder_date=date(2022, 1, 3)).is_displayed(date(2022, 1, 1)) is False - assert Task(due_date=date(2022, 1, 5), reminder_date=date(2022, 1, 3)).is_displayed(date(2022, 1, 3)) is True - assert Task(due_date=date(2022, 1, 5), reminder_date=date(2022, 1, 3)).is_displayed(date(2022, 1, 4)) is True - assert Task(due_date=date(2022, 1, 5), reminder_date=date(2022, 1, 3), done=True).is_displayed(date(2022, 1, 4)) is False + task = Task(attrs=TaskAttributes(DateAttribute(due_date=date(2022, 1, 5)))) + task_reminder = Task( + attrs=TaskAttributes( + DateAttribute(due_date=date(2022, 1, 5), reminder_date=date(2022, 1, 3)) + ) + ) + assert task.is_displayed(date(2022, 1, 4)) is False + assert task.is_displayed(date(2022, 1, 5)) is True + assert task.is_displayed(date(2022, 1, 6)) is True + assert replace(task, done=True).is_displayed(date(2022, 1, 6)) is False + assert task_reminder.is_displayed(date(2022, 1, 1)) is False + assert task_reminder.is_displayed(date(2022, 1, 3)) + assert task_reminder.is_displayed(date(2022, 1, 4)) is True + assert replace(task_reminder, done=True).is_displayed(date(2022, 1, 4)) is False def test_task_summary(self) -> None: # Task with only a due date - task = Task(due_date=date(2022, 1, 5), title="Task 1") - assert task.summary(today=date(2022, 1, 5)) == \ - "[**Due today**]" - assert task.summary(today=date(2022, 1, 4)) ==\ - "[Due in 1 day]" - assert task.summary(today=date(2022, 1, 1)) == \ - "[Due in 4 days]" - assert task.summary(today=date(2022, 1, 6)) == \ - "[**Due 1 day ago**]" - assert task.summary(today=date(2022, 1, 10)) == \ - "[**Due 5 days ago**]" + task = Task( + attrs=TaskAttributes(DateAttribute(due_date=date(2022, 1, 5))), + title="Task 1", + ) + assert task.summary(today=date(2022, 1, 5)) == "[**Due today**]" + assert task.summary(today=date(2022, 1, 4)) == "[Due in 1 day]" + assert task.summary(today=date(2022, 1, 1)) == "[Due in 4 days]" + assert task.summary(today=date(2022, 1, 6)) == "[**Due 1 day ago**]" + assert task.summary(today=date(2022, 1, 10)) == "[**Due 5 days ago**]" # Task with only reminder date - task = Task(reminder_date=date(2022, 1, 5), title="Task 1") - assert task.summary(today=date(2022, 1, 5)) == \ - "[**Reminder today**]" - assert task.summary(today=date(2022, 1, 4)) == \ - "[Reminder in 1 day]" - assert task.summary(today=date(2022, 1, 1)) == \ - "[Reminder in 4 days]" - assert task.summary(today=date(2022, 1, 6)) == \ - "[**Reminder 1 day ago**]" - assert task.summary(today=date(2022, 1, 10)) == \ - "[**Reminder 5 days ago**]" + task = Task( + attrs=TaskAttributes(DateAttribute(reminder_date=date(2022, 1, 5))), + title="Task 1", + ) + assert task.summary(today=date(2022, 1, 5)) == "[**Reminder today**]" + assert task.summary(today=date(2022, 1, 4)) == "[Reminder in 1 day]" + assert task.summary(today=date(2022, 1, 1)) == "[Reminder in 4 days]" + assert task.summary(today=date(2022, 1, 6)) == "[**Reminder 1 day ago**]" + assert task.summary(today=date(2022, 1, 10)) == "[**Reminder 5 days ago**]" # Task with due and reminder dates - task = Task(due_date=date(2022, 1, 5), reminder_date=date(2022, 1, 1), title="Task 1") - assert task.summary(today=date(2022, 1, 5)) == \ - "[**Due today**]" - assert task.summary(today=date(2022, 1, 4)) == \ - "[**Reminder 3 days ago**] [Due in 1 day]" - assert task.summary(today=date(2022, 1, 1)) == \ - "[**Reminder today**] [Due in 4 days]" - assert task.summary(today=date(2021, 12, 31)) == \ - "[Reminder in 1 day] [Due in 5 days]" - assert task.summary(today=date(2022, 1, 6)) == \ - "[**Due 1 day ago**]" - assert task.summary(today=date(2022, 1, 10)) == \ - "[**Due 5 days ago**]" + task = Task( + attrs=TaskAttributes( + DateAttribute(due_date=date(2022, 1, 5), reminder_date=date(2022, 1, 1)) + ), + title="Task 1", + ) + assert task.summary(today=date(2022, 1, 5)) == "[**Due today**]" + assert ( + task.summary(today=date(2022, 1, 4)) + == "[**Reminder 3 days ago**] [Due in 1 day]" + ) + assert ( + task.summary(today=date(2022, 1, 1)) + == "[**Reminder today**] [Due in 4 days]" + ) + assert ( + task.summary(today=date(2021, 12, 31)) + == "[Reminder in 1 day] [Due in 5 days]" + ) + assert task.summary(today=date(2022, 1, 6)) == "[**Due 1 day ago**]" + assert task.summary(today=date(2022, 1, 10)) == "[**Due 5 days ago**]" def test_task_sorting(self) -> None: today = date(2022, 1, 6) - due_1_5 = Task(title="due_1_5", due_date=date(2022, 1, 5)) - remind_1_5 = Task(title="remind_1_5", reminder_date=date(2022, 1, 5)) - due_1_6 = Task(title="due_1_6", due_date=date(2022, 1, 6)) - remind_1_6 = Task(title="remind_1_6", reminder_date=date(2022, 1, 6)) - due_1_7 = Task(title="due_1_7", due_date=date(2022, 1, 7)) - - # sort by: - # 1. heading path - # 2. past due tasks - # 3. tasks due today - # 4. tasks with reminders today or in the past - # 5. tasks with due/reminder dates in the future + due_1_5 = Task( + title="due_1_5", + attrs=TaskAttributes(DateAttribute(due_date=date(2022, 1, 5))), + ) + remind_1_5 = Task( + title="remind_1_5", + attrs=TaskAttributes(DateAttribute(reminder_date=date(2022, 1, 5))), + ) + due_1_6 = Task( + title="due_1_6", + attrs=TaskAttributes(DateAttribute(due_date=date(2022, 1, 6))), + ) + remind_1_6 = Task( + title="remind_1_6", + attrs=TaskAttributes(DateAttribute(reminder_date=date(2022, 1, 6))), + ) + due_1_7 = Task( + title="due_1_7", + attrs=TaskAttributes(DateAttribute(due_date=date(2022, 1, 7))), + ) + pri0_task = Task( + title="pri0", attrs=TaskAttributes(priority_attr=PriorityAttribute(0)) + ) + pri1_task = Task( + title="pri1", attrs=TaskAttributes(priority_attr=PriorityAttribute(1)) + ) # Task due at 1/5 is past due vs the task due at 1/6 (due today) - assert sorted([due_1_5, due_1_6], key=functools.partial(task_sorter, today=today)) == \ - [due_1_5, due_1_6] - assert sorted([due_1_6, due_1_5], key=functools.partial(task_sorter, today=today)) == \ - [due_1_5, due_1_6] + assert sorted( + [due_1_5, due_1_6], key=functools.partial(task_sorter, today=today) + ) == [due_1_5, due_1_6] + assert sorted( + [due_1_6, due_1_5], key=functools.partial(task_sorter, today=today) + ) == [due_1_5, due_1_6] + + assert sorted( + [due_1_6, due_1_5, remind_1_5, remind_1_6], + key=functools.partial(task_sorter, today=today), + ) == [remind_1_5, due_1_5, due_1_6, remind_1_6] + assert sorted( + [remind_1_5, remind_1_6], key=functools.partial(task_sorter, today=today) + ) == [remind_1_5, remind_1_6] - assert sorted([due_1_6, due_1_5, remind_1_5, remind_1_6], key=functools.partial(task_sorter, today=today)) == \ - [remind_1_5, due_1_5, due_1_6, remind_1_6] - assert sorted([remind_1_5, remind_1_6], key=functools.partial(task_sorter, today=today)) == \ - [remind_1_5, remind_1_6] + assert sorted( + [due_1_7, due_1_5, remind_1_5, remind_1_6], + key=functools.partial(task_sorter, today=today), + ) == [remind_1_5, due_1_5, remind_1_6, due_1_7] - assert sorted([due_1_7, due_1_5, remind_1_5, remind_1_6], key=functools.partial(task_sorter, today=today)) == \ - [remind_1_5, due_1_5, remind_1_6, due_1_7] + assert sorted( + [due_1_7, due_1_5, pri1_task, pri0_task], + key=functools.partial(task_sorter, today=today), + ) == [pri0_task, pri1_task, due_1_5, due_1_7] diff --git a/today-tasks.md b/today-tasks.md index 284770e..6d99820 100644 --- a/today-tasks.md +++ b/today-tasks.md @@ -37,11 +37,14 @@ The idea is that every day there might be 1-3 critical tasks that should be placed at the top no matter what the heading order is. There should be a way to mark those critical tasks such that they always show up at the top and it makes task selection easy. Also, the remaining tasks can be displayed in the normal tree form as 'extras' for a given day. +I'm generalizing this into a priority marking system. - [ ] Importance markers [d:3/12/2024] - - [ ] Rename date_defns to task_attrs - - [ ] Add importance markers parsing - - [ ] Add new logic to tree printing + - [x] Rename date_defns to task_attrs + - [x] Add importance markers parsing + - [x] Cleanup task printing (summary + description) logic into attributes + - [x] Add new logic to tree printing + - [x] Add README docs ### Assignees diff --git a/today/cli.py b/today/cli.py index 9b87f1c..9336a2a 100644 --- a/today/cli.py +++ b/today/cli.py @@ -11,7 +11,7 @@ from rich.console import Console from rich.markdown import Markdown -from today.task import Task, task_sorter, days +from today.task import PriorityAttribute, Task, task_sorter, days from today.parser import parse_markdown @@ -107,39 +107,31 @@ def parse_task_files(args: CliArgs) -> List[Task]: task for task in tasks if task.is_displayed(args.task_date_filter()) ] - # Sort tasks by their headings and due dates + # Sort tasks by their priorities and headings and due dates tasks_visible.sort(key=functools.partial(task_sorter, today=args.today)) return tasks_visible -def maybe_display_specific_task( - args: CliArgs, tasks: List[Task], console: Console +def display_specific_task( + task: Task, task_id: int, today: date, console: Console ) -> None: - # If a specific task id is given, print its description and details and exit - if args.task_id is not None: - if args.task_id < 0 or args.task_id >= len(tasks): - console.print(f"The task_id {args.task_id} does not exist") - sys.exit(1) - task = tasks[args.task_id] - details = task.details(args.task_id, args.today) - console.print("") - console.print(Markdown(details)) + details = task.details(task_id, today) + console.print("") + console.print(Markdown(details)) + console.print("") + + if len(task.subtasks) > 0: + console.print(Markdown("**Subtasks**:")) + for subtask in task.subtasks: + subtask_summary = subtask.summary(today) + if subtask.done: + console.print(Markdown(f"- **DONE**: {subtask.title} {subtask_summary}")) + else: + console.print(Markdown(f"- {subtask.title} {subtask_summary}")) + if len(task.subtasks) > 0: console.print("") - if len(task.subtasks) > 0: - console.print(Markdown("**Subtasks**:")) - for subtask in task.subtasks: - subtask_summary = subtask.summary(args.today) - if subtask.done: - console.print( - Markdown(f"- **DONE**: {subtask.title} {subtask_summary}") - ) - else: - console.print(Markdown(f"- {subtask.title} {subtask_summary}")) - if len(task.subtasks) > 0: - console.print("") - - sys.exit(0) + sys.exit(0) def tasks_to_tree(args: CliArgs, tasks: List[Task]) -> Tree: @@ -153,6 +145,19 @@ def tasks_to_tree(args: CliArgs, tasks: List[Task]) -> Tree: ) ) + # Tasks should already be sorted with priority tasks first, then non-priority tasks + priority_tasks = [t for t in tasks if t.attrs.priority_attr is not None] + other_tasks = [t for t in tasks if t.attrs.priority_attr is None] + + priority_label = tree.add("[bold]Priority Tasks[/bold]") + for i, task in enumerate(priority_tasks): + priority_label.add( + # f"[bold]{i}[/bold] - [blue]{' / '.join(task.path)}[/blue] [blue bold]➔[/blue bold] {task.title} {Markdown(task.summary(args.today))} ([red italic]{task.file_path.relative_to(args.task_dir)}:{task.line_number}[/red italic])" + Markdown( + f"**{i}** - {' / '.join(task.path)} → {task.title} {task.summary(args.today)} (*{task.file_path.relative_to(args.task_dir)}:{task.line_number}*)" + ) + ) + def add_to_tree(task: Task, tree: Tree, task_idx: int, first_call: bool) -> Tree: if len(task.path) == 0: # Base case parent = tree.add( @@ -187,6 +192,6 @@ def add_to_tree(task: Task, tree: Tree, task_idx: int, first_call: bool) -> Tree task.path = task.path[1:] return add_to_tree(task, child, task_idx, False) - for i, task in enumerate(tasks): - add_to_tree(task, tree, i, True) + for i, task in enumerate(other_tasks): + add_to_tree(task, tree, i + len(priority_tasks), True) return tree diff --git a/today/parser.py b/today/parser.py index 2efb05b..14c8600 100644 --- a/today/parser.py +++ b/today/parser.py @@ -1,10 +1,18 @@ -from typing import Tuple, List, Optional +from typing import Sequence, Tuple, List, Optional, Union from datetime import date import re - -from today.task import Task, Heading - -date_defn_re = re.compile(r"\[.:.") +from more_itertools import windowed + +from today.task import ( + AssignmentAttribute, + PriorityAttribute, + Task, + Heading, + TaskAttributes, + TaskTitle, +) + +task_attr_re = re.compile(r"\[(?P(.:|@|!))(?P.*?)\]\s?") task_re = re.compile(r"^- \[[ xX]\] ") subtask_re = re.compile(r"^[ \t]+- \[[ xX]\] ") @@ -20,6 +28,8 @@ def parse_heading(s: str) -> Heading: raise ValueError("This should never happen") +# Given the current state of headings in [headings_stack] and a new heading [heading_raw], +# validate that the new heading has the correct indentation and return a new heading stack def handle_headings_stack(headings_stack: List[str], heading_raw: str) -> List[str]: heading = parse_heading(heading_raw) last_level = len(headings_stack) @@ -35,72 +45,106 @@ def handle_headings_stack(headings_stack: List[str], heading_raw: str) -> List[s return headings_stack -# extract_task_attrs - call them task attributes -class RawAttribute: - prefix: str - value: str - - -def extract_date_defns(title: str) -> Tuple[List[str], str]: - date_defns: List[str] = [] - - # remove date defns iteratively until nothing is left - while (match := date_defn_re.search(title)) is not None: - start_idx = title.find("[", match.start()) - end_idx = title.find("]", match.start()) - date_defns.append(title[start_idx + 1 : end_idx]) - title = title.replace(title[start_idx : end_idx + 2], "") - return date_defns, title.rstrip() +def md_checkbox(s: str) -> Optional[bool]: + # None = not a checkbox, True = checked, False = unchecked + if s.startswith("[ ]"): + return False + elif s.startswith("[x]") or s.startswith("[X]"): + return True + else: + return None -def parse_task_title(title: str, today: date) -> Task: - date_defns, task_title = extract_date_defns(title) - t = Task(title=task_title) - for defn in date_defns: - prefix = defn[0] - assert defn[1] == ":" - if defn[2:] == "t": +# Mutates the fields of [task_attr] based on a raw attribute string (prefix + value) +# of the form [d:] (prefix='d:', value='') or [@person] or [!2] +# If the prefix or value are malformed, return an error message +def assign_task_attr( + prefix: str, value: str, task_attr: TaskAttributes, today: date +) -> Union[None, str]: + if prefix == "@": + # This is an assignment attribute + task_attr.assn_attr = AssignmentAttribute(value) + return + elif prefix == "!": + # This is a priority attribute + task_attr.priority_attr = PriorityAttribute(int(value)) + return + else: + # This must be a date attribute + prefix = prefix[0] # the raw prefix passed is of the form 'd:' + date_raw = value + date_value: date + if date_raw == "t": date_value = today else: - date_split = [int(d) for d in defn[2:].split("/")] - if len(date_split) == 3: + date_split = [int(d) for d in date_raw.split("/")] + if len(date_split) == 3: # month / day / year date_value = date( year=date_split[2], month=date_split[0], day=date_split[1] ) - elif len(date_split) == 2: + elif len(date_split) == 2: # month / day (year is implicitly today's year) date_value = date( year=today.year, month=date_split[0], day=date_split[1] ) else: - raise ValueError( - f"Unable to parse date for task {title} and date string {defn}" - ) + return f"Date attribute value '{value}' is improperly formatted" if prefix == "c": - t.created_date = date_value - elif prefix == "r": - t.reminder_date = date_value + task_attr.date_attr.created_date = date_value elif prefix == "d": - t.due_date = date_value + task_attr.date_attr.due_date = date_value + elif prefix == "r": + task_attr.date_attr.reminder_date = date_value elif prefix == "f": - t.finished_date = date_value + task_attr.date_attr.finished_date = date_value else: - raise ValueError( - f"Prefix {prefix} in date definition string {defn} is not recognized" + return f"Date attribute prefix '{prefix}' isn't recognized" + return + + +def extract_task_attrs( + raw_task_title: str, today: date +) -> Tuple[TaskAttributes, TaskTitle]: + task_attr = TaskAttributes() + + # Find all matches for the attribute regex and parse each one while mutating [task_attr] + task_attr_matches = list(re.finditer(task_attr_re, raw_task_title)) + if len(task_attr_matches) == 0: + # short circuit when there are no attributes to parse + return task_attr, raw_task_title + for match in task_attr_matches: + prefix = match.group("prefix") + value = match.group("value") + error_msg = assign_task_attr(prefix, value, task_attr, today) + if error_msg is not None: + raise RuntimeError( + f"An error was encountered when parsing the task title '{raw_task_title}'. Error: {error_msg}" ) - return t + # Strip all the task attribute matches from the [raw_task_title] + # Do this efficiently by first computing the substring indices we need, then constructing the new string + match_spans: List[Tuple[int, int]] = [m.span(0) for m in task_attr_matches] + not_match_spans = ( + [(0, match_spans[0][0])] + + [ + (span1[1], span2[0]) + for (span1, span2) in windowed(match_spans, 2) + if span1 is not None and span2 is not None + ] + + [(match_spans[-1][1], len(raw_task_title))] + ) + task_title = "" + for span in not_match_spans: + task_title = task_title + raw_task_title[span[0] : span[1]] + return task_attr, task_title.rstrip() -def md_checkbox(s: str) -> Optional[bool]: - # None = not a checkbox, True = checked, False = unchecked - if s.startswith("[ ]"): - return False - elif s.startswith("[x]") or s.startswith("[X]"): - return True - else: - return None + +def parse_task_title(title: str, today: date) -> Task: + task_attr, task_title = extract_task_attrs(title, today) + t = Task(title=task_title, attrs=task_attr) + return t -def parse_markdown(md: List[str], today: date = date.today()) -> List[Task]: +def parse_markdown(md: Sequence[str], today: date = date.today()) -> List[Task]: headings_stack: List[str] = [] current_task: Optional[Task] = None tasks: List[Task] = [] @@ -133,14 +177,7 @@ def parse_markdown(md: List[str], today: date = date.today()) -> List[Task]: subtask.path = headings_stack.copy() subtask.done = subtask_status subtask.line_number = i + 1 - if not subtask.due_date and current_task.due_date: - subtask.due_date = current_task.due_date - if not subtask.reminder_date and current_task.reminder_date: - subtask.reminder_date = current_task.reminder_date - if not subtask.finished_date and current_task.finished_date: - subtask.finished_date = current_task.finished_date - if not subtask.created_date and current_task.created_date: - subtask.created_date = current_task.created_date + subtask.attrs.merge_attributes(current_task.attrs) current_task.subtasks.append(subtask) elif len(line) == 0 and current_task is None: continue diff --git a/today/scripts/today.py b/today/scripts/today.py index 7be5810..31354da 100644 --- a/today/scripts/today.py +++ b/today/scripts/today.py @@ -6,7 +6,7 @@ build_parser, parse_args, parse_task_files, - maybe_display_specific_task, + display_specific_task, tasks_to_tree, ) @@ -16,10 +16,17 @@ def run(args) -> None: cli_args = parse_args(parser, args) console = Console() + # TODO: cache task parsing for each markdown file based on file hash tasks = parse_task_files(cli_args) - maybe_display_specific_task( - cli_args, tasks, console - ) # If a specific task is displayed, the program will exit + + # If a specific task is displayed, the program will exit + if cli_args.task_id is not None: + if cli_args.task_id < 0 or cli_args.task_id >= len(tasks): + console.print(f"The task_id {args.task_id} does not exist") + sys.exit(1) + task = tasks[cli_args.task_id] + display_specific_task(task, cli_args.task_id, cli_args.today, console) + sys.exit(0) tree = tasks_to_tree(cli_args, tasks) console.print("") diff --git a/today/task.py b/today/task.py index f51d4af..7b2b177 100644 --- a/today/task.py +++ b/today/task.py @@ -4,6 +4,7 @@ from pathlib import Path +# Some functions to simplify stringifying task descriptions and summaries def date_relative_to_today(d: date, today: date, prefix: str = "") -> str: if d < today: delta: timedelta = today - d @@ -28,47 +29,42 @@ class Heading: name: str +TaskTitle = str + + @dataclass -class Task: - path: List[str] = field(default_factory=lambda: []) - title: str = "" - done: bool = False - description: str = "" # A Markdown string with the task description - subtasks: List["Task"] = field(default_factory=lambda: []) +class DateAttribute: created_date: Optional[date] = None - reminder_date: Optional[date] = None due_date: Optional[date] = None + reminder_date: Optional[date] = None finished_date: Optional[date] = None - file_path: Path = Path.cwd() - line_number: int = 0 - def is_displayed(self, date_limit: date) -> bool: - if self.due_date and self.due_date <= date_limit and self.done is False: - return True - elif ( - self.reminder_date - and self.reminder_date <= date_limit - and self.done is False - ): + # today = 3, due_date = 5 (not visible) + # today = 5, due_date = 5 (visible) + # today = 3, due_date = 5, lookahead_days = 1 (not visible) + # today = 3, due_date = 5, lookahead_days = 2 (visible) + def is_visible(self, today: date, lookahead_days: int) -> bool: + effective_date = today + timedelta(days=lookahead_days) + if self.due_date and effective_date >= self.due_date: return True - elif self.subtasks and any( - [ - st.due_date and st.due_date <= date_limit and st.done is False - for st in self.subtasks - ] - ): - return True - elif self.subtasks and any( - [ - st.reminder_date and st.reminder_date <= date_limit and st.done is False - for st in self.subtasks - ] - ): + elif self.reminder_date and effective_date >= self.reminder_date: return True else: return False - def summary(self, today: date) -> str: # Returns a Markdown string + # If this is a subtask and we have the attributes of the parent task, + # propagate the parent attributes into the subtask + def merge_attributes(self, parent_attrs: "DateAttribute") -> None: + if self.created_date is None: + self.created_date = parent_attrs.created_date + if self.due_date is None: + self.due_date = parent_attrs.due_date + if self.reminder_date is None: + self.reminder_date = parent_attrs.reminder_date + if self.finished_date is None: + self.finished_date = parent_attrs.finished_date + + def summary(self, today: date) -> str: reminder_msg: Optional[str] = None due_msg: Optional[str] = None if self.due_date: @@ -92,28 +88,103 @@ def summary(self, today: date) -> str: # Returns a Markdown string else: return f"[{due_msg}]" - def details(self, task_id: int, today: date) -> str: # Returns a Markdown string + def details(self, today: date) -> str: string = "" - string += f"**Title**: {self.title} (id = `{task_id}`) \n" if self.due_date: string += f"**Due date**: {self.due_date} ({date_relative_to_today(self.due_date, today, prefix='Due ')}) \n" if self.reminder_date: string += f"**Reminder date**: {self.reminder_date} ({date_relative_to_today(self.reminder_date, today, prefix='Reminder ')}) \n" + return string + + +@dataclass +class AssignmentAttribute: + assigned_to: str + + +@dataclass +class PriorityAttribute: + # [priority] of 0 is higher than [priority] of 1 + priority: int + + def summary(self) -> str: + return f"[***Priority*** = {self.priority}]" + + +@dataclass +class TaskAttributes: + date_attr: DateAttribute = field(default_factory=lambda: DateAttribute()) + assn_attr: Optional[AssignmentAttribute] = None + priority_attr: Optional[PriorityAttribute] = None + + def is_visible(self, today: date, lookahead_days: int) -> bool: + raise NotImplementedError() + + def merge_attributes(self, parent_attrs: "TaskAttributes") -> None: + # TODO: are there other attributes to merge? (priority or assignment) + self.date_attr.merge_attributes(parent_attrs.date_attr) + + +@dataclass +class Task: + path: List[str] = field(default_factory=lambda: []) + title: str = "" + done: bool = False + description: str = "" # A Markdown string with the task description + subtasks: List["Task"] = field(default_factory=lambda: []) + attrs: TaskAttributes = field(default_factory=lambda: TaskAttributes()) + file_path: Path = Path.cwd() + line_number: int = 0 + + # A task should be displayed if it has a reminder or due date that is today or has passed + # If a task is already done then it should not be displayed no matter what + def is_displayed(self, today: date, lookahead_days: int = 0) -> bool: + task_visible = self.attrs.date_attr.is_visible(today, lookahead_days) + subtasks_visible = any( + [t.is_displayed(today, lookahead_days) for t in self.subtasks] + ) + return (task_visible or subtasks_visible) and not self.done + + def summary(self, today: date) -> str: # Returns a Markdown string + date_summary = self.attrs.date_attr.summary(today) + pri_summary = ( + (self.attrs.priority_attr.summary() + " ") + if self.attrs.priority_attr + else "" + ) + return pri_summary + date_summary + + def details(self, task_id: int, today: date) -> str: # Returns a Markdown string + string = "" + string += f"**Title**: {self.title} (id = `{task_id}`) \n" if len(self.description) > 0: string += "**Description**: \n\n" string += self.description return string +# sort by: +# 0. task priority +# 1. heading path +# 2. past due tasks +# 3. tasks due today +# 4. tasks with reminders today or in the past +# 5. tasks with due/reminder dates in the future def task_sorter(task: Task, today: date) -> Any: keys: List[Any] = [] + if task.attrs.priority_attr: + keys.append(task.attrs.priority_attr.priority) + else: + keys.append( + 100000 + ) # very big number to not consider priority for tasks without priorities defined keys.append(task.path) - if task.reminder_date: - keys.append(task.reminder_date - today) + if task.attrs.date_attr.reminder_date: + keys.append(task.attrs.date_attr.reminder_date - today) else: keys.append(timedelta(days=0)) - if task.due_date: - keys.append(task.due_date - today) + if task.attrs.date_attr.due_date: + keys.append(task.attrs.date_attr.due_date - today) else: keys.append(timedelta(days=0)) return keys