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/cli.py ADDED
@@ -0,0 +1,1816 @@
1
+ """Command line entry point."""
2
+
3
+ import argparse
4
+ import difflib
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from datetime import datetime, timedelta
12
+
13
+ from klok import __version__
14
+ from klok.config import Config
15
+ from klok.focus import ART, countdown, notify, pomodoro_plan
16
+ from klok.formats import FORMATS, parse_import, render
17
+ from klok.mindful import (BREATHING_TAG, MEDITATION_TAG, PATTERNS, Checkin,
18
+ CheckinStore, MindfulError, breath_plan, breathe,
19
+ break_due, check_score, cycle_length, daily_averages,
20
+ describe_pattern, longest_streak, meditate,
21
+ parse_pattern, pattern_phases, rounds_for, sit_plan,
22
+ sparkline, streak)
23
+ from klok.model import Frame, normalise_tags
24
+ from klok.report import (Renderer, by_day, by_project, by_sheet, by_tag,
25
+ render_day_chart, render_gaps, render_log,
26
+ render_punchcard, render_report, render_summary,
27
+ render_totals)
28
+ from klok.storage import Store, StoreError
29
+ from klok.timeparse import (TimeParseError, format_duration, humanize_ago,
30
+ now_local, parse_datetime, parse_datetime_ex,
31
+ parse_duration,
32
+ parse_hours_window, parse_range)
33
+ from klok.utils import Term, die, render_table
34
+
35
+ ARCHIVE_PREFIX = "_"
36
+ # Options whose value is a time or duration and may therefore start with "-".
37
+ TIME_OPTIONS = {"-a", "--at", "--from", "--to", "--start", "--stop", "--now",
38
+ "--min", "--work", "--break", "--long-break", "--round",
39
+ "--for", "--interval-bell", "--warmup"}
40
+
41
+
42
+ class Context:
43
+ """Everything a command needs: config, store, clock and renderer."""
44
+
45
+ def __init__(self, args):
46
+ self.config = Config(args.home)
47
+ self.store = Store(self.config)
48
+ self.now = parse_datetime(args.now) if args.now else now_local()
49
+ color_mode = "never" if args.no_color else (args.color or self.config.get("general.color", "auto"))
50
+ self.term = Term(color_mode)
51
+ self.args = args
52
+ round_minutes = args.round if args.round is not None else self.config.get_int("general.round", 0)
53
+ self.round_minutes = round_minutes
54
+ self.week_start = self.config.get("general.week_start", "monday")
55
+ self.time_format = self.config.get("general.time_format", "24")
56
+ self.renderer = Renderer(self.term, self.now, self.time_format, round_minutes,
57
+ self.week_start, getattr(args, "duration_style", "short") or "short")
58
+
59
+ # -- sheet resolution ---------------------------------------------
60
+ def sheet_filter(self):
61
+ """``None`` means "every sheet"; otherwise the sheet to restrict to."""
62
+ if getattr(self.args, "all_sheets", False):
63
+ return None
64
+ return getattr(self.args, "sheet", None) or self.store.current_sheet
65
+
66
+ def target_sheet(self) -> str:
67
+ return getattr(self.args, "sheet", None) or self.store.current_sheet
68
+
69
+ def echo(self, *parts) -> None:
70
+ print(*parts)
71
+
72
+ def range_of(self, default: str):
73
+ tokens = getattr(self.args, "range_tokens", None) or []
74
+ if getattr(self.args, "from_time", None) or getattr(self.args, "to_time", None):
75
+ start = (parse_datetime(self.args.from_time, self.now) if self.args.from_time
76
+ else self.now.replace(hour=0, minute=0, second=0, microsecond=0))
77
+ if not self.args.to_time:
78
+ return start, self.now + timedelta(seconds=1)
79
+ end, granularity = parse_datetime_ex(self.args.to_time, self.now)
80
+ # A bare date as the end of a range means "up to and including".
81
+ return start, end + timedelta(days=1) if granularity == "day" else end
82
+ return parse_range(tokens, self.now, self.week_start, default=default)
83
+
84
+ def selection(self, default_range: str):
85
+ start, end = self.range_of(default_range)
86
+ frames = self.store.select(
87
+ start, end,
88
+ sheet=self.sheet_filter(),
89
+ projects=getattr(self.args, "project", None),
90
+ tags=getattr(self.args, "tag", None),
91
+ exclude_tags=getattr(self.args, "exclude_tag", None),
92
+ contains=getattr(self.args, "contains", None),
93
+ now=self.now,
94
+ )
95
+ return frames, start, end
96
+
97
+
98
+ # ------------------------------------------------------------------ helpers
99
+ def split_project_tags(tokens):
100
+ """``acme +api +docs`` -> ``("acme", ["api", "docs"])``."""
101
+ project, tags = "", []
102
+ for token in tokens or []:
103
+ token = str(token)
104
+ if token.startswith("+"):
105
+ tags.append(token[1:])
106
+ elif not project:
107
+ project = token
108
+ else:
109
+ tags.append(token)
110
+ return project.strip(), normalise_tags(tags)
111
+
112
+
113
+ def resolve_time(ctx: Context, value, fallback=None):
114
+ if value in (None, ""):
115
+ return fallback if fallback is not None else ctx.now
116
+ return parse_datetime(value, ctx.now)
117
+
118
+
119
+ def describe(ctx: Context, frame: Frame, prefix: str = "") -> str:
120
+ colour = ctx.term.project_color(frame.project)
121
+ label = ctx.term.paint(frame.project or "(no project)", colour, "bold")
122
+ if frame.tags:
123
+ label += " " + ctx.term.paint(" ".join("+" + tag for tag in frame.tags), "blue")
124
+ return "%s%s" % (prefix, label)
125
+
126
+
127
+ def emit(ctx: Context, frames, text_builder, default_range: str = None):
128
+ """Print either a machine format or the human rendering."""
129
+ fmt = getattr(ctx.args, "format", None) or "text"
130
+ if fmt != "text":
131
+ print(render(frames, ctx.now, fmt))
132
+ return 0
133
+ print(text_builder())
134
+ return 0
135
+
136
+
137
+ def check_project(ctx: Context, project: str) -> None:
138
+ if not project or not ctx.config.get_bool("general.confirm_new_project"):
139
+ return
140
+ if project not in ctx.store.projects(None) and not getattr(ctx.args, "force", False):
141
+ raise StoreError("unknown project %r (use --force, or unset general.confirm_new_project)" % project)
142
+
143
+
144
+ def stop_running(ctx: Context, when: datetime, note: str = "", sheet=None, clamp: bool = False):
145
+ """Stop whatever is running, returning the frames that were closed.
146
+
147
+ ``clamp`` is for the implicit stops that ``start``/``switch``/``restart``
148
+ perform: an entry that began later than the new one simply collapses to
149
+ zero rather than failing the whole command.
150
+ """
151
+ stopped = []
152
+ for frame in list(ctx.store.frames):
153
+ if not frame.running:
154
+ continue
155
+ if sheet is not None and frame.sheet != sheet:
156
+ continue
157
+ moment = when
158
+ if moment < frame.start:
159
+ if not clamp:
160
+ raise StoreError("cannot stop %s before it started (%s)"
161
+ % (frame.id, frame.start.isoformat()))
162
+ moment = frame.start
163
+ frame.stop = moment
164
+ if note:
165
+ frame.note = (frame.note + " " + note).strip() if frame.note else note
166
+ frame.touch(ctx.now)
167
+ stopped.append(frame)
168
+ return stopped
169
+
170
+
171
+ # ----------------------------------------------------------------- tracking
172
+ def cmd_start(ctx: Context) -> int:
173
+ args = ctx.args
174
+ project, tags = split_project_tags(args.words)
175
+ project = project or ctx.config.get("general.default_project", "")
176
+ check_project(ctx, project)
177
+ at = resolve_time(ctx, args.at)
178
+ sheet = ctx.target_sheet()
179
+
180
+ autostop = ctx.config.get_bool("general.autostop", True) and not args.no_stop
181
+ stopped = []
182
+ running = ctx.store.running(None)
183
+ if running and not autostop:
184
+ raise StoreError("%s is already running; stop it first or allow general.autostop" % running.id)
185
+ if running:
186
+ stopped = stop_running(ctx, at, sheet=None, clamp=True)
187
+
188
+ frame = Frame(start=at, project=project, tags=tags, note=args.note or "", sheet=sheet)
189
+ ctx.store.add(frame)
190
+ ctx.store.save("start %s" % (project or frame.id))
191
+ for frame_stopped in stopped:
192
+ ctx.echo("Stopped %s (%s)" % (describe(ctx, frame_stopped),
193
+ format_duration(frame_stopped.duration(ctx.now))))
194
+ ctx.echo("Starting %s at %s [%s]" % (describe(ctx, frame),
195
+ at.strftime("%Y-%m-%d %H:%M"),
196
+ ctx.term.paint(frame.id, "grey")))
197
+ return 0
198
+
199
+
200
+ def cmd_stop(ctx: Context) -> int:
201
+ args = ctx.args
202
+ at = resolve_time(ctx, args.at)
203
+ sheet = None if args.all else ctx.sheet_filter()
204
+ stopped = stop_running(ctx, at, note=args.note or "", sheet=sheet)
205
+ if not stopped:
206
+ ctx.echo("Nothing is running.")
207
+ return 1
208
+ ctx.store.save("stop")
209
+ for frame in stopped:
210
+ ctx.echo("Stopped %s after %s [%s]" % (describe(ctx, frame),
211
+ format_duration(frame.duration(ctx.now)),
212
+ ctx.term.paint(frame.id, "grey")))
213
+ return 0
214
+
215
+
216
+ def cmd_cancel(ctx: Context) -> int:
217
+ frame = ctx.store.running(ctx.sheet_filter()) or ctx.store.running(None)
218
+ if not frame:
219
+ ctx.echo("Nothing is running.")
220
+ return 1
221
+ ctx.store.remove(frame)
222
+ ctx.store.save("cancel")
223
+ ctx.echo("Cancelled %s (%s discarded)" % (describe(ctx, frame),
224
+ format_duration(frame.duration(ctx.now))))
225
+ return 0
226
+
227
+
228
+ def cmd_status(ctx: Context) -> int:
229
+ frame = ctx.store.running(ctx.sheet_filter()) or ctx.store.running(None)
230
+ if getattr(ctx.args, "format", "text") == "json":
231
+ payload = {"running": bool(frame)}
232
+ if frame:
233
+ payload.update(frame.to_dict())
234
+ payload["elapsed_seconds"] = int(frame.duration(ctx.now).total_seconds())
235
+ print(json.dumps(payload, indent=2))
236
+ return 0 if frame else 1
237
+ if not frame:
238
+ last = ctx.store.last(ctx.sheet_filter())
239
+ if last and not ctx.args.quiet:
240
+ ctx.echo("Nothing running. Last: %s ended %s." %
241
+ (describe(ctx, last), humanize_ago(ctx.now - last.end_or(ctx.now))))
242
+ elif not ctx.args.quiet:
243
+ if ctx.store.frames:
244
+ ctx.echo("Nothing running.")
245
+ else:
246
+ ctx.echo("Nothing tracked yet.")
247
+ ctx.echo(ctx.term.paint(
248
+ " Try `klok start acme +api` to begin, or `klok --help` for the full picture.",
249
+ "dim"))
250
+ return 1
251
+ elapsed = frame.duration(ctx.now)
252
+ today_start = ctx.now.replace(hour=0, minute=0, second=0, microsecond=0)
253
+ today = ctx.store.select(today_start, today_start + timedelta(days=1),
254
+ sheet=frame.sheet, now=ctx.now)
255
+ today_total = sum((f.duration(ctx.now) for f in today), timedelta(0))
256
+ ctx.echo("%s %s (started %s, %s)" % (
257
+ describe(ctx, frame),
258
+ ctx.term.paint(format_duration(elapsed), "yellow", "bold"),
259
+ frame.start.strftime("%H:%M"),
260
+ humanize_ago(ctx.now - frame.start)))
261
+ if frame.note:
262
+ ctx.echo(" note: %s" % frame.note)
263
+ ctx.echo(" sheet %s | today %s | id %s" % (
264
+ frame.sheet, format_duration(today_total), ctx.term.paint(frame.id, "grey")))
265
+ threshold = ctx.config.get("mindful.break_after", "")
266
+ if threshold and break_due(frame, parse_duration(threshold), ctx.now):
267
+ ctx.echo(ctx.term.paint(
268
+ " You have been at this %s straight - `klok breathe` takes about a minute."
269
+ % format_duration(elapsed), "dim"))
270
+ return 0
271
+
272
+
273
+ def cmd_restart(ctx: Context) -> int:
274
+ args = ctx.args
275
+ reference = args.ref or "@"
276
+ sheet = ctx.sheet_filter()
277
+ pool = [frame for frame in ctx.store.frames if sheet is None or frame.sheet == sheet]
278
+ if not pool:
279
+ raise StoreError("nothing to restart yet")
280
+ frame = ctx.store.by_id(reference, sheet) if args.ref else (
281
+ ctx.store.running(sheet) or pool[-1])
282
+ at = resolve_time(ctx, args.at)
283
+ if frame.running:
284
+ ctx.echo("%s is already running." % describe(ctx, frame))
285
+ return 0
286
+ stopped = stop_running(ctx, at, sheet=None, clamp=True)
287
+ fresh = Frame(start=at, project=frame.project, tags=list(frame.tags),
288
+ note=frame.note if args.keep_note else "", sheet=frame.sheet)
289
+ ctx.store.add(fresh)
290
+ ctx.store.save("restart %s" % (frame.project or frame.id))
291
+ for item in stopped:
292
+ ctx.echo("Stopped %s (%s)" % (describe(ctx, item), format_duration(item.duration(ctx.now))))
293
+ ctx.echo("Restarted %s at %s [%s]" % (describe(ctx, fresh), at.strftime("%H:%M"),
294
+ ctx.term.paint(fresh.id, "grey")))
295
+ return 0
296
+
297
+
298
+ def cmd_track(ctx: Context) -> int:
299
+ """Record a finished interval after the fact."""
300
+ args = ctx.args
301
+ tokens = list(args.words)
302
+ start_token, end_token = args.from_time, args.to_time
303
+ if not start_token and tokens:
304
+ # Support: klok track 09:00 to 11:30 acme +api
305
+ lowered = [token.lower() for token in tokens]
306
+ if "to" in lowered:
307
+ index = lowered.index("to")
308
+ start_token = " ".join(tokens[:index])
309
+ rest = tokens[index + 1:]
310
+ end_token = rest[0] if rest else None
311
+ tokens = rest[1:]
312
+ elif "-" in lowered and lowered.index("-") + 1 < len(tokens):
313
+ index = lowered.index("-")
314
+ start_token, end_token = " ".join(tokens[:index]), tokens[index + 1]
315
+ tokens = tokens[index + 2:]
316
+ if not start_token:
317
+ raise StoreError("track needs a time range, e.g. `klok track 09:00 to 11:30 acme +api`")
318
+
319
+ start = parse_datetime(start_token, ctx.now)
320
+ if end_token:
321
+ end = parse_datetime(end_token, ctx.now)
322
+ elif args.duration:
323
+ end = start + parse_duration(args.duration)
324
+ else:
325
+ end = ctx.now
326
+ if end < start:
327
+ end += timedelta(days=1)
328
+ project, tags = split_project_tags(tokens)
329
+ project = project or ctx.config.get("general.default_project", "")
330
+ check_project(ctx, project)
331
+ frame = Frame(start=start, stop=end, project=project, tags=tags,
332
+ note=args.note or "", sheet=ctx.target_sheet())
333
+ ctx.store.add(frame)
334
+ ctx.store.save("track %s" % (project or frame.id))
335
+ ctx.echo("Tracked %s from %s to %s (%s) [%s]" % (
336
+ describe(ctx, frame), start.strftime("%Y-%m-%d %H:%M"), end.strftime("%H:%M"),
337
+ format_duration(frame.duration(ctx.now)), ctx.term.paint(frame.id, "grey")))
338
+ return 0
339
+
340
+
341
+ def cmd_add(ctx: Context) -> int:
342
+ """utt-style: close the open stretch of time with a named activity."""
343
+ args = ctx.args
344
+ project, tags = split_project_tags(args.words)
345
+ if not project and not tags:
346
+ raise StoreError("add needs an activity name, e.g. `klok add acme +api`")
347
+ end = resolve_time(ctx, args.at)
348
+ sheet = ctx.target_sheet()
349
+ running = ctx.store.running(None)
350
+ if running:
351
+ start = running.start
352
+ ctx.store.remove(running)
353
+ else:
354
+ previous = [frame for frame in ctx.store.frames
355
+ if frame.sheet == sheet and frame.stop and frame.stop <= end]
356
+ if not previous:
357
+ raise StoreError("no earlier entry to continue from; use `klok track` instead")
358
+ start = max(frame.stop for frame in previous)
359
+ if end <= start:
360
+ raise StoreError("nothing to add: the previous entry already ends at %s" % start.strftime("%H:%M"))
361
+ frame = Frame(start=start, stop=end, project=project, tags=tags,
362
+ note=args.note or "", sheet=sheet)
363
+ ctx.store.add(frame)
364
+ ctx.store.save("add %s" % (project or frame.id))
365
+ ctx.echo("Added %s %s to %s (%s) [%s]" % (
366
+ describe(ctx, frame), start.strftime("%H:%M"), end.strftime("%H:%M"),
367
+ format_duration(frame.duration(ctx.now)), ctx.term.paint(frame.id, "grey")))
368
+ return 0
369
+
370
+
371
+ def cmd_switch(ctx: Context) -> int:
372
+ args = ctx.args
373
+ project, tags = split_project_tags(args.words)
374
+ if not project and not tags:
375
+ raise StoreError("switch needs a project, e.g. `klok switch acme +api`")
376
+ at = resolve_time(ctx, args.at)
377
+ stopped = stop_running(ctx, at, sheet=None, clamp=True)
378
+ check_project(ctx, project)
379
+ frame = Frame(start=at, project=project, tags=tags, note=args.note or "",
380
+ sheet=ctx.target_sheet())
381
+ ctx.store.add(frame)
382
+ ctx.store.save("switch %s" % (project or frame.id))
383
+ for item in stopped:
384
+ ctx.echo("Stopped %s (%s)" % (describe(ctx, item), format_duration(item.duration(ctx.now))))
385
+ ctx.echo("Switched to %s at %s [%s]" % (describe(ctx, frame), at.strftime("%H:%M"),
386
+ ctx.term.paint(frame.id, "grey")))
387
+ return 0
388
+
389
+
390
+ def cmd_stretch(ctx: Context) -> int:
391
+ """Pull an entry's start back to the end of the one before it."""
392
+ sheet = ctx.sheet_filter()
393
+ frame = ctx.store.by_id(ctx.args.ref or "@", sheet)
394
+ if ctx.args.by:
395
+ new_start = frame.start - parse_duration(ctx.args.by)
396
+ else:
397
+ earlier = [item for item in ctx.store.frames
398
+ if item.sheet == frame.sheet and item.id != frame.id and item.end_or(ctx.now) <= frame.start]
399
+ if not earlier:
400
+ raise StoreError("no earlier entry to stretch back to")
401
+ new_start = max(item.end_or(ctx.now) for item in earlier)
402
+ if new_start >= frame.end_or(ctx.now):
403
+ raise StoreError("stretching that far would leave a negative duration")
404
+ gained = frame.start - new_start
405
+ frame.start = new_start
406
+ frame.touch(ctx.now)
407
+ ctx.store.save("stretch %s" % frame.id)
408
+ ctx.echo("Stretched %s back %s; it now starts at %s (%s)" % (
409
+ ctx.term.paint(frame.id, "grey"), format_duration(gained),
410
+ frame.start.strftime("%H:%M"), format_duration(frame.duration(ctx.now))))
411
+ return 0
412
+
413
+
414
+ # ------------------------------------------------------------------ editing
415
+ def _editor(ctx: Context):
416
+ return (ctx.config.get("general.editor") or os.environ.get("VISUAL")
417
+ or os.environ.get("EDITOR") or "vi")
418
+
419
+
420
+ def _open_editor(ctx: Context, text: str, suffix: str = ".json") -> str:
421
+ handle, path = tempfile.mkstemp(prefix="klok-", suffix=suffix)
422
+ try:
423
+ with os.fdopen(handle, "w", encoding="utf-8") as stream:
424
+ stream.write(text)
425
+ command = _editor(ctx)
426
+ subprocess.call("%s %s" % (command, path), shell=True)
427
+ with open(path, "r", encoding="utf-8") as stream:
428
+ return stream.read()
429
+ finally:
430
+ os.unlink(path)
431
+
432
+
433
+ def cmd_edit(ctx: Context) -> int:
434
+ args = ctx.args
435
+ sheet = ctx.sheet_filter()
436
+ if args.range_tokens or args.all:
437
+ frames, _, _ = ctx.selection(":day")
438
+ if not frames:
439
+ raise StoreError("no entries match that range")
440
+ else:
441
+ frames = [ctx.store.by_id(args.ref or "@", sheet)]
442
+
443
+ inline = any([args.start, args.stop, args.set_project is not None,
444
+ args.note is not None, args.set_tags])
445
+ if inline:
446
+ for frame in frames:
447
+ if args.start:
448
+ frame.start = parse_datetime(args.start, ctx.now)
449
+ if args.stop:
450
+ frame.stop = None if args.stop.lower() in ("none", "running") else parse_datetime(args.stop, ctx.now)
451
+ if args.set_project is not None:
452
+ frame.project = args.set_project
453
+ if args.note is not None:
454
+ frame.note = args.note
455
+ if args.set_tags:
456
+ frame.tags = normalise_tags(args.set_tags)
457
+ if frame.stop and frame.stop < frame.start:
458
+ raise StoreError("%s would end before it starts" % frame.id)
459
+ frame.touch(ctx.now)
460
+ ctx.store.save("edit")
461
+ ctx.echo("Updated %d entr%s." % (len(frames), "y" if len(frames) == 1 else "ies"))
462
+ return 0
463
+
464
+ payload = json.dumps([frame.to_dict() for frame in frames], indent=2, ensure_ascii=False)
465
+ edited = _open_editor(ctx, payload + "\n")
466
+ try:
467
+ parsed = json.loads(edited)
468
+ except ValueError as exc:
469
+ raise StoreError("the edited file is not valid JSON (%s); nothing was changed" % exc)
470
+ if isinstance(parsed, dict):
471
+ parsed = [parsed]
472
+ kept = {frame.id for frame in frames}
473
+ remaining = [frame for frame in ctx.store.frames if frame.id not in kept]
474
+ replacements = []
475
+ for item in parsed:
476
+ frame = Frame.from_dict(item)
477
+ if frame.stop and frame.stop < frame.start:
478
+ raise StoreError("%s would end before it starts; nothing was changed" % frame.id)
479
+ frame.touch(ctx.now)
480
+ replacements.append(frame)
481
+ ctx.store.replace(remaining + replacements)
482
+ ctx.store.save("edit")
483
+ removed = len(frames) - len(replacements)
484
+ ctx.echo("Saved %d entr%s%s." % (len(replacements), "y" if len(replacements) == 1 else "ies",
485
+ ", removed %d" % removed if removed > 0 else ""))
486
+ return 0
487
+
488
+
489
+ def cmd_annotate(ctx: Context) -> int:
490
+ """``klok annotate ID text`` and ``klok annotate text`` both work."""
491
+ words = list(ctx.args.words)
492
+ reference = ctx.args.ref or "@"
493
+ try:
494
+ frame = ctx.store.by_id(reference, ctx.sheet_filter())
495
+ except StoreError:
496
+ if not ctx.args.ref:
497
+ raise
498
+ # The first word was prose, not an entry id.
499
+ words.insert(0, ctx.args.ref)
500
+ frame = ctx.store.by_id("@", ctx.sheet_filter())
501
+ text = " ".join(words).strip()
502
+ if ctx.args.append and frame.note:
503
+ frame.note = "%s %s" % (frame.note, text)
504
+ else:
505
+ frame.note = text
506
+ frame.touch(ctx.now)
507
+ ctx.store.save("annotate %s" % frame.id)
508
+ ctx.echo("Annotated %s: %s" % (ctx.term.paint(frame.id, "grey"), frame.note or "(cleared)"))
509
+ return 0
510
+
511
+
512
+ def cmd_tag(ctx: Context) -> int:
513
+ frame = ctx.store.by_id(ctx.args.ref or "@", ctx.sheet_filter())
514
+ added, removed = [], []
515
+ for token in ctx.args.words:
516
+ if token.startswith("-") or token.startswith("~"):
517
+ name = token[1:]
518
+ if name in frame.tags:
519
+ frame.tags.remove(name)
520
+ removed.append(name)
521
+ else:
522
+ name = token.lstrip("+")
523
+ if name and name not in frame.tags:
524
+ frame.tags.append(name)
525
+ added.append(name)
526
+ frame.touch(ctx.now)
527
+ ctx.store.save("tag %s" % frame.id)
528
+ parts = []
529
+ if added:
530
+ parts.append("added %s" % ", ".join(added))
531
+ if removed:
532
+ parts.append("removed %s" % ", ".join(removed))
533
+ ctx.echo("%s: %s" % (ctx.term.paint(frame.id, "grey"), "; ".join(parts) or "no change"))
534
+ return 0
535
+
536
+
537
+ def cmd_untag(ctx: Context) -> int:
538
+ ctx.args.words = ["~" + token.lstrip("+-~") for token in ctx.args.words]
539
+ return cmd_tag(ctx)
540
+
541
+
542
+ def cmd_rename(ctx: Context) -> int:
543
+ kind, old, new = ctx.args.kind, ctx.args.old, ctx.args.new
544
+ touched = 0
545
+ for frame in ctx.store.frames:
546
+ if kind == "project" and frame.project == old:
547
+ frame.project = new
548
+ touched += 1
549
+ elif kind == "tag" and old in frame.tags:
550
+ frame.tags = normalise_tags([new if tag == old else tag for tag in frame.tags])
551
+ touched += 1
552
+ elif kind == "sheet" and frame.sheet == old:
553
+ frame.sheet = new
554
+ touched += 1
555
+ else:
556
+ continue
557
+ frame.touch(ctx.now)
558
+ if not touched:
559
+ raise StoreError("no entries use the %s %r" % (kind, old))
560
+ ctx.store.save("rename %s" % kind)
561
+ ctx.echo("Renamed %s %s -> %s in %d entr%s." % (kind, old, new, touched,
562
+ "y" if touched == 1 else "ies"))
563
+ return 0
564
+
565
+
566
+ def cmd_move(ctx: Context) -> int:
567
+ frame = ctx.store.by_id(ctx.args.ref, ctx.sheet_filter())
568
+ new_start = parse_datetime(ctx.args.time, ctx.now)
569
+ shift = new_start - frame.start
570
+ frame.start = new_start
571
+ if frame.stop:
572
+ frame.stop += shift
573
+ frame.touch(ctx.now)
574
+ ctx.store.save("move %s" % frame.id)
575
+ ctx.echo("Moved %s by %s; now %s%s" % (
576
+ ctx.term.paint(frame.id, "grey"), format_duration(shift),
577
+ frame.start.strftime("%Y-%m-%d %H:%M"),
578
+ " to " + frame.stop.strftime("%H:%M") if frame.stop else ""))
579
+ return 0
580
+
581
+
582
+ def cmd_resize(ctx: Context) -> int:
583
+ """Shared implementation of lengthen and shorten."""
584
+ frame = ctx.store.by_id(ctx.args.ref, ctx.sheet_filter())
585
+ delta = parse_duration(ctx.args.duration)
586
+ if ctx.args.command == "shorten":
587
+ delta = -delta
588
+ if frame.running:
589
+ raise StoreError("%s is still running; stop it before resizing" % frame.id)
590
+ if ctx.args.start_side:
591
+ new_start = frame.start - delta
592
+ if new_start >= frame.stop:
593
+ raise StoreError("that would leave a negative duration")
594
+ frame.start = new_start
595
+ else:
596
+ new_stop = frame.stop + delta
597
+ if new_stop <= frame.start:
598
+ raise StoreError("that would leave a negative duration")
599
+ frame.stop = new_stop
600
+ frame.touch(ctx.now)
601
+ ctx.store.save("%s %s" % (ctx.args.command, frame.id))
602
+ ctx.echo("%s is now %s (%s to %s)" % (
603
+ ctx.term.paint(frame.id, "grey"), format_duration(frame.duration(ctx.now)),
604
+ frame.start.strftime("%H:%M"), frame.stop.strftime("%H:%M")))
605
+ return 0
606
+
607
+
608
+ def cmd_fill(ctx: Context) -> int:
609
+ """Grow an entry until it touches its neighbours."""
610
+ frame = ctx.store.by_id(ctx.args.ref, ctx.sheet_filter())
611
+ siblings = [item for item in ctx.store.frames if item.sheet == frame.sheet and item.id != frame.id]
612
+ before = [item.end_or(ctx.now) for item in siblings if item.end_or(ctx.now) <= frame.start]
613
+ after = [item.start for item in siblings if item.start >= frame.end_or(ctx.now)]
614
+ day_start = frame.start.replace(hour=0, minute=0, second=0, microsecond=0)
615
+ new_start = max(before) if before else day_start
616
+ frame.start = new_start
617
+ if not frame.running:
618
+ frame.stop = min(after) if after else min(ctx.now, day_start + timedelta(days=1))
619
+ frame.touch(ctx.now)
620
+ ctx.store.save("fill %s" % frame.id)
621
+ ctx.echo("Filled %s: %s to %s (%s)" % (
622
+ ctx.term.paint(frame.id, "grey"), frame.start.strftime("%H:%M"),
623
+ frame.stop.strftime("%H:%M") if frame.stop else "now",
624
+ format_duration(frame.duration(ctx.now))))
625
+ return 0
626
+
627
+
628
+ def cmd_join(ctx: Context) -> int:
629
+ sheet = ctx.sheet_filter()
630
+ frames = [ctx.store.by_id(ref, sheet) for ref in ctx.args.refs]
631
+ if len(frames) < 2:
632
+ raise StoreError("join needs at least two entries")
633
+ frames.sort(key=lambda frame: frame.start)
634
+ first = frames[0]
635
+ last_end = max(frame.end_or(ctx.now) for frame in frames)
636
+ running = any(frame.running for frame in frames)
637
+ tags, notes = [], []
638
+ for frame in frames:
639
+ tags.extend(frame.tags)
640
+ if frame.note:
641
+ notes.append(frame.note)
642
+ merged = Frame(start=first.start, stop=None if running else last_end,
643
+ project=first.project, tags=normalise_tags(tags),
644
+ note="; ".join(dict.fromkeys(notes)), sheet=first.sheet, id=first.id)
645
+ for frame in frames:
646
+ ctx.store.remove(frame)
647
+ ctx.store.add(merged)
648
+ ctx.store.save("join")
649
+ ctx.echo("Joined %d entries into %s (%s)" % (
650
+ len(frames), ctx.term.paint(merged.id, "grey"), format_duration(merged.duration(ctx.now))))
651
+ return 0
652
+
653
+
654
+ def cmd_split(ctx: Context) -> int:
655
+ frame = ctx.store.by_id(ctx.args.ref, ctx.sheet_filter())
656
+ end = frame.end_or(ctx.now)
657
+ if ctx.args.at:
658
+ cut = parse_datetime(ctx.args.at, ctx.now)
659
+ if not (frame.start < cut < end):
660
+ raise StoreError("%s is outside the entry" % cut.strftime("%H:%M"))
661
+ cuts = [cut]
662
+ else:
663
+ parts = max(2, ctx.args.into)
664
+ step = (end - frame.start) / parts
665
+ cuts = [frame.start + step * index for index in range(1, parts)]
666
+ boundaries = [frame.start] + cuts + [end]
667
+ pieces = []
668
+ for index in range(len(boundaries) - 1):
669
+ pieces.append(Frame(start=boundaries[index],
670
+ stop=None if (frame.running and index == len(boundaries) - 2) else boundaries[index + 1],
671
+ project=frame.project, tags=list(frame.tags), note=frame.note,
672
+ sheet=frame.sheet))
673
+ ctx.store.remove(frame)
674
+ for piece in pieces:
675
+ ctx.store.add(piece)
676
+ ctx.store.save("split %s" % frame.id)
677
+ ctx.echo("Split into %d entries: %s" % (len(pieces), ", ".join(piece.id for piece in pieces)))
678
+ return 0
679
+
680
+
681
+ def cmd_delete(ctx: Context) -> int:
682
+ sheet = ctx.sheet_filter()
683
+ frames = [ctx.store.by_id(ref, sheet) for ref in ctx.args.refs]
684
+ for frame in frames:
685
+ ctx.store.remove(frame)
686
+ ctx.store.save("delete")
687
+ for frame in frames:
688
+ ctx.echo("Deleted %s %s (%s)" % (ctx.term.paint(frame.id, "grey"), describe(ctx, frame),
689
+ format_duration(frame.duration(ctx.now))))
690
+ ctx.echo("Use `klok undo` to put %s back." % ("it" if len(frames) == 1 else "them"))
691
+ return 0
692
+
693
+
694
+ def cmd_undo(ctx: Context) -> int:
695
+ label = ctx.store.undo()
696
+ if label is None:
697
+ ctx.echo("Nothing to undo.")
698
+ return 1
699
+ ctx.echo("Undid: %s" % label)
700
+ return 0
701
+
702
+
703
+ # ---------------------------------------------------------------- reporting
704
+ def cmd_log(ctx: Context) -> int:
705
+ frames, start, end = ctx.selection(":week")
706
+ if ctx.args.limit:
707
+ frames = frames[-ctx.args.limit:]
708
+ if ctx.args.reverse:
709
+ frames = list(reversed(frames))
710
+ return emit(ctx, frames, lambda: render_log(frames, ctx.renderer, show_notes=not ctx.args.no_notes))
711
+
712
+
713
+ def cmd_summary(ctx: Context) -> int:
714
+ frames, start, end = ctx.selection(":day")
715
+ return emit(ctx, frames, lambda: render_summary(frames, ctx.renderer, start, end))
716
+
717
+
718
+ def cmd_report(ctx: Context) -> int:
719
+ frames, start, end = ctx.selection(":week")
720
+ return emit(ctx, frames, lambda: render_report(frames, ctx.renderer, start, end,
721
+ show_tags=not ctx.args.no_tags,
722
+ show_entries=ctx.args.entries))
723
+
724
+
725
+ def cmd_chart(ctx: Context) -> int:
726
+ frames, start, end = ctx.selection(":week")
727
+ mode = ctx.args.by
728
+ if mode == "day":
729
+ text = render_day_chart(frames, ctx.renderer, start, end)
730
+ elif mode == "project":
731
+ text = render_totals(by_project(frames, ctx.renderer), ctx.renderer, "By project")
732
+ elif mode == "tag":
733
+ text = render_totals(by_tag(frames, ctx.renderer), ctx.renderer, "By tag")
734
+ elif mode == "sheet":
735
+ text = render_totals(by_sheet(frames, ctx.renderer), ctx.renderer, "By sheet")
736
+ else:
737
+ text = render_punchcard(frames, ctx.renderer)
738
+ print(text)
739
+ return 0
740
+
741
+
742
+ def cmd_gaps(ctx: Context) -> int:
743
+ start, end = ctx.range_of(":day")
744
+ minimum = parse_duration(ctx.args.min) if ctx.args.min else timedelta(minutes=5)
745
+ gaps = ctx.store.gaps(start, end, sheet=ctx.sheet_filter(), now=ctx.now, minimum=minimum)
746
+ window = parse_hours_window(ctx.config.get("exclusions.hours", ""))
747
+ excluded_days = {name.strip().lower()[:3] for name in ctx.config.get_list("exclusions.days")}
748
+ holidays = set(ctx.config.get_list("exclusions.holidays"))
749
+ if window or excluded_days or holidays:
750
+ kept = []
751
+ for gap_start, gap_end in gaps:
752
+ if gap_start.strftime("%a").lower() in excluded_days:
753
+ continue
754
+ if gap_start.strftime("%Y-%m-%d") in holidays:
755
+ continue
756
+ if window:
757
+ low = gap_start.replace(hour=window[0].hour, minute=window[0].minute, second=0)
758
+ high = gap_start.replace(hour=window[1].hour, minute=window[1].minute, second=0)
759
+ gap_start, gap_end = max(gap_start, low), min(gap_end, high)
760
+ if gap_end <= gap_start or gap_end - gap_start < minimum:
761
+ continue
762
+ kept.append((gap_start, gap_end))
763
+ gaps = kept
764
+ print(render_gaps(gaps, ctx.renderer))
765
+ return 0
766
+
767
+
768
+ def cmd_stats(ctx: Context) -> int:
769
+ frames, start, end = ctx.selection(":month")
770
+ if not frames:
771
+ ctx.echo("No time tracked in this range.")
772
+ return 1
773
+ renderer = ctx.renderer
774
+ totals = by_day(frames, renderer)
775
+ grand = sum(totals.values(), timedelta(0))
776
+ longest = max(frames, key=lambda frame: renderer.duration_of(frame))
777
+ projects = by_project(frames, renderer)
778
+ busiest_day = max(totals.items(), key=lambda item: item[1])
779
+
780
+ streak, cursor = 0, ctx.now.date()
781
+ tracked_days = set(totals)
782
+ while cursor in tracked_days:
783
+ streak += 1
784
+ cursor -= timedelta(days=1)
785
+
786
+ rows = [
787
+ ["Range", "%s to %s" % (start.strftime("%Y-%m-%d"), (end - timedelta(seconds=1)).strftime("%Y-%m-%d"))],
788
+ ["Entries", str(len(frames))],
789
+ ["Tracked", format_duration(grand)],
790
+ ["Days tracked", str(len(totals))],
791
+ ["Average per tracked day", format_duration(grand / len(totals))],
792
+ ["Busiest day", "%s (%s)" % (busiest_day[0].isoformat(), format_duration(busiest_day[1]))],
793
+ ["Longest entry", "%s (%s)" % (longest.project or "(no project)",
794
+ format_duration(renderer.duration_of(longest)))],
795
+ ["Projects", str(len(projects))],
796
+ ["Top project", "%s (%s)" % (next(iter(projects)), format_duration(next(iter(projects.values()))))
797
+ if projects else "-"],
798
+ ["Current streak", "%d day%s" % (streak, "" if streak == 1 else "s")],
799
+ ]
800
+ print(render_table(rows, aligns=["left", "left"]))
801
+ return 0
802
+
803
+
804
+ def cmd_projects(ctx: Context) -> int:
805
+ frames, _, _ = ctx.selection(":all")
806
+ totals = by_project(frames, ctx.renderer)
807
+ if not totals:
808
+ ctx.echo("No projects recorded yet.")
809
+ return 1
810
+ if ctx.args.quiet:
811
+ for name in sorted(totals):
812
+ print(name)
813
+ return 0
814
+ rows = [[name, format_duration(length),
815
+ str(len([frame for frame in frames if (frame.project or "(no project)") == name]))]
816
+ for name, length in totals.items()]
817
+ print(render_table(rows, headers=["Project", "Time", "Entries"],
818
+ aligns=["left", "right", "right"], term=ctx.term))
819
+ return 0
820
+
821
+
822
+ def cmd_tags(ctx: Context) -> int:
823
+ frames, _, _ = ctx.selection(":all")
824
+ totals = by_tag(frames, ctx.renderer)
825
+ if not totals:
826
+ ctx.echo("No tags recorded yet.")
827
+ return 1
828
+ if ctx.args.quiet:
829
+ for name in sorted(totals):
830
+ print(name)
831
+ return 0
832
+ rows = [[name, format_duration(length)] for name, length in totals.items()]
833
+ print(render_table(rows, headers=["Tag", "Time"], aligns=["left", "right"], term=ctx.term))
834
+ return 0
835
+
836
+
837
+ def cmd_export(ctx: Context) -> int:
838
+ frames, _, _ = ctx.selection(":all")
839
+ text = render(frames, ctx.now, ctx.args.format or "json")
840
+ if ctx.args.output:
841
+ with open(ctx.args.output, "w", encoding="utf-8") as handle:
842
+ handle.write(text + "\n")
843
+ ctx.echo("Wrote %d entries to %s" % (len(frames), ctx.args.output))
844
+ else:
845
+ print(text)
846
+ return 0
847
+
848
+
849
+ def cmd_import(ctx: Context) -> int:
850
+ text = (open(ctx.args.file, encoding="utf-8").read() if ctx.args.file and ctx.args.file != "-"
851
+ else sys.stdin.read())
852
+ incoming = parse_import(text)
853
+ if not incoming:
854
+ ctx.echo("Nothing to import.")
855
+ return 1
856
+ existing = {frame.id for frame in ctx.store.frames}
857
+ added, skipped = 0, 0
858
+ for frame in incoming:
859
+ if frame.id in existing:
860
+ skipped += 1
861
+ continue
862
+ if ctx.args.sheet:
863
+ frame.sheet = ctx.args.sheet
864
+ ctx.store.add(frame)
865
+ added += 1
866
+ ctx.store.save("import")
867
+ ctx.echo("Imported %d entries (%d already present)." % (added, skipped))
868
+ return 0
869
+
870
+
871
+ # ------------------------------------------------------------------- sheets
872
+ def cmd_sheet(ctx: Context) -> int:
873
+ if not ctx.args.name:
874
+ ctx.echo(ctx.store.current_sheet)
875
+ return 0
876
+ previous = ctx.store.current_sheet
877
+ name = ctx.store.set_sheet(ctx.args.name)
878
+ ctx.echo("Switched to sheet %s (was %s)." % (ctx.term.paint(name, "bold"), previous))
879
+ return 0
880
+
881
+
882
+ def cmd_sheets(ctx: Context) -> int:
883
+ current = ctx.store.current_sheet
884
+ rows = []
885
+ for name in ctx.store.sheets():
886
+ frames = [frame for frame in ctx.store.frames if frame.sheet == name]
887
+ total = sum((ctx.renderer.duration_of(frame) for frame in frames), timedelta(0))
888
+ running = any(frame.running for frame in frames)
889
+ marker = "*" if name == current else ""
890
+ rows.append([marker, ctx.term.paint(name, "bold") if marker else name,
891
+ str(len(frames)), format_duration(total), "running" if running else ""])
892
+ print(render_table(rows, headers=["", "Sheet", "Entries", "Time", ""],
893
+ aligns=["left", "left", "right", "right", "left"], term=ctx.term))
894
+ return 0
895
+
896
+
897
+ def cmd_archive(ctx: Context) -> int:
898
+ frames, _, _ = ctx.selection(":all")
899
+ frames = [frame for frame in frames if not frame.sheet.startswith(ARCHIVE_PREFIX)]
900
+ if not frames:
901
+ ctx.echo("Nothing to archive in this range.")
902
+ return 1
903
+ for frame in frames:
904
+ frame.sheet = ARCHIVE_PREFIX + frame.sheet
905
+ frame.touch(ctx.now)
906
+ ctx.store.save("archive")
907
+ ctx.echo("Archived %d entries. `klok log --all-sheets` still shows them." % len(frames))
908
+ return 0
909
+
910
+
911
+ # ------------------------------------------------------------------- config
912
+ def cmd_config(ctx: Context) -> int:
913
+ action = ctx.args.action
914
+ if action == "list" or (action is None):
915
+ for key, value in ctx.config.items():
916
+ print("%s = %s" % (key, value))
917
+ return 0
918
+ if action == "path":
919
+ print(ctx.config.path)
920
+ return 0
921
+ if action == "edit":
922
+ ctx.config.save()
923
+ subprocess.call("%s %s" % (_editor(ctx), ctx.config.path), shell=True)
924
+ return 0
925
+ if action == "get":
926
+ value = ctx.config.get(ctx.args.key)
927
+ if value is None:
928
+ raise StoreError("no such config key: %s" % ctx.args.key)
929
+ print(value)
930
+ return 0
931
+ if action == "set":
932
+ ctx.config.set(ctx.args.key, ctx.args.value)
933
+ ctx.config.save()
934
+ ctx.echo("%s = %s" % (ctx.args.key, ctx.args.value))
935
+ return 0
936
+ if action == "unset":
937
+ if not ctx.config.unset(ctx.args.key):
938
+ raise StoreError("no such config key: %s" % ctx.args.key)
939
+ ctx.config.save()
940
+ ctx.echo("Removed %s" % ctx.args.key)
941
+ return 0
942
+ raise StoreError("unknown config action %r" % action)
943
+
944
+
945
+ def cmd_where(ctx: Context) -> int:
946
+ rows = [["data", str(ctx.config.home)], ["frames", str(ctx.config.frames_path)],
947
+ ["config", str(ctx.config.path)], ["state", str(ctx.config.state_path)],
948
+ ["undo", str(ctx.config.undo_path)]]
949
+ print(render_table(rows, aligns=["left", "left"]))
950
+ return 0
951
+
952
+
953
+ # -------------------------------------------------------------------- focus
954
+ def _focus_frame(ctx: Context, project, tags, note, start, end, sheet):
955
+ frame = Frame(start=start, stop=end, project=project, tags=tags, note=note, sheet=sheet)
956
+ ctx.store.add(frame)
957
+ ctx.store.save("focus")
958
+ return frame
959
+
960
+
961
+ def cmd_timer(ctx: Context) -> int:
962
+ duration = parse_duration(ctx.args.duration)
963
+ label = ctx.args.message or "Timer"
964
+ track = ctx.args.project is not None and not ctx.args.no_track
965
+ started = now_local()
966
+ finished = countdown(duration, label, ctx.term, art=ctx.args.art, big=ctx.args.big,
967
+ quiet=ctx.args.quiet,
968
+ notify_on_end=ctx.config.get_bool("focus.notify", True),
969
+ bell=ctx.config.get_bool("focus.bell", True))
970
+ if track:
971
+ project, tags = split_project_tags([ctx.args.project] + ["+" + tag for tag in (ctx.args.tag or [])])
972
+ end = now_local()
973
+ frame = _focus_frame(ctx, project, tags, ctx.args.message or "", started, end, ctx.target_sheet())
974
+ ctx.echo("Recorded %s (%s)" % (describe(ctx, frame), format_duration(frame.duration(ctx.now))))
975
+ return 0 if finished else 130
976
+
977
+
978
+ def cmd_pomodoro(ctx: Context) -> int:
979
+ config = ctx.config
980
+ work = parse_duration(ctx.args.work or config.get("focus.work", "25m"))
981
+ short_break = parse_duration(ctx.args.short_break or config.get("focus.break", "5m"))
982
+ long_break = parse_duration(ctx.args.long_break or config.get("focus.long_break", "15m"))
983
+ rounds = ctx.args.rounds or config.get_int("focus.rounds", 4)
984
+ track = config.get_bool("focus.track", True) and not ctx.args.no_track
985
+ project, tags = split_project_tags(ctx.args.words)
986
+ completed = 0
987
+
988
+ for kind, label, duration in pomodoro_plan(rounds, work, short_break, long_break):
989
+ if kind != "work" and ctx.args.no_breaks:
990
+ continue
991
+ started = now_local()
992
+ finished = countdown(duration, label, ctx.term, art=ctx.args.art, big=ctx.args.big,
993
+ quiet=ctx.args.quiet,
994
+ notify_on_end=config.get_bool("focus.notify", True),
995
+ bell=config.get_bool("focus.bell", True))
996
+ if kind == "work":
997
+ elapsed = now_local() - started
998
+ if track and elapsed.total_seconds() >= 60:
999
+ _focus_frame(ctx, project, tags, label, started, now_local(), ctx.target_sheet())
1000
+ if finished:
1001
+ completed += 1
1002
+ if not finished:
1003
+ ctx.echo("Stopped after %d completed round%s." % (completed, "" if completed == 1 else "s"))
1004
+ return 130
1005
+ if kind != "work" and not ctx.args.no_prompt and duration.total_seconds() > 0:
1006
+ try:
1007
+ input("Press enter to start the next round (Ctrl-C to stop)... ")
1008
+ except (KeyboardInterrupt, EOFError):
1009
+ ctx.echo("")
1010
+ return 130
1011
+ notify("klok", "Pomodoro finished: %d rounds" % completed,
1012
+ enabled=config.get_bool("focus.notify", True), bell=config.get_bool("focus.bell", True))
1013
+ ctx.echo("Done: %d round%s of %s." % (completed, "" if completed == 1 else "s", format_duration(work)))
1014
+ return 0
1015
+
1016
+
1017
+ # ---------------------------------------------------------------- mindfulness
1018
+ def practice_sheet(ctx: Context) -> str:
1019
+ """Practice lives on its own sheet unless the user says otherwise."""
1020
+ return getattr(ctx.args, "sheet", None) or ctx.config.get("mindful.sheet", "wellbeing")
1021
+
1022
+
1023
+ def record_practice(ctx: Context, tag: str, started: datetime, ended: datetime,
1024
+ note: str = "", extra_tags=()):
1025
+ """Store a finished session as an ordinary frame, or return None."""
1026
+ if getattr(ctx.args, "no_track", False) or not ctx.config.get_bool("mindful.track", True):
1027
+ return None
1028
+ if (ended - started).total_seconds() < 5:
1029
+ return None
1030
+ frame = Frame(start=started, stop=ended,
1031
+ project=ctx.config.get("mindful.project", "mindfulness"),
1032
+ tags=normalise_tags([tag] + list(extra_tags)),
1033
+ note=note or "", sheet=practice_sheet(ctx))
1034
+ ctx.store.add(frame)
1035
+ ctx.store.save("practice %s" % tag)
1036
+ return frame
1037
+
1038
+
1039
+ def cmd_breathe(ctx: Context) -> int:
1040
+ """A guided breathing pacer."""
1041
+ args = ctx.args
1042
+ if args.list:
1043
+ rows = [[name, "-".join("%g" % value for value in values[:4]), values[4]]
1044
+ for name, values in sorted(PATTERNS.items())]
1045
+ print(render_table(rows, headers=["Pattern", "Counts", "What it is"],
1046
+ aligns=["left", "left", "left"], term=ctx.term))
1047
+ print("\nAny counts work too: `klok breathe 4-7-8` or `klok breathe 5-2-7-2`.")
1048
+ return 0
1049
+
1050
+ pattern = parse_pattern(args.pattern or (args.words[0] if args.words else None)
1051
+ or ctx.config.get("mindful.pattern", "box"))
1052
+ if args.for_time:
1053
+ rounds = rounds_for(pattern, parse_duration(args.for_time))
1054
+ else:
1055
+ rounds = args.rounds or ctx.config.get_int("mindful.breath_rounds", 6)
1056
+ if rounds < 1:
1057
+ raise MindfulError("a session needs at least one round")
1058
+ total = cycle_length(pattern) * rounds
1059
+
1060
+ if args.plan:
1061
+ ctx.echo("Pattern: %s" % describe_pattern(pattern))
1062
+ ctx.echo("Rounds: %d Cycle: %s Total: %s"
1063
+ % (rounds, format_duration(cycle_length(pattern)), format_duration(total)))
1064
+ rows = [[label, "%gs" % seconds] for _, label, seconds in pattern_phases(pattern)]
1065
+ print(render_table(rows, aligns=["left", "right"], indent=" "))
1066
+ ctx.echo("Steps: %d" % len(breath_plan(pattern, rounds)))
1067
+ return 0
1068
+
1069
+ started = now_local()
1070
+ ctx.echo("%s %s" % (ctx.term.paint("Breathing", "cyan", "bold"), describe_pattern(pattern)))
1071
+ finished = breathe(pattern, rounds, ctx.term, quiet=args.quiet,
1072
+ bell=ctx.config.get_bool("focus.bell", True),
1073
+ notify_on_end=ctx.config.get_bool("focus.notify", True))
1074
+ ended = now_local()
1075
+ frame = record_practice(ctx, BREATHING_TAG, started, ended,
1076
+ note=args.note or pattern[4], extra_tags=args.tag or [])
1077
+ if not finished:
1078
+ ctx.echo("Stopped after %s." % format_duration(ended - started))
1079
+ if frame:
1080
+ ctx.echo("Recorded %s (%s) on sheet %s"
1081
+ % (describe(ctx, frame), format_duration(frame.duration(ended)), frame.sheet))
1082
+ return 0 if finished else 130
1083
+
1084
+
1085
+ def cmd_meditate(ctx: Context) -> int:
1086
+ """A timed sit, with a bell at the end and optionally along the way."""
1087
+ args = ctx.args
1088
+ length = parse_duration(args.for_time or args.duration
1089
+ or ctx.config.get("mindful.default_sit", "10m"))
1090
+ if length.total_seconds() <= 0:
1091
+ raise MindfulError("a sit needs a positive length")
1092
+ interval_raw = args.interval_bell if args.interval_bell is not None else ctx.config.get("mindful.interval_bell", "")
1093
+ interval = parse_duration(interval_raw) if interval_raw else None
1094
+ warmup_raw = args.warmup if args.warmup is not None else ctx.config.get("mindful.warmup", "")
1095
+ warmup = parse_duration(warmup_raw) if warmup_raw else None
1096
+ guidance = ctx.config.get_bool("mindful.guidance", True) and not args.no_guidance
1097
+
1098
+ if args.plan:
1099
+ marks = sit_plan(length, interval, warmup)
1100
+ ctx.echo("Sit: %s" % format_duration(length))
1101
+ ctx.echo("Bells: %s" % ", ".join(format_duration(timedelta(seconds=mark)) for mark in marks))
1102
+ ctx.echo("Warm-up: %s" % (format_duration(warmup) if warmup else "none"))
1103
+ ctx.echo("Guidance: %s" % ("on" if guidance else "off"))
1104
+ ctx.echo("Recorded: %s +%s on sheet %s"
1105
+ % (ctx.config.get("mindful.project", "mindfulness"), MEDITATION_TAG,
1106
+ practice_sheet(ctx)))
1107
+ return 0
1108
+
1109
+ started = now_local()
1110
+ finished = meditate(length, ctx.term, interval=interval, warmup=warmup, guidance=guidance,
1111
+ quiet=args.quiet, bell=ctx.config.get_bool("focus.bell", True),
1112
+ notify_on_end=ctx.config.get_bool("focus.notify", True))
1113
+ ended = now_local()
1114
+ frame = record_practice(ctx, MEDITATION_TAG, started, ended,
1115
+ note=args.note or "", extra_tags=args.tag or [])
1116
+ if not finished:
1117
+ ctx.echo("Ended early after %s." % format_duration(ended - started))
1118
+ if frame:
1119
+ ctx.echo("Recorded %s (%s) on sheet %s"
1120
+ % (describe(ctx, frame), format_duration(frame.duration(ended)), frame.sheet))
1121
+ return 0 if finished else 130
1122
+
1123
+
1124
+ def cmd_checkin(ctx: Context) -> int:
1125
+ """Note how you are doing, on three 1-5 scales."""
1126
+ args = ctx.args
1127
+ store = CheckinStore(ctx.config)
1128
+ if args.delete:
1129
+ removed = store.remove(args.delete)
1130
+ store.save()
1131
+ ctx.echo("Deleted check-in %s from %s"
1132
+ % (removed.id, removed.at.strftime("%Y-%m-%d %H:%M")))
1133
+ return 0
1134
+
1135
+ mood = check_score("mood", args.mood)
1136
+ energy = check_score("energy", args.energy)
1137
+ stress = check_score("stress", args.stress)
1138
+ if mood is None and energy is None and stress is None and sys.stdin.isatty():
1139
+ mood = check_score("mood", _ask("Mood 1-5 (1 low, 5 good)"))
1140
+ energy = check_score("energy", _ask("Energy 1-5"))
1141
+ stress = check_score("stress", _ask("Stress 1-5 (1 calm, 5 wound up)"))
1142
+ if mood is None and energy is None and stress is None:
1143
+ raise MindfulError("give at least one of --mood, --energy or --stress")
1144
+
1145
+ checkin = Checkin(at=resolve_time(ctx, args.at), mood=mood, energy=energy, stress=stress,
1146
+ note=" ".join(args.words).strip(), tags=normalise_tags(args.tag or []))
1147
+ store.add(checkin)
1148
+ store.save()
1149
+ parts = ["%s %d" % (name, value) for name, value in
1150
+ (("mood", mood), ("energy", energy), ("stress", stress)) if value is not None]
1151
+ ctx.echo("Noted %s at %s [%s]" % (", ".join(parts), checkin.at.strftime("%H:%M"),
1152
+ ctx.term.paint(checkin.id, "grey")))
1153
+ return 0
1154
+
1155
+
1156
+ def _ask(prompt: str):
1157
+ try:
1158
+ answer = input("%s: " % prompt).strip()
1159
+ except (EOFError, KeyboardInterrupt):
1160
+ return None
1161
+ return answer or None
1162
+
1163
+
1164
+ def cmd_mood(ctx: Context) -> int:
1165
+ """Show check-ins and how the scales have moved."""
1166
+ start, end = ctx.range_of(":month")
1167
+ store = CheckinStore(ctx.config)
1168
+ checkins = store.select(start, end)
1169
+ if getattr(ctx.args, "format", None) == "json":
1170
+ print(json.dumps([item.to_dict() for item in checkins], indent=2, ensure_ascii=False))
1171
+ return 0
1172
+ if not checkins:
1173
+ ctx.echo("No check-ins in this range. Add one with `klok checkin --mood 4`.")
1174
+ return 1
1175
+
1176
+ rows = [[item.at.strftime("%Y-%m-%d"), ctx.renderer.fmt_time(item.at),
1177
+ "" if item.mood is None else str(item.mood),
1178
+ "" if item.energy is None else str(item.energy),
1179
+ "" if item.stress is None else str(item.stress),
1180
+ " ".join("+" + tag for tag in item.tags),
1181
+ item.note, ctx.term.paint(item.id, "grey")]
1182
+ for item in checkins]
1183
+ print(render_table(rows, headers=["Date", "Time", "Mood", "Energy", "Stress", "Tags", "Note", "ID"],
1184
+ aligns=["left", "left", "right", "right", "right", "left", "left", "left"],
1185
+ term=ctx.term))
1186
+
1187
+ print("")
1188
+ days = sorted({item.at.date() for item in checkins})
1189
+ for field_name in ("mood", "energy", "stress"):
1190
+ averages = daily_averages(checkins, field_name)
1191
+ if not averages:
1192
+ continue
1193
+ series = [averages.get(day) for day in days]
1194
+ values = [value for value in series if value is not None]
1195
+ print("%-8s %s avg %.1f" % (field_name.title(),
1196
+ ctx.term.paint(sparkline(series), "cyan"),
1197
+ sum(values) / len(values)))
1198
+
1199
+ insight = _workload_insight(ctx, checkins, days)
1200
+ if insight:
1201
+ print("")
1202
+ print(ctx.term.paint(insight, "dim"))
1203
+ return 0
1204
+
1205
+
1206
+ def _workload_insight(ctx: Context, checkins, days):
1207
+ """Compare mood on heavier and lighter days. Descriptive, not a claim."""
1208
+ moods = daily_averages(checkins, "mood")
1209
+ if len(moods) < 4:
1210
+ return None
1211
+ work_sheet = ctx.store.current_sheet
1212
+ tracked = {}
1213
+ for day in moods:
1214
+ day_start = ctx.now.replace(year=day.year, month=day.month, day=day.day,
1215
+ hour=0, minute=0, second=0, microsecond=0)
1216
+ frames = ctx.store.select(day_start, day_start + timedelta(days=1),
1217
+ sheet=work_sheet, now=ctx.now)
1218
+ tracked[day] = sum((frame.duration(ctx.now) for frame in frames), timedelta(0))
1219
+ if not any(value.total_seconds() for value in tracked.values()):
1220
+ return None
1221
+ ordered = sorted(tracked.values())
1222
+ median = ordered[len(ordered) // 2]
1223
+ heavy = [moods[day] for day in moods if tracked[day] > median]
1224
+ light = [moods[day] for day in moods if tracked[day] <= median]
1225
+ if len(heavy) < 2 or len(light) < 2:
1226
+ return None
1227
+ return ("On the %d busier days (over %s tracked) mood averaged %.1f; "
1228
+ "on the %d lighter days, %.1f."
1229
+ % (len(heavy), format_duration(median), sum(heavy) / len(heavy),
1230
+ len(light), sum(light) / len(light)))
1231
+
1232
+
1233
+ def cmd_mindful(ctx: Context) -> int:
1234
+ """How the practice itself is going."""
1235
+ start, end = ctx.range_of(":month")
1236
+ sheet = None if ctx.args.all_sheets else practice_sheet(ctx)
1237
+ frames = ctx.store.select(start, end, sheet=sheet, now=ctx.now)
1238
+ frames = [frame for frame in frames
1239
+ if MEDITATION_TAG in frame.tags or BREATHING_TAG in frame.tags]
1240
+ if not frames:
1241
+ ctx.echo("No practice recorded in this range. Try `klok meditate 10m` or `klok breathe`.")
1242
+ return 1
1243
+
1244
+ renderer = ctx.renderer
1245
+ total = sum((renderer.duration_of(frame) for frame in frames), timedelta(0))
1246
+ sits = [frame for frame in frames if MEDITATION_TAG in frame.tags]
1247
+ breaths = [frame for frame in frames if BREATHING_TAG in frame.tags]
1248
+ days = {frame.start.date() for frame in frames}
1249
+ last = max(frames, key=lambda frame: frame.end_or(ctx.now))
1250
+
1251
+ rows = [
1252
+ ["Range", "%s to %s" % (start.strftime("%Y-%m-%d"),
1253
+ (end - timedelta(seconds=1)).strftime("%Y-%m-%d"))],
1254
+ ["Sessions", str(len(frames))],
1255
+ ["Total practice", format_duration(total)],
1256
+ ["Sitting", "%d session%s, %s" % (len(sits), "" if len(sits) == 1 else "s",
1257
+ format_duration(sum((renderer.duration_of(f) for f in sits),
1258
+ timedelta(0))))],
1259
+ ["Breathing", "%d session%s, %s" % (len(breaths), "" if len(breaths) == 1 else "s",
1260
+ format_duration(sum((renderer.duration_of(f) for f in breaths),
1261
+ timedelta(0))))],
1262
+ ["Average session", format_duration(total / len(frames))],
1263
+ ["Days practised", str(len(days))],
1264
+ ["Current streak", "%d day%s" % (streak(days, ctx.now.date()),
1265
+ "" if streak(days, ctx.now.date()) == 1 else "s")],
1266
+ ["Longest streak", "%d day%s" % (longest_streak(days),
1267
+ "" if longest_streak(days) == 1 else "s")],
1268
+ ["Last practice", humanize_ago(ctx.now - last.end_or(ctx.now))],
1269
+ ]
1270
+ print(render_table(rows, aligns=["left", "left"]))
1271
+
1272
+ if not ctx.args.no_chart:
1273
+ print("")
1274
+ print(render_day_chart(frames, renderer, start, end))
1275
+
1276
+ checkins = CheckinStore(ctx.config).select(start, end)
1277
+ moods = daily_averages(checkins, "mood") if checkins else {}
1278
+ if moods:
1279
+ series = [moods.get(day) for day in sorted(moods)]
1280
+ values = [value for value in series if value is not None]
1281
+ print("")
1282
+ print("Mood %s avg %.1f" % (ctx.term.paint(sparkline(series), "cyan"),
1283
+ sum(values) / len(values)))
1284
+ return 0
1285
+
1286
+
1287
+ # ------------------------------------------------------------------- checks
1288
+ def cmd_check(ctx: Context) -> int:
1289
+ problems = []
1290
+ for first, second in ctx.store.overlaps(ctx.now):
1291
+ problems.append(("overlap", "%s and %s overlap on sheet %s" % (first.id, second.id, first.sheet)))
1292
+ running = [frame for frame in ctx.store.frames if frame.running]
1293
+ for frame in running:
1294
+ hours = frame.duration(ctx.now).total_seconds() / 3600
1295
+ if hours > 12:
1296
+ problems.append(("long", "%s has been running for %s" % (frame.id, format_duration(frame.duration(ctx.now)))))
1297
+ if len(running) > 1:
1298
+ problems.append(("multiple", "%d entries are running at once (%s)" %
1299
+ (len(running), ", ".join(frame.id for frame in running))))
1300
+ for frame in ctx.store.frames:
1301
+ if frame.stop and frame.stop < frame.start:
1302
+ problems.append(("negative", "%s ends before it starts" % frame.id))
1303
+ if frame.stop and frame.stop == frame.start:
1304
+ problems.append(("empty", "%s has zero duration" % frame.id))
1305
+ if frame.start > ctx.now + timedelta(minutes=1):
1306
+ problems.append(("future", "%s starts in the future" % frame.id))
1307
+ if not problems:
1308
+ ctx.echo("%s %d entries, no problems found." % (ctx.term.paint("OK", "green", "bold"),
1309
+ len(ctx.store.frames)))
1310
+ return 0
1311
+ rows = [[ctx.term.paint(kind, "yellow"), message] for kind, message in problems]
1312
+ print(render_table(rows, headers=["Issue", "Detail"], aligns=["left", "left"], term=ctx.term))
1313
+ return 1
1314
+
1315
+
1316
+ COMPLETIONS = {
1317
+ "bash": """# klok bash completion - add to ~/.bashrc:
1318
+ # source <(klok completion bash)
1319
+ _klok_completions() {
1320
+ local commands="start stop cancel status restart switch track add stretch edit annotate tag untag
1321
+ rename move lengthen shorten fill join split delete undo log summary report chart gaps stats projects
1322
+ tags sheet sheets archive export import config where timer pomodoro breathe meditate checkin mood
1323
+ mindful check completion version"
1324
+ COMPREPLY=($(compgen -W "$commands" -- "${COMP_WORDS[COMP_CWORD]}"))
1325
+ }
1326
+ complete -F _klok_completions klok
1327
+ """,
1328
+ "zsh": """# klok zsh completion - add to ~/.zshrc:
1329
+ # source <(klok completion zsh)
1330
+ _klok() {
1331
+ local -a commands
1332
+ commands=(start stop cancel status restart switch track add stretch edit annotate tag untag rename
1333
+ move lengthen shorten fill join split delete undo log summary report chart gaps stats projects tags
1334
+ sheet sheets archive export import config where timer pomodoro breathe meditate checkin mood
1335
+ mindful check completion version)
1336
+ _describe 'klok command' commands
1337
+ }
1338
+ compdef _klok klok
1339
+ """,
1340
+ "fish": """# klok fish completion - save as ~/.config/fish/completions/klok.fish
1341
+ for cmd in start stop cancel status restart switch track add stretch edit annotate tag untag rename \\
1342
+ move lengthen shorten fill join split delete undo log summary report chart gaps stats projects \\
1343
+ tags sheet sheets archive export import config where timer pomodoro breathe meditate \\
1344
+ checkin mood mindful check completion version
1345
+ complete -c klok -n __fish_use_subcommand -a $cmd
1346
+ end
1347
+ """,
1348
+ }
1349
+
1350
+
1351
+ def cmd_completion(ctx: Context) -> int:
1352
+ print(COMPLETIONS[ctx.args.shell].strip())
1353
+ return 0
1354
+
1355
+
1356
+ def cmd_version(ctx: Context) -> int:
1357
+ print("klok %s" % __version__)
1358
+ return 0
1359
+
1360
+
1361
+ # ------------------------------------------------------------------- parser
1362
+ GLOBAL_VALUE_OPTIONS = {"--home", "--color", "--round", "--duration-style", "--now"}
1363
+
1364
+
1365
+ def _subcommand_index(argv):
1366
+ """Index of the subcommand word, skipping global options and their values."""
1367
+ index = 0
1368
+ while index < len(argv):
1369
+ token = argv[index]
1370
+ if token.startswith("-"):
1371
+ index += 2 if token in GLOBAL_VALUE_OPTIONS else 1
1372
+ continue
1373
+ return index
1374
+ return None
1375
+
1376
+
1377
+ def _preprocess(argv):
1378
+ """Smooth over two things argparse cannot express on its own.
1379
+
1380
+ ``--at -15m`` is folded into ``--at=-15m``, and ``klok tag ID -email``
1381
+ keeps working by rewriting the removal marker to ``~`` before argparse
1382
+ mistakes it for an option.
1383
+ """
1384
+ position = _subcommand_index(argv)
1385
+ tag_command = position is not None and argv[position] in ("tag", "untag")
1386
+ out, index = [], 0
1387
+ while index < len(argv):
1388
+ token = argv[index]
1389
+ following = argv[index + 1] if index + 1 < len(argv) else None
1390
+ if token in TIME_OPTIONS and following and re.match(r"^-\d", following):
1391
+ out.append("%s=%s" % (token, following))
1392
+ index += 2
1393
+ continue
1394
+ if (tag_command and index > position and token not in ("-h", "--help", "--sheet")
1395
+ and re.match(r"^-[A-Za-z]", token)):
1396
+ token = "~" + token[1:]
1397
+ out.append(token)
1398
+ index += 1
1399
+ return out
1400
+
1401
+
1402
+ GROUP_ORDER = ["Tracking", "Fixing entries", "Reporting", "Sheets & config",
1403
+ "Focus & mindfulness", "Other"]
1404
+
1405
+ QUICK_START = """\
1406
+ Quick start:
1407
+ klok start acme +api start tracking, tag it
1408
+ klok stop stop the clock
1409
+ klok status what's running right now
1410
+ klok report :week where the week went
1411
+ klok breathe sixty seconds, box breathing
1412
+ """
1413
+
1414
+
1415
+ class KlokArgumentParser(argparse.ArgumentParser):
1416
+ """Suggests a close match for a mistyped subcommand instead of just failing."""
1417
+
1418
+ command_names = ()
1419
+
1420
+ def error(self, message):
1421
+ match = re.search(r"argument COMMAND: invalid choice: '([^']+)'", message)
1422
+ if match:
1423
+ bad = match.group(1)
1424
+ guesses = difflib.get_close_matches(bad, self.command_names, n=1) if self.command_names else []
1425
+ hint = " - did you mean %r?" % guesses[0] if guesses else ""
1426
+ # Argparse's default message for this dumps every command and
1427
+ # alias on one line; that wall of text helps no one, so replace
1428
+ # it with a short pointer at the grouped list in --help instead.
1429
+ self.exit(2, "klok: unknown command %r%s\nRun `klok --help` for the full list.\n" % (bad, hint))
1430
+ super().error(message)
1431
+
1432
+
1433
+ def _format_command_group(entries):
1434
+ width = min(max((len(name) for name, _, _ in entries), default=8), 12)
1435
+ lines = []
1436
+ for name, aliases, help_text in entries:
1437
+ line = " %-*s %s" % (width, name, help_text)
1438
+ if aliases:
1439
+ line += " (%s)" % ", ".join(aliases)
1440
+ lines.append(line)
1441
+ return lines
1442
+
1443
+
1444
+ def _build_epilog(groups) -> str:
1445
+ lines = [QUICK_START]
1446
+ for group_name in GROUP_ORDER:
1447
+ entries = groups.get(group_name) or []
1448
+ if not entries:
1449
+ continue
1450
+ lines.append("%s:" % group_name)
1451
+ lines.extend(_format_command_group(entries))
1452
+ lines.append("")
1453
+ lines.append("Full reference for one command: `klok <command> --help`")
1454
+ lines.append("Time and duration syntax, config keys, exports: see README.md")
1455
+ return "\n".join(lines).rstrip("\n")
1456
+
1457
+
1458
+ def build_parser() -> argparse.ArgumentParser:
1459
+ parser = KlokArgumentParser(
1460
+ prog="klok",
1461
+ description="Track where your time goes, from the command line.",
1462
+ formatter_class=argparse.RawDescriptionHelpFormatter)
1463
+ parser.add_argument("--home", metavar="DIR", help="data directory (default: $KLOK_HOME or XDG data dir)")
1464
+ parser.add_argument("--color", choices=["auto", "always", "never"], help="colour output")
1465
+ parser.add_argument("--no-color", action="store_true", help="disable colour")
1466
+ parser.add_argument("--round", type=int, metavar="MIN", help="round durations up to N minutes")
1467
+ parser.add_argument("--duration-style", choices=["short", "clock", "hours"], default="short",
1468
+ help="how durations are printed (1h 30m, 01:30:00 or 1.50)")
1469
+ parser.add_argument("--now", metavar="TIME", help=argparse.SUPPRESS)
1470
+ parser.add_argument("-V", "--version", action="version", version="klok %s" % __version__)
1471
+
1472
+ sheet_opts = argparse.ArgumentParser(add_help=False)
1473
+ sheet_opts.add_argument("--sheet", metavar="NAME", help="operate on this sheet instead of the active one")
1474
+ sheet_opts.add_argument("--all-sheets", action="store_true", help="include every sheet")
1475
+
1476
+ filter_opts = argparse.ArgumentParser(add_help=False)
1477
+ filter_opts.add_argument("-p", "--project", action="append", metavar="NAME",
1478
+ help="only this project (repeatable, globs and parent prefixes work)")
1479
+ filter_opts.add_argument("-t", "--tag", action="append", metavar="TAG",
1480
+ help="only entries carrying this tag (repeatable, all must match)")
1481
+ filter_opts.add_argument("-T", "--exclude-tag", action="append", metavar="TAG",
1482
+ help="skip entries carrying this tag (repeatable)")
1483
+ filter_opts.add_argument("--contains", metavar="TEXT", help="only entries whose text contains TEXT")
1484
+
1485
+ range_opts = argparse.ArgumentParser(add_help=False)
1486
+ range_opts.add_argument("range_tokens", nargs="*", metavar="RANGE",
1487
+ help="e.g. :week, yesterday, 'last 7 days', '2026-08-01 to 2026-08-31'")
1488
+ range_opts.add_argument("--from", dest="from_time", metavar="TIME", help="range start")
1489
+ range_opts.add_argument("--to", dest="to_time", metavar="TIME", help="range end")
1490
+
1491
+ format_opts = argparse.ArgumentParser(add_help=False)
1492
+ format_opts.add_argument("-f", "--format", choices=list(FORMATS), help="output format (default: text)")
1493
+
1494
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND",
1495
+ help="see the grouped command list below")
1496
+ groups = {name: [] for name in GROUP_ORDER}
1497
+ current_group = ["Tracking"]
1498
+
1499
+ def add(name, help_text, aliases=(), parents=(), func=None, **kwargs):
1500
+ # `help=` is deliberately omitted: passing it would make argparse dump
1501
+ # every one of these into a single wall-of-text "positional arguments"
1502
+ # block. The curated, grouped rundown in the epilog replaces it, and
1503
+ # each command's own `--help` still shows help_text via `description`.
1504
+ sub = subparsers.add_parser(name, aliases=list(aliases),
1505
+ parents=list(parents), description=help_text, **kwargs)
1506
+ sub.set_defaults(func=func, command=name)
1507
+ groups[current_group[0]].append((name, list(aliases), help_text))
1508
+ return sub
1509
+
1510
+ # -- tracking ------------------------------------------------------
1511
+ start = add("start", "Start tracking a project now (or at a given time).",
1512
+ aliases=["in", "on"], parents=[sheet_opts], func=cmd_start)
1513
+ start.add_argument("words", nargs="*", metavar="PROJECT +TAG", help="project name and +tags")
1514
+ start.add_argument("-a", "--at", metavar="TIME", help="start at this time (e.g. 9am, -15m)")
1515
+ start.add_argument("-n", "--note", metavar="TEXT", help="annotation for the entry")
1516
+ start.add_argument("--no-stop", action="store_true", help="fail instead of stopping what is running")
1517
+ start.add_argument("--force", action="store_true", help="allow a project that has never been used")
1518
+
1519
+ stop = add("stop", "Stop the running entry.", aliases=["out", "off"],
1520
+ parents=[sheet_opts], func=cmd_stop)
1521
+ stop.add_argument("-a", "--at", metavar="TIME", help="stop at this time (e.g. 17:30, -5m)")
1522
+ stop.add_argument("-n", "--note", metavar="TEXT", help="append an annotation while stopping")
1523
+ stop.add_argument("--all", action="store_true", help="stop running entries on every sheet")
1524
+
1525
+ add("cancel", "Discard the running entry without recording it.",
1526
+ aliases=["abort"], parents=[sheet_opts], func=cmd_cancel)
1527
+
1528
+ status = add("status", "Show what is running right now.", aliases=["now", "current"],
1529
+ parents=[sheet_opts], func=cmd_status)
1530
+ status.add_argument("--json", dest="format", action="store_const", const="json",
1531
+ help="machine-readable output")
1532
+ status.add_argument("-q", "--quiet", action="store_true", help="say nothing when idle")
1533
+
1534
+ restart = add("restart", "Start a new entry with a previous entry's project and tags.",
1535
+ aliases=["continue", "resume", "again"], parents=[sheet_opts], func=cmd_restart)
1536
+ restart.add_argument("ref", nargs="?", metavar="ID", help="entry to copy (default: the last one)")
1537
+ restart.add_argument("-a", "--at", metavar="TIME", help="start at this time")
1538
+ restart.add_argument("--keep-note", action="store_true", help="carry the annotation over too")
1539
+
1540
+ switch = add("switch", "Stop what is running and start something else at the same instant.",
1541
+ parents=[sheet_opts], func=cmd_switch)
1542
+ switch.add_argument("words", nargs="*", metavar="PROJECT +TAG")
1543
+ switch.add_argument("-a", "--at", metavar="TIME", help="switch at this time")
1544
+ switch.add_argument("-n", "--note", metavar="TEXT")
1545
+
1546
+ track = add("track", "Record a finished interval, e.g. `klok track 09:00 to 11:30 acme +api`.",
1547
+ parents=[sheet_opts], func=cmd_track)
1548
+ track.add_argument("words", nargs="*", metavar="RANGE PROJECT +TAG")
1549
+ track.add_argument("--from", dest="from_time", metavar="TIME")
1550
+ track.add_argument("--to", dest="to_time", metavar="TIME")
1551
+ track.add_argument("-d", "--duration", metavar="DURATION", help="length instead of an end time")
1552
+ track.add_argument("-n", "--note", metavar="TEXT")
1553
+ track.add_argument("--force", action="store_true")
1554
+
1555
+ entry_add = add("add", "Close the open stretch since the last entry with an activity (utt style).",
1556
+ parents=[sheet_opts], func=cmd_add)
1557
+ entry_add.add_argument("words", nargs="*", metavar="PROJECT +TAG")
1558
+ entry_add.add_argument("-a", "--at", metavar="TIME", help="end the activity at this time")
1559
+ entry_add.add_argument("-n", "--note", metavar="TEXT")
1560
+ entry_add.add_argument("--force", action="store_true")
1561
+
1562
+ stretch = add("stretch", "Pull an entry's start back to the end of the previous one.",
1563
+ parents=[sheet_opts], func=cmd_stretch)
1564
+ stretch.add_argument("ref", nargs="?", metavar="ID")
1565
+ stretch.add_argument("--by", metavar="DURATION", help="stretch by a fixed amount instead")
1566
+
1567
+ current_group[0] = "Fixing entries"
1568
+ # -- editing -------------------------------------------------------
1569
+ edit = add("edit", "Edit entries in $EDITOR, or change fields directly with flags.",
1570
+ parents=[sheet_opts], func=cmd_edit)
1571
+ edit.add_argument("ref", nargs="?", metavar="ID", help="entry to edit (default: running or last)")
1572
+ edit.add_argument("--range", dest="range_tokens", nargs="*", default=[], metavar="RANGE",
1573
+ help="edit every entry in a range")
1574
+ edit.add_argument("--all", action="store_true", help="edit every entry on the sheet")
1575
+ edit.add_argument("--start", metavar="TIME")
1576
+ edit.add_argument("--stop", metavar="TIME", help="new end time, or 'running' to reopen")
1577
+ edit.add_argument("--set-project", dest="set_project", metavar="NAME", help="replace the project")
1578
+ edit.add_argument("-n", "--note", metavar="TEXT")
1579
+ edit.add_argument("--tags", dest="set_tags", nargs="*", metavar="TAG", help="replace the tag list")
1580
+ edit.set_defaults(project=None, tag=None, exclude_tag=None, contains=None)
1581
+
1582
+ annotate = add("annotate", "Attach a note to an entry.", aliases=["note"], func=cmd_annotate)
1583
+ annotate.add_argument("ref", nargs="?", metavar="ID")
1584
+ annotate.add_argument("words", nargs="*", metavar="TEXT")
1585
+ annotate.add_argument("--append", action="store_true", help="append instead of replacing")
1586
+ annotate.add_argument("--sheet", metavar="NAME")
1587
+ annotate.set_defaults(all_sheets=False)
1588
+
1589
+ tag = add("tag", "Add (+tag) or remove (-tag) tags on an entry.", func=cmd_tag)
1590
+ tag.add_argument("ref", metavar="ID")
1591
+ tag.add_argument("words", nargs="+", metavar="+TAG|-TAG",
1592
+ help="+name adds a tag, -name removes one")
1593
+ tag.add_argument("--sheet", metavar="NAME")
1594
+ tag.set_defaults(all_sheets=False)
1595
+
1596
+ untag = add("untag", "Remove tags from an entry.", func=cmd_untag)
1597
+ untag.add_argument("ref", metavar="ID")
1598
+ untag.add_argument("words", nargs="+", metavar="TAG")
1599
+ untag.add_argument("--sheet", metavar="NAME")
1600
+ untag.set_defaults(all_sheets=False)
1601
+
1602
+ rename = add("rename", "Rename a project, tag or sheet everywhere.", func=cmd_rename)
1603
+ rename.add_argument("kind", choices=["project", "tag", "sheet"])
1604
+ rename.add_argument("old")
1605
+ rename.add_argument("new")
1606
+
1607
+ move = add("move", "Move an entry to a new start time, keeping its length.",
1608
+ parents=[sheet_opts], func=cmd_move)
1609
+ move.add_argument("ref", metavar="ID")
1610
+ move.add_argument("time", metavar="TIME")
1611
+
1612
+ for name, help_text in (("lengthen", "Make an entry longer."), ("shorten", "Make an entry shorter.")):
1613
+ resize = add(name, help_text, parents=[sheet_opts], func=cmd_resize)
1614
+ resize.add_argument("ref", metavar="ID")
1615
+ resize.add_argument("duration", metavar="DURATION")
1616
+ resize.add_argument("--start-side", action="store_true",
1617
+ help="move the start instead of the end")
1618
+
1619
+ fill = add("fill", "Expand an entry until it touches its neighbours.",
1620
+ parents=[sheet_opts], func=cmd_fill)
1621
+ fill.add_argument("ref", metavar="ID")
1622
+
1623
+ join = add("join", "Merge two or more entries into one.", parents=[sheet_opts], func=cmd_join)
1624
+ join.add_argument("refs", nargs="+", metavar="ID")
1625
+
1626
+ split = add("split", "Split an entry in two, or into N equal pieces.",
1627
+ parents=[sheet_opts], func=cmd_split)
1628
+ split.add_argument("ref", metavar="ID")
1629
+ split.add_argument("--at", metavar="TIME", help="split at this time")
1630
+ split.add_argument("--into", type=int, default=2, metavar="N", help="split into N equal pieces")
1631
+
1632
+ delete = add("delete", "Delete entries (undoable).", aliases=["rm", "remove", "kill"],
1633
+ parents=[sheet_opts], func=cmd_delete)
1634
+ delete.add_argument("refs", nargs="+", metavar="ID")
1635
+
1636
+ add("undo", "Undo the last change to the timesheet.", func=cmd_undo)
1637
+
1638
+ current_group[0] = "Reporting"
1639
+ # -- reporting -----------------------------------------------------
1640
+ log = add("log", "List entries day by day.", aliases=["display", "list"],
1641
+ parents=[range_opts, filter_opts, sheet_opts, format_opts], func=cmd_log)
1642
+ log.add_argument("--limit", type=int, metavar="N", help="only the last N entries")
1643
+ log.add_argument("--reverse", action="store_true", help="newest first")
1644
+ log.add_argument("--no-notes", action="store_true", help="hide annotations")
1645
+
1646
+ add("summary", "A dense table of entries with daily subtotals.",
1647
+ parents=[range_opts, filter_opts, sheet_opts, format_opts], func=cmd_summary)
1648
+
1649
+ report = add("report", "Totals per project, broken down by tag.",
1650
+ parents=[range_opts, filter_opts, sheet_opts, format_opts], func=cmd_report)
1651
+ report.add_argument("--entries", action="store_true", help="also list the individual entries")
1652
+ report.add_argument("--no-tags", action="store_true", help="skip the tag breakdown")
1653
+
1654
+ chart = add("chart", "Bar charts and a punchcard of when you work.",
1655
+ parents=[range_opts, filter_opts, sheet_opts], func=cmd_chart)
1656
+ chart.add_argument("--by", choices=["day", "project", "tag", "sheet", "punchcard"],
1657
+ default="day", help="what to chart (default: day)")
1658
+
1659
+ gaps = add("gaps", "Show untracked stretches of time.",
1660
+ parents=[range_opts, sheet_opts], func=cmd_gaps)
1661
+ gaps.add_argument("--min", metavar="DURATION", help="ignore gaps shorter than this (default 5m)")
1662
+
1663
+ add("stats", "A quick numeric overview of a range.",
1664
+ parents=[range_opts, filter_opts, sheet_opts], func=cmd_stats)
1665
+
1666
+ projects = add("projects", "List projects with their totals.",
1667
+ parents=[range_opts, filter_opts, sheet_opts], func=cmd_projects)
1668
+ projects.add_argument("-q", "--quiet", action="store_true", help="names only, one per line")
1669
+
1670
+ tags_cmd = add("tags", "List tags with their totals.",
1671
+ parents=[range_opts, filter_opts, sheet_opts], func=cmd_tags)
1672
+ tags_cmd.add_argument("-q", "--quiet", action="store_true", help="names only, one per line")
1673
+
1674
+ export = add("export", "Export entries as JSON, CSV, iCal, ledger or Markdown.",
1675
+ parents=[range_opts, filter_opts, sheet_opts, format_opts], func=cmd_export)
1676
+ export.add_argument("-o", "--output", metavar="FILE", help="write to a file instead of stdout")
1677
+
1678
+ import_cmd = add("import", "Import entries from JSON, JSONL or CSV.", func=cmd_import)
1679
+ import_cmd.add_argument("file", nargs="?", metavar="FILE", help="file to read (default: stdin)")
1680
+ import_cmd.add_argument("--sheet", metavar="NAME", help="put everything on this sheet")
1681
+ import_cmd.set_defaults(all_sheets=False)
1682
+
1683
+ current_group[0] = "Sheets & config"
1684
+ # -- sheets and config ---------------------------------------------
1685
+ sheet_cmd = add("sheet", "Show or switch the active sheet ('-' goes back).", func=cmd_sheet)
1686
+ sheet_cmd.add_argument("name", nargs="?", metavar="NAME")
1687
+
1688
+ add("sheets", "List sheets with their totals.", func=cmd_sheets)
1689
+
1690
+ add("archive", "Move entries to an archived sheet so they stay out of reports.",
1691
+ parents=[range_opts, filter_opts, sheet_opts], func=cmd_archive)
1692
+
1693
+ config_cmd = add("config", "Read or change configuration.", func=cmd_config)
1694
+ config_cmd.add_argument("action", nargs="?", choices=["get", "set", "unset", "list", "edit", "path"],
1695
+ default="list")
1696
+ config_cmd.add_argument("key", nargs="?", metavar="SECTION.OPTION")
1697
+ config_cmd.add_argument("value", nargs="?")
1698
+
1699
+ add("where", "Print the paths klok reads and writes.", aliases=["paths"], func=cmd_where)
1700
+
1701
+ current_group[0] = "Focus & mindfulness"
1702
+ # -- focus ---------------------------------------------------------
1703
+ timer = add("timer", "Run a countdown timer with a notification at the end.",
1704
+ parents=[sheet_opts], func=cmd_timer)
1705
+ timer.add_argument("duration", metavar="DURATION", help="e.g. 25m, 1h30m, 90")
1706
+ timer.add_argument("-m", "--message", metavar="TEXT", help="label shown while counting down")
1707
+ timer.add_argument("--art", choices=sorted(ART), help="ASCII art to sit above the clock")
1708
+ timer.add_argument("--big", action="store_true", help="draw the remaining time in block digits")
1709
+ timer.add_argument("-q", "--quiet", action="store_true", help="no live display")
1710
+ timer.add_argument("-p", "--project", metavar="NAME", help="also record the time against a project")
1711
+ timer.add_argument("-t", "--tag", action="append", metavar="TAG")
1712
+ timer.add_argument("--no-track", action="store_true", help="never record a frame")
1713
+
1714
+ pomo = add("pomodoro", "Run pomodoro rounds and record the focus time.", aliases=["pomo", "focus"],
1715
+ parents=[sheet_opts], func=cmd_pomodoro)
1716
+ pomo.add_argument("words", nargs="*", metavar="PROJECT +TAG")
1717
+ pomo.add_argument("--work", metavar="DURATION")
1718
+ pomo.add_argument("--break", dest="short_break", metavar="DURATION")
1719
+ pomo.add_argument("--long-break", dest="long_break", metavar="DURATION")
1720
+ pomo.add_argument("--rounds", type=int, metavar="N")
1721
+ pomo.add_argument("--no-breaks", action="store_true", help="work rounds only")
1722
+ pomo.add_argument("--no-prompt", action="store_true", help="do not wait for enter between rounds")
1723
+ pomo.add_argument("--no-track", action="store_true", help="do not record frames")
1724
+ pomo.add_argument("--art", choices=sorted(ART))
1725
+ pomo.add_argument("--big", action="store_true")
1726
+ pomo.add_argument("-q", "--quiet", action="store_true")
1727
+
1728
+ # -- mindfulness ---------------------------------------------------
1729
+ breathe_cmd = add("breathe", "Run a guided breathing pacer.", aliases=["breath"],
1730
+ parents=[sheet_opts], func=cmd_breathe)
1731
+ breathe_cmd.add_argument("words", nargs="*", metavar="PATTERN",
1732
+ help="a named pattern (box, calm, relax, ...) or counts like 4-7-8")
1733
+ breathe_cmd.add_argument("--pattern", metavar="NAME|COUNTS", help="same, as a flag")
1734
+ breathe_cmd.add_argument("--rounds", type=int, metavar="N", help="number of breaths")
1735
+ breathe_cmd.add_argument("--for", dest="for_time", metavar="DURATION",
1736
+ help="breathe for this long instead of a round count")
1737
+ breathe_cmd.add_argument("--list", action="store_true", help="list the named patterns")
1738
+ breathe_cmd.add_argument("--plan", action="store_true", help="describe the session without running it")
1739
+ breathe_cmd.add_argument("-q", "--quiet", action="store_true", help="no live display")
1740
+ breathe_cmd.add_argument("-n", "--note", metavar="TEXT")
1741
+ breathe_cmd.add_argument("-t", "--tag", action="append", metavar="TAG")
1742
+ breathe_cmd.add_argument("--no-track", action="store_true", help="do not record the session")
1743
+
1744
+ meditate_cmd = add("meditate", "Sit for a set time, with a bell at the end.",
1745
+ aliases=["sit", "zen"], parents=[sheet_opts], func=cmd_meditate)
1746
+ meditate_cmd.add_argument("duration", nargs="?", metavar="DURATION",
1747
+ help="how long to sit (default: mindful.default_sit)")
1748
+ meditate_cmd.add_argument("--for", dest="for_time", metavar="DURATION")
1749
+ meditate_cmd.add_argument("--interval-bell", metavar="DURATION", default=None,
1750
+ help="ring a bell this often during the sit")
1751
+ meditate_cmd.add_argument("--warmup", metavar="DURATION", default=None,
1752
+ help="a settling bell this far in")
1753
+ meditate_cmd.add_argument("--no-guidance", action="store_true", help="no prompts, just the bells")
1754
+ meditate_cmd.add_argument("--plan", action="store_true", help="describe the sit without running it")
1755
+ meditate_cmd.add_argument("-q", "--quiet", action="store_true")
1756
+ meditate_cmd.add_argument("-n", "--note", metavar="TEXT")
1757
+ meditate_cmd.add_argument("-t", "--tag", action="append", metavar="TAG")
1758
+ meditate_cmd.add_argument("--no-track", action="store_true")
1759
+
1760
+ checkin_cmd = add("checkin", "Note how you are doing on three 1-5 scales.",
1761
+ aliases=["feel"], func=cmd_checkin)
1762
+ checkin_cmd.add_argument("words", nargs="*", metavar="NOTE")
1763
+ checkin_cmd.add_argument("--mood", metavar="1-5")
1764
+ checkin_cmd.add_argument("--energy", metavar="1-5")
1765
+ checkin_cmd.add_argument("--stress", metavar="1-5")
1766
+ checkin_cmd.add_argument("-a", "--at", metavar="TIME")
1767
+ checkin_cmd.add_argument("-t", "--tag", action="append", metavar="TAG")
1768
+ checkin_cmd.add_argument("--delete", metavar="ID", help="remove a check-in")
1769
+
1770
+ mood_cmd = add("mood", "Show check-ins and how the scales have moved.",
1771
+ parents=[range_opts], func=cmd_mood)
1772
+ mood_cmd.add_argument("--json", dest="format", action="store_const", const="json")
1773
+
1774
+ mindful_cmd = add("mindful", "How the practice itself is going.", aliases=["practice"],
1775
+ parents=[range_opts, sheet_opts], func=cmd_mindful)
1776
+ mindful_cmd.add_argument("--no-chart", action="store_true", help="skip the per-day chart")
1777
+
1778
+ current_group[0] = "Other"
1779
+ add("check", "Look for overlaps and other suspicious entries.", aliases=["sanity", "doctor"],
1780
+ func=cmd_check)
1781
+
1782
+ completion = add("completion", "Print a shell completion script.", func=cmd_completion)
1783
+ completion.add_argument("shell", choices=sorted(COMPLETIONS))
1784
+
1785
+ add("version", "Print the version.", func=cmd_version)
1786
+
1787
+ parser.epilog = _build_epilog(groups)
1788
+ parser.command_names = list(subparsers.choices)
1789
+ return parser
1790
+
1791
+
1792
+ def main(argv=None) -> int:
1793
+ argv = list(sys.argv[1:] if argv is None else argv)
1794
+ if not argv:
1795
+ argv = ["status"]
1796
+ parser = build_parser()
1797
+ args = parser.parse_args(_preprocess(argv))
1798
+ if not getattr(args, "func", None):
1799
+ parser.print_help()
1800
+ return 1
1801
+ try:
1802
+ ctx = Context(args)
1803
+ return args.func(ctx) or 0
1804
+ except (StoreError, TimeParseError, ValueError, KeyError) as exc:
1805
+ return die(str(exc))
1806
+ except FileNotFoundError as exc:
1807
+ return die("%s" % exc)
1808
+ except KeyboardInterrupt:
1809
+ print()
1810
+ return 130
1811
+ except BrokenPipeError:
1812
+ return 0
1813
+
1814
+
1815
+ if __name__ == "__main__":
1816
+ raise SystemExit(main())