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.
- pyworklog-0.3.0.dist-info/METADATA +136 -0
- pyworklog-0.3.0.dist-info/RECORD +20 -0
- pyworklog-0.3.0.dist-info/WHEEL +4 -0
- pyworklog-0.3.0.dist-info/entry_points.txt +2 -0
- pyworklog-0.3.0.dist-info/licenses/LICENSE +21 -0
- worklog/__init__.py +8 -0
- worklog/cli.py +1160 -0
- worklog/commands/__init__.py +89 -0
- worklog/commands/bulk.py +512 -0
- worklog/commands/meta.py +579 -0
- worklog/commands/query.py +854 -0
- worklog/commands/state.py +651 -0
- worklog/commands/views.py +558 -0
- worklog/completion.py +624 -0
- worklog/db.py +92 -0
- worklog/helpers.py +288 -0
- worklog/migrations/0001_initial_schema.sql +90 -0
- worklog/queries.py +216 -0
- worklog/render.py +209 -0
- worklog/xdg.py +42 -0
|
@@ -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
|
+
|