leadger 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.
- leadger/__init__.py +1 -0
- leadger/__main__.py +5 -0
- leadger/cli.py +253 -0
- leadger/core/__init__.py +14 -0
- leadger/core/ics.py +82 -0
- leadger/core/ids.py +16 -0
- leadger/core/parse.py +79 -0
- leadger/core/periods.py +32 -0
- leadger/core/recur.py +45 -0
- leadger/core/storage.py +385 -0
- leadger/core/tasks.py +161 -0
- leadger/core/time_utils.py +43 -0
- leadger/core/yaml_io.py +97 -0
- leadger/server.py +221 -0
- leadger/static/assets/index-BFMiMAZM.css +2 -0
- leadger/static/assets/index-PnqahOsL.js +24 -0
- leadger/static/favicon.svg +5 -0
- leadger/static/index.html +14 -0
- leadger-0.1.0.dist-info/METADATA +215 -0
- leadger-0.1.0.dist-info/RECORD +23 -0
- leadger-0.1.0.dist-info/WHEEL +4 -0
- leadger-0.1.0.dist-info/entry_points.txt +2 -0
- leadger-0.1.0.dist-info/licenses/LICENSE +21 -0
leadger/core/storage.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Storage: the YAML-backed orchestrator for tasks, days and metrics.
|
|
2
|
+
|
|
3
|
+
Implements the product's core rules:
|
|
4
|
+
- lazy snapshot + rollover at config.day_start (regras 1-2)
|
|
5
|
+
- the "% do dia entregue" hero metric, including over-delivery and
|
|
6
|
+
the goal=0 bonus-day case (regra 3)
|
|
7
|
+
- external-edit detection via mtime, last-write-wins (regra 7)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import date, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
18
|
+
|
|
19
|
+
from .ids import generate_task_id
|
|
20
|
+
from .recur import next_occurrence
|
|
21
|
+
from .tasks import Task, TaskStatus
|
|
22
|
+
from .time_utils import leadger_today, now_in_zone, parse_day_start
|
|
23
|
+
from .yaml_io import read_yaml, write_yaml_atomic
|
|
24
|
+
|
|
25
|
+
DEFAULT_TIMEZONE = "America/Sao_Paulo"
|
|
26
|
+
DEFAULT_DAY_START = "05:00"
|
|
27
|
+
|
|
28
|
+
# sentinel distinguishing "leave unchanged" from "remove" in update_task(recur=...)
|
|
29
|
+
_UNSET: object = object()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class DayMetrics:
|
|
34
|
+
"""The hero metric for a single day."""
|
|
35
|
+
|
|
36
|
+
date: date
|
|
37
|
+
goal: int
|
|
38
|
+
snapshot_done: int
|
|
39
|
+
extras_done: int
|
|
40
|
+
extras_total: int
|
|
41
|
+
done: int
|
|
42
|
+
pct: Optional[float]
|
|
43
|
+
bonus_day: bool
|
|
44
|
+
over_delivery: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Storage:
|
|
48
|
+
"""Loads/saves a single leadger.yaml and applies the business rules."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, path: Path | str) -> None:
|
|
51
|
+
self.path = Path(path).expanduser()
|
|
52
|
+
self._mtime: Optional[float] = None
|
|
53
|
+
self.data: CommentedMap = CommentedMap()
|
|
54
|
+
self.reload()
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
# IO
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
def reload(self) -> None:
|
|
60
|
+
"""Force a re-read from disk. Raises CorruptedYAMLError if invalid."""
|
|
61
|
+
self.data = read_yaml(self.path)
|
|
62
|
+
self._mtime = self.path.stat().st_mtime if self.path.exists() else None
|
|
63
|
+
|
|
64
|
+
def _sync(self) -> None:
|
|
65
|
+
"""Reload if the file changed on disk since our last read (regra 7)."""
|
|
66
|
+
if not self.path.exists():
|
|
67
|
+
return
|
|
68
|
+
mtime = self.path.stat().st_mtime
|
|
69
|
+
if self._mtime is None or mtime != self._mtime:
|
|
70
|
+
self.reload()
|
|
71
|
+
|
|
72
|
+
def save(self) -> None:
|
|
73
|
+
write_yaml_atomic(self.path, self.data)
|
|
74
|
+
self._mtime = self.path.stat().st_mtime
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Config / time
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
@property
|
|
80
|
+
def config(self) -> CommentedMap:
|
|
81
|
+
return self.data.setdefault("config", CommentedMap())
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def timezone_name(self) -> str:
|
|
85
|
+
return str(self.config.get("timezone", DEFAULT_TIMEZONE))
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def day_start(self): # -> datetime.time
|
|
89
|
+
return parse_day_start(str(self.config.get("day_start", DEFAULT_DAY_START)))
|
|
90
|
+
|
|
91
|
+
def now(self) -> datetime:
|
|
92
|
+
return now_in_zone(self.timezone_name)
|
|
93
|
+
|
|
94
|
+
def today(self, now: Optional[datetime] = None) -> date:
|
|
95
|
+
now = now or self.now()
|
|
96
|
+
return leadger_today(now, self.day_start)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Tasks
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
@property
|
|
102
|
+
def _tasks_seq(self) -> CommentedSeq:
|
|
103
|
+
return self.data.setdefault("tasks", CommentedSeq())
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def _days_seq(self) -> CommentedSeq:
|
|
107
|
+
return self.data.setdefault("days", CommentedSeq())
|
|
108
|
+
|
|
109
|
+
def all_tasks(self) -> list[Task]:
|
|
110
|
+
self._sync()
|
|
111
|
+
return [Task(raw) for raw in self._tasks_seq]
|
|
112
|
+
|
|
113
|
+
def get_task(self, task_id: str) -> Task:
|
|
114
|
+
self._sync()
|
|
115
|
+
for raw in self._tasks_seq:
|
|
116
|
+
if raw["id"] == task_id:
|
|
117
|
+
return Task(raw)
|
|
118
|
+
raise KeyError(task_id)
|
|
119
|
+
|
|
120
|
+
def _existing_ids(self) -> set[str]:
|
|
121
|
+
return {raw["id"] for raw in self._tasks_seq}
|
|
122
|
+
|
|
123
|
+
def add_task(
|
|
124
|
+
self,
|
|
125
|
+
title: str,
|
|
126
|
+
*,
|
|
127
|
+
target: Optional[date] = None,
|
|
128
|
+
tags: Optional[list[str]] = None,
|
|
129
|
+
recur: Optional[str] = None,
|
|
130
|
+
now: Optional[datetime] = None,
|
|
131
|
+
) -> Task:
|
|
132
|
+
self._sync()
|
|
133
|
+
now = now or self.now()
|
|
134
|
+
today = self.today(now)
|
|
135
|
+
target = target or today
|
|
136
|
+
|
|
137
|
+
self.ensure_day(now=now)
|
|
138
|
+
|
|
139
|
+
task_id = generate_task_id(self._existing_ids())
|
|
140
|
+
task = Task.new(
|
|
141
|
+
task_id=task_id, title=title, target=target, created=now, tags=tags, recur=recur
|
|
142
|
+
)
|
|
143
|
+
self._tasks_seq.append(task.raw)
|
|
144
|
+
|
|
145
|
+
if target == today:
|
|
146
|
+
self._get_day_record(today)["extras"].append(task_id)
|
|
147
|
+
|
|
148
|
+
self.save()
|
|
149
|
+
return task
|
|
150
|
+
|
|
151
|
+
def set_task_status(
|
|
152
|
+
self, task_id: str, status: TaskStatus, now: Optional[datetime] = None
|
|
153
|
+
) -> Task:
|
|
154
|
+
self._sync()
|
|
155
|
+
now = now or self.now()
|
|
156
|
+
self.ensure_day(now=now)
|
|
157
|
+
task = self.get_task(task_id)
|
|
158
|
+
changed = task.set_status(status, now)
|
|
159
|
+
if changed and status is TaskStatus.DONE:
|
|
160
|
+
self._spawn_next_if_recurring(task, now)
|
|
161
|
+
self.save()
|
|
162
|
+
return task
|
|
163
|
+
|
|
164
|
+
def update_task(
|
|
165
|
+
self,
|
|
166
|
+
task_id: str,
|
|
167
|
+
*,
|
|
168
|
+
title: Optional[str] = None,
|
|
169
|
+
target: Optional[date] = None,
|
|
170
|
+
tags: Optional[list[str]] = None,
|
|
171
|
+
status: Optional[TaskStatus] = None,
|
|
172
|
+
recur: object = _UNSET,
|
|
173
|
+
now: Optional[datetime] = None,
|
|
174
|
+
) -> Task:
|
|
175
|
+
"""Edit title/target/tags/status/recur of an existing task.
|
|
176
|
+
|
|
177
|
+
Raises KeyError (unknown id) or InvalidTransitionError (bad status
|
|
178
|
+
edge). Retargeting onto today adds the task to today's `extras` —
|
|
179
|
+
it counts toward delivery if done, but never inflates the goal.
|
|
180
|
+
`recur` uses a sentinel: omitted = unchanged; None = end the series.
|
|
181
|
+
"""
|
|
182
|
+
self._sync()
|
|
183
|
+
now = now or self.now()
|
|
184
|
+
today = self.today(now)
|
|
185
|
+
self.ensure_day(now=now)
|
|
186
|
+
task = self.get_task(task_id)
|
|
187
|
+
|
|
188
|
+
if title is not None:
|
|
189
|
+
task.title = title
|
|
190
|
+
if tags is not None:
|
|
191
|
+
task.tags = tags
|
|
192
|
+
if recur is not _UNSET:
|
|
193
|
+
task.recur = recur # type: ignore[assignment]
|
|
194
|
+
if target is not None and target != task.target:
|
|
195
|
+
task.target = target
|
|
196
|
+
if target == today:
|
|
197
|
+
day_record = self._get_day_record(today)
|
|
198
|
+
snapshot_ids = list(day_record.get("snapshot", []))
|
|
199
|
+
extras_ids = list(day_record.get("extras", []))
|
|
200
|
+
if task_id not in snapshot_ids and task_id not in extras_ids:
|
|
201
|
+
day_record["extras"].append(task_id)
|
|
202
|
+
if status is not None:
|
|
203
|
+
changed = task.set_status(status, now)
|
|
204
|
+
if changed and status is TaskStatus.DONE:
|
|
205
|
+
self._spawn_next_if_recurring(task, now)
|
|
206
|
+
|
|
207
|
+
self.save()
|
|
208
|
+
return task
|
|
209
|
+
|
|
210
|
+
def _spawn_next_if_recurring(
|
|
211
|
+
self, task: Task, now: datetime
|
|
212
|
+
) -> Optional[Task]:
|
|
213
|
+
"""Completing a recurring occurrence creates the next one (series rule).
|
|
214
|
+
|
|
215
|
+
Cancelling or deleting the open occurrence ends the series. If an
|
|
216
|
+
open future occurrence of the same series already exists (e.g. the
|
|
217
|
+
task was reopened and completed again), nothing is created.
|
|
218
|
+
"""
|
|
219
|
+
recur = task.recur
|
|
220
|
+
if not recur:
|
|
221
|
+
return None
|
|
222
|
+
today = self.today(now)
|
|
223
|
+
for raw in self._tasks_seq:
|
|
224
|
+
other = Task(raw)
|
|
225
|
+
if (
|
|
226
|
+
other.id != task.id
|
|
227
|
+
and other.recur == recur
|
|
228
|
+
and other.title == task.title
|
|
229
|
+
and other.status in (TaskStatus.TODO, TaskStatus.PAUSED)
|
|
230
|
+
and other.target > today
|
|
231
|
+
):
|
|
232
|
+
return None
|
|
233
|
+
target = next_occurrence(recur, max(task.target, today))
|
|
234
|
+
next_task = Task.new(
|
|
235
|
+
task_id=generate_task_id(self._existing_ids()),
|
|
236
|
+
title=task.title,
|
|
237
|
+
target=target,
|
|
238
|
+
created=now,
|
|
239
|
+
tags=task.tags,
|
|
240
|
+
recur=recur,
|
|
241
|
+
)
|
|
242
|
+
self._tasks_seq.append(next_task.raw)
|
|
243
|
+
return next_task
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Days / snapshot lazy / rollover
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
def _get_day_record(self, day: date) -> Optional[CommentedMap]:
|
|
249
|
+
for raw in self._days_seq:
|
|
250
|
+
if _as_date(raw["date"]) == day:
|
|
251
|
+
return raw
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
def ensure_day(
|
|
255
|
+
self, day: Optional[date] = None, now: Optional[datetime] = None
|
|
256
|
+
) -> CommentedMap:
|
|
257
|
+
"""Lazily run the day-turnover (rollover + snapshot) for `day`.
|
|
258
|
+
|
|
259
|
+
No-op if `day` already has an entry in `days`. Otherwise:
|
|
260
|
+
1. rollover stale todo/paused tasks (target < day) onto `day`,
|
|
261
|
+
bumping their migration count;
|
|
262
|
+
2. snapshot the (now up to date) todo/paused tasks targeting
|
|
263
|
+
`day` as `goal`/`snapshot`, with empty `extras`.
|
|
264
|
+
"""
|
|
265
|
+
self._sync()
|
|
266
|
+
now = now or self.now()
|
|
267
|
+
target_day = day or self.today(now)
|
|
268
|
+
|
|
269
|
+
existing = self._get_day_record(target_day)
|
|
270
|
+
if existing is not None:
|
|
271
|
+
return existing
|
|
272
|
+
|
|
273
|
+
for raw in self._tasks_seq:
|
|
274
|
+
task = Task(raw)
|
|
275
|
+
if task.status in (TaskStatus.TODO, TaskStatus.PAUSED) and task.target < target_day:
|
|
276
|
+
task.migrate(target_day)
|
|
277
|
+
|
|
278
|
+
snapshot_ids = [
|
|
279
|
+
task.id
|
|
280
|
+
for task in (Task(raw) for raw in self._tasks_seq)
|
|
281
|
+
if task.target == target_day and task.status in (TaskStatus.TODO, TaskStatus.PAUSED)
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
day_record = CommentedMap()
|
|
285
|
+
day_record["date"] = target_day
|
|
286
|
+
day_record["goal"] = len(snapshot_ids)
|
|
287
|
+
day_record["snapshot"] = CommentedSeq(snapshot_ids)
|
|
288
|
+
day_record["extras"] = CommentedSeq()
|
|
289
|
+
self._days_seq.append(day_record)
|
|
290
|
+
|
|
291
|
+
self.save()
|
|
292
|
+
return day_record
|
|
293
|
+
|
|
294
|
+
def delete_task(self, task_id: str, now: Optional[datetime] = None) -> None:
|
|
295
|
+
"""Remove a task permanently. Raises KeyError if unknown.
|
|
296
|
+
|
|
297
|
+
The id is also removed from every day's `extras`, but stays in
|
|
298
|
+
`snapshot` (the frozen photo): deleting a planned task does NOT
|
|
299
|
+
reduce that day's goal, same anti-gaming rule as cancelling.
|
|
300
|
+
"""
|
|
301
|
+
self._sync()
|
|
302
|
+
now = now or self.now()
|
|
303
|
+
self.ensure_day(now=now)
|
|
304
|
+
seq = self._tasks_seq
|
|
305
|
+
for i, raw in enumerate(seq):
|
|
306
|
+
if raw["id"] == task_id:
|
|
307
|
+
del seq[i]
|
|
308
|
+
break
|
|
309
|
+
else:
|
|
310
|
+
raise KeyError(task_id)
|
|
311
|
+
for day_record in self._days_seq:
|
|
312
|
+
extras = day_record.get("extras")
|
|
313
|
+
if extras is not None and task_id in list(extras):
|
|
314
|
+
extras.remove(task_id)
|
|
315
|
+
self.save()
|
|
316
|
+
|
|
317
|
+
def recorded_days(self) -> list[date]:
|
|
318
|
+
"""Sorted dates of every entry in `days`."""
|
|
319
|
+
self._sync()
|
|
320
|
+
return sorted(_as_date(raw["date"]) for raw in self._days_seq)
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------------------
|
|
323
|
+
# Metrics
|
|
324
|
+
# ------------------------------------------------------------------
|
|
325
|
+
def get_day_metrics(
|
|
326
|
+
self, day: Optional[date] = None, now: Optional[datetime] = None
|
|
327
|
+
) -> DayMetrics:
|
|
328
|
+
self._sync()
|
|
329
|
+
now = now or self.now()
|
|
330
|
+
today = self.today(now)
|
|
331
|
+
target_day = day or today
|
|
332
|
+
|
|
333
|
+
if target_day == today:
|
|
334
|
+
day_record = self.ensure_day(target_day, now=now)
|
|
335
|
+
else:
|
|
336
|
+
day_record = self._get_day_record(target_day)
|
|
337
|
+
|
|
338
|
+
if day_record is None:
|
|
339
|
+
day_record = CommentedMap(
|
|
340
|
+
date=target_day, goal=0, snapshot=CommentedSeq(), extras=CommentedSeq()
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
goal = int(day_record.get("goal", 0))
|
|
344
|
+
tasks_by_id = {task.id: task for task in self.all_tasks()}
|
|
345
|
+
|
|
346
|
+
snapshot_done = sum(
|
|
347
|
+
1
|
|
348
|
+
for task_id in day_record.get("snapshot", [])
|
|
349
|
+
if tasks_by_id.get(task_id) and tasks_by_id[task_id].status is TaskStatus.DONE
|
|
350
|
+
)
|
|
351
|
+
# ids that don't resolve (task deleted / file edited by hand) don't count
|
|
352
|
+
extras_ids = [
|
|
353
|
+
task_id for task_id in day_record.get("extras", []) if task_id in tasks_by_id
|
|
354
|
+
]
|
|
355
|
+
extras_done = sum(
|
|
356
|
+
1 for task_id in extras_ids if tasks_by_id[task_id].status is TaskStatus.DONE
|
|
357
|
+
)
|
|
358
|
+
done = snapshot_done + extras_done
|
|
359
|
+
|
|
360
|
+
if goal == 0:
|
|
361
|
+
pct = None if done > 0 else 0.0
|
|
362
|
+
bonus_day = done > 0
|
|
363
|
+
else:
|
|
364
|
+
pct = done / goal * 100
|
|
365
|
+
bonus_day = False
|
|
366
|
+
|
|
367
|
+
return DayMetrics(
|
|
368
|
+
date=target_day,
|
|
369
|
+
goal=goal,
|
|
370
|
+
snapshot_done=snapshot_done,
|
|
371
|
+
extras_done=extras_done,
|
|
372
|
+
extras_total=len(extras_ids),
|
|
373
|
+
done=done,
|
|
374
|
+
pct=pct,
|
|
375
|
+
bonus_day=bonus_day,
|
|
376
|
+
over_delivery=pct is not None and pct > 100,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _as_date(value: object) -> date:
|
|
381
|
+
if isinstance(value, datetime):
|
|
382
|
+
return value.date()
|
|
383
|
+
if isinstance(value, date):
|
|
384
|
+
return value
|
|
385
|
+
return date.fromisoformat(str(value))
|
leadger/core/tasks.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Task model: a typed view over a YAML task entry, plus its state machine.
|
|
2
|
+
|
|
3
|
+
`Task` wraps the ruamel CommentedMap loaded from leadger.yaml and mutates
|
|
4
|
+
it in place, so any comments the user attached to that task survive.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
14
|
+
|
|
15
|
+
from .yaml_io import to_yaml_timestamp
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskStatus(str, Enum):
|
|
19
|
+
TODO = "todo"
|
|
20
|
+
DONE = "done"
|
|
21
|
+
PAUSED = "paused"
|
|
22
|
+
CANCELLED = "cancelled"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidTransitionError(ValueError):
|
|
26
|
+
"""Raised when a task status transition is not allowed."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# todo <-> paused, todo|paused -> done|cancelled, done|cancelled -> todo
|
|
30
|
+
_VALID_TRANSITIONS: dict[TaskStatus, frozenset[TaskStatus]] = {
|
|
31
|
+
TaskStatus.TODO: frozenset({TaskStatus.PAUSED, TaskStatus.DONE, TaskStatus.CANCELLED}),
|
|
32
|
+
TaskStatus.PAUSED: frozenset({TaskStatus.TODO, TaskStatus.DONE, TaskStatus.CANCELLED}),
|
|
33
|
+
TaskStatus.DONE: frozenset({TaskStatus.TODO}),
|
|
34
|
+
TaskStatus.CANCELLED: frozenset({TaskStatus.TODO}),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _as_date(value: object) -> date:
|
|
39
|
+
if isinstance(value, datetime):
|
|
40
|
+
return value.date()
|
|
41
|
+
if isinstance(value, date):
|
|
42
|
+
return value
|
|
43
|
+
return date.fromisoformat(str(value))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Task:
|
|
47
|
+
"""Typed view over a task's CommentedMap."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, raw: CommentedMap) -> None:
|
|
50
|
+
self._raw = raw
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def raw(self) -> CommentedMap:
|
|
54
|
+
return self._raw
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def id(self) -> str:
|
|
58
|
+
return self._raw["id"]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def title(self) -> str:
|
|
62
|
+
return self._raw["title"]
|
|
63
|
+
|
|
64
|
+
@title.setter
|
|
65
|
+
def title(self, value: str) -> None:
|
|
66
|
+
self._raw["title"] = value
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def status(self) -> TaskStatus:
|
|
70
|
+
return TaskStatus(self._raw["status"])
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def target(self) -> date:
|
|
74
|
+
return _as_date(self._raw["target"])
|
|
75
|
+
|
|
76
|
+
@target.setter
|
|
77
|
+
def target(self, value: date) -> None:
|
|
78
|
+
self._raw["target"] = value
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def created(self) -> datetime:
|
|
82
|
+
return self._raw["created"]
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def completed(self) -> Optional[datetime]:
|
|
86
|
+
return self._raw.get("completed")
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def tags(self) -> list[str]:
|
|
90
|
+
return list(self._raw.get("tags") or [])
|
|
91
|
+
|
|
92
|
+
@tags.setter
|
|
93
|
+
def tags(self, value: list[str]) -> None:
|
|
94
|
+
self._raw["tags"] = CommentedSeq(value)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def migrations(self) -> int:
|
|
98
|
+
return int(self._raw.get("migrations", 0))
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def recur(self) -> Optional[str]:
|
|
102
|
+
"""Recurrence spec ("dia" | "seg".."dom") or None. See core/recur.py."""
|
|
103
|
+
value = self._raw.get("recur")
|
|
104
|
+
return str(value) if value else None
|
|
105
|
+
|
|
106
|
+
@recur.setter
|
|
107
|
+
def recur(self, value: Optional[str]) -> None:
|
|
108
|
+
if value:
|
|
109
|
+
self._raw["recur"] = value
|
|
110
|
+
else:
|
|
111
|
+
self._raw.pop("recur", None)
|
|
112
|
+
|
|
113
|
+
def migrate(self, new_target: date) -> None:
|
|
114
|
+
"""Roll this task over to `new_target`, bumping its migration count."""
|
|
115
|
+
self._raw["target"] = new_target
|
|
116
|
+
self._raw["migrations"] = self.migrations + 1
|
|
117
|
+
|
|
118
|
+
def set_status(self, new_status: TaskStatus, now: datetime) -> bool:
|
|
119
|
+
"""Apply a status transition. Returns False if already in that state.
|
|
120
|
+
|
|
121
|
+
Raises InvalidTransitionError for disallowed transitions (e.g.
|
|
122
|
+
done -> paused). Sets/clears `completed` for the done <-> todo edge.
|
|
123
|
+
"""
|
|
124
|
+
current = self.status
|
|
125
|
+
if new_status == current:
|
|
126
|
+
return False
|
|
127
|
+
allowed = _VALID_TRANSITIONS.get(current, frozenset())
|
|
128
|
+
if new_status not in allowed:
|
|
129
|
+
raise InvalidTransitionError(
|
|
130
|
+
f"Transicao invalida: {current.value} -> {new_status.value}"
|
|
131
|
+
)
|
|
132
|
+
self._raw["status"] = new_status.value
|
|
133
|
+
if new_status is TaskStatus.DONE:
|
|
134
|
+
self._raw["completed"] = to_yaml_timestamp(now)
|
|
135
|
+
elif current is TaskStatus.DONE and new_status is TaskStatus.TODO:
|
|
136
|
+
self._raw["completed"] = None
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def new(
|
|
141
|
+
cls,
|
|
142
|
+
*,
|
|
143
|
+
task_id: str,
|
|
144
|
+
title: str,
|
|
145
|
+
target: date,
|
|
146
|
+
created: datetime,
|
|
147
|
+
tags: Optional[list[str]] = None,
|
|
148
|
+
recur: Optional[str] = None,
|
|
149
|
+
) -> "Task":
|
|
150
|
+
raw = CommentedMap()
|
|
151
|
+
raw["id"] = task_id
|
|
152
|
+
raw["title"] = title
|
|
153
|
+
raw["status"] = TaskStatus.TODO.value
|
|
154
|
+
raw["target"] = target
|
|
155
|
+
raw["created"] = to_yaml_timestamp(created)
|
|
156
|
+
raw["completed"] = None
|
|
157
|
+
raw["tags"] = CommentedSeq(tags or [])
|
|
158
|
+
raw["migrations"] = 0
|
|
159
|
+
if recur: # only write the key when there is recurrence (lean YAML)
|
|
160
|
+
raw["recur"] = recur
|
|
161
|
+
return cls(raw)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Timezone-aware 'leadger day' calculations.
|
|
2
|
+
|
|
3
|
+
The 'leadger day' is the date used for `target`/`days[].date`. It only
|
|
4
|
+
rolls over at `config.day_start`: at 03:00 with day_start=05:00, it's
|
|
5
|
+
still 'yesterday'.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
11
|
+
from zoneinfo import ZoneInfo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def now_in_zone(tz_name: str) -> datetime:
|
|
15
|
+
"""Current time in `tz_name`, with a fixed UTC-offset tzinfo.
|
|
16
|
+
|
|
17
|
+
A fixed offset (vs. a ZoneInfo) is what ruamel.yaml's round-trip
|
|
18
|
+
representer needs to dump a clean `+HH:MM`/`-HH:MM` suffix.
|
|
19
|
+
"""
|
|
20
|
+
return _to_fixed_offset(datetime.now(ZoneInfo(tz_name)))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _to_fixed_offset(dt: datetime) -> datetime:
|
|
24
|
+
offset = dt.utcoffset()
|
|
25
|
+
if offset is None:
|
|
26
|
+
return dt.replace(microsecond=0)
|
|
27
|
+
total_minutes = int(offset.total_seconds() // 60)
|
|
28
|
+
sign = "+" if total_minutes >= 0 else "-"
|
|
29
|
+
hours, minutes = divmod(abs(total_minutes), 60)
|
|
30
|
+
fixed = timezone(offset, f"{sign}{hours:02d}:{minutes:02d}")
|
|
31
|
+
return dt.replace(microsecond=0, tzinfo=fixed)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def leadger_today(now: datetime, day_start: time) -> date:
|
|
35
|
+
"""The 'leadger day' for `now`, given the configured day_start cutoff."""
|
|
36
|
+
if now.time() < day_start:
|
|
37
|
+
return now.date() - timedelta(days=1)
|
|
38
|
+
return now.date()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_day_start(value: str) -> time:
|
|
42
|
+
hour_str, minute_str = value.split(":")
|
|
43
|
+
return time(hour=int(hour_str), minute=int(minute_str))
|
leadger/core/yaml_io.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Low-level YAML round-trip IO: atomic writes, backups, corruption handling.
|
|
2
|
+
|
|
3
|
+
Uses ruamel.yaml in round-trip mode so comments and key order in the
|
|
4
|
+
user's leadger.yaml survive every read/write cycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ruamel.yaml import YAML
|
|
14
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
15
|
+
from ruamel.yaml.error import YAMLError
|
|
16
|
+
from ruamel.yaml.timestamp import TimeStamp
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = 1
|
|
19
|
+
|
|
20
|
+
yaml = YAML(typ="rt")
|
|
21
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
22
|
+
yaml.preserve_quotes = True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CorruptedYAMLError(Exception):
|
|
26
|
+
"""Raised when the data file exists but cannot be parsed safely."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_yaml_timestamp(dt: datetime) -> TimeStamp:
|
|
30
|
+
"""Wrap a tz-aware datetime so it dumps as `YYYY-MM-DDTHH:MM:SS+HH:MM`.
|
|
31
|
+
|
|
32
|
+
ruamel uses `tzinfo.tzname()` as the dumped suffix, so the tzinfo is
|
|
33
|
+
normalized to a fixed offset named `+HH:MM`/`-HH:MM` — an unnamed
|
|
34
|
+
`timezone(timedelta(...))` would dump as `UTC-03:00`, which ruamel
|
|
35
|
+
cannot parse back.
|
|
36
|
+
"""
|
|
37
|
+
offset = dt.utcoffset()
|
|
38
|
+
if offset is not None:
|
|
39
|
+
total_minutes = int(offset.total_seconds() // 60)
|
|
40
|
+
sign = "+" if total_minutes >= 0 else "-"
|
|
41
|
+
hours, minutes = divmod(abs(total_minutes), 60)
|
|
42
|
+
dt = dt.replace(tzinfo=timezone(offset, f"{sign}{hours:02d}:{minutes:02d}"))
|
|
43
|
+
ts = TimeStamp(
|
|
44
|
+
dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond, dt.tzinfo
|
|
45
|
+
)
|
|
46
|
+
ts._yaml["t"] = True
|
|
47
|
+
return ts
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_document() -> CommentedMap:
|
|
51
|
+
"""A fresh leadger.yaml document with a minimal commented config."""
|
|
52
|
+
doc = CommentedMap()
|
|
53
|
+
doc["version"] = SCHEMA_VERSION
|
|
54
|
+
config = CommentedMap()
|
|
55
|
+
config["day_start"] = "05:00"
|
|
56
|
+
config.yaml_add_eol_comment("hora em que o dia vira", "day_start")
|
|
57
|
+
config["timezone"] = "America/Sao_Paulo"
|
|
58
|
+
doc["config"] = config
|
|
59
|
+
doc["tasks"] = CommentedSeq()
|
|
60
|
+
doc["days"] = CommentedSeq()
|
|
61
|
+
return doc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_yaml(path: Path) -> CommentedMap:
|
|
65
|
+
"""Load `path`, or return a fresh default document if it doesn't exist yet.
|
|
66
|
+
|
|
67
|
+
Raises CorruptedYAMLError if the file exists but isn't valid YAML, or its
|
|
68
|
+
root isn't a mapping. Callers must not write back in that case.
|
|
69
|
+
"""
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return default_document()
|
|
72
|
+
try:
|
|
73
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
74
|
+
data = yaml.load(fh)
|
|
75
|
+
except YAMLError as exc:
|
|
76
|
+
raise CorruptedYAMLError(f"{path} nao e um YAML valido: {exc}") from exc
|
|
77
|
+
if not isinstance(data, CommentedMap):
|
|
78
|
+
raise CorruptedYAMLError(f"{path} nao tem o formato esperado (mapa no nivel raiz)")
|
|
79
|
+
data.setdefault("version", SCHEMA_VERSION)
|
|
80
|
+
data.setdefault("config", CommentedMap())
|
|
81
|
+
data.setdefault("tasks", CommentedSeq())
|
|
82
|
+
data.setdefault("days", CommentedSeq())
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_yaml_atomic(path: Path, data: CommentedMap) -> None:
|
|
87
|
+
"""Write `data` to `path` atomically, keeping `<path>.bak` of the prior version."""
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
tmp_path = path.with_name(path.name + ".tmp")
|
|
90
|
+
bak_path = path.with_name(path.name + ".bak")
|
|
91
|
+
|
|
92
|
+
with tmp_path.open("w", encoding="utf-8") as fh:
|
|
93
|
+
yaml.dump(data, fh)
|
|
94
|
+
|
|
95
|
+
if path.exists():
|
|
96
|
+
os.replace(path, bak_path)
|
|
97
|
+
os.replace(tmp_path, path)
|