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
worklog/commands/meta.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""worklog commands: meta group."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .. import render
|
|
14
|
+
from ..helpers import (
|
|
15
|
+
_apply_top_limit,
|
|
16
|
+
_fmt_dur,
|
|
17
|
+
_is_brief,
|
|
18
|
+
_log_full,
|
|
19
|
+
_norm_sched,
|
|
20
|
+
_resolve_at_ts,
|
|
21
|
+
_resolve_concrete_date,
|
|
22
|
+
_resolve_log_tail,
|
|
23
|
+
_resolve_window,
|
|
24
|
+
_sched_anchor,
|
|
25
|
+
_sched_display,
|
|
26
|
+
_sched_kind,
|
|
27
|
+
_sched_sort_key,
|
|
28
|
+
_status_marker,
|
|
29
|
+
_term_width,
|
|
30
|
+
_truncate_log_body,
|
|
31
|
+
GENERIC_TAGS,
|
|
32
|
+
)
|
|
33
|
+
from ..queries import (
|
|
34
|
+
_ancestors_chain,
|
|
35
|
+
_check_ids_exist,
|
|
36
|
+
_collect_descendants,
|
|
37
|
+
_has_tag,
|
|
38
|
+
_insert_log,
|
|
39
|
+
_node_bucket,
|
|
40
|
+
_node_clock_min,
|
|
41
|
+
_node_exists,
|
|
42
|
+
_node_plan,
|
|
43
|
+
_node_project,
|
|
44
|
+
_node_tags,
|
|
45
|
+
_project_members,
|
|
46
|
+
_sec_group,
|
|
47
|
+
_status_filter_sql,
|
|
48
|
+
_upsert_prop,
|
|
49
|
+
)
|
|
50
|
+
from ..render import (
|
|
51
|
+
_PRI_STYLE,
|
|
52
|
+
_STATUS_STYLE,
|
|
53
|
+
_RICH_AVAIL,
|
|
54
|
+
_resolve_theme,
|
|
55
|
+
THEMES,
|
|
56
|
+
_c,
|
|
57
|
+
_hl,
|
|
58
|
+
_node_line,
|
|
59
|
+
_print_truncation_hint,
|
|
60
|
+
_snippet,
|
|
61
|
+
out,
|
|
62
|
+
)
|
|
63
|
+
from ..xdg import _resolve_db_path, _resolve_aliases_path, _xdg_data_home, _xdg_config_home
|
|
64
|
+
|
|
65
|
+
# Lazy access to cli module (for DB wrappers + module state).
|
|
66
|
+
# Used at function call time (not at import) to avoid the cli ↔ commands
|
|
67
|
+
# import cycle.
|
|
68
|
+
from .. import cli as _cli # noqa: E402
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
from .views import _cn_weekday, _date_label, _scheduled_node_ids
|
|
72
|
+
|
|
73
|
+
from .state import _ids_list
|
|
74
|
+
from .views import _WEEKDAY_ABBR, _cn_weekday, _date_label, _scheduled_node_ids
|
|
75
|
+
|
|
76
|
+
def cmd_init(args, con):
|
|
77
|
+
_cli.db_init(con)
|
|
78
|
+
print(f"✓ DB initialized: {_resolve_db_path(args)}")
|
|
79
|
+
|
|
80
|
+
def cmd_config(args, con):
|
|
81
|
+
"""Print resolved configuration: where the DB and config files live + env."""
|
|
82
|
+
db = _resolve_db_path(args)
|
|
83
|
+
if getattr(args, "db", None):
|
|
84
|
+
db_src = "--db flag"
|
|
85
|
+
elif os.environ.get("WORKLOG_DB"):
|
|
86
|
+
db_src = "$WORKLOG_DB"
|
|
87
|
+
else:
|
|
88
|
+
db_src = "XDG default"
|
|
89
|
+
db_exists = db.exists()
|
|
90
|
+
db_size = f"{db.stat().st_size:,} bytes" if db_exists else "missing — run `wl init`"
|
|
91
|
+
|
|
92
|
+
aliases = _resolve_aliases_path()
|
|
93
|
+
|
|
94
|
+
def _row(label, value, hint=""):
|
|
95
|
+
hint_part = " " + _c(hint, "meta") if hint else ""
|
|
96
|
+
out(f" {label:<18} {value}{hint_part}")
|
|
97
|
+
|
|
98
|
+
out(_c(f"worklog {_cli.__version__}", "header"))
|
|
99
|
+
out("")
|
|
100
|
+
out(_c("paths:", "header"))
|
|
101
|
+
_row("database", db, f"[{db_src}] {db_size}")
|
|
102
|
+
_row("aliases", aliases, "(exists)" if aliases.exists() else "(not configured)")
|
|
103
|
+
out("")
|
|
104
|
+
out(_c("XDG directories:", "header"))
|
|
105
|
+
_row("XDG_DATA_HOME", _xdg_data_home(), "(env set)" if os.environ.get("XDG_DATA_HOME") else "(default)")
|
|
106
|
+
_row("XDG_CONFIG_HOME", _xdg_config_home(), "(env set)" if os.environ.get("XDG_CONFIG_HOME") else "(default)")
|
|
107
|
+
out("")
|
|
108
|
+
out(_c("environment:", "header"))
|
|
109
|
+
for var in ("WORKLOG_DB", "WORKLOG_COLOR", "WORKLOG_THEME", "NO_COLOR"):
|
|
110
|
+
val = os.environ.get(var)
|
|
111
|
+
_row(var, val if val else _c("(not set)", "meta"))
|
|
112
|
+
out("")
|
|
113
|
+
out(_c("runtime:", "header"))
|
|
114
|
+
_row("python", sys.executable, f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
|
115
|
+
_row("rich", "available" if render._RICH_AVAIL else "not installed (plain-text mode)")
|
|
116
|
+
|
|
117
|
+
def cmd_migrate(args, con):
|
|
118
|
+
"""List + apply pending SQL migrations (`migrations/NNNN_*.sql`).
|
|
119
|
+
|
|
120
|
+
Idempotent: re-running after everything is applied prints "up to date".
|
|
121
|
+
Failure mid-sequence rolls back the offending migration and leaves the DB
|
|
122
|
+
at the last successfully-applied number — re-run after fixing.
|
|
123
|
+
"""
|
|
124
|
+
files = _cli._migration_files()
|
|
125
|
+
current = _cli._db_version(con)
|
|
126
|
+
pending = [p for p in files if int(p.stem.split("_", 1)[0]) > current]
|
|
127
|
+
if not pending:
|
|
128
|
+
out(_c(f"✓ DB at version {current}, no pending migrations ({len(files)} total).", "done"))
|
|
129
|
+
return
|
|
130
|
+
out(_c(f"applying {len(pending)} migration(s) (DB at version {current}):", "header"))
|
|
131
|
+
applied = _cli._run_migrations(con, verbose=True)
|
|
132
|
+
new_version = _cli._db_version(con)
|
|
133
|
+
out(_c(f"✓ DB now at version {new_version} ({len(applied)} migration(s) applied).", "done"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ─── command handlers ───
|
|
137
|
+
|
|
138
|
+
def cmd_themes(args, con):
|
|
139
|
+
"""List all color themes, each rendering a one-line sample in its own palette for comparison."""
|
|
140
|
+
req = args.theme or os.environ.get("WORKLOG_THEME") or "auto"
|
|
141
|
+
cur = _resolve_theme(req) # resolve auto to a real theme
|
|
142
|
+
auto_note = f" (auto -> {cur})" if req in (None, "auto") else ""
|
|
143
|
+
no_color = args.color == "never" or os.environ.get("NO_COLOR")
|
|
144
|
+
if not render._RICH_AVAIL or no_color:
|
|
145
|
+
# no rich or color explicitly off: plain text listing
|
|
146
|
+
for name in THEMES:
|
|
147
|
+
mark = " <- current" if name == cur else ""
|
|
148
|
+
print(f"■ {name}{mark}")
|
|
149
|
+
print(f"current: {req}{auto_note}")
|
|
150
|
+
if not render._RICH_AVAIL:
|
|
151
|
+
print("(rich not installed; no color preview; pip install rich)")
|
|
152
|
+
return
|
|
153
|
+
# render the sample with each theme's own palette (force_terminal: keeps colors when piped to less -R)
|
|
154
|
+
for name in THEMES:
|
|
155
|
+
prev = render._RichConsole(theme=render._RichTheme(THEMES[name]), force_terminal=True, highlight=False, soft_wrap=True)
|
|
156
|
+
mark = f" [done]<- current {auto_note}[/done]" if name == cur else ""
|
|
157
|
+
prev.print(f"[header]■ {name}[/header]{mark}")
|
|
158
|
+
prev.print(" [done]\\[x][/done] [pri_a]\\[#A][/pri_a] [id]#42[/id] [kind]\\[project][/kind] "
|
|
159
|
+
"sample task with [hit]match[/hit] [planned]·planned[/planned] [clock]⏱30min[/clock] [tag]:work:[/tag]")
|
|
160
|
+
prev.print(" [doing]\\[/][/doing] [pri_b]\\[#B][/pri_b] [id]#43[/id] doing sample "
|
|
161
|
+
"[later]\\[>][/later] [pri_c]\\[#C][/pri_c] [id]#44[/id] later sample [meta]«meta»[/meta]")
|
|
162
|
+
prev.print()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- helpers ---
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- argparse ---
|
|
169
|
+
|
|
170
|
+
def cmd_dateinfo(args, con):
|
|
171
|
+
"""Date metadata: holiday / vacation / working-day-swap label. Set one / batch-import a yearly holiday table / list."""
|
|
172
|
+
if args.import_file:
|
|
173
|
+
import json
|
|
174
|
+
|
|
175
|
+
raw = sys.stdin.read() if args.import_file == "-" else Path(args.import_file).read_text(encoding="utf-8")
|
|
176
|
+
data = json.loads(raw) # {"2026-05-01": "Labor Day", ...}
|
|
177
|
+
n = 0
|
|
178
|
+
for d, label in data.items():
|
|
179
|
+
con.execute("INSERT OR REPLACE INTO date_meta (date, label) VALUES (?, ?)", (d, label))
|
|
180
|
+
n += 1
|
|
181
|
+
con.commit()
|
|
182
|
+
out(_c(f"✓ imported {n} date metadata entries", "meta"))
|
|
183
|
+
return
|
|
184
|
+
if args.date and args.label:
|
|
185
|
+
con.execute("INSERT OR REPLACE INTO date_meta (date, label) VALUES (?, ?)", (args.date, args.label))
|
|
186
|
+
con.commit()
|
|
187
|
+
out(_c(f"✓ {args.date} {_cn_weekday(args.date)} · {args.label}", "meta"))
|
|
188
|
+
return
|
|
189
|
+
if args.date and args.clear:
|
|
190
|
+
con.execute("DELETE FROM date_meta WHERE date = ?", (args.date,))
|
|
191
|
+
con.commit()
|
|
192
|
+
out(_c(f"✓ cleared metadata for {args.date}", "meta"))
|
|
193
|
+
return
|
|
194
|
+
# no args / only date: list
|
|
195
|
+
if args.date:
|
|
196
|
+
lbl = _date_label(con, args.date)
|
|
197
|
+
out(_c(f"{args.date} {_cn_weekday(args.date)}" + (f" · {lbl}" if lbl else " (no label)"), "meta"))
|
|
198
|
+
else:
|
|
199
|
+
for r in con.execute("SELECT date, label FROM date_meta ORDER BY date"):
|
|
200
|
+
out(_c(f"{r['date']} {_cn_weekday(r['date'])} · {r['label']}", "meta"))
|
|
201
|
+
|
|
202
|
+
def cmd_goal(args, con):
|
|
203
|
+
"""Shortcut to read/write today's goal: `wl goal` reads; `wl goal 'text'` writes. Today's day-node is auto-created if missing."""
|
|
204
|
+
nid = _ensure_today_day(con)
|
|
205
|
+
if not args.text:
|
|
206
|
+
v = _get_prop(con, nid, "goal")
|
|
207
|
+
out(v if v else _c("(no goal set for today)", "meta"))
|
|
208
|
+
return
|
|
209
|
+
_set_prop(con, nid, "goal", args.text)
|
|
210
|
+
out(_c(f"✓ today's goal: {args.text}", "meta"))
|
|
211
|
+
|
|
212
|
+
def cmd_summary_prop(args, con):
|
|
213
|
+
"""Shortcut to read/write today's end-of-day recap. On write, stamps summary_at (YYYY-MM-DD HH:MM:SS),
|
|
214
|
+
so we can later detect changes added after the recap (wl day shows a hint to rewrite)."""
|
|
215
|
+
nid = _ensure_today_day(con)
|
|
216
|
+
if not args.text:
|
|
217
|
+
v = _get_prop(con, nid, "summary")
|
|
218
|
+
if not v:
|
|
219
|
+
out(_c("(no summary set for today)", "meta"))
|
|
220
|
+
return
|
|
221
|
+
at = _get_prop(con, nid, "summary_at")
|
|
222
|
+
out(v + (_c(f" (written at {at})", "meta") if at else ""))
|
|
223
|
+
return
|
|
224
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
225
|
+
_upsert_prop(con, nid, "summary", args.text)
|
|
226
|
+
_upsert_prop(con, nid, "summary_at", now)
|
|
227
|
+
con.commit()
|
|
228
|
+
out(_c(f"✓ today's summary (written at {now}): {args.text}", "meta"))
|
|
229
|
+
|
|
230
|
+
def cmd_checkin(args, con):
|
|
231
|
+
"""Interactive check-in for today's habits.
|
|
232
|
+
Default: multi-select (up/down + space + Enter), pick all at once and check in.
|
|
233
|
+
--per-item: per-item prompt mode (allows per-item note; also the fallback for non-TTY / piped input)."""
|
|
234
|
+
import sys
|
|
235
|
+
|
|
236
|
+
rows, today, kinds = _checkin_collect(con, args)
|
|
237
|
+
if not rows:
|
|
238
|
+
out(_c(f"(no {'/'.join(kinds)} scheduled to check in for {today})", "meta"))
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
pending = [r for r in rows if not r["already"]]
|
|
242
|
+
pre_done = len(rows) - len(pending)
|
|
243
|
+
|
|
244
|
+
if not pending:
|
|
245
|
+
out(_c(f"all {len(rows)}/{len(rows)} already checked in for {today} ✓", "done"))
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
if getattr(args, "per_item", False) or not _is_interactive_tty():
|
|
249
|
+
_checkin_per_item(con, rows)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
header = _c(f"{today} · pick habits done today (already checked in {pre_done}/{len(rows)})", "header")
|
|
253
|
+
# default unselected for all (use space to toggle on what you did); intuitive: 'mark what I did' not 'unmark what I missed'
|
|
254
|
+
options = [(f"#{r['id']} {r['title']}", False) for r in pending]
|
|
255
|
+
chosen = _multi_select_tty(options, header)
|
|
256
|
+
if chosen is None:
|
|
257
|
+
out(_c("(canceled, no changes made)", "meta"))
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
for i in chosen:
|
|
261
|
+
_insert_log(con, pending[i]["id"], "✓ done")
|
|
262
|
+
con.commit()
|
|
263
|
+
done_now = len(chosen)
|
|
264
|
+
skipped = len(pending) - done_now
|
|
265
|
+
out(_c(
|
|
266
|
+
f"done {pre_done + done_now}/{len(rows)} · new this run {done_now}" +
|
|
267
|
+
(f" · skipped {skipped}" if skipped else "") +
|
|
268
|
+
" · for detailed notes use `wl tick <id> --note ...` or `wl checkin --per-item`",
|
|
269
|
+
"header"))
|
|
270
|
+
|
|
271
|
+
def cmd_sched(args, con):
|
|
272
|
+
"""Forward planning: schedule a task to a specific day / recurrence. A scheduled task appears as 'planned' in wl day even with no log.
|
|
273
|
+
Accepts multiple ids: wl sched 18 19 20 today (first N are ids; the trailing positional is the date)."""
|
|
274
|
+
ids = _ids_list(args)
|
|
275
|
+
_check_ids_exist(con, ids)
|
|
276
|
+
if args.clear:
|
|
277
|
+
for nid in ids:
|
|
278
|
+
cur = con.execute("DELETE FROM sched WHERE node_id = ?", (nid,))
|
|
279
|
+
out(_c(f"✓ #{nid} cleared {cur.rowcount} schedule entries", "meta"))
|
|
280
|
+
con.commit()
|
|
281
|
+
return
|
|
282
|
+
if not args.when and not args.recur:
|
|
283
|
+
# if multiple ids, show schedule for each (caters to single-id scenario)
|
|
284
|
+
for nid in ids:
|
|
285
|
+
rows = con.execute(
|
|
286
|
+
"SELECT on_date, rrule FROM sched WHERE node_id = ? ORDER BY on_date NULLS LAST, rrule", (nid,)
|
|
287
|
+
).fetchall()
|
|
288
|
+
if not rows:
|
|
289
|
+
out(_c(f"#{nid} has no schedule", "meta"))
|
|
290
|
+
for r in rows:
|
|
291
|
+
out(" " + _c(f"#{nid} @" + (r["on_date"] or r["rrule"]), "planned"))
|
|
292
|
+
return
|
|
293
|
+
if args.recur:
|
|
294
|
+
try:
|
|
295
|
+
rule = _norm_rrule(args.recur)
|
|
296
|
+
except ValueError as e:
|
|
297
|
+
sys.exit(f"✗ {e}")
|
|
298
|
+
for nid in ids:
|
|
299
|
+
con.execute("INSERT INTO sched (node_id, rrule) VALUES (?, ?)", (nid, rule))
|
|
300
|
+
con.commit()
|
|
301
|
+
for nid in ids:
|
|
302
|
+
out(_c(f"✓ #{nid} recurring schedule: {rule}", "meta"))
|
|
303
|
+
if args.when:
|
|
304
|
+
try:
|
|
305
|
+
d = _resolve_concrete_date(args.when)
|
|
306
|
+
except ValueError:
|
|
307
|
+
sys.exit(f"✗ invalid date '{args.when}' (use YYYY-MM-DD / today / yesterday / tomorrow / day-after-tomorrow)")
|
|
308
|
+
for nid in ids:
|
|
309
|
+
con.execute("INSERT INTO sched (node_id, on_date) VALUES (?, ?)", (nid, d))
|
|
310
|
+
con.commit()
|
|
311
|
+
for nid in ids:
|
|
312
|
+
out(_c(f"✓ #{nid} scheduled to {d}", "meta"))
|
|
313
|
+
|
|
314
|
+
def _set_prop(con, nid, key, value):
|
|
315
|
+
_upsert_prop(con, nid, key, value)
|
|
316
|
+
con.commit()
|
|
317
|
+
|
|
318
|
+
def _get_prop(con, nid, key):
|
|
319
|
+
r = con.execute("SELECT value FROM prop WHERE node_id = ? AND key = ?", (nid, key)).fetchone()
|
|
320
|
+
return r["value"] if r else None
|
|
321
|
+
|
|
322
|
+
def _ensure_today_day(con):
|
|
323
|
+
"""Return today's day-node id; create one if missing (attach to current ISO week; unparented if week absent)."""
|
|
324
|
+
from datetime import date
|
|
325
|
+
|
|
326
|
+
today = date.today().isoformat()
|
|
327
|
+
r = con.execute(
|
|
328
|
+
"SELECT id FROM node WHERE kind='day' AND title LIKE ? ORDER BY id LIMIT 1", (today + "%",)
|
|
329
|
+
).fetchone()
|
|
330
|
+
if r:
|
|
331
|
+
return r["id"]
|
|
332
|
+
iso = date.today().isocalendar()
|
|
333
|
+
week_title = f"{iso[0]}-W{iso[1]:02d}"
|
|
334
|
+
w = con.execute("SELECT id FROM node WHERE kind='week' AND title = ? LIMIT 1", (week_title,)).fetchone()
|
|
335
|
+
cur = con.execute(
|
|
336
|
+
"INSERT INTO node (parent_id, title, kind) VALUES (?, ?, 'day')",
|
|
337
|
+
(w["id"] if w else None, today),
|
|
338
|
+
)
|
|
339
|
+
con.commit()
|
|
340
|
+
return cur.lastrowid
|
|
341
|
+
|
|
342
|
+
def _checkin_collect(con, args):
|
|
343
|
+
"""Collect today's habits to check in. Returns [{id, title, priority, kind, already}]."""
|
|
344
|
+
from datetime import date as _d
|
|
345
|
+
|
|
346
|
+
today = _d.today().isoformat()
|
|
347
|
+
sched_ids = _scheduled_node_ids(con, today)
|
|
348
|
+
kinds = {args.kind} if args.kind else {"habit"}
|
|
349
|
+
if args.all_kinds:
|
|
350
|
+
kinds = {"habit", "task", "meetlog"}
|
|
351
|
+
|
|
352
|
+
rows = []
|
|
353
|
+
for nid in sorted(sched_ids):
|
|
354
|
+
n = con.execute("SELECT * FROM node WHERE id = ?", (nid,)).fetchone()
|
|
355
|
+
if not n or n["kind"] not in kinds:
|
|
356
|
+
continue
|
|
357
|
+
if n["status"] == "CANCELED" and not getattr(args, "show_canceled", False):
|
|
358
|
+
continue
|
|
359
|
+
already = con.execute(
|
|
360
|
+
"SELECT 1 FROM log WHERE node_id = ? AND date(logged_at) = ? "
|
|
361
|
+
"AND body NOT LIKE 'CLOCK\\_%' ESCAPE '\\' LIMIT 1",
|
|
362
|
+
(nid, today),
|
|
363
|
+
).fetchone()
|
|
364
|
+
rows.append({
|
|
365
|
+
"id": n["id"], "title": n["title"], "priority": n["priority"],
|
|
366
|
+
"kind": n["kind"], "already": bool(already),
|
|
367
|
+
})
|
|
368
|
+
return rows, today, kinds
|
|
369
|
+
|
|
370
|
+
def _is_interactive_tty():
|
|
371
|
+
"""Whether we can run a raw-mode TUI: both stdin and stdout are TTYs. Used by tests via monkeypatch."""
|
|
372
|
+
import sys
|
|
373
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
374
|
+
|
|
375
|
+
def _multi_select_tty(options, header): # pragma: no cover -- TTY interactive, needs termios+os.read, manual smoke only
|
|
376
|
+
"""Terminal multi-select widget (rich.Live render, no misalignment): up/down moves cursor; space toggles; Enter confirms; q/Esc cancels.
|
|
377
|
+
options: [(label, default_selected)]
|
|
378
|
+
Returns: list of selected indices, or None (canceled).
|
|
379
|
+
Requires rich available + both stdin/stdout are TTYs; otherwise returns None so caller can fall back."""
|
|
380
|
+
import sys
|
|
381
|
+
if not render._RICH_AVAIL or not _is_interactive_tty():
|
|
382
|
+
return None
|
|
383
|
+
import os, termios, tty, select
|
|
384
|
+
from rich.console import Console as _LiveConsole
|
|
385
|
+
from rich.live import Live
|
|
386
|
+
from rich.text import Text
|
|
387
|
+
|
|
388
|
+
selected = [d for _, d in options]
|
|
389
|
+
cursor = 0
|
|
390
|
+
n = len(options)
|
|
391
|
+
|
|
392
|
+
def make_view():
|
|
393
|
+
# header may contain [style]..[/style] markup; from_markup parses; rich output handles \r\n
|
|
394
|
+
t = Text.from_markup(header)
|
|
395
|
+
t.append("\n")
|
|
396
|
+
t.append("(up/down or j/k to move · space to toggle · Enter to confirm · q/Esc to cancel)\n\n",
|
|
397
|
+
style="dim")
|
|
398
|
+
for i, (label, _) in enumerate(options):
|
|
399
|
+
mark = "[x] " if selected[i] else "[ ] "
|
|
400
|
+
pointer = "▸ " if i == cursor else " "
|
|
401
|
+
line = f" {pointer}{mark}{label}\n"
|
|
402
|
+
t.append(line, style="bold reverse" if i == cursor else None)
|
|
403
|
+
return t
|
|
404
|
+
|
|
405
|
+
fd = sys.stdin.fileno()
|
|
406
|
+
old = termios.tcgetattr(fd)
|
|
407
|
+
canceled = False
|
|
408
|
+
# use a separate Console to avoid collision with wl's global render._CONSOLE theme/highlight
|
|
409
|
+
live_console = _LiveConsole(file=sys.stderr, force_terminal=True)
|
|
410
|
+
try:
|
|
411
|
+
# cbreak (not raw): disable echo + line buffer but keep ONLCR (\n auto-adds \r);
|
|
412
|
+
# otherwise rich's \n won't return to col 0 and each line drifts right
|
|
413
|
+
tty.setcbreak(fd)
|
|
414
|
+
# important: use os.read(fd, ...) to bypass Python's sys.stdin buffer.
|
|
415
|
+
# sys.stdin.read(1) would swallow the entire ESC[A 3-byte sequence; select would then
|
|
416
|
+
# see no more data and misinterpret ESC as a single keypress -> exit (root cause of prior bug)
|
|
417
|
+
def read_byte():
|
|
418
|
+
return os.read(fd, 1).decode("utf-8", errors="replace")
|
|
419
|
+
|
|
420
|
+
def peek_more(timeout):
|
|
421
|
+
# check whether fd has more bytes ready (terminals emit ESC[A as 3 bytes nearly instantly)
|
|
422
|
+
return bool(select.select([fd], [], [], timeout)[0])
|
|
423
|
+
|
|
424
|
+
with Live(make_view(), console=live_console, refresh_per_second=30,
|
|
425
|
+
screen=False, transient=True) as live:
|
|
426
|
+
while True:
|
|
427
|
+
ch = read_byte()
|
|
428
|
+
if ch == "\x1b": # ESC or arrow sequence
|
|
429
|
+
if peek_more(0.05): # more bytes already there = escape sequence
|
|
430
|
+
seq = os.read(fd, 2).decode("utf-8", errors="replace")
|
|
431
|
+
if seq == "[A":
|
|
432
|
+
cursor = (cursor - 1) % n
|
|
433
|
+
elif seq == "[B":
|
|
434
|
+
cursor = (cursor + 1) % n
|
|
435
|
+
# other arrows / Home / End: ignore
|
|
436
|
+
else:
|
|
437
|
+
canceled = True
|
|
438
|
+
break
|
|
439
|
+
elif ch == " ":
|
|
440
|
+
selected[cursor] = not selected[cursor]
|
|
441
|
+
elif ch in ("\r", "\n"):
|
|
442
|
+
break
|
|
443
|
+
elif ch in ("q", "Q", "\x03", "\x04"): # q / Ctrl-C / Ctrl-D
|
|
444
|
+
canceled = True
|
|
445
|
+
break
|
|
446
|
+
elif ch in ("j", "J"):
|
|
447
|
+
cursor = (cursor + 1) % n
|
|
448
|
+
elif ch in ("k", "K"):
|
|
449
|
+
cursor = (cursor - 1) % n
|
|
450
|
+
live.update(make_view())
|
|
451
|
+
finally:
|
|
452
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
453
|
+
if canceled:
|
|
454
|
+
return None
|
|
455
|
+
return [i for i, s in enumerate(selected) if s]
|
|
456
|
+
|
|
457
|
+
def _checkin_per_item(con, rows):
|
|
458
|
+
"""Per-item prompt fallback mode: y/n/note/q (works on non-TTY / piped input; also supports per-item note)."""
|
|
459
|
+
pre_done = sum(1 for r in rows if r["already"])
|
|
460
|
+
out(_c(f"{len(rows)} items to check in, {pre_done} already done:", "header"))
|
|
461
|
+
out(_c("Input: [Enter]/y = check in · n = skip · q = quit · any other text = check in with that as note", "meta"))
|
|
462
|
+
print()
|
|
463
|
+
|
|
464
|
+
done_now = skipped = 0
|
|
465
|
+
for r in rows:
|
|
466
|
+
nid = r["id"]
|
|
467
|
+
pri = f"[#{r['priority']}]" if r["priority"] else ""
|
|
468
|
+
head = f"#{nid} {pri} {r['title']}".strip()
|
|
469
|
+
if r["already"]:
|
|
470
|
+
out(_c(f" ✓ {head} (already done today)", "done"))
|
|
471
|
+
continue
|
|
472
|
+
try:
|
|
473
|
+
ans = input(_c(f" ▸ {head}\n [y/n/note/q] > ", "header")).strip()
|
|
474
|
+
except (EOFError, KeyboardInterrupt):
|
|
475
|
+
print()
|
|
476
|
+
out(_c("(interrupted; remaining tasks skipped)", "meta"))
|
|
477
|
+
break
|
|
478
|
+
if ans in ("q", "Q", "exit", "quit"):
|
|
479
|
+
out(_c("(quit)", "meta"))
|
|
480
|
+
break
|
|
481
|
+
if ans in ("n", "N", "no", "skip"):
|
|
482
|
+
skipped += 1
|
|
483
|
+
out(_c(f" ⏭ #{nid} skipped", "meta"))
|
|
484
|
+
continue
|
|
485
|
+
body = "✓ done" if ans in ("", "y", "Y", "yes") else ans
|
|
486
|
+
_insert_log(con, nid, body)
|
|
487
|
+
con.commit()
|
|
488
|
+
done_now += 1
|
|
489
|
+
marker = _c(" ✓", "done")
|
|
490
|
+
if body == "✓ done":
|
|
491
|
+
out(f"{marker} #{nid} checked in")
|
|
492
|
+
else:
|
|
493
|
+
out(f"{marker} #{nid} checked in: {_c(body, 'meta')}")
|
|
494
|
+
print()
|
|
495
|
+
out(_c(
|
|
496
|
+
f"done {pre_done + done_now}/{len(rows)} · new this run {done_now}" +
|
|
497
|
+
(f" · skipped {skipped}" if skipped else ""),
|
|
498
|
+
"header"))
|
|
499
|
+
|
|
500
|
+
def _norm_rrule(s):
|
|
501
|
+
"""Validate / normalize a recurrence rule:
|
|
502
|
+
- daily
|
|
503
|
+
- weekly:Mon,Wed,Fri | 1-7 | -1..-7 (1=Mon..7=Sun, -1=Sun..-7=Mon)
|
|
504
|
+
- monthly:5 / 5,15 / -1 (last day of month)
|
|
505
|
+
- quarterly:M-D / -1 (last day of quarter): M in 1-3 = month offset within the quarter
|
|
506
|
+
- yearly:03-21 / -1 (last day of year): MM-DD
|
|
507
|
+
"""
|
|
508
|
+
import re as _re
|
|
509
|
+
rule = s.strip()
|
|
510
|
+
if rule == "daily":
|
|
511
|
+
return "daily"
|
|
512
|
+
if rule.startswith("weekly:"):
|
|
513
|
+
raw = [x.strip() for x in rule[len("weekly:"):].split(",") if x.strip()]
|
|
514
|
+
if not raw:
|
|
515
|
+
raise ValueError("weekly rule needs at least 1 weekday (Mon/Tue/.../Sun or 1-7 / -1..-7)")
|
|
516
|
+
norm_days = []
|
|
517
|
+
for tok in raw:
|
|
518
|
+
cap = tok.capitalize()
|
|
519
|
+
if cap in _WEEKDAY_ABBR:
|
|
520
|
+
norm_days.append(cap)
|
|
521
|
+
continue
|
|
522
|
+
try:
|
|
523
|
+
n = int(tok)
|
|
524
|
+
except ValueError:
|
|
525
|
+
raise ValueError(f"invalid weekly day '{tok}' (use Mon..Sun or 1-7 / -1..-7)")
|
|
526
|
+
if n > 0 and 1 <= n <= 7:
|
|
527
|
+
norm_days.append(_WEEKDAY_ABBR[n - 1])
|
|
528
|
+
elif n < 0 and -7 <= n <= -1:
|
|
529
|
+
norm_days.append(_WEEKDAY_ABBR[7 + n]) # -1 -> 6 (Sun), -7 -> 0 (Mon)
|
|
530
|
+
else:
|
|
531
|
+
raise ValueError(f"weekly number '{n}' out of range (allowed 1-7 or -1..-7)")
|
|
532
|
+
# dedup preserve order
|
|
533
|
+
seen = set()
|
|
534
|
+
deduped = [d for d in norm_days if not (d in seen or seen.add(d))]
|
|
535
|
+
return "weekly:" + ",".join(deduped)
|
|
536
|
+
if rule.startswith("monthly:"):
|
|
537
|
+
tokens = [x.strip() for x in rule[len("monthly:"):].split(",") if x.strip()]
|
|
538
|
+
if not tokens:
|
|
539
|
+
raise ValueError("monthly rule needs at least 1 day (e.g. monthly:5 / monthly:1,15 / monthly:-1)")
|
|
540
|
+
norm = []
|
|
541
|
+
for tok in tokens:
|
|
542
|
+
try:
|
|
543
|
+
n = int(tok)
|
|
544
|
+
except ValueError:
|
|
545
|
+
raise ValueError(f"monthly day must be an integer: '{tok}' (positive 1-31 / negative -1..-28 from month-end)")
|
|
546
|
+
if n == 0 or not (-28 <= n <= 31):
|
|
547
|
+
raise ValueError(f"monthly day '{n}' out of range (allowed 1-31 or -1..-28)")
|
|
548
|
+
norm.append(str(n))
|
|
549
|
+
return "monthly:" + ",".join(norm)
|
|
550
|
+
if rule.startswith("quarterly:"):
|
|
551
|
+
tokens = [x.strip() for x in rule[len("quarterly:"):].split(",") if x.strip()]
|
|
552
|
+
if not tokens:
|
|
553
|
+
raise ValueError("quarterly rule needs at least 1 M-D or -1 (e.g. quarterly:1-15 / quarterly:-1)")
|
|
554
|
+
for tok in tokens:
|
|
555
|
+
if tok == "-1":
|
|
556
|
+
continue
|
|
557
|
+
if not _re.fullmatch(r"\d{1,2}-\d{1,2}", tok):
|
|
558
|
+
raise ValueError(f"invalid quarterly '{tok}' (expected M-D, M in 1-3 month-in-quarter; or -1 quarter end)")
|
|
559
|
+
mm, dd = (int(x) for x in tok.split("-"))
|
|
560
|
+
if not (1 <= mm <= 3):
|
|
561
|
+
raise ValueError(f"quarterly '{tok}' month offset out of range (1=Q-start / 2=mid / 3=Q-end)")
|
|
562
|
+
if not (1 <= dd <= 31):
|
|
563
|
+
raise ValueError(f"quarterly '{tok}' day out of range (1-31)")
|
|
564
|
+
return "quarterly:" + ",".join(tokens)
|
|
565
|
+
if rule.startswith("yearly:"):
|
|
566
|
+
tokens = [x.strip() for x in rule[len("yearly:"):].split(",") if x.strip()]
|
|
567
|
+
if not tokens:
|
|
568
|
+
raise ValueError("yearly rule needs at least 1 MM-DD or -1 (e.g. yearly:03-21 / yearly:-1)")
|
|
569
|
+
for tok in tokens:
|
|
570
|
+
if tok == "-1":
|
|
571
|
+
continue
|
|
572
|
+
if not _re.fullmatch(r"\d{2}-\d{2}", tok):
|
|
573
|
+
raise ValueError(f"invalid yearly '{tok}' (expected MM-DD like 03-21; or -1 year end)")
|
|
574
|
+
mm, dd = int(tok[:2]), int(tok[3:])
|
|
575
|
+
if not (1 <= mm <= 12 and 1 <= dd <= 31):
|
|
576
|
+
raise ValueError(f"yearly '{tok}' out of range (month 1-12 / day 1-31)")
|
|
577
|
+
return "yearly:" + ",".join(tokens)
|
|
578
|
+
raise ValueError(f"unknown recurrence rule '{s}' (supports daily / weekly / monthly / quarterly / yearly, each accepting -1 = end of cycle)")
|
|
579
|
+
|