mdtodo 0.1.0__py3-none-any.whl
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/__init__.py +5 -0
- mdtodo/__main__.py +3 -0
- mdtodo/cli.py +202 -0
- mdtodo/edits.py +72 -0
- mdtodo/model.py +73 -0
- mdtodo/parser.py +192 -0
- mdtodo/py.typed +0 -0
- mdtodo/queries.py +62 -0
- mdtodo/refs.py +38 -0
- mdtodo/render.py +114 -0
- mdtodo/store.py +96 -0
- mdtodo-0.1.0.dist-info/METADATA +120 -0
- mdtodo-0.1.0.dist-info/RECORD +16 -0
- mdtodo-0.1.0.dist-info/WHEEL +4 -0
- mdtodo-0.1.0.dist-info/entry_points.txt +3 -0
- mdtodo-0.1.0.dist-info/licenses/LICENSE +21 -0
mdtodo/__init__.py
ADDED
mdtodo/__main__.py
ADDED
mdtodo/cli.py
ADDED
|
@@ -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
|
mdtodo/edits.py
ADDED
|
@@ -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 :]])
|
mdtodo/model.py
ADDED
|
@@ -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
|
mdtodo/parser.py
ADDED
|
@@ -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
|
+
)
|
mdtodo/py.typed
ADDED
|
File without changes
|
mdtodo/queries.py
ADDED
|
@@ -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)
|
mdtodo/refs.py
ADDED
|
@@ -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)
|
mdtodo/render.py
ADDED
|
@@ -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
|
mdtodo/store.py
ADDED
|
@@ -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
|
|
@@ -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).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
mdtodo/__init__.py,sha256=BDmGFX0MDRczP4lmSkRfSPA018WMxOR8dPE3dL5P-R4,134
|
|
2
|
+
mdtodo/__main__.py,sha256=LZblgny2WLMCOdsjZ9_Rz_-JXguw4NUp7wAzcwWKXJ0,54
|
|
3
|
+
mdtodo/cli.py,sha256=msK7RqQ6oOT2knEDNn_fLGrhxLlyfK-Xag4mZNlKd5s,6809
|
|
4
|
+
mdtodo/edits.py,sha256=ItqrexNpzDOXUWsk4ujuvB4ytv_g7lEgtD05rr_QGFo,2526
|
|
5
|
+
mdtodo/model.py,sha256=7KngV9b1yFdzYGLAN9PsnHpTeameer8EdNXslQwc34w,1785
|
|
6
|
+
mdtodo/parser.py,sha256=LbOX6_MqNsRsiDVDkz5OxAgJf8hThBjSVBV32nC2S1o,6260
|
|
7
|
+
mdtodo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
mdtodo/queries.py,sha256=gX2OqWGMc0cc1qLbnEsUxGh2T7Wb-4voffsAUp0F5Ek,2045
|
|
9
|
+
mdtodo/refs.py,sha256=SC-mjHIwVyhEnUbyvUK3DaX6xqdoH63Q7yUh3ZZVkUU,1205
|
|
10
|
+
mdtodo/render.py,sha256=UE3kRDhowCxufq_e88kFVm1b4mXVBPbhaClWcxOGkG4,3886
|
|
11
|
+
mdtodo/store.py,sha256=QjGnImll8OQd8IyY0uOchZWqYJsHbDGSwBE9xcP9wQk,2868
|
|
12
|
+
mdtodo-0.1.0.dist-info/licenses/LICENSE,sha256=gocx1Z0fVJr9ixoGhIRFooNpHvwb5Uh5VQ3eVfk575Y,1065
|
|
13
|
+
mdtodo-0.1.0.dist-info/WHEEL,sha256=93kfTGt3a0Dykt_T-gsjtyS5_p8F_d6CE1NwmBOirzo,79
|
|
14
|
+
mdtodo-0.1.0.dist-info/entry_points.txt,sha256=exwBhvPpHxVgONqZiOS-jWT1OWisltwilf9XsSqAgDg,44
|
|
15
|
+
mdtodo-0.1.0.dist-info/METADATA,sha256=_gj5VI6gmDWM_IOpnDk1416mEnr_uiLKN3Zf8q1w6CQ,4478
|
|
16
|
+
mdtodo-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|