tsk-todo 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.
- tsk/__init__.py +0 -0
- tsk/cli.py +202 -0
- tsk/dates.py +122 -0
- tsk/model.py +137 -0
- tsk/parse.py +272 -0
- tsk/query.py +47 -0
- tsk/render.py +128 -0
- tsk/store.py +125 -0
- tsk_todo-0.1.0.dist-info/METADATA +270 -0
- tsk_todo-0.1.0.dist-info/RECORD +12 -0
- tsk_todo-0.1.0.dist-info/WHEEL +4 -0
- tsk_todo-0.1.0.dist-info/entry_points.txt +3 -0
tsk/__init__.py
ADDED
|
File without changes
|
tsk/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from collections.abc import Callable, Sequence
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import TextIO
|
|
6
|
+
|
|
7
|
+
from tsk.dates import describe
|
|
8
|
+
from tsk.model import Task, TaskNotFound, Tasks
|
|
9
|
+
from tsk.parse import (
|
|
10
|
+
Add,
|
|
11
|
+
Attributes,
|
|
12
|
+
Command,
|
|
13
|
+
Done,
|
|
14
|
+
Edit,
|
|
15
|
+
Help,
|
|
16
|
+
Listing,
|
|
17
|
+
Note,
|
|
18
|
+
ParseError,
|
|
19
|
+
Remove,
|
|
20
|
+
Show,
|
|
21
|
+
Version,
|
|
22
|
+
parse,
|
|
23
|
+
)
|
|
24
|
+
from tsk.query import Query
|
|
25
|
+
from tsk.render import Renderer, ink_for
|
|
26
|
+
from tsk.store import Store, StoreError, default_path
|
|
27
|
+
|
|
28
|
+
HELP = """tsk, a small task manager
|
|
29
|
+
|
|
30
|
+
usage
|
|
31
|
+
tsk list pending tasks
|
|
32
|
+
tsk add <text> [attrs] add a task
|
|
33
|
+
tsk done <id>... complete tasks
|
|
34
|
+
tsk rm <id>... delete tasks
|
|
35
|
+
tsk edit <id> <attrs> change a task
|
|
36
|
+
tsk note <id> <text> attach a note
|
|
37
|
+
tsk show <id> show one task in full
|
|
38
|
+
tsk list [filters] list tasks
|
|
39
|
+
|
|
40
|
+
attributes
|
|
41
|
+
@project set the project, bare @ clears it
|
|
42
|
+
+tag add a tag
|
|
43
|
+
-tag remove a tag, when editing
|
|
44
|
+
due:<when> set a due date, bare due: clears it
|
|
45
|
+
p:h|m|l set priority
|
|
46
|
+
every:<repeat> reschedule on completion
|
|
47
|
+
|
|
48
|
+
filters
|
|
49
|
+
@project +tag -tag match on project and tags
|
|
50
|
+
due:<when> due on or before a date
|
|
51
|
+
overdue past due
|
|
52
|
+
done completed tasks
|
|
53
|
+
all every task
|
|
54
|
+
<word> search descriptions and notes
|
|
55
|
+
|
|
56
|
+
when
|
|
57
|
+
today, tomorrow, yesterday
|
|
58
|
+
monday to sunday, or mon to sun
|
|
59
|
+
3d, 2w, 1m, 1y
|
|
60
|
+
eow, eom, eoy
|
|
61
|
+
2026-08-01
|
|
62
|
+
|
|
63
|
+
repeat
|
|
64
|
+
daily, weekdays, weekly, monthly, yearly
|
|
65
|
+
|
|
66
|
+
examples
|
|
67
|
+
tsk add water the plants @home +garden due:tomorrow every:weekly
|
|
68
|
+
tsk add fix the login bug @work p:h due:friday
|
|
69
|
+
tsk +garden
|
|
70
|
+
tsk done 3
|
|
71
|
+
tsk edit 3 due: p:l"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _utcnow() -> datetime:
|
|
75
|
+
return datetime.now(UTC)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class App:
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
store: Store,
|
|
82
|
+
out: TextIO,
|
|
83
|
+
err: TextIO,
|
|
84
|
+
now: Callable[[], datetime] = _utcnow,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._store = store
|
|
87
|
+
self._out = out
|
|
88
|
+
self._err = err
|
|
89
|
+
self._clock = now
|
|
90
|
+
self._today = now().astimezone().date()
|
|
91
|
+
self._renderer = Renderer(ink_for(out), today=self._today)
|
|
92
|
+
|
|
93
|
+
def run(self, argv: Sequence[str]) -> int:
|
|
94
|
+
try:
|
|
95
|
+
return self._execute(parse(argv, today=self._today))
|
|
96
|
+
except (ParseError, StoreError) as error:
|
|
97
|
+
return self._fail(str(error))
|
|
98
|
+
except TaskNotFound as error:
|
|
99
|
+
return self._fail(f"no task with id {error.args[0]}")
|
|
100
|
+
|
|
101
|
+
def _execute(self, command: Command) -> int:
|
|
102
|
+
match command:
|
|
103
|
+
case Help():
|
|
104
|
+
return self._emit(HELP)
|
|
105
|
+
case Version():
|
|
106
|
+
return self._emit(f"tsk {_installed_version()}")
|
|
107
|
+
case Listing(query):
|
|
108
|
+
return self._list(query)
|
|
109
|
+
case Show(task_id):
|
|
110
|
+
return self._show(task_id)
|
|
111
|
+
case Add(attributes):
|
|
112
|
+
return self._add(attributes)
|
|
113
|
+
case Done(ids):
|
|
114
|
+
return self._done(ids)
|
|
115
|
+
case Remove(ids):
|
|
116
|
+
return self._remove(ids)
|
|
117
|
+
case Edit(task_id, attributes):
|
|
118
|
+
return self._edit(task_id, attributes)
|
|
119
|
+
case Note(task_id, text):
|
|
120
|
+
return self._note(task_id, text)
|
|
121
|
+
|
|
122
|
+
def _list(self, query: Query) -> int:
|
|
123
|
+
tasks = self._store.read()
|
|
124
|
+
found = sorted(
|
|
125
|
+
(task for task in tasks if query.matches(task, self._today)),
|
|
126
|
+
key=Task.ordering,
|
|
127
|
+
)
|
|
128
|
+
return self._emit(self._renderer.table(found))
|
|
129
|
+
|
|
130
|
+
def _show(self, task_id: int) -> int:
|
|
131
|
+
return self._emit(self._renderer.detail(self._store.read().find(task_id)))
|
|
132
|
+
|
|
133
|
+
def _add(self, attributes: Attributes) -> int:
|
|
134
|
+
tasks = self._store.read()
|
|
135
|
+
blank = Task(id=tasks.next_id(), description="", created=self._clock())
|
|
136
|
+
task = attributes.apply(blank)
|
|
137
|
+
tasks.append(task)
|
|
138
|
+
self._store.write(tasks)
|
|
139
|
+
return self._emit(f"added {task.id}: {task.description}")
|
|
140
|
+
|
|
141
|
+
def _done(self, ids: Sequence[int]) -> int:
|
|
142
|
+
tasks = self._store.read()
|
|
143
|
+
lines: list[str] = []
|
|
144
|
+
for task_id in ids:
|
|
145
|
+
done, successor = tasks.find(task_id).complete(
|
|
146
|
+
now=self._clock(), today=self._today
|
|
147
|
+
)
|
|
148
|
+
tasks.update(done)
|
|
149
|
+
lines.append(f"done {done.id}: {done.description}")
|
|
150
|
+
if successor is not None:
|
|
151
|
+
lines.append(self._reschedule(successor, tasks))
|
|
152
|
+
self._store.write(tasks)
|
|
153
|
+
return self._emit("\n".join(lines))
|
|
154
|
+
|
|
155
|
+
def _reschedule(self, successor: Task, tasks: Tasks) -> str:
|
|
156
|
+
scheduled = replace(successor, id=tasks.next_id())
|
|
157
|
+
tasks.append(scheduled)
|
|
158
|
+
when = describe(scheduled.due, self._today) if scheduled.due else "no date"
|
|
159
|
+
return f" repeats as {scheduled.id}, due {when}"
|
|
160
|
+
|
|
161
|
+
def _remove(self, ids: Sequence[int]) -> int:
|
|
162
|
+
tasks = self._store.read()
|
|
163
|
+
lines: list[str] = []
|
|
164
|
+
for task_id in ids:
|
|
165
|
+
task = tasks.find(task_id)
|
|
166
|
+
tasks.discard(task_id)
|
|
167
|
+
lines.append(f"removed {task.id}: {task.description}")
|
|
168
|
+
self._store.write(tasks)
|
|
169
|
+
return self._emit("\n".join(lines))
|
|
170
|
+
|
|
171
|
+
def _edit(self, task_id: int, attributes: Attributes) -> int:
|
|
172
|
+
tasks = self._store.read()
|
|
173
|
+
task = attributes.apply(tasks.find(task_id))
|
|
174
|
+
tasks.update(task)
|
|
175
|
+
self._store.write(tasks)
|
|
176
|
+
return self._emit(f"updated {task.id}: {task.description}")
|
|
177
|
+
|
|
178
|
+
def _note(self, task_id: int, text: str) -> int:
|
|
179
|
+
tasks = self._store.read()
|
|
180
|
+
task = tasks.find(task_id).annotate(text)
|
|
181
|
+
tasks.update(task)
|
|
182
|
+
self._store.write(tasks)
|
|
183
|
+
return self._emit(f"noted on {task.id}: {task.description}")
|
|
184
|
+
|
|
185
|
+
def _emit(self, message: str) -> int:
|
|
186
|
+
print(message, file=self._out)
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
def _fail(self, message: str) -> int:
|
|
190
|
+
print(f"tsk: {message}", file=self._err)
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _installed_version() -> str:
|
|
195
|
+
from importlib.metadata import version
|
|
196
|
+
|
|
197
|
+
return version("tsk-todo")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main() -> int:
|
|
201
|
+
app = App(Store(default_path()), out=sys.stdout, err=sys.stderr)
|
|
202
|
+
return app.run(sys.argv[1:])
|
tsk/dates.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import date, timedelta
|
|
5
|
+
|
|
6
|
+
_ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}\Z")
|
|
7
|
+
_OFFSET = re.compile(r"(\d+)([dwmy])\Z")
|
|
8
|
+
_WEEKDAY_NAMES = (
|
|
9
|
+
"monday",
|
|
10
|
+
"tuesday",
|
|
11
|
+
"wednesday",
|
|
12
|
+
"thursday",
|
|
13
|
+
"friday",
|
|
14
|
+
"saturday",
|
|
15
|
+
"sunday",
|
|
16
|
+
)
|
|
17
|
+
_DAYS_PER_YEAR = 365
|
|
18
|
+
_SATURDAY = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DateError(ValueError):
|
|
22
|
+
"""Raised when a phrase cannot be read as a date."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_date(phrase: str, today: date) -> date:
|
|
26
|
+
text = phrase.strip().lower()
|
|
27
|
+
for resolve in (_from_iso, _from_name, _from_weekday, _from_offset):
|
|
28
|
+
when = resolve(text, today)
|
|
29
|
+
if when is not None:
|
|
30
|
+
return when
|
|
31
|
+
raise DateError(f"not a date: {phrase!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def describe(when: date, today: date) -> str:
|
|
35
|
+
days = (when - today).days
|
|
36
|
+
if days == 0:
|
|
37
|
+
return "today"
|
|
38
|
+
if days == 1:
|
|
39
|
+
return "tomorrow"
|
|
40
|
+
if days == -1:
|
|
41
|
+
return "yesterday"
|
|
42
|
+
if abs(days) > _DAYS_PER_YEAR:
|
|
43
|
+
return when.isoformat()
|
|
44
|
+
span = _span(abs(days))
|
|
45
|
+
return f"in {span}" if days > 0 else f"{span} ago"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _span(days: int) -> str:
|
|
49
|
+
if days < 7:
|
|
50
|
+
return _counted(days, "day")
|
|
51
|
+
if days < 30:
|
|
52
|
+
return _counted(days // 7, "week")
|
|
53
|
+
return _counted(days // 30, "month")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _counted(count: int, unit: str) -> str:
|
|
57
|
+
return f"{count} {unit}" if count == 1 else f"{count} {unit}s"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _from_iso(text: str, today: date) -> date | None:
|
|
61
|
+
if not _ISO_DATE.match(text):
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return date.fromisoformat(text)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _from_name(text: str, today: date) -> date | None:
|
|
70
|
+
resolve = _NAMED.get(text)
|
|
71
|
+
return resolve(today) if resolve else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _from_weekday(text: str, today: date) -> date | None:
|
|
75
|
+
for index, name in enumerate(_WEEKDAY_NAMES):
|
|
76
|
+
if text in (name, name[:3]):
|
|
77
|
+
return today + timedelta(days=(index - today.weekday()) % 7 or 7)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _from_offset(text: str, today: date) -> date | None:
|
|
82
|
+
match = _OFFSET.match(text)
|
|
83
|
+
if match is None:
|
|
84
|
+
return None
|
|
85
|
+
count, unit = int(match[1]), match[2]
|
|
86
|
+
if unit == "d":
|
|
87
|
+
return today + timedelta(days=count)
|
|
88
|
+
if unit == "w":
|
|
89
|
+
return today + timedelta(weeks=count)
|
|
90
|
+
return add_months(today, count * 12 if unit == "y" else count)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def add_months(start: date, months: int) -> date:
|
|
94
|
+
month_index = start.month - 1 + months
|
|
95
|
+
year = start.year + month_index // 12
|
|
96
|
+
month = month_index % 12 + 1
|
|
97
|
+
return date(year, month, min(start.day, calendar.monthrange(year, month)[1]))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _end_of_week(today: date) -> date:
|
|
101
|
+
return today + timedelta(days=6 - today.weekday())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _end_of_month(today: date) -> date:
|
|
105
|
+
return today.replace(day=calendar.monthrange(today.year, today.month)[1])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def next_weekday(after: date) -> date:
|
|
109
|
+
when = after + timedelta(days=1)
|
|
110
|
+
while when.weekday() >= _SATURDAY:
|
|
111
|
+
when += timedelta(days=1)
|
|
112
|
+
return when
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_NAMED: dict[str, Callable[[date], date]] = {
|
|
116
|
+
"today": lambda today: today,
|
|
117
|
+
"tomorrow": lambda today: today + timedelta(days=1),
|
|
118
|
+
"yesterday": lambda today: today - timedelta(days=1),
|
|
119
|
+
"eow": _end_of_week,
|
|
120
|
+
"eom": _end_of_month,
|
|
121
|
+
"eoy": lambda today: today.replace(month=12, day=31),
|
|
122
|
+
}
|
tsk/model.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
2
|
+
from dataclasses import dataclass, field, replace
|
|
3
|
+
from datetime import UTC, date, datetime, timedelta
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
from tsk.dates import add_months, next_weekday
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Status(StrEnum):
|
|
10
|
+
PENDING = "pending"
|
|
11
|
+
DONE = "done"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Priority(StrEnum):
|
|
15
|
+
HIGH = "high"
|
|
16
|
+
MEDIUM = "medium"
|
|
17
|
+
LOW = "low"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def rank(self) -> int:
|
|
21
|
+
return list(Priority).index(self)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Recurrence(StrEnum):
|
|
25
|
+
DAILY = "daily"
|
|
26
|
+
WEEKDAYS = "weekdays"
|
|
27
|
+
WEEKLY = "weekly"
|
|
28
|
+
MONTHLY = "monthly"
|
|
29
|
+
YEARLY = "yearly"
|
|
30
|
+
|
|
31
|
+
def after(self, when: date) -> date:
|
|
32
|
+
return _ADVANCE[self](when)
|
|
33
|
+
|
|
34
|
+
def upcoming(self, after: date, today: date) -> date:
|
|
35
|
+
when = self.after(after)
|
|
36
|
+
while when <= today:
|
|
37
|
+
when = self.after(when)
|
|
38
|
+
return when
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_ADVANCE: dict[Recurrence, Callable[[date], date]] = {
|
|
42
|
+
Recurrence.DAILY: lambda when: when + timedelta(days=1),
|
|
43
|
+
Recurrence.WEEKDAYS: next_weekday,
|
|
44
|
+
Recurrence.WEEKLY: lambda when: when + timedelta(weeks=1),
|
|
45
|
+
Recurrence.MONTHLY: lambda when: add_months(when, 1),
|
|
46
|
+
Recurrence.YEARLY: lambda when: add_months(when, 12),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _utcnow() -> datetime:
|
|
51
|
+
return datetime.now(UTC)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class Task:
|
|
56
|
+
id: int
|
|
57
|
+
description: str
|
|
58
|
+
status: Status = Status.PENDING
|
|
59
|
+
project: str | None = None
|
|
60
|
+
tags: frozenset[str] = frozenset()
|
|
61
|
+
due: date | None = None
|
|
62
|
+
priority: Priority | None = None
|
|
63
|
+
every: Recurrence | None = None
|
|
64
|
+
notes: tuple[str, ...] = ()
|
|
65
|
+
created: datetime = field(default_factory=_utcnow)
|
|
66
|
+
completed: datetime | None = None
|
|
67
|
+
|
|
68
|
+
def complete(self, now: datetime, today: date) -> tuple["Task", "Task | None"]:
|
|
69
|
+
done = replace(self, status=Status.DONE, completed=now)
|
|
70
|
+
return done, self._successor(now, today)
|
|
71
|
+
|
|
72
|
+
def annotate(self, note: str) -> "Task":
|
|
73
|
+
return replace(self, notes=(*self.notes, note))
|
|
74
|
+
|
|
75
|
+
def is_overdue(self, today: date) -> bool:
|
|
76
|
+
return (
|
|
77
|
+
self.status is Status.PENDING and self.due is not None and self.due < today
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def ordering(self) -> tuple[date, int, int]:
|
|
81
|
+
return (
|
|
82
|
+
self.due or date.max,
|
|
83
|
+
self.priority.rank if self.priority else len(Priority),
|
|
84
|
+
self.id,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _successor(self, now: datetime, today: date) -> "Task | None":
|
|
88
|
+
if self.every is None or self.due is None:
|
|
89
|
+
return None
|
|
90
|
+
return replace(
|
|
91
|
+
self,
|
|
92
|
+
due=self.every.upcoming(self.due, today),
|
|
93
|
+
notes=(),
|
|
94
|
+
created=now,
|
|
95
|
+
completed=None,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TaskNotFound(LookupError):
|
|
100
|
+
"""Raised when no task carries the given id."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Tasks:
|
|
104
|
+
def __init__(self, items: Iterable[Task] = (), issued: int = 0) -> None:
|
|
105
|
+
self._items = list(items)
|
|
106
|
+
self._issued = max([issued, *(task.id for task in self._items)])
|
|
107
|
+
|
|
108
|
+
def __iter__(self) -> Iterator[Task]:
|
|
109
|
+
return iter(self._items)
|
|
110
|
+
|
|
111
|
+
def __len__(self) -> int:
|
|
112
|
+
return len(self._items)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def issued(self) -> int:
|
|
116
|
+
return self._issued
|
|
117
|
+
|
|
118
|
+
def next_id(self) -> int:
|
|
119
|
+
self._issued += 1
|
|
120
|
+
return self._issued
|
|
121
|
+
|
|
122
|
+
def find(self, task_id: int) -> Task:
|
|
123
|
+
for task in self._items:
|
|
124
|
+
if task.id == task_id:
|
|
125
|
+
return task
|
|
126
|
+
raise TaskNotFound(task_id)
|
|
127
|
+
|
|
128
|
+
def append(self, task: Task) -> None:
|
|
129
|
+
self._items.append(task)
|
|
130
|
+
|
|
131
|
+
def update(self, task: Task) -> None:
|
|
132
|
+
self.find(task.id)
|
|
133
|
+
self._items = [task if item.id == task.id else item for item in self._items]
|
|
134
|
+
|
|
135
|
+
def discard(self, task_id: int) -> None:
|
|
136
|
+
self.find(task_id)
|
|
137
|
+
self._items = [item for item in self._items if item.id != task_id]
|
tsk/parse.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
2
|
+
from dataclasses import dataclass, field, replace
|
|
3
|
+
from datetime import date
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from tsk.dates import DateError, parse_date
|
|
7
|
+
from tsk.model import Priority, Recurrence, Status, Task
|
|
8
|
+
from tsk.query import Query
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ParseError(ValueError):
|
|
12
|
+
"""Raised when a command line cannot be understood."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Attributes:
|
|
17
|
+
words: tuple[str, ...] = ()
|
|
18
|
+
changes: Mapping[str, Any] = field(default_factory=dict)
|
|
19
|
+
add_tags: frozenset[str] = frozenset()
|
|
20
|
+
remove_tags: frozenset[str] = frozenset()
|
|
21
|
+
|
|
22
|
+
def apply(self, task: Task) -> Task:
|
|
23
|
+
updates = dict(self.changes)
|
|
24
|
+
if self.words:
|
|
25
|
+
updates["description"] = " ".join(self.words)
|
|
26
|
+
tags = (task.tags | self.add_tags) - self.remove_tags
|
|
27
|
+
return replace(task, tags=tags, **updates)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Add:
|
|
32
|
+
attributes: Attributes
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Listing:
|
|
37
|
+
query: Query
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Done:
|
|
42
|
+
ids: tuple[int, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Remove:
|
|
47
|
+
ids: tuple[int, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Edit:
|
|
52
|
+
id: int
|
|
53
|
+
attributes: Attributes
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Note:
|
|
58
|
+
id: int
|
|
59
|
+
text: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Show:
|
|
64
|
+
id: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Help:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Version:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
Command = Add | Listing | Done | Remove | Edit | Note | Show | Help | Version
|
|
78
|
+
|
|
79
|
+
_HELP_WORDS = frozenset({"help", "--help", "-h"})
|
|
80
|
+
_VERSION_WORDS = frozenset({"version", "--version"})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse(argv: Sequence[str], today: date) -> Command:
|
|
84
|
+
if not argv:
|
|
85
|
+
return Listing(Query())
|
|
86
|
+
head, *rest = argv
|
|
87
|
+
if head in _HELP_WORDS:
|
|
88
|
+
return Help()
|
|
89
|
+
if head in _VERSION_WORDS:
|
|
90
|
+
return Version()
|
|
91
|
+
verb = _VERBS.get(head)
|
|
92
|
+
if verb is not None:
|
|
93
|
+
return verb(rest, today)
|
|
94
|
+
if len(argv) == 1 and _is_id(head):
|
|
95
|
+
return Show(int(head))
|
|
96
|
+
return _listing(argv, today)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _add(tokens: Sequence[str], today: date) -> Command:
|
|
100
|
+
attributes = _attributes(tokens, today, removals=False)
|
|
101
|
+
if not attributes.words:
|
|
102
|
+
raise ParseError("add needs a description")
|
|
103
|
+
return Add(attributes)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _done(tokens: Sequence[str], today: date) -> Command:
|
|
107
|
+
return Done(_ids(tokens, "done"))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _remove(tokens: Sequence[str], today: date) -> Command:
|
|
111
|
+
return Remove(_ids(tokens, "rm"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _edit(tokens: Sequence[str], today: date) -> Command:
|
|
115
|
+
task_id, rest = _leading_id(tokens, "edit")
|
|
116
|
+
if not rest:
|
|
117
|
+
raise ParseError("edit needs something to change")
|
|
118
|
+
return Edit(task_id, _attributes(rest, today, removals=True))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _note(tokens: Sequence[str], today: date) -> Command:
|
|
122
|
+
task_id, rest = _leading_id(tokens, "note")
|
|
123
|
+
if not rest:
|
|
124
|
+
raise ParseError("note needs some text")
|
|
125
|
+
return Note(task_id, " ".join(rest))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _show(tokens: Sequence[str], today: date) -> Command:
|
|
129
|
+
ids = _ids(tokens, "show")
|
|
130
|
+
if len(ids) > 1:
|
|
131
|
+
raise ParseError("show takes a single task id")
|
|
132
|
+
return Show(ids[0])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _listing(tokens: Sequence[str], today: date) -> Command:
|
|
136
|
+
status: Status | None = Status.PENDING
|
|
137
|
+
tags: set[str] = set()
|
|
138
|
+
without_tags: set[str] = set()
|
|
139
|
+
terms: list[str] = []
|
|
140
|
+
project: str | None = None
|
|
141
|
+
due_by: date | None = None
|
|
142
|
+
overdue = False
|
|
143
|
+
|
|
144
|
+
for token in tokens:
|
|
145
|
+
key, separator, value = token.partition(":")
|
|
146
|
+
if token == "done":
|
|
147
|
+
status = Status.DONE
|
|
148
|
+
elif token == "all":
|
|
149
|
+
status = None
|
|
150
|
+
elif token == "overdue":
|
|
151
|
+
overdue = True
|
|
152
|
+
elif _prefixed(token, "@"):
|
|
153
|
+
project = token[1:]
|
|
154
|
+
elif _prefixed(token, "+"):
|
|
155
|
+
tags.add(token[1:])
|
|
156
|
+
elif _prefixed(token, "-"):
|
|
157
|
+
without_tags.add(token[1:])
|
|
158
|
+
elif separator and key == "due":
|
|
159
|
+
due_by = _read_due(value, today)
|
|
160
|
+
else:
|
|
161
|
+
terms.append(token)
|
|
162
|
+
|
|
163
|
+
return Listing(
|
|
164
|
+
Query(
|
|
165
|
+
status=status,
|
|
166
|
+
tags=frozenset(tags),
|
|
167
|
+
without_tags=frozenset(without_tags),
|
|
168
|
+
project=project,
|
|
169
|
+
due_by=due_by,
|
|
170
|
+
overdue=overdue,
|
|
171
|
+
terms=tuple(terms),
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _attributes(tokens: Sequence[str], today: date, *, removals: bool) -> Attributes:
|
|
177
|
+
words: list[str] = []
|
|
178
|
+
changes: dict[str, Any] = {}
|
|
179
|
+
add_tags: set[str] = set()
|
|
180
|
+
remove_tags: set[str] = set()
|
|
181
|
+
|
|
182
|
+
for token in tokens:
|
|
183
|
+
key, separator, value = token.partition(":")
|
|
184
|
+
if token == "@":
|
|
185
|
+
changes["project"] = None
|
|
186
|
+
elif _prefixed(token, "@"):
|
|
187
|
+
changes["project"] = token[1:]
|
|
188
|
+
elif _prefixed(token, "+"):
|
|
189
|
+
add_tags.add(token[1:])
|
|
190
|
+
elif removals and _prefixed(token, "-"):
|
|
191
|
+
remove_tags.add(token[1:])
|
|
192
|
+
elif separator and key in _ATTRIBUTES:
|
|
193
|
+
name, read = _ATTRIBUTES[key]
|
|
194
|
+
changes[name] = read(value, today)
|
|
195
|
+
else:
|
|
196
|
+
words.append(token)
|
|
197
|
+
|
|
198
|
+
return Attributes(
|
|
199
|
+
tuple(words), changes, frozenset(add_tags), frozenset(remove_tags)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _prefixed(token: str, marker: str) -> bool:
|
|
204
|
+
return len(token) > 1 and token.startswith(marker)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _is_id(token: str) -> bool:
|
|
208
|
+
return token.isdigit()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ids(tokens: Sequence[str], verb: str) -> tuple[int, ...]:
|
|
212
|
+
if not tokens:
|
|
213
|
+
raise ParseError(f"{verb} needs a task id")
|
|
214
|
+
for token in tokens:
|
|
215
|
+
if not _is_id(token):
|
|
216
|
+
raise ParseError(f"not a task id: {token!r}")
|
|
217
|
+
return tuple(int(token) for token in tokens)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _leading_id(tokens: Sequence[str], verb: str) -> tuple[int, Sequence[str]]:
|
|
221
|
+
if not tokens:
|
|
222
|
+
raise ParseError(f"{verb} needs a task id")
|
|
223
|
+
head, *rest = tokens
|
|
224
|
+
if not _is_id(head):
|
|
225
|
+
raise ParseError(f"not a task id: {head!r}")
|
|
226
|
+
return int(head), rest
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _read_due(value: str, today: date) -> date | None:
|
|
230
|
+
if not value:
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
return parse_date(value, today)
|
|
234
|
+
except DateError as error:
|
|
235
|
+
raise ParseError(str(error)) from error
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _read_priority(value: str, today: date) -> Priority | None:
|
|
239
|
+
if not value:
|
|
240
|
+
return None
|
|
241
|
+
for priority in Priority:
|
|
242
|
+
if value in (priority.value, priority.value[0]):
|
|
243
|
+
return priority
|
|
244
|
+
choices = ", ".join(item.value[0] for item in Priority)
|
|
245
|
+
raise ParseError(f"not a priority: {value!r} (use {choices})")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _read_recurrence(value: str, today: date) -> Recurrence | None:
|
|
249
|
+
if not value:
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
return Recurrence(value)
|
|
253
|
+
except ValueError:
|
|
254
|
+
choices = ", ".join(item.value for item in Recurrence)
|
|
255
|
+
raise ParseError(f"not a repeat: {value!r} (use {choices})") from None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_ATTRIBUTES: dict[str, tuple[str, Callable[[str, date], Any]]] = {
|
|
259
|
+
"due": ("due", _read_due),
|
|
260
|
+
"p": ("priority", _read_priority),
|
|
261
|
+
"every": ("every", _read_recurrence),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_VERBS: dict[str, Callable[[Sequence[str], date], Command]] = {
|
|
265
|
+
"add": _add,
|
|
266
|
+
"list": _listing,
|
|
267
|
+
"done": _done,
|
|
268
|
+
"rm": _remove,
|
|
269
|
+
"edit": _edit,
|
|
270
|
+
"note": _note,
|
|
271
|
+
"show": _show,
|
|
272
|
+
}
|
tsk/query.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
from tsk.model import Status, Task
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class Query:
|
|
9
|
+
status: Status | None = Status.PENDING
|
|
10
|
+
tags: frozenset[str] = frozenset()
|
|
11
|
+
without_tags: frozenset[str] = frozenset()
|
|
12
|
+
project: str | None = None
|
|
13
|
+
due_by: date | None = None
|
|
14
|
+
overdue: bool = False
|
|
15
|
+
terms: tuple[str, ...] = ()
|
|
16
|
+
|
|
17
|
+
def matches(self, task: Task, today: date) -> bool:
|
|
18
|
+
return all(
|
|
19
|
+
test(task, today)
|
|
20
|
+
for test in (
|
|
21
|
+
self._status_matches,
|
|
22
|
+
self._tags_match,
|
|
23
|
+
self._project_matches,
|
|
24
|
+
self._dates_match,
|
|
25
|
+
self._terms_match,
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def _status_matches(self, task: Task, today: date) -> bool:
|
|
30
|
+
return self.status is None or task.status is self.status
|
|
31
|
+
|
|
32
|
+
def _tags_match(self, task: Task, today: date) -> bool:
|
|
33
|
+
return self.tags <= task.tags and not (self.without_tags & task.tags)
|
|
34
|
+
|
|
35
|
+
def _project_matches(self, task: Task, today: date) -> bool:
|
|
36
|
+
return self.project is None or task.project == self.project
|
|
37
|
+
|
|
38
|
+
def _dates_match(self, task: Task, today: date) -> bool:
|
|
39
|
+
if self.overdue and not task.is_overdue(today):
|
|
40
|
+
return False
|
|
41
|
+
if self.due_by is None:
|
|
42
|
+
return True
|
|
43
|
+
return task.due is not None and task.due <= self.due_by
|
|
44
|
+
|
|
45
|
+
def _terms_match(self, task: Task, today: date) -> bool:
|
|
46
|
+
haystack = " ".join((task.description, *task.notes)).casefold()
|
|
47
|
+
return all(term.casefold() in haystack for term in self.terms)
|
tsk/render.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Iterator, Sequence
|
|
3
|
+
from datetime import date
|
|
4
|
+
from typing import TextIO
|
|
5
|
+
|
|
6
|
+
from tsk.dates import describe
|
|
7
|
+
from tsk.model import Priority, Status, Task
|
|
8
|
+
|
|
9
|
+
_CODES = {
|
|
10
|
+
"bold": "1",
|
|
11
|
+
"dim": "2",
|
|
12
|
+
"red": "31",
|
|
13
|
+
"green": "32",
|
|
14
|
+
"yellow": "33",
|
|
15
|
+
"cyan": "36",
|
|
16
|
+
"magenta": "35",
|
|
17
|
+
}
|
|
18
|
+
_PRIORITY_STYLES = {
|
|
19
|
+
Priority.HIGH: "red",
|
|
20
|
+
Priority.MEDIUM: "yellow",
|
|
21
|
+
Priority.LOW: "dim",
|
|
22
|
+
}
|
|
23
|
+
_GAP = " "
|
|
24
|
+
_LABEL_WIDTH = 11
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Ink:
|
|
28
|
+
def __init__(self, *, enabled: bool) -> None:
|
|
29
|
+
self._enabled = enabled
|
|
30
|
+
|
|
31
|
+
def paint(self, text: str, style: str) -> str:
|
|
32
|
+
if not self._enabled or not text.strip():
|
|
33
|
+
return text
|
|
34
|
+
return f"\x1b[{_CODES[style]}m{text}\x1b[0m"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ink_for(stream: TextIO) -> Ink:
|
|
38
|
+
return Ink(enabled=stream.isatty() and "NO_COLOR" not in os.environ)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Layout:
|
|
42
|
+
def __init__(self, tasks: Sequence[Task], today: date) -> None:
|
|
43
|
+
spans = [describe(task.due, today) for task in tasks if task.due]
|
|
44
|
+
self.id_width = max(len(str(task.id)) for task in tasks)
|
|
45
|
+
self.due_width = max((len(span) for span in spans), default=0)
|
|
46
|
+
self.show_due = bool(spans)
|
|
47
|
+
self.show_priority = any(task.priority for task in tasks)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Renderer:
|
|
51
|
+
def __init__(self, ink: Ink, today: date) -> None:
|
|
52
|
+
self._ink = ink
|
|
53
|
+
self._today = today
|
|
54
|
+
|
|
55
|
+
def table(self, tasks: Sequence[Task]) -> str:
|
|
56
|
+
if not tasks:
|
|
57
|
+
return "nothing to do"
|
|
58
|
+
layout = _Layout(tasks, self._today)
|
|
59
|
+
return "\n".join(self._row(task, layout) for task in tasks)
|
|
60
|
+
|
|
61
|
+
def detail(self, task: Task) -> str:
|
|
62
|
+
lines = [f"{self._ink.paint(str(task.id), 'dim')}{_GAP}{task.description}", ""]
|
|
63
|
+
lines.extend(
|
|
64
|
+
f"{_GAP}{self._ink.paint(label.ljust(_LABEL_WIDTH), 'dim')}{value}"
|
|
65
|
+
for label, value in self._facts(task)
|
|
66
|
+
)
|
|
67
|
+
if task.notes:
|
|
68
|
+
lines.extend(["", f"{_GAP}{self._ink.paint('notes', 'dim')}"])
|
|
69
|
+
lines.extend(f"{_GAP} {note}" for note in task.notes)
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
def _row(self, task: Task, layout: _Layout) -> str:
|
|
73
|
+
cells = [self._ink.paint(str(task.id).rjust(layout.id_width), "dim")]
|
|
74
|
+
if layout.show_priority:
|
|
75
|
+
cells.append(self._priority_cell(task))
|
|
76
|
+
if layout.show_due:
|
|
77
|
+
cells.append(self._due_cell(task, layout.due_width))
|
|
78
|
+
cells.append(self._summary(task))
|
|
79
|
+
return _GAP.join(cells)
|
|
80
|
+
|
|
81
|
+
def _priority_cell(self, task: Task) -> str:
|
|
82
|
+
if task.priority is None:
|
|
83
|
+
return " "
|
|
84
|
+
letter = task.priority.value[0].upper()
|
|
85
|
+
return self._ink.paint(letter, _PRIORITY_STYLES[task.priority])
|
|
86
|
+
|
|
87
|
+
def _due_cell(self, task: Task, width: int) -> str:
|
|
88
|
+
if task.due is None:
|
|
89
|
+
return " " * width
|
|
90
|
+
span = describe(task.due, self._today).ljust(width)
|
|
91
|
+
return self._ink.paint(span, self._due_style(task))
|
|
92
|
+
|
|
93
|
+
def _due_style(self, task: Task) -> str:
|
|
94
|
+
if task.is_overdue(self._today):
|
|
95
|
+
return "red"
|
|
96
|
+
if task.due == self._today and task.status is Status.PENDING:
|
|
97
|
+
return "yellow"
|
|
98
|
+
return "dim"
|
|
99
|
+
|
|
100
|
+
def _summary(self, task: Task) -> str:
|
|
101
|
+
description = task.description
|
|
102
|
+
if task.status is Status.DONE:
|
|
103
|
+
description = self._ink.paint(description, "dim")
|
|
104
|
+
parts = [description]
|
|
105
|
+
if task.project:
|
|
106
|
+
parts.append(self._ink.paint(f"@{task.project}", "cyan"))
|
|
107
|
+
parts.extend(self._ink.paint(f"+{tag}", "magenta") for tag in sorted(task.tags))
|
|
108
|
+
if task.every:
|
|
109
|
+
parts.append(self._ink.paint(f"every:{task.every.value}", "dim"))
|
|
110
|
+
if task.notes:
|
|
111
|
+
parts.append(self._ink.paint("*", "dim"))
|
|
112
|
+
return " ".join(parts)
|
|
113
|
+
|
|
114
|
+
def _facts(self, task: Task) -> Iterator[tuple[str, str]]:
|
|
115
|
+
yield "status", task.status.value
|
|
116
|
+
if task.project:
|
|
117
|
+
yield "project", task.project
|
|
118
|
+
if task.tags:
|
|
119
|
+
yield "tags", ", ".join(sorted(task.tags))
|
|
120
|
+
if task.due:
|
|
121
|
+
yield "due", f"{describe(task.due, self._today)} ({task.due.isoformat()})"
|
|
122
|
+
if task.priority:
|
|
123
|
+
yield "priority", task.priority.value
|
|
124
|
+
if task.every:
|
|
125
|
+
yield "repeats", task.every.value
|
|
126
|
+
yield "created", task.created.date().isoformat()
|
|
127
|
+
if task.completed:
|
|
128
|
+
yield "completed", task.completed.date().isoformat()
|
tsk/store.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from tsk.model import Priority, Recurrence, Status, Task, Tasks
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StoreError(RuntimeError):
|
|
12
|
+
"""Raised when the task file cannot be used."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Store:
|
|
16
|
+
VERSION = 1
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: Path) -> None:
|
|
19
|
+
self.path = path
|
|
20
|
+
|
|
21
|
+
def read(self) -> Tasks:
|
|
22
|
+
if not self.path.exists():
|
|
23
|
+
return Tasks()
|
|
24
|
+
document = self._document()
|
|
25
|
+
if document.get("version", 0) > self.VERSION:
|
|
26
|
+
raise StoreError(f"{self.path} was written by a newer version of tsk")
|
|
27
|
+
try:
|
|
28
|
+
return Tasks(
|
|
29
|
+
(_revive(entry) for entry in document.get("tasks", ())),
|
|
30
|
+
issued=document.get("issued", 0),
|
|
31
|
+
)
|
|
32
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
33
|
+
raise StoreError(f"{self.path} is unreadable: {error}") from error
|
|
34
|
+
|
|
35
|
+
def write(self, tasks: Tasks) -> None:
|
|
36
|
+
document = {
|
|
37
|
+
"version": self.VERSION,
|
|
38
|
+
"issued": tasks.issued,
|
|
39
|
+
"tasks": [_record(task) for task in tasks],
|
|
40
|
+
}
|
|
41
|
+
_write_atomically(self.path, json.dumps(document, indent=2) + "\n")
|
|
42
|
+
|
|
43
|
+
def _document(self) -> dict[str, Any]:
|
|
44
|
+
try:
|
|
45
|
+
document = json.loads(self.path.read_text(encoding="utf-8"))
|
|
46
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
47
|
+
raise StoreError(f"{self.path} is unreadable: {error}") from error
|
|
48
|
+
if not isinstance(document, dict):
|
|
49
|
+
raise StoreError(f"{self.path} is unreadable: expected a JSON object")
|
|
50
|
+
return document
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_path() -> Path:
|
|
54
|
+
override = os.environ.get("TSK_DATA")
|
|
55
|
+
if override:
|
|
56
|
+
return Path(override)
|
|
57
|
+
data_home = os.environ.get("XDG_DATA_HOME")
|
|
58
|
+
root = Path(data_home) if data_home else Path.home() / ".local" / "share"
|
|
59
|
+
return root / "tsk" / "tasks.json"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _record(task: Task) -> dict[str, Any]:
|
|
63
|
+
always: dict[str, Any] = {
|
|
64
|
+
"id": task.id,
|
|
65
|
+
"description": task.description,
|
|
66
|
+
"status": task.status.value,
|
|
67
|
+
"created": task.created.isoformat(),
|
|
68
|
+
}
|
|
69
|
+
sometimes: dict[str, Any] = {
|
|
70
|
+
"project": task.project,
|
|
71
|
+
"tags": sorted(task.tags),
|
|
72
|
+
"due": task.due.isoformat() if task.due else None,
|
|
73
|
+
"priority": task.priority.value if task.priority else None,
|
|
74
|
+
"every": task.every.value if task.every else None,
|
|
75
|
+
"notes": list(task.notes),
|
|
76
|
+
"completed": task.completed.isoformat() if task.completed else None,
|
|
77
|
+
}
|
|
78
|
+
return always | {key: value for key, value in sometimes.items() if value}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _revive(entry: dict[str, Any]) -> Task:
|
|
82
|
+
return Task(
|
|
83
|
+
id=entry["id"],
|
|
84
|
+
description=entry["description"],
|
|
85
|
+
status=Status(entry["status"]),
|
|
86
|
+
project=entry.get("project"),
|
|
87
|
+
tags=frozenset(entry.get("tags", ())),
|
|
88
|
+
due=date.fromisoformat(entry["due"]) if "due" in entry else None,
|
|
89
|
+
priority=Priority(entry["priority"]) if "priority" in entry else None,
|
|
90
|
+
every=Recurrence(entry["every"]) if "every" in entry else None,
|
|
91
|
+
notes=tuple(entry.get("notes", ())),
|
|
92
|
+
created=datetime.fromisoformat(entry["created"]),
|
|
93
|
+
completed=(
|
|
94
|
+
datetime.fromisoformat(entry["completed"]) if "completed" in entry else None
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write_atomically(path: Path, text: str) -> None:
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
inherited = path.stat().st_mode & 0o777 if path.exists() else None
|
|
102
|
+
handle, name = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
|
|
103
|
+
temporary = Path(name)
|
|
104
|
+
try:
|
|
105
|
+
with os.fdopen(handle, "w", encoding="utf-8") as opened:
|
|
106
|
+
opened.write(text)
|
|
107
|
+
opened.flush()
|
|
108
|
+
os.fsync(opened.fileno())
|
|
109
|
+
if inherited is not None:
|
|
110
|
+
temporary.chmod(inherited)
|
|
111
|
+
temporary.replace(path)
|
|
112
|
+
_fsync_directory(path.parent)
|
|
113
|
+
except BaseException:
|
|
114
|
+
temporary.unlink(missing_ok=True)
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fsync_directory(directory: Path) -> None:
|
|
119
|
+
if os.name != "posix":
|
|
120
|
+
return
|
|
121
|
+
handle = os.open(directory, os.O_RDONLY)
|
|
122
|
+
try:
|
|
123
|
+
os.fsync(handle)
|
|
124
|
+
finally:
|
|
125
|
+
os.close(handle)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tsk-todo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal todo CLI for the terminal. Pure Python, zero dependencies.
|
|
5
|
+
Keywords: cli,todo,task,task-manager,productivity,terminal,gtd
|
|
6
|
+
Author: michaelrbarley
|
|
7
|
+
Author-email: michaelrbarley <301309661+michaelrbarley@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Project-URL: Homepage, https://github.com/michaelrbarley/tsk-todo
|
|
17
|
+
Project-URL: Source, https://github.com/michaelrbarley/tsk-todo
|
|
18
|
+
Project-URL: Issues, https://github.com/michaelrbarley/tsk-todo/issues
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# tsk
|
|
22
|
+
|
|
23
|
+
A small task manager for the terminal.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
$ tsk add water the plants @home +garden due:tomorrow every:weekly
|
|
27
|
+
added 1: water the plants
|
|
28
|
+
|
|
29
|
+
$ tsk add fix the login bug @work +urgent due:yesterday p:h
|
|
30
|
+
added 2: fix the login bug
|
|
31
|
+
|
|
32
|
+
$ tsk
|
|
33
|
+
2 H yesterday fix the login bug @work +urgent
|
|
34
|
+
1 tomorrow water the plants @home +garden every:weekly
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
No daemon, no config file, no dependencies. Your tasks live in one JSON file you can read.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
Needs Python 3.11 or newer.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
git clone https://github.com/michaelrbarley/tsk-todo
|
|
45
|
+
cd tsk-todo
|
|
46
|
+
uv tool install .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The command is `tsk`. The package is `tsk-todo`, because `tsk` on PyPI belongs to something else.
|
|
50
|
+
|
|
51
|
+
Or run it from the clone without installing anything:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
uv run tsk
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## The idea
|
|
58
|
+
|
|
59
|
+
You type what you mean and `tsk` works out which parts are metadata:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
tsk add renew the passport @admin +boring due:eom p:h
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`@admin` is the project, `+boring` is a tag, `due:eom` is the end of this month, `p:h` is high
|
|
66
|
+
priority. Everything left over is the task itself.
|
|
67
|
+
|
|
68
|
+
Only `due:`, `p:` and `every:` are read as attributes, so ordinary text stays ordinary:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
$ tsk add email bob@example.com about the invoice
|
|
72
|
+
$ tsk add fix the -v flag
|
|
73
|
+
$ tsk add ticket: replace the boiler
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
None of those pick up a project, a tag or an attribute. If it is not a form `tsk` knows, it is
|
|
77
|
+
part of the description.
|
|
78
|
+
|
|
79
|
+
## Commands
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
tsk list pending tasks
|
|
83
|
+
tsk add <text> [attrs] add a task
|
|
84
|
+
tsk done <id>... complete tasks
|
|
85
|
+
tsk rm <id>... delete tasks
|
|
86
|
+
tsk edit <id> <attrs> change a task
|
|
87
|
+
tsk note <id> <text> attach a note
|
|
88
|
+
tsk show <id> show one task in full
|
|
89
|
+
tsk list [filters] list tasks
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`tsk 3` on its own is shorthand for `tsk show 3`.
|
|
93
|
+
|
|
94
|
+
`done` and `rm` take several ids at once: `tsk done 2 5 9`.
|
|
95
|
+
|
|
96
|
+
## Attributes
|
|
97
|
+
|
|
98
|
+
Use these when adding or editing.
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
@project set the project
|
|
102
|
+
+tag add a tag
|
|
103
|
+
-tag remove a tag, when editing
|
|
104
|
+
due:<when> set a due date
|
|
105
|
+
p:h|m|l set priority, high medium or low
|
|
106
|
+
every:<how> reschedule on completion
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Priority also accepts the full word, so `p:high` and `p:h` are the same.
|
|
110
|
+
|
|
111
|
+
Editing changes only what you name. Everything else is left alone.
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
$ tsk edit 3 due:friday +urgent
|
|
115
|
+
updated 3: read the book
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
To clear something, give it no value:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
$ tsk edit 3 due: p: every:
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
A bare `@` clears the project. A leading `-` removes a tag.
|
|
125
|
+
|
|
126
|
+
## Filters
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
tsk +garden tagged garden
|
|
130
|
+
tsk -work not tagged work
|
|
131
|
+
tsk @home in the home project
|
|
132
|
+
tsk overdue past due
|
|
133
|
+
tsk due:friday due on or before friday
|
|
134
|
+
tsk done completed tasks
|
|
135
|
+
tsk all everything
|
|
136
|
+
tsk plants search descriptions and notes
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Filters combine, and `list` is optional:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
$ tsk @home +garden overdue
|
|
143
|
+
$ tsk list @work due:eow
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Bare words search. `tsk list dentist` finds anything with dentist in the description or in a note.
|
|
147
|
+
|
|
148
|
+
## Dates
|
|
149
|
+
|
|
150
|
+
Anywhere a date is wanted:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
today, tomorrow, yesterday
|
|
154
|
+
monday .. sunday, or mon .. sun
|
|
155
|
+
3d, 2w, 1m, 1y
|
|
156
|
+
eow, eom, eoy
|
|
157
|
+
2026-08-01
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Weekday names mean the next one coming. On a Thursday, `due:thursday` is a week away, not today.
|
|
161
|
+
|
|
162
|
+
Month and year offsets land on a real date. `1m` from the 31st of January is the 28th of February,
|
|
163
|
+
not the 3rd of March.
|
|
164
|
+
|
|
165
|
+
Due dates are plain calendar dates. `due:friday` means Friday wherever you happen to be, and it
|
|
166
|
+
does not drift when you cross a timezone.
|
|
167
|
+
|
|
168
|
+
## Repeats
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
every:daily
|
|
172
|
+
every:weekdays
|
|
173
|
+
every:weekly
|
|
174
|
+
every:monthly
|
|
175
|
+
every:yearly
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
A repeating task needs a due date. When you complete it, the next one is created for you:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
$ tsk done 1
|
|
182
|
+
done 1: water the plants
|
|
183
|
+
repeats as 5, due in 1 week
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The next date is worked out from the old due date, then rolled forward until it is in the future.
|
|
187
|
+
Finish a daily task five days late and the next one is due tomorrow, not last week. You never come
|
|
188
|
+
back to a pile of overdue copies.
|
|
189
|
+
|
|
190
|
+
Notes do not carry over. The completed task keeps them.
|
|
191
|
+
|
|
192
|
+
## Notes
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
$ tsk note 3 the ferns look worse than the rest
|
|
196
|
+
noted on 3: water the plants
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Tasks with notes are marked with a `*` in the list. `tsk show 3` prints them.
|
|
200
|
+
|
|
201
|
+
## Ordering
|
|
202
|
+
|
|
203
|
+
Tasks sort by due date, then priority, then id. That is the whole rule. Nothing is scored or
|
|
204
|
+
weighted behind your back, so the order is always one you can predict and explain.
|
|
205
|
+
|
|
206
|
+
Undated tasks sort last. They are not urgent, they are just there.
|
|
207
|
+
|
|
208
|
+
## Your data
|
|
209
|
+
|
|
210
|
+
One JSON file, at `~/.local/share/tsk/tasks.json` unless you say otherwise:
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"version": 1,
|
|
215
|
+
"issued": 7,
|
|
216
|
+
"tasks": [
|
|
217
|
+
{
|
|
218
|
+
"id": 2,
|
|
219
|
+
"description": "fix the login bug",
|
|
220
|
+
"status": "pending",
|
|
221
|
+
"created": "2026-07-16T14:49:48+00:00",
|
|
222
|
+
"project": "work",
|
|
223
|
+
"tags": ["urgent"],
|
|
224
|
+
"due": "2026-07-15",
|
|
225
|
+
"priority": "high"
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Set `TSK_DATA` to put it somewhere else, or `XDG_DATA_HOME` to move the lot. Keep it in a git repo
|
|
232
|
+
if you want history. Tags are written in a fixed order and absent fields are left out, so diffs
|
|
233
|
+
stay small.
|
|
234
|
+
|
|
235
|
+
Writes are atomic. The file is written beside itself and then moved into place, so an interrupted
|
|
236
|
+
write cannot leave you with half a file. Existing permissions are preserved.
|
|
237
|
+
|
|
238
|
+
Ids are never reused. Once id 3 has been handed out, no later task gets it, even if you delete task
|
|
239
|
+
3 the same day. The id you read a minute ago is still the task you thought it was.
|
|
240
|
+
|
|
241
|
+
## Colour
|
|
242
|
+
|
|
243
|
+
On when writing to a terminal, off when piped. Set `NO_COLOR` to turn it off entirely.
|
|
244
|
+
|
|
245
|
+
Overdue is red, due today is yellow, projects are cyan, tags are magenta.
|
|
246
|
+
|
|
247
|
+
## What it does not do
|
|
248
|
+
|
|
249
|
+
There is no urgency score, no dependency graph, no user defined attributes, no hooks, no custom
|
|
250
|
+
report definitions and no sync. Each of those either failed the question "would I miss this" or
|
|
251
|
+
costs far more than it returns for a tool this size.
|
|
252
|
+
|
|
253
|
+
If you want a task on the calendar, `due:` is where it goes. If you want to find it later, tag it.
|
|
254
|
+
That is most of task management.
|
|
255
|
+
|
|
256
|
+
## Development
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
uv sync
|
|
260
|
+
uv run pytest
|
|
261
|
+
uv run ruff check .
|
|
262
|
+
uv run ruff format .
|
|
263
|
+
uv run mypy
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The suite runs in well under a second. It is worth keeping it that way.
|
|
267
|
+
|
|
268
|
+
## Licence
|
|
269
|
+
|
|
270
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
tsk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
tsk/cli.py,sha256=pUHfrYHousIZH9BUjPnyZJoGQ5Vn_iA_gk1Nj2nq3vo,6380
|
|
3
|
+
tsk/dates.py,sha256=zJdUj6xsY3J6qVJChfdOax2DaUEH36ojSxwbQ6hCg3U,3288
|
|
4
|
+
tsk/model.py,sha256=f2AmnSMkb9XlQCO4mTj08RJ2oQtaIFM7TIF82p1ERoo,3802
|
|
5
|
+
tsk/parse.py,sha256=KtMyW9DVFOe0iXf40FS8irQCc1vPJMkP2k-sNXI7Lck,7155
|
|
6
|
+
tsk/query.py,sha256=Yk-OS9ZTf6m7CQcUjWY3yq33LxvC1GBRDDj-DOv8-6Q,1605
|
|
7
|
+
tsk/render.py,sha256=W3zThco2idEgGrcUZU6Gt-XNKpwQZXKLRtHGBp88te8,4422
|
|
8
|
+
tsk/store.py,sha256=xv8PU5qLopqtOHgmgVxCa9XVPvSWTkOIO18ety51ePc,4258
|
|
9
|
+
tsk_todo-0.1.0.dist-info/WHEEL,sha256=YR4QUxGCbsyoOnWBVTv-p1I4yc3seKGPev46mvmc8Cg,81
|
|
10
|
+
tsk_todo-0.1.0.dist-info/entry_points.txt,sha256=rCSY00ZIUbG_BgUBegQaaOtWtxo1CZa7l0mid9rDnNo,38
|
|
11
|
+
tsk_todo-0.1.0.dist-info/METADATA,sha256=p6dV4KLZ6FncImpA4sNXxJVQbRt5fE126NTZa7trxAc,7083
|
|
12
|
+
tsk_todo-0.1.0.dist-info/RECORD,,
|