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/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 + "/")