minutes-cli 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.
- minutes/__init__.py +0 -0
- minutes/add.py +198 -0
- minutes/cli.py +79 -0
- minutes/display.py +156 -0
- minutes/models.py +81 -0
- minutes/store.py +101 -0
- minutes_cli-0.1.0.dist-info/METADATA +80 -0
- minutes_cli-0.1.0.dist-info/RECORD +10 -0
- minutes_cli-0.1.0.dist-info/WHEEL +4 -0
- minutes_cli-0.1.0.dist-info/entry_points.txt +2 -0
minutes/__init__.py
ADDED
|
File without changes
|
minutes/add.py
ADDED
|
@@ -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].")
|
minutes/cli.py
ADDED
|
@@ -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()
|
minutes/display.py
ADDED
|
@@ -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
|
minutes/models.py
ADDED
|
@@ -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")
|
minutes/store.py
ADDED
|
@@ -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
|
|
@@ -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,10 @@
|
|
|
1
|
+
minutes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
minutes/add.py,sha256=I94jqOlquhzMFspxabgc6Vka0xaSX9_pHRBYi9JjF9g,5827
|
|
3
|
+
minutes/cli.py,sha256=8bKwDGOKpeCEmrGKKlTJW0CK5pNBUjE1ZJHirvCmCG8,2580
|
|
4
|
+
minutes/display.py,sha256=5cBsSQgJOgArcwIHaizY62fNuY0tsSMoK5CMFy8yaV0,4932
|
|
5
|
+
minutes/models.py,sha256=PKPV0t2CcG1DG0T2V5Q_GuVNxEE9BDnGm6j0zDUePFk,2066
|
|
6
|
+
minutes/store.py,sha256=GhMPKSLa09_LVC3efRdV3X89OjB_RG0Dp9JLdJTNarc,2872
|
|
7
|
+
minutes_cli-0.1.0.dist-info/METADATA,sha256=6raV0_Qzy_LeL_YUUlhBMyEy0EzkT5Ghni0ByhN062g,1926
|
|
8
|
+
minutes_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
minutes_cli-0.1.0.dist-info/entry_points.txt,sha256=LiEuklupxywa7IutS97I3nh5QvoCxjLmmIdXOpgWvdM,45
|
|
10
|
+
minutes_cli-0.1.0.dist-info/RECORD,,
|