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 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>&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].")
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ minutes = minutes.cli:main