minutes-cli 0.1.0__tar.gz
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.
- minutes_cli-0.1.0/PKG-INFO +80 -0
- minutes_cli-0.1.0/README.md +65 -0
- minutes_cli-0.1.0/pyproject.toml +28 -0
- minutes_cli-0.1.0/src/minutes/__init__.py +0 -0
- minutes_cli-0.1.0/src/minutes/add.py +198 -0
- minutes_cli-0.1.0/src/minutes/cli.py +79 -0
- minutes_cli-0.1.0/src/minutes/display.py +156 -0
- minutes_cli-0.1.0/src/minutes/models.py +81 -0
- minutes_cli-0.1.0/src/minutes/store.py +101 -0
- minutes_cli-0.1.0/tests/__init__.py +0 -0
- minutes_cli-0.1.0/tests/test_add.py +101 -0
- minutes_cli-0.1.0/tests/test_models.py +62 -0
- minutes_cli-0.1.0/tests/test_store.py +142 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: minutes-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Project-centric meeting notes and task tracker for the command line
|
|
5
|
+
Project-URL: Repository, https://github.com/ucyo/minutes
|
|
6
|
+
Author-email: Ugur Cayoglu <cayoglu@me.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: prompt-toolkit
|
|
10
|
+
Requires-Dist: rich
|
|
11
|
+
Requires-Dist: typer
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# minutes
|
|
17
|
+
|
|
18
|
+
Project-centric meeting notes and task tracker for the command line.
|
|
19
|
+
|
|
20
|
+
Capture notes from meetings, track actions and decisions per project, and get a clean weekly summary before giving updates.
|
|
21
|
+
|
|
22
|
+
## Getting started
|
|
23
|
+
|
|
24
|
+
**Requirements:** Python 3.11+
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install -e .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Add entries after a meeting:**
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
minutes add
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
You will be prompted for a project name (with autocomplete) and an optional meeting name. Then enter entries one per line:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
* decision → * Drop v1 endpoints by Q3
|
|
40
|
+
! action → ! Write migration guide @fri
|
|
41
|
+
> waiting → > Spec approval from Marco
|
|
42
|
+
note → Just a note, no prefix needed
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Press Enter on an empty line or Ctrl+D to finish. Each entry is saved immediately.
|
|
46
|
+
|
|
47
|
+
**Enable shell completion (once):**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
minutes --install-completion
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
After restarting your shell, `minutes logs -p <TAB>` and `minutes add -p <TAB>` will autocomplete project names from your store.
|
|
54
|
+
|
|
55
|
+
**Browse entries:**
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
minutes logs # all projects, all entries
|
|
59
|
+
minutes logs --project api-migration # one project in detail
|
|
60
|
+
minutes logs --since 14 # last 14 days
|
|
61
|
+
minutes logs --since mon # since Monday
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Check open actions:**
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
minutes logs --open
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Running tests
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
make test
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Data
|
|
77
|
+
|
|
78
|
+
Entries are stored as JSONL at `~/.local/share/minutes/entries.jsonl`.
|
|
79
|
+
|
|
80
|
+
See [docs/whitepaper.md](docs/whitepaper.md) for the full specification.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# minutes
|
|
2
|
+
|
|
3
|
+
Project-centric meeting notes and task tracker for the command line.
|
|
4
|
+
|
|
5
|
+
Capture notes from meetings, track actions and decisions per project, and get a clean weekly summary before giving updates.
|
|
6
|
+
|
|
7
|
+
## Getting started
|
|
8
|
+
|
|
9
|
+
**Requirements:** Python 3.11+
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install -e .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Add entries after a meeting:**
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
minutes add
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
You will be prompted for a project name (with autocomplete) and an optional meeting name. Then enter entries one per line:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
* decision → * Drop v1 endpoints by Q3
|
|
25
|
+
! action → ! Write migration guide @fri
|
|
26
|
+
> waiting → > Spec approval from Marco
|
|
27
|
+
note → Just a note, no prefix needed
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Press Enter on an empty line or Ctrl+D to finish. Each entry is saved immediately.
|
|
31
|
+
|
|
32
|
+
**Enable shell completion (once):**
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
minutes --install-completion
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
After restarting your shell, `minutes logs -p <TAB>` and `minutes add -p <TAB>` will autocomplete project names from your store.
|
|
39
|
+
|
|
40
|
+
**Browse entries:**
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
minutes logs # all projects, all entries
|
|
44
|
+
minutes logs --project api-migration # one project in detail
|
|
45
|
+
minutes logs --since 14 # last 14 days
|
|
46
|
+
minutes logs --since mon # since Monday
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Check open actions:**
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
minutes logs --open
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Running tests
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
make test
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Data
|
|
62
|
+
|
|
63
|
+
Entries are stored as JSONL at `~/.local/share/minutes/entries.jsonl`.
|
|
64
|
+
|
|
65
|
+
See [docs/whitepaper.md](docs/whitepaper.md) for the full specification.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "minutes-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Project-centric meeting notes and task tracker for the command line"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Ugur Cayoglu", email = "cayoglu@me.com" }]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = ["typer", "rich", "prompt_toolkit"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
minutes = "minutes.cli:main"
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Repository = "https://github.com/ucyo/minutes"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/minutes"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit import PromptSession
|
|
9
|
+
from prompt_toolkit.application import get_app
|
|
10
|
+
from prompt_toolkit.completion import FuzzyWordCompleter
|
|
11
|
+
from prompt_toolkit.formatted_text import HTML
|
|
12
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
13
|
+
from prompt_toolkit.styles import Style
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from .display import _TYPE_COLORS, _TYPE_LABELS
|
|
17
|
+
from .models import Entry, EntryStatus, EntryType
|
|
18
|
+
from .store import append_entry, get_projects
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
_STYLE = Style.from_dict({
|
|
23
|
+
"bottom-toolbar": "bg:#2a2a2a #888888",
|
|
24
|
+
"completion-menu.completion": "bg:#1e3a5f #ffffff",
|
|
25
|
+
"completion-menu.completion.current": "bg:#0066cc #ffffff",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
_WEEKDAYS = {"mon": 0, "tue": 1, "wed": 2, "thu": 3, "fri": 4, "sat": 5, "sun": 6}
|
|
29
|
+
|
|
30
|
+
_entry_bindings = KeyBindings()
|
|
31
|
+
|
|
32
|
+
@_entry_bindings.add("c-d")
|
|
33
|
+
def _exit_on_ctrl_d(event):
|
|
34
|
+
event.app.exit(exception=EOFError())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _toolbar() -> HTML:
|
|
38
|
+
try:
|
|
39
|
+
text = get_app().current_buffer.text
|
|
40
|
+
except Exception:
|
|
41
|
+
text = ""
|
|
42
|
+
|
|
43
|
+
if text.startswith("*"):
|
|
44
|
+
return HTML(
|
|
45
|
+
" <ansiblue><b>decision</b></ansiblue> — type the decision text"
|
|
46
|
+
)
|
|
47
|
+
if text.startswith("!"):
|
|
48
|
+
return HTML(
|
|
49
|
+
" <ansiyellow><b>action</b></ansiyellow> — "
|
|
50
|
+
"due date: <ansigreen>@fri @7 @2026-05-30</ansigreen>"
|
|
51
|
+
)
|
|
52
|
+
if text.startswith(">"):
|
|
53
|
+
return HTML(
|
|
54
|
+
" <ansimagenta><b>waiting</b></ansimagenta> — "
|
|
55
|
+
"person: <ansigreen>@Person</ansigreen>"
|
|
56
|
+
)
|
|
57
|
+
return HTML(
|
|
58
|
+
" <ansiblue>*</ansiblue> decision "
|
|
59
|
+
"<ansiyellow>!</ansiyellow> action "
|
|
60
|
+
"<ansimagenta>></ansimagenta> waiting "
|
|
61
|
+
"note"
|
|
62
|
+
" │ "
|
|
63
|
+
"Empty line or Ctrl+D to finish"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_due(token: str) -> Optional[str]:
|
|
68
|
+
today = date.today()
|
|
69
|
+
t = token.lower()
|
|
70
|
+
if t in _WEEKDAYS:
|
|
71
|
+
target_wd = _WEEKDAYS[t]
|
|
72
|
+
delta = (target_wd - today.weekday()) % 7 or 7
|
|
73
|
+
return (today + timedelta(days=delta)).isoformat()
|
|
74
|
+
try:
|
|
75
|
+
return (today + timedelta(days=int(t))).isoformat()
|
|
76
|
+
except ValueError:
|
|
77
|
+
pass
|
|
78
|
+
try:
|
|
79
|
+
return date.fromisoformat(t).isoformat()
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_line(raw: str, meeting: Optional[str] = None) -> Optional[Entry]:
|
|
85
|
+
raw = raw.strip()
|
|
86
|
+
if not raw:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
entry_id = Entry.make_id()
|
|
90
|
+
ts = Entry.now_ts()
|
|
91
|
+
|
|
92
|
+
if raw.startswith("*"):
|
|
93
|
+
return Entry(
|
|
94
|
+
id=entry_id, ts=ts, project="",
|
|
95
|
+
type=EntryType.DECISION, text=raw[1:].strip(), meeting=meeting,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if raw.startswith("!"):
|
|
99
|
+
rest = raw[1:].strip()
|
|
100
|
+
due = None
|
|
101
|
+
m = re.search(r"@(\S+)", rest)
|
|
102
|
+
if m:
|
|
103
|
+
due = _parse_due(m.group(1))
|
|
104
|
+
rest = (rest[: m.start()] + rest[m.end() :]).strip()
|
|
105
|
+
return Entry(
|
|
106
|
+
id=entry_id, ts=ts, project="",
|
|
107
|
+
type=EntryType.ACTION, text=rest, meeting=meeting,
|
|
108
|
+
due=due, status=EntryStatus.OPEN,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if raw.startswith(">"):
|
|
112
|
+
rest = raw[1:].strip()
|
|
113
|
+
person = None
|
|
114
|
+
m = re.search(r"@(\w+)", rest)
|
|
115
|
+
if m:
|
|
116
|
+
person = m.group(1)
|
|
117
|
+
rest = (rest[: m.start()] + rest[m.end() :]).strip()
|
|
118
|
+
return Entry(
|
|
119
|
+
id=entry_id, ts=ts, project="",
|
|
120
|
+
type=EntryType.WAITING, text=rest, meeting=meeting, person=person,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return Entry(id=entry_id, ts=ts, project="", type=EntryType.NOTE, text=raw, meeting=meeting)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _echo_entry(entry: Entry) -> None:
|
|
127
|
+
color = _TYPE_COLORS[entry.type]
|
|
128
|
+
label = _TYPE_LABELS[entry.type]
|
|
129
|
+
extra = ""
|
|
130
|
+
if entry.due:
|
|
131
|
+
extra += f" [dim]due {entry.due}[/dim]"
|
|
132
|
+
if entry.person:
|
|
133
|
+
extra += f" [dim]→ {entry.person}[/dim]"
|
|
134
|
+
console.print(f" [{color}]{label:10}[/{color}] {entry.text}{extra}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_add(store: Optional[Path] = None, project: Optional[str] = None) -> None:
|
|
138
|
+
projects = get_projects(store)
|
|
139
|
+
project_session: PromptSession = PromptSession(
|
|
140
|
+
style=_STYLE, completer=FuzzyWordCompleter(projects)
|
|
141
|
+
)
|
|
142
|
+
entry_session: PromptSession = PromptSession(style=_STYLE)
|
|
143
|
+
|
|
144
|
+
console.print()
|
|
145
|
+
|
|
146
|
+
def _show_all_completions():
|
|
147
|
+
get_app().current_buffer.start_completion(select_first=False)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
project = project_session.prompt(
|
|
151
|
+
HTML("<ansiblue><b>Project: </b></ansiblue>"),
|
|
152
|
+
default=project or "",
|
|
153
|
+
pre_run=_show_all_completions,
|
|
154
|
+
).strip()
|
|
155
|
+
except (EOFError, KeyboardInterrupt):
|
|
156
|
+
console.print("[dim]Aborted.[/dim]")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if not project:
|
|
160
|
+
console.print("[dim]Aborted.[/dim]")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
meeting_raw = entry_session.prompt(HTML("<ansicyan>Meeting (optional): </ansicyan>")).strip()
|
|
165
|
+
except (EOFError, KeyboardInterrupt):
|
|
166
|
+
console.print("[dim]Aborted.[/dim]")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
meeting = meeting_raw or None
|
|
170
|
+
console.print()
|
|
171
|
+
|
|
172
|
+
saved = 0
|
|
173
|
+
while True:
|
|
174
|
+
try:
|
|
175
|
+
raw = entry_session.prompt(HTML("<b>→ </b>"), bottom_toolbar=_toolbar, key_bindings=_entry_bindings)
|
|
176
|
+
except (EOFError, KeyboardInterrupt):
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
if not raw.strip():
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
entry = parse_line(raw, meeting)
|
|
183
|
+
if entry is None:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
entry.project = project
|
|
187
|
+
append_entry(entry, store)
|
|
188
|
+
_echo_entry(entry)
|
|
189
|
+
saved += 1
|
|
190
|
+
|
|
191
|
+
if saved == 0:
|
|
192
|
+
console.print()
|
|
193
|
+
console.print("[dim]Nothing saved.[/dim]")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
console.print()
|
|
197
|
+
noun = "entry" if saved == 1 else "entries"
|
|
198
|
+
console.print(f"[green]Saved {saved} {noun} to[/green] [bold cyan]{project}[/bold cyan].")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from .add import parse_line, run_add
|
|
10
|
+
from .display import render_logs
|
|
11
|
+
from .store import (
|
|
12
|
+
append_entry,
|
|
13
|
+
filter_entries,
|
|
14
|
+
get_projects,
|
|
15
|
+
load_entries,
|
|
16
|
+
parse_since,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
help="tgsa — project-centric meeting notes and task tracker",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _complete_project(incomplete: str) -> list[str]:
|
|
27
|
+
try:
|
|
28
|
+
return [p for p in get_projects() if incomplete.lower() in p.lower()]
|
|
29
|
+
except Exception:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def add(
|
|
35
|
+
text: Optional[str] = typer.Argument(None, help="Inline entry (skips interactive mode)"),
|
|
36
|
+
project: Optional[str] = typer.Option(
|
|
37
|
+
None, "--project", "-p",
|
|
38
|
+
help="Project slug",
|
|
39
|
+
autocompletion=_complete_project,
|
|
40
|
+
),
|
|
41
|
+
file: Optional[Path] = typer.Option(None, "--file", help="Override store path"),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Add entries for a project interactively, or inline with --project."""
|
|
44
|
+
if text is not None and project is not None:
|
|
45
|
+
entry = parse_line(text)
|
|
46
|
+
if entry is None:
|
|
47
|
+
console.print("[red]Could not parse entry.[/red]")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
entry.project = project
|
|
50
|
+
append_entry(entry, file)
|
|
51
|
+
console.print("[green]Saved.[/green]")
|
|
52
|
+
else:
|
|
53
|
+
if text is not None and project is None:
|
|
54
|
+
console.print("[red]Inline mode requires --project.[/red]")
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
run_add(file, project=project)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def logs(
|
|
61
|
+
project: Optional[str] = typer.Option(
|
|
62
|
+
None, "--project", "-p",
|
|
63
|
+
help="Scope to one project",
|
|
64
|
+
autocompletion=_complete_project,
|
|
65
|
+
),
|
|
66
|
+
since: Optional[str] = typer.Option(None, "--since", help="Restrict to entries since: 'mon', integer days back, or YYYY-MM-DD"),
|
|
67
|
+
open_only: bool = typer.Option(False, "--open", help="Show only open actions and waiting entries"),
|
|
68
|
+
show_all: bool = typer.Option(False, "--all", help="Show all fields: meeting, tags, done timestamp"),
|
|
69
|
+
file: Optional[Path] = typer.Option(None, "--file"),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Show entries chronologically. All entries by default, restrict with --since."""
|
|
72
|
+
since_date = parse_since(since) if since else None
|
|
73
|
+
entries = load_entries(file)
|
|
74
|
+
filtered = filter_entries(entries, since=since_date, project=project, open_only=open_only)
|
|
75
|
+
render_logs(filtered, since_date, project=project, show_all=show_all)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
app()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, timedelta
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from .models import Entry, EntryStatus, EntryType
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
_DUE_SOON_DAYS = 2
|
|
15
|
+
|
|
16
|
+
_BUCKET_ORDER = ["Older", "This year", "This month", "This week", "Yesterday", "Today"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _date_bucket(entry_date: date, today: date) -> str:
|
|
20
|
+
if entry_date == today:
|
|
21
|
+
return "Today"
|
|
22
|
+
if entry_date == today - timedelta(days=1):
|
|
23
|
+
return "Yesterday"
|
|
24
|
+
if entry_date.isocalendar()[:2] == today.isocalendar()[:2]:
|
|
25
|
+
return "This week"
|
|
26
|
+
if entry_date.year == today.year and entry_date.month == today.month:
|
|
27
|
+
return "This month"
|
|
28
|
+
if entry_date.year == today.year:
|
|
29
|
+
return "This year"
|
|
30
|
+
return "Older"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _due_style(due_str: Optional[str]) -> str:
|
|
34
|
+
if due_str is None:
|
|
35
|
+
return ""
|
|
36
|
+
today = date.today()
|
|
37
|
+
d = date.fromisoformat(due_str)
|
|
38
|
+
if d < today:
|
|
39
|
+
return "bold red"
|
|
40
|
+
if d <= today + timedelta(days=_DUE_SOON_DAYS):
|
|
41
|
+
return "yellow"
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_TYPE_COLORS = {
|
|
46
|
+
EntryType.DECISION: "blue",
|
|
47
|
+
EntryType.ACTION: "yellow",
|
|
48
|
+
EntryType.WAITING: "magenta",
|
|
49
|
+
EntryType.NOTE: "dim",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_TYPE_LABELS = {
|
|
53
|
+
EntryType.DECISION: "decision",
|
|
54
|
+
EntryType.ACTION: "action",
|
|
55
|
+
EntryType.WAITING: "waiting",
|
|
56
|
+
EntryType.NOTE: "note",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_parts(entry: Entry) -> tuple[Text, Text]:
|
|
61
|
+
"""Return (label, content) Text objects for a single entry."""
|
|
62
|
+
color = _TYPE_COLORS[entry.type]
|
|
63
|
+
label = _TYPE_LABELS[entry.type]
|
|
64
|
+
|
|
65
|
+
if entry.type == EntryType.ACTION:
|
|
66
|
+
done = entry.status == EntryStatus.DONE
|
|
67
|
+
cancelled = entry.status == EntryStatus.CANCELLED
|
|
68
|
+
if done:
|
|
69
|
+
color = "green"
|
|
70
|
+
elif cancelled:
|
|
71
|
+
color = "dim"
|
|
72
|
+
elif entry.due:
|
|
73
|
+
color = _due_style(entry.due) or color
|
|
74
|
+
checkbox = " [x]" if done else (" [-]" if cancelled else " [ ]")
|
|
75
|
+
content = Text()
|
|
76
|
+
content.append(entry.text + checkbox, style=f"{color} strike" if done else color)
|
|
77
|
+
if entry.due and not done and not cancelled:
|
|
78
|
+
content.append(f" due {entry.due}", style=_due_style(entry.due) or "dim")
|
|
79
|
+
|
|
80
|
+
elif entry.type == EntryType.WAITING:
|
|
81
|
+
content = Text()
|
|
82
|
+
content.append(entry.text, style=color)
|
|
83
|
+
if entry.person:
|
|
84
|
+
content.append(f" → {entry.person}", style="dim magenta")
|
|
85
|
+
|
|
86
|
+
else:
|
|
87
|
+
content = Text(entry.text, style=color)
|
|
88
|
+
|
|
89
|
+
return Text(label, style=color), content
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_meta(entry: Entry) -> tuple[Text, Text, Text]:
|
|
93
|
+
"""Return (meeting, tags, updated_ts) as separate Text objects."""
|
|
94
|
+
meeting = Text(entry.meeting or "", style="dim")
|
|
95
|
+
tags = Text(" ".join(f"#{t}" for t in entry.tags), style="dim")
|
|
96
|
+
updated = Text()
|
|
97
|
+
if entry.type == EntryType.ACTION and entry.status == EntryStatus.DONE and entry.updated_ts:
|
|
98
|
+
updated = Text(entry.updated_ts[:16].replace("T", " "), style="dim")
|
|
99
|
+
return meeting, tags, updated
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _make_table(show_all: bool) -> Table:
|
|
103
|
+
table = Table(box=None, show_header=False, padding=(0, 2, 0, 0))
|
|
104
|
+
table.add_column(no_wrap=True) # timestamp
|
|
105
|
+
table.add_column(no_wrap=True) # project
|
|
106
|
+
table.add_column(no_wrap=True) # type label
|
|
107
|
+
table.add_column() # content — wraps here
|
|
108
|
+
if show_all:
|
|
109
|
+
table.add_column(no_wrap=True) # meeting
|
|
110
|
+
table.add_column(no_wrap=True) # tags
|
|
111
|
+
table.add_column(no_wrap=True) # updated_ts
|
|
112
|
+
return table
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _add_row(table: Table, entry: Entry, show_all: bool) -> None:
|
|
116
|
+
ts = Text(entry.ts[:16].replace("T", " "), style="dim")
|
|
117
|
+
proj = Text(entry.project, style="cyan")
|
|
118
|
+
label, content = _build_parts(entry)
|
|
119
|
+
if show_all:
|
|
120
|
+
meeting, tags, updated = _build_meta(entry)
|
|
121
|
+
table.add_row(ts, proj, label, content, meeting, tags, updated)
|
|
122
|
+
else:
|
|
123
|
+
table.add_row(ts, proj, label, content)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def render_logs(
|
|
127
|
+
entries: list[Entry],
|
|
128
|
+
since: Optional[date] = None,
|
|
129
|
+
project: Optional[str] = None,
|
|
130
|
+
show_all: bool = False,
|
|
131
|
+
) -> None:
|
|
132
|
+
if not entries:
|
|
133
|
+
console.print("[dim]No entries found.[/dim]")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
today = date.today()
|
|
137
|
+
sorted_entries = sorted(entries, key=lambda e: e.ts)
|
|
138
|
+
|
|
139
|
+
groups: dict[str, list[Entry]] = {b: [] for b in _BUCKET_ORDER}
|
|
140
|
+
for entry in sorted_entries:
|
|
141
|
+
bucket = _date_bucket(date.fromisoformat(entry.ts[:10]), today)
|
|
142
|
+
groups[bucket].append(entry)
|
|
143
|
+
|
|
144
|
+
first = True
|
|
145
|
+
for bucket in _BUCKET_ORDER:
|
|
146
|
+
bucket_entries = groups[bucket]
|
|
147
|
+
if not bucket_entries:
|
|
148
|
+
continue
|
|
149
|
+
if not first:
|
|
150
|
+
console.print()
|
|
151
|
+
console.rule(f"[dim]{bucket}[/dim]", style="dim")
|
|
152
|
+
table = _make_table(show_all)
|
|
153
|
+
for entry in bucket_entries:
|
|
154
|
+
_add_row(table, entry, show_all)
|
|
155
|
+
console.print(table)
|
|
156
|
+
first = False
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EntryType(str, Enum):
|
|
11
|
+
NOTE = "note"
|
|
12
|
+
ACTION = "action"
|
|
13
|
+
DECISION = "decision"
|
|
14
|
+
WAITING = "waiting"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EntryStatus(str, Enum):
|
|
18
|
+
OPEN = "open"
|
|
19
|
+
DONE = "done"
|
|
20
|
+
CANCELLED = "cancelled"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Entry:
|
|
25
|
+
id: str
|
|
26
|
+
ts: str
|
|
27
|
+
project: str
|
|
28
|
+
type: EntryType
|
|
29
|
+
text: str
|
|
30
|
+
meeting: Optional[str] = None
|
|
31
|
+
due: Optional[str] = None
|
|
32
|
+
status: Optional[EntryStatus] = None
|
|
33
|
+
person: Optional[str] = None
|
|
34
|
+
tags: list[str] = field(default_factory=list)
|
|
35
|
+
updated_ts: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
d: dict = {
|
|
39
|
+
"id": self.id,
|
|
40
|
+
"ts": self.ts,
|
|
41
|
+
"project": self.project,
|
|
42
|
+
"type": self.type.value,
|
|
43
|
+
"text": self.text,
|
|
44
|
+
}
|
|
45
|
+
if self.meeting is not None:
|
|
46
|
+
d["meeting"] = self.meeting
|
|
47
|
+
if self.due is not None:
|
|
48
|
+
d["due"] = self.due
|
|
49
|
+
if self.status is not None:
|
|
50
|
+
d["status"] = self.status.value
|
|
51
|
+
if self.person is not None:
|
|
52
|
+
d["person"] = self.person
|
|
53
|
+
if self.tags:
|
|
54
|
+
d["tags"] = self.tags
|
|
55
|
+
if self.updated_ts is not None:
|
|
56
|
+
d["updated_ts"] = self.updated_ts
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, d: dict) -> Entry:
|
|
61
|
+
return cls(
|
|
62
|
+
id=d["id"],
|
|
63
|
+
ts=d["ts"],
|
|
64
|
+
project=d["project"],
|
|
65
|
+
type=EntryType(d["type"]),
|
|
66
|
+
text=d["text"],
|
|
67
|
+
meeting=d.get("meeting"),
|
|
68
|
+
due=d.get("due"),
|
|
69
|
+
status=EntryStatus(d["status"]) if d.get("status") else None,
|
|
70
|
+
person=d.get("person"),
|
|
71
|
+
tags=d.get("tags", []),
|
|
72
|
+
updated_ts=d.get("updated_ts"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def make_id() -> str:
|
|
77
|
+
return uuid.uuid4().hex[:7]
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def now_ts() -> str:
|
|
81
|
+
return datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .models import Entry, EntryStatus, EntryType
|
|
9
|
+
|
|
10
|
+
DEFAULT_PATH = Path.home() / ".local" / "share" / "minutes" / "entries.jsonl"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_store_path(override: Optional[Path] = None) -> Path:
|
|
14
|
+
p = override or DEFAULT_PATH
|
|
15
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
return p
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def append_entry(entry: Entry, store: Optional[Path] = None) -> None:
|
|
20
|
+
path = get_store_path(store)
|
|
21
|
+
with open(path, "a") as f:
|
|
22
|
+
f.write(json.dumps(entry.to_dict()) + "\n")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_entries(store: Optional[Path] = None) -> list[Entry]:
|
|
26
|
+
path = get_store_path(store)
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return []
|
|
29
|
+
seen: dict[str, Entry] = {}
|
|
30
|
+
with open(path) as f:
|
|
31
|
+
for line in f:
|
|
32
|
+
line = line.strip()
|
|
33
|
+
if not line:
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
d = json.loads(line)
|
|
37
|
+
seen[d["id"]] = Entry.from_dict(d)
|
|
38
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
39
|
+
continue
|
|
40
|
+
return list(seen.values())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_projects(store: Optional[Path] = None) -> list[str]:
|
|
44
|
+
entries = load_entries(store)
|
|
45
|
+
projects: dict[str, str] = {}
|
|
46
|
+
for e in entries:
|
|
47
|
+
if e.project not in projects or e.ts > projects[e.project]:
|
|
48
|
+
projects[e.project] = e.ts
|
|
49
|
+
return sorted(projects.keys())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_since(value: str) -> date:
|
|
53
|
+
today = date.today()
|
|
54
|
+
if value == "mon":
|
|
55
|
+
return today - timedelta(days=today.weekday())
|
|
56
|
+
try:
|
|
57
|
+
days = int(value)
|
|
58
|
+
return today - timedelta(days=days)
|
|
59
|
+
except ValueError:
|
|
60
|
+
pass
|
|
61
|
+
return date.fromisoformat(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def filter_entries(
|
|
65
|
+
entries: list[Entry],
|
|
66
|
+
since: Optional[date] = None,
|
|
67
|
+
project: Optional[str] = None,
|
|
68
|
+
entry_type: Optional[EntryType] = None,
|
|
69
|
+
open_only: bool = False,
|
|
70
|
+
) -> list[Entry]:
|
|
71
|
+
result = []
|
|
72
|
+
for e in entries:
|
|
73
|
+
if since and date.fromisoformat(e.ts[:10]) < since:
|
|
74
|
+
continue
|
|
75
|
+
if project and e.project != project:
|
|
76
|
+
continue
|
|
77
|
+
if entry_type and e.type != entry_type:
|
|
78
|
+
continue
|
|
79
|
+
if open_only:
|
|
80
|
+
if e.type == EntryType.ACTION:
|
|
81
|
+
if e.status in (EntryStatus.DONE, EntryStatus.CANCELLED):
|
|
82
|
+
continue
|
|
83
|
+
elif e.type != EntryType.WAITING:
|
|
84
|
+
continue
|
|
85
|
+
result.append(e)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def mark_done(
|
|
90
|
+
entry_id: str,
|
|
91
|
+
status: EntryStatus = EntryStatus.DONE,
|
|
92
|
+
store: Optional[Path] = None,
|
|
93
|
+
) -> Optional[Entry]:
|
|
94
|
+
entries = load_entries(store)
|
|
95
|
+
target = next((e for e in entries if e.id == entry_id), None)
|
|
96
|
+
if target is None:
|
|
97
|
+
return None
|
|
98
|
+
target.status = status
|
|
99
|
+
target.updated_ts = Entry.now_ts()
|
|
100
|
+
append_entry(target, store)
|
|
101
|
+
return target
|
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from datetime import date, timedelta
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from minutes.add import parse_line, _parse_due
|
|
6
|
+
from minutes.models import EntryStatus, EntryType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestParseDue:
|
|
10
|
+
def test_integer_days(self):
|
|
11
|
+
today = date.today()
|
|
12
|
+
assert _parse_due("7") == (today + timedelta(days=7)).isoformat()
|
|
13
|
+
assert _parse_due("0") == today.isoformat()
|
|
14
|
+
|
|
15
|
+
def test_weekday_is_future(self):
|
|
16
|
+
result = _parse_due("fri")
|
|
17
|
+
d = date.fromisoformat(result)
|
|
18
|
+
assert d.weekday() == 4 # Friday
|
|
19
|
+
assert d >= date.today()
|
|
20
|
+
|
|
21
|
+
def test_all_weekday_abbreviations(self):
|
|
22
|
+
for abbr, wd in [("mon", 0), ("tue", 1), ("wed", 2), ("thu", 3),
|
|
23
|
+
("fri", 4), ("sat", 5), ("sun", 6)]:
|
|
24
|
+
result = _parse_due(abbr)
|
|
25
|
+
assert date.fromisoformat(result).weekday() == wd
|
|
26
|
+
|
|
27
|
+
def test_iso_date(self):
|
|
28
|
+
assert _parse_due("2026-12-31") == "2026-12-31"
|
|
29
|
+
|
|
30
|
+
def test_invalid_returns_none(self):
|
|
31
|
+
assert _parse_due("notadate") is None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestParseLine:
|
|
35
|
+
def test_note_no_prefix(self):
|
|
36
|
+
e = parse_line("just a note")
|
|
37
|
+
assert e is not None
|
|
38
|
+
assert e.type == EntryType.NOTE
|
|
39
|
+
assert e.text == "just a note"
|
|
40
|
+
|
|
41
|
+
def test_decision_prefix(self):
|
|
42
|
+
e = parse_line("* Drop v1 endpoints")
|
|
43
|
+
assert e is not None
|
|
44
|
+
assert e.type == EntryType.DECISION
|
|
45
|
+
assert e.text == "Drop v1 endpoints"
|
|
46
|
+
|
|
47
|
+
def test_action_prefix(self):
|
|
48
|
+
e = parse_line("! Write migration guide")
|
|
49
|
+
assert e is not None
|
|
50
|
+
assert e.type == EntryType.ACTION
|
|
51
|
+
assert e.text == "Write migration guide"
|
|
52
|
+
assert e.status == EntryStatus.OPEN
|
|
53
|
+
|
|
54
|
+
def test_action_with_date(self):
|
|
55
|
+
e = parse_line("! Write migration guide @7")
|
|
56
|
+
assert e is not None
|
|
57
|
+
assert e.type == EntryType.ACTION
|
|
58
|
+
assert e.due == (date.today() + timedelta(days=7)).isoformat()
|
|
59
|
+
assert "@" not in e.text
|
|
60
|
+
|
|
61
|
+
def test_action_with_iso_date(self):
|
|
62
|
+
e = parse_line("! Write migration guide @2026-06-01")
|
|
63
|
+
assert e is not None
|
|
64
|
+
assert e.due == "2026-06-01"
|
|
65
|
+
|
|
66
|
+
def test_waiting_prefix(self):
|
|
67
|
+
e = parse_line("> v2 spec approval")
|
|
68
|
+
assert e is not None
|
|
69
|
+
assert e.type == EntryType.WAITING
|
|
70
|
+
assert e.text == "v2 spec approval"
|
|
71
|
+
|
|
72
|
+
def test_waiting_with_person(self):
|
|
73
|
+
e = parse_line("> v2 spec @Marco")
|
|
74
|
+
assert e is not None
|
|
75
|
+
assert e.person == "Marco"
|
|
76
|
+
assert "@Marco" not in e.text
|
|
77
|
+
|
|
78
|
+
def test_waiting_person_stripped_from_text(self):
|
|
79
|
+
e = parse_line("> get approval @Anna")
|
|
80
|
+
assert e is not None
|
|
81
|
+
assert e.person == "Anna"
|
|
82
|
+
assert e.text == "get approval"
|
|
83
|
+
|
|
84
|
+
def test_empty_line_returns_none(self):
|
|
85
|
+
assert parse_line("") is None
|
|
86
|
+
assert parse_line(" ") is None
|
|
87
|
+
|
|
88
|
+
def test_meeting_is_attached(self):
|
|
89
|
+
e = parse_line("* A decision", meeting="team sync")
|
|
90
|
+
assert e is not None
|
|
91
|
+
assert e.meeting == "team sync"
|
|
92
|
+
|
|
93
|
+
def test_project_is_empty_placeholder(self):
|
|
94
|
+
e = parse_line("some note")
|
|
95
|
+
assert e is not None
|
|
96
|
+
assert e.project == ""
|
|
97
|
+
|
|
98
|
+
def test_id_is_four_chars(self):
|
|
99
|
+
e = parse_line("a note")
|
|
100
|
+
assert e is not None
|
|
101
|
+
assert len(e.id) == 7
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from minutes.models import Entry, EntryStatus, EntryType
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_entry_roundtrip():
|
|
5
|
+
e = Entry(
|
|
6
|
+
id="a1b2",
|
|
7
|
+
ts="2026-05-27T14:00",
|
|
8
|
+
project="api-migration",
|
|
9
|
+
type=EntryType.ACTION,
|
|
10
|
+
text="Write migration guide",
|
|
11
|
+
due="2026-05-30",
|
|
12
|
+
status=EntryStatus.OPEN,
|
|
13
|
+
)
|
|
14
|
+
assert Entry.from_dict(e.to_dict()) == e
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_to_dict_omits_none_fields():
|
|
18
|
+
e = Entry(id="x", ts="2026-05-27T09:00", project="p", type=EntryType.NOTE, text="hello")
|
|
19
|
+
d = e.to_dict()
|
|
20
|
+
assert "meeting" not in d
|
|
21
|
+
assert "due" not in d
|
|
22
|
+
assert "status" not in d
|
|
23
|
+
assert "person" not in d
|
|
24
|
+
assert "tags" not in d
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_to_dict_includes_optional_when_set():
|
|
28
|
+
e = Entry(
|
|
29
|
+
id="x", ts="2026-05-27T09:00", project="p",
|
|
30
|
+
type=EntryType.WAITING, text="approval",
|
|
31
|
+
person="Marco", meeting="team sync",
|
|
32
|
+
)
|
|
33
|
+
d = e.to_dict()
|
|
34
|
+
assert d["person"] == "Marco"
|
|
35
|
+
assert d["meeting"] == "team sync"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_make_id_length():
|
|
39
|
+
assert len(Entry.make_id()) == 7
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_make_id_unique():
|
|
43
|
+
ids = {Entry.make_id() for _ in range(100)}
|
|
44
|
+
assert len(ids) > 90 # allow tiny collision chance
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_entry_type_values():
|
|
48
|
+
assert EntryType.NOTE.value == "note"
|
|
49
|
+
assert EntryType.ACTION.value == "action"
|
|
50
|
+
assert EntryType.DECISION.value == "decision"
|
|
51
|
+
assert EntryType.WAITING.value == "waiting"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_status_roundtrip():
|
|
55
|
+
e = Entry(
|
|
56
|
+
id="z", ts="2026-05-27T10:00", project="p",
|
|
57
|
+
type=EntryType.ACTION, text="do thing",
|
|
58
|
+
status=EntryStatus.DONE,
|
|
59
|
+
)
|
|
60
|
+
d = e.to_dict()
|
|
61
|
+
assert d["status"] == "done"
|
|
62
|
+
assert Entry.from_dict(d).status == EntryStatus.DONE
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import tempfile
|
|
3
|
+
from datetime import date, timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from minutes.models import Entry, EntryStatus, EntryType
|
|
9
|
+
from minutes.store import (
|
|
10
|
+
append_entry,
|
|
11
|
+
filter_entries,
|
|
12
|
+
load_entries,
|
|
13
|
+
mark_done,
|
|
14
|
+
parse_since,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def store(tmp_path):
|
|
20
|
+
return tmp_path / "entries.jsonl"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _entry(project="proj", etype=EntryType.NOTE, text="hello", **kwargs) -> Entry:
|
|
24
|
+
return Entry(
|
|
25
|
+
id=Entry.make_id(),
|
|
26
|
+
ts=kwargs.pop("ts", "2026-05-27T10:00"),
|
|
27
|
+
project=project,
|
|
28
|
+
type=etype,
|
|
29
|
+
text=text,
|
|
30
|
+
**kwargs,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestAppendAndLoad:
|
|
35
|
+
def test_append_creates_file(self, store):
|
|
36
|
+
e = _entry()
|
|
37
|
+
append_entry(e, store)
|
|
38
|
+
assert store.exists()
|
|
39
|
+
|
|
40
|
+
def test_load_roundtrip(self, store):
|
|
41
|
+
e = _entry(text="test note")
|
|
42
|
+
append_entry(e, store)
|
|
43
|
+
loaded = load_entries(store)
|
|
44
|
+
assert len(loaded) == 1
|
|
45
|
+
assert loaded[0].text == "test note"
|
|
46
|
+
|
|
47
|
+
def test_load_last_write_wins(self, store):
|
|
48
|
+
e = _entry(etype=EntryType.ACTION, status=EntryStatus.OPEN)
|
|
49
|
+
append_entry(e, store)
|
|
50
|
+
e.status = EntryStatus.DONE
|
|
51
|
+
e.updated_ts = "2026-05-27T15:00"
|
|
52
|
+
append_entry(e, store)
|
|
53
|
+
loaded = load_entries(store)
|
|
54
|
+
assert len(loaded) == 1
|
|
55
|
+
assert loaded[0].status == EntryStatus.DONE
|
|
56
|
+
|
|
57
|
+
def test_load_empty_store(self, store):
|
|
58
|
+
assert load_entries(store) == []
|
|
59
|
+
|
|
60
|
+
def test_load_skips_malformed_lines(self, store):
|
|
61
|
+
store.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
store.write_text('not json\n{"id":"x","ts":"2026-05-27T10:00","project":"p","type":"note","text":"ok"}\n')
|
|
63
|
+
loaded = load_entries(store)
|
|
64
|
+
assert len(loaded) == 1
|
|
65
|
+
assert loaded[0].text == "ok"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestParseSince:
|
|
69
|
+
def test_mon_returns_monday(self):
|
|
70
|
+
d = parse_since("mon")
|
|
71
|
+
assert d.weekday() == 0
|
|
72
|
+
|
|
73
|
+
def test_integer_days_back(self):
|
|
74
|
+
today = date.today()
|
|
75
|
+
assert parse_since("7") == today - timedelta(days=7)
|
|
76
|
+
assert parse_since("0") == today
|
|
77
|
+
|
|
78
|
+
def test_iso_date(self):
|
|
79
|
+
assert parse_since("2026-01-15") == date(2026, 1, 15)
|
|
80
|
+
|
|
81
|
+
def test_invalid_raises(self):
|
|
82
|
+
with pytest.raises(ValueError):
|
|
83
|
+
parse_since("notadate")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestFilterEntries:
|
|
87
|
+
def _entries(self):
|
|
88
|
+
return [
|
|
89
|
+
_entry(project="alpha", etype=EntryType.NOTE, ts="2026-05-26T09:00"),
|
|
90
|
+
_entry(project="beta", etype=EntryType.ACTION, ts="2026-05-27T10:00",
|
|
91
|
+
status=EntryStatus.OPEN),
|
|
92
|
+
_entry(project="alpha", etype=EntryType.DECISION, ts="2026-05-27T11:00"),
|
|
93
|
+
_entry(project="beta", etype=EntryType.ACTION, ts="2026-05-27T12:00",
|
|
94
|
+
status=EntryStatus.DONE),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
def test_filter_by_project(self):
|
|
98
|
+
result = filter_entries(self._entries(), project="alpha")
|
|
99
|
+
assert all(e.project == "alpha" for e in result)
|
|
100
|
+
assert len(result) == 2
|
|
101
|
+
|
|
102
|
+
def test_filter_by_since(self):
|
|
103
|
+
result = filter_entries(self._entries(), since=date(2026, 5, 27))
|
|
104
|
+
assert all(e.ts >= "2026-05-27" for e in result)
|
|
105
|
+
assert len(result) == 3
|
|
106
|
+
|
|
107
|
+
def test_filter_by_type(self):
|
|
108
|
+
result = filter_entries(self._entries(), entry_type=EntryType.ACTION)
|
|
109
|
+
assert all(e.type == EntryType.ACTION for e in result)
|
|
110
|
+
assert len(result) == 2
|
|
111
|
+
|
|
112
|
+
def test_open_only_excludes_done_actions(self):
|
|
113
|
+
result = filter_entries(self._entries(), open_only=True)
|
|
114
|
+
assert all(e.status != EntryStatus.DONE for e in result if e.type == EntryType.ACTION)
|
|
115
|
+
|
|
116
|
+
def test_open_only_excludes_notes_and_decisions(self):
|
|
117
|
+
result = filter_entries(self._entries(), open_only=True)
|
|
118
|
+
assert all(e.type in (EntryType.ACTION, EntryType.WAITING) for e in result)
|
|
119
|
+
|
|
120
|
+
def test_no_filters_returns_all(self):
|
|
121
|
+
entries = self._entries()
|
|
122
|
+
assert filter_entries(entries) == entries
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestMarkDone:
|
|
126
|
+
def test_marks_existing_action_done(self, store):
|
|
127
|
+
e = _entry(etype=EntryType.ACTION, status=EntryStatus.OPEN)
|
|
128
|
+
append_entry(e, store)
|
|
129
|
+
result = mark_done(e.id, store=store)
|
|
130
|
+
assert result is not None
|
|
131
|
+
assert result.status == EntryStatus.DONE
|
|
132
|
+
loaded = load_entries(store)
|
|
133
|
+
assert loaded[0].status == EntryStatus.DONE
|
|
134
|
+
|
|
135
|
+
def test_returns_none_for_unknown_id(self, store):
|
|
136
|
+
assert mark_done("zzzz", store=store) is None
|
|
137
|
+
|
|
138
|
+
def test_sets_updated_ts(self, store):
|
|
139
|
+
e = _entry(etype=EntryType.ACTION, status=EntryStatus.OPEN)
|
|
140
|
+
append_entry(e, store)
|
|
141
|
+
result = mark_done(e.id, store=store)
|
|
142
|
+
assert result.updated_ts is not None
|