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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
leadger/__main__.py
ADDED
leadger/cli.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Leadger CLI - typer entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
import uvicorn
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="leadger",
|
|
18
|
+
help="Leadger - produtividade pessoal local, calma e enxuta.",
|
|
19
|
+
invoke_without_command=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEFAULT_DATA_PATH = Path.home() / ".leadger" / "leadger.yaml"
|
|
23
|
+
DEFAULT_PORT = 4242
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _find_free_port(start: int = DEFAULT_PORT, attempts: int = 50) -> int:
|
|
27
|
+
"""Return the first free TCP port starting at `start`."""
|
|
28
|
+
for port in range(start, start + attempts):
|
|
29
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
30
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
31
|
+
try:
|
|
32
|
+
sock.bind(("127.0.0.1", port))
|
|
33
|
+
except OSError:
|
|
34
|
+
continue
|
|
35
|
+
return port
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
f"Nenhuma porta livre entre {start} e {start + attempts - 1}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_serve(data_path: Path, port: Optional[int], open_browser: bool) -> None:
|
|
42
|
+
from leadger.server import create_app
|
|
43
|
+
|
|
44
|
+
chosen_port = port or _find_free_port()
|
|
45
|
+
fastapi_app = create_app(data_path)
|
|
46
|
+
|
|
47
|
+
if open_browser:
|
|
48
|
+
|
|
49
|
+
def _open() -> None:
|
|
50
|
+
time.sleep(0.5)
|
|
51
|
+
webbrowser.open(f"http://127.0.0.1:{chosen_port}")
|
|
52
|
+
|
|
53
|
+
threading.Thread(target=_open, daemon=True).start()
|
|
54
|
+
|
|
55
|
+
typer.echo(f"Leadger em http://127.0.0.1:{chosen_port} (dados: {data_path})")
|
|
56
|
+
uvicorn.run(fastapi_app, host="127.0.0.1", port=chosen_port, log_level="warning")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.callback(invoke_without_command=True)
|
|
60
|
+
def main(
|
|
61
|
+
ctx: typer.Context,
|
|
62
|
+
data: Optional[Path] = typer.Option(
|
|
63
|
+
None,
|
|
64
|
+
"--data",
|
|
65
|
+
envvar="LEADGER_DATA",
|
|
66
|
+
help="Caminho para o arquivo leadger.yaml (default: ~/.leadger/leadger.yaml)",
|
|
67
|
+
),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Leadger - produtividade pessoal local, calma e enxuta."""
|
|
70
|
+
ctx.obj = {"data": data or DEFAULT_DATA_PATH}
|
|
71
|
+
if ctx.invoked_subcommand is None:
|
|
72
|
+
_run_serve(ctx.obj["data"], port=None, open_browser=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
def serve(
|
|
77
|
+
ctx: typer.Context,
|
|
78
|
+
port: Optional[int] = typer.Option(
|
|
79
|
+
None,
|
|
80
|
+
"--port",
|
|
81
|
+
help="Porta do servidor (default: primeira porta livre a partir de 4242)",
|
|
82
|
+
),
|
|
83
|
+
no_browser: bool = typer.Option(
|
|
84
|
+
False, "--no-browser", help="Nao abre o navegador automaticamente"
|
|
85
|
+
),
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Sobe o servidor web local e abre o navegador."""
|
|
88
|
+
_run_serve(ctx.obj["data"], port=port, open_browser=not no_browser)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _open_storage(ctx: typer.Context):
|
|
92
|
+
from leadger.core import CorruptedYAMLError, Storage
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
return Storage(ctx.obj["data"])
|
|
96
|
+
except CorruptedYAMLError as exc:
|
|
97
|
+
typer.echo(f"Erro: {exc}", err=True)
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def add(
|
|
103
|
+
ctx: typer.Context,
|
|
104
|
+
text: str = typer.Argument(
|
|
105
|
+
...,
|
|
106
|
+
help="Texto da tarefa (suporta #tag, !data e recorrencia, ex.: !amanha, !rec:seg)",
|
|
107
|
+
),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Adiciona uma tarefa direto pelo terminal."""
|
|
110
|
+
from leadger.core.parse import parse_inline
|
|
111
|
+
|
|
112
|
+
storage = _open_storage(ctx)
|
|
113
|
+
parsed = parse_inline(text, storage.today())
|
|
114
|
+
if not parsed.title:
|
|
115
|
+
typer.echo("Erro: tarefa sem titulo.", err=True)
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
task = storage.add_task(
|
|
119
|
+
parsed.title, target=parsed.target, tags=parsed.tags, recur=parsed.recur
|
|
120
|
+
)
|
|
121
|
+
tags_label = " " + " ".join(f"#{tag}" for tag in task.tags) if task.tags else ""
|
|
122
|
+
recur_label = f" (rec: {task.recur})" if task.recur else ""
|
|
123
|
+
typer.echo(
|
|
124
|
+
f"Adicionada: {task.title} -> {task.target.isoformat()}{tags_label}{recur_label}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_STATUS_SECTIONS = [
|
|
129
|
+
("A fazer", "todo", "[ ]"),
|
|
130
|
+
("Pausadas", "paused", "[~]"),
|
|
131
|
+
("Concluidas", "done", "[x]"),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command()
|
|
136
|
+
def today(ctx: typer.Context) -> None:
|
|
137
|
+
"""Mostra o resumo do dia atual: meta, entregues, % e lista."""
|
|
138
|
+
storage = _open_storage(ctx)
|
|
139
|
+
metrics = storage.get_day_metrics()
|
|
140
|
+
|
|
141
|
+
if metrics.bonus_day:
|
|
142
|
+
pct_label = "dia bonus"
|
|
143
|
+
elif metrics.pct is None:
|
|
144
|
+
pct_label = "-"
|
|
145
|
+
else:
|
|
146
|
+
pct_label = f"{metrics.pct:.0f}%"
|
|
147
|
+
extras_label = f" ({metrics.extras_done} extras)" if metrics.extras_done else ""
|
|
148
|
+
typer.echo(
|
|
149
|
+
f"{metrics.date.isoformat()} | meta {metrics.goal}"
|
|
150
|
+
f" | entregues {metrics.done}{extras_label} | {pct_label}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
day_tasks = [task for task in storage.all_tasks() if task.target == metrics.date]
|
|
154
|
+
for label, status, marker in _STATUS_SECTIONS:
|
|
155
|
+
section = [task for task in day_tasks if task.status.value == status]
|
|
156
|
+
if not section:
|
|
157
|
+
continue
|
|
158
|
+
typer.echo(f"\n{label}:")
|
|
159
|
+
for task in section:
|
|
160
|
+
tags_label = " " + " ".join(f"#{tag}" for tag in task.tags) if task.tags else ""
|
|
161
|
+
recur_label = f" (rec: {task.recur})" if task.recur else ""
|
|
162
|
+
rotten = f" (migrada {task.migrations}x)" if task.migrations >= 3 else ""
|
|
163
|
+
typer.echo(f" {marker} {task.title}{tags_label}{recur_label}{rotten}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_WEEKDAY_LABELS = ["seg", "ter", "qua", "qui", "sex", "sab", "dom"]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def week(ctx: typer.Context) -> None:
|
|
171
|
+
"""Resumo da semana: % por dia, agregado e tarefas (nao) concluidas."""
|
|
172
|
+
from leadger.core.periods import period_range
|
|
173
|
+
|
|
174
|
+
storage = _open_storage(ctx)
|
|
175
|
+
today_metrics = storage.get_day_metrics() # runs the lazy snapshot + rollover
|
|
176
|
+
today = today_metrics.date
|
|
177
|
+
start, end = period_range("week", today)
|
|
178
|
+
recorded = set(storage.recorded_days())
|
|
179
|
+
|
|
180
|
+
week_days = [start + timedelta(days=i) for i in range(7)]
|
|
181
|
+
day_metrics = {day: storage.get_day_metrics(day) for day in week_days if day in recorded}
|
|
182
|
+
goal_sum = sum(m.goal for m in day_metrics.values())
|
|
183
|
+
done_sum = sum(m.done for m in day_metrics.values())
|
|
184
|
+
if goal_sum == 0:
|
|
185
|
+
week_pct = "dia bonus" if done_sum > 0 else "0%"
|
|
186
|
+
else:
|
|
187
|
+
week_pct = f"{done_sum / goal_sum * 100:.0f}%"
|
|
188
|
+
typer.echo(
|
|
189
|
+
f"Semana {start.isoformat()} -> {end.isoformat()}"
|
|
190
|
+
f" | meta {goal_sum} | entregues {done_sum} | {week_pct}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
typer.echo("")
|
|
194
|
+
for i, day in enumerate(week_days):
|
|
195
|
+
marker = "*" if day == today else " "
|
|
196
|
+
label = f"{_WEEKDAY_LABELS[i]} {day.day:02d}"
|
|
197
|
+
metrics = day_metrics.get(day)
|
|
198
|
+
if metrics is None:
|
|
199
|
+
typer.echo(f" {marker}{label} -")
|
|
200
|
+
continue
|
|
201
|
+
if metrics.bonus_day:
|
|
202
|
+
pct_label = "dia bonus"
|
|
203
|
+
elif metrics.pct is None:
|
|
204
|
+
pct_label = "-"
|
|
205
|
+
else:
|
|
206
|
+
pct_label = f"{metrics.pct:.0f}%"
|
|
207
|
+
typer.echo(
|
|
208
|
+
f" {marker}{label} meta {metrics.goal} entregues {metrics.done} {pct_label}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
week_tasks = [
|
|
212
|
+
task
|
|
213
|
+
for task in storage.all_tasks()
|
|
214
|
+
if start <= task.target <= end and task.status.value != "cancelled"
|
|
215
|
+
]
|
|
216
|
+
week_tasks.sort(key=lambda task: (task.target, task.created.isoformat()))
|
|
217
|
+
open_tasks = [task for task in week_tasks if task.status.value != "done"]
|
|
218
|
+
done_tasks = [task for task in week_tasks if task.status.value == "done"]
|
|
219
|
+
|
|
220
|
+
def _echo_section(label: str, marker: str, tasks: list) -> None:
|
|
221
|
+
typer.echo(f"\n{label} ({len(tasks)}):")
|
|
222
|
+
for task in tasks:
|
|
223
|
+
tags_label = " " + " ".join(f"#{tag}" for tag in task.tags) if task.tags else ""
|
|
224
|
+
recur_label = f" (rec: {task.recur})" if task.recur else ""
|
|
225
|
+
day_label = f"({_WEEKDAY_LABELS[task.target.weekday()]} {task.target.day:02d})"
|
|
226
|
+
typer.echo(f" {marker} {task.title}{tags_label}{recur_label} {day_label}")
|
|
227
|
+
|
|
228
|
+
_echo_section("Nao concluidas", "[ ]", open_tasks)
|
|
229
|
+
_echo_section("Concluidas", "[x]", done_tasks)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.command()
|
|
233
|
+
def ics(
|
|
234
|
+
ctx: typer.Context,
|
|
235
|
+
out: Optional[Path] = typer.Option(
|
|
236
|
+
None, "--out", help="Grava o .ics nesse arquivo (default: imprime no stdout)"
|
|
237
|
+
),
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Exporta as tarefas abertas como iCalendar (.ics), com RRULE para recorrentes."""
|
|
240
|
+
from leadger.core.ics import tasks_to_ics
|
|
241
|
+
|
|
242
|
+
storage = _open_storage(ctx)
|
|
243
|
+
storage.ensure_day()
|
|
244
|
+
content = tasks_to_ics(storage.all_tasks(), now=storage.now())
|
|
245
|
+
if out:
|
|
246
|
+
out.write_text(content, encoding="utf-8", newline="") # keep the RFC's CRLF
|
|
247
|
+
typer.echo(f"Calendario exportado: {out}")
|
|
248
|
+
else:
|
|
249
|
+
typer.echo(content, nl=False)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
app()
|
leadger/core/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Leadger core domain: YAML storage, task state machine, day metrics."""
|
|
2
|
+
|
|
3
|
+
from .storage import DayMetrics, Storage
|
|
4
|
+
from .tasks import InvalidTransitionError, Task, TaskStatus
|
|
5
|
+
from .yaml_io import CorruptedYAMLError
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Storage",
|
|
9
|
+
"DayMetrics",
|
|
10
|
+
"Task",
|
|
11
|
+
"TaskStatus",
|
|
12
|
+
"InvalidTransitionError",
|
|
13
|
+
"CorruptedYAMLError",
|
|
14
|
+
]
|
leadger/core/ics.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Read-only iCalendar export: open tasks as all-day events. Recurrence
|
|
2
|
+
becomes RRULE, so the whole series shows up projected on the calendar even
|
|
3
|
+
though the YAML only holds the current occurrence.
|
|
4
|
+
|
|
5
|
+
No cloud: the file is generated locally (CLI `leadger ics`) or served at
|
|
6
|
+
`/calendar.ics` by the local server, and any calendar app can import it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
from .recur import DAILY
|
|
15
|
+
from .tasks import Task, TaskStatus
|
|
16
|
+
|
|
17
|
+
_BYDAY = {"seg": "MO", "ter": "TU", "qua": "WE", "qui": "TH", "sex": "FR", "sab": "SA", "dom": "SU"}
|
|
18
|
+
|
|
19
|
+
_MAX_LINE = 73 # RFC 5545 folds lines longer than 75 octets
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _escape(text: str) -> str:
|
|
23
|
+
return (
|
|
24
|
+
text.replace("\\", "\\\\")
|
|
25
|
+
.replace(";", "\\;")
|
|
26
|
+
.replace(",", "\\,")
|
|
27
|
+
.replace("\n", "\\n")
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fold(line: str) -> list[str]:
|
|
32
|
+
"""Break long lines with space-prefixed continuations (RFC 5545 3.1)."""
|
|
33
|
+
if len(line) <= _MAX_LINE:
|
|
34
|
+
return [line]
|
|
35
|
+
parts = [line[:_MAX_LINE]]
|
|
36
|
+
rest = line[_MAX_LINE:]
|
|
37
|
+
while rest:
|
|
38
|
+
parts.append(" " + rest[: _MAX_LINE - 1])
|
|
39
|
+
rest = rest[_MAX_LINE - 1 :]
|
|
40
|
+
return parts
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rrule(recur: str) -> str:
|
|
44
|
+
if recur == DAILY:
|
|
45
|
+
return "FREQ=DAILY"
|
|
46
|
+
return f"FREQ=WEEKLY;BYDAY={_BYDAY[recur]}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tasks_to_ics(tasks: Iterable[Task], *, now: datetime) -> str:
|
|
50
|
+
"""VCALENDAR with one all-day VEVENT per open (todo/paused) task."""
|
|
51
|
+
stamp = now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
52
|
+
lines = [
|
|
53
|
+
"BEGIN:VCALENDAR",
|
|
54
|
+
"VERSION:2.0",
|
|
55
|
+
"PRODID:-//Leadger//Leadger//PT",
|
|
56
|
+
"CALSCALE:GREGORIAN",
|
|
57
|
+
"X-WR-CALNAME:Leadger",
|
|
58
|
+
]
|
|
59
|
+
for task in tasks:
|
|
60
|
+
if task.status not in (TaskStatus.TODO, TaskStatus.PAUSED):
|
|
61
|
+
continue
|
|
62
|
+
start = task.target
|
|
63
|
+
end = start + timedelta(days=1)
|
|
64
|
+
lines += [
|
|
65
|
+
"BEGIN:VEVENT",
|
|
66
|
+
f"UID:{task.id}@leadger",
|
|
67
|
+
f"DTSTAMP:{stamp}",
|
|
68
|
+
f"DTSTART;VALUE=DATE:{start.strftime('%Y%m%d')}",
|
|
69
|
+
f"DTEND;VALUE=DATE:{end.strftime('%Y%m%d')}",
|
|
70
|
+
f"SUMMARY:{_escape(task.title)}",
|
|
71
|
+
]
|
|
72
|
+
if task.tags:
|
|
73
|
+
lines.append("CATEGORIES:" + ",".join(_escape(tag) for tag in task.tags))
|
|
74
|
+
if task.recur:
|
|
75
|
+
lines.append(f"RRULE:{_rrule(task.recur)}")
|
|
76
|
+
lines.append("END:VEVENT")
|
|
77
|
+
lines.append("END:VCALENDAR")
|
|
78
|
+
|
|
79
|
+
folded: list[str] = []
|
|
80
|
+
for line in lines:
|
|
81
|
+
folded.extend(_fold(line))
|
|
82
|
+
return "\r\n".join(folded) + "\r\n"
|
leadger/core/ids.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Task ID generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from typing import AbstractSet
|
|
7
|
+
|
|
8
|
+
ID_PREFIX = "t_"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_task_id(existing_ids: AbstractSet[str]) -> str:
|
|
12
|
+
"""Generate a unique task id: 't_' + 6 lowercase hex chars."""
|
|
13
|
+
while True:
|
|
14
|
+
candidate = ID_PREFIX + secrets.token_hex(3)
|
|
15
|
+
if candidate not in existing_ids:
|
|
16
|
+
return candidate
|
leadger/core/parse.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Inline capture syntax: "Estudar FAISS #estudo !amanha".
|
|
2
|
+
|
|
3
|
+
Tokens:
|
|
4
|
+
- `#tag` -> adds a tag (removed from the title)
|
|
5
|
+
- `!hoje`, `!amanha`, `!seg`..`!dom`, `!YYYY-MM-DD` -> sets the target
|
|
6
|
+
date (removed from the title); the last date token wins
|
|
7
|
+
- `!todo-dia`, `!rec:dia`, `!rec:seg`..`!rec:dom` -> recurrence; without
|
|
8
|
+
an explicit date, the target defaults to the first occurrence
|
|
9
|
+
Unrecognized `!...` tokens stay in the title untouched.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import unicodedata
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import date, timedelta
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from .recur import WEEKDAYS as _WEEKDAYS
|
|
20
|
+
from .recur import first_occurrence, normalize_recur
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ParsedInput:
|
|
25
|
+
title: str
|
|
26
|
+
tags: list[str] = field(default_factory=list)
|
|
27
|
+
target: Optional[date] = None # None = not specified (caller defaults to today)
|
|
28
|
+
recur: Optional[str] = None # "dia" | "seg".."dom" (see core/recur.py)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _strip_accents(text: str) -> str:
|
|
32
|
+
return unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_date_token(token: str, today: date) -> Optional[date]:
|
|
36
|
+
"""`hoje`/`amanha`/weekday/ISO date -> date, or None if unrecognized."""
|
|
37
|
+
word = _strip_accents(token.lower())
|
|
38
|
+
if word == "hoje":
|
|
39
|
+
return today
|
|
40
|
+
if word == "amanha":
|
|
41
|
+
return today + timedelta(days=1)
|
|
42
|
+
if word in _WEEKDAYS:
|
|
43
|
+
# next occurrence of that weekday; today itself counts
|
|
44
|
+
return today + timedelta(days=(_WEEKDAYS[word] - today.weekday()) % 7)
|
|
45
|
+
try:
|
|
46
|
+
return date.fromisoformat(word)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_inline(text: str, today: date) -> ParsedInput:
|
|
52
|
+
"""Split capture text into title, tags, target date and recurrence."""
|
|
53
|
+
tags: list[str] = []
|
|
54
|
+
target: Optional[date] = None
|
|
55
|
+
recur: Optional[str] = None
|
|
56
|
+
title_words: list[str] = []
|
|
57
|
+
|
|
58
|
+
for token in text.split():
|
|
59
|
+
if token.startswith("#") and len(token) > 1:
|
|
60
|
+
tag = token[1:]
|
|
61
|
+
if tag not in tags:
|
|
62
|
+
tags.append(tag)
|
|
63
|
+
elif token.startswith("!") and len(token) > 1:
|
|
64
|
+
recur_spec = normalize_recur(_strip_accents(token[1:].lower()))
|
|
65
|
+
if recur_spec is not None:
|
|
66
|
+
recur = recur_spec
|
|
67
|
+
continue
|
|
68
|
+
resolved = _resolve_date_token(token[1:], today)
|
|
69
|
+
if resolved is not None:
|
|
70
|
+
target = resolved
|
|
71
|
+
else:
|
|
72
|
+
title_words.append(token)
|
|
73
|
+
else:
|
|
74
|
+
title_words.append(token)
|
|
75
|
+
|
|
76
|
+
if target is None and recur is not None:
|
|
77
|
+
target = first_occurrence(recur, today)
|
|
78
|
+
|
|
79
|
+
return ParsedInput(title=" ".join(title_words), tags=tags, target=target, recur=recur)
|
leadger/core/periods.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Period filters (regra 5): date ranges over `target`, week starts Monday."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
|
|
8
|
+
PERIODS = ("day", "week", "month", "quarter", "semester", "year")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _month_end(year: int, month: int) -> date:
|
|
12
|
+
return date(year, month, calendar.monthrange(year, month)[1])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def period_range(period: str, today: date) -> tuple[date, date]:
|
|
16
|
+
"""Inclusive (start, end) range of `period` containing `today`."""
|
|
17
|
+
if period == "day":
|
|
18
|
+
return today, today
|
|
19
|
+
if period == "week":
|
|
20
|
+
start = today - timedelta(days=today.weekday())
|
|
21
|
+
return start, start + timedelta(days=6)
|
|
22
|
+
if period == "month":
|
|
23
|
+
return today.replace(day=1), _month_end(today.year, today.month)
|
|
24
|
+
if period == "quarter":
|
|
25
|
+
start_month = 3 * ((today.month - 1) // 3) + 1
|
|
26
|
+
return date(today.year, start_month, 1), _month_end(today.year, start_month + 2)
|
|
27
|
+
if period == "semester":
|
|
28
|
+
start_month = 1 if today.month <= 6 else 7
|
|
29
|
+
return date(today.year, start_month, 1), _month_end(today.year, start_month + 5)
|
|
30
|
+
if period == "year":
|
|
31
|
+
return date(today.year, 1, 1), date(today.year, 12, 31)
|
|
32
|
+
raise ValueError(f"Periodo desconhecido: {period!r} (esperado um de {PERIODS})")
|
leadger/core/recur.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Minimal recurrence: `dia` (daily) or a weekday `seg`..`dom`.
|
|
2
|
+
|
|
3
|
+
Capture syntax: `!todo-dia`, `!rec:dia`, `!rec:seg`..`!rec:dom`.
|
|
4
|
+
The series is materialized one occurrence at a time: completing the open
|
|
5
|
+
occurrence creates the next one; cancelling or deleting ends the series.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import date, timedelta
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
DAILY = "dia"
|
|
14
|
+
|
|
15
|
+
# Monday=0 .. Sunday=6, matching date.weekday()
|
|
16
|
+
WEEKDAYS = {"seg": 0, "ter": 1, "qua": 2, "qui": 3, "sex": 4, "sab": 5, "dom": 6}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_recur(word: str) -> Optional[str]:
|
|
20
|
+
"""`todo-dia` / `rec:dia` / `rec:seg` -> normalized spec, else None.
|
|
21
|
+
|
|
22
|
+
`word` must already be lowercase and accent-stripped (as in parse_inline).
|
|
23
|
+
"""
|
|
24
|
+
if word == "todo-dia":
|
|
25
|
+
return DAILY
|
|
26
|
+
if word.startswith("rec:"):
|
|
27
|
+
spec = word[4:]
|
|
28
|
+
if spec == DAILY or spec in WEEKDAYS:
|
|
29
|
+
return spec
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def first_occurrence(recur: str, today: date) -> date:
|
|
34
|
+
"""First occurrence starting at `today` (today itself counts)."""
|
|
35
|
+
if recur == DAILY:
|
|
36
|
+
return today
|
|
37
|
+
return today + timedelta(days=(WEEKDAYS[recur] - today.weekday()) % 7)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def next_occurrence(recur: str, after: date) -> date:
|
|
41
|
+
"""Next occurrence strictly after `after`."""
|
|
42
|
+
if recur == DAILY:
|
|
43
|
+
return after + timedelta(days=1)
|
|
44
|
+
delta = (WEEKDAYS[recur] - after.weekday()) % 7
|
|
45
|
+
return after + timedelta(days=delta or 7)
|