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.
@@ -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>&gt;</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