klok-cli 1.0.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.
klok/config.py ADDED
@@ -0,0 +1,162 @@
1
+ """Configuration and on-disk locations.
2
+
3
+ Everything klok keeps lives under a single data directory so a whole
4
+ install can be backed up, synced or version-controlled by copying one
5
+ folder. ``KLOK_HOME`` overrides it, which is also what the test-suite
6
+ uses to stay away from real data.
7
+ """
8
+
9
+ import configparser
10
+ import os
11
+ from pathlib import Path
12
+
13
+ DEFAULTS = {
14
+ "general": {
15
+ # Project used by `klok start` when none is given.
16
+ "default_project": "",
17
+ # monday or sunday - drives :week and the week charts.
18
+ "week_start": "monday",
19
+ # Round durations to a multiple of this many minutes in reports (0 = off).
20
+ "round": "0",
21
+ # 24 or 12 hour clock in rendered output.
22
+ "time_format": "24",
23
+ # Stop a running frame automatically when a new one is started.
24
+ "autostop": "true",
25
+ # auto, always or never.
26
+ "color": "auto",
27
+ # Overrides $VISUAL / $EDITOR for `klok edit`.
28
+ "editor": "",
29
+ # Refuse to create a project that has never been seen before.
30
+ "confirm_new_project": "false",
31
+ },
32
+ "exclusions": {
33
+ # Hours considered "working hours"; `klok gaps` ignores anything outside.
34
+ "hours": "",
35
+ # Days that are never expected to hold tracked time.
36
+ "days": "",
37
+ # Comma separated YYYY-MM-DD dates treated like excluded days.
38
+ "holidays": "",
39
+ },
40
+ "mindful": {
41
+ # Practice is recorded here so it never lands in a client's report.
42
+ "project": "mindfulness",
43
+ "sheet": "wellbeing",
44
+ "track": "true",
45
+ # Default breathing pattern and length.
46
+ "pattern": "box",
47
+ "breath_rounds": "6",
48
+ # Default sit, and how often the interval bell rings during it.
49
+ "default_sit": "10m",
50
+ "interval_bell": "",
51
+ "warmup": "",
52
+ "guidance": "true",
53
+ # `klok status` suggests a pause once a stretch of work runs this long.
54
+ "break_after": "90m",
55
+ },
56
+ "focus": {
57
+ "work": "25m",
58
+ "break": "5m",
59
+ "long_break": "15m",
60
+ "rounds": "4",
61
+ "notify": "true",
62
+ "bell": "true",
63
+ # Record focus sessions as tracked frames.
64
+ "track": "true",
65
+ },
66
+ }
67
+
68
+ TRUTHY = {"1", "true", "yes", "on", "y"}
69
+
70
+
71
+ def data_home() -> Path:
72
+ """Directory holding frames, state, config and the undo stack."""
73
+ env = os.environ.get("KLOK_HOME")
74
+ if env:
75
+ return Path(env).expanduser()
76
+ xdg = os.environ.get("XDG_DATA_HOME")
77
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "share"
78
+ return base / "klok"
79
+
80
+
81
+ class Config:
82
+ def __init__(self, home: Path = None):
83
+ self.home = Path(home) if home else data_home()
84
+ self.path = self.home / "config.ini"
85
+ self._parser = configparser.ConfigParser()
86
+ self._parser.read_dict(DEFAULTS)
87
+ if self.path.exists():
88
+ self._parser.read(self.path, encoding="utf-8")
89
+
90
+ # -- paths ---------------------------------------------------------
91
+ @property
92
+ def frames_path(self) -> Path:
93
+ return self.home / "frames.jsonl"
94
+
95
+ @property
96
+ def state_path(self) -> Path:
97
+ return self.home / "state.json"
98
+
99
+ @property
100
+ def undo_path(self) -> Path:
101
+ return self.home / "undo.jsonl"
102
+
103
+ @property
104
+ def checkins_path(self) -> Path:
105
+ return self.home / "checkins.jsonl"
106
+
107
+ def ensure_home(self) -> None:
108
+ self.home.mkdir(parents=True, exist_ok=True)
109
+
110
+ # -- values --------------------------------------------------------
111
+ def get(self, key: str, fallback=None):
112
+ section, _, option = key.partition(".")
113
+ if not option:
114
+ raise KeyError("config keys look like 'section.option', got %r" % key)
115
+ try:
116
+ return self._parser.get(section, option)
117
+ except (configparser.NoSectionError, configparser.NoOptionError):
118
+ return fallback
119
+
120
+ def get_bool(self, key: str, fallback: bool = False) -> bool:
121
+ raw = self.get(key)
122
+ if raw is None or raw == "":
123
+ return fallback
124
+ return str(raw).strip().lower() in TRUTHY
125
+
126
+ def get_int(self, key: str, fallback: int = 0) -> int:
127
+ raw = self.get(key)
128
+ try:
129
+ return int(str(raw).strip())
130
+ except (TypeError, ValueError):
131
+ return fallback
132
+
133
+ def get_list(self, key: str):
134
+ raw = self.get(key) or ""
135
+ return [part.strip() for part in raw.split(",") if part.strip()]
136
+
137
+ def set(self, key: str, value: str) -> None:
138
+ section, _, option = key.partition(".")
139
+ if not option:
140
+ raise KeyError("config keys look like 'section.option', got %r" % key)
141
+ if not self._parser.has_section(section):
142
+ self._parser.add_section(section)
143
+ self._parser.set(section, option, value)
144
+
145
+ def unset(self, key: str) -> bool:
146
+ section, _, option = key.partition(".")
147
+ try:
148
+ return self._parser.remove_option(section, option)
149
+ except configparser.NoSectionError:
150
+ return False
151
+
152
+ def items(self):
153
+ for section in sorted(self._parser.sections()):
154
+ for option, value in sorted(self._parser.items(section)):
155
+ yield "%s.%s" % (section, option), value
156
+
157
+ def save(self) -> None:
158
+ self.ensure_home()
159
+ tmp = self.path.with_suffix(".ini.tmp")
160
+ with tmp.open("w", encoding="utf-8") as handle:
161
+ self._parser.write(handle)
162
+ os.replace(tmp, self.path)
klok/focus.py ADDED
@@ -0,0 +1,169 @@
1
+ """Countdown timers, pomodoro rounds and desktop notifications.
2
+
3
+ This is the part that covers the "minimal timer" tools - a visible
4
+ countdown, a big clock, a nudge when the time is up - and it feeds the
5
+ same frame store as everything else so focus sessions show up in reports.
6
+ """
7
+
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from datetime import timedelta
13
+
14
+ from klok.timeparse import format_duration
15
+ from klok.utils import bar
16
+
17
+ BIG_DIGITS = {
18
+ "0": ["█▀▀█", "█ █", "█ █", "█ █", "█▄▄█"],
19
+ "1": [" ▀█", " █", " █", " █", " █"],
20
+ "2": ["█▀▀█", " █", " ▄▀ ", "▄▀ ", "█▄▄▄"],
21
+ "3": ["█▀▀█", " █", " ▀▀▄", " █", "█▄▄█"],
22
+ "4": ["█ █", "█ █", "█▄▄█", " █", " █"],
23
+ "5": ["█▀▀▀", "█▄▄ ", " █", " █", "▀▄▄▀"],
24
+ "6": ["█▀▀▀", "█ ", "█▀▀█", "█ █", "█▄▄█"],
25
+ "7": ["▀▀▀█", " █", " ▄▀", " ▄▀ ", "▄▀ "],
26
+ "8": ["█▀▀█", "█ █", "█▄▄█", "█ █", "█▄▄█"],
27
+ "9": ["█▀▀█", "█ █", "▀▀▀█", " █", "▄▄▄█"],
28
+ ":": [" ", " ██ ", " ", " ██ ", " "],
29
+ " ": [" ", " ", " ", " ", " "],
30
+ }
31
+
32
+ ART = {
33
+ "coffee": [
34
+ " ( ) ( ) )",
35
+ " ) ( ) ( (",
36
+ " ( ) ( ) )",
37
+ " _____________",
38
+ " <_____________> ___",
39
+ " | |/ _ \\",
40
+ " | | | |",
41
+ " | |_| |",
42
+ " ___| |\\___/",
43
+ " / \\___________/ \\",
44
+ " \\_____________________/",
45
+ ],
46
+ "tree": [
47
+ " &&& && & &&",
48
+ " && &\\/&\\|& ()|/ @, &&",
49
+ " &\\/(/&/&||/& /_/)_&/_&",
50
+ " &() &\\/&|()|/&\\/ '%\" & ()",
51
+ " &_\\_&&_\\ |& |&&/&__%_/_& &&",
52
+ "&& && & &| &| /& & % ()& /&&",
53
+ " ||| |||",
54
+ ],
55
+ "cat": [
56
+ " /\\_/\\",
57
+ " ( o.o )",
58
+ " > ^ <",
59
+ ],
60
+ "none": [],
61
+ }
62
+
63
+
64
+ def notify(title: str, message: str, enabled: bool = True, bell: bool = True) -> None:
65
+ """Best-effort desktop notification; never fails the command."""
66
+ if bell:
67
+ try:
68
+ sys.stdout.write("\a")
69
+ sys.stdout.flush()
70
+ except Exception:
71
+ pass
72
+ if not enabled:
73
+ return
74
+ commands = []
75
+ if shutil.which("notify-send"):
76
+ commands.append(["notify-send", title, message])
77
+ if shutil.which("terminal-notifier"):
78
+ commands.append(["terminal-notifier", "-title", title, "-message", message])
79
+ if sys.platform == "darwin" and shutil.which("osascript"):
80
+ script = 'display notification "%s" with title "%s"' % (
81
+ message.replace('"', "'"), title.replace('"', "'"))
82
+ commands.append(["osascript", "-e", script])
83
+ if sys.platform.startswith("win") and shutil.which("powershell"):
84
+ commands.append(["powershell", "-NoProfile", "-Command",
85
+ "[console]::beep(880,300); Write-Host '%s: %s'" % (title, message)])
86
+ for command in commands:
87
+ try:
88
+ subprocess.run(command, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
89
+ return
90
+ except OSError:
91
+ continue
92
+
93
+
94
+ def big_clock(text: str) -> str:
95
+ """Render ``MM:SS`` in block characters, arttime style."""
96
+ rows = ["" for _ in range(5)]
97
+ for char in text:
98
+ glyph = BIG_DIGITS.get(char, BIG_DIGITS[" "])
99
+ for index in range(5):
100
+ rows[index] += glyph[index] + " "
101
+ return "\n".join(row.rstrip() for row in rows)
102
+
103
+
104
+ def _clock_text(remaining: timedelta) -> str:
105
+ total = max(0, int(remaining.total_seconds()))
106
+ hours, rest = divmod(total, 3600)
107
+ minutes, seconds = divmod(rest, 60)
108
+ if hours:
109
+ return "%d:%02d:%02d" % (hours, minutes, seconds)
110
+ return "%02d:%02d" % (minutes, seconds)
111
+
112
+
113
+ def countdown(duration: timedelta, label: str, term, art: str = None,
114
+ big: bool = False, quiet: bool = False, notify_on_end: bool = True,
115
+ bell: bool = True, stream=None) -> bool:
116
+ """Run a countdown. Returns False when the user interrupts it."""
117
+ stream = stream or sys.stdout
118
+ total_seconds = max(1, int(duration.total_seconds()))
119
+ started = time.monotonic()
120
+ art_lines = ART.get(art or "", []) if art else []
121
+ interactive = bool(getattr(stream, "isatty", lambda: False)()) and not quiet
122
+ drawn_lines = 0
123
+
124
+ try:
125
+ while True:
126
+ elapsed = time.monotonic() - started
127
+ remaining = timedelta(seconds=max(0, total_seconds - int(elapsed)))
128
+ if interactive:
129
+ block = []
130
+ if art_lines:
131
+ block.extend(art_lines)
132
+ block.append("")
133
+ if big:
134
+ block.extend(big_clock(_clock_text(remaining)).splitlines())
135
+ block.append("")
136
+ width = min(40, max(20, term.width() - 30))
137
+ block.append("%s %s %s" % (
138
+ term.paint(label, "bold"),
139
+ term.paint(bar(total_seconds - remaining.total_seconds(), total_seconds, width), "cyan"),
140
+ term.paint(_clock_text(remaining), "yellow")))
141
+ if drawn_lines:
142
+ stream.write("\033[%dA" % drawn_lines)
143
+ for line in block:
144
+ stream.write("\033[2K" + line + "\n")
145
+ drawn_lines = len(block)
146
+ stream.flush()
147
+ if remaining.total_seconds() <= 0:
148
+ break
149
+ time.sleep(min(1.0, max(0.05, total_seconds - elapsed)))
150
+ except KeyboardInterrupt:
151
+ if interactive:
152
+ stream.write("\n")
153
+ return False
154
+
155
+ if not interactive and not quiet:
156
+ stream.write("%s finished (%s)\n" % (label, format_duration(duration)))
157
+ notify("klok", "%s finished (%s)" % (label, format_duration(duration)),
158
+ enabled=notify_on_end, bell=bell)
159
+ return True
160
+
161
+
162
+ def pomodoro_plan(rounds: int, work: timedelta, short_break: timedelta, long_break: timedelta):
163
+ """Yield ``(kind, label, duration)`` for a full pomodoro cycle."""
164
+ for index in range(1, rounds + 1):
165
+ yield ("work", "Focus %d/%d" % (index, rounds), work)
166
+ if index == rounds:
167
+ yield ("long_break", "Long break", long_break)
168
+ else:
169
+ yield ("break", "Break %d" % index, short_break)
klok/formats.py ADDED
@@ -0,0 +1,169 @@
1
+ """Machine-readable output: JSON, CSV, iCalendar, ledger timeclock, Markdown.
2
+
3
+ These are what let klok hand its data to a spreadsheet, an invoicing
4
+ script or ``hledger`` without anyone writing a parser.
5
+ """
6
+
7
+ import csv
8
+ import io
9
+ import json
10
+ from datetime import timedelta
11
+
12
+ from klok.model import Frame
13
+ from klok.timeparse import format_duration, to_iso
14
+
15
+ FORMATS = ("text", "json", "csv", "ical", "ledger", "timeclock", "md", "tsv")
16
+
17
+
18
+ def _rows(frames, now):
19
+ for frame in frames:
20
+ end = frame.end_or(now)
21
+ yield {
22
+ "id": frame.id,
23
+ "sheet": frame.sheet,
24
+ "project": frame.project,
25
+ "tags": frame.tags,
26
+ "note": frame.note,
27
+ "start": to_iso(frame.start),
28
+ "stop": to_iso(frame.stop) if frame.stop else "",
29
+ "running": frame.running,
30
+ "seconds": int((end - frame.start).total_seconds()),
31
+ "hours": round((end - frame.start).total_seconds() / 3600.0, 4),
32
+ }
33
+
34
+
35
+ def to_json(frames, now, indent: int = 2) -> str:
36
+ return json.dumps(list(_rows(frames, now)), indent=indent, ensure_ascii=False)
37
+
38
+
39
+ def to_csv(frames, now, delimiter: str = ",") -> str:
40
+ buffer = io.StringIO()
41
+ fields = ["id", "sheet", "project", "tags", "note", "start", "stop", "seconds", "hours"]
42
+ writer = csv.DictWriter(buffer, fieldnames=fields, delimiter=delimiter, lineterminator="\n")
43
+ writer.writeheader()
44
+ for row in _rows(frames, now):
45
+ row = dict(row)
46
+ row["tags"] = " ".join(row["tags"])
47
+ row.pop("running", None)
48
+ writer.writerow(row)
49
+ return buffer.getvalue().rstrip("\n")
50
+
51
+
52
+ def to_ical(frames, now) -> str:
53
+ def stamp(value):
54
+ return value.astimezone().strftime("%Y%m%dT%H%M%S")
55
+
56
+ lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//klok//EN", "CALSCALE:GREGORIAN"]
57
+ for frame in frames:
58
+ summary = frame.project or "(no project)"
59
+ if frame.tags:
60
+ summary += " " + " ".join("+" + tag for tag in frame.tags)
61
+ lines += [
62
+ "BEGIN:VEVENT",
63
+ "UID:%s@klok" % frame.id,
64
+ "DTSTART:%s" % stamp(frame.start),
65
+ "DTEND:%s" % stamp(frame.end_or(now)),
66
+ "SUMMARY:%s" % _escape_ical(summary),
67
+ ]
68
+ if frame.note:
69
+ lines.append("DESCRIPTION:%s" % _escape_ical(frame.note))
70
+ lines.append("END:VEVENT")
71
+ lines.append("END:VCALENDAR")
72
+ return "\n".join(lines)
73
+
74
+
75
+ def _escape_ical(text: str) -> str:
76
+ return text.replace("\\", "\\\\").replace(";", "\;").replace(",", "\\,").replace("\n", "\\n")
77
+
78
+
79
+ def to_timeclock(frames, now) -> str:
80
+ """The ``i``/``o`` timeclock format ledger and hledger read natively."""
81
+ lines = []
82
+ for frame in frames:
83
+ account = frame.project or "unsorted"
84
+ if frame.tags:
85
+ account += ":" + ":".join(frame.tags)
86
+ lines.append("i %s %s%s" % (frame.start.strftime("%Y/%m/%d %H:%M:%S"), account,
87
+ (" " + frame.note) if frame.note else ""))
88
+ lines.append("o %s" % frame.end_or(now).strftime("%Y/%m/%d %H:%M:%S"))
89
+ return "\n".join(lines)
90
+
91
+
92
+ def to_ledger(frames, now) -> str:
93
+ """Postings with hour amounts, one transaction per entry."""
94
+ lines = []
95
+ for frame in frames:
96
+ hours = (frame.end_or(now) - frame.start).total_seconds() / 3600.0
97
+ payee = frame.note or frame.project or "time"
98
+ lines.append("%s * %s" % (frame.start.strftime("%Y/%m/%d"), payee))
99
+ account = (frame.project or "unsorted").replace(".", ":")
100
+ lines.append(" %s %.2fh" % (account, hours))
101
+ lines.append(" (%s)" % frame.sheet)
102
+ lines.append("")
103
+ return "\n".join(lines).rstrip()
104
+
105
+
106
+ def to_markdown(frames, now) -> str:
107
+ lines = ["| Date | Start | End | Project | Tags | Note | Duration |",
108
+ "| --- | --- | --- | --- | --- | --- | --- |"]
109
+ total = timedelta(0)
110
+ for frame in frames:
111
+ length = frame.end_or(now) - frame.start
112
+ total += length
113
+ lines.append("| %s | %s | %s | %s | %s | %s | %s |" % (
114
+ frame.start.strftime("%Y-%m-%d"),
115
+ frame.start.strftime("%H:%M"),
116
+ frame.stop.strftime("%H:%M") if frame.stop else "running",
117
+ frame.project or "",
118
+ " ".join("+" + tag for tag in frame.tags),
119
+ frame.note.replace("|", "\\|"),
120
+ format_duration(length),
121
+ ))
122
+ lines.append("")
123
+ lines.append("**Total: %s**" % format_duration(total))
124
+ return "\n".join(lines)
125
+
126
+
127
+ def render(frames, now, fmt: str) -> str:
128
+ fmt = (fmt or "json").lower()
129
+ if fmt == "json":
130
+ return to_json(frames, now)
131
+ if fmt == "csv":
132
+ return to_csv(frames, now)
133
+ if fmt == "tsv":
134
+ return to_csv(frames, now, delimiter="\t")
135
+ if fmt == "ical":
136
+ return to_ical(frames, now)
137
+ if fmt == "timeclock":
138
+ return to_timeclock(frames, now)
139
+ if fmt == "ledger":
140
+ return to_ledger(frames, now)
141
+ if fmt in ("md", "markdown"):
142
+ return to_markdown(frames, now)
143
+ raise ValueError("unknown format %r (pick one of %s)" % (fmt, ", ".join(FORMATS)))
144
+
145
+
146
+ def parse_import(text: str):
147
+ """Read frames from JSON, JSONL or CSV produced by klok or a sibling tool."""
148
+ text = text.strip()
149
+ if not text:
150
+ return []
151
+ if text.startswith("["):
152
+ return [Frame.from_dict(item) for item in json.loads(text)]
153
+ if text.startswith("{"):
154
+ return [Frame.from_dict(json.loads(line)) for line in text.splitlines() if line.strip()]
155
+ reader = csv.DictReader(io.StringIO(text))
156
+ frames = []
157
+ for row in reader:
158
+ if not row.get("start"):
159
+ continue
160
+ frames.append(Frame.from_dict({
161
+ "id": row.get("id"),
162
+ "start": row["start"],
163
+ "stop": row.get("stop") or row.get("end") or None,
164
+ "project": row.get("project", ""),
165
+ "tags": row.get("tags", ""),
166
+ "note": row.get("note", ""),
167
+ "sheet": row.get("sheet") or "default",
168
+ }))
169
+ return frames