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,854 @@
1
+ """worklog commands: query 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 _ORDER_BY_PRI_ID, _TIME_KINDS # noqa: F401
15
+ from ..helpers import (
16
+ _apply_top_limit,
17
+ _fmt_dur,
18
+ _is_brief,
19
+ _log_full,
20
+ _norm_sched,
21
+ _resolve_at_ts,
22
+ _resolve_concrete_date,
23
+ _resolve_log_tail,
24
+ _resolve_window,
25
+ _sched_anchor,
26
+ _sched_display,
27
+ _sched_kind,
28
+ _sched_sort_key,
29
+ _status_marker,
30
+ _term_width,
31
+ _truncate_log_body,
32
+ GENERIC_TAGS,
33
+ )
34
+ from ..queries import (
35
+ _ancestors_chain,
36
+ _check_ids_exist,
37
+ _collect_descendants,
38
+ _has_tag,
39
+ _insert_log,
40
+ _node_bucket,
41
+ _node_clock_min,
42
+ _node_exists,
43
+ _node_plan,
44
+ _node_project,
45
+ _node_tags,
46
+ _project_members,
47
+ _sec_group,
48
+ _status_filter_sql,
49
+ _upsert_prop,
50
+ )
51
+ from ..render import (
52
+ _PRI_STYLE,
53
+ _STATUS_STYLE,
54
+ _RICH_AVAIL,
55
+ _resolve_theme,
56
+ THEMES,
57
+ _c,
58
+ _hl,
59
+ _node_line,
60
+ _print_truncation_hint,
61
+ _snippet,
62
+ out,
63
+ )
64
+ from ..xdg import _resolve_db_path, _resolve_aliases_path, _xdg_data_home, _xdg_config_home
65
+
66
+ # Lazy access to cli module (for DB wrappers + module state).
67
+ # Used at function call time (not at import) to avoid the cli ↔ commands
68
+ # import cycle.
69
+ from .. import cli as _cli # noqa: E402
70
+
71
+
72
+ from .views import _print_tree, _render_day_group, _scheduled_node_ids
73
+
74
+ from .bulk import _VALID_FIND_FIELDS, _VALID_KINDS
75
+ from .state import _ids_list
76
+ from .views import _print_tree, _render_day_group, _scheduled_node_ids
77
+
78
+ def cmd_show(args, con):
79
+ # multiple ids: show each in turn, blank-line separated; same rendering
80
+ ids = _ids_list(args)
81
+ for i, nid in enumerate(ids):
82
+ if i > 0:
83
+ print()
84
+ args.id = nid
85
+ _show_one(args, con)
86
+
87
+ def cmd_ls(args, con):
88
+ """list nodes. Mirrors shell ls multi-dimensional query conventions (ls -t / -S / -r etc.):
89
+ - default --sort pri (priority+id), like ls default-by-name
90
+ - --sort created/closed/scheduled/updated/title/id similar to ls -t / -S
91
+ - --reverse / -r reverses (like ls -r)
92
+ - --recent N anything that changed in the last N days (created/log/closed)
93
+ - --unscheduled tasks not in sched
94
+ - --ids 1 2 3 list specific ids directly (like ls file1 file2)
95
+ """
96
+ inc_cancel = getattr(args, "show_canceled", False)
97
+
98
+ # --ids mode: list specific ids directly (like ls file1 file2), skipping filters
99
+ if getattr(args, "ids", None):
100
+ rows = []
101
+ for nid in args.ids:
102
+ r = con.execute("SELECT * FROM node WHERE id = ?", (nid,)).fetchone()
103
+ if r:
104
+ rows.append(r)
105
+ if not rows:
106
+ print("(no nodes matched given ids)")
107
+ return
108
+ brief = getattr(args, "brief", False)
109
+ for n in rows:
110
+ out(_node_line(con, n, tags=not brief, sched=not brief))
111
+ return
112
+
113
+ where = []
114
+ params = []
115
+ if args.kind:
116
+ where.append("kind = ?")
117
+ params.append(args.kind)
118
+ if args.status:
119
+ where.append("status = ?")
120
+ params.append(args.status)
121
+ elif not args.all:
122
+ # default: list non-DONE only (DONE hidden); --show-canceled decides CANCELED visibility separately
123
+ frag, p = _status_filter_sql(inc_cancel, hide_done=True)
124
+ if frag:
125
+ where.append(frag)
126
+ params.extend(p)
127
+ if args.tag:
128
+ tags_list = args.tag.split(",") if "," in args.tag else [args.tag]
129
+ for t in tags_list:
130
+ where.append("id IN (SELECT node_id FROM tag WHERE tag = ?)")
131
+ params.append(t.strip())
132
+ if args.parent is not None:
133
+ where.append("parent_id = ?")
134
+ params.append(args.parent)
135
+ if getattr(args, "unscheduled", False):
136
+ where.append("id NOT IN (SELECT node_id FROM sched)")
137
+ if getattr(args, "recent", None):
138
+ from datetime import date, timedelta
139
+ cutoff = (date.today() - timedelta(days=args.recent)).isoformat()
140
+ where.append("(date(created_at) >= ? OR date(closed_at) >= ? "
141
+ "OR id IN (SELECT node_id FROM log WHERE date(logged_at) >= ?))")
142
+ params.extend([cutoff, cutoff, cutoff])
143
+
144
+ sort_key = getattr(args, "sort", "pri") or "pri"
145
+ if sort_key == "updated":
146
+ # subquery: each node's latest log time; nodes with no log fall back to created_at
147
+ sql = ("SELECT n.*, COALESCE((SELECT MAX(logged_at) FROM log WHERE node_id = n.id), n.created_at) "
148
+ "AS _last FROM node n")
149
+ order_by = "_last DESC, id DESC"
150
+ else:
151
+ sql = "SELECT * FROM node"
152
+ order_by = _LS_SORT_SQL[sort_key]
153
+ if where:
154
+ sql += " WHERE " + " AND ".join(where)
155
+ if getattr(args, "reverse", False):
156
+ # simple ASC/DESC swap; columns without ASC/DESC get DESC appended
157
+ flipped = []
158
+ for piece in order_by.split(","):
159
+ piece = piece.strip()
160
+ if " DESC" in piece:
161
+ flipped.append(piece.replace(" DESC", " ASC"))
162
+ elif " ASC" in piece:
163
+ flipped.append(piece.replace(" ASC", " DESC"))
164
+ else:
165
+ flipped.append(piece + " DESC")
166
+ order_by = ", ".join(flipped)
167
+ sql += f" ORDER BY {order_by}"
168
+
169
+ rows = list(con.execute(sql, params))
170
+ if not rows:
171
+ print("(no nodes)")
172
+ return
173
+
174
+ # default limit 20 (avoids flooding on bare ls); --all / --limit 0 removes it; --limit N / --top N is explicit
175
+ explicit_limit = getattr(args, "limit", None)
176
+ explicit_top = getattr(args, "top", None)
177
+ if explicit_limit is None and explicit_top is None and not args.all:
178
+ args.limit = 20 # default 20 injected into args so _apply_top_limit sees it
179
+ rows, total = _apply_top_limit(rows, args)
180
+ if len(rows) < total:
181
+ out(_c(f"(showing {len(rows)}/{total}; --limit N to adjust / --all to see all)", "meta"))
182
+
183
+ brief = getattr(args, "brief", False)
184
+ for n in rows:
185
+ out(_node_line(con, n, tags=not brief, sched=not brief))
186
+
187
+ def cmd_find(args, con):
188
+ """Full-text search nodes: title/body/log/tag/prop/link; mark hits + show hit content not in the title line in indented expansion."""
189
+ q = args.query
190
+ if not q or not q.strip():
191
+ sys.exit("✗ search term cannot be empty (empty query would dump all nodes)")
192
+ q = q.strip()
193
+ like = f"%{q}%"
194
+ if args.in_:
195
+ fields = set(args.in_.split(","))
196
+ bad = fields - _VALID_FIND_FIELDS
197
+ if bad:
198
+ sys.exit(f"✗ invalid --in fields: {sorted(bad)} (valid: {sorted(_VALID_FIND_FIELDS)})")
199
+ else:
200
+ fields = _VALID_FIND_FIELDS
201
+ if args.kind and args.kind not in _VALID_KINDS:
202
+ sys.exit(f"✗ invalid --kind: '{args.kind}' (valid: {sorted(_VALID_KINDS)})")
203
+ hits = {} # node_id -> set of fields with hits
204
+
205
+ def mark(rows, where):
206
+ for r in rows:
207
+ hits.setdefault(r[0], set()).add(where)
208
+
209
+ if "title" in fields:
210
+ mark(con.execute("SELECT id FROM node WHERE title LIKE ?", (like,)), "title")
211
+ if "body" in fields:
212
+ mark(con.execute("SELECT id FROM node WHERE body LIKE ?", (like,)), "body")
213
+ if "log" in fields:
214
+ mark(con.execute("SELECT DISTINCT node_id FROM log WHERE body LIKE ? AND body NOT LIKE 'CLOCK_%'", (like,)), "log")
215
+ if "tag" in fields:
216
+ mark(con.execute("SELECT DISTINCT node_id FROM tag WHERE tag LIKE ?", (like,)), "tag")
217
+ if "prop" in fields:
218
+ mark(con.execute("SELECT DISTINCT node_id FROM prop WHERE key LIKE ? OR value LIKE ?", (like, like)), "prop")
219
+ if "link" in fields:
220
+ mark(con.execute("SELECT DISTINCT node_id FROM link WHERE vault_doc LIKE ?", (like,)), "link")
221
+
222
+ inc_cancel = getattr(args, "show_canceled", False)
223
+ rows = []
224
+ for nid in hits:
225
+ n = con.execute("SELECT * FROM node WHERE id = ?", (nid,)).fetchone()
226
+ if args.kind and n["kind"] != args.kind:
227
+ continue
228
+ if not inc_cancel and n["status"] == "CANCELED":
229
+ continue
230
+ rows.append(n)
231
+ if not rows:
232
+ print(f"(no matches for '{q}')")
233
+ return
234
+ rows.sort(key=lambda n: (n["priority"] or "Z", n["id"]))
235
+ total = len(rows)
236
+ # --limit: default 20 to avoid flooding; --limit 0 / --all shows all
237
+ limit = getattr(args, "limit", None)
238
+ show_all = getattr(args, "all", False)
239
+ if limit is None and not show_all:
240
+ limit = 20
241
+ if limit and limit > 0 and total > limit:
242
+ rows = rows[:limit]
243
+ out(_c(f"'{q}' {total} hits (showing first {limit}; use --all or --limit 0 to see all):", "header"))
244
+ else:
245
+ out(_c(f"'{q}' {total} hits:", "header"))
246
+ for n in rows:
247
+ nid = n["id"]
248
+ where = hits[nid]
249
+ out(_node_line(con, n, hl=q) + " " + _c(f"«{'/'.join(sorted(where))}»", "meta"))
250
+ # show hit contents not in the title line (title already highlighted, no expansion needed)
251
+ if "body" in where and n["body"]:
252
+ out(" " + _c("body:", "meta") + " " + _snippet(n["body"], q))
253
+ if "log" in where:
254
+ for r in con.execute("SELECT body FROM log WHERE node_id=? AND body LIKE ? AND body NOT LIKE 'CLOCK_%' ORDER BY id", (nid, like)):
255
+ out(" " + _c("log:", "meta") + " " + _snippet(r["body"], q))
256
+ if "tag" in where:
257
+ tg = [r["tag"] for r in con.execute("SELECT tag FROM tag WHERE node_id=? AND tag LIKE ?", (nid, like))]
258
+ out(" " + _c("tag:", "meta") + " " + _c(", ".join(tg), "tag"))
259
+ if "prop" in where:
260
+ for r in con.execute("SELECT key,value FROM prop WHERE node_id=? AND (key LIKE ? OR value LIKE ?)", (nid, like, like)):
261
+ out(" " + _c("prop:", "meta") + " " + _c(f"{r['key']}={r['value']}"))
262
+ if "link" in where:
263
+ for r in con.execute("SELECT vault_doc FROM link WHERE node_id=? AND vault_doc LIKE ?", (nid, like)):
264
+ out(" " + _c("link:", "meta") + " " + _c(f"[[{r['vault_doc']}]]"))
265
+
266
+ def cmd_focus(args, con):
267
+ """Focus on a node: upstream path + self + downstream subtree."""
268
+ n = con.execute("SELECT * FROM node WHERE id = ?", (args.id,)).fetchone()
269
+ if not n:
270
+ sys.exit(f"✗ node #{args.id} not found")
271
+
272
+ chain = _ancestors_chain(con, args.id)
273
+ # upstream path (excludes self)
274
+ upstream = chain[:-1]
275
+ if upstream:
276
+ out(_c("upstream:", "meta") + " " + _c(" / ".join(f"#{p['id']} {p['title']}" for p in upstream)))
277
+
278
+ # self
279
+ mk = _c(_status_marker(n["status"]), _STATUS_STYLE.get(n["status"], "todo"))
280
+ pri = (_c(f"[#{n['priority']}]", _PRI_STYLE.get(n["priority"])) + " ") if n["priority"] else ""
281
+ out("▶ focus " + mk + " " + _c(f"#{n['id']}", "id") + " " + pri + _c(f"[{n['kind']}]", "kind") + " " + _c(n["title"], "header"))
282
+
283
+ # downstream subtree
284
+ children = con.execute(
285
+ f"SELECT * FROM node WHERE parent_id = ? {_ORDER_BY_PRI_ID}", (args.id,)
286
+ ).fetchall()
287
+ if children:
288
+ out(_c("downstream:", "meta"))
289
+ for c in children:
290
+ _print_tree(con, c, depth=1, max_depth=args.depth)
291
+ else:
292
+ out(_c("downstream: (no children)", "meta"))
293
+
294
+ # related: other nodes sharing semantic tags (excluding upstream/downstream/self + generic tags to avoid flooding)
295
+ if args.related:
296
+ own_tags = _node_tags(con, args.id)
297
+ sem_tags = [t for t in own_tags if t not in GENERIC_TAGS]
298
+ if not sem_tags:
299
+ out(_c("related: (only generic-dimension tags; no project/topic tag to link by)", "meta"))
300
+ else:
301
+ qmarks = ",".join("?" * len(sem_tags))
302
+ exclude = set(c["id"] for c in children) | {p["id"] for p in chain}
303
+ rel = con.execute(
304
+ f"SELECT DISTINCT n.* FROM node n "
305
+ f"JOIN tag t ON n.id = t.node_id WHERE t.tag IN ({qmarks}) "
306
+ f"ORDER BY n.id",
307
+ sem_tags,
308
+ ).fetchall()
309
+ rel = [r for r in rel if r["id"] not in exclude]
310
+ if rel:
311
+ out(_c(f"related (shared tag {'/'.join(sem_tags)}):", "header"))
312
+ for r in rel:
313
+ out(_node_line(con, r, indent=" "))
314
+ else:
315
+ out(_c(f"related (tag {'/'.join(sem_tags)}): (no other nodes)", "meta"))
316
+
317
+ def cmd_ancestors(args, con):
318
+ """Show only the upstream path (root -> node)."""
319
+ chain = _ancestors_chain(con, args.id)
320
+ if not chain:
321
+ sys.exit(f"✗ node #{args.id} not found")
322
+ for depth, node in enumerate(chain):
323
+ indent = " " * depth
324
+ arrow = "▶ " if node["id"] == args.id else ""
325
+ out(f"{indent}{arrow}" + _c(f"#{node['id']}", "id") + " " + _c(f"[{node['kind']}]", "kind") + " " + _c(node["title"], "header" if node["id"] == args.id else None))
326
+
327
+ def cmd_descendants(args, con):
328
+ """Show only the downstream subtree (node -> all descendants)."""
329
+ n = con.execute("SELECT * FROM node WHERE id = ?", (args.id,)).fetchone()
330
+ if not n:
331
+ sys.exit(f"✗ node #{args.id} not found")
332
+ _print_tree(con, n, depth=0, max_depth=args.depth)
333
+
334
+
335
+
336
+ def cmd_projects(args, con):
337
+ """List active projects + per-project todo/done counts + recent activity.
338
+ brief or no --since: skip the "recent YYYY-MM-DD" segment. --since: only list projects with logs after that day."""
339
+ inc_cancel = getattr(args, "show_canceled", False)
340
+ brief = getattr(args, "brief", False)
341
+ # if any of --since/--week/--month is set, use _resolve_window to get since as cutoff;
342
+ # otherwise since=None (no filter). until has no meaning here (we only check "active after that day").
343
+ if any(getattr(args, k, None) for k in ("since", "until", "week", "month")):
344
+ resolved_since, _ = _resolve_window(args)
345
+ since = resolved_since
346
+ else:
347
+ since = None
348
+ where = "WHERE kind = 'project'"
349
+ proj_params = []
350
+ if not args.all:
351
+ frag, p = _status_filter_sql(inc_cancel, hide_done=True)
352
+ if frag:
353
+ where += " AND " + frag
354
+ proj_params.extend(p)
355
+ projects = con.execute(
356
+ f"SELECT * FROM node {where} {_ORDER_BY_PRI_ID}",
357
+ proj_params,
358
+ ).fetchall()
359
+ if not projects:
360
+ print("(no active projects)")
361
+ return
362
+
363
+ # collect lines -> apply --since/--top/--limit truncation -> print
364
+ lines = []
365
+ for proj in projects:
366
+ ids = _project_members(con, proj["id"])
367
+ done = doing = pending = total = 0
368
+ recent = None
369
+ if ids:
370
+ qm = ",".join("?" * len(ids))
371
+ for r in con.execute(
372
+ f"SELECT status, COUNT(*) c FROM node WHERE id IN ({qm}) GROUP BY status",
373
+ list(ids),
374
+ ):
375
+ c = r["c"]
376
+ total += c
377
+ s = r["status"]
378
+ if s == "DONE":
379
+ done += c
380
+ elif s == "DOING":
381
+ doing += c
382
+ elif s in ("CANCELED",):
383
+ pass
384
+ else:
385
+ pending += c
386
+ r1 = con.execute(f"SELECT MAX(logged_at) m FROM log WHERE node_id IN ({qm})", list(ids)).fetchone()["m"]
387
+ r2 = con.execute(
388
+ f"SELECT MAX(COALESCE(closed_at, created_at)) m FROM node WHERE id IN ({qm})", list(ids)
389
+ ).fetchone()["m"]
390
+ cands = [x for x in (r1, r2) if x]
391
+ recent = max(cands) if cands else None
392
+
393
+ # --since filter: based on real activity signals (log time / closed_at), not created_at
394
+ # (a newly-created task doesn't count as "active"; if it sits idle a few days it still gets filtered out)
395
+ if since:
396
+ r_log = con.execute(f"SELECT MAX(logged_at) m FROM log WHERE node_id IN ({qm})", list(ids)).fetchone()["m"] if ids else None
397
+ r_closed = con.execute(f"SELECT MAX(closed_at) m FROM node WHERE id IN ({qm})", list(ids)).fetchone()["m"] if ids else None
398
+ activity = max([x for x in (r_log, r_closed) if x], default=None)
399
+ if not activity or activity[:10] < since:
400
+ continue
401
+
402
+ pri = _c(f"[#{proj['priority']}]", _PRI_STYLE.get(proj["priority"])) if proj["priority"] else _c("[ ]", "todo")
403
+ parts = [f"done {done}/{total}"]
404
+ if doing:
405
+ parts.append(f"doing {doing}")
406
+ if pending:
407
+ parts.append(f"todo {pending}")
408
+ stat = " · ".join(parts)
409
+ if recent and not brief:
410
+ stat += f" · latest {recent[:16]}"
411
+ lines.append(_c(f"#{proj['id']:<3d}", "id") + " " + pri + " " + _c(proj["title"], "header") + " — " + _c(stat, "meta"))
412
+
413
+ lines, total_lines = _apply_top_limit(lines, args)
414
+ _print_truncation_hint(len(lines), total_lines)
415
+ for line in lines:
416
+ out(line)
417
+
418
+ def cmd_changes(args, con):
419
+ """Per-project changes in a time window: closed / added / log activity (input for weekly reports / Linear update)."""
420
+ since, until = _resolve_window(args)
421
+
422
+ def in_win(ts):
423
+ return bool(ts) and since <= ts[:10] <= until
424
+
425
+ out(_c(f"📅 {since} ~ {until} change summary", "header"))
426
+ projects = con.execute(
427
+ f"SELECT * FROM node WHERE kind = 'project' {_ORDER_BY_PRI_ID}"
428
+ ).fetchall()
429
+
430
+ any_output = False
431
+ for proj in projects:
432
+ members = _project_members(con, proj["id"])
433
+ done, added_open, logged = [], [], 0
434
+ for mid in members:
435
+ n = con.execute("SELECT * FROM node WHERE id = ?", (mid,)).fetchone()
436
+ d = in_win(n["closed_at"])
437
+ if d:
438
+ done.append(n)
439
+ elif in_win(n["created_at"]):
440
+ added_open.append(n)
441
+ has_log = con.execute(
442
+ "SELECT 1 FROM log WHERE node_id = ? AND substr(logged_at,1,10) BETWEEN ? AND ? "
443
+ "AND body NOT LIKE 'CLOCK_%' LIMIT 1",
444
+ (mid, since, until),
445
+ ).fetchone()
446
+ if has_log:
447
+ logged += 1
448
+ if not (done or added_open or logged):
449
+ continue
450
+ any_output = True
451
+ pri = (_c(f"[#{proj['priority']}]", _PRI_STYLE.get(proj["priority"])) + " ") if proj["priority"] else ""
452
+ out("\n▸ " + pri + _c(proj["title"], "header"))
453
+ if done:
454
+ out(" " + _c("✓ done", "done") + f" {len(done)}: " + _c(", ".join(f"#{n['id']} {n['title']}" for n in done)))
455
+ if added_open:
456
+ out(f" + added open {len(added_open)}: " + _c(", ".join(f"#{n['id']} {n['title']}" for n in added_open)))
457
+ if logged:
458
+ out(" " + _c(f"· {logged} node(s) with progress logs", "meta"))
459
+
460
+ if not any_output:
461
+ out(_c("(no project changes in window)", "meta"))
462
+
463
+
464
+
465
+
466
+
467
+ # --- DRY helpers: filter / truncate / bulk status change, reused across commands ---
468
+
469
+
470
+
471
+
472
+
473
+
474
+ def cmd_summary(args, con):
475
+ """Time-window summary: aggregate counts + sliced by direction/project + completion list (input for weekly / monthly reports)."""
476
+ import re as _re
477
+
478
+ since, until = _resolve_window(args)
479
+ inc_cancel = getattr(args, "show_canceled", False)
480
+
481
+ def inw(ts):
482
+ return bool(ts) and since <= ts[:10] <= until
483
+
484
+ sql = "SELECT * FROM node WHERE kind IN ('task','meetlog','habit')"
485
+ sm_params = []
486
+ frag, p = _status_filter_sql(include_canceled=inc_cancel)
487
+ if frag:
488
+ sql += " AND " + frag
489
+ sm_params.extend(p)
490
+ nodes = con.execute(sql, sm_params).fetchall()
491
+ done = [n for n in nodes if inw(n["closed_at"])]
492
+ added_open = [n for n in nodes if inw(n["created_at"]) and not inw(n["closed_at"])]
493
+ doing = [n for n in nodes if n["status"] == "DOING"]
494
+
495
+ clock_min = 0
496
+ for r in con.execute("SELECT body, logged_at FROM log WHERE body LIKE 'CLOCK_OUT%'"):
497
+ if inw(r["logged_at"]):
498
+ m = _re.search(r"elapsed=(\d+)min", r["body"])
499
+ if m:
500
+ clock_min += int(m.group(1))
501
+
502
+ out(_c(f"📊 {since} ~ {until} summary", "header"))
503
+ line = f"done {len(done)} · doing {len(doing)} · added-open {len(added_open)}"
504
+ if clock_min:
505
+ line += f" · clock {clock_min // 60}h{clock_min % 60}m"
506
+ out(_c(line))
507
+
508
+ # by direction
509
+ dir_lines = []
510
+ for d in ("work", "personal"):
511
+ dd = [n for n in done if _has_tag(con, n["id"], d)]
512
+ if dd:
513
+ dir_lines.append(f" {d}: done {len(dd)}")
514
+ if dir_lines:
515
+ out(_c("\nby direction:", "header"))
516
+ out(_c("\n".join(dir_lines)))
517
+
518
+ # pending (window-relevant): planned / doing / added-in-window and not done
519
+ pending = [
520
+ n for n in nodes
521
+ if (n["status"] or "TODO") not in ("DONE", "CANCELED")
522
+ and (_has_tag(con, n["id"], "planned") or n["status"] == "DOING" or inw(n["created_at"]))
523
+ ]
524
+
525
+ # === by project: per-project done + pending (grouped by status), each with priority + clock ===
526
+ done_map = {n["id"]: n for n in done}
527
+ pend_map = {n["id"]: n for n in pending}
528
+
529
+ def _print_block(p_done, p_pending, indent=" "):
530
+ if p_done:
531
+ for n in sorted(p_done, key=lambda n: (n["priority"] or "Z", n["id"])):
532
+ out(_node_line(con, n, indent=indent, done=True, planned=True, clock=True, sched=True))
533
+ if p_pending:
534
+ by_status = {}
535
+ for n in p_pending:
536
+ by_status.setdefault(n["status"] or "TODO", []).append(n)
537
+ for st, label in (("DOING", "doing"), ("TODO", "todo"), ("LATER", "later"), ("WAIT", "waiting")):
538
+ grp = by_status.get(st, [])
539
+ if not grp:
540
+ continue
541
+ out(_c(f"{indent}· {label} ({st}):", "meta"))
542
+ for n in sorted(grp, key=lambda n: (n["priority"] or "Z", n["id"])):
543
+ out(_node_line(con, n, indent=indent + " ", planned=True, clock=True, sched=True))
544
+
545
+ if args.by == "day":
546
+ from collections import defaultdict
547
+
548
+ day_done = defaultdict(list)
549
+ for n in done:
550
+ day_done[n["closed_at"][:10]].append(n)
551
+ day_pend = defaultdict(list)
552
+ for n in pending:
553
+ d = (n["scheduled_at"] or n["created_at"] or "")[:10] or "unscheduled"
554
+ day_pend[d].append(n)
555
+ if day_done or day_pend:
556
+ out(_c("\n=== by day ===", "header"))
557
+ for d in sorted(set(day_done) | set(day_pend)):
558
+ pd = day_done.get(d, [])
559
+ pp = day_pend.get(d, [])
560
+ out("\n▸ " + _c(d, "header") + _c(f" (done {len(pd)} / pending {len(pp)})", "meta"))
561
+ _print_block(pd, pp)
562
+ elif done_map or pend_map:
563
+ out(_c("\n=== by project ===", "header"))
564
+ projects = con.execute(
565
+ f"SELECT * FROM node WHERE kind = 'project' {_ORDER_BY_PRI_ID}"
566
+ ).fetchall()
567
+ # by default dedup by task id: a task appearing in multiple projects is listed only in the first match;
568
+ # --no-dedup restores the old behavior (task repeated in each project bucket).
569
+ dedup = not getattr(args, "no_dedup", False)
570
+ projects_only = getattr(args, "brief", False) or getattr(args, "projects_only", False)
571
+ top_n = getattr(args, "top", None)
572
+ claimed = set()
573
+ # compute pd/pp per project first, used for --top sort; dedup happens here
574
+ plan = [] # [(proj, pd, pp)]
575
+ for proj in projects:
576
+ members = _project_members(con, proj["id"])
577
+ if dedup:
578
+ pd = [done_map[i] for i in members if i in done_map and i not in claimed]
579
+ pp = [pend_map[i] for i in members if i in pend_map and i not in claimed]
580
+ else:
581
+ pd = [done_map[i] for i in members if i in done_map]
582
+ pp = [pend_map[i] for i in members if i in pend_map]
583
+ if not (pd or pp):
584
+ continue
585
+ # claimed is always tracked (dedup uses it to exclude in later projects; no-dedup only affects the "unassigned" segment)
586
+ claimed |= {n["id"] for n in pd} | {n["id"] for n in pp}
587
+ plan.append((proj, pd, pp))
588
+ if top_n is not None:
589
+ plan.sort(key=lambda x: (len(x[1]) + len(x[2])), reverse=True)
590
+ plan = plan[:top_n]
591
+ for proj, pd, pp in plan:
592
+ pri = (_c(f"[#{proj['priority']}]", _PRI_STYLE.get(proj["priority"])) + " ") if proj["priority"] else ""
593
+ out("\n▸ " + pri + _c(proj["title"], "header") + _c(f" (done {len(pd)} / pending {len(pp)})", "meta"))
594
+ if not projects_only:
595
+ _print_block(pd, pp)
596
+ # window nodes not in any project
597
+ od = [done_map[i] for i in done_map if i not in claimed]
598
+ op = [pend_map[i] for i in pend_map if i not in claimed]
599
+ if (od or op) and top_n is None:
600
+ out("\n▸ " + _c("(unassigned)", "header") + _c(f" (done {len(od)} / pending {len(op)})", "meta"))
601
+ if not projects_only:
602
+ _print_block(od, op)
603
+
604
+ def cmd_logs(args, con):
605
+ """List all log entries in a time range. Default: last N days only, to avoid full-history flooding."""
606
+ from datetime import date, timedelta
607
+
608
+ # presets: wl logs today / yesterday / week / recent
609
+ preset = getattr(args, "preset", None)
610
+ if preset == "today":
611
+ args.date = date.today().isoformat()
612
+ elif preset == "yesterday":
613
+ args.date = (date.today() - timedelta(days=1)).isoformat()
614
+ elif preset == "week":
615
+ # this Monday
616
+ today = date.today()
617
+ args.since = (today - timedelta(days=today.weekday())).isoformat()
618
+ elif preset == "recent":
619
+ args.days = 1
620
+ args.brief = True # explicit brief
621
+
622
+ where = []
623
+ params = []
624
+ if args.id:
625
+ where.append("node_id = ?")
626
+ params.append(args.id)
627
+ if args.date:
628
+ try:
629
+ args.date = _resolve_concrete_date(args.date)
630
+ except ValueError:
631
+ sys.exit(f"✗ invalid --date '{args.date}' (use YYYY-MM-DD / today / yesterday)")
632
+ where.append("date(logged_at) = ?")
633
+ params.append(args.date)
634
+ # default time window: when no id/date/since given, only the last N days (default 7)
635
+ since = args.since
636
+ if not args.id and not args.date and not since:
637
+ since = (date.today() - timedelta(days=getattr(args, "days", 7) or 7)).isoformat()
638
+ if since:
639
+ where.append("date(logged_at) >= ?")
640
+ params.append(since)
641
+ if getattr(args, "until", None):
642
+ where.append("date(logged_at) <= ?")
643
+ params.append(args.until)
644
+ grouped = getattr(args, "group", "none") == "day"
645
+ cols = "log.id, log.node_id, log.logged_at, log.body, node.title"
646
+ if grouped:
647
+ cols += ", node.status, node.priority, node.kind"
648
+ sql = f"SELECT {cols} FROM log JOIN node ON log.node_id = node.id"
649
+ if where:
650
+ sql += " WHERE " + " AND ".join(where)
651
+ sql += " ORDER BY log.logged_at"
652
+ rows = con.execute(sql, params).fetchall()
653
+
654
+ if not rows:
655
+ # provide a useful hint explaining why empty
656
+ if args.id and not _node_exists(con, args.id):
657
+ out(_c(f"(node #{args.id} does not exist)", "meta"))
658
+ elif args.id:
659
+ out(_c(f"(node #{args.id} has no logs in this window)", "meta"))
660
+ else:
661
+ hint = []
662
+ if args.date:
663
+ hint.append(f"on {args.date}")
664
+ elif since:
665
+ hint.append(f"since {since}")
666
+ if args.until:
667
+ hint.append(f"until {args.until}")
668
+ out(_c(f"(no logs {' '.join(hint)})", "meta"))
669
+ return
670
+
671
+ brief = _is_brief(args, "no_body")
672
+ by_task = getattr(args, "by_task", False)
673
+ # tail default 3 (aligns with wl day / wl tree); 0 = no expansion; --all-logs / large number = full expansion
674
+ tail = _resolve_log_tail(args, brief, default_tail=3)
675
+
676
+ if grouped:
677
+ # date header -> bucket -> project -> task -> log (reuse day-view grouping)
678
+ from collections import OrderedDict
679
+
680
+ by_date = OrderedDict()
681
+ for r in rows:
682
+ by_date.setdefault(str(r["logged_at"])[:10], []).append(r)
683
+ log_tail = tail # reuse (default 3 / 0 when brief / None when --all-logs)
684
+ for d, drows in by_date.items():
685
+ out(_c(d, "header"))
686
+ items = {}
687
+ for r in drows:
688
+ items.setdefault(r["node_id"], {"node": r, "logs": []})["logs"].append(r["body"])
689
+ _render_day_group(con, items, by=getattr(args, "by", "project"),
690
+ sched_ids=_scheduled_node_ids(con, d), log_tail=log_tail,
691
+ full=_log_full(args))
692
+ print()
693
+ return
694
+
695
+ if by_task:
696
+ # aggregate by task: last N per task (default all)
697
+ from collections import OrderedDict
698
+
699
+ groups = OrderedDict()
700
+ for r in rows:
701
+ groups.setdefault(r["node_id"], {"title": r["title"], "rows": []})["rows"].append(r)
702
+ for nid, g in groups.items():
703
+ if tail is None:
704
+ picks = g["rows"]
705
+ elif tail <= 0:
706
+ picks = [] # tail 0 = no expansion (same as brief 'header only')
707
+ else:
708
+ picks = g["rows"][-tail:]
709
+ head = _c(f"#{nid}", "id") + " " + _c(f"'{g['title'][:60]}'")
710
+ if tail is not None and len(g["rows"]) > tail:
711
+ head += " " + _c(f"({len(g['rows'])} total, showing last {tail})", "meta")
712
+ else:
713
+ head += " " + _c(f"({len(g['rows'])} entries)", "meta")
714
+ out(head)
715
+ if brief:
716
+ # brief + by_task: list all dates, no body (bypassing tail=0-truncated picks)
717
+ dates = ", ".join(r["logged_at"][:10] for r in g["rows"])
718
+ out(" " + _c(dates, "meta"))
719
+ continue
720
+ for r in picks:
721
+ # --by-task indent " [YYYY-MM-DD HH:MM:SS] " ~ 26 cols
722
+ body = _truncate_log_body(r["body"], indent_cols=26, full=_log_full(args))
723
+ out(" " + _c(f"[{r['logged_at']}]", "meta") + " " + _c(body))
724
+ return
725
+
726
+ # --tail N also works in --id single-task mode (consistent with --by-task tail)
727
+ # without --by-task, tail directly slices the flat list tail, coordinating with _apply_top_limit
728
+ raw_tail = getattr(args, "tail", None)
729
+ if raw_tail is not None and raw_tail > 0 and len(rows) > raw_tail:
730
+ omitted = len(rows) - raw_tail
731
+ rows = rows[-raw_tail:]
732
+ out(_c(f"… ({omitted} earlier elided, showing last {raw_tail}); use --all-logs or --limit 0 to see all", "meta"))
733
+ elif raw_tail is None:
734
+ rows, total = _apply_top_limit(rows, args)
735
+ _print_truncation_hint(len(rows), total, extra="--limit 0 for all")
736
+ for r in rows:
737
+ lid = _c(f"#L{r['id']}", "id")
738
+ if brief:
739
+ out(_c(f"[{r['logged_at'][:10]}]", "meta") + " " + lid + " " + _c(f"#{r['node_id']}", "id") + " " + _c(f"{r['title'][:50]}"))
740
+ else:
741
+ # flat logs row "[YYYY-MM-DD HH:MM:SS] #L<id> #<node> 'title': <body>" prefix ~ 60 cols
742
+ body = _truncate_log_body(r["body"], indent_cols=60, full=_log_full(args))
743
+ out(_c(f"[{r['logged_at']}]", "meta") + " " + lid + " " + _c(f"#{r['node_id']}", "id") + " " + _c(f"'{r['title'][:30]}': {body}"))
744
+
745
+
746
+ # --- completion generator (argparse -> fish/bash/zsh) ---
747
+ # loaded via ~/.config/<shell>/<config> | source pattern; does not write a persistent file
748
+
749
+ # action attribute name -> fish helper function (dynamic completion)
750
+
751
+
752
+
753
+
754
+
755
+ # --- bash backend ---
756
+
757
+ # bash does not show descriptions, only completes tokens. helper is a bash function that emits a token list.
758
+
759
+
760
+
761
+ # --- zsh backend ---
762
+
763
+ def _show_one(args, con):
764
+ n = con.execute("SELECT * FROM node WHERE id = ?", (args.id,)).fetchone()
765
+ if not n:
766
+ sys.exit(f"✗ node #{args.id} not found")
767
+ out(_c(f"#{n['id']}", "id") + " " + _c(f"[{n['kind']}]", "kind") + " " + _c(n["title"], "header"))
768
+ if n["status"]:
769
+ st = _c(n["status"], _STATUS_STYLE.get(n["status"], "todo"))
770
+ pr = (" " + _c(f"[#{n['priority']}]", _PRI_STYLE.get(n["priority"]))) if n["priority"] else ""
771
+ out(" " + _c("status:", "meta") + " " + st + pr)
772
+ chain = _ancestors_chain(con, args.id)
773
+ if len(chain) > 1:
774
+ out(" " + _c("ancestors:", "meta") + " " + _c(" / ".join(f"#{p['id']} {p['title']}" for p in chain[:-1])))
775
+ for k in ("created_at", "scheduled_at", "deadline_at", "closed_at"):
776
+ if n[k]:
777
+ out(" " + _c(f"{k:9s}", "meta") + " " + _c(n[k]))
778
+ if n["body"]:
779
+ out(" " + _c("body:", "meta") + " " + _c(n["body"]))
780
+ tags = _node_tags(con, args.id)
781
+ if tags:
782
+ out(" " + _c("tags:", "meta") + " " + _c(f":{':'.join(tags)}:", "tag"))
783
+ props = list(con.execute("SELECT key, value FROM prop WHERE node_id = ?", (args.id,)))
784
+ if props:
785
+ out(" " + _c("props:", "meta"))
786
+ for r in props:
787
+ out(" " + _c(f"{r['key']:12s} = {r['value']}"))
788
+ links = [r["vault_doc"] for r in con.execute("SELECT vault_doc FROM link WHERE node_id = ?", (args.id,))]
789
+ if links:
790
+ out(" " + _c("links:", "meta") + " " + _c(", ".join(f"[[{d}]]" for d in links)))
791
+ # children (direct only)
792
+ children = con.execute(
793
+ f"SELECT * FROM node WHERE parent_id = ? {_ORDER_BY_PRI_ID}", (args.id,)
794
+ ).fetchall()
795
+ if children:
796
+ out(" " + _c(f"children ({len(children)}):", "header"))
797
+ for c in children:
798
+ out(_node_line(con, c, indent=" "))
799
+
800
+ # timeline / changes: created / scheduled / closed / each log (including CLOCK events), merged by time
801
+ # brief / --no-timeline -> skip entire section; --timeline-tail N -> only the latest N
802
+ brief = _is_brief(args, "no_timeline")
803
+ if brief:
804
+ return
805
+ logs = list(con.execute("SELECT id, logged_at, body FROM log WHERE node_id = ? ORDER BY id", (args.id,)))
806
+ # event tuple: (ts, kind_label, extra, log_id) -- log_id only for log events, meta events None
807
+ events = []
808
+ if n["created_at"]:
809
+ events.append((n["created_at"], "● created", "", None))
810
+ if n["scheduled_at"]:
811
+ events.append((n["scheduled_at"], "◷ scheduled", "", None))
812
+ if n["closed_at"]:
813
+ events.append((n["closed_at"], f"✓ {n['status'] or 'closed'}", "", None))
814
+ for r in logs:
815
+ body = r["body"]
816
+ if body.startswith("CLOCK_IN"):
817
+ events.append((r["logged_at"], "⏱ clock-in", "", r["id"]))
818
+ elif body.startswith("CLOCK_OUT"):
819
+ import re as _re
820
+ m = _re.search(r"elapsed=(\d+)min", body)
821
+ events.append((r["logged_at"], "⏱ clock-out", f"({m.group(1)}min)" if m else "", r["id"]))
822
+ else:
823
+ # timeline log row: " YYYY-MM-DD HH:MM:SS #L<id> ✎ log <body>" indented ~ 32 cols
824
+ head = _truncate_log_body(body, indent_cols=32, full=_log_full(args))
825
+ events.append((r["logged_at"], "✎ log", head, r["id"]))
826
+ if events:
827
+ events.sort(key=lambda e: e[0])
828
+ # tail: --no-timeline/brief=0 / --all-timelines=None full expansion / --timeline-tail N / default 5
829
+ # default 5 (slightly more than wl day, since wl show is a detail command, but still elides middle)
830
+ tail = _resolve_log_tail(args, brief=False, default_tail=5)
831
+ shown = events if tail is None else (events[-tail:] if tail else [])
832
+ title = f"timeline / changes ({len(events)})"
833
+ if tail is not None and len(events) > tail:
834
+ title += f", showing last {tail}"
835
+ out(" " + _c(title + ":", "header"))
836
+ if tail is not None and len(events) > tail:
837
+ out(" " + _c(f"… ({len(events) - tail} earlier elided; use --all-timelines for full)", "meta"))
838
+ # log.id used for operations (wl unlog #L<id>); meta events have no id, just a placeholder for alignment
839
+ # prefix #L<id> mirrors node #123 with '#'; 'L' distinguishes (letter prefix = log, plain digits = node)
840
+ for ts, kind, extra, lid in shown:
841
+ lid_str = _c(f"#L{lid}", "id") if lid is not None else _c(" ", "meta")
842
+ out(" " + _c(ts, "meta") + " " + lid_str + " " + _c(kind) + (f" {_c(extra)}" if extra else ""))
843
+
844
+
845
+ _LS_SORT_SQL = {
846
+ "pri": "priority NULLS LAST, id",
847
+ "created": "created_at DESC, id DESC", # like shell ls -t (newest first)
848
+ "closed": "closed_at DESC NULLS LAST, id DESC",
849
+ "scheduled": "scheduled_at DESC NULLS LAST, id DESC",
850
+ "title": "title COLLATE NOCASE, id",
851
+ "id": "id",
852
+ # updated goes through a subquery, not here
853
+ }
854
+