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/completion.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""Shell completion generation (fish / bash / zsh).
|
|
2
|
+
|
|
3
|
+
argparse is the source of truth: each generator walks `build_parser()`
|
|
4
|
+
and emits a completion script tailored to that shell. The scripts run
|
|
5
|
+
under init-load (`wl print-completion fish | source` in shell rc), so
|
|
6
|
+
new subcommands / flags propagate automatically.
|
|
7
|
+
|
|
8
|
+
Dynamic completion (node id / tag / date) uses SQLite directly via
|
|
9
|
+
helper functions inlined into each script — no Python startup, sub-50ms
|
|
10
|
+
Tab response. The helpers know the DB resolution order ($WORKLOG_DB,
|
|
11
|
+
then $XDG_DATA_HOME/worklog/worklog.db).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_FISH_HELPERS = {
|
|
21
|
+
# (sub_cmd, opt_name) → fish completion source
|
|
22
|
+
# opt_name None = positional argument
|
|
23
|
+
("__any__", "--parent"): "(__wl_list_nodes)",
|
|
24
|
+
("__any__", "--root"): "(__wl_list_nodes)",
|
|
25
|
+
("__any__", "--id"): "(__wl_list_nodes)",
|
|
26
|
+
("__any__", "--node"): "(__wl_list_nodes)",
|
|
27
|
+
("__any__", "--ids"): "(__wl_list_nodes)",
|
|
28
|
+
("__any__", "--tag"): "(__wl_list_tags)",
|
|
29
|
+
("sched", "--recur"): "(__wl_recur_suggestions)",
|
|
30
|
+
# time / date related -> date suggestions
|
|
31
|
+
("log", "--date"): "(__wl_date_suggestions)",
|
|
32
|
+
("logs", "--date"): "(__wl_date_suggestions)",
|
|
33
|
+
("unlog", "--date"): "(__wl_date_suggestions)",
|
|
34
|
+
("dateinfo", "date"): "(__wl_date_suggestions)",
|
|
35
|
+
("dateinfo", None): "(__wl_date_suggestions)",
|
|
36
|
+
("day", "date"): "(__wl_date_suggestions)",
|
|
37
|
+
("sched", "when"): "(__wl_date_suggestions)",
|
|
38
|
+
("defer", "date"): "(__wl_date_suggestions)",
|
|
39
|
+
}
|
|
40
|
+
# subcommands whose default positional argument takes a node id (when not explicitly specified)
|
|
41
|
+
|
|
42
|
+
_FISH_POSITIONAL_NODE = {"log", "done", "defer", "start", "stop", "wait", "reopen",
|
|
43
|
+
"cancel", "tick", "link", "set", "show", "focus", "ancestors",
|
|
44
|
+
"descendants", "spent", "unlog", "relog"}
|
|
45
|
+
|
|
46
|
+
_FISH_HELPER_FUNCTIONS = r"""# --- helper functions (dynamic queries against worklog.db; no Python startup, fast) ---
|
|
47
|
+
function __wl_db_path
|
|
48
|
+
# $WORKLOG_DB env, else $XDG_DATA_HOME/worklog/worklog.db (default ~/.local/share/worklog/worklog.db)
|
|
49
|
+
if set -q WORKLOG_DB
|
|
50
|
+
echo $WORKLOG_DB
|
|
51
|
+
else if set -q XDG_DATA_HOME
|
|
52
|
+
echo $XDG_DATA_HOME/worklog/worklog.db
|
|
53
|
+
else
|
|
54
|
+
echo $HOME/.local/share/worklog/worklog.db
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
function __wl_list_nodes
|
|
59
|
+
set -l db (__wl_db_path)
|
|
60
|
+
test -f $db; or return
|
|
61
|
+
# SQLite char(9) = tab (fish completion uses \t to separate token + desc)
|
|
62
|
+
sqlite3 $db "SELECT id || char(9) || title FROM node WHERE status IS NULL OR status NOT IN ('DONE', 'CANCELED') ORDER BY id DESC LIMIT 80" 2>/dev/null
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
function __wl_list_tags
|
|
66
|
+
set -l db (__wl_db_path)
|
|
67
|
+
test -f $db; or return
|
|
68
|
+
sqlite3 $db "SELECT DISTINCT tag FROM tag ORDER BY tag" 2>/dev/null
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
function __wl_date_suggestions
|
|
72
|
+
printf 'today\ttoday\nyesterday\tyesterday\nday-before-yesterday\tday before yesterday\ntomorrow\ttomorrow\nday-after-tomorrow\tday after tomorrow\n'
|
|
73
|
+
printf 'someday\tno specific time\n'
|
|
74
|
+
set -l today (date +%Y-%m-%d)
|
|
75
|
+
printf '%s\ttoday YYYY-MM-DD\n' $today
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
function __wl_recur_suggestions
|
|
79
|
+
printf 'daily\tevery day\n'
|
|
80
|
+
printf 'weekly:Mon,Wed,Fri\tMon/Wed/Fri\n'
|
|
81
|
+
printf 'weekly:Sat,Sun\tweekends\n'
|
|
82
|
+
printf 'weekly:-1\tevery Sunday (last day)\n'
|
|
83
|
+
printf 'monthly:1\t1st of every month\n'
|
|
84
|
+
printf 'monthly:15\t15th of every month\n'
|
|
85
|
+
printf 'monthly:-1\tlast day of every month\n'
|
|
86
|
+
printf 'quarterly:1-1\tfirst day of every quarter\n'
|
|
87
|
+
printf 'quarterly:-1\tlast day of every quarter\n'
|
|
88
|
+
printf 'yearly:01-01\tJan 1 every year\n'
|
|
89
|
+
printf 'yearly:-1\tlast day of year (12-31)\n'
|
|
90
|
+
end
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def _completion_iter_actions(parser):
|
|
94
|
+
"""yield action; skip help / version / dest=cmd / subparsers"""
|
|
95
|
+
for a in parser._actions:
|
|
96
|
+
if isinstance(a, (argparse._HelpAction, argparse._VersionAction)):
|
|
97
|
+
continue
|
|
98
|
+
if isinstance(a, argparse._SubParsersAction):
|
|
99
|
+
continue
|
|
100
|
+
yield a
|
|
101
|
+
|
|
102
|
+
def _fish_escape(s):
|
|
103
|
+
"""fish string escape: wrap in single quotes; inner single quote becomes \\'"""
|
|
104
|
+
if s is None:
|
|
105
|
+
return ""
|
|
106
|
+
return s.replace("\\", "\\\\").replace("'", "\\'")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fish_one_complete(prefix, action, sub_cmd=None):
|
|
110
|
+
"""Emit one fish complete line for a single action. prefix is the leading text of the complete line (with -c wl -n ...)."""
|
|
111
|
+
lines = []
|
|
112
|
+
descr = (action.help or "").split("\n")[0].strip()
|
|
113
|
+
# short / long options
|
|
114
|
+
short = []
|
|
115
|
+
long_ = []
|
|
116
|
+
for o in action.option_strings:
|
|
117
|
+
(long_ if o.startswith("--") else short).append(o.lstrip("-"))
|
|
118
|
+
opt_parts = []
|
|
119
|
+
for s in short:
|
|
120
|
+
opt_parts.append(f"-s {s}")
|
|
121
|
+
for l in long_:
|
|
122
|
+
opt_parts.append(f"-l {l}")
|
|
123
|
+
opt_str = " ".join(opt_parts)
|
|
124
|
+
if not opt_str:
|
|
125
|
+
return [] # no short/long = positional; handled by caller
|
|
126
|
+
|
|
127
|
+
# value-taking options disable filename completion (-x); store_true / store_false take no value
|
|
128
|
+
takes_value = not isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction,
|
|
129
|
+
argparse._StoreConstAction, argparse._CountAction))
|
|
130
|
+
if takes_value:
|
|
131
|
+
opt_str += " -x"
|
|
132
|
+
|
|
133
|
+
line = f"{prefix} {opt_str}"
|
|
134
|
+
if descr:
|
|
135
|
+
line += f' -d "{_fish_escape(descr)}"'
|
|
136
|
+
|
|
137
|
+
# value-completion source: choices > helper map > default (none)
|
|
138
|
+
val_src = None
|
|
139
|
+
if action.choices:
|
|
140
|
+
val_src = " ".join(str(c) for c in action.choices)
|
|
141
|
+
else:
|
|
142
|
+
# find helper: first (sub_cmd, --long), then (__any__, --long)
|
|
143
|
+
for opt in action.option_strings:
|
|
144
|
+
for key in [(sub_cmd, opt), ("__any__", opt)]:
|
|
145
|
+
if key in _FISH_HELPERS:
|
|
146
|
+
val_src = _FISH_HELPERS[key]
|
|
147
|
+
break
|
|
148
|
+
if val_src:
|
|
149
|
+
break
|
|
150
|
+
if val_src:
|
|
151
|
+
line += f' -a "{val_src}"'
|
|
152
|
+
|
|
153
|
+
lines.append(line)
|
|
154
|
+
return lines
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _fish_positional_complete(parser, sub_cmd):
|
|
158
|
+
"""Positional argument completion for a subcommand (mostly node id / date)."""
|
|
159
|
+
lines = []
|
|
160
|
+
for a in parser._actions:
|
|
161
|
+
if a.option_strings or isinstance(a, (argparse._SubParsersAction,
|
|
162
|
+
argparse._HelpAction, argparse._VersionAction)):
|
|
163
|
+
continue
|
|
164
|
+
# positional. Look up dest -> helper
|
|
165
|
+
prefix = f'complete -c wl -n "__fish_seen_subcommand_from {sub_cmd}"'
|
|
166
|
+
val_src = None
|
|
167
|
+
# explicit helper
|
|
168
|
+
for key in [(sub_cmd, a.dest), (sub_cmd, None)]:
|
|
169
|
+
if key in _FISH_HELPERS:
|
|
170
|
+
val_src = _FISH_HELPERS[key]
|
|
171
|
+
break
|
|
172
|
+
# default: subcommand in node-id operation set -> __wl_list_nodes
|
|
173
|
+
if val_src is None and sub_cmd in _FISH_POSITIONAL_NODE:
|
|
174
|
+
val_src = "(__wl_list_nodes)"
|
|
175
|
+
if val_src is None and a.choices:
|
|
176
|
+
val_src = " ".join(str(c) for c in a.choices)
|
|
177
|
+
if val_src:
|
|
178
|
+
descr = (a.help or "").split("\n")[0].strip()
|
|
179
|
+
line = f"{prefix} -f -a \"{val_src}\""
|
|
180
|
+
if descr:
|
|
181
|
+
line += f' -d "{_fish_escape(descr)}"'
|
|
182
|
+
lines.append(line)
|
|
183
|
+
return lines
|
|
184
|
+
|
|
185
|
+
def _generate_fish_completion(parser):
|
|
186
|
+
"""Walk build_parser() to produce full fish completion. argparse is the source of truth."""
|
|
187
|
+
lines = [
|
|
188
|
+
"# wl fish completion (auto-generated by `wl --print-completion fish`)",
|
|
189
|
+
"# Load: add `wl --print-completion fish | source` to ~/.config/fish/config.fish",
|
|
190
|
+
"",
|
|
191
|
+
"complete -c wl -f # disable filename completion by default",
|
|
192
|
+
"",
|
|
193
|
+
_FISH_HELPER_FUNCTIONS,
|
|
194
|
+
"# --- global args ---",
|
|
195
|
+
]
|
|
196
|
+
# global (top-level parser) actions
|
|
197
|
+
for a in _completion_iter_actions(parser):
|
|
198
|
+
lines += _fish_one_complete('complete -c wl', a, sub_cmd=None)
|
|
199
|
+
|
|
200
|
+
# subcommands
|
|
201
|
+
subparsers_action = next((x for x in parser._actions
|
|
202
|
+
if isinstance(x, argparse._SubParsersAction)), None)
|
|
203
|
+
if subparsers_action is None:
|
|
204
|
+
return "\n".join(lines) + "\n"
|
|
205
|
+
|
|
206
|
+
# use _collect_sub_meta to get (name, help, sub, aliases)
|
|
207
|
+
sub_metas = _collect_sub_meta(parser)
|
|
208
|
+
lines.append("")
|
|
209
|
+
lines.append("# --- subcommand names (+ aliases) ---")
|
|
210
|
+
for name, descr, _sub, aliases in sub_metas:
|
|
211
|
+
descr_part = f' -d "{_fish_escape(descr)}"' if descr else ""
|
|
212
|
+
lines.append(f'complete -c wl -n "__fish_use_subcommand" -a "{name}"{descr_part}')
|
|
213
|
+
for alias in aliases:
|
|
214
|
+
alias_descr = f"{descr} (= {name})" if descr else f"alias of {name}"
|
|
215
|
+
lines.append(f'complete -c wl -n "__fish_use_subcommand" -a "{alias}"'
|
|
216
|
+
f' -d "{_fish_escape(alias_descr)}"')
|
|
217
|
+
|
|
218
|
+
# per-subcommand arguments -- condition includes the primary name + all aliases
|
|
219
|
+
lines.append("")
|
|
220
|
+
lines.append("# --- per-subcommand arguments ---")
|
|
221
|
+
for name, _descr, sub, aliases in sub_metas:
|
|
222
|
+
all_names = " ".join([name] + aliases)
|
|
223
|
+
cond = f'__fish_seen_subcommand_from {all_names}'
|
|
224
|
+
prefix = f'complete -c wl -n "{cond}"'
|
|
225
|
+
section = [f"\n# {name}"]
|
|
226
|
+
for a in _completion_iter_actions(sub):
|
|
227
|
+
section += _fish_one_complete(prefix, a, sub_cmd=name)
|
|
228
|
+
section += _fish_positional_complete(sub, name)
|
|
229
|
+
if len(section) > 1:
|
|
230
|
+
lines += section
|
|
231
|
+
|
|
232
|
+
return "\n".join(lines) + "\n"
|
|
233
|
+
|
|
234
|
+
_BASH_HELPER_FUNCTIONS = r"""# helper functions (local SQLite query against worklog.db; no Python startup)
|
|
235
|
+
__wl_db_path_bash() {
|
|
236
|
+
# $WORKLOG_DB env, else $XDG_DATA_HOME/worklog/worklog.db (default ~/.local/share/worklog/worklog.db)
|
|
237
|
+
if [ -n "$WORKLOG_DB" ]; then
|
|
238
|
+
echo "$WORKLOG_DB"
|
|
239
|
+
else
|
|
240
|
+
echo "${XDG_DATA_HOME:-$HOME/.local/share}/worklog/worklog.db"
|
|
241
|
+
fi
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
__wl_list_nodes_bash() {
|
|
245
|
+
local db=$(__wl_db_path_bash)
|
|
246
|
+
[ -f "$db" ] || return
|
|
247
|
+
sqlite3 "$db" "SELECT id FROM node WHERE status IS NULL OR status NOT IN ('DONE', 'CANCELED') ORDER BY id DESC LIMIT 80" 2>/dev/null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
__wl_list_tags_bash() {
|
|
251
|
+
local db=$(__wl_db_path_bash)
|
|
252
|
+
[ -f "$db" ] || return
|
|
253
|
+
sqlite3 "$db" "SELECT DISTINCT tag FROM tag ORDER BY tag" 2>/dev/null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
__wl_date_suggestions_bash() {
|
|
257
|
+
echo "today yesterday day-before-yesterday tomorrow day-after-tomorrow someday $(date +%Y-%m-%d)"
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
__wl_recur_suggestions_bash() {
|
|
261
|
+
echo "daily weekly:Mon,Wed,Fri weekly:Sat,Sun weekly:-1 monthly:1 monthly:15 monthly:-1 quarterly:1-1 quarterly:-1 yearly:01-01 yearly:-1"
|
|
262
|
+
}
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
# subcommand / argument -> bash helper function name (outputs token list, consumed by compgen -W)
|
|
266
|
+
|
|
267
|
+
_BASH_DYN_HELPERS = {
|
|
268
|
+
("__any__", "--parent"): "__wl_list_nodes_bash",
|
|
269
|
+
("__any__", "--root"): "__wl_list_nodes_bash",
|
|
270
|
+
("__any__", "--id"): "__wl_list_nodes_bash",
|
|
271
|
+
("__any__", "--node"): "__wl_list_nodes_bash",
|
|
272
|
+
("__any__", "--ids"): "__wl_list_nodes_bash",
|
|
273
|
+
("__any__", "--tag"): "__wl_list_tags_bash",
|
|
274
|
+
("sched", "--recur"): "__wl_recur_suggestions_bash",
|
|
275
|
+
("log", "--date"): "__wl_date_suggestions_bash",
|
|
276
|
+
("logs", "--date"): "__wl_date_suggestions_bash",
|
|
277
|
+
("unlog", "--date"): "__wl_date_suggestions_bash",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
def _collect_sub_meta(parser):
|
|
281
|
+
"""Return [(sub_name, sub_help, sub_parser, [aliases])].
|
|
282
|
+
aliases are all alias names of the sub (excluding the primary name); the primary sub matches via choices key against _choices_actions; aliases point to the same parser object."""
|
|
283
|
+
subparsers_action = next((x for x in parser._actions
|
|
284
|
+
if isinstance(x, argparse._SubParsersAction)), None)
|
|
285
|
+
if not subparsers_action:
|
|
286
|
+
return []
|
|
287
|
+
# reverse map: parser obj id -> list of names
|
|
288
|
+
parser_to_names = {}
|
|
289
|
+
for name, sub_p in subparsers_action.choices.items():
|
|
290
|
+
parser_to_names.setdefault(id(sub_p), []).append(name)
|
|
291
|
+
# primary names: those in _choices_actions with help text
|
|
292
|
+
primary_names = set()
|
|
293
|
+
if subparsers_action._choices_actions:
|
|
294
|
+
for c in subparsers_action._choices_actions:
|
|
295
|
+
primary_names.add(c.dest)
|
|
296
|
+
result = []
|
|
297
|
+
seen = set()
|
|
298
|
+
for name, sub in subparsers_action.choices.items():
|
|
299
|
+
if id(sub) in seen:
|
|
300
|
+
continue
|
|
301
|
+
if name not in primary_names:
|
|
302
|
+
# this is an alias; skip for now, collected together when the primary name appears
|
|
303
|
+
continue
|
|
304
|
+
seen.add(id(sub))
|
|
305
|
+
help_text = ""
|
|
306
|
+
if subparsers_action._choices_actions:
|
|
307
|
+
for c in subparsers_action._choices_actions:
|
|
308
|
+
if c.dest == name:
|
|
309
|
+
help_text = c.help or ""
|
|
310
|
+
break
|
|
311
|
+
aliases = [n for n in parser_to_names[id(sub)] if n != name]
|
|
312
|
+
result.append((name, (help_text or "").split("\n")[0].strip(), sub, aliases))
|
|
313
|
+
return result
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _sub_options(sub_parser):
|
|
317
|
+
"""List of all --long / -short options for a subcommand."""
|
|
318
|
+
opts = []
|
|
319
|
+
for a in _completion_iter_actions(sub_parser):
|
|
320
|
+
for o in a.option_strings:
|
|
321
|
+
opts.append(o)
|
|
322
|
+
return opts
|
|
323
|
+
|
|
324
|
+
def _generate_bash_completion(parser):
|
|
325
|
+
"""argparse → bash _wl() function + complete -F _wl wl."""
|
|
326
|
+
sub_metas = _collect_sub_meta(parser)
|
|
327
|
+
# subcmds list includes primary names + aliases
|
|
328
|
+
all_sub_names = []
|
|
329
|
+
for name, _, _, aliases in sub_metas:
|
|
330
|
+
all_sub_names.append(name)
|
|
331
|
+
all_sub_names.extend(aliases)
|
|
332
|
+
sub_names = " ".join(all_sub_names)
|
|
333
|
+
|
|
334
|
+
# global flags (top-level parser)
|
|
335
|
+
global_opts = []
|
|
336
|
+
for a in _completion_iter_actions(parser):
|
|
337
|
+
global_opts.extend(a.option_strings)
|
|
338
|
+
global_opts_str = " ".join(global_opts)
|
|
339
|
+
|
|
340
|
+
lines = [
|
|
341
|
+
"# wl bash completion (auto-generated by `wl print-completion bash`)",
|
|
342
|
+
"# Load: add `eval \"$(wl print-completion bash)\"` to ~/.bashrc",
|
|
343
|
+
"",
|
|
344
|
+
_BASH_HELPER_FUNCTIONS,
|
|
345
|
+
"_wl() {",
|
|
346
|
+
' local cur="${COMP_WORDS[COMP_CWORD]}"',
|
|
347
|
+
' local prev="${COMP_WORDS[COMP_CWORD-1]}"',
|
|
348
|
+
"",
|
|
349
|
+
" # find current sub: first word not starting with -",
|
|
350
|
+
' local sub=""',
|
|
351
|
+
" local i",
|
|
352
|
+
" for ((i=1; i<COMP_CWORD; i++)); do",
|
|
353
|
+
' case "${COMP_WORDS[i]}" in',
|
|
354
|
+
" -*) ;;",
|
|
355
|
+
' *) sub="${COMP_WORDS[i]}"; break ;;',
|
|
356
|
+
" esac",
|
|
357
|
+
" done",
|
|
358
|
+
"",
|
|
359
|
+
f' local global_opts="{global_opts_str}"',
|
|
360
|
+
f' local subcmds="{sub_names}"',
|
|
361
|
+
"",
|
|
362
|
+
' if [ -z "$sub" ]; then',
|
|
363
|
+
' if [[ "$cur" == -* ]]; then',
|
|
364
|
+
' COMPREPLY=( $(compgen -W "$global_opts" -- "$cur") )',
|
|
365
|
+
" else",
|
|
366
|
+
' COMPREPLY=( $(compgen -W "$subcmds" -- "$cur") )',
|
|
367
|
+
" fi",
|
|
368
|
+
" return",
|
|
369
|
+
" fi",
|
|
370
|
+
"",
|
|
371
|
+
' case "$sub" in',
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
for name, _, sub, aliases in sub_metas:
|
|
375
|
+
opts = _sub_options(sub)
|
|
376
|
+
opts_str = " ".join(opts)
|
|
377
|
+
# bash case pattern: name|alias1|alias2)
|
|
378
|
+
case_pattern = "|".join([name] + aliases)
|
|
379
|
+
case_lines = [f' {case_pattern})']
|
|
380
|
+
# when prev is a long option, look up its helper / choices
|
|
381
|
+
prev_cases = []
|
|
382
|
+
for a in _completion_iter_actions(sub):
|
|
383
|
+
if not a.option_strings:
|
|
384
|
+
continue
|
|
385
|
+
long_opts = [o for o in a.option_strings if o.startswith("--")]
|
|
386
|
+
if not long_opts:
|
|
387
|
+
continue
|
|
388
|
+
for opt in long_opts:
|
|
389
|
+
src = None
|
|
390
|
+
if a.choices:
|
|
391
|
+
src = " ".join(str(c) for c in a.choices)
|
|
392
|
+
else:
|
|
393
|
+
for key in [(name, opt), ("__any__", opt)]:
|
|
394
|
+
if key in _BASH_DYN_HELPERS:
|
|
395
|
+
src = f'$({_BASH_DYN_HELPERS[key]})'
|
|
396
|
+
break
|
|
397
|
+
if src:
|
|
398
|
+
prev_cases.append((opt, src))
|
|
399
|
+
if prev_cases:
|
|
400
|
+
case_lines.append(' case "$prev" in')
|
|
401
|
+
for opt, src in prev_cases:
|
|
402
|
+
if src.startswith("$("):
|
|
403
|
+
case_lines.append(f' {opt}) COMPREPLY=( $(compgen -W "{src}" -- "$cur") ); return ;;')
|
|
404
|
+
else:
|
|
405
|
+
case_lines.append(f' {opt}) COMPREPLY=( $(compgen -W "{src}" -- "$cur") ); return ;;')
|
|
406
|
+
case_lines.append(' esac')
|
|
407
|
+
|
|
408
|
+
case_lines.append(' if [[ "$cur" == -* ]]; then')
|
|
409
|
+
case_lines.append(f' COMPREPLY=( $(compgen -W "{opts_str} $global_opts" -- "$cur") )')
|
|
410
|
+
case_lines.append(' else')
|
|
411
|
+
# positional: when the subcommand operates on node ids -> __wl_list_nodes_bash
|
|
412
|
+
if name in _FISH_POSITIONAL_NODE:
|
|
413
|
+
case_lines.append(f' COMPREPLY=( $(compgen -W "$(__wl_list_nodes_bash)" -- "$cur") )')
|
|
414
|
+
else:
|
|
415
|
+
case_lines.append(' :')
|
|
416
|
+
case_lines.append(' fi')
|
|
417
|
+
case_lines.append(' ;;')
|
|
418
|
+
lines.extend(case_lines)
|
|
419
|
+
|
|
420
|
+
lines.append(' esac')
|
|
421
|
+
lines.append('}')
|
|
422
|
+
lines.append('complete -F _wl wl')
|
|
423
|
+
return "\n".join(lines) + "\n"
|
|
424
|
+
|
|
425
|
+
_ZSH_HELPER_FUNCTIONS = r"""# helper functions (local SQLite query against worklog.db; no Python startup)
|
|
426
|
+
__wl_db_path_zsh() {
|
|
427
|
+
# $WORKLOG_DB env, else $XDG_DATA_HOME/worklog/worklog.db (default ~/.local/share/worklog/worklog.db)
|
|
428
|
+
if [ -n "$WORKLOG_DB" ]; then
|
|
429
|
+
echo "$WORKLOG_DB"
|
|
430
|
+
else
|
|
431
|
+
echo "${XDG_DATA_HOME:-$HOME/.local/share}/worklog/worklog.db"
|
|
432
|
+
fi
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
__wl_list_nodes_zsh() {
|
|
436
|
+
local db=$(__wl_db_path_zsh)
|
|
437
|
+
[ -f "$db" ] || return
|
|
438
|
+
local -a nodes
|
|
439
|
+
nodes=( "${(@f)$(sqlite3 "$db" "SELECT id || ':' || replace(title, ':', '\\:') FROM node WHERE status IS NULL OR status NOT IN ('DONE', 'CANCELED') ORDER BY id DESC LIMIT 80" 2>/dev/null)}" )
|
|
440
|
+
_describe 'node' nodes
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
__wl_list_tags_zsh() {
|
|
444
|
+
local db=$(__wl_db_path_zsh)
|
|
445
|
+
[ -f "$db" ] || return
|
|
446
|
+
local -a tags
|
|
447
|
+
tags=( "${(@f)$(sqlite3 "$db" "SELECT DISTINCT tag FROM tag ORDER BY tag" 2>/dev/null)}" )
|
|
448
|
+
_values 'tag' $tags
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
__wl_date_suggestions_zsh() {
|
|
452
|
+
local today=$(date +%Y-%m-%d)
|
|
453
|
+
_describe 'date' \
|
|
454
|
+
"today:today" "yesterday:yesterday" "day-before-yesterday:day before yesterday" "tomorrow:tomorrow" "day-after-tomorrow:day after tomorrow" \
|
|
455
|
+
"someday:no specific time" "$today:today YYYY-MM-DD"
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
__wl_recur_suggestions_zsh() {
|
|
459
|
+
_describe 'recur' \
|
|
460
|
+
"daily:every day" \
|
|
461
|
+
"weekly\\:Mon,Wed,Fri:Mon/Wed/Fri" \
|
|
462
|
+
"weekly\\:Sat,Sun:weekends" \
|
|
463
|
+
"weekly\\:-1:every Sunday (last day)" \
|
|
464
|
+
"monthly\\:1:1st of every month" \
|
|
465
|
+
"monthly\\:15:15th of every month" \
|
|
466
|
+
"monthly\\:-1:last day of every month" \
|
|
467
|
+
"quarterly\\:1-1:first day of every quarter" \
|
|
468
|
+
"quarterly\\:-1:last day of every quarter" \
|
|
469
|
+
"yearly\\:01-01:Jan 1 every year" \
|
|
470
|
+
"yearly\\:-1:last day of year (12-31)"
|
|
471
|
+
}
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
def _zsh_escape(s):
|
|
475
|
+
"""zsh string escape: backticks / square brackets / single + double quotes"""
|
|
476
|
+
if s is None:
|
|
477
|
+
return ""
|
|
478
|
+
return s.replace("\\", "\\\\").replace("'", "''").replace("[", "\\[").replace("]", "\\]").replace(":", "\\:")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
_ZSH_DYN_HELPERS = {
|
|
483
|
+
("__any__", "--parent"): "__wl_list_nodes_zsh",
|
|
484
|
+
("__any__", "--root"): "__wl_list_nodes_zsh",
|
|
485
|
+
("__any__", "--id"): "__wl_list_nodes_zsh",
|
|
486
|
+
("__any__", "--node"): "__wl_list_nodes_zsh",
|
|
487
|
+
("__any__", "--ids"): "__wl_list_nodes_zsh",
|
|
488
|
+
("__any__", "--tag"): "__wl_list_tags_zsh",
|
|
489
|
+
("sched", "--recur"): "__wl_recur_suggestions_zsh",
|
|
490
|
+
("log", "--date"): "__wl_date_suggestions_zsh",
|
|
491
|
+
("logs", "--date"): "__wl_date_suggestions_zsh",
|
|
492
|
+
("unlog", "--date"): "__wl_date_suggestions_zsh",
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
def _zsh_arg_spec(action, sub_cmd):
|
|
496
|
+
"""For a single action, produce the _arguments spec string. None means positional (handled separately)."""
|
|
497
|
+
if not action.option_strings:
|
|
498
|
+
return None # positional
|
|
499
|
+
descr = (action.help or "").split("\n")[0].strip()
|
|
500
|
+
descr_part = f"[{_zsh_escape(descr)}]" if descr else ""
|
|
501
|
+
|
|
502
|
+
takes_value = not isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction,
|
|
503
|
+
argparse._StoreConstAction, argparse._CountAction))
|
|
504
|
+
|
|
505
|
+
val_part = ""
|
|
506
|
+
if takes_value:
|
|
507
|
+
# value-completion source
|
|
508
|
+
val_src = None
|
|
509
|
+
if action.choices:
|
|
510
|
+
val_src = "(" + " ".join(str(c) for c in action.choices) + ")"
|
|
511
|
+
else:
|
|
512
|
+
for opt in action.option_strings:
|
|
513
|
+
for key in [(sub_cmd, opt), ("__any__", opt)]:
|
|
514
|
+
if key in _ZSH_DYN_HELPERS:
|
|
515
|
+
val_src = _ZSH_DYN_HELPERS[key]
|
|
516
|
+
break
|
|
517
|
+
if val_src:
|
|
518
|
+
break
|
|
519
|
+
if val_src:
|
|
520
|
+
val_part = f": :{val_src}" if val_src.startswith("__") else f": :{val_src}"
|
|
521
|
+
else:
|
|
522
|
+
val_part = ": :"
|
|
523
|
+
|
|
524
|
+
# multiple option strings (e.g. -q --brief): zsh uses {-q,--brief} form
|
|
525
|
+
opts = action.option_strings
|
|
526
|
+
if len(opts) == 1:
|
|
527
|
+
return f"'{opts[0]}{descr_part}{val_part}'"
|
|
528
|
+
elif len(opts) == 2:
|
|
529
|
+
return "'(" + " ".join(opts) + ")'{" + ",".join(opts) + "}'" + descr_part + val_part + "'"
|
|
530
|
+
else:
|
|
531
|
+
# > 2 options: one entry per option
|
|
532
|
+
return " ".join(f"'{o}{descr_part}{val_part}'" for o in opts)
|
|
533
|
+
|
|
534
|
+
def _generate_zsh_completion(parser):
|
|
535
|
+
"""argparse → zsh _wl() function + compdef _wl wl."""
|
|
536
|
+
sub_metas = _collect_sub_meta(parser)
|
|
537
|
+
|
|
538
|
+
lines = [
|
|
539
|
+
"#compdef wl",
|
|
540
|
+
"# wl zsh completion (auto-generated by `wl print-completion zsh`)",
|
|
541
|
+
"# Load: add `eval \"$(wl print-completion zsh)\"` to ~/.zshrc",
|
|
542
|
+
"",
|
|
543
|
+
_ZSH_HELPER_FUNCTIONS,
|
|
544
|
+
"_wl() {",
|
|
545
|
+
" local context state line",
|
|
546
|
+
" typeset -A opt_args",
|
|
547
|
+
"",
|
|
548
|
+
" _arguments -C \\",
|
|
549
|
+
]
|
|
550
|
+
|
|
551
|
+
# global args
|
|
552
|
+
global_specs = []
|
|
553
|
+
for a in _completion_iter_actions(parser):
|
|
554
|
+
spec = _zsh_arg_spec(a, sub_cmd=None)
|
|
555
|
+
if spec:
|
|
556
|
+
global_specs.append(spec)
|
|
557
|
+
for spec in global_specs:
|
|
558
|
+
lines.append(f" {spec} \\")
|
|
559
|
+
lines.append(" '1: :->cmds' \\")
|
|
560
|
+
lines.append(" '*::arg:->args'")
|
|
561
|
+
lines.append("")
|
|
562
|
+
lines.append(' case "$state" in')
|
|
563
|
+
lines.append(' cmds)')
|
|
564
|
+
lines.append(' local -a subcmds')
|
|
565
|
+
lines.append(' subcmds=(')
|
|
566
|
+
for name, descr, _, aliases in sub_metas:
|
|
567
|
+
descr_safe = _zsh_escape(descr)
|
|
568
|
+
lines.append(f" '{name}:{descr_safe}'")
|
|
569
|
+
for alias in aliases:
|
|
570
|
+
alias_descr = _zsh_escape(f"{descr} (= {name})" if descr else f"alias of {name}")
|
|
571
|
+
lines.append(f" '{alias}:{alias_descr}'")
|
|
572
|
+
lines.append(' )')
|
|
573
|
+
lines.append(" _describe 'subcommand' subcmds")
|
|
574
|
+
lines.append(' ;;')
|
|
575
|
+
lines.append(' args)')
|
|
576
|
+
lines.append(' case $line[1] in')
|
|
577
|
+
|
|
578
|
+
for name, _, sub, aliases in sub_metas:
|
|
579
|
+
# zsh case pattern: name|alias1|alias2)
|
|
580
|
+
case_pattern = "|".join([name] + aliases)
|
|
581
|
+
lines.append(f' {case_pattern})')
|
|
582
|
+
lines.append(' _arguments \\')
|
|
583
|
+
sub_specs = []
|
|
584
|
+
for a in _completion_iter_actions(sub):
|
|
585
|
+
spec = _zsh_arg_spec(a, sub_cmd=name)
|
|
586
|
+
if spec:
|
|
587
|
+
sub_specs.append(spec)
|
|
588
|
+
# positional (single positional taking a node id)
|
|
589
|
+
positional_helper = None
|
|
590
|
+
if name in _FISH_POSITIONAL_NODE:
|
|
591
|
+
positional_helper = "__wl_list_nodes_zsh"
|
|
592
|
+
for i, spec in enumerate(sub_specs):
|
|
593
|
+
suffix = " \\" if (i < len(sub_specs) - 1 or positional_helper) else ""
|
|
594
|
+
lines.append(f" {spec}{suffix}")
|
|
595
|
+
if positional_helper:
|
|
596
|
+
lines.append(f" '*: :{positional_helper}'")
|
|
597
|
+
lines.append(' ;;')
|
|
598
|
+
|
|
599
|
+
lines.append(' esac')
|
|
600
|
+
lines.append(' ;;')
|
|
601
|
+
lines.append(' esac')
|
|
602
|
+
lines.append('}')
|
|
603
|
+
lines.append('compdef _wl wl')
|
|
604
|
+
return "\n".join(lines) + "\n"
|
|
605
|
+
|
|
606
|
+
def cmd_print_completion(args, con=None):
|
|
607
|
+
"""Dump shell completion script. See per-shell header for how to load.
|
|
608
|
+
|
|
609
|
+
fish: add `wl print-completion fish | source` to ~/.config/fish/config.fish
|
|
610
|
+
bash: add `eval "$(wl print-completion bash)"` to ~/.bashrc
|
|
611
|
+
zsh: add `eval "$(wl print-completion zsh)"` to ~/.zshrc
|
|
612
|
+
"""
|
|
613
|
+
from .cli import build_parser # lazy import — cli imports completion, so this side must be deferred
|
|
614
|
+
shell = args.shell
|
|
615
|
+
parser = build_parser()
|
|
616
|
+
if shell == "fish":
|
|
617
|
+
sys.stdout.write(_generate_fish_completion(parser))
|
|
618
|
+
elif shell == "bash":
|
|
619
|
+
sys.stdout.write(_generate_bash_completion(parser))
|
|
620
|
+
elif shell == "zsh":
|
|
621
|
+
sys.stdout.write(_generate_zsh_completion(parser))
|
|
622
|
+
else:
|
|
623
|
+
sys.exit(f"✗ shell '{shell}' not supported (fish / bash / zsh)")
|
|
624
|
+
|