-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathworklog.py
More file actions
468 lines (396 loc) · 16.7 KB
/
Copy pathworklog.py
File metadata and controls
468 lines (396 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
"""The worklog text format: pure parsing and aggregation engine.
No filesystem access, no GTK -- see store.py for file handling.
Format:
# YYYY-MM-DD [comment]
H:MM (token) comment on opening a task
H:MM comment on continuing the open task
H:MM (*) comment on ending the task
[anything else] -- a free note attached to the current segment
"""
import re
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, date
@dataclass
class Segment:
task: str
start: datetime
end: datetime | None
comment: str
notes: list[str] = field(default_factory=list)
def project(token):
return token.split('-')[0]
DAY_RE = re.compile(r"^#\s*(\d{4})-(\d{2})-(\d{2})")
TIME_RE = re.compile(r"^(\d{1,2}):(\d{2})(?:\s*\(([^)]+)\))?\s*(.*)$")
def day_header(day):
return f"# {day.isoformat()}"
def open_line(time, token, comment=""):
text = f"{time.hour}:{time.minute:02} ({token})"
return f"{text} {comment}" if comment else text
def stop_line(time, comment=""):
text = f"{time.hour}:{time.minute:02} (*)"
return f"{text} {comment}" if comment else text
def note_line(time, text):
if text.startswith('('):
text = '\\' + text
return f"{time.hour}:{time.minute:02} {text}"
def _header_date(match):
return date(*(int(x) for x in match.groups()))
BARE_TIME_RE = re.compile(r"^\d{1,2}:\d{2}\s*$")
def latest_time_for_day(text, day):
"""The datetime of the last valid time-stamped line under day's header."""
latest = None
current = None
for line in text.splitlines():
if m := DAY_RE.match(line):
current = _header_date(m)
elif current == day and (m := TIME_RE.match(line)):
hour, minute = int(m.group(1)), int(m.group(2))
if 0 <= hour < 24 and 0 <= minute < 60:
when = datetime(day.year, day.month, day.day, hour, minute)
if latest is None or when > latest:
latest = when
return latest
def prepare_entry(text, day, now):
"""Ensure day's header exists and append a fresh 'HH:MM ' scaffold line.
Returns (text, cursor_offset) where cursor_offset points just past the
space in the scaffold, so the caller can drop the cursor there for typing.
The scaffold time is now, but never earlier than the last time already
logged today, so a fresh line can never read as going backwards.
"""
lines = text.splitlines()
while lines and not lines[-1].strip():
lines.pop()
if not any(DAY_RE.match(l) and _header_date(DAY_RE.match(l)) == day for l in lines):
if lines:
lines.append("")
lines.append(day_header(day))
when = now.replace(second=0, microsecond=0)
last = latest_time_for_day(text, day)
if last is not None and last > when:
when = last
lines.append(f"{when.hour}:{when.minute:02} ")
new_text = "\n".join(lines) + "\n"
return new_text, len(new_text) - 1
def entry_times(text):
"""Datetimes of every valid timed line under any day header, sorted.
A line whose time lies in the future is a scheduled reminder: the app
watches these and opens the editor when their moment arrives.
"""
times = []
day = None
for line in text.splitlines():
if m := DAY_RE.match(line):
day = _header_date(m)
elif day is not None and (m := TIME_RE.match(line)):
hour, minute = int(m.group(1)), int(m.group(2))
if 0 <= hour < 24 and 0 <= minute < 60:
times.append(datetime(day.year, day.month, day.day, hour, minute))
return sorted(times)
def locate_entry(text, when):
"""Char offset at the end of the first timed line matching when's date and
H:MM, or None -- so the editor can drop the cursor on the entry a fired
reminder refers to."""
day = None
offset = 0
for line in text.split("\n"):
if m := DAY_RE.match(line):
day = _header_date(m)
elif day == when.date() and (m := TIME_RE.match(line)):
if int(m.group(1)) == when.hour and int(m.group(2)) == when.minute:
return offset + len(line)
offset += len(line) + 1
return None
def day_intervals(journal, day, now):
"""Task-time spans on `day` as (start, end, running, task) tuples, ordered
by start. The open segment, if it belongs to `day`, is closed at `now` and
flagged running so a caller can render it distinctly."""
spans = [(s.start, s.end, False, s.task) for s in journal.segments
if s.start.date() == day and s.end is not None]
open_seg = journal.open_segment
if open_seg and open_seg.start.date() == day:
spans.append((open_seg.start, now, True, open_seg.task))
return sorted(spans)
def strip_trailing_scaffold(text):
"""Drop an unused scaffold the user opened but never filled in: trailing
blank and bare-timestamp lines, plus a now-empty day header left behind,
then normalise the trailing newline."""
lines = text.split("\n")
while lines and (not lines[-1].strip() or BARE_TIME_RE.match(lines[-1]) or DAY_RE.match(lines[-1])):
lines.pop()
return "\n".join(lines) + "\n" if lines else ""
class Journal:
def __init__(self, segments, open_segment, problems):
self.segments = segments
self.open_segment = open_segment
self.problems = problems
def current(self):
return self.open_segment.task if self.open_segment else None
def recent_tasks(self, limit=None):
by_start = sorted(self.segments, key=lambda s: s.start, reverse=True)
if self.open_segment:
by_start = [self.open_segment, *by_start]
seen = dict.fromkeys(s.task for s in by_start)
return list(seen)[:limit]
def by_task(self):
groups = defaultdict(list)
for s in self.segments:
groups[s.task].append(s)
return groups
def by_day(self):
groups = defaultdict(list)
for s in self.segments:
groups[s.start.date()].append(s)
return groups
def total_minutes(self, segments=None):
segments = self.segments if segments is None else segments
return sum((s.end - s.start).total_seconds() // 60 for s in segments)
def parse(text):
lines = text.splitlines() if isinstance(text, str) else list(text)
segments = []
problems = []
day = None
open_seg = None
unclosed_days = []
def close(end):
nonlocal open_seg
if open_seg is not None:
if open_seg.task != '*':
segments.append(Segment(open_seg.task, open_seg.start, end, open_seg.comment, open_seg.notes))
open_seg = None
for lineno, line in enumerate(lines, start=1):
if m := DAY_RE.match(line):
if open_seg is not None:
unclosed_days.append((day, lineno, open_seg))
open_seg = None
year, month, daynum = (int(x) for x in m.groups())
try:
day = date(year, month, daynum)
except ValueError as e:
problems.append((lineno, f"invalid date: {line!r}: {e}"))
day = None
continue
if m := TIME_RE.match(line):
hour, minute, token, comment = m.groups()
hour, minute = int(hour), int(minute)
if not (0 <= hour < 24 and 0 <= minute < 60):
problems.append((lineno, f"invalid time: {line!r}"))
continue
if day is None:
problems.append((lineno, f"time entry before any day header: {line!r}"))
continue
when = datetime(day.year, day.month, day.day, hour, minute)
if token is not None and not token.strip():
problems.append((lineno, f"empty task token: {line!r}"))
continue
token = token.strip() if token else token
if token:
if open_seg is not None and when < open_seg.start:
problems.append((lineno, f"time went backwards: {line!r}"))
continue
if token == '*' and open_seg is not None and comment.strip():
open_seg.notes.append(comment.strip())
close(when)
if token != '*':
open_seg = Segment(token, when, None, comment.strip())
elif open_seg is not None:
if when < open_seg.start:
problems.append((lineno, f"time went backwards: {line!r}"))
continue
note = comment.strip()
if note.startswith('\\('):
note = note[1:]
if note:
open_seg.notes.append(note)
for bad_day, lineno, seg in unclosed_days:
problems.append((lineno, f"day {bad_day} left unclosed by task {seg.task!r}"))
return Journal(segments, open_seg, problems)
def test():
import sys
text = (
"# 2026-03-01\n"
"9:00 (proj-1) morning\n"
"9:30 (proj-2) switch\n"
"10:00 (*)\n"
"# 2026-03-02\n"
"8:00 (proj-3) next day\n"
"8:45 (proj-1) back to one\n"
"9:15 (*)\n"
)
journal = parse(text)
if journal.problems:
print(f"FAIL: unexpected problems parsing a clean worklog: {journal.problems}")
sys.exit(1)
if journal.current() is not None or journal.open_segment is not None:
print("FAIL: a worklog whose last day is closed should not be running")
sys.exit(1)
recent = journal.recent_tasks(20)
if len(recent) != len(set(recent)):
print("FAIL: recent_tasks returned duplicates")
sys.exit(1)
starts = {s.task: s.start for s in journal.segments}
for i in range(len(recent) - 1):
if starts[recent[i]] < starts[recent[i + 1]]:
print("FAIL: recent_tasks not ordered most-recent-first")
sys.exit(1)
j = parse("# 2026-03-01\n9:00 (proj-1) doing stuff\n")
if j.current() != "proj-1" or j.open_segment is None or j.problems:
print("FAIL: (a) latest open day should be current")
sys.exit(1)
j = parse(
"# 2026-03-01\n"
"9:00 (proj-1) doing stuff\n"
"# 2026-03-02\n"
"9:00 (proj-2) other stuff\n"
"10:00 (*)\n"
)
if not j.problems:
print("FAIL: (b) earlier unclosed day should be a problem")
sys.exit(1)
if j.current() is not None or j.open_segment is not None:
print("FAIL: (b) latest day is closed, should not be running")
sys.exit(1)
if [s.task for s in j.segments] != ["proj-2"]:
print(f"FAIL: (b) expected only proj-2 counted, got {[s.task for s in j.segments]}")
sys.exit(1)
j = parse(
"# 2026-03-01\n"
"10:00 (proj-1) start\n"
"9:00 (proj-2) backwards\n"
)
if not j.problems:
print("FAIL: (c) backwards time should be a problem")
sys.exit(1)
if j.current() != "proj-1":
print("FAIL: (c) earlier good segment should survive as open segment")
sys.exit(1)
j = parse(
"# 2026-03-01\n"
"9:00 (proj-1) work\n"
"9:30 (*)\n"
"9:45 (proj-2) more work\n"
)
if j.problems:
print(f"FAIL: (d) unexpected problems: {j.problems}")
sys.exit(1)
if any(s.task == '*' for s in j.segments):
print("FAIL: (d) idle stretch should not be counted")
sys.exit(1)
first = [s for s in j.segments if s.task == 'proj-1']
if len(first) != 1 or first[0].end.minute != 30:
print("FAIL: (d) proj-1 should end at 9:30, idle stretch excluded")
sys.exit(1)
j = parse(
"# 2026-03-01\n"
"9:00 (proj-1) work\n"
"9:15 a continuation note\n"
)
if j.segments:
print("FAIL: (e) continuation note should not close the open segment")
sys.exit(1)
if j.open_segment is None or j.open_segment.notes != ["a continuation note"]:
print(f"FAIL: (e) note should land in open segment notes, got {j.open_segment}")
sys.exit(1)
j = parse(
"# 2026-03-15\n"
+ open_line(datetime(2026, 3, 15, 8, 5), "proj-1", "x") + "\n"
+ note_line(datetime(2026, 3, 15, 8, 5), "(paren) note") + "\n"
)
if j.current() != "proj-1" or j.open_segment.notes != ["(paren) note"]:
print(f"FAIL: (f) note starting with parenthesized word did not round-trip, got {j.open_segment}")
sys.exit(1)
j = parse(
"# 2026-03-15\n"
+ open_line(datetime(2026, 3, 15, 8, 5), "proj-1", "x") + "\n"
+ note_line(datetime(2026, 3, 15, 8, 6), "(*) note") + "\n"
)
if j.current() != "proj-1" or j.open_segment is None:
print("FAIL: (f) note starting with (*) should not stop the running task")
sys.exit(1)
j = parse("# 2026-03-01\n9:00 ( ) huh\n10:00 (*)\n")
if not j.problems or j.by_task():
print("FAIL: (g) empty task token should be a problem, not a zero-name task")
sys.exit(1)
j = parse(
"# 2026-03-01\n"
+ open_line(datetime(2026, 3, 1, 9, 0), "proj-1") + "\n"
+ stop_line(datetime(2026, 3, 1, 10, 0), "wrapped up") + "\n"
)
if len(j.segments) != 1 or j.segments[0].notes != ["wrapped up"]:
print(f"FAIL: (h) stop with a comment should attach it as a note, got {j.segments}")
sys.exit(1)
day = date(2026, 3, 15)
now = datetime(2026, 3, 15, 8, 5)
new_text, offset = prepare_entry("", day, now)
if new_text != "# 2026-03-15\n8:05 \n":
print(f"FAIL: (i) empty prepare_entry should seed header and scaffold, got {new_text!r}")
sys.exit(1)
if new_text[offset - 1] != " " or new_text[offset] != "\n":
print(f"FAIL: (i) cursor offset should sit past the scaffold space, got {offset}")
sys.exit(1)
existing = "# 2026-03-15\n9:00 (proj-1) work\n"
new_text, _ = prepare_entry(existing, day, datetime(2026, 3, 15, 9, 30))
if new_text != existing + "9:30 \n":
print(f"FAIL: (j) prepare_entry should append under an existing header, got {new_text!r}")
sys.exit(1)
# Clock behind the last logged entry: the scaffold must not go backwards.
new_text, _ = prepare_entry(existing, day, datetime(2026, 3, 15, 8, 0))
if new_text != existing + "9:00 \n":
print(f"FAIL: (k) scaffold should clamp to the last logged time, got {new_text!r}")
sys.exit(1)
# A different day already present: prepare_entry starts a new dated section.
prior = "# 2026-03-14\n9:00 (proj-1) work\n10:00 (*)\n"
new_text, _ = prepare_entry(prior, day, now)
if new_text != prior + "\n# 2026-03-15\n8:05 \n":
print(f"FAIL: (l) prepare_entry should open a new day section, got {new_text!r}")
sys.exit(1)
if strip_trailing_scaffold(existing + "9:30 \n\n") != existing:
print("FAIL: (m) strip_trailing_scaffold should drop an unused scaffold")
sys.exit(1)
if strip_trailing_scaffold(existing + "9:30 (proj-2) real\n") != existing + "9:30 (proj-2) real\n":
print("FAIL: (m) strip_trailing_scaffold should keep a filled-in entry")
sys.exit(1)
j = parse(
"# 2026-03-15\n"
"9:00 (proj-1) work\n"
"9:30 (*)\n"
"10:00 (proj-2) more\n"
)
spans = day_intervals(j, date(2026, 3, 15), datetime(2026, 3, 15, 10, 20))
if spans != [
(datetime(2026, 3, 15, 9, 0), datetime(2026, 3, 15, 9, 30), False, "proj-1"),
(datetime(2026, 3, 15, 10, 0), datetime(2026, 3, 15, 10, 20), True, "proj-2"),
]:
print(f"FAIL: (n) day_intervals should close the open span at now, got {spans}")
sys.exit(1)
if day_intervals(j, date(2026, 3, 14), datetime(2026, 3, 15, 10, 20)) != []:
print("FAIL: (n) day_intervals should ignore other days")
sys.exit(1)
reminder_text = (
"# 2026-03-15\n"
"9:00 (proj-1) work\n"
"15:00 zavolat Petrovi\n"
"# 2026-03-16\n"
"8:30 (proj-2) standup\n"
)
times = entry_times(reminder_text)
if times != [
datetime(2026, 3, 15, 9, 0),
datetime(2026, 3, 15, 15, 0),
datetime(2026, 3, 16, 8, 30),
]:
print(f"FAIL: (o) entry_times should list every timed line sorted, got {times}")
sys.exit(1)
offset = locate_entry(reminder_text, datetime(2026, 3, 15, 15, 0))
if reminder_text[offset - len("15:00 zavolat Petrovi"):offset] != "15:00 zavolat Petrovi":
print(f"FAIL: (o) locate_entry should point past the matching line, got {offset}")
sys.exit(1)
if locate_entry(reminder_text, datetime(2026, 3, 16, 8, 30)) is None:
print("FAIL: (o) locate_entry should find an entry under a later day header")
sys.exit(1)
if locate_entry(reminder_text, datetime(2026, 3, 15, 12, 0)) is not None:
print("FAIL: (o) locate_entry should return None for a time with no line")
sys.exit(1)
print("PASS")
if __name__ == "__main__":
test()