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/__init__.py +3 -0
- klok/__main__.py +4 -0
- klok/cli.py +1816 -0
- klok/config.py +162 -0
- klok/focus.py +169 -0
- klok/formats.py +169 -0
- klok/mindful.py +434 -0
- klok/model.py +111 -0
- klok/report.py +308 -0
- klok/storage.py +327 -0
- klok/timeparse.py +417 -0
- klok/utils.py +115 -0
- klok_cli-1.0.0.dist-info/METADATA +466 -0
- klok_cli-1.0.0.dist-info/RECORD +18 -0
- klok_cli-1.0.0.dist-info/WHEEL +5 -0
- klok_cli-1.0.0.dist-info/entry_points.txt +2 -0
- klok_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- klok_cli-1.0.0.dist-info/top_level.txt +1 -0
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())
|