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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
leadger/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m leadger` as an alias for the `leadger` command."""
2
+
3
+ from .cli import app
4
+
5
+ app()
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()
@@ -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)
@@ -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)