pyworklog 0.3.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.
@@ -0,0 +1,651 @@
1
+ """worklog commands: state group."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import sqlite3
9
+ import sys
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ from .. import render
14
+ from ..helpers import (
15
+ _apply_top_limit,
16
+ _fmt_dur,
17
+ _is_brief,
18
+ _log_full,
19
+ _norm_sched,
20
+ _resolve_at_ts,
21
+ _resolve_concrete_date,
22
+ _resolve_log_tail,
23
+ _resolve_window,
24
+ _sched_anchor,
25
+ _sched_display,
26
+ _sched_kind,
27
+ _sched_sort_key,
28
+ _status_marker,
29
+ _term_width,
30
+ _truncate_log_body,
31
+ GENERIC_TAGS,
32
+ )
33
+ from ..queries import (
34
+ _ancestors_chain,
35
+ _check_ids_exist,
36
+ _collect_descendants,
37
+ _has_tag,
38
+ _insert_log,
39
+ _node_bucket,
40
+ _node_clock_min,
41
+ _node_exists,
42
+ _node_plan,
43
+ _node_project,
44
+ _node_tags,
45
+ _project_members,
46
+ _sec_group,
47
+ _status_filter_sql,
48
+ _upsert_prop,
49
+ )
50
+ from ..render import (
51
+ _PRI_STYLE,
52
+ _STATUS_STYLE,
53
+ _RICH_AVAIL,
54
+ _resolve_theme,
55
+ THEMES,
56
+ _c,
57
+ _hl,
58
+ _node_line,
59
+ _print_truncation_hint,
60
+ _snippet,
61
+ out,
62
+ )
63
+ from ..xdg import _resolve_db_path, _resolve_aliases_path, _xdg_data_home, _xdg_config_home
64
+
65
+ # Lazy access to cli module (for DB wrappers + module state).
66
+ # Used at function call time (not at import) to avoid the cli ↔ commands
67
+ # import cycle.
68
+ from .. import cli as _cli # noqa: E402
69
+
70
+
71
+ def cmd_add(args, con):
72
+ if not args.title or not args.title.strip():
73
+ sys.exit("✗ title cannot be empty")
74
+ args.title = args.title.strip()
75
+ if args.sched and args.scheduled:
76
+ sys.exit("✗ --sched (precise, writes sched table) and --scheduled (rough hint, writes node.scheduled_at) are mutually exclusive; use --sched day-to-day")
77
+ tags = [t.strip() for t in (args.tag or "").split(",") if t.strip()]
78
+ props = {}
79
+ if args.proj:
80
+ props["project"] = args.proj
81
+ if args.deadline:
82
+ deadline = args.deadline
83
+ else:
84
+ deadline = None
85
+
86
+ status = args.status
87
+ if not status and args.kind in ("task", "habit"):
88
+ status = "TODO"
89
+ # --done overrides status directly (one-shot retrospective entry)
90
+ if getattr(args, "done", False):
91
+ status = "DONE"
92
+
93
+ # --at affects --log timestamp + (if --done) closed_at
94
+ at_ts = None
95
+ if getattr(args, "at", None):
96
+ try:
97
+ at_ts = _resolve_at_ts(args.at)
98
+ except ValueError as e:
99
+ sys.exit(f"✗ {e}")
100
+ closed_at = None
101
+ if status == "DONE":
102
+ closed_at = at_ts if at_ts else "__NOW__" # placeholder, SQL below decides
103
+
104
+ try:
105
+ scheduled = _norm_sched(args.scheduled)
106
+ except ValueError as e:
107
+ sys.exit(f"✗ {e}")
108
+
109
+ if closed_at == "__NOW__":
110
+ cur = con.execute(
111
+ """INSERT INTO node (parent_id, title, kind, status, priority, scheduled_at, deadline_at, body, closed_at)
112
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'))""",
113
+ (args.parent, args.title, args.kind, status, args.priority, scheduled, deadline, args.body),
114
+ )
115
+ elif closed_at:
116
+ cur = con.execute(
117
+ """INSERT INTO node (parent_id, title, kind, status, priority, scheduled_at, deadline_at, body, closed_at)
118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
119
+ (args.parent, args.title, args.kind, status, args.priority, scheduled, deadline, args.body, closed_at),
120
+ )
121
+ else:
122
+ cur = con.execute(
123
+ """INSERT INTO node (parent_id, title, kind, status, priority, scheduled_at, deadline_at, body)
124
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
125
+ (args.parent, args.title, args.kind, status, args.priority, scheduled, deadline, args.body),
126
+ )
127
+ node_id = cur.lastrowid
128
+ for t in tags:
129
+ con.execute("INSERT OR IGNORE INTO tag (node_id, tag) VALUES (?, ?)", (node_id, t))
130
+ if args.proj:
131
+ con.execute("INSERT OR IGNORE INTO prop (node_id, key, value) VALUES (?, ?, ?)", (node_id, "project", args.proj))
132
+ # --sched: write directly to sched table (one command = "create task + schedule it as planned for a day")
133
+ sched_hint = ""
134
+ if getattr(args, "sched", None):
135
+ try:
136
+ d = _resolve_concrete_date(args.sched)
137
+ except ValueError:
138
+ sys.exit(f"✗ invalid --sched date '{args.sched}' (use YYYY-MM-DD / today / tomorrow / day-after-tomorrow / yesterday)")
139
+ con.execute("INSERT INTO sched (node_id, on_date) VALUES (?, ?)", (node_id, d))
140
+ sched_hint = " " + _c(f"@{d}", "planned")
141
+
142
+ # --link: attach a vault doc
143
+ link_hint = ""
144
+ if getattr(args, "link", None):
145
+ link_doc = args.link.strip()
146
+ if link_doc:
147
+ con.execute("INSERT OR IGNORE INTO link (node_id, vault_doc) VALUES (?, ?)", (node_id, link_doc))
148
+ link_hint = " → " + _c(f"[[{link_doc}]]", "meta")
149
+
150
+ # --log: insert a log (using at_ts if given, otherwise NOW)
151
+ log_hint = ""
152
+ log_body = getattr(args, "log", None)
153
+ if log_body and log_body.strip():
154
+ if at_ts:
155
+ _insert_log(con, node_id, {"date": at_ts[:10], "time": at_ts[11:16], "body": log_body.strip()})
156
+ else:
157
+ _insert_log(con, node_id, log_body.strip())
158
+ log_hint = " + log"
159
+
160
+ con.commit()
161
+ st = (" " + _c(f"[{status}]", _STATUS_STYLE.get(status, "todo"))) if status else ""
162
+ out(_c("✓", "done") + " " + _c(f"#{node_id}", "id") + " " + _c(f"{args.kind} '{args.title}'")
163
+ + st + sched_hint + link_hint + log_hint)
164
+
165
+ def cmd_log(args, con):
166
+ if not _node_exists(con, args.id):
167
+ sys.exit(f"✗ node #{args.id} not found")
168
+ if not args.body or not args.body.strip():
169
+ sys.exit("✗ log body cannot be empty")
170
+ args.body = args.body.strip()
171
+ date = getattr(args, "date", None)
172
+ time_ = getattr(args, "time", None)
173
+ if date or time_:
174
+ entry = {"date": date, "time": time_, "body": args.body}
175
+ else:
176
+ entry = args.body
177
+ try:
178
+ _insert_log(con, args.id, entry)
179
+ except ValueError as e:
180
+ sys.exit(f"✗ invalid date: {e}")
181
+ # auto TODO -> DOING (when no --date, "I logged something" implies "I'm working on it")
182
+ # backfilling history (--date) does not change status; --keep-status explicitly disables
183
+ auto_progress_hint = ""
184
+ if not getattr(args, "keep_status", False) and not date:
185
+ row = con.execute("SELECT status FROM node WHERE id = ?", (args.id,)).fetchone()
186
+ if row and row["status"] == "TODO":
187
+ con.execute("UPDATE node SET status = 'DOING' WHERE id = ?", (args.id,))
188
+ auto_progress_hint = " (status: TODO → DOING)"
189
+ con.commit()
190
+ print(f"✓ log added to #{args.id}{auto_progress_hint}")
191
+
192
+ def cmd_done(args, con):
193
+ _bulk_status_change(con, args, "DONE", close=True)
194
+
195
+ def cmd_defer(args, con):
196
+ ids = _ids_list(args)
197
+ _check_ids_exist(con, ids)
198
+ try:
199
+ when = _norm_sched(args.date)
200
+ except ValueError as e:
201
+ sys.exit(f"✗ {e}")
202
+ for nid in ids:
203
+ con.execute(
204
+ "UPDATE node SET status = 'LATER', scheduled_at = ? WHERE id = ?",
205
+ (when, nid),
206
+ )
207
+ con.commit()
208
+ for nid in ids:
209
+ out(_c("✓", "done") + " " + _c(f"#{nid}", "id") + " → LATER, scheduled " + _c(_sched_display(when), "planned"))
210
+
211
+ def cmd_start(args, con):
212
+ ids = _ids_list(args)
213
+ _check_ids_exist(con, ids)
214
+ # --at: backfill past start time. None -> NOW
215
+ try:
216
+ ts = _resolve_at_ts(getattr(args, "at", None))
217
+ except ValueError as e:
218
+ sys.exit(f"✗ {e}")
219
+ for nid in ids:
220
+ con.execute("UPDATE node SET status = 'DOING' WHERE id = ?", (nid,))
221
+ con.execute("INSERT INTO log (node_id, logged_at, body) VALUES (?, ?, 'CLOCK_IN')", (nid, ts))
222
+ con.commit()
223
+ note = f" @{ts[11:16]}" if getattr(args, "at", None) else ""
224
+ for nid in ids:
225
+ print(f"✓ #{nid} → DOING, clocked in{note}")
226
+
227
+ def cmd_stop(args, con):
228
+ ids = _ids_list(args)
229
+ _check_ids_exist(con, ids)
230
+ # --at: backfill past stop time (must be later than the matching CLOCK_IN)
231
+ try:
232
+ stop_ts = _resolve_at_ts(getattr(args, "at", None))
233
+ except ValueError as e:
234
+ sys.exit(f"✗ {e}")
235
+ for nid in ids:
236
+ row = con.execute(
237
+ "SELECT logged_at FROM log WHERE node_id = ? AND body = 'CLOCK_IN' ORDER BY id DESC LIMIT 1",
238
+ (nid,),
239
+ ).fetchone()
240
+ if not row:
241
+ sys.exit(f"✗ no open CLOCK_IN for #{nid}")
242
+ started = datetime.fromisoformat(row["logged_at"])
243
+ stopped = datetime.fromisoformat(stop_ts)
244
+ if stopped < started:
245
+ sys.exit(f"✗ --at {stop_ts} is earlier than CLOCK_IN {row['logged_at']} (#{nid})")
246
+ mins = max(1, int((stopped - started).total_seconds() / 60))
247
+ con.execute(
248
+ "INSERT INTO log (node_id, logged_at, body) VALUES (?, ?, ?)",
249
+ (nid, stop_ts, f"CLOCK_OUT elapsed={mins}min (from {row['logged_at']})"),
250
+ )
251
+ print(f"✓ #{nid} stopped, elapsed {mins} min")
252
+ con.commit()
253
+
254
+ def cmd_spent(args, con):
255
+ """Record a past time spent without opening a live CLOCK pair (retrospective entries).
256
+ wl spent <id> 45 45 minutes (default: CLOCK_IN = NOW - 45m, CLOCK_OUT = NOW)
257
+ wl spent <id> 45 --at 14:30 specify end time (CLOCK_IN = at - 45m, CLOCK_OUT = at)
258
+ wl spent <id> 1h30m supports 1h / 30m / 1h30m
259
+ """
260
+ import re as _re
261
+ nid = args.id
262
+ if not _node_exists(con, nid):
263
+ sys.exit(f"✗ node #{nid} not found")
264
+ # parse duration: 1h30m / 90m / 90 (bare number = minutes)
265
+ s = args.duration.strip().lower()
266
+ mins = 0
267
+ m = _re.fullmatch(r"(?:(\d+)h)?(?:(\d+)m)?", s)
268
+ if m and (m.group(1) or m.group(2)):
269
+ mins = int(m.group(1) or 0) * 60 + int(m.group(2) or 0)
270
+ elif _re.fullmatch(r"\d+", s):
271
+ mins = int(s)
272
+ else:
273
+ sys.exit(f"✗ invalid duration '{s}': supported formats: 90 / 90m / 1h30m / 2h")
274
+ if mins <= 0:
275
+ sys.exit("✗ duration must be > 0")
276
+ try:
277
+ end_ts = _resolve_at_ts(getattr(args, "at", None))
278
+ except ValueError as e:
279
+ sys.exit(f"✗ {e}")
280
+ end_dt = datetime.fromisoformat(end_ts)
281
+ from datetime import timedelta as _td
282
+ start_dt = end_dt - _td(minutes=mins)
283
+ start_ts = start_dt.strftime("%Y-%m-%d %H:%M:%S")
284
+ con.execute("INSERT INTO log (node_id, logged_at, body) VALUES (?, ?, 'CLOCK_IN')", (nid, start_ts))
285
+ con.execute(
286
+ "INSERT INTO log (node_id, logged_at, body) VALUES (?, ?, ?)",
287
+ (nid, end_ts, f"CLOCK_OUT elapsed={mins}min (from {start_ts})"),
288
+ )
289
+ con.commit()
290
+ print(f"✓ #{nid} spent {mins}min ({start_ts[11:16]} → {end_ts[11:16]})")
291
+
292
+ def cmd_link(args, con):
293
+ if not args.vault_doc or not args.vault_doc.strip():
294
+ sys.exit("✗ vault_doc cannot be empty")
295
+ args.vault_doc = args.vault_doc.strip()
296
+ ids = _ids_list(args)
297
+ _check_ids_exist(con, ids)
298
+ for nid in ids:
299
+ con.execute("INSERT OR IGNORE INTO link (node_id, vault_doc) VALUES (?, ?)", (nid, args.vault_doc))
300
+ con.commit()
301
+ for nid in ids:
302
+ out(_c("✓", "done") + " " + _c(f"#{nid}", "id") + " " + _c(f"linked → [[{args.vault_doc}]]"))
303
+
304
+ def cmd_set(args, con):
305
+ if not _node_exists(con, args.id):
306
+ sys.exit(f"✗ node #{args.id} not found")
307
+ if not args.key or not args.key.strip():
308
+ sys.exit("✗ prop key cannot be empty")
309
+ args.key = args.key.strip()
310
+ _upsert_prop(con, args.id, args.key, args.value)
311
+ con.commit()
312
+ print(f"✓ #{args.id} {args.key}={args.value}")
313
+
314
+ def cmd_tick(args, con):
315
+ """Quick check-in: add a log for today to one or more nodes (default body='✓ done', overridable with --note).
316
+ --done also marks the node DONE. Bulk habit check-in: `wl tick 39 40 41 --note "..."`."""
317
+ ids = _ids_list(args)
318
+ _check_ids_exist(con, ids)
319
+ # empty note (--note '') falls back to default; we don't allow inserting a truly empty log
320
+ note = (args.note or "").strip()
321
+ body = note if note else "✓ done"
322
+ for nid in ids:
323
+ _insert_log(con, nid, body)
324
+ if args.done:
325
+ con.execute(
326
+ "UPDATE node SET status = 'DONE', closed_at = datetime('now','localtime') WHERE id = ?", (nid,)
327
+ )
328
+ con.commit()
329
+ for nid in ids:
330
+ out(_c(f"✓ #{nid} checked in", "meta") + (_c(" + DONE", "done") if args.done else ""))
331
+
332
+ def cmd_wait(args, con):
333
+ """Mark WAIT status (blocked on others / external input). Optional --note adds a log explaining what we're waiting on.
334
+ If the task has an open CLOCK_IN, auto-emits CLOCK_OUT (WAIT = suspended, no longer timing)."""
335
+ ids = _ids_list(args)
336
+ _check_ids_exist(con, ids)
337
+ for nid in ids:
338
+ # if there's an open CLOCK_IN, close it
339
+ row = con.execute(
340
+ "SELECT id, logged_at FROM log WHERE node_id = ? AND body = 'CLOCK_IN' "
341
+ "AND NOT EXISTS (SELECT 1 FROM log l2 WHERE l2.node_id = log.node_id AND l2.id > log.id AND l2.body LIKE 'CLOCK_OUT%') "
342
+ "ORDER BY id DESC LIMIT 1",
343
+ (nid,),
344
+ ).fetchone()
345
+ if row:
346
+ started = datetime.fromisoformat(row["logged_at"])
347
+ mins = max(1, int((datetime.now() - started).total_seconds() / 60))
348
+ con.execute(
349
+ "INSERT INTO log (node_id, body) VALUES (?, ?)",
350
+ (nid, f"CLOCK_OUT elapsed={mins}min (from {row['logged_at']}) [auto by wait]"),
351
+ )
352
+ con.execute("UPDATE node SET status = 'WAIT' WHERE id = ?", (nid,))
353
+ if args.note:
354
+ _insert_log(con, nid, f"WAIT: {args.note}")
355
+ con.commit()
356
+ for nid in ids:
357
+ msg = f"✓ #{nid} → WAIT"
358
+ if args.note:
359
+ msg += f" ({args.note})"
360
+ print(msg)
361
+
362
+ def cmd_reopen(args, con):
363
+ """Undo DONE/CANCELED: back to TODO, clear closed_at. Common when a task was mistakenly closed."""
364
+ _bulk_status_change(con, args, "TODO", reopen=True)
365
+
366
+ def cmd_cancel(args, con):
367
+ """Mark CANCELED + write closed_at. Parallel to done semantically but different status (dropped / not doing).
368
+ Different from `wl set <id> status CANCELED`: set writes the prop table, cancel changes node.status."""
369
+ _bulk_status_change(con, args, "CANCELED", close=True)
370
+
371
+ def cmd_unlog(args, con):
372
+ """Delete log entries. Two usages:
373
+ wl unlog 282 delete by exact log.id (find id from wl show timeline)
374
+ wl unlog --node 39 delete the latest non-CLOCK log for that node today (undo a mistaken tick)
375
+ wl unlog --node 39 --date yesterday delete the latest log for that node on that day
376
+ wl unlog --node 39 --all delete all non-CLOCK logs for that node that day
377
+ """
378
+ import re as _re
379
+
380
+ log_id = getattr(args, "log_id", None)
381
+ nid = getattr(args, "node", None)
382
+ if (log_id is None) == (nid is None):
383
+ sys.exit("✗ provide either positional <log_id> or --node <id>; pick one")
384
+
385
+ if log_id is not None:
386
+ row = con.execute("SELECT node_id, logged_at, body FROM log WHERE id = ?", (log_id,)).fetchone()
387
+ if not row:
388
+ sys.exit(f"✗ log #{log_id} not found")
389
+ if _re.match(r"^CLOCK_(IN|OUT)", row["body"]):
390
+ sys.exit(f"✗ log #{log_id} is a CLOCK event; use wl stop instead of unlog (to avoid breaking timing pairs)")
391
+ con.execute("DELETE FROM log WHERE id = ?", (log_id,))
392
+ con.commit()
393
+ body_preview = row["body"][:60] + ("…" if len(row["body"]) > 60 else "")
394
+ out(_c(f"✓ deleted log #{log_id} (node #{row['node_id']}, {row['logged_at']}): {body_preview}", "meta"))
395
+ return
396
+
397
+ # --node <id>: delete latest log for that day
398
+ if not _node_exists(con, nid):
399
+ sys.exit(f"✗ node #{nid} not found")
400
+ date = getattr(args, "date", None)
401
+ if date:
402
+ try:
403
+ date = _resolve_concrete_date(date)
404
+ except ValueError:
405
+ sys.exit(f"✗ invalid --date '{date}'")
406
+ else:
407
+ from datetime import date as _d
408
+ date = _d.today().isoformat()
409
+
410
+ sql = ("SELECT id, logged_at, body FROM log WHERE node_id = ? AND date(logged_at) = ? "
411
+ "AND body NOT LIKE 'CLOCK\\_%' ESCAPE '\\' ORDER BY id DESC")
412
+ if not args.all:
413
+ sql += " LIMIT 1"
414
+ rows = list(con.execute(sql, (nid, date)))
415
+ if not rows:
416
+ out(_c(f"(node #{nid} has no non-CLOCK logs on {date})", "meta"))
417
+ return
418
+ for r in rows:
419
+ con.execute("DELETE FROM log WHERE id = ?", (r["id"],))
420
+ body_preview = r["body"][:60] + ("…" if len(r["body"]) > 60 else "")
421
+ out(_c(f"✓ deleted log #{r['id']} (node #{nid}, {r['logged_at']}): {body_preview}", "meta"))
422
+ con.commit()
423
+
424
+ def cmd_relog(args, con):
425
+ """Rewrite an existing log: body or timestamp.
426
+
427
+ wl relog #L282 "fixed content" positional = new body
428
+ wl relog #L282 -m "fixed content" -m explicit
429
+ wl relog #L282 --at 14:30 only change time (same day HH:MM, date auto-prepended)
430
+ wl relog #L282 --at 2026-05-30 14:30 full ts (YYYY-MM-DD or YYYY-MM-DD HH:MM)
431
+ wl relog #L282 no body/--at -> open $EDITOR to edit body
432
+
433
+ Constraints:
434
+ - Cannot edit CLOCK_IN/CLOCK_OUT logs (breaks timing stats; use wl stop --at to fix time)
435
+ - Cannot move across nodes (that's unlog + log, not relog)
436
+ """
437
+ import re as _re
438
+
439
+ log_id = args.log_id
440
+ row = con.execute("SELECT id, node_id, logged_at, body FROM log WHERE id = ?", (log_id,)).fetchone()
441
+ if not row:
442
+ sys.exit(f"✗ log #{log_id} not found")
443
+ if _re.match(r"^CLOCK_(IN|OUT)", row["body"]):
444
+ sys.exit(f"✗ log #{log_id} is a CLOCK event; relog not allowed (use wl stop --at to fix time, or the source command to delete)")
445
+
446
+ # body: positional or -m, mutually exclusive; both empty -> EDITOR (only when --at also missing)
447
+ new_body = None
448
+ if args.body and args.message:
449
+ sys.exit("✗ positional body and -m/--message are mutually exclusive; pick one")
450
+ if args.body:
451
+ new_body = " ".join(args.body).strip()
452
+ elif args.message:
453
+ new_body = args.message.strip()
454
+
455
+ # --at: accepts HH:MM (keep original date) / YYYY-MM-DD / YYYY-MM-DD HH:MM
456
+ new_ts = None
457
+ at = args.at
458
+ if at:
459
+ from datetime import datetime as _dt
460
+ at = at.strip()
461
+ orig_date = row["logged_at"][:10]
462
+ try:
463
+ if _re.fullmatch(r"\d{2}:\d{2}", at):
464
+ _dt.strptime(at, "%H:%M") # validate HH/MM range
465
+ new_ts = f"{orig_date} {at}:00"
466
+ elif _re.fullmatch(r"\d{4}-\d{2}-\d{2}", at):
467
+ _dt.strptime(at, "%Y-%m-%d")
468
+ orig_time = row["logged_at"][11:] or "00:00:00"
469
+ new_ts = f"{at} {orig_time}"
470
+ elif _re.fullmatch(r"\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?", at):
471
+ ts = at.replace("T", " ")
472
+ if len(ts) == 16:
473
+ ts += ":00"
474
+ _dt.strptime(ts, "%Y-%m-%d %H:%M:%S")
475
+ new_ts = ts
476
+ else:
477
+ raise ValueError("format")
478
+ except ValueError:
479
+ sys.exit(f"✗ invalid --at '{at}': supported formats: HH:MM / YYYY-MM-DD / YYYY-MM-DD HH:MM[:SS]")
480
+
481
+ if new_body is None and new_ts is None:
482
+ # nothing given -> open EDITOR to edit body
483
+ new_body = _edit_in_editor(row["body"], suffix=".log.txt")
484
+ if new_body is None or new_body.strip() == row["body"]:
485
+ out(_c("(no change; relog canceled)", "meta"))
486
+ return
487
+ new_body = new_body.strip()
488
+
489
+ # prevent changing body to a CLOCK_* prefix (type collision)
490
+ if new_body is not None and _re.match(r"^CLOCK_(IN|OUT)", new_body):
491
+ sys.exit("✗ relog body cannot start with CLOCK_IN/CLOCK_OUT (to prevent forging timing events)")
492
+
493
+ sets, params = [], []
494
+ if new_body is not None:
495
+ sets.append("body = ?")
496
+ params.append(new_body)
497
+ if new_ts is not None:
498
+ sets.append("logged_at = ?")
499
+ params.append(new_ts)
500
+ params.append(log_id)
501
+ con.execute(f"UPDATE log SET {', '.join(sets)} WHERE id = ?", params)
502
+ con.commit()
503
+
504
+ new_row = con.execute("SELECT logged_at, body FROM log WHERE id = ?", (log_id,)).fetchone()
505
+ body_preview = new_row["body"][:60] + ("…" if len(new_row["body"]) > 60 else "")
506
+ out(_c(f"✓ relog #{log_id} (node #{row['node_id']}, {new_row['logged_at']}): {body_preview}", "meta"))
507
+
508
+ def cmd_active(args, con):
509
+ """List tasks running right now: tasks with an open CLOCK_IN (actually timing).
510
+ Each task shows: id / title / current-session elapsed + today's total + latest log (context).
511
+
512
+ Use cases:
513
+ - Before lunch / a meeting, glance at which task is timing right now
514
+ - Late in the day, find a task you forgot to stop and wrap up with wl stop <id>
515
+ - When juggling several tasks, confirm current focus
516
+
517
+ Difference from wl day: wl day = full single-day view (done / not-yet-started included); wl active = what's timing right now.
518
+ `-q` skips total / log detail, listing only id + elapsed.
519
+ """
520
+ from datetime import datetime as _dt, date as _date
521
+
522
+ rows = con.execute("""
523
+ SELECT l.node_id, l.logged_at, n.title, n.status, n.priority
524
+ FROM log l JOIN node n ON l.node_id = n.id
525
+ WHERE l.body = 'CLOCK_IN'
526
+ AND NOT EXISTS (
527
+ SELECT 1 FROM log l2
528
+ WHERE l2.node_id = l.node_id AND l2.id > l.id AND l2.body LIKE 'CLOCK_OUT%'
529
+ )
530
+ ORDER BY l.logged_at DESC
531
+ """).fetchall()
532
+
533
+ if not rows:
534
+ out(_c("(no active task right now; use wl start <id> to start timing, wl day for today's progress)", "meta"))
535
+ return
536
+
537
+ brief = getattr(args, "brief", False)
538
+ now = _dt.now()
539
+ today = _date.today().isoformat()
540
+ full = _log_full(args)
541
+ for r in rows:
542
+ started = _dt.fromisoformat(r["logged_at"])
543
+ mins = int((now - started).total_seconds() / 60)
544
+ pri = (_c(f"[#{r['priority']}]", _PRI_STYLE.get(r["priority"])) + " ") if r["priority"] else ""
545
+ # head: id + priority + title + current session
546
+ head_tail = "" if brief else " " + _c(f"({mins}min, since {r['logged_at'][11:16]})", "meta")
547
+ out(_c("⏱", "clock") + " " + _c(f"#{r['node_id']}", "id") + " " + pri + _c(r["title"]) + head_tail)
548
+ if brief:
549
+ continue
550
+ # today's CLOCK total + log progress section (helps decide "continue or stop")
551
+ today_clock = con.execute(
552
+ "SELECT body FROM log WHERE node_id = ? AND date(logged_at) = ? AND body LIKE 'CLOCK_OUT elapsed=%'",
553
+ (r["node_id"], today),
554
+ ).fetchall()
555
+ total_min = mins # includes current open session
556
+ import re as _re
557
+ for row in today_clock:
558
+ m = _re.search(r"elapsed=(\d+)min", row["body"])
559
+ if m:
560
+ total_min += int(m.group(1))
561
+ out(" " + _c(f"today's total {total_min}min ({total_min // 60}h{total_min % 60}m), includes current session", "meta"))
562
+ # latest non-CLOCK log (oneline truncated)
563
+ last = con.execute(
564
+ "SELECT body FROM log WHERE node_id = ? AND body NOT LIKE 'CLOCK\\_%' ESCAPE '\\' "
565
+ "ORDER BY id DESC LIMIT 1", (r["node_id"],),
566
+ ).fetchone()
567
+ if last:
568
+ body_one = _truncate_log_body(last["body"], indent_cols=14, full=full)
569
+ out(" " + _c(f"latest log: {body_one}", "meta"))
570
+
571
+ def _ids_list(args):
572
+ """argparse compat: if args.ids (list, nargs='+') is set use it, else fall back to [args.id] (older type=int)."""
573
+ if hasattr(args, "ids") and args.ids:
574
+ return args.ids
575
+ return [args.id]
576
+
577
+ def _bulk_status_change(con, args, new_status, *, close=False, reopen=False, msg=None):
578
+ """Unified batch status change: done/cancel/reopen all go through this path.
579
+ - close=True: write closed_at = NOW (or args.at if given)
580
+ - reopen=True: clear closed_at = NULL
581
+ - otherwise: only change status
582
+
583
+ If args has a .log field, insert a log per id first (via _insert_log, supporting args.at).
584
+ """
585
+ ids = _ids_list(args)
586
+ _check_ids_exist(con, ids)
587
+
588
+ # --at parse (reuses _resolve_at_ts; affects closed_at + log time)
589
+ at_ts = None
590
+ if close and getattr(args, "at", None):
591
+ try:
592
+ at_ts = _resolve_at_ts(args.at)
593
+ except ValueError as e:
594
+ sys.exit(f"✗ {e}")
595
+
596
+ # --log: insert log first (use at_ts; default to NOW if no at)
597
+ log_body = getattr(args, "log", None)
598
+ if log_body:
599
+ log_body = log_body.strip()
600
+ if log_body:
601
+ for nid in ids:
602
+ if at_ts:
603
+ _insert_log(con, nid, {"date": at_ts[:10], "time": at_ts[11:16], "body": log_body})
604
+ else:
605
+ _insert_log(con, nid, log_body)
606
+
607
+ parts = ["status = ?"]
608
+ sql_params_extra = [new_status]
609
+ if close:
610
+ if at_ts:
611
+ parts.append("closed_at = ?")
612
+ sql_params_extra.append(at_ts)
613
+ else:
614
+ parts.append("closed_at = datetime('now', 'localtime')")
615
+ elif reopen:
616
+ parts.append("closed_at = NULL")
617
+ sql = f"UPDATE node SET {', '.join(parts)} WHERE id = ?"
618
+ for nid in ids:
619
+ con.execute(sql, sql_params_extra + [nid])
620
+ con.commit()
621
+ label = msg or ("reopened → " + new_status if reopen else "→ " + new_status)
622
+ note = f" @{at_ts[11:16]}" if at_ts else ""
623
+ log_hint = " + log" if log_body else ""
624
+ for nid in ids:
625
+ print(f"✓ #{nid} {label}{note}{log_hint}")
626
+
627
+
628
+ # --- scheduled time: precise dates + fuzzy granularity (month/week/quarter/year/someday) ---
629
+
630
+ def _edit_in_editor(initial_text, suffix=".txt"):
631
+ """Open $EDITOR to edit a piece of text; return the new content, or None if canceled or unchanged."""
632
+ import os
633
+ import subprocess
634
+ import tempfile
635
+
636
+ editor = os.environ.get("EDITOR", "vi")
637
+ with tempfile.NamedTemporaryFile("w+", suffix=suffix, delete=False) as f:
638
+ f.write(initial_text)
639
+ path = f.name
640
+ try:
641
+ rc = subprocess.call([editor, path])
642
+ if rc != 0:
643
+ return None
644
+ with open(path, encoding="utf-8") as f:
645
+ return f.read()
646
+ finally:
647
+ try:
648
+ os.unlink(path)
649
+ except OSError:
650
+ pass
651
+