mdtodo 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mdtodo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mjws4oss
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.
mdtodo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: mdtodo
3
+ Version: 0.1.0
4
+ Summary: A todo list manager that keeps projects and tasks in a plain markdown file.
5
+ Keywords: todo,markdown,cli
6
+ Author: mjws4oss
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Utilities
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.14
19
+ Description-Content-Type: text/markdown
20
+
21
+ # mdtodo
22
+
23
+ `mdtodo` is a command-line todo manager that keeps everything in one plain markdown file.
24
+ Headings are projects and checkbox items are tasks. You can edit the file in any editor, or
25
+ use the `mdtodo` commands. Either way, it stays readable markdown.
26
+
27
+ ```markdown
28
+ - [ ] Call the bank due:2026-10-20
29
+
30
+ ## Website
31
+ - [ ] Write landing copy @active due:2026-10-18
32
+ - [ ] Pick a color palette @next
33
+ - [ ] Shortlist 3 palettes
34
+ - [x] Buy domain
35
+ ```
36
+
37
+ - `[ ]` means open, and `[x]` means completed.
38
+ - `@active` marks a task you're working on now. `@next` adds a task to the pool of tasks
39
+ you plan to pick up next.
40
+ - `due:YYYY-MM-DD` sets a deadline. Open tasks are flagged as overdue, or as due soon
41
+ (today through the next 3 days).
42
+ - Tasks above the first heading go into the `Inbox` project. Prose, notes, and code blocks
43
+ are ignored.
44
+
45
+ The full syntax is in
46
+ [specs/001-markdown-todo-manager/contracts/file-format.md](specs/001-markdown-todo-manager/contracts/file-format.md).
47
+
48
+ ## Install
49
+
50
+ With [uv](https://docs.astral.sh/uv/) installed:
51
+
52
+ ```bash
53
+ uv tool install mdtodo # puts `mdtodo` on your PATH (~/.local/bin)
54
+ mdtodo --version
55
+ ```
56
+
57
+ If you don't have a suitable Python, uv downloads one for you.
58
+
59
+ ```bash
60
+ uv tool install mdtodo==X.Y.Z # install a specific version
61
+ uv tool upgrade mdtodo # move to the latest release
62
+ uv tool uninstall mdtodo # remove the command
63
+ ```
64
+
65
+ Uninstalling removes only the command. Your todo file (`~/tracker.md`, or whatever `--file`
66
+ or `MDTODO_FILE` points at) is left untouched.
67
+
68
+ **Switching from an editable install**: if you installed with `uv tool install --editable .`,
69
+ run `uv tool uninstall mdtodo` first, or replace it in one step with
70
+ `uv tool install --force mdtodo`.
71
+
72
+ ## Which file is used
73
+
74
+ The first match wins:
75
+
76
+ 1. `--file PATH`, which can go before or after the command;
77
+ 2. the `MDTODO_FILE` environment variable;
78
+ 3. `~/tracker.md`.
79
+
80
+ If you used an earlier version that read `~/TODO.md`, rename that file to `~/tracker.md`, or
81
+ point `--file` or `MDTODO_FILE` at it.
82
+
83
+ `mdtodo init` creates a starter file. Nothing else ever creates the file.
84
+
85
+ ## Commands
86
+
87
+ | Command | What it does |
88
+ |---------|--------------|
89
+ | `mdtodo` / `mdtodo summary` | Active, Next, Overdue, and Due soon tasks, plus counts per project |
90
+ | `mdtodo list [--all] [--project NAME]` | Tasks grouped by project, each with its reference |
91
+ | `mdtodo next` | The next pool, ordered by deadline |
92
+ | `mdtodo due` | Open tasks ordered by deadline |
93
+ | `mdtodo start REF` / `mdtodo stop REF` | Add or remove `@active` |
94
+ | `mdtodo next add REF` / `mdtodo next rm REF` | Add or remove `@next` |
95
+ | `mdtodo done REF` | Check the box, and remove `@active` and `@next` |
96
+ | `mdtodo init` | Create a starter file |
97
+
98
+ `REF` is the 6-character reference shown in every listing. It stays the same when markers
99
+ change. If you edit a task's text, its reference changes, and the old one is refused rather
100
+ than applied to the wrong task. Each command changes only the task's own line. If the file
101
+ changed while the command was running, nothing is written.
102
+
103
+ Details, including output formats and exit codes, are in
104
+ [contracts/cli.md](specs/001-markdown-todo-manager/contracts/cli.md).
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ uv sync
110
+ uv tool install --editable . # installs this working tree as the `mdtodo` command
111
+ scripts/check.sh # format, lint, mypy --strict, tests (coverage ≥ 80%)
112
+ uv run pytest -m perf --no-cov # timing checks (SC-002), kept out of the default run
113
+ ```
114
+
115
+ The design documents are in [specs/001-markdown-todo-manager/](specs/001-markdown-todo-manager/),
116
+ and the project principles are in [.specify/memory/constitution.md](.specify/memory/constitution.md).
117
+
118
+ ## Releasing
119
+
120
+ Releases go to PyPI with `scripts/release.sh`. See [docs/releasing.md](docs/releasing.md).
mdtodo-0.1.0/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # mdtodo
2
+
3
+ `mdtodo` is a command-line todo manager that keeps everything in one plain markdown file.
4
+ Headings are projects and checkbox items are tasks. You can edit the file in any editor, or
5
+ use the `mdtodo` commands. Either way, it stays readable markdown.
6
+
7
+ ```markdown
8
+ - [ ] Call the bank due:2026-10-20
9
+
10
+ ## Website
11
+ - [ ] Write landing copy @active due:2026-10-18
12
+ - [ ] Pick a color palette @next
13
+ - [ ] Shortlist 3 palettes
14
+ - [x] Buy domain
15
+ ```
16
+
17
+ - `[ ]` means open, and `[x]` means completed.
18
+ - `@active` marks a task you're working on now. `@next` adds a task to the pool of tasks
19
+ you plan to pick up next.
20
+ - `due:YYYY-MM-DD` sets a deadline. Open tasks are flagged as overdue, or as due soon
21
+ (today through the next 3 days).
22
+ - Tasks above the first heading go into the `Inbox` project. Prose, notes, and code blocks
23
+ are ignored.
24
+
25
+ The full syntax is in
26
+ [specs/001-markdown-todo-manager/contracts/file-format.md](specs/001-markdown-todo-manager/contracts/file-format.md).
27
+
28
+ ## Install
29
+
30
+ With [uv](https://docs.astral.sh/uv/) installed:
31
+
32
+ ```bash
33
+ uv tool install mdtodo # puts `mdtodo` on your PATH (~/.local/bin)
34
+ mdtodo --version
35
+ ```
36
+
37
+ If you don't have a suitable Python, uv downloads one for you.
38
+
39
+ ```bash
40
+ uv tool install mdtodo==X.Y.Z # install a specific version
41
+ uv tool upgrade mdtodo # move to the latest release
42
+ uv tool uninstall mdtodo # remove the command
43
+ ```
44
+
45
+ Uninstalling removes only the command. Your todo file (`~/tracker.md`, or whatever `--file`
46
+ or `MDTODO_FILE` points at) is left untouched.
47
+
48
+ **Switching from an editable install**: if you installed with `uv tool install --editable .`,
49
+ run `uv tool uninstall mdtodo` first, or replace it in one step with
50
+ `uv tool install --force mdtodo`.
51
+
52
+ ## Which file is used
53
+
54
+ The first match wins:
55
+
56
+ 1. `--file PATH`, which can go before or after the command;
57
+ 2. the `MDTODO_FILE` environment variable;
58
+ 3. `~/tracker.md`.
59
+
60
+ If you used an earlier version that read `~/TODO.md`, rename that file to `~/tracker.md`, or
61
+ point `--file` or `MDTODO_FILE` at it.
62
+
63
+ `mdtodo init` creates a starter file. Nothing else ever creates the file.
64
+
65
+ ## Commands
66
+
67
+ | Command | What it does |
68
+ |---------|--------------|
69
+ | `mdtodo` / `mdtodo summary` | Active, Next, Overdue, and Due soon tasks, plus counts per project |
70
+ | `mdtodo list [--all] [--project NAME]` | Tasks grouped by project, each with its reference |
71
+ | `mdtodo next` | The next pool, ordered by deadline |
72
+ | `mdtodo due` | Open tasks ordered by deadline |
73
+ | `mdtodo start REF` / `mdtodo stop REF` | Add or remove `@active` |
74
+ | `mdtodo next add REF` / `mdtodo next rm REF` | Add or remove `@next` |
75
+ | `mdtodo done REF` | Check the box, and remove `@active` and `@next` |
76
+ | `mdtodo init` | Create a starter file |
77
+
78
+ `REF` is the 6-character reference shown in every listing. It stays the same when markers
79
+ change. If you edit a task's text, its reference changes, and the old one is refused rather
80
+ than applied to the wrong task. Each command changes only the task's own line. If the file
81
+ changed while the command was running, nothing is written.
82
+
83
+ Details, including output formats and exit codes, are in
84
+ [contracts/cli.md](specs/001-markdown-todo-manager/contracts/cli.md).
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ uv sync
90
+ uv tool install --editable . # installs this working tree as the `mdtodo` command
91
+ scripts/check.sh # format, lint, mypy --strict, tests (coverage ≥ 80%)
92
+ uv run pytest -m perf --no-cov # timing checks (SC-002), kept out of the default run
93
+ ```
94
+
95
+ The design documents are in [specs/001-markdown-todo-manager/](specs/001-markdown-todo-manager/),
96
+ and the project principles are in [.specify/memory/constitution.md](.specify/memory/constitution.md).
97
+
98
+ ## Releasing
99
+
100
+ Releases go to PyPI with `scripts/release.sh`. See [docs/releasing.md](docs/releasing.md).
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "mdtodo"
3
+ version = "0.1.0"
4
+ description = "A todo list manager that keeps projects and tasks in a plain markdown file."
5
+ readme = "README.md"
6
+ authors = [{ name = "mjws4oss" }]
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = ["todo", "markdown", "cli"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Operating System :: MacOS",
14
+ "Operating System :: POSIX :: Linux",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Topic :: Utilities",
19
+ "Typing :: Typed",
20
+ ]
21
+ requires-python = ">=3.14"
22
+ dependencies = []
23
+
24
+ [project.scripts]
25
+ mdtodo = "mdtodo.cli:main"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.9.16,<0.10.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "mypy>=2.4.0",
34
+ "pytest>=9.1.1",
35
+ "pytest-cov>=7.1.0",
36
+ "ruff>=0.17.1",
37
+ ]
38
+
39
+ [tool.ruff]
40
+ target-version = "py314"
41
+ line-length = 100
42
+ src = ["src", "tests"]
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF", "PL", "PT"]
46
+
47
+ [tool.ruff.lint.per-file-ignores]
48
+ # Tests compare against literal values such as exit codes and counts.
49
+ "tests/**" = ["PLR2004"]
50
+
51
+ [tool.mypy]
52
+ strict = true
53
+ files = ["src", "tests"]
54
+ python_version = "3.14"
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ addopts = "--strict-markers -m 'not perf' --cov=mdtodo --cov-report=term-missing --cov-fail-under=80"
59
+ markers = ["perf: timing-sensitive performance tests"]
60
+
61
+ [tool.coverage.run]
62
+ branch = true
63
+ source = ["mdtodo"]
@@ -0,0 +1,5 @@
1
+ """mdtodo: a todo list manager backed by a markdown file."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("mdtodo")
@@ -0,0 +1,3 @@
1
+ from mdtodo.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,202 @@
1
+ """`todo` command-line interface (contracts/cli.md)."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from datetime import date
9
+ from pathlib import Path
10
+
11
+ from mdtodo import __version__, edits, queries, refs, render, store
12
+ from mdtodo.model import Task, TodoError, TodoFile
13
+ from mdtodo.parser import parse
14
+
15
+
16
+ def today() -> date:
17
+ return date.today()
18
+
19
+
20
+ _FILE_HELP = "todo file to use (default: $MDTODO_FILE, then ~/tracker.md)"
21
+
22
+
23
+ def _build_parser() -> argparse.ArgumentParser:
24
+ # Subcommands also accept --file; SUPPRESS keeps them from overwriting a --file given
25
+ # before the command. The main parser gets its own action: `parents=` shares action
26
+ # objects, so a shared action's default would leak between parsers.
27
+ file_option = argparse.ArgumentParser(add_help=False)
28
+ file_option.add_argument("--file", metavar="PATH", default=argparse.SUPPRESS, help=_FILE_HELP)
29
+
30
+ parser = argparse.ArgumentParser(
31
+ prog="mdtodo",
32
+ description="Manage todos kept in a markdown file. With no command, shows the summary.",
33
+ )
34
+ parser.add_argument("--file", metavar="PATH", default=None, help=_FILE_HELP)
35
+ parser.add_argument("--version", action="version", version=f"mdtodo {__version__}")
36
+ commands = parser.add_subparsers(dest="command", metavar="COMMAND")
37
+
38
+ commands.add_parser("summary", parents=[file_option], help="active, next, and due tasks")
39
+
40
+ list_cmd = commands.add_parser("list", parents=[file_option], help="tasks grouped by project")
41
+ list_cmd.add_argument("--all", action="store_true", help="include completed tasks")
42
+ list_cmd.add_argument("--project", metavar="NAME", help="only this project")
43
+
44
+ commands.add_parser("due", parents=[file_option], help="open tasks ordered by deadline")
45
+
46
+ next_cmd = commands.add_parser(
47
+ "next", parents=[file_option], help="list next tasks, or add/rm the next marker"
48
+ )
49
+ next_actions = next_cmd.add_subparsers(dest="next_action", metavar="ACTION")
50
+ for action, help_text in (("add", "mark a task as next"), ("rm", "unmark a next task")):
51
+ next_actions.add_parser(action, parents=[file_option], help=help_text).add_argument(
52
+ "ref", metavar="REF"
53
+ )
54
+
55
+ for name, help_text in (
56
+ ("start", "mark a task as active"),
57
+ ("stop", "unmark an active task"),
58
+ ("done", "mark a task as completed"),
59
+ ):
60
+ commands.add_parser(name, parents=[file_option], help=help_text).add_argument(
61
+ "ref", metavar="REF"
62
+ )
63
+
64
+ commands.add_parser("init", parents=[file_option], help="create a starter todo file")
65
+ return parser
66
+
67
+
68
+ def load(args: argparse.Namespace) -> tuple[Path, TodoFile, bytes]:
69
+ path = store.locate(args.file, os.environ)
70
+ text, snapshot = store.read(path)
71
+ todo = parse(text)
72
+ if todo.warnings:
73
+ print(render.render_warnings(todo.warnings), file=sys.stderr)
74
+ return path, todo, snapshot
75
+
76
+
77
+ def _cmd_summary(args: argparse.Namespace) -> None:
78
+ _, todo, _ = load(args)
79
+ now = today()
80
+ print(
81
+ render.render_summary(
82
+ active=queries.active_tasks(todo),
83
+ next_tasks=queries.next_pool(todo),
84
+ overdue=queries.overdue(todo, now),
85
+ due_soon=queries.due_soon(todo, now),
86
+ counts=queries.project_counts(todo),
87
+ today=now,
88
+ )
89
+ )
90
+
91
+
92
+ def _cmd_due(args: argparse.Namespace) -> None:
93
+ _, todo, _ = load(args)
94
+ print(render.render_due(queries.by_deadline(todo), today()))
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class _WriteAction:
99
+ verb: str # shown as "<verb>: <ref> <description>"
100
+ needs_open: bool # refuse on completed tasks
101
+ is_satisfied: Callable[[Task], bool]
102
+ edit: Callable[[str], str]
103
+ satisfied_reason: str
104
+
105
+
106
+ _WRITE_ACTIONS = {
107
+ "start": _WriteAction(
108
+ "started",
109
+ True,
110
+ lambda t: t.active_marker,
111
+ lambda line: edits.add_marker(line, edits.ACTIVE),
112
+ "already active",
113
+ ),
114
+ "stop": _WriteAction(
115
+ "stopped",
116
+ False,
117
+ lambda t: not t.active_marker,
118
+ lambda line: edits.remove_marker(line, edits.ACTIVE),
119
+ "not active",
120
+ ),
121
+ "next add": _WriteAction(
122
+ "queued",
123
+ True,
124
+ lambda t: t.next_marker,
125
+ lambda line: edits.add_marker(line, edits.NEXT),
126
+ "already a next task",
127
+ ),
128
+ "next rm": _WriteAction(
129
+ "unqueued",
130
+ False,
131
+ lambda t: not t.next_marker,
132
+ lambda line: edits.remove_marker(line, edits.NEXT),
133
+ "not a next task",
134
+ ),
135
+ "done": _WriteAction("done", True, lambda t: False, edits.complete, ""),
136
+ }
137
+
138
+
139
+ def _write(args: argparse.Namespace, action: _WriteAction) -> None:
140
+ path, todo, snapshot = load(args)
141
+ task = refs.resolve(todo, args.ref)
142
+ if action.needs_open and not task.is_open:
143
+ raise TodoError(f"task {task.ref} is already completed")
144
+ if action.is_satisfied(task):
145
+ print(f"nothing to change: {task.ref} is {action.satisfied_reason}")
146
+ return
147
+ new_line = action.edit(todo.lines[task.line_no - 1])
148
+ store.write_if_unchanged(
149
+ path, snapshot, edits.apply_line_edit(todo.lines, task.line_no, new_line)
150
+ )
151
+ print(f"{action.verb}: {task.ref} {task.description}")
152
+
153
+
154
+ def _cmd_write(args: argparse.Namespace) -> None:
155
+ _write(args, _WRITE_ACTIONS[args.command])
156
+
157
+
158
+ def _cmd_next(args: argparse.Namespace) -> None:
159
+ if args.next_action is not None:
160
+ _write(args, _WRITE_ACTIONS[f"next {args.next_action}"])
161
+ return
162
+ _, todo, _ = load(args)
163
+ print(render.render_next(queries.next_pool(todo), today()))
164
+
165
+
166
+ def _cmd_list(args: argparse.Namespace) -> None:
167
+ _, todo, _ = load(args)
168
+ if args.project is not None and args.project not in {p.name for p in todo.projects}:
169
+ print(f"note: no project named '{args.project}'", file=sys.stderr)
170
+ print(render.render_list(todo, show_completed=args.all, project=args.project, today=today()))
171
+
172
+
173
+ def _cmd_init(args: argparse.Namespace) -> None:
174
+ path = store.locate(args.file, os.environ)
175
+ store.create(path, render.example_todo(today()))
176
+ print(f"created {path}")
177
+
178
+
179
+ _COMMANDS: dict[str, Callable[[argparse.Namespace], None]] = {
180
+ "summary": _cmd_summary,
181
+ "list": _cmd_list,
182
+ "next": _cmd_next,
183
+ "due": _cmd_due,
184
+ "init": _cmd_init,
185
+ "start": _cmd_write,
186
+ "stop": _cmd_write,
187
+ "done": _cmd_write,
188
+ }
189
+
190
+
191
+ def main(argv: list[str] | None = None) -> int:
192
+ parser = _build_parser()
193
+ try:
194
+ args = parser.parse_args(argv)
195
+ except SystemExit as exc:
196
+ return exc.code if isinstance(exc.code, int) else 2
197
+ try:
198
+ _COMMANDS[args.command or "summary"](args)
199
+ except TodoError as exc:
200
+ print(f"mdtodo: error: {exc}", file=sys.stderr)
201
+ return 1
202
+ return 0
@@ -0,0 +1,72 @@
1
+ """Pure single-line edits (contracts/file-format.md "Editing rules")."""
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+
6
+ from mdtodo.parser import TASK_PATTERN, indent_width
7
+
8
+ ACTIVE = "@active"
9
+ NEXT = "@next"
10
+
11
+ _CHECKBOX_PREFIX = re.compile(r"[ \t]*[-*+][ \t]+\[[ xX]\][ \t]*")
12
+
13
+
14
+ def _split_ending(line: str) -> tuple[str, str]:
15
+ content = line.rstrip("\r\n")
16
+ return content, line[len(content) :]
17
+
18
+
19
+ def _marker_pattern(marker: str) -> re.Pattern[str]:
20
+ return re.compile(rf"(?<!\S){re.escape(marker)}(?!\S)", re.IGNORECASE)
21
+
22
+
23
+ def add_marker(line: str, marker: str) -> str:
24
+ """Append ` marker` before the line ending, unless the marker is already present."""
25
+ content, ending = _split_ending(line)
26
+ if _marker_pattern(marker).search(content):
27
+ return line
28
+ return f"{content.rstrip(' \t')} {marker}{ending}"
29
+
30
+
31
+ def remove_marker(line: str, marker: str) -> str:
32
+ """Remove every occurrence of the marker word plus one adjacent space."""
33
+ content, ending = _split_ending(line)
34
+ while match := _marker_pattern(marker).search(content):
35
+ start, end = match.span()
36
+ if end < len(content) and content[end] in " \t":
37
+ end += 1
38
+ elif (
39
+ start > 0
40
+ and content[start - 1] in " \t"
41
+ and not _CHECKBOX_PREFIX.fullmatch(content[: start - 1])
42
+ ):
43
+ # Keep the separator right after the checkbox so the line stays a task.
44
+ start -= 1
45
+ content = content[:start] + content[end:]
46
+ return content + ending
47
+
48
+
49
+ def complete(line: str) -> str:
50
+ """Mark the task completed (`[x]`) and drop its @active and @next markers."""
51
+ content, ending = _split_ending(line)
52
+ match = TASK_PATTERN.match(content)
53
+ if match is None:
54
+ raise ValueError(f"not a task line: {line!r}")
55
+ box = match.start("box")
56
+ completed = content[:box] + "x" + content[box + 1 :] + ending
57
+ return remove_marker(remove_marker(completed, ACTIVE), NEXT)
58
+
59
+
60
+ def normalize_indent(line: str) -> str:
61
+ """Write the line's leading tabs as spaces, keeping its indent width (research R12)."""
62
+ rest = line.lstrip(" \t")
63
+ return " " * indent_width(line[: len(line) - len(rest)]) + rest
64
+
65
+
66
+ def apply_line_edit(lines: Sequence[str], line_no: int, new_line: str) -> str:
67
+ """Return the full text with only line `line_no` (1-based) replaced.
68
+
69
+ Its leading tabs are written as spaces (research R12).
70
+ """
71
+ index = line_no - 1
72
+ return "".join([*lines[:index], normalize_indent(new_line), *lines[index + 1 :]])
@@ -0,0 +1,73 @@
1
+ """Immutable domain model parsed from the todo file (see data-model.md)."""
2
+
3
+ from collections.abc import Iterator
4
+ from dataclasses import dataclass
5
+ from datetime import date
6
+ from typing import Literal
7
+
8
+ type Status = Literal["open", "completed"]
9
+
10
+ # One nesting level, shared by everything that reads or writes indentation (research R12).
11
+ INDENT = " "
12
+
13
+
14
+ class TodoError(Exception):
15
+ """Base class for errors reported to the user as `mdtodo: error: <message>`."""
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ParseWarning:
20
+ line_no: int
21
+ message: str
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Task:
26
+ ref: str
27
+ line_no: int
28
+ indent: int
29
+ description: str
30
+ raw_text: str
31
+ status: Status
32
+ active_marker: bool
33
+ next_marker: bool
34
+ deadline: date | None
35
+ deadline_raw: str | None
36
+ deadline_invalid: bool
37
+ parent_ref: str | None
38
+ project: str
39
+
40
+ @property
41
+ def is_open(self) -> bool:
42
+ return self.status == "open"
43
+
44
+ @property
45
+ def is_active(self) -> bool:
46
+ # Completion wins over the active marker (spec edge case).
47
+ return self.active_marker and self.is_open
48
+
49
+ @property
50
+ def is_next(self) -> bool:
51
+ return self.next_marker and self.is_open
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class Project:
56
+ name: str
57
+ heading_lines: tuple[int, ...]
58
+ tasks: tuple[Task, ...]
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class TodoFile:
63
+ lines: tuple[str, ...]
64
+ projects: tuple[Project, ...]
65
+ warnings: tuple[ParseWarning, ...]
66
+
67
+ def all_tasks(self) -> list[Task]:
68
+ """Every task in file order."""
69
+ return sorted(self._iter_tasks(), key=lambda task: task.line_no)
70
+
71
+ def _iter_tasks(self) -> Iterator[Task]:
72
+ for project in self.projects:
73
+ yield from project.tasks
@@ -0,0 +1,192 @@
1
+ """Line-oriented parser for the todo file format (contracts/file-format.md)."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from datetime import date
6
+
7
+ from mdtodo.model import INDENT, ParseWarning, Project, Status, Task, TodoFile
8
+ from mdtodo.refs import assign_refs
9
+
10
+ INBOX = "Inbox"
11
+
12
+ _FENCE = re.compile(r"^[ \t]*(?P<marker>```|~~~)")
13
+ _HEADING = re.compile(r"^#{1,6}[ \t]+(?P<name>.*?)(?:[ \t]+#+)?[ \t]*$")
14
+ TASK_PATTERN = re.compile(r"^(?P<indent>[ \t]*)[-*+][ \t]+\[(?P<box>[ xX])\][ \t]+(?P<text>.*)$")
15
+ _BAD_CHECKBOX = re.compile(r"^[ \t]*[-*+][ \t]+(?P<box>\[[^ xX\]]\])[ \t]+")
16
+
17
+ _ACTIVE = "@active"
18
+ _NEXT = "@next"
19
+ _DUE = "due:"
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Tokens:
24
+ description: str
25
+ active: bool
26
+ next: bool
27
+ deadline: date | None
28
+ deadline_raw: str | None
29
+ deadline_invalid: bool
30
+ warnings: tuple[str, ...]
31
+
32
+
33
+ def parse_tokens(text: str) -> Tokens:
34
+ """Split task text into its description and markers (research R5)."""
35
+ words: list[str] = []
36
+ warnings: list[str] = []
37
+ active = next_ = False
38
+ deadline: date | None = None
39
+ deadline_raw: str | None = None
40
+ for token in text.split():
41
+ lowered = token.lower()
42
+ if lowered == _ACTIVE:
43
+ active = True
44
+ elif lowered == _NEXT:
45
+ next_ = True
46
+ elif lowered.startswith(_DUE):
47
+ if deadline_raw is not None:
48
+ warnings.append("multiple deadlines; using the first")
49
+ continue
50
+ deadline_raw = token[len(_DUE) :]
51
+ deadline = _parse_date(deadline_raw)
52
+ if deadline is None:
53
+ warnings.append(f"invalid date: {token}")
54
+ else:
55
+ words.append(token)
56
+ return Tokens(
57
+ description=" ".join(words),
58
+ active=active,
59
+ next=next_,
60
+ deadline=deadline,
61
+ deadline_raw=deadline_raw,
62
+ deadline_invalid=deadline_raw is not None and deadline is None,
63
+ warnings=tuple(warnings),
64
+ )
65
+
66
+
67
+ def _parse_date(raw: str) -> date | None:
68
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
69
+ return None
70
+ try:
71
+ return date.fromisoformat(raw)
72
+ except ValueError:
73
+ return None
74
+
75
+
76
+ def indent_width(indent: str) -> int:
77
+ """Width in columns, where a tab is one nesting level."""
78
+ return sum(len(INDENT) if ch == "\t" else 1 for ch in indent)
79
+
80
+
81
+ @dataclass(slots=True)
82
+ class _RawTask:
83
+ line_no: int
84
+ indent: int
85
+ raw_text: str
86
+ status: Status
87
+ tokens: Tokens
88
+ project: str
89
+ parent_index: int | None
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class _ProjectBuilder:
94
+ heading_lines: list[int] = field(default_factory=list)
95
+ task_indices: list[int] = field(default_factory=list)
96
+
97
+
98
+ def parse(text: str) -> TodoFile:
99
+ lines = tuple(text.splitlines(keepends=True))
100
+ projects: dict[str, _ProjectBuilder] = {}
101
+ raw_tasks: list[_RawTask] = []
102
+ warnings: list[ParseWarning] = []
103
+ current = INBOX
104
+ # (indent, index into raw_tasks) of possible parents in the current project.
105
+ parent_stack: list[tuple[int, int]] = []
106
+ fence: str | None = None
107
+
108
+ for line_no, line in enumerate(lines, start=1):
109
+ content = line.rstrip("\r\n")
110
+
111
+ fence_match = _FENCE.match(content)
112
+ if fence is not None:
113
+ if fence_match and fence_match["marker"] == fence:
114
+ fence = None
115
+ continue
116
+ if fence_match:
117
+ fence = fence_match["marker"]
118
+ continue
119
+
120
+ heading = _HEADING.match(content)
121
+ if heading and heading["name"]:
122
+ current = heading["name"]
123
+ projects.setdefault(current, _ProjectBuilder()).heading_lines.append(line_no)
124
+ parent_stack = []
125
+ continue
126
+
127
+ task_match = TASK_PATTERN.match(content)
128
+ if task_match is None:
129
+ bad = _BAD_CHECKBOX.match(content)
130
+ if bad:
131
+ warnings.append(ParseWarning(line_no, f"unrecognized checkbox: {bad['box']}"))
132
+ continue
133
+
134
+ indent = indent_width(task_match["indent"])
135
+ status: Status = "open" if task_match["box"] == " " else "completed"
136
+ tokens = parse_tokens(task_match["text"])
137
+ warnings.extend(ParseWarning(line_no, message) for message in tokens.warnings)
138
+ if status == "completed":
139
+ for marker, present in ((_NEXT, tokens.next), (_ACTIVE, tokens.active)):
140
+ if present:
141
+ warnings.append(ParseWarning(line_no, f"marker on completed task: {marker}"))
142
+
143
+ while parent_stack and parent_stack[-1][0] >= indent:
144
+ parent_stack.pop()
145
+ parent_index = parent_stack[-1][1] if parent_stack else None
146
+
147
+ index = len(raw_tasks)
148
+ raw_tasks.append(
149
+ _RawTask(
150
+ line_no=line_no,
151
+ indent=indent,
152
+ raw_text=task_match["text"],
153
+ status=status,
154
+ tokens=tokens,
155
+ project=current,
156
+ parent_index=parent_index,
157
+ )
158
+ )
159
+ projects.setdefault(current, _ProjectBuilder()).task_indices.append(index)
160
+ parent_stack.append((indent, index))
161
+
162
+ task_refs = assign_refs([(raw.project, raw.tokens.description) for raw in raw_tasks])
163
+ tasks = [
164
+ Task(
165
+ ref=task_refs[i],
166
+ line_no=raw.line_no,
167
+ indent=raw.indent,
168
+ description=raw.tokens.description,
169
+ raw_text=raw.raw_text,
170
+ status=raw.status,
171
+ active_marker=raw.tokens.active,
172
+ next_marker=raw.tokens.next,
173
+ deadline=raw.tokens.deadline,
174
+ deadline_raw=raw.tokens.deadline_raw,
175
+ deadline_invalid=raw.tokens.deadline_invalid,
176
+ parent_ref=None if raw.parent_index is None else task_refs[raw.parent_index],
177
+ project=raw.project,
178
+ )
179
+ for i, raw in enumerate(raw_tasks)
180
+ ]
181
+ return TodoFile(
182
+ lines=lines,
183
+ projects=tuple(
184
+ Project(
185
+ name=name,
186
+ heading_lines=tuple(builder.heading_lines),
187
+ tasks=tuple(tasks[i] for i in builder.task_indices),
188
+ )
189
+ for name, builder in projects.items()
190
+ ),
191
+ warnings=tuple(sorted(warnings, key=lambda w: w.line_no)),
192
+ )
File without changes
@@ -0,0 +1,62 @@
1
+ """Read-only views over a parsed todo file (data-model.md "Queries")."""
2
+
3
+ from datetime import date
4
+ from typing import Literal
5
+
6
+ from mdtodo.model import Task, TodoFile
7
+
8
+
9
+ def project_counts(todo: TodoFile) -> list[tuple[str, int, int]]:
10
+ """(name, open, completed) for each project, in file order."""
11
+ return [
12
+ (
13
+ project.name,
14
+ sum(task.is_open for task in project.tasks),
15
+ sum(not task.is_open for task in project.tasks),
16
+ )
17
+ for project in todo.projects
18
+ ]
19
+
20
+
21
+ def _deadline_order(task: Task) -> tuple[bool, date, int]:
22
+ """Earliest deadline first, no (or invalid) deadline last, then file order."""
23
+ return (task.deadline is None, task.deadline or date.max, task.line_no)
24
+
25
+
26
+ def active_tasks(todo: TodoFile) -> list[Task]:
27
+ return [task for task in todo.all_tasks() if task.is_active]
28
+
29
+
30
+ def next_pool(todo: TodoFile) -> list[Task]:
31
+ """Open tasks marked @next in any project (FR-009)."""
32
+ return sorted((task for task in todo.all_tasks() if task.is_next), key=_deadline_order)
33
+
34
+
35
+ DUE_SOON_DAYS = 3
36
+
37
+ type DueState = Literal["overdue", "due_soon", "none"]
38
+
39
+
40
+ def due_state(task: Task, today: date) -> DueState:
41
+ """Overdue or due-soon flags apply only to open tasks with a valid deadline (FR-008)."""
42
+ if not task.is_open or task.deadline is None:
43
+ return "none"
44
+ days_left = (task.deadline - today).days
45
+ if days_left < 0:
46
+ return "overdue"
47
+ if days_left <= DUE_SOON_DAYS:
48
+ return "due_soon"
49
+ return "none"
50
+
51
+
52
+ def overdue(todo: TodoFile, today: date) -> list[Task]:
53
+ return [task for task in by_deadline(todo) if due_state(task, today) == "overdue"]
54
+
55
+
56
+ def due_soon(todo: TodoFile, today: date) -> list[Task]:
57
+ return [task for task in by_deadline(todo) if due_state(task, today) == "due_soon"]
58
+
59
+
60
+ def by_deadline(todo: TodoFile) -> list[Task]:
61
+ """All open tasks across projects, earliest deadline first, undated last (FR-011)."""
62
+ return sorted((task for task in todo.all_tasks() if task.is_open), key=_deadline_order)
@@ -0,0 +1,38 @@
1
+ """Stable, fixed-length task references (research R6)."""
2
+
3
+ import hashlib
4
+ from collections.abc import Sequence
5
+
6
+ from mdtodo.model import Task, TodoError, TodoFile
7
+
8
+ REF_LENGTH = 6
9
+
10
+
11
+ class RefNotFound(TodoError):
12
+ def __init__(self, ref: str) -> None:
13
+ super().__init__(
14
+ f"task not found: {ref} (it may have been edited; run 'mdtodo list' for current refs)"
15
+ )
16
+ self.ref = ref
17
+
18
+
19
+ def assign_refs(items: Sequence[tuple[str, str]]) -> list[str]:
20
+ """Return one ref per (project, description) pair, in the given file order."""
21
+ seen: dict[str, int] = {}
22
+ result: list[str] = []
23
+ for project, description in items:
24
+ digest = hashlib.sha1(f"{project}\n{description}".encode()).hexdigest()
25
+ base = digest[:REF_LENGTH]
26
+ count = seen.get(base, 0) + 1
27
+ seen[base] = count
28
+ result.append(base if count == 1 else f"{base}-{count}")
29
+ return result
30
+
31
+
32
+ def resolve(todo: TodoFile, ref: str) -> Task:
33
+ """Find the task whose ref matches exactly, ignoring case. Prefixes never match."""
34
+ wanted = ref.strip().lower()
35
+ for task in todo.all_tasks():
36
+ if task.ref == wanted:
37
+ return task
38
+ raise RefNotFound(ref)
@@ -0,0 +1,114 @@
1
+ """Plain-text output for the CLI (contracts/cli.md)."""
2
+
3
+ from collections.abc import Iterable, Sequence
4
+ from datetime import date, timedelta
5
+
6
+ from mdtodo.model import INDENT, ParseWarning, Task, TodoFile
7
+ from mdtodo.queries import due_state
8
+
9
+ EMPTY_LIST = "(no tasks)"
10
+ EMPTY_SECTION = f"{INDENT}(none)"
11
+ EMPTY_NEXT = "No next tasks. Mark one with: mdtodo next add <ref>"
12
+
13
+ _DUE_FLAGS = {"overdue": ", overdue", "due_soon": ", due soon", "none": ""}
14
+
15
+
16
+ def example_todo(today: date) -> str:
17
+ """Starter file for `mdtodo init`, with deadlines relative to today."""
18
+
19
+ def due(days: int) -> str:
20
+ return f"due:{(today + timedelta(days=days)).isoformat()}"
21
+
22
+ return f"""- [ ] Call the bank {due(3)}
23
+
24
+ ## Website
25
+ - [ ] Write landing copy @active {due(1)}
26
+ - [ ] Pick a color palette @next
27
+ {INDENT}- [ ] Shortlist 3 palettes
28
+ - [x] Buy domain
29
+
30
+ Notes and plain text like this line are ignored.
31
+
32
+ ## Garden
33
+ - [ ] Order seeds @next {due(14)}
34
+ - [ ] Fix the fence
35
+ """
36
+
37
+
38
+ def render_warnings(warnings: Iterable[ParseWarning]) -> str:
39
+ return "\n".join(f"warning: line {w.line_no}: {w.message}" for w in warnings)
40
+
41
+
42
+ def _deadline_suffix(task: Task, today: date) -> str:
43
+ if task.deadline_invalid:
44
+ return f" (due {task.deadline_raw}, invalid date)"
45
+ if task.deadline is None:
46
+ return ""
47
+ return f" (due {task.deadline.isoformat()}{_DUE_FLAGS[due_state(task, today)]})"
48
+
49
+
50
+ def render_task_line(task: Task, today: date, with_project: bool = True) -> str:
51
+ project = f"[{task.project}] " if with_project else ""
52
+ return f"{task.ref} {project}{task.description}{_deadline_suffix(task, today)}"
53
+
54
+
55
+ def _depths(tasks: Iterable[Task]) -> dict[str, int]:
56
+ depths: dict[str, int] = {}
57
+ for task in tasks: # file order guarantees parents come first
58
+ depths[task.ref] = 0 if task.parent_ref is None else depths[task.parent_ref] + 1
59
+ return depths
60
+
61
+
62
+ def render_list(todo: TodoFile, show_completed: bool, project: str | None, today: date) -> str:
63
+ depths = _depths(todo.all_tasks())
64
+ groups: list[str] = []
65
+ for proj in todo.projects:
66
+ if project is not None and proj.name != project:
67
+ continue
68
+ lines = [
69
+ f"{INDENT * depths[task.ref]}{task.ref} {'' if task.is_open else '[x] '}"
70
+ f"{task.description}{_deadline_suffix(task, today)}"
71
+ for task in proj.tasks
72
+ if show_completed or task.is_open
73
+ ]
74
+ if lines:
75
+ groups.append("\n".join([f"## {proj.name}", *lines]))
76
+ return "\n\n".join(groups) if groups else EMPTY_LIST
77
+
78
+
79
+ def render_projects_section(counts: Iterable[tuple[str, int, int]]) -> str:
80
+ lines = [f"{INDENT}{name} {open_} open, {done} done" for name, open_, done in counts]
81
+ return "\n".join(["Projects", *(lines or [EMPTY_SECTION])])
82
+
83
+
84
+ def _section(title: str, tasks: Sequence[Task], today: date) -> str:
85
+ lines = [f"{INDENT}{render_task_line(task, today)}" for task in tasks]
86
+ return "\n".join([title, *(lines or [EMPTY_SECTION])])
87
+
88
+
89
+ def render_summary( # noqa: PLR0913 - one argument per summary section (contracts/cli.md)
90
+ *,
91
+ active: Sequence[Task],
92
+ next_tasks: Sequence[Task],
93
+ overdue: Sequence[Task],
94
+ due_soon: Sequence[Task],
95
+ counts: Iterable[tuple[str, int, int]],
96
+ today: date,
97
+ ) -> str:
98
+ return "\n\n".join(
99
+ [
100
+ _section("Active", active, today),
101
+ _section("Next", next_tasks, today),
102
+ _section("Overdue", overdue, today),
103
+ _section("Due soon", due_soon, today),
104
+ render_projects_section(counts),
105
+ ]
106
+ )
107
+
108
+
109
+ def render_next(tasks: Sequence[Task], today: date) -> str:
110
+ return "\n".join(render_task_line(task, today) for task in tasks) if tasks else EMPTY_NEXT
111
+
112
+
113
+ def render_due(tasks: Sequence[Task], today: date) -> str:
114
+ return "\n".join(render_task_line(task, today) for task in tasks) if tasks else EMPTY_LIST
@@ -0,0 +1,96 @@
1
+ """Finding, reading, and safely writing the todo file (research R7, R8)."""
2
+
3
+ import contextlib
4
+ import os
5
+ import stat
6
+ import tempfile
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+
10
+ from mdtodo.model import TodoError
11
+
12
+ ENV_VAR = "MDTODO_FILE"
13
+ DEFAULT_NAME = "tracker.md"
14
+
15
+ __all__ = [
16
+ "FileChangedError",
17
+ "TodoError",
18
+ "TodoFileExists",
19
+ "TodoFileMissing",
20
+ "create",
21
+ "locate",
22
+ "read",
23
+ "write_if_unchanged",
24
+ ]
25
+
26
+
27
+ class TodoFileMissing(TodoError):
28
+ def __init__(self, path: Path) -> None:
29
+ super().__init__(f"no todo file at {path}. Create one with 'mdtodo init' or set {ENV_VAR}.")
30
+
31
+
32
+ class TodoFileExists(TodoError):
33
+ def __init__(self, path: Path) -> None:
34
+ super().__init__(f"{path} already exists; not overwriting it")
35
+
36
+
37
+ class FileChangedError(TodoError):
38
+ def __init__(self) -> None:
39
+ super().__init__("file changed since it was read; nothing was written, please re-run")
40
+
41
+
42
+ def locate(cli_path: str | None, env: Mapping[str, str]) -> Path:
43
+ """`--file`, then `$MDTODO_FILE`, then `~/tracker.md`."""
44
+ chosen = cli_path or env.get(ENV_VAR)
45
+ if chosen:
46
+ return Path(chosen).expanduser()
47
+ return Path.home() / DEFAULT_NAME
48
+
49
+
50
+ def read(path: Path) -> tuple[str, bytes]:
51
+ """Return the file's text (line endings untouched) and the raw bytes as a snapshot."""
52
+ try:
53
+ snapshot = path.read_bytes()
54
+ except FileNotFoundError:
55
+ raise TodoFileMissing(path) from None
56
+ except OSError as exc:
57
+ raise TodoError(f"cannot read {path}: {exc.strerror}") from exc
58
+ try:
59
+ return snapshot.decode("utf-8"), snapshot
60
+ except UnicodeDecodeError:
61
+ raise TodoError(f"cannot read {path}: not valid UTF-8") from None
62
+
63
+
64
+ def write_if_unchanged(path: Path, snapshot: bytes, new_text: str) -> None:
65
+ """Atomically replace the file, unless it changed since `snapshot` was read."""
66
+ if path.read_bytes() != snapshot:
67
+ raise FileChangedError
68
+ mode = stat.S_IMODE(path.stat().st_mode)
69
+ with tempfile.NamedTemporaryFile(
70
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False
71
+ ) as tmp:
72
+ tmp_path = Path(tmp.name)
73
+ try:
74
+ tmp.write(new_text.encode("utf-8"))
75
+ tmp.flush()
76
+ os.fsync(tmp.fileno())
77
+ except BaseException:
78
+ tmp.close()
79
+ tmp_path.unlink()
80
+ raise
81
+ try:
82
+ tmp_path.chmod(mode)
83
+ os.replace(tmp_path, path)
84
+ except BaseException:
85
+ with contextlib.suppress(FileNotFoundError):
86
+ tmp_path.unlink()
87
+ raise
88
+
89
+
90
+ def create(path: Path, text: str) -> None:
91
+ """Create a new todo file; refuse if one already exists."""
92
+ try:
93
+ with path.open("xb") as handle:
94
+ handle.write(text.encode("utf-8"))
95
+ except FileExistsError:
96
+ raise TodoFileExists(path) from None