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,558 @@
|
|
|
1
|
+
"""worklog commands: views 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
|
+
|
|
73
|
+
def cmd_tree(args, con):
|
|
74
|
+
inc_cancel = getattr(args, "show_canceled", False)
|
|
75
|
+
log_tail = _resolve_log_tail(args, _is_brief(args, "no_logs"), default_tail=3)
|
|
76
|
+
if args.by:
|
|
77
|
+
_tree_by(con, args.by)
|
|
78
|
+
return
|
|
79
|
+
full = _log_full(args)
|
|
80
|
+
if args.root is None and args.kind is None and args.depth is None:
|
|
81
|
+
# bare wl tree: areas one level + timeline up to today
|
|
82
|
+
_print_default_tree(con, include_canceled=inc_cancel, log_tail=log_tail, full=full)
|
|
83
|
+
return
|
|
84
|
+
if args.root is not None:
|
|
85
|
+
# expand subtree from a specified node as root (no longer requires parent_id IS NULL)
|
|
86
|
+
root = con.execute("SELECT * FROM node WHERE id = ?", (args.root,)).fetchone()
|
|
87
|
+
if not root:
|
|
88
|
+
sys.exit(f"✗ node #{args.root} not found")
|
|
89
|
+
roots = [root]
|
|
90
|
+
else:
|
|
91
|
+
root_sql = "SELECT * FROM node WHERE parent_id IS NULL"
|
|
92
|
+
params_root = []
|
|
93
|
+
if args.kind:
|
|
94
|
+
root_sql += " AND kind = ?"
|
|
95
|
+
params_root.append(args.kind)
|
|
96
|
+
if not inc_cancel:
|
|
97
|
+
frag, p = _status_filter_sql(include_canceled=False)
|
|
98
|
+
if frag:
|
|
99
|
+
root_sql += " AND " + frag
|
|
100
|
+
params_root.extend(p)
|
|
101
|
+
roots = list(con.execute(root_sql, params_root))
|
|
102
|
+
|
|
103
|
+
if not roots:
|
|
104
|
+
print("(no root nodes)")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# default depth limit to avoid flooding: full tree default 2 (area->project / year->quarter overview), --root default 3 (one extra level for drill-down)
|
|
108
|
+
max_depth = args.depth if args.depth is not None else (3 if args.root is not None else 2)
|
|
109
|
+
for root in roots:
|
|
110
|
+
_print_tree(con, root, depth=0, max_depth=max_depth,
|
|
111
|
+
include_canceled=inc_cancel, log_tail=log_tail, full=full)
|
|
112
|
+
|
|
113
|
+
def cmd_day(args, con):
|
|
114
|
+
"""Reproduce a single day's progress (default today): bucket by work/personal -> project -> task -> that day's logs.
|
|
115
|
+
Driven by log dates (not the day node), so it works for historical days too."""
|
|
116
|
+
from datetime import date as _date
|
|
117
|
+
|
|
118
|
+
if args.date:
|
|
119
|
+
try:
|
|
120
|
+
target = _resolve_concrete_date(args.date)
|
|
121
|
+
except ValueError:
|
|
122
|
+
sys.exit(f"✗ invalid date '{args.date}' (use YYYY-MM-DD / today / yesterday / day-before-yesterday / tomorrow / day-after-tomorrow)")
|
|
123
|
+
else:
|
|
124
|
+
target = _date.today().isoformat()
|
|
125
|
+
day = con.execute(
|
|
126
|
+
"SELECT * FROM node WHERE kind = 'day' AND title LIKE ? ORDER BY id LIMIT 1",
|
|
127
|
+
(target + "%",),
|
|
128
|
+
).fetchone()
|
|
129
|
+
# date context: date + auto-computed weekday + date_meta label (holiday/vacation/working-day swap)
|
|
130
|
+
wd = _cn_weekday(target)
|
|
131
|
+
label = _date_label(con, target)
|
|
132
|
+
head = target + (f" {wd}" if wd else "") + (f" · {label}" if label else "")
|
|
133
|
+
out(_c(head, "header"))
|
|
134
|
+
# meta (stored as props on the day node): goal / recap / Top5; plus parent week node's overview
|
|
135
|
+
if day:
|
|
136
|
+
meta = {r["key"]: r["value"] for r in con.execute("SELECT key, value FROM prop WHERE node_id = ?", (day["id"],))}
|
|
137
|
+
if meta.get("goal"):
|
|
138
|
+
out(_c(" > 🎯 " + meta["goal"], "meta"))
|
|
139
|
+
if meta.get("summary"):
|
|
140
|
+
at = meta.get("summary_at")
|
|
141
|
+
when = _c(f" (written at {at[5:16]})", "meta") if at else ""
|
|
142
|
+
out(_c(" > Recap: " + meta["summary"], "meta") + when)
|
|
143
|
+
# stale check: after recap, if there are new non-CLOCK logs today, prompt to rewrite
|
|
144
|
+
if at:
|
|
145
|
+
newer = con.execute(
|
|
146
|
+
"SELECT COUNT(*) FROM log WHERE logged_at > ? "
|
|
147
|
+
"AND substr(logged_at, 1, 10) = ? AND body NOT LIKE 'CLOCK_%'",
|
|
148
|
+
(at, target),
|
|
149
|
+
).fetchone()[0]
|
|
150
|
+
if newer:
|
|
151
|
+
out(_c(f" > ⚠ {newer} change(s) after recap; consider rewriting via wl recap", "doing"))
|
|
152
|
+
if meta.get("top5"):
|
|
153
|
+
out(_c(" > Top5: " + meta["top5"], "meta"))
|
|
154
|
+
wk = con.execute("SELECT id FROM node WHERE id = ? AND kind = 'week'", (day["parent_id"],)).fetchone()
|
|
155
|
+
if wk:
|
|
156
|
+
ov = con.execute("SELECT value FROM prop WHERE node_id = ? AND key = 'overview'", (wk["id"],)).fetchone()
|
|
157
|
+
if ov:
|
|
158
|
+
out(_c(" > This week: " + ov["value"], "meta"))
|
|
159
|
+
|
|
160
|
+
inc_cancel = getattr(args, "show_canceled", False)
|
|
161
|
+
cfrag, cparams = _status_filter_sql(include_canceled=inc_cancel, col="node.status")
|
|
162
|
+
cancel_sql = (" AND " + cfrag) if cfrag else ""
|
|
163
|
+
rows = con.execute(
|
|
164
|
+
rf"""SELECT log.node_id, log.logged_at, log.body,
|
|
165
|
+
node.title, node.status, node.priority, node.kind
|
|
166
|
+
FROM log JOIN node ON log.node_id = node.id
|
|
167
|
+
WHERE date(log.logged_at) = ?
|
|
168
|
+
AND log.body NOT LIKE 'CLOCK\_%' ESCAPE '\'
|
|
169
|
+
AND node.kind IN ('task', 'habit', 'meetlog')
|
|
170
|
+
{cancel_sql}
|
|
171
|
+
ORDER BY log.logged_at, log.node_id""",
|
|
172
|
+
[target] + cparams,
|
|
173
|
+
).fetchall()
|
|
174
|
+
|
|
175
|
+
# items: tasks with logs + tasks scheduled but with no log yet today (planned items visible ahead of time)
|
|
176
|
+
items = {}
|
|
177
|
+
for r in rows:
|
|
178
|
+
items.setdefault(r["node_id"], {"node": r, "logs": []})["logs"].append(r["body"])
|
|
179
|
+
sched_ids = _scheduled_node_ids(con, target)
|
|
180
|
+
for nid in sched_ids:
|
|
181
|
+
if nid not in items:
|
|
182
|
+
nr = con.execute(
|
|
183
|
+
"SELECT id AS node_id, title, status, priority, kind FROM node WHERE id = ?", (nid,)
|
|
184
|
+
).fetchone()
|
|
185
|
+
if nr and nr["kind"] in ("task", "habit", "meetlog"):
|
|
186
|
+
if not inc_cancel and nr["status"] == "CANCELED":
|
|
187
|
+
continue
|
|
188
|
+
items[nid] = {"node": nr, "logs": []}
|
|
189
|
+
|
|
190
|
+
if not items:
|
|
191
|
+
out(_c(f" (no log progress for {target}, and nothing planned)", "meta"))
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
# log_tail priority: --no-logs/--brief -> 0 / --all-logs -> None (full) /
|
|
195
|
+
# --log-tail N -> N / default 3 (elide middle, only the end visible to keep wl day from blowing up on long logs)
|
|
196
|
+
brief = _is_brief(args, "no_logs")
|
|
197
|
+
log_tail = _resolve_log_tail(args, brief, default_tail=3)
|
|
198
|
+
_render_day_group(con, items, by=getattr(args, "by", "plan"),
|
|
199
|
+
sched_ids=sched_ids, log_tail=log_tail,
|
|
200
|
+
full=_log_full(args))
|
|
201
|
+
|
|
202
|
+
# bottom stats: per-status distribution + planned-not-done count + CLOCK time
|
|
203
|
+
import re
|
|
204
|
+
|
|
205
|
+
logged = {r["node_id"]: (r["status"] or "TODO") for r in rows}
|
|
206
|
+
stats = {}
|
|
207
|
+
for s in logged.values():
|
|
208
|
+
stats[s] = stats.get(s, 0) + 1
|
|
209
|
+
done = stats.get("DONE", 0)
|
|
210
|
+
total = len(logged)
|
|
211
|
+
planned_undone = sum(1 for nid in items if not items[nid]["logs"])
|
|
212
|
+
parts = [f"{s} {stats[s]}" for s in ("DONE", "DOING", "TODO", "LATER", "WAIT", "DEFERRED", "CANCELED") if stats.get(s)]
|
|
213
|
+
clock = con.execute(
|
|
214
|
+
"SELECT body FROM log WHERE date(logged_at) = ? AND body LIKE 'CLOCK_OUT%'",
|
|
215
|
+
(target,),
|
|
216
|
+
).fetchall()
|
|
217
|
+
total_min = sum(int(m.group(1)) for r in clock if (m := re.search(r"elapsed=(\d+)min", r["body"])))
|
|
218
|
+
print()
|
|
219
|
+
line = f" ── {target}: {done}/{total} tasks with progress"
|
|
220
|
+
if parts:
|
|
221
|
+
line += " · " + " · ".join(parts)
|
|
222
|
+
if planned_undone:
|
|
223
|
+
line += f" · planned·not-done {planned_undone}"
|
|
224
|
+
if total_min:
|
|
225
|
+
line += f" · CLOCK {total_min}min ({total_min // 60}h{total_min % 60}m)"
|
|
226
|
+
out(_c(line, "meta"))
|
|
227
|
+
|
|
228
|
+
def _tree_by(con, by):
|
|
229
|
+
"""Flat 2-level view, regrouped by dimension (avoids deep time-layered nesting)."""
|
|
230
|
+
if by == "tag":
|
|
231
|
+
tags = [r["tag"] for r in con.execute("SELECT DISTINCT tag FROM tag ORDER BY tag")]
|
|
232
|
+
sem = [t for t in tags if t not in GENERIC_TAGS]
|
|
233
|
+
if not sem:
|
|
234
|
+
print("(no semantic tags)")
|
|
235
|
+
return
|
|
236
|
+
for tag in sem:
|
|
237
|
+
rows = con.execute(
|
|
238
|
+
"SELECT n.* FROM node n JOIN tag t ON n.id = t.node_id WHERE t.tag = ? "
|
|
239
|
+
"ORDER BY n.priority NULLS LAST, n.id",
|
|
240
|
+
(tag,),
|
|
241
|
+
).fetchall()
|
|
242
|
+
out(_c(f"#{tag}", "tag") + " " + _c(f"({len(rows)})", "meta"))
|
|
243
|
+
for n in rows:
|
|
244
|
+
out(_node_line(con, n))
|
|
245
|
+
|
|
246
|
+
elif by == "project":
|
|
247
|
+
projects = con.execute(
|
|
248
|
+
f"SELECT * FROM node WHERE kind = 'project' {_ORDER_BY_PRI_ID}"
|
|
249
|
+
).fetchall()
|
|
250
|
+
if not projects:
|
|
251
|
+
print("(no project nodes)")
|
|
252
|
+
return
|
|
253
|
+
claimed = set()
|
|
254
|
+
for proj in projects:
|
|
255
|
+
proj_tags = {r["tag"] for r in con.execute("SELECT tag FROM tag WHERE node_id = ?", (proj["id"],))} - GENERIC_TAGS
|
|
256
|
+
ids = set()
|
|
257
|
+
# (a) structural children
|
|
258
|
+
for r in con.execute("SELECT id FROM node WHERE parent_id = ?", (proj["id"],)):
|
|
259
|
+
ids.add(r["id"])
|
|
260
|
+
# (b) task/meetlog/habit sharing a semantic tag
|
|
261
|
+
if proj_tags:
|
|
262
|
+
qm = ",".join("?" * len(proj_tags))
|
|
263
|
+
for r in con.execute(
|
|
264
|
+
f"SELECT DISTINCT n.id FROM node n JOIN tag t ON n.id = t.node_id "
|
|
265
|
+
f"WHERE t.tag IN ({qm}) AND n.kind IN ('task','meetlog','habit')",
|
|
266
|
+
list(proj_tags),
|
|
267
|
+
):
|
|
268
|
+
ids.add(r["id"])
|
|
269
|
+
pri = (" " + _c(f"[#{proj['priority']}]", _PRI_STYLE.get(proj["priority"]))) if proj["priority"] else ""
|
|
270
|
+
out("▸ " + _c(f"#{proj['id']}", "id") + pri + " " + _c(proj["title"], "header") + " " + _c(f"({len(ids)})", "meta"))
|
|
271
|
+
for nid in sorted(ids):
|
|
272
|
+
n = con.execute("SELECT * FROM node WHERE id = ?", (nid,)).fetchone()
|
|
273
|
+
claimed.add(nid)
|
|
274
|
+
out(_node_line(con, n))
|
|
275
|
+
if not ids:
|
|
276
|
+
out(" " + _c("(no linked tasks)", "meta"))
|
|
277
|
+
# orphans: task/meetlog/habit not attached to any project
|
|
278
|
+
orphans = con.execute(
|
|
279
|
+
f"SELECT * FROM node WHERE kind IN ('task','meetlog','habit') {_ORDER_BY_PRI_ID}"
|
|
280
|
+
).fetchall()
|
|
281
|
+
orphans = [n for n in orphans if n["id"] not in claimed]
|
|
282
|
+
if orphans:
|
|
283
|
+
out("▸ " + _c("(unassigned)", "header") + " " + _c(f"({len(orphans)})", "meta"))
|
|
284
|
+
for n in orphans:
|
|
285
|
+
out(_node_line(con, n))
|
|
286
|
+
|
|
287
|
+
elif by == "direction":
|
|
288
|
+
for direction in ("work", "personal"):
|
|
289
|
+
rows = con.execute(
|
|
290
|
+
"SELECT n.* FROM node n JOIN tag t ON n.id = t.node_id WHERE t.tag = ? "
|
|
291
|
+
"AND n.kind IN ('task','meetlog','habit','project') "
|
|
292
|
+
"ORDER BY n.priority NULLS LAST, n.id",
|
|
293
|
+
(direction,),
|
|
294
|
+
).fetchall()
|
|
295
|
+
out(_c(f"[{direction}]", "header") + " " + _c(f"({len(rows)})", "meta"))
|
|
296
|
+
for n in rows:
|
|
297
|
+
out(_node_line(con, n))
|
|
298
|
+
|
|
299
|
+
def _tree_children(con, node, include_canceled=False):
|
|
300
|
+
"""Children ordering: time-kinds ascending by title (date); others by priority -> id. CANCELED excluded by default."""
|
|
301
|
+
sql = "SELECT * FROM node WHERE parent_id = ?"
|
|
302
|
+
sql_params = [node["id"]]
|
|
303
|
+
frag, p = _status_filter_sql(include_canceled=include_canceled)
|
|
304
|
+
if frag:
|
|
305
|
+
sql += " AND " + frag
|
|
306
|
+
sql_params.extend(p)
|
|
307
|
+
rows = list(con.execute(sql, sql_params))
|
|
308
|
+
|
|
309
|
+
def key(r):
|
|
310
|
+
if r["kind"] in _TIME_KINDS:
|
|
311
|
+
return (0, r["title"], 0)
|
|
312
|
+
pr = {"A": 0, "B": 1, "C": 2}.get(r["priority"], 3)
|
|
313
|
+
return (1, pr, r["id"])
|
|
314
|
+
|
|
315
|
+
return sorted(rows, key=key)
|
|
316
|
+
|
|
317
|
+
def _print_tree(con, node, depth, max_depth, *, include_canceled=False, log_tail=3, full=False):
|
|
318
|
+
out(_node_line(con, node, indent=" " * depth, sched=True))
|
|
319
|
+
if max_depth is not None and depth >= max_depth:
|
|
320
|
+
return
|
|
321
|
+
if node["kind"] == "day": # day has no real children (empty); expand today's log activity instead
|
|
322
|
+
_print_day_activity(con, node, depth, max_depth,
|
|
323
|
+
include_canceled=include_canceled, log_tail=log_tail, full=full)
|
|
324
|
+
return
|
|
325
|
+
for c in _tree_children(con, node, include_canceled=include_canceled):
|
|
326
|
+
_print_tree(con, c, depth + 1, max_depth,
|
|
327
|
+
include_canceled=include_canceled, log_tail=log_tail, full=full)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
_BUCKET_ORDER = ["work", "personal", "other"]
|
|
331
|
+
_WEEKDAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
332
|
+
|
|
333
|
+
def _print_day_activity(con, day_node, depth, max_depth, *, include_canceled=False, log_tail=3, full=False):
|
|
334
|
+
"""For a day node in the tree view, expand that day's activity: tasks with logs + that day's logs (today only, not others).
|
|
335
|
+
log_tail: None = full expansion / 0 = no log expansion / N = latest N per task (default 3, middle elided to keep wl tree compact)."""
|
|
336
|
+
from collections import OrderedDict
|
|
337
|
+
|
|
338
|
+
target = day_node["title"][:10]
|
|
339
|
+
cfrag, cparams = _status_filter_sql(include_canceled=include_canceled, col="node.status")
|
|
340
|
+
cancel_sql = (" AND " + cfrag) if cfrag else ""
|
|
341
|
+
rows = con.execute(
|
|
342
|
+
rf"""SELECT log.node_id, log.body, node.title, node.status, node.priority, node.kind
|
|
343
|
+
FROM log JOIN node ON log.node_id = node.id
|
|
344
|
+
WHERE date(log.logged_at) = ?
|
|
345
|
+
AND log.body NOT LIKE 'CLOCK\_%' ESCAPE '\'
|
|
346
|
+
AND node.kind IN ('task', 'habit', 'meetlog')
|
|
347
|
+
{cancel_sql}
|
|
348
|
+
ORDER BY log.node_id""",
|
|
349
|
+
[target] + cparams,
|
|
350
|
+
).fetchall()
|
|
351
|
+
tasks = OrderedDict()
|
|
352
|
+
for r in rows:
|
|
353
|
+
tasks.setdefault(r["node_id"], {"r": r, "logs": []})["logs"].append(r["body"])
|
|
354
|
+
ind = " " * (depth + 1)
|
|
355
|
+
for nid, t in tasks.items():
|
|
356
|
+
n = t["r"]
|
|
357
|
+
# habit with a log today = done today (render-layer smarts, same as _render_day_group)
|
|
358
|
+
if n["kind"] == "habit" and t["logs"]:
|
|
359
|
+
mk = _c("[x]", "done")
|
|
360
|
+
else:
|
|
361
|
+
mk = _c(_status_marker(n["status"]), _STATUS_STYLE.get(n["status"], "todo"))
|
|
362
|
+
pri = (_c(f"[#{n['priority']}]", _PRI_STYLE.get(n["priority"])) + " ") if n["priority"] else ""
|
|
363
|
+
out(ind + mk + " " + _c(f"#{nid}", "id") + " " + pri + _c(n["title"]))
|
|
364
|
+
if log_tail != 0 and (max_depth is None or depth + 1 < max_depth):
|
|
365
|
+
logs = t["logs"]
|
|
366
|
+
shown = logs if log_tail is None else logs[-log_tail:]
|
|
367
|
+
omitted = 0 if log_tail is None else max(0, len(logs) - log_tail)
|
|
368
|
+
if omitted:
|
|
369
|
+
out(" " * (depth + 2) + _c(f"· … ({omitted} earlier logs elided)", "meta"))
|
|
370
|
+
for body in shown:
|
|
371
|
+
indent = " " * (depth + 2)
|
|
372
|
+
shown_body = _truncate_log_body(body, indent_cols=len(indent) + 2, full=full)
|
|
373
|
+
out(indent + _c("· " + shown_body, "meta"))
|
|
374
|
+
|
|
375
|
+
def _print_default_tree(con, *, include_canceled=False, log_tail=3, full=False):
|
|
376
|
+
"""Default wl tree: areas one level (area name only) + timeline expanded up to today (year -> quarter -> month -> week -> today + today's activity).
|
|
377
|
+
To drill into an area's projects use --root <area>; for other days use --root <week/month>. CANCELED excluded by default."""
|
|
378
|
+
from datetime import date
|
|
379
|
+
|
|
380
|
+
life = con.execute("SELECT * FROM node WHERE kind = 'lifetime' ORDER BY id LIMIT 1").fetchone()
|
|
381
|
+
has_day = con.execute("SELECT 1 FROM node WHERE kind = 'day' LIMIT 1").fetchone()
|
|
382
|
+
has_month = con.execute("SELECT 1 FROM node WHERE kind = 'month' LIMIT 1").fetchone()
|
|
383
|
+
if not life and not has_day and not has_month:
|
|
384
|
+
print("(no root nodes)")
|
|
385
|
+
return
|
|
386
|
+
base = 0
|
|
387
|
+
if life:
|
|
388
|
+
out(_node_line(con, life))
|
|
389
|
+
base = 1
|
|
390
|
+
|
|
391
|
+
# timeline -> path to today (year -> quarter -> month -> week -> day) + today's activity; if no day node today, fall back to the latest month
|
|
392
|
+
today = date.today().isoformat()
|
|
393
|
+
dayn = con.execute(
|
|
394
|
+
"SELECT * FROM node WHERE kind = 'day' AND title LIKE ? ORDER BY id LIMIT 1", (today + "%",)
|
|
395
|
+
).fetchone()
|
|
396
|
+
if dayn:
|
|
397
|
+
chain = [n for n in _ancestors_chain(con, dayn["id"]) if n["kind"] != "lifetime"]
|
|
398
|
+
for d, n in enumerate(chain):
|
|
399
|
+
out(_node_line(con, n, indent=" " * (base + d), sched=True))
|
|
400
|
+
# today: only tasks, no log expansion (logs are for drill-down: wl day / wl tree --root <day> --depth big)
|
|
401
|
+
day_depth = base + len(chain) - 1
|
|
402
|
+
_print_day_activity(con, dayn, day_depth, max_depth=day_depth + 1, log_tail=log_tail, full=full)
|
|
403
|
+
else:
|
|
404
|
+
mon = con.execute("SELECT * FROM node WHERE kind = 'month' ORDER BY title DESC LIMIT 1").fetchone()
|
|
405
|
+
if mon:
|
|
406
|
+
out(_node_line(con, mon, indent=" " * base, sched=True))
|
|
407
|
+
|
|
408
|
+
# areas one level only (no project expansion)
|
|
409
|
+
if life:
|
|
410
|
+
for a in _tree_children(con, life, include_canceled=include_canceled):
|
|
411
|
+
if a["kind"] == "area":
|
|
412
|
+
out(_node_line(con, a, indent=" " * base, sched=True))
|
|
413
|
+
|
|
414
|
+
def _render_day_group(con, items, by="plan", sched_ids=frozenset(), log_tail=None, full=False):
|
|
415
|
+
"""Render a day: items = {nid: {"node": row(title/status/priority), "logs": [body...]}}.
|
|
416
|
+
Layout: bucket -> (plan/project/priority) -> task -> logs (indented). A task with no log but on a schedule is marked "planned·not-done".
|
|
417
|
+
log_tail: None = full / 0 = no expansion / N = latest N per task.
|
|
418
|
+
full: True keeps body untruncated (default truncates to one line by terminal width)."""
|
|
419
|
+
from collections import OrderedDict
|
|
420
|
+
|
|
421
|
+
buckets = OrderedDict()
|
|
422
|
+
for nid, it in items.items():
|
|
423
|
+
bucket = _node_bucket(con, nid)
|
|
424
|
+
key, title = _sec_group(con, nid, it["node"], by, sched_ids)
|
|
425
|
+
b = buckets.setdefault(bucket, OrderedDict())
|
|
426
|
+
g = b.setdefault(key, {"title": title, "tasks": OrderedDict()})
|
|
427
|
+
g["tasks"][nid] = it
|
|
428
|
+
|
|
429
|
+
sortk = _sec_sort_key(by)
|
|
430
|
+
for bucket in sorted(buckets, key=lambda x: _BUCKET_ORDER.index(x) if x in _BUCKET_ORDER else 99):
|
|
431
|
+
out(" " + _c(bucket, "header"))
|
|
432
|
+
groups = buckets[bucket].items()
|
|
433
|
+
if sortk:
|
|
434
|
+
groups = sorted(groups, key=lambda kv: sortk(kv[1]["title"]))
|
|
435
|
+
for _, g in groups:
|
|
436
|
+
out(" ▸ " + _c(g["title"], "kind"))
|
|
437
|
+
for nid, it in g["tasks"].items():
|
|
438
|
+
n = it["node"]
|
|
439
|
+
logs = it["logs"]
|
|
440
|
+
# habit with a log today = done today (render-layer smarts; resets next day; DB untouched)
|
|
441
|
+
if n["kind"] == "habit" and logs:
|
|
442
|
+
mk = _c("[x]", "done")
|
|
443
|
+
else:
|
|
444
|
+
mk = _c(_status_marker(n["status"]), _STATUS_STYLE.get(n["status"], "todo"))
|
|
445
|
+
pri = (_c(f"[#{n['priority']}]", _PRI_STYLE.get(n["priority"])) + " ") if n["priority"] else ""
|
|
446
|
+
hint = ""
|
|
447
|
+
if not logs:
|
|
448
|
+
hint = _c(" «planned·not-done»", "planned")
|
|
449
|
+
elif log_tail == 0:
|
|
450
|
+
# compact mode: don't expand body, attach a count hint after the title line
|
|
451
|
+
hint = _c(f" ({len(logs)} log)", "meta")
|
|
452
|
+
# total duration (CLOCK union log span); see _node_clock_min docstring
|
|
453
|
+
dur = _fmt_dur(_node_clock_min(con, nid))
|
|
454
|
+
dur_str = (" " + _c(dur, "clock")) if dur else ""
|
|
455
|
+
out(" " + mk + " " + _c(f"#{nid}", "id") + " " + pri + _c(n["title"]) + dur_str + hint)
|
|
456
|
+
if log_tail == 0:
|
|
457
|
+
continue
|
|
458
|
+
bodies = logs if log_tail is None else logs[-log_tail:]
|
|
459
|
+
if log_tail is not None and len(logs) > log_tail:
|
|
460
|
+
out(" " + _c(f"· … ({len(logs) - log_tail} earlier logs elided)", "meta"))
|
|
461
|
+
# log body rendering: indent 8 + "· " 2 = 10 cols, remaining width-10-2 for one line
|
|
462
|
+
for body in bodies:
|
|
463
|
+
shown = _truncate_log_body(body, indent_cols=10, full=full)
|
|
464
|
+
out(" " + _c("· " + shown, "meta"))
|
|
465
|
+
|
|
466
|
+
def _sec_sort_key(by):
|
|
467
|
+
if by == "priority":
|
|
468
|
+
return lambda lbl: _PRI_GROUP_ORDER.index(lbl) if lbl in _PRI_GROUP_ORDER else 99
|
|
469
|
+
if by == "plan":
|
|
470
|
+
return lambda lbl: _PLAN_ORDER.index(lbl) if lbl in _PLAN_ORDER else 99
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
def _sched_fires(on_date, rrule, target):
|
|
474
|
+
"""Whether this sched row fires on target (YYYY-MM-DD). Rules:
|
|
475
|
+
- daily: every day
|
|
476
|
+
- weekly:Mon,Wed,Fri | 1-7 | -1..-7: specific weekday(s) (number 1=Mon..7=Sun, -1=Sun..-7=Mon)
|
|
477
|
+
- monthly:5 | 5,15,25 | -1: day of month; -N counts from month end (-1=last day)
|
|
478
|
+
- quarterly:M-D | -1: M-th month in quarter (1-3), D-th day; -1 = quarter end (3/31, 6/30, 9/30, 12/31)
|
|
479
|
+
- yearly:03-21 | -1: every year MM-DD; -1 = year end (12-31)
|
|
480
|
+
"""
|
|
481
|
+
from datetime import date
|
|
482
|
+
import calendar
|
|
483
|
+
|
|
484
|
+
if on_date:
|
|
485
|
+
return on_date == target
|
|
486
|
+
if not rrule:
|
|
487
|
+
return False
|
|
488
|
+
rule = rrule.strip()
|
|
489
|
+
if rule == "daily":
|
|
490
|
+
return True
|
|
491
|
+
y, m, d = (int(x) for x in target.split("-"))
|
|
492
|
+
if rule.startswith("weekly:"):
|
|
493
|
+
days = [x.strip() for x in rule[len("weekly:"):].split(",") if x.strip()]
|
|
494
|
+
return _WEEKDAY_ABBR[date(y, m, d).weekday()] in days
|
|
495
|
+
if rule.startswith("monthly:"):
|
|
496
|
+
tokens = [x.strip() for x in rule[len("monthly:"):].split(",") if x.strip()]
|
|
497
|
+
last = calendar.monthrange(y, m)[1]
|
|
498
|
+
for tok in tokens:
|
|
499
|
+
n = int(tok)
|
|
500
|
+
target_day = n if n > 0 else last + n + 1 # -1 → last, -2 → last-1
|
|
501
|
+
if 1 <= target_day <= last and target_day == d:
|
|
502
|
+
return True
|
|
503
|
+
return False
|
|
504
|
+
if rule.startswith("quarterly:"):
|
|
505
|
+
tokens = [x.strip() for x in rule[len("quarterly:"):].split(",") if x.strip()]
|
|
506
|
+
quarter_month_idx = (m - 1) % 3 + 1 # month offset within the quarter: 1/2/3
|
|
507
|
+
last = calendar.monthrange(y, m)[1]
|
|
508
|
+
for tok in tokens:
|
|
509
|
+
if tok == "-1":
|
|
510
|
+
# quarter end: last day of the quarter's 3rd month (3/6/9/12)
|
|
511
|
+
if quarter_month_idx == 3 and d == last:
|
|
512
|
+
return True
|
|
513
|
+
continue
|
|
514
|
+
mm, dd = (int(x) for x in tok.split("-"))
|
|
515
|
+
if mm == quarter_month_idx and dd == d and 1 <= dd <= last:
|
|
516
|
+
return True
|
|
517
|
+
return False
|
|
518
|
+
if rule.startswith("yearly:"):
|
|
519
|
+
tokens = [x.strip() for x in rule[len("yearly:"):].split(",") if x.strip()]
|
|
520
|
+
md = f"{m:02d}-{d:02d}"
|
|
521
|
+
for tok in tokens:
|
|
522
|
+
if tok == "-1" and md == "12-31":
|
|
523
|
+
return True
|
|
524
|
+
if tok == md:
|
|
525
|
+
return True
|
|
526
|
+
return False
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
def _scheduled_node_ids(con, target):
|
|
530
|
+
"""Set of node_ids hit by a schedule on target (forward planning -> planned bucket)."""
|
|
531
|
+
ids = set()
|
|
532
|
+
for r in con.execute("SELECT node_id, on_date, rrule FROM sched"):
|
|
533
|
+
if _sched_fires(r["on_date"], r["rrule"], target):
|
|
534
|
+
ids.add(r["node_id"])
|
|
535
|
+
return ids
|
|
536
|
+
|
|
537
|
+
def _date_label(con, target):
|
|
538
|
+
"""Label (holiday/vacation/working-day-swap) for the date from date_meta, or None."""
|
|
539
|
+
r = con.execute("SELECT label FROM date_meta WHERE date = ?", (target,)).fetchone()
|
|
540
|
+
return r["label"] if r else None
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
_PLAN_ORDER = ["planned", "unplanned", "unplanned (untagged)"]
|
|
546
|
+
_PRI_GROUP_ORDER = ["P0", "P1", "P2", "—"]
|
|
547
|
+
_WEEKDAY_ABBR = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
548
|
+
|
|
549
|
+
def _cn_weekday(date_str):
|
|
550
|
+
"""YYYY-MM-DD -> weekday name (computed, not stored)"""
|
|
551
|
+
from datetime import date
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
y, m, d = (int(x) for x in date_str.split("-"))
|
|
555
|
+
return _WEEKDAY_NAMES[date(y, m, d).weekday()]
|
|
556
|
+
except (ValueError, IndexError):
|
|
557
|
+
return ""
|
|
558
|
+
|