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/report.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Aggregation and the human-readable renderings built on top of it."""
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict, defaultdict
|
|
4
|
+
from datetime import date, datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from klok.timeparse import (format_datetime, format_duration, format_time,
|
|
7
|
+
round_duration, week_bounds)
|
|
8
|
+
from klok.utils import Term, bar, render_table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Renderer:
|
|
12
|
+
"""Shared presentation settings for every report style."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, term: Term, now: datetime, time_format: str = "24",
|
|
15
|
+
round_minutes: int = 0, week_start: str = "monday",
|
|
16
|
+
duration_style: str = "short"):
|
|
17
|
+
self.term = term
|
|
18
|
+
self.now = now
|
|
19
|
+
self.time_format = time_format
|
|
20
|
+
self.round_minutes = round_minutes
|
|
21
|
+
self.week_start = week_start
|
|
22
|
+
self.duration_style = duration_style
|
|
23
|
+
|
|
24
|
+
# -- helpers -------------------------------------------------------
|
|
25
|
+
def duration_of(self, frame) -> timedelta:
|
|
26
|
+
return round_duration(frame.duration(self.now), self.round_minutes)
|
|
27
|
+
|
|
28
|
+
def fmt_duration(self, delta: timedelta) -> str:
|
|
29
|
+
return format_duration(delta, self.duration_style)
|
|
30
|
+
|
|
31
|
+
def fmt_time(self, value: datetime) -> str:
|
|
32
|
+
return format_time(value, self.time_format)
|
|
33
|
+
|
|
34
|
+
def project_cell(self, frame) -> str:
|
|
35
|
+
name = frame.project or "(no project)"
|
|
36
|
+
return self.term.paint(name, self.term.project_color(frame.project))
|
|
37
|
+
|
|
38
|
+
def tag_cell(self, frame) -> str:
|
|
39
|
+
if not frame.tags:
|
|
40
|
+
return ""
|
|
41
|
+
return self.term.paint(" ".join("+" + tag for tag in frame.tags), "blue")
|
|
42
|
+
|
|
43
|
+
def clipped(self, frame, start: datetime, end: datetime) -> timedelta:
|
|
44
|
+
"""Duration of the part of ``frame`` that falls inside a range."""
|
|
45
|
+
frame_end = frame.end_or(self.now)
|
|
46
|
+
span = min(frame_end, end) - max(frame.start, start)
|
|
47
|
+
if span.total_seconds() <= 0:
|
|
48
|
+
return timedelta(0)
|
|
49
|
+
return round_duration(span, self.round_minutes)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------- aggregation
|
|
53
|
+
def total(frames, renderer: Renderer) -> timedelta:
|
|
54
|
+
return sum((renderer.duration_of(frame) for frame in frames), timedelta(0))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def group_by(frames, key, renderer: Renderer) -> "OrderedDict[str, timedelta]":
|
|
58
|
+
buckets = defaultdict(timedelta)
|
|
59
|
+
for frame in frames:
|
|
60
|
+
for value in key(frame):
|
|
61
|
+
buckets[value] += renderer.duration_of(frame)
|
|
62
|
+
return OrderedDict(sorted(buckets.items(), key=lambda item: (-item[1], item[0])))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def by_project(frames, renderer: Renderer):
|
|
66
|
+
return group_by(frames, lambda frame: [frame.project or "(no project)"], renderer)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def by_tag(frames, renderer: Renderer):
|
|
70
|
+
return group_by(frames, lambda frame: frame.tags or ["(untagged)"], renderer)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def by_day(frames, renderer: Renderer) -> "OrderedDict[date, timedelta]":
|
|
74
|
+
buckets = defaultdict(timedelta)
|
|
75
|
+
for frame in frames:
|
|
76
|
+
buckets[frame.start.date()] += renderer.duration_of(frame)
|
|
77
|
+
return OrderedDict(sorted(buckets.items()))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def by_sheet(frames, renderer: Renderer):
|
|
81
|
+
return group_by(frames, lambda frame: [frame.sheet], renderer)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------- renderers
|
|
85
|
+
def render_log(frames, renderer: Renderer, show_notes: bool = True) -> str:
|
|
86
|
+
"""Chronological entries grouped by day, newest day last."""
|
|
87
|
+
if not frames:
|
|
88
|
+
return "No time tracked in this range."
|
|
89
|
+
days = defaultdict(list)
|
|
90
|
+
for frame in frames:
|
|
91
|
+
days[frame.start.date()].append(frame)
|
|
92
|
+
|
|
93
|
+
blocks = []
|
|
94
|
+
for day in sorted(days):
|
|
95
|
+
entries = sorted(days[day], key=lambda frame: frame.start)
|
|
96
|
+
day_total = sum((renderer.duration_of(frame) for frame in entries), timedelta(0))
|
|
97
|
+
heading = "%s %s" % (day.strftime("%a %d %B %Y"), renderer.fmt_duration(day_total))
|
|
98
|
+
rows, aligns = [], ["left", "left", "left", "left", "right"]
|
|
99
|
+
for frame in entries:
|
|
100
|
+
when = "%s to %s" % (renderer.fmt_time(frame.start),
|
|
101
|
+
renderer.fmt_time(frame.stop) if frame.stop else "now")
|
|
102
|
+
note = frame.note if show_notes else ""
|
|
103
|
+
rows.append([
|
|
104
|
+
renderer.term.paint(frame.id, "grey"),
|
|
105
|
+
when,
|
|
106
|
+
renderer.project_cell(frame),
|
|
107
|
+
" ".join(filter(None, [renderer.tag_cell(frame),
|
|
108
|
+
renderer.term.paint(note, "dim") if note else ""])),
|
|
109
|
+
renderer.fmt_duration(renderer.duration_of(frame)),
|
|
110
|
+
])
|
|
111
|
+
blocks.append(renderer.term.paint(heading, "bold") + "\n" +
|
|
112
|
+
render_table(rows, aligns=aligns, indent=" "))
|
|
113
|
+
grand = sum((renderer.duration_of(frame) for frame in frames), timedelta(0))
|
|
114
|
+
blocks.append(renderer.term.paint("Total: %s" % renderer.fmt_duration(grand), "bold"))
|
|
115
|
+
return "\n\n".join(blocks)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def render_summary(frames, renderer: Renderer, start: datetime = None, end: datetime = None) -> str:
|
|
119
|
+
"""A dense table with per-day subtotals, in the spirit of Timewarrior."""
|
|
120
|
+
if not frames:
|
|
121
|
+
return "No time tracked in this range."
|
|
122
|
+
headers = ["Wk", "Date", "Day", "ID", "Project", "Tags", "Start", "End", "Time", "Total"]
|
|
123
|
+
aligns = ["left", "left", "left", "left", "left", "left", "left", "left", "right", "right"]
|
|
124
|
+
rows = []
|
|
125
|
+
days = defaultdict(list)
|
|
126
|
+
for frame in frames:
|
|
127
|
+
days[frame.start.date()].append(frame)
|
|
128
|
+
|
|
129
|
+
grand = timedelta(0)
|
|
130
|
+
for day in sorted(days):
|
|
131
|
+
entries = sorted(days[day], key=lambda frame: frame.start)
|
|
132
|
+
day_total = timedelta(0)
|
|
133
|
+
week = "W%02d" % day.isocalendar()[1]
|
|
134
|
+
for index, frame in enumerate(entries):
|
|
135
|
+
length = renderer.duration_of(frame)
|
|
136
|
+
day_total += length
|
|
137
|
+
rows.append([
|
|
138
|
+
week if index == 0 else "",
|
|
139
|
+
day.isoformat() if index == 0 else "",
|
|
140
|
+
day.strftime("%a") if index == 0 else "",
|
|
141
|
+
renderer.term.paint(frame.id, "grey"),
|
|
142
|
+
renderer.project_cell(frame),
|
|
143
|
+
renderer.tag_cell(frame),
|
|
144
|
+
renderer.fmt_time(frame.start),
|
|
145
|
+
renderer.fmt_time(frame.stop) if frame.stop else "now",
|
|
146
|
+
renderer.fmt_duration(length),
|
|
147
|
+
"",
|
|
148
|
+
])
|
|
149
|
+
rows[-1][-1] = renderer.term.paint(renderer.fmt_duration(day_total), "bold")
|
|
150
|
+
grand += day_total
|
|
151
|
+
table = render_table(rows, headers=headers, aligns=aligns, term=renderer.term)
|
|
152
|
+
return "%s\n\n%s" % (table, renderer.term.paint("Total: %s" % renderer.fmt_duration(grand), "bold"))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def render_report(frames, renderer: Renderer, start: datetime, end: datetime,
|
|
156
|
+
show_tags: bool = True, show_entries: bool = False) -> str:
|
|
157
|
+
"""Totals per project, with an optional tag and entry breakdown."""
|
|
158
|
+
if not frames:
|
|
159
|
+
return "No time tracked in this range."
|
|
160
|
+
header = "%s -> %s" % (start.strftime("%a %d %B %Y"), (end - timedelta(seconds=1)).strftime("%a %d %B %Y"))
|
|
161
|
+
lines = [renderer.term.paint(header, "bold"), ""]
|
|
162
|
+
|
|
163
|
+
grouped = defaultdict(list)
|
|
164
|
+
for frame in frames:
|
|
165
|
+
grouped[frame.project or "(no project)"].append(frame)
|
|
166
|
+
|
|
167
|
+
ordered = sorted(grouped.items(),
|
|
168
|
+
key=lambda item: -sum((renderer.duration_of(f) for f in item[1]), timedelta(0)))
|
|
169
|
+
grand = timedelta(0)
|
|
170
|
+
for project, entries in ordered:
|
|
171
|
+
project_total = sum((renderer.duration_of(frame) for frame in entries), timedelta(0))
|
|
172
|
+
grand += project_total
|
|
173
|
+
colour = renderer.term.project_color(entries[0].project)
|
|
174
|
+
lines.append("%s - %s" % (renderer.term.paint(project, colour, "bold"),
|
|
175
|
+
renderer.fmt_duration(project_total)))
|
|
176
|
+
if show_tags:
|
|
177
|
+
tag_totals = by_tag(entries, renderer)
|
|
178
|
+
rows = [[renderer.term.paint("+" + name if name != "(untagged)" else name, "blue"),
|
|
179
|
+
renderer.fmt_duration(length)]
|
|
180
|
+
for name, length in tag_totals.items()]
|
|
181
|
+
if rows:
|
|
182
|
+
lines.append(render_table(rows, aligns=["left", "right"], indent=" "))
|
|
183
|
+
if show_entries:
|
|
184
|
+
rows = [[renderer.term.paint(frame.id, "grey"),
|
|
185
|
+
format_datetime(frame.start, renderer.time_format),
|
|
186
|
+
renderer.fmt_duration(renderer.duration_of(frame)),
|
|
187
|
+
renderer.term.paint(frame.note, "dim")]
|
|
188
|
+
for frame in sorted(entries, key=lambda f: f.start)]
|
|
189
|
+
lines.append(render_table(rows, aligns=["left", "left", "right", "left"], indent=" "))
|
|
190
|
+
lines.append("")
|
|
191
|
+
lines.append(renderer.term.paint("Total: %s" % renderer.fmt_duration(grand), "bold"))
|
|
192
|
+
return "\n".join(lines)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def render_totals(totals, renderer: Renderer, title: str = "", width: int = 28) -> str:
|
|
196
|
+
"""A labelled bar chart for any ``label -> duration`` mapping."""
|
|
197
|
+
if not totals:
|
|
198
|
+
return "No time tracked in this range."
|
|
199
|
+
biggest = max(totals.values())
|
|
200
|
+
grand = sum(totals.values(), timedelta(0))
|
|
201
|
+
rows = []
|
|
202
|
+
for label, length in totals.items():
|
|
203
|
+
share = (length.total_seconds() / grand.total_seconds() * 100) if grand else 0
|
|
204
|
+
rows.append([
|
|
205
|
+
str(label),
|
|
206
|
+
renderer.term.paint(bar(length.total_seconds(), biggest.total_seconds(), width), "cyan"),
|
|
207
|
+
renderer.fmt_duration(length),
|
|
208
|
+
"%5.1f%%" % share,
|
|
209
|
+
])
|
|
210
|
+
table = render_table(rows, aligns=["left", "left", "right", "right"])
|
|
211
|
+
head = renderer.term.paint(title, "bold") + "\n" if title else ""
|
|
212
|
+
return "%s%s\n\n%s" % (head, table, renderer.term.paint("Total: %s" % renderer.fmt_duration(grand), "bold"))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def render_day_chart(frames, renderer: Renderer, start: datetime, end: datetime, width: int = 34) -> str:
|
|
216
|
+
"""One bar per calendar day across the whole range, including empty days."""
|
|
217
|
+
totals = OrderedDict()
|
|
218
|
+
cursor = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
219
|
+
limit = end
|
|
220
|
+
while cursor < limit:
|
|
221
|
+
next_day = cursor + timedelta(days=1)
|
|
222
|
+
amount = sum((renderer.clipped(frame, cursor, next_day) for frame in frames), timedelta(0))
|
|
223
|
+
totals[cursor.date()] = amount
|
|
224
|
+
cursor = next_day
|
|
225
|
+
if len(totals) > 400:
|
|
226
|
+
break
|
|
227
|
+
if not totals:
|
|
228
|
+
return "No time tracked in this range."
|
|
229
|
+
biggest = max(totals.values()) or timedelta(seconds=1)
|
|
230
|
+
rows = []
|
|
231
|
+
for day, amount in totals.items():
|
|
232
|
+
label = "%s %s" % (day.isoformat(), day.strftime("%a"))
|
|
233
|
+
weekend = day.weekday() >= 5
|
|
234
|
+
rows.append([
|
|
235
|
+
renderer.term.paint(label, "grey") if weekend else label,
|
|
236
|
+
renderer.term.paint(bar(amount.total_seconds(), biggest.total_seconds(), width), "cyan"),
|
|
237
|
+
renderer.fmt_duration(amount) if amount else renderer.term.paint("-", "grey"),
|
|
238
|
+
])
|
|
239
|
+
grand = sum(totals.values(), timedelta(0))
|
|
240
|
+
tracked_days = len([value for value in totals.values() if value])
|
|
241
|
+
average = grand / tracked_days if tracked_days else timedelta(0)
|
|
242
|
+
footer = "Total: %s over %d day%s (avg %s per tracked day)" % (
|
|
243
|
+
renderer.fmt_duration(grand), tracked_days, "" if tracked_days == 1 else "s",
|
|
244
|
+
renderer.fmt_duration(average))
|
|
245
|
+
return "%s\n\n%s" % (render_table(rows, aligns=["left", "left", "right"]),
|
|
246
|
+
renderer.term.paint(footer, "bold"))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def render_punchcard(frames, renderer: Renderer) -> str:
|
|
250
|
+
"""A weekday x hour heat map of when the work actually happens."""
|
|
251
|
+
grid = [[timedelta(0)] * 24 for _ in range(7)]
|
|
252
|
+
for frame in frames:
|
|
253
|
+
cursor = frame.start
|
|
254
|
+
end = frame.end_or(renderer.now)
|
|
255
|
+
while cursor < end:
|
|
256
|
+
slot_end = min(end, (cursor + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0))
|
|
257
|
+
if slot_end <= cursor:
|
|
258
|
+
slot_end = cursor + timedelta(hours=1)
|
|
259
|
+
grid[cursor.weekday()][cursor.hour] += min(slot_end, end) - cursor
|
|
260
|
+
cursor = slot_end
|
|
261
|
+
peak = max((cell for row in grid for cell in row), default=timedelta(0))
|
|
262
|
+
if not peak:
|
|
263
|
+
return "No time tracked in this range."
|
|
264
|
+
shades = " .:-=+*#%@"
|
|
265
|
+
names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
266
|
+
ruler = [" "] * 24
|
|
267
|
+
for hour in range(0, 24, 3):
|
|
268
|
+
for offset, char in enumerate(str(hour)):
|
|
269
|
+
if hour + offset < 24:
|
|
270
|
+
ruler[hour + offset] = char
|
|
271
|
+
lines = [renderer.term.paint(" " + "".join(ruler) + " Total", "bold")]
|
|
272
|
+
for index, row in enumerate(grid):
|
|
273
|
+
cells = []
|
|
274
|
+
for cell in row:
|
|
275
|
+
level = int(round((cell.total_seconds() / peak.total_seconds()) * (len(shades) - 1)))
|
|
276
|
+
cells.append(shades[level])
|
|
277
|
+
day_total = sum(row, timedelta(0))
|
|
278
|
+
lines.append("%-4s %s %s" % (names[index], renderer.term.paint("".join(cells), "cyan"),
|
|
279
|
+
renderer.fmt_duration(day_total) if day_total else ""))
|
|
280
|
+
lines.append("")
|
|
281
|
+
lines.append(renderer.term.paint(
|
|
282
|
+
"Each column is an hour of the day; darker means more time. Busiest hour holds %s."
|
|
283
|
+
% renderer.fmt_duration(peak), "dim"))
|
|
284
|
+
return "\n".join(lines)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def render_gaps(gaps, renderer: Renderer) -> str:
|
|
288
|
+
if not gaps:
|
|
289
|
+
return "No untracked gaps in this range."
|
|
290
|
+
rows = []
|
|
291
|
+
total_gap = timedelta(0)
|
|
292
|
+
for start, end in gaps:
|
|
293
|
+
length = end - start
|
|
294
|
+
total_gap += length
|
|
295
|
+
rows.append([
|
|
296
|
+
start.strftime("%Y-%m-%d %a"),
|
|
297
|
+
renderer.fmt_time(start),
|
|
298
|
+
renderer.fmt_time(end),
|
|
299
|
+
renderer.fmt_duration(length),
|
|
300
|
+
])
|
|
301
|
+
table = render_table(rows, headers=["Date", "From", "To", "Gap"],
|
|
302
|
+
aligns=["left", "left", "left", "right"], term=renderer.term)
|
|
303
|
+
return "%s\n\n%s" % (table, renderer.term.paint("Untracked: %s" % renderer.fmt_duration(total_gap), "bold"))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def week_label(value: datetime, week_start: str) -> str:
|
|
307
|
+
start, end = week_bounds(value, week_start)
|
|
308
|
+
return "%s - %s" % (start.strftime("%Y-%m-%d"), (end - timedelta(days=1)).strftime("%Y-%m-%d"))
|
klok/storage.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""The plaintext store: one JSON object per line, newest last.
|
|
2
|
+
|
|
3
|
+
The file is meant to survive contact with humans - it is readable, it
|
|
4
|
+
diffs well in git, and a botched hand-edit only ever costs the lines that
|
|
5
|
+
were touched. Every mutation snapshots the previous state so ``klok
|
|
6
|
+
undo`` can walk backwards.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterable, List, Optional
|
|
15
|
+
|
|
16
|
+
from klok.model import DEFAULT_SHEET, Frame
|
|
17
|
+
from klok.timeparse import now_local
|
|
18
|
+
|
|
19
|
+
UNDO_LIMIT = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StoreError(Exception):
|
|
23
|
+
"""Raised for user-visible storage problems (bad ids, locks, corruption)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FileLock:
|
|
27
|
+
"""A tiny cross-platform advisory lock built on exclusive file creation."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: Path, timeout: float = 5.0):
|
|
30
|
+
self.path = Path(str(path) + ".lock")
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
self._fd = None
|
|
33
|
+
|
|
34
|
+
def __enter__(self):
|
|
35
|
+
deadline = time.time() + self.timeout
|
|
36
|
+
while True:
|
|
37
|
+
try:
|
|
38
|
+
self._fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
39
|
+
os.write(self._fd, str(os.getpid()).encode())
|
|
40
|
+
return self
|
|
41
|
+
except FileExistsError:
|
|
42
|
+
if self._stale():
|
|
43
|
+
self.path.unlink(missing_ok=True)
|
|
44
|
+
continue
|
|
45
|
+
if time.time() >= deadline:
|
|
46
|
+
raise StoreError("another klok process is holding %s" % self.path)
|
|
47
|
+
time.sleep(0.05)
|
|
48
|
+
|
|
49
|
+
def _stale(self) -> bool:
|
|
50
|
+
try:
|
|
51
|
+
return time.time() - self.path.stat().st_mtime > 30
|
|
52
|
+
except OSError:
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def __exit__(self, *exc):
|
|
56
|
+
if self._fd is not None:
|
|
57
|
+
os.close(self._fd)
|
|
58
|
+
self._fd = None
|
|
59
|
+
self.path.unlink(missing_ok=True)
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Store:
|
|
64
|
+
def __init__(self, config):
|
|
65
|
+
self.config = config
|
|
66
|
+
self.path = config.frames_path
|
|
67
|
+
self._frames: Optional[List[Frame]] = None
|
|
68
|
+
self._pending_label = None
|
|
69
|
+
|
|
70
|
+
# -- loading / saving ----------------------------------------------
|
|
71
|
+
@property
|
|
72
|
+
def frames(self) -> List[Frame]:
|
|
73
|
+
if self._frames is None:
|
|
74
|
+
self._frames = self._load()
|
|
75
|
+
return self._frames
|
|
76
|
+
|
|
77
|
+
def _load(self) -> List[Frame]:
|
|
78
|
+
if not self.path.exists():
|
|
79
|
+
return []
|
|
80
|
+
frames = []
|
|
81
|
+
with self.path.open("r", encoding="utf-8") as handle:
|
|
82
|
+
for number, line in enumerate(handle, 1):
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if not line or line.startswith("#"):
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
frames.append(Frame.from_dict(json.loads(line)))
|
|
88
|
+
except (ValueError, KeyError) as exc:
|
|
89
|
+
raise StoreError("%s line %d is not a valid frame: %s" % (self.path, number, exc))
|
|
90
|
+
frames.sort(key=lambda frame: frame.start)
|
|
91
|
+
return frames
|
|
92
|
+
|
|
93
|
+
def reload(self) -> None:
|
|
94
|
+
self._frames = None
|
|
95
|
+
|
|
96
|
+
def save(self, label: str = "") -> None:
|
|
97
|
+
"""Write frames back, snapshotting the previous file for undo first."""
|
|
98
|
+
self.config.ensure_home()
|
|
99
|
+
with FileLock(self.path):
|
|
100
|
+
self._snapshot(label or self._pending_label or "change")
|
|
101
|
+
tmp = self.path.with_suffix(".jsonl.tmp")
|
|
102
|
+
with tmp.open("w", encoding="utf-8") as handle:
|
|
103
|
+
for frame in sorted(self.frames, key=lambda item: item.start):
|
|
104
|
+
handle.write(json.dumps(frame.to_dict(), ensure_ascii=False) + "\n")
|
|
105
|
+
os.replace(tmp, self.path)
|
|
106
|
+
self._pending_label = None
|
|
107
|
+
|
|
108
|
+
# -- undo -----------------------------------------------------------
|
|
109
|
+
def _snapshot(self, label: str) -> None:
|
|
110
|
+
previous = []
|
|
111
|
+
if self.path.exists():
|
|
112
|
+
with self.path.open("r", encoding="utf-8") as handle:
|
|
113
|
+
previous = [line.rstrip("\n") for line in handle if line.strip()]
|
|
114
|
+
entry = {"at": now_local().isoformat(), "label": label, "lines": previous}
|
|
115
|
+
undo_path = self.config.undo_path
|
|
116
|
+
history = self._read_undo()
|
|
117
|
+
history.append(entry)
|
|
118
|
+
history = history[-UNDO_LIMIT:]
|
|
119
|
+
tmp = undo_path.with_suffix(".jsonl.tmp")
|
|
120
|
+
with tmp.open("w", encoding="utf-8") as handle:
|
|
121
|
+
for item in history:
|
|
122
|
+
handle.write(json.dumps(item, ensure_ascii=False) + "\n")
|
|
123
|
+
os.replace(tmp, undo_path)
|
|
124
|
+
|
|
125
|
+
def _read_undo(self) -> List[dict]:
|
|
126
|
+
path = self.config.undo_path
|
|
127
|
+
if not path.exists():
|
|
128
|
+
return []
|
|
129
|
+
entries = []
|
|
130
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
131
|
+
for line in handle:
|
|
132
|
+
line = line.strip()
|
|
133
|
+
if line:
|
|
134
|
+
try:
|
|
135
|
+
entries.append(json.loads(line))
|
|
136
|
+
except ValueError:
|
|
137
|
+
continue
|
|
138
|
+
return entries
|
|
139
|
+
|
|
140
|
+
def undo(self) -> Optional[str]:
|
|
141
|
+
"""Restore the previous state. Returns the label that was undone."""
|
|
142
|
+
history = self._read_undo()
|
|
143
|
+
if not history:
|
|
144
|
+
return None
|
|
145
|
+
entry = history.pop()
|
|
146
|
+
self.config.ensure_home()
|
|
147
|
+
with FileLock(self.path):
|
|
148
|
+
tmp = self.path.with_suffix(".jsonl.tmp")
|
|
149
|
+
with tmp.open("w", encoding="utf-8") as handle:
|
|
150
|
+
for line in entry.get("lines", []):
|
|
151
|
+
handle.write(line + "\n")
|
|
152
|
+
os.replace(tmp, self.path)
|
|
153
|
+
undo_tmp = self.config.undo_path.with_suffix(".jsonl.tmp")
|
|
154
|
+
with undo_tmp.open("w", encoding="utf-8") as handle:
|
|
155
|
+
for item in history:
|
|
156
|
+
handle.write(json.dumps(item, ensure_ascii=False) + "\n")
|
|
157
|
+
os.replace(undo_tmp, self.config.undo_path)
|
|
158
|
+
self.reload()
|
|
159
|
+
return entry.get("label", "change")
|
|
160
|
+
|
|
161
|
+
# -- state (active sheet) -------------------------------------------
|
|
162
|
+
def _state(self) -> dict:
|
|
163
|
+
path = self.config.state_path
|
|
164
|
+
if not path.exists():
|
|
165
|
+
return {}
|
|
166
|
+
try:
|
|
167
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
168
|
+
except ValueError:
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
def _write_state(self, state: dict) -> None:
|
|
172
|
+
self.config.ensure_home()
|
|
173
|
+
tmp = self.config.state_path.with_suffix(".json.tmp")
|
|
174
|
+
tmp.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
|
|
175
|
+
os.replace(tmp, self.config.state_path)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def current_sheet(self) -> str:
|
|
179
|
+
return self._state().get("sheet") or DEFAULT_SHEET
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def last_sheet(self) -> str:
|
|
183
|
+
return self._state().get("last_sheet") or DEFAULT_SHEET
|
|
184
|
+
|
|
185
|
+
def set_sheet(self, name: str) -> str:
|
|
186
|
+
state = self._state()
|
|
187
|
+
previous = state.get("sheet") or DEFAULT_SHEET
|
|
188
|
+
if name == "-":
|
|
189
|
+
name = state.get("last_sheet") or DEFAULT_SHEET
|
|
190
|
+
state["last_sheet"] = previous
|
|
191
|
+
state["sheet"] = name
|
|
192
|
+
self._write_state(state)
|
|
193
|
+
return name
|
|
194
|
+
|
|
195
|
+
def sheets(self) -> List[str]:
|
|
196
|
+
names = {frame.sheet for frame in self.frames}
|
|
197
|
+
names.add(self.current_sheet)
|
|
198
|
+
return sorted(names)
|
|
199
|
+
|
|
200
|
+
# -- mutation --------------------------------------------------------
|
|
201
|
+
def add(self, frame: Frame) -> Frame:
|
|
202
|
+
frame.touch(now_local())
|
|
203
|
+
self.frames.append(frame)
|
|
204
|
+
self._frames.sort(key=lambda item: item.start)
|
|
205
|
+
return frame
|
|
206
|
+
|
|
207
|
+
def remove(self, frame: Frame) -> None:
|
|
208
|
+
self._frames = [item for item in self.frames if item.id != frame.id]
|
|
209
|
+
|
|
210
|
+
def replace(self, frames: Iterable[Frame]) -> None:
|
|
211
|
+
self._frames = sorted(frames, key=lambda item: item.start)
|
|
212
|
+
|
|
213
|
+
# -- queries ---------------------------------------------------------
|
|
214
|
+
def running(self, sheet: str = None) -> Optional[Frame]:
|
|
215
|
+
candidates = [frame for frame in self.frames if frame.running]
|
|
216
|
+
if sheet:
|
|
217
|
+
candidates = [frame for frame in candidates if frame.sheet == sheet]
|
|
218
|
+
return candidates[-1] if candidates else None
|
|
219
|
+
|
|
220
|
+
def any_running(self) -> Optional[Frame]:
|
|
221
|
+
return self.running(None)
|
|
222
|
+
|
|
223
|
+
def last(self, sheet: str = None) -> Optional[Frame]:
|
|
224
|
+
candidates = self.frames
|
|
225
|
+
if sheet:
|
|
226
|
+
candidates = [frame for frame in candidates if frame.sheet == sheet]
|
|
227
|
+
return candidates[-1] if candidates else None
|
|
228
|
+
|
|
229
|
+
def by_id(self, ref: str, sheet: str = None) -> Frame:
|
|
230
|
+
"""Resolve ``@`` (running/last), ``@N`` (Nth newest) or an id prefix."""
|
|
231
|
+
ref = str(ref).strip()
|
|
232
|
+
pool = [frame for frame in self.frames if sheet is None or frame.sheet == sheet]
|
|
233
|
+
if not pool:
|
|
234
|
+
raise StoreError("no entries recorded yet")
|
|
235
|
+
if ref in ("@", "last", "current"):
|
|
236
|
+
return self.running(sheet) or pool[-1]
|
|
237
|
+
if ref.startswith("@") and ref[1:].isdigit():
|
|
238
|
+
index = int(ref[1:])
|
|
239
|
+
if index < 1 or index > len(pool):
|
|
240
|
+
raise StoreError("no entry @%d (only %d recorded)" % (index, len(pool)))
|
|
241
|
+
return list(reversed(pool))[index - 1]
|
|
242
|
+
matches = [frame for frame in pool if frame.id == ref]
|
|
243
|
+
if not matches:
|
|
244
|
+
matches = [frame for frame in pool if frame.id.startswith(ref.lower())]
|
|
245
|
+
if not matches:
|
|
246
|
+
raise StoreError("no entry matching %r" % ref)
|
|
247
|
+
if len(matches) > 1:
|
|
248
|
+
raise StoreError("%r is ambiguous (%s)" % (ref, ", ".join(f.id for f in matches)))
|
|
249
|
+
return matches[0]
|
|
250
|
+
|
|
251
|
+
def select(self, start: datetime = None, end: datetime = None, sheet: str = None,
|
|
252
|
+
projects=None, tags=None, exclude_tags=None, contains: str = None,
|
|
253
|
+
now: datetime = None) -> List[Frame]:
|
|
254
|
+
now = now or now_local()
|
|
255
|
+
results = []
|
|
256
|
+
for frame in self.frames:
|
|
257
|
+
if sheet is not None and frame.sheet != sheet:
|
|
258
|
+
continue
|
|
259
|
+
if start is not None and end is not None and not frame.spans(start, end, now):
|
|
260
|
+
continue
|
|
261
|
+
if projects and not any(_matches(frame.project, pattern) for pattern in projects):
|
|
262
|
+
continue
|
|
263
|
+
if tags and not all(tag.lstrip("+") in frame.tags for tag in tags):
|
|
264
|
+
continue
|
|
265
|
+
if exclude_tags and any(tag.lstrip("-") in frame.tags for tag in exclude_tags):
|
|
266
|
+
continue
|
|
267
|
+
if contains:
|
|
268
|
+
haystack = " ".join([frame.project, frame.note, " ".join(frame.tags)]).lower()
|
|
269
|
+
if contains.lower() not in haystack:
|
|
270
|
+
continue
|
|
271
|
+
results.append(frame)
|
|
272
|
+
return results
|
|
273
|
+
|
|
274
|
+
def projects(self, sheet: str = None) -> List[str]:
|
|
275
|
+
return sorted({frame.project for frame in self.frames
|
|
276
|
+
if frame.project and (sheet is None or frame.sheet == sheet)})
|
|
277
|
+
|
|
278
|
+
def tags(self, sheet: str = None) -> List[str]:
|
|
279
|
+
found = set()
|
|
280
|
+
for frame in self.frames:
|
|
281
|
+
if sheet is None or frame.sheet == sheet:
|
|
282
|
+
found.update(frame.tags)
|
|
283
|
+
return sorted(found)
|
|
284
|
+
|
|
285
|
+
def overlaps(self, now: datetime = None) -> List[tuple]:
|
|
286
|
+
"""Pairs of frames on the same sheet whose intervals intersect."""
|
|
287
|
+
now = now or now_local()
|
|
288
|
+
found = []
|
|
289
|
+
ordered = sorted(self.frames, key=lambda frame: frame.start)
|
|
290
|
+
for index, frame in enumerate(ordered):
|
|
291
|
+
for other in ordered[index + 1:]:
|
|
292
|
+
if other.start >= frame.end_or(now):
|
|
293
|
+
break
|
|
294
|
+
if frame.sheet == other.sheet and frame.overlaps(other, now):
|
|
295
|
+
found.append((frame, other))
|
|
296
|
+
return found
|
|
297
|
+
|
|
298
|
+
def gaps(self, start: datetime, end: datetime, sheet: str = None,
|
|
299
|
+
now: datetime = None, minimum: timedelta = timedelta(minutes=1)) -> List[tuple]:
|
|
300
|
+
"""Untracked stretches inside a range, as ``(start, end)`` pairs."""
|
|
301
|
+
now = now or now_local()
|
|
302
|
+
frames = sorted(self.select(start, end, sheet=sheet, now=now), key=lambda f: f.start)
|
|
303
|
+
cursor = start
|
|
304
|
+
found = []
|
|
305
|
+
for frame in frames:
|
|
306
|
+
if frame.start > cursor and frame.start - cursor >= minimum:
|
|
307
|
+
found.append((cursor, min(frame.start, end)))
|
|
308
|
+
cursor = max(cursor, frame.end_or(now))
|
|
309
|
+
if cursor >= end:
|
|
310
|
+
break
|
|
311
|
+
boundary = min(end, now)
|
|
312
|
+
if cursor < boundary and boundary - cursor >= minimum:
|
|
313
|
+
found.append((cursor, boundary))
|
|
314
|
+
return found
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _matches(value: str, pattern: str) -> bool:
|
|
318
|
+
"""Project filters match exactly, or as a ``parent`` prefix, or by glob."""
|
|
319
|
+
import fnmatch
|
|
320
|
+
|
|
321
|
+
value = value or ""
|
|
322
|
+
pattern = pattern or ""
|
|
323
|
+
if value == pattern:
|
|
324
|
+
return True
|
|
325
|
+
if any(char in pattern for char in "*?["):
|
|
326
|
+
return fnmatch.fnmatch(value, pattern)
|
|
327
|
+
return value.startswith(pattern + ".") or value.startswith(pattern + "/")
|