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/render.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Rich-based highlighting and node-line rendering for worklog.
|
|
2
|
+
|
|
3
|
+
Holds the mutable `_CONSOLE` state (set by `_init_console` from main()).
|
|
4
|
+
Coloring helpers (`_c`, `_hl`, `out`) read `_CONSOLE` at call time, so the
|
|
5
|
+
rest of the codebase doesn't pass a console object around. The trade-off is
|
|
6
|
+
that tests reading `_CONSOLE` must do so via `wl._CONSOLE` (a live
|
|
7
|
+
attribute lookup) rather than `wl._CONSOLE` (an import-time binding that
|
|
8
|
+
would not follow mutations).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from rich.console import Console as _RichConsole
|
|
17
|
+
from rich.theme import Theme as _RichTheme
|
|
18
|
+
from rich.markup import escape as _rich_escape
|
|
19
|
+
_RICH_AVAIL = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
_RICH_AVAIL = False
|
|
22
|
+
|
|
23
|
+
# theme = semantic element -> rich style. No "default" theme; default is auto, probes terminal bg and resolves to dark/light/mono.
|
|
24
|
+
_THEME_KEYS = "done doing later wait todo canceled pri_a pri_b pri_c id kind tag hit header meta planned clock".split()
|
|
25
|
+
THEMES = {
|
|
26
|
+
# dark: dark background, use bright_* for contrast
|
|
27
|
+
"dark": {
|
|
28
|
+
"done": "bright_green", "doing": "bright_yellow", "later": "bright_cyan", "wait": "grey50",
|
|
29
|
+
"todo": "default", "canceled": "strike grey50",
|
|
30
|
+
"pri_a": "bold bright_red", "pri_b": "bright_yellow", "pri_c": "grey50",
|
|
31
|
+
"id": "grey50", "kind": "bright_cyan", "tag": "bright_magenta", "hit": "bold black on bright_yellow",
|
|
32
|
+
"header": "bold bright_white", "meta": "grey50", "planned": "bright_blue", "clock": "bright_green",
|
|
33
|
+
},
|
|
34
|
+
# light: light background, use deep saturated colors (avoid bright/white getting lost on white bg)
|
|
35
|
+
"light": {
|
|
36
|
+
"done": "green4", "doing": "dark_orange3", "later": "blue", "wait": "grey42",
|
|
37
|
+
"todo": "default", "canceled": "strike grey42",
|
|
38
|
+
"pri_a": "bold red3", "pri_b": "dark_orange3", "pri_c": "grey42",
|
|
39
|
+
"id": "grey42", "kind": "dark_cyan", "tag": "purple", "hit": "bold black on yellow3",
|
|
40
|
+
"header": "bold grey15", "meta": "grey42", "planned": "blue", "clock": "green4",
|
|
41
|
+
},
|
|
42
|
+
# mono: no color (want rich layout but no color)
|
|
43
|
+
"mono": {k: "default" for k in _THEME_KEYS},
|
|
44
|
+
}
|
|
45
|
+
_STATUS_STYLE = {"DONE": "done", "DOING": "doing", "LATER": "later", "WAIT": "wait",
|
|
46
|
+
"TODO": "todo", "DEFERRED": "later", "CANCELED": "canceled", None: "todo"}
|
|
47
|
+
_PRI_STYLE = {"A": "pri_a", "B": "pri_b", "C": "pri_c"}
|
|
48
|
+
|
|
49
|
+
_CONSOLE = None # initialized by main() based on --color/--theme; None = plain text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _resolve_color(mode):
|
|
53
|
+
if mode is None:
|
|
54
|
+
mode = os.environ.get("WORKLOG_COLOR", "auto")
|
|
55
|
+
if mode == "never":
|
|
56
|
+
return False
|
|
57
|
+
if mode == "always":
|
|
58
|
+
return True
|
|
59
|
+
return _RICH_AVAIL and sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _detect_bg_is_dark(): # pragma: no cover -- TTY/escape-seq probe, not unit-tested at integration layer
|
|
63
|
+
"""Detect terminal bg: True=dark / False=light / None=unknown.
|
|
64
|
+
First check $COLORFGBG (no I/O), then query OSC 11 (requires TTY, short timeout)."""
|
|
65
|
+
fgbg = os.environ.get("COLORFGBG")
|
|
66
|
+
if fgbg and ";" in fgbg:
|
|
67
|
+
try:
|
|
68
|
+
bg = int(fgbg.split(";")[-1])
|
|
69
|
+
return bg not in (7, 15) # 7/15 = light bg, others treated as dark
|
|
70
|
+
except ValueError:
|
|
71
|
+
pass
|
|
72
|
+
if not (sys.stdout.isatty() and sys.stdin.isatty()):
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
import termios, tty, select, re
|
|
76
|
+
fd = sys.stdin.fileno()
|
|
77
|
+
old = termios.tcgetattr(fd)
|
|
78
|
+
try:
|
|
79
|
+
tty.setraw(fd)
|
|
80
|
+
sys.stdout.write("\033]11;?\033\\")
|
|
81
|
+
sys.stdout.flush()
|
|
82
|
+
resp = ""
|
|
83
|
+
if select.select([fd], [], [], 0.15)[0]:
|
|
84
|
+
resp = os.read(fd, 64).decode("latin-1", "ignore")
|
|
85
|
+
finally:
|
|
86
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
87
|
+
m = re.search(r"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", resp)
|
|
88
|
+
if not m:
|
|
89
|
+
return None
|
|
90
|
+
r, g, b = (int(m.group(i)[:2], 16) for i in (1, 2, 3)) # take top 2 hex digits per channel
|
|
91
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5 # perceived brightness < 0.5 = dark
|
|
92
|
+
except (ValueError, AttributeError):
|
|
93
|
+
# int(..., 16) parse failure / m.group out of range -> undetectable, treat as unknown
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_theme(name):
|
|
98
|
+
"""Resolve theme name to a real palette name. auto (default): probe bg -> dark/light, fallback dark if unknown."""
|
|
99
|
+
if name in THEMES:
|
|
100
|
+
return name # explicit real theme
|
|
101
|
+
# name is None / "auto" / unknown -> auto-detect
|
|
102
|
+
dark = _detect_bg_is_dark()
|
|
103
|
+
if dark is False:
|
|
104
|
+
return "light"
|
|
105
|
+
return "dark" # dark or unknown -> use dark (most terminals have dark bg)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _init_console(color_mode, theme_name):
|
|
109
|
+
global _CONSOLE
|
|
110
|
+
if not _resolve_color(color_mode) or not _RICH_AVAIL:
|
|
111
|
+
_CONSOLE = None
|
|
112
|
+
return
|
|
113
|
+
name = _resolve_theme(theme_name or os.environ.get("WORKLOG_THEME"))
|
|
114
|
+
force = True if color_mode == "always" else None
|
|
115
|
+
_CONSOLE = _RichConsole(theme=_RichTheme(THEMES[name]), force_terminal=force, highlight=False, soft_wrap=True)
|
|
116
|
+
# terminal without color support (TERM=dumb etc.) -> effectively mono, rich won't emit ANSI
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def out(s):
|
|
120
|
+
"""Unified output: when highlighting is enabled, use rich (markup rendering); otherwise plain print."""
|
|
121
|
+
if _CONSOLE is not None:
|
|
122
|
+
_CONSOLE.print(s)
|
|
123
|
+
else:
|
|
124
|
+
print(s)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _c(text, style=None):
|
|
128
|
+
"""Color a fragment: returns rich markup when enabled (content escaped to prevent injection), otherwise plain text."""
|
|
129
|
+
t = str(text)
|
|
130
|
+
if _CONSOLE is None:
|
|
131
|
+
return t
|
|
132
|
+
t = _rich_escape(t)
|
|
133
|
+
return f"[{style}]{t}[/{style}]" if style else t
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _hl(text, q):
|
|
137
|
+
"""In a string, mark query matches (styled: hit style / plain: *…*). No match -> plain _c."""
|
|
138
|
+
text = str(text)
|
|
139
|
+
if not q:
|
|
140
|
+
return _c(text)
|
|
141
|
+
i = text.lower().find(q.lower())
|
|
142
|
+
if i < 0:
|
|
143
|
+
return _c(text)
|
|
144
|
+
mid = text[i:i + len(q)]
|
|
145
|
+
pre, post = text[:i], text[i + len(q):]
|
|
146
|
+
if _CONSOLE is None:
|
|
147
|
+
return pre + f"*{mid}*" + post
|
|
148
|
+
return _c(pre) + _c(mid, "hit") + _c(post)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- node-line rendering (extracted from cli.py) ---
|
|
152
|
+
from .helpers import _status_marker, _sched_display, _fmt_dur
|
|
153
|
+
from .queries import _has_tag, _node_clock_min, _node_tags
|
|
154
|
+
|
|
155
|
+
def _node_line(con, n, *, indent="", done=False, show_kind=True, tags=False, planned=False, clock=True, sched=False, hl=None):
|
|
156
|
+
"""Unified node-line rendering (sole source per DESIGN.md §6).
|
|
157
|
+
|
|
158
|
+
Format: <indent><marker> [#pri] #<id> [kind] <title>[ ·planned][ @sched][ [Xh Ym]][ :tags:]
|
|
159
|
+
Everywhere that "lists tasks" goes through this; do not roll your own. hl=query highlights matches in title (used by find).
|
|
160
|
+
clock defaults True: shows total duration [Xh Ym] when there's a CLOCK or log span; 0 hides it.
|
|
161
|
+
"""
|
|
162
|
+
mk = "✓" if done else _status_marker(n["status"])
|
|
163
|
+
marker = _c(mk, "done" if done else _STATUS_STYLE.get(n["status"], "todo"))
|
|
164
|
+
if n["priority"]:
|
|
165
|
+
pri = _c(f"[#{n['priority']}]", _PRI_STYLE.get(n["priority"]))
|
|
166
|
+
else:
|
|
167
|
+
pri = " " # no priority: spaces as placeholder to align with [#A], no collision with marker
|
|
168
|
+
kind = (_c(f"[{n['kind']}]", "kind") + " ") if (show_kind and n["kind"] != "task") else ""
|
|
169
|
+
nid = _c(f"#{n['id']}", "id")
|
|
170
|
+
title = _hl(n["title"], hl) if hl else _c(n["title"])
|
|
171
|
+
s = f"{indent}{marker} {pri} {nid} {kind}{title}"
|
|
172
|
+
if planned and _has_tag(con, n["id"], "planned"):
|
|
173
|
+
s += " " + _c("·planned", "planned")
|
|
174
|
+
if sched and n["scheduled_at"]:
|
|
175
|
+
s += " " + _c("@" + _sched_display(n["scheduled_at"]), "planned")
|
|
176
|
+
if clock:
|
|
177
|
+
cm = _node_clock_min(con, n["id"])
|
|
178
|
+
d = _fmt_dur(cm)
|
|
179
|
+
if d:
|
|
180
|
+
s += " " + _c(d, "clock")
|
|
181
|
+
if tags:
|
|
182
|
+
tl = _node_tags(con, n["id"])
|
|
183
|
+
if tl:
|
|
184
|
+
s += " " + _c(f":{':'.join(tl)}:", "tag")
|
|
185
|
+
return s
|
|
186
|
+
|
|
187
|
+
def _snippet(text, q, ctx=30):
|
|
188
|
+
"""Extract a snippet around the query, with the match highlighted (styled) / *…* marked (plain)."""
|
|
189
|
+
i = text.lower().find(q.lower())
|
|
190
|
+
if i < 0:
|
|
191
|
+
return _c(text[:80] + ("…" if len(text) > 80 else ""))
|
|
192
|
+
a, b = max(0, i - ctx), min(len(text), i + len(q) + ctx)
|
|
193
|
+
mid = text[i:i + len(q)]
|
|
194
|
+
pre = ("…" if a > 0 else "") + text[a:i]
|
|
195
|
+
post = text[i + len(q):b] + ("…" if b < len(text) else "")
|
|
196
|
+
if _CONSOLE is None:
|
|
197
|
+
return pre + f"*{mid}*" + post
|
|
198
|
+
return _c(pre) + _c(mid, "hit") + _c(post)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _print_truncation_hint(shown, total, extra=""):
|
|
203
|
+
"""Print `(showing N/total[, extra])` hint when truncated; print nothing otherwise."""
|
|
204
|
+
if shown < total:
|
|
205
|
+
msg = f"(showing {shown}/{total}"
|
|
206
|
+
if extra:
|
|
207
|
+
msg += f", {extra}"
|
|
208
|
+
msg += ")"
|
|
209
|
+
out(_c(msg, "meta"))
|
worklog/xdg.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""XDG Base Directory resolution for worklog's DB and config files.
|
|
2
|
+
|
|
3
|
+
Spec: https://specifications.freedesktop.org/basedir-spec/
|
|
4
|
+
|
|
5
|
+
All four helpers re-read environment variables on every call so test
|
|
6
|
+
fixtures that monkeypatch `$HOME` / `$XDG_*` see the change without
|
|
7
|
+
needing a module reload.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _xdg_data_home() -> Path:
|
|
16
|
+
"""`$XDG_DATA_HOME` (default `~/.local/share`)."""
|
|
17
|
+
return Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _xdg_config_home() -> Path:
|
|
21
|
+
"""`$XDG_CONFIG_HOME` (default `~/.config`)."""
|
|
22
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_db_path(args=None) -> Path:
|
|
26
|
+
"""Resolve the SQLite DB path. Priority:
|
|
27
|
+
|
|
28
|
+
1. ``--db`` flag (per-invocation override, top priority)
|
|
29
|
+
2. ``$WORKLOG_DB`` env
|
|
30
|
+
3. ``$XDG_DATA_HOME/worklog/worklog.db`` (default ``~/.local/share/worklog/worklog.db``)
|
|
31
|
+
"""
|
|
32
|
+
if args is not None and getattr(args, "db", None):
|
|
33
|
+
return Path(args.db).resolve()
|
|
34
|
+
env = os.environ.get("WORKLOG_DB")
|
|
35
|
+
if env:
|
|
36
|
+
return Path(env).resolve()
|
|
37
|
+
return (_xdg_data_home() / "worklog" / "worklog.db").resolve()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_aliases_path() -> Path:
|
|
41
|
+
"""``$XDG_CONFIG_HOME/worklog/aliases.ini`` (default ``~/.config/worklog/aliases.ini``)."""
|
|
42
|
+
return _xdg_config_home() / "worklog" / "aliases.ini"
|