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/db.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""SQLite connection and migration runner for worklog.
|
|
2
|
+
|
|
3
|
+
Functions take their DB path / migrations directory as arguments instead of
|
|
4
|
+
reading module-level globals — `cli.py` keeps the `DB_PATH` / `MIGRATIONS_DIR`
|
|
5
|
+
globals (because `main()` mutates `DB_PATH` per the `--db` flag) and passes
|
|
6
|
+
them in via thin wrappers.
|
|
7
|
+
|
|
8
|
+
This keeps the schema-upgrade logic isolated and importable from anywhere
|
|
9
|
+
that already has a path, without depending on cli.py's module state.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def db_connect(db_path: Path) -> sqlite3.Connection:
|
|
18
|
+
"""Open a connection (creating parent dirs if missing). Row factory =
|
|
19
|
+
sqlite3.Row, foreign-key enforcement enabled."""
|
|
20
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
con = sqlite3.connect(db_path)
|
|
22
|
+
con.row_factory = sqlite3.Row
|
|
23
|
+
con.execute("PRAGMA foreign_keys = ON")
|
|
24
|
+
return con
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def migration_files(migrations_dir: Path) -> list[Path]:
|
|
28
|
+
"""Return migration files sorted by NNNN_ numeric prefix; skip filenames
|
|
29
|
+
without a numeric prefix."""
|
|
30
|
+
if not migrations_dir.exists():
|
|
31
|
+
return []
|
|
32
|
+
files = []
|
|
33
|
+
for p in migrations_dir.glob("*.sql"):
|
|
34
|
+
prefix = p.stem.split("_", 1)[0]
|
|
35
|
+
if prefix.isdigit():
|
|
36
|
+
files.append((int(prefix), p))
|
|
37
|
+
files.sort(key=lambda x: x[0])
|
|
38
|
+
return [p for _, p in files]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def db_version(con: sqlite3.Connection) -> int:
|
|
42
|
+
"""The highest migration number applied to this DB (`PRAGMA user_version`)."""
|
|
43
|
+
return con.execute("PRAGMA user_version").fetchone()[0]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_migrations(con: sqlite3.Connection, migrations_dir: Path, verbose: bool = False) -> list[Path]:
|
|
47
|
+
"""Apply every migration whose number > `PRAGMA user_version`.
|
|
48
|
+
|
|
49
|
+
Each migration runs in its own transaction; `user_version` is bumped
|
|
50
|
+
per file, so a mid-sequence failure leaves the DB at the last
|
|
51
|
+
successfully-applied number — re-run after fixing.
|
|
52
|
+
|
|
53
|
+
Downgrade guard: if `PRAGMA user_version` exceeds the highest migration
|
|
54
|
+
number shipped, the DB was written by a newer worklog and must not be
|
|
55
|
+
touched by older code — abort with a clear message.
|
|
56
|
+
"""
|
|
57
|
+
files = migration_files(migrations_dir)
|
|
58
|
+
max_n = max((int(p.stem.split("_", 1)[0]) for p in files), default=0)
|
|
59
|
+
current = db_version(con)
|
|
60
|
+
if current > max_n:
|
|
61
|
+
raise SystemExit(
|
|
62
|
+
f"✗ DB at user_version={current} but this worklog build only ships "
|
|
63
|
+
f"migrations up to {max_n}. The DB was written by a newer version; "
|
|
64
|
+
f"upgrade worklog (e.g. `pip install --upgrade pyworklog`) and retry."
|
|
65
|
+
)
|
|
66
|
+
applied = []
|
|
67
|
+
for path in files:
|
|
68
|
+
n = int(path.stem.split("_", 1)[0])
|
|
69
|
+
if n <= current:
|
|
70
|
+
continue
|
|
71
|
+
sql = path.read_text(encoding="utf-8")
|
|
72
|
+
try:
|
|
73
|
+
con.executescript(sql)
|
|
74
|
+
con.execute(f"PRAGMA user_version = {n}")
|
|
75
|
+
con.commit()
|
|
76
|
+
except Exception:
|
|
77
|
+
con.rollback()
|
|
78
|
+
raise
|
|
79
|
+
if verbose:
|
|
80
|
+
print(f"✓ applied migration {path.stem}")
|
|
81
|
+
applied.append(path)
|
|
82
|
+
return applied
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ensure_db(db_path: Path, migrations_dir: Path) -> None:
|
|
86
|
+
"""Open the DB (creating the file if missing) and run any pending migrations."""
|
|
87
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
con = db_connect(db_path)
|
|
89
|
+
try:
|
|
90
|
+
run_migrations(con, migrations_dir)
|
|
91
|
+
finally:
|
|
92
|
+
con.close()
|
worklog/helpers.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Pure utility helpers for worklog.
|
|
2
|
+
|
|
3
|
+
No I/O, no sqlite, no rich — these all take args / strings and return
|
|
4
|
+
args / strings (or types like Path/datetime). Tested in isolation and
|
|
5
|
+
safely importable from any other module without side effects.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# generic-dimension tags (planning attributes / priority / type) -- excluded from focus --related, which links only on project/topic tags
|
|
11
|
+
GENERIC_TAGS = {
|
|
12
|
+
"work", "personal", "planned", "unplanned",
|
|
13
|
+
"P0", "P1", "P2", "habit", "meeting", "followup",
|
|
14
|
+
"dev", "ai", "sync", "strategy", "reflection", "reading",
|
|
15
|
+
"family", "health", "morning_check", "slack_scan",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _status_marker(status):
|
|
20
|
+
return {
|
|
21
|
+
None: "[ ]",
|
|
22
|
+
"TODO": "[ ]",
|
|
23
|
+
"DOING": "[/]",
|
|
24
|
+
"LATER": "[>]",
|
|
25
|
+
"WAIT": "[?]",
|
|
26
|
+
"DONE": "[x]",
|
|
27
|
+
"DEFERRED": "[>]",
|
|
28
|
+
"CANCELED": "[-]",
|
|
29
|
+
}.get(status, "[ ]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_window(args):
|
|
33
|
+
"""Resolve a time window to (since, until) YYYY-MM-DD pair. Priority: week > month > since/until > this Monday to today."""
|
|
34
|
+
from datetime import date, timedelta
|
|
35
|
+
|
|
36
|
+
if getattr(args, "week", None):
|
|
37
|
+
y, w = args.week.split("-W")
|
|
38
|
+
monday = date.fromisocalendar(int(y), int(w), 1)
|
|
39
|
+
return monday.isoformat(), (monday + timedelta(days=6)).isoformat()
|
|
40
|
+
if getattr(args, "month", None):
|
|
41
|
+
y, m = (int(x) for x in args.month.split("-"))
|
|
42
|
+
first = date(y, m, 1)
|
|
43
|
+
nxt = date(y + 1, 1, 1) if m == 12 else date(y, m + 1, 1)
|
|
44
|
+
return first.isoformat(), (nxt - timedelta(days=1)).isoformat()
|
|
45
|
+
today = date.today()
|
|
46
|
+
since = getattr(args, "since", None) or (today - timedelta(days=today.weekday())).isoformat()
|
|
47
|
+
until = getattr(args, "until", None) or today.isoformat()
|
|
48
|
+
return since, until
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_concrete_date(s):
|
|
52
|
+
"""Resolve today/yesterday/tomorrow/day-before-yesterday/day-after-tomorrow/YYYY-MM-DD (and Chinese aliases) to a concrete date string.
|
|
53
|
+
English aliases are case-insensitive."""
|
|
54
|
+
from datetime import date, timedelta
|
|
55
|
+
|
|
56
|
+
s = s.strip()
|
|
57
|
+
lower = s.lower()
|
|
58
|
+
rel = {
|
|
59
|
+
"today": 0, "今天": 0,
|
|
60
|
+
"yesterday": -1, "昨天": -1,
|
|
61
|
+
"day-before-yesterday": -2, "前天": -2,
|
|
62
|
+
"tomorrow": 1, "明天": 1,
|
|
63
|
+
"day-after-tomorrow": 2, "后天": 2,
|
|
64
|
+
}
|
|
65
|
+
if s in rel:
|
|
66
|
+
return (date.today() + timedelta(days=rel[s])).isoformat()
|
|
67
|
+
if lower in rel:
|
|
68
|
+
return (date.today() + timedelta(days=rel[lower])).isoformat()
|
|
69
|
+
date.fromisoformat(s) # validate; raises ValueError on bad input
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
def _resolve_at_ts(at, default_now=True):
|
|
73
|
+
"""Parse --at: HH:MM (today + that time) / YYYY-MM-DD (that day, current time) /
|
|
74
|
+
YYYY-MM-DD HH:MM[:SS] / ISO with 'T' separator. None -> now.
|
|
75
|
+
Validates range (rejects 25:00 / month 13); raises ValueError on error.
|
|
76
|
+
"""
|
|
77
|
+
from datetime import datetime as _dt
|
|
78
|
+
import re as _re
|
|
79
|
+
if not at:
|
|
80
|
+
return _dt.now().strftime("%Y-%m-%d %H:%M:%S") if default_now else None
|
|
81
|
+
at = at.strip()
|
|
82
|
+
today = _dt.now().strftime("%Y-%m-%d")
|
|
83
|
+
if _re.fullmatch(r"\d{2}:\d{2}", at):
|
|
84
|
+
_dt.strptime(at, "%H:%M")
|
|
85
|
+
return f"{today} {at}:00"
|
|
86
|
+
if _re.fullmatch(r"\d{4}-\d{2}-\d{2}", at):
|
|
87
|
+
_dt.strptime(at, "%Y-%m-%d")
|
|
88
|
+
return f"{at} {_dt.now().strftime('%H:%M:%S')}"
|
|
89
|
+
if _re.fullmatch(r"\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?", at):
|
|
90
|
+
ts = at.replace("T", " ")
|
|
91
|
+
if len(ts) == 16:
|
|
92
|
+
ts += ":00"
|
|
93
|
+
_dt.strptime(ts, "%Y-%m-%d %H:%M:%S")
|
|
94
|
+
return ts
|
|
95
|
+
raise ValueError(f"invalid --at '{at}': supported formats: HH:MM / YYYY-MM-DD / YYYY-MM-DD HH:MM[:SS]")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _term_width():
|
|
99
|
+
"""Terminal column count. No TTY (pipe/redirect) -> default 80."""
|
|
100
|
+
import shutil
|
|
101
|
+
try:
|
|
102
|
+
return shutil.get_terminal_size().columns or 80
|
|
103
|
+
except OSError:
|
|
104
|
+
return 80
|
|
105
|
+
|
|
106
|
+
def _truncate_log_body(body, indent_cols, full=False):
|
|
107
|
+
"""Truncate log body to one line (terminal width - indent - safety margin), append … at end. full=True keeps body untouched.
|
|
108
|
+
indent_cols is the column width already occupied before body (indent + marker).
|
|
109
|
+
CJK characters may take 2 columns; this approximation by character count is acceptable.
|
|
110
|
+
"""
|
|
111
|
+
if full:
|
|
112
|
+
return body
|
|
113
|
+
width = _term_width()
|
|
114
|
+
# available columns = width - indent_cols - small safety margin (2 cols to avoid edge wrap)
|
|
115
|
+
avail = max(20, width - indent_cols - 2)
|
|
116
|
+
# CJK chars take 2 cols; estimate effective usage
|
|
117
|
+
used = 0
|
|
118
|
+
out_chars = []
|
|
119
|
+
for ch in body:
|
|
120
|
+
w = 2 if ord(ch) > 0x7F else 1
|
|
121
|
+
if used + w > avail - 1: # reserve 1 col for …
|
|
122
|
+
out_chars.append("…")
|
|
123
|
+
return "".join(out_chars)
|
|
124
|
+
out_chars.append(ch)
|
|
125
|
+
used += w
|
|
126
|
+
return body
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_brief(args, *extras):
|
|
130
|
+
"""Brief mode: -q/--brief or any extra flag (no_logs/no_timeline/no_body etc.) triggers it.
|
|
131
|
+
Usage: brief = _is_brief(args, "no_logs", "no_timeline")
|
|
132
|
+
"""
|
|
133
|
+
if getattr(args, "brief", False):
|
|
134
|
+
return True
|
|
135
|
+
return any(getattr(args, e, False) for e in extras)
|
|
136
|
+
|
|
137
|
+
def _resolve_log_tail(args, brief, default_tail=3):
|
|
138
|
+
"""Unified log tail resolution. Shared by all commands that show log lists.
|
|
139
|
+
|
|
140
|
+
Priority (high to low):
|
|
141
|
+
- brief / --no-logs / --no-timeline / --no-body -> 0 (none shown)
|
|
142
|
+
- --all-logs (or --all-timeline) -> None (full expansion)
|
|
143
|
+
- --log-tail N (or --timeline-tail N / --tail N) -> N
|
|
144
|
+
- otherwise -> default_tail (usually 3)
|
|
145
|
+
"""
|
|
146
|
+
if brief:
|
|
147
|
+
return 0
|
|
148
|
+
if (getattr(args, "all_logs", False) or getattr(args, "all_timelines", False)
|
|
149
|
+
or getattr(args, "all_timeline", False)):
|
|
150
|
+
return None
|
|
151
|
+
for attr in ("log_tail", "timeline_tail", "tail"):
|
|
152
|
+
v = getattr(args, attr, None)
|
|
153
|
+
if v is not None:
|
|
154
|
+
return v
|
|
155
|
+
return default_tail
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _norm_sched(s):
|
|
159
|
+
"""Normalize user-input scheduled time. Returns normalized string; raises ValueError on bad input; empty returns None.
|
|
160
|
+
Accepts YYYY-MM-DD / YYYY-MM / YYYY-Www / YYYY-Qn / YYYY / someday
|
|
161
|
+
+ relative words today|tomorrow|next-week|next-month|next-quarter."""
|
|
162
|
+
import re as _re
|
|
163
|
+
import datetime as _dt
|
|
164
|
+
if s is None:
|
|
165
|
+
return None
|
|
166
|
+
s = s.strip()
|
|
167
|
+
if not s:
|
|
168
|
+
return None
|
|
169
|
+
today = _dt.date.today()
|
|
170
|
+
if s in ("today", "今天"):
|
|
171
|
+
return today.isoformat()
|
|
172
|
+
if s in ("tomorrow", "明天"):
|
|
173
|
+
return (today + _dt.timedelta(days=1)).isoformat()
|
|
174
|
+
if s in ("next-week", "下周", "下个星期"):
|
|
175
|
+
y, w, _ = (today + _dt.timedelta(days=7)).isocalendar()
|
|
176
|
+
return f"{y}-W{w:02d}"
|
|
177
|
+
if s in ("next-month", "下月", "下个月"):
|
|
178
|
+
y, m = (today.year + 1, 1) if today.month == 12 else (today.year, today.month + 1)
|
|
179
|
+
return f"{y}-{m:02d}"
|
|
180
|
+
if s in ("next-quarter", "下季", "下个季度"):
|
|
181
|
+
q = (today.month - 1) // 3 + 1
|
|
182
|
+
ny, nq = (today.year + 1, 1) if q == 4 else (today.year, q + 1)
|
|
183
|
+
return f"{ny}-Q{nq}"
|
|
184
|
+
if s in ("someday", "以后", "有空", "总有一天"):
|
|
185
|
+
return "someday"
|
|
186
|
+
if _re.fullmatch(r"\d{4}-\d{2}-\d{2}", s):
|
|
187
|
+
_dt.date.fromisoformat(s) # validate; raises ValueError on bad input
|
|
188
|
+
return s
|
|
189
|
+
if _re.fullmatch(r"\d{4}-\d{2}", s):
|
|
190
|
+
if not 1 <= int(s[5:7]) <= 12:
|
|
191
|
+
raise ValueError(f"invalid month: {s}")
|
|
192
|
+
return s
|
|
193
|
+
if _re.fullmatch(r"\d{4}-W\d{2}", s):
|
|
194
|
+
if not 1 <= int(s[6:]) <= 53:
|
|
195
|
+
raise ValueError(f"invalid week: {s}")
|
|
196
|
+
return s
|
|
197
|
+
if _re.fullmatch(r"\d{4}-Q[1-4]", s):
|
|
198
|
+
return s
|
|
199
|
+
if _re.fullmatch(r"\d{4}", s):
|
|
200
|
+
return s
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"unrecognized scheduled time '{s}' (use YYYY-MM-DD / YYYY-MM / YYYY-Www / YYYY-Qn / YYYY / someday / tomorrow / next-week / next-month / next-quarter)")
|
|
203
|
+
|
|
204
|
+
def _sched_kind(s):
|
|
205
|
+
"""Normalized value -> granularity: day/week/month/quarter/year/someday/fuzzy"""
|
|
206
|
+
import re as _re
|
|
207
|
+
if not s:
|
|
208
|
+
return None
|
|
209
|
+
if s == "someday":
|
|
210
|
+
return "someday"
|
|
211
|
+
if _re.fullmatch(r"\d{4}-\d{2}-\d{2}", s):
|
|
212
|
+
return "day"
|
|
213
|
+
if _re.fullmatch(r"\d{4}-W\d{2}", s):
|
|
214
|
+
return "week"
|
|
215
|
+
if _re.fullmatch(r"\d{4}-\d{2}", s):
|
|
216
|
+
return "month"
|
|
217
|
+
if _re.fullmatch(r"\d{4}-Q[1-4]", s):
|
|
218
|
+
return "quarter"
|
|
219
|
+
if _re.fullmatch(r"\d{4}", s):
|
|
220
|
+
return "year"
|
|
221
|
+
return "fuzzy"
|
|
222
|
+
|
|
223
|
+
def _sched_anchor(s):
|
|
224
|
+
"""Normalized value -> anchor date (for sorting). someday/fuzzy -> far-future, sorts last."""
|
|
225
|
+
import datetime as _dt
|
|
226
|
+
k = _sched_kind(s)
|
|
227
|
+
try:
|
|
228
|
+
if k == "day":
|
|
229
|
+
return s
|
|
230
|
+
if k == "month":
|
|
231
|
+
return s + "-01"
|
|
232
|
+
if k == "year":
|
|
233
|
+
return s + "-01-01"
|
|
234
|
+
if k == "quarter":
|
|
235
|
+
y, q = int(s[:4]), int(s[6])
|
|
236
|
+
return f"{y}-{(q - 1) * 3 + 1:02d}-01"
|
|
237
|
+
if k == "week":
|
|
238
|
+
return _dt.date.fromisocalendar(int(s[:4]), int(s[6:]), 1).isoformat()
|
|
239
|
+
except (ValueError, IndexError):
|
|
240
|
+
# fromisoformat / fromisocalendar / int() / slice out of range -> treat as unparseable, use sentinel
|
|
241
|
+
pass
|
|
242
|
+
return "9999-12-31"
|
|
243
|
+
|
|
244
|
+
def _sched_sort_key(s):
|
|
245
|
+
"""Sort key (anchor date, granularity rank). Coarser granularities sort later; someday/fuzzy last. Callers compose final keys."""
|
|
246
|
+
rank = {"day": 0, "week": 1, "month": 2, "quarter": 3, "year": 4, "someday": 9, "fuzzy": 9}
|
|
247
|
+
return (_sched_anchor(s), rank.get(_sched_kind(s), 9))
|
|
248
|
+
|
|
249
|
+
def _sched_display(s):
|
|
250
|
+
"""Display: precise dates show MM-DD only (current-year context); fuzzy values shown as-is (month/week/quarter/year/someday)."""
|
|
251
|
+
if not s:
|
|
252
|
+
return ""
|
|
253
|
+
return s[5:] if _sched_kind(s) == "day" else s
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _fmt_dur(minutes):
|
|
257
|
+
"""Compact duration format: [2h30m] / [45m] / [0] hidden. ASCII-safe, no reliance on emoji widths."""
|
|
258
|
+
if not minutes or minutes <= 0:
|
|
259
|
+
return ""
|
|
260
|
+
h, m = divmod(int(minutes), 60)
|
|
261
|
+
if h:
|
|
262
|
+
return f"[{h}h{m}m]" if m else f"[{h}h]"
|
|
263
|
+
return f"[{m}m]"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _apply_top_limit(rows, args):
|
|
267
|
+
"""Truncate the list by args.top / args.limit; return (rows, total_before).
|
|
268
|
+
`--top` takes the first N (rows are already in target order); `--limit` further truncates the display.
|
|
269
|
+
"""
|
|
270
|
+
total = len(rows)
|
|
271
|
+
top = getattr(args, "top", None)
|
|
272
|
+
if top is not None and top > 0:
|
|
273
|
+
rows = rows[:top]
|
|
274
|
+
limit = getattr(args, "limit", None)
|
|
275
|
+
if limit is not None and limit > 0:
|
|
276
|
+
rows = rows[:limit]
|
|
277
|
+
return rows, total
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _log_full(args):
|
|
281
|
+
"""args.log_format == 'full' -> True; otherwise (including None / 'oneline') -> False."""
|
|
282
|
+
return getattr(args, "log_format", "oneline") == "full"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# SQL / kind constants shared between command modules
|
|
286
|
+
_ORDER_BY_PRI_ID = "ORDER BY priority NULLS LAST, id"
|
|
287
|
+
_TIME_KINDS = {"lifetime", "decade", "year", "quarter", "month", "week", "day"}
|
|
288
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
-- worklog schema v0
|
|
2
|
+
-- main table: node (one id space spans lifetime / decade / year / quarter / month / week / day / area / project / task / signal / habit / any custom kind)
|
|
3
|
+
-- tree: parent_id self-reference
|
|
4
|
+
-- multi-log: log table hangs off node
|
|
5
|
+
-- multi-tag: tag table (many-to-many)
|
|
6
|
+
-- multi-field: prop table (UDA-style key/value)
|
|
7
|
+
-- vault wikilink: link table
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS node (
|
|
10
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
|
+
parent_id INTEGER REFERENCES node(id) ON DELETE SET NULL,
|
|
12
|
+
title TEXT NOT NULL,
|
|
13
|
+
kind TEXT NOT NULL, -- lifetime/decade/year/quarter/month/week/day/area/project/task/signal/habit/meetlog/...
|
|
14
|
+
status TEXT, -- TODO/DOING/LATER/WAIT/DONE/DEFERRED/CANCELED (task-like kinds only)
|
|
15
|
+
priority TEXT, -- A/B/C (task-like kinds only)
|
|
16
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
17
|
+
scheduled_at TEXT, -- planned start / appearance date (task-like kinds)
|
|
18
|
+
deadline_at TEXT, -- hard deadline
|
|
19
|
+
closed_at TEXT, -- completion / cancel time
|
|
20
|
+
body TEXT -- optional long content
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_node_parent ON node(parent_id);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_node_kind ON node(kind);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_node_status ON node(status);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS tag (
|
|
28
|
+
node_id INTEGER NOT NULL REFERENCES node(id) ON DELETE CASCADE,
|
|
29
|
+
tag TEXT NOT NULL,
|
|
30
|
+
PRIMARY KEY (node_id, tag)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_tag_tag ON tag(tag);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS log (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
node_id INTEGER NOT NULL REFERENCES node(id) ON DELETE CASCADE,
|
|
38
|
+
logged_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
39
|
+
body TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_log_node ON log(node_id);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_log_time ON log(logged_at);
|
|
44
|
+
|
|
45
|
+
-- forward planning (calendar-like, decoupled from log): pin tasks to a specific day / recurrence rule
|
|
46
|
+
-- on_date and rrule are mutually exclusive; a task can have multiple rows (multi-day / one-off + recurring together)
|
|
47
|
+
-- `wl day` derives planned (hit by sched) vs unplanned (logged with no sched) from this table
|
|
48
|
+
CREATE TABLE IF NOT EXISTS sched (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
node_id INTEGER NOT NULL REFERENCES node(id) ON DELETE CASCADE,
|
|
51
|
+
on_date TEXT, -- a specific day YYYY-MM-DD (one-off)
|
|
52
|
+
rrule TEXT, -- recurrence rule: daily / weekly:Mon,Wed,Fri
|
|
53
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_sched_node ON sched(node_id);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_sched_date ON sched(on_date);
|
|
58
|
+
|
|
59
|
+
-- date metadata (calendar-like): holiday / vacation / makeup-workday / solar-term context; weekday is computed, not stored
|
|
60
|
+
-- can be bulk-imported (e.g. national holidays) plus user customization (personal vacation / makeup days); independent of day-node existence
|
|
61
|
+
CREATE TABLE IF NOT EXISTS date_meta (
|
|
62
|
+
date TEXT PRIMARY KEY, -- YYYY-MM-DD
|
|
63
|
+
label TEXT NOT NULL -- e.g. "Labor Day holiday" / "makeup workday" / "solar term: Lesser Fullness" / "annual leave"
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS prop (
|
|
67
|
+
node_id INTEGER NOT NULL REFERENCES node(id) ON DELETE CASCADE,
|
|
68
|
+
key TEXT NOT NULL,
|
|
69
|
+
value TEXT NOT NULL,
|
|
70
|
+
PRIMARY KEY (node_id, key)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS link (
|
|
74
|
+
node_id INTEGER NOT NULL REFERENCES node(id) ON DELETE CASCADE,
|
|
75
|
+
vault_doc TEXT NOT NULL, -- vault document name (no .md suffix)
|
|
76
|
+
PRIMARY KEY (node_id, vault_doc)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_link_doc ON link(vault_doc);
|
|
80
|
+
|
|
81
|
+
-- view: tree path (used by `wl tree` for display)
|
|
82
|
+
CREATE VIEW IF NOT EXISTS v_node_path AS
|
|
83
|
+
WITH RECURSIVE path(id, depth, label) AS (
|
|
84
|
+
SELECT id, 0, title FROM node WHERE parent_id IS NULL
|
|
85
|
+
UNION ALL
|
|
86
|
+
SELECT n.id, p.depth + 1, p.label || ' / ' || n.title
|
|
87
|
+
FROM node n
|
|
88
|
+
JOIN path p ON n.parent_id = p.id
|
|
89
|
+
)
|
|
90
|
+
SELECT * FROM path;
|
worklog/queries.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""sqlite-backed query helpers for worklog.
|
|
2
|
+
|
|
3
|
+
These take a sqlite3.Connection as an argument and don't touch any
|
|
4
|
+
module-level state, so they're safe to import from any module that
|
|
5
|
+
already has a connection. They sit between the pure utilities in
|
|
6
|
+
helpers.py and the command handlers in cli.py.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
import sys
|
|
12
|
+
from .helpers import GENERIC_TAGS # noqa: F401
|
|
13
|
+
from .helpers import _resolve_concrete_date
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _insert_log(con, nid, entry):
|
|
17
|
+
"""Insert a log. entry can carry a historical date + time:
|
|
18
|
+
- dict{date, time, body}: date=YYYY-MM-DD / today / yesterday / day-before-yesterday; time=HH:MM optional
|
|
19
|
+
- string prefixed with 'YYYY-MM-DD content': date only
|
|
20
|
+
- plain body: use NOW (DB DEFAULT)
|
|
21
|
+
"""
|
|
22
|
+
import re as _re
|
|
23
|
+
date, time_part, body = None, None, entry
|
|
24
|
+
if isinstance(entry, dict):
|
|
25
|
+
date, time_part, body = entry.get("date"), entry.get("time"), entry["body"]
|
|
26
|
+
else:
|
|
27
|
+
m = _re.match(r"^(\d{4}-\d{2}-\d{2})[ T](.*)$", entry)
|
|
28
|
+
if m:
|
|
29
|
+
date, body = m.group(1), m.group(2)
|
|
30
|
+
if date:
|
|
31
|
+
# parse short form ("yesterday/today/day-before-yesterday/tomorrow/day-after-tomorrow" or YYYY-MM-DD)
|
|
32
|
+
date = _resolve_concrete_date(date)
|
|
33
|
+
if time_part:
|
|
34
|
+
if not _re.match(r"^(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$", time_part):
|
|
35
|
+
raise ValueError(f"invalid --time '{time_part}' (expected HH:MM or HH:MM:SS)")
|
|
36
|
+
# pad seconds
|
|
37
|
+
if time_part.count(":") == 1:
|
|
38
|
+
time_part += ":00"
|
|
39
|
+
logged_at = f"{date} {time_part}"
|
|
40
|
+
else:
|
|
41
|
+
logged_at = date
|
|
42
|
+
con.execute("INSERT INTO log (node_id, logged_at, body) VALUES (?, ?, ?)", (nid, logged_at, body))
|
|
43
|
+
elif time_part:
|
|
44
|
+
# no date but time given -> today + that time
|
|
45
|
+
from datetime import date as _date
|
|
46
|
+
if not _re.match(r"^(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$", time_part):
|
|
47
|
+
raise ValueError(f"invalid --time '{time_part}' (expected HH:MM or HH:MM:SS)")
|
|
48
|
+
if time_part.count(":") == 1:
|
|
49
|
+
time_part += ":00"
|
|
50
|
+
logged_at = f"{_date.today().isoformat()} {time_part}"
|
|
51
|
+
con.execute("INSERT INTO log (node_id, logged_at, body) VALUES (?, ?, ?)", (nid, logged_at, body))
|
|
52
|
+
else:
|
|
53
|
+
con.execute("INSERT INTO log (node_id, body) VALUES (?, ?)", (nid, body))
|
|
54
|
+
|
|
55
|
+
def _project_members(con, proj_id):
|
|
56
|
+
"""Set of task/meetlog/habit ids linked to a project: structural children (parent) + shared semantic tags"""
|
|
57
|
+
ids = set()
|
|
58
|
+
proj_tags = {r["tag"] for r in con.execute("SELECT tag FROM tag WHERE node_id = ?", (proj_id,))} - GENERIC_TAGS
|
|
59
|
+
for r in con.execute(
|
|
60
|
+
"SELECT id FROM node WHERE parent_id = ? AND kind IN ('task','meetlog','habit')", (proj_id,)
|
|
61
|
+
):
|
|
62
|
+
ids.add(r["id"])
|
|
63
|
+
if proj_tags:
|
|
64
|
+
qm = ",".join("?" * len(proj_tags))
|
|
65
|
+
for r in con.execute(
|
|
66
|
+
f"SELECT DISTINCT n.id FROM node n JOIN tag t ON n.id = t.node_id "
|
|
67
|
+
f"WHERE t.tag IN ({qm}) AND n.kind IN ('task','meetlog','habit')",
|
|
68
|
+
list(proj_tags),
|
|
69
|
+
):
|
|
70
|
+
ids.add(r["id"])
|
|
71
|
+
return ids
|
|
72
|
+
|
|
73
|
+
def _ancestors_chain(con, node_id):
|
|
74
|
+
"""Return the path list[Row] from the top-level root to node (inclusive)."""
|
|
75
|
+
chain = []
|
|
76
|
+
cur = con.execute("SELECT * FROM node WHERE id = ?", (node_id,)).fetchone()
|
|
77
|
+
if not cur:
|
|
78
|
+
return chain
|
|
79
|
+
chain.append(cur)
|
|
80
|
+
while cur["parent_id"]:
|
|
81
|
+
cur = con.execute("SELECT * FROM node WHERE id = ?", (cur["parent_id"],)).fetchone()
|
|
82
|
+
if not cur:
|
|
83
|
+
break
|
|
84
|
+
chain.append(cur)
|
|
85
|
+
return list(reversed(chain))
|
|
86
|
+
|
|
87
|
+
def _node_bucket(con, nid):
|
|
88
|
+
"""Bucket a node into work / personal / other by work/personal tag."""
|
|
89
|
+
tags = {r["tag"] for r in con.execute("SELECT tag FROM tag WHERE node_id = ?", (nid,))}
|
|
90
|
+
if "work" in tags:
|
|
91
|
+
return "work"
|
|
92
|
+
if "personal" in tags:
|
|
93
|
+
return "personal"
|
|
94
|
+
return "other"
|
|
95
|
+
|
|
96
|
+
def _node_project(con, nid):
|
|
97
|
+
"""Return the project ancestor (id, title) of a node, or (None, '(unassigned)') if none."""
|
|
98
|
+
for p in _ancestors_chain(con, nid):
|
|
99
|
+
if p["kind"] == "project":
|
|
100
|
+
return p["id"], p["title"]
|
|
101
|
+
return None, "(unassigned)"
|
|
102
|
+
|
|
103
|
+
def _node_plan(con, nid, sched_ids):
|
|
104
|
+
"""Derive planned/unplanned: schedule-hit = planned; otherwise check transitional planned/unplanned tag; neither -> unplanned (untagged)."""
|
|
105
|
+
if nid in sched_ids:
|
|
106
|
+
return "planned"
|
|
107
|
+
tags = {r["tag"] for r in con.execute("SELECT tag FROM tag WHERE node_id = ?", (nid,))}
|
|
108
|
+
if "planned" in tags:
|
|
109
|
+
return "planned"
|
|
110
|
+
if "unplanned" in tags:
|
|
111
|
+
return "unplanned"
|
|
112
|
+
return "unplanned (untagged)"
|
|
113
|
+
|
|
114
|
+
def _sec_group(con, nid, n, by, sched_ids):
|
|
115
|
+
"""(key, display title) for the secondary group. by in project/priority/plan."""
|
|
116
|
+
if by == "priority":
|
|
117
|
+
label = {"A": "P0", "B": "P1", "C": "P2"}.get(n["priority"], "—")
|
|
118
|
+
return label, label
|
|
119
|
+
if by == "plan":
|
|
120
|
+
label = _node_plan(con, nid, sched_ids)
|
|
121
|
+
return label, label
|
|
122
|
+
pid, ptitle = _node_project(con, nid)
|
|
123
|
+
return (pid if pid is not None else ptitle), ptitle
|
|
124
|
+
|
|
125
|
+
def _collect_descendants(con, root_id):
|
|
126
|
+
"""Recursively collect all descendant ids of a node (excluding self)."""
|
|
127
|
+
acc = []
|
|
128
|
+
stack = [root_id]
|
|
129
|
+
while stack:
|
|
130
|
+
pid = stack.pop()
|
|
131
|
+
children = con.execute("SELECT id FROM node WHERE parent_id = ?", (pid,)).fetchall()
|
|
132
|
+
for c in children:
|
|
133
|
+
acc.append(c["id"])
|
|
134
|
+
stack.append(c["id"])
|
|
135
|
+
return acc
|
|
136
|
+
|
|
137
|
+
def _has_tag(con, nid, tag):
|
|
138
|
+
return con.execute("SELECT 1 FROM tag WHERE node_id = ? AND tag = ? LIMIT 1", (nid, tag)).fetchone() is not None
|
|
139
|
+
|
|
140
|
+
def _node_clock_min(con, nid):
|
|
141
|
+
"""Total minutes spent on this node, auto-combined: CLOCK_OUT elapsed sum union log timestamp span.
|
|
142
|
+
Takes the greater so "no wl start/stop, only wl log" workflows still get a rough duration.
|
|
143
|
+
Design choice: auto-compute, no explicit --duration field. Auto-calc surfaces drift; an explicit field
|
|
144
|
+
rarely gets updated and pollutes upper-level aggregations.
|
|
145
|
+
"""
|
|
146
|
+
import re as _re
|
|
147
|
+
|
|
148
|
+
# 1. CLOCK_OUT elapsed total (precise, from wl start/stop)
|
|
149
|
+
clock = 0
|
|
150
|
+
for r in con.execute("SELECT body FROM log WHERE node_id = ? AND body LIKE 'CLOCK_OUT%'", (nid,)):
|
|
151
|
+
m = _re.search(r"elapsed=(\d+)min", r["body"])
|
|
152
|
+
if m:
|
|
153
|
+
clock += int(m.group(1))
|
|
154
|
+
|
|
155
|
+
# 2. ordinary log timestamp span (rough, max - min, excluding CLOCK_*)
|
|
156
|
+
# multiple logs at the same timestamp count as one "batch-backfilled instant", not duplicated
|
|
157
|
+
rows = list(con.execute(
|
|
158
|
+
"SELECT DISTINCT logged_at FROM log WHERE node_id = ? AND body NOT LIKE 'CLOCK_%' ESCAPE '\\' ORDER BY logged_at",
|
|
159
|
+
(nid,),
|
|
160
|
+
))
|
|
161
|
+
span = 0
|
|
162
|
+
if len(rows) >= 2:
|
|
163
|
+
try:
|
|
164
|
+
from datetime import datetime
|
|
165
|
+
first = datetime.fromisoformat(rows[0]["logged_at"])
|
|
166
|
+
last = datetime.fromisoformat(rows[-1]["logged_at"])
|
|
167
|
+
span = max(0, int((last - first).total_seconds() / 60))
|
|
168
|
+
except (ValueError, TypeError):
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
return max(clock, span)
|
|
172
|
+
|
|
173
|
+
def _node_exists(con, node_id):
|
|
174
|
+
return con.execute("SELECT 1 FROM node WHERE id = ?", (node_id,)).fetchone() is not None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _node_tags(con, nid):
|
|
178
|
+
"""Return the tag list for a node (insertion order)."""
|
|
179
|
+
return [r["tag"] for r in con.execute("SELECT tag FROM tag WHERE node_id = ?", (nid,))]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _check_ids_exist(con, ids):
|
|
183
|
+
"""Batch existence check; sys.exit if any id is missing. Used by multi-id commands."""
|
|
184
|
+
for nid in ids:
|
|
185
|
+
if not _node_exists(con, nid):
|
|
186
|
+
sys.exit(f"✗ node #{nid} not found")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _upsert_prop(con, nid, key, value):
|
|
190
|
+
"""Unified prop UPSERT (no commit; caller controls the transaction). Batch-friendly.
|
|
191
|
+
`_set_prop` is the commit version for single daily operations."""
|
|
192
|
+
con.execute("INSERT OR REPLACE INTO prop (node_id, key, value) VALUES (?, ?, ?)", (nid, key, value))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# generic ORDER BY fragment: priority A/B/C first, NULL last; same priority by id ascending.
|
|
196
|
+
# Usage: f"SELECT * FROM node WHERE ... {_ORDER_BY_PRI_ID}"
|
|
197
|
+
# Note: when joining, write the qualified column "n.priority"; that case stays inline.
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _status_filter_sql(include_canceled=False, hide_done=False, col="status"):
|
|
201
|
+
"""Build a `status` column filter SQL fragment + params. Used uniformly across cmds, avoids scattered string-concat.
|
|
202
|
+
Returns (where_fragment, params_list); when nothing is filtered returns ("", []).
|
|
203
|
+
|
|
204
|
+
Usage:
|
|
205
|
+
frag, params = _status_filter_sql(inc_cancel, hide_done=not args.all)
|
|
206
|
+
if frag: where.append(frag); sql_params.extend(params)
|
|
207
|
+
"""
|
|
208
|
+
excluded = []
|
|
209
|
+
if hide_done:
|
|
210
|
+
excluded.append("DONE")
|
|
211
|
+
if not include_canceled:
|
|
212
|
+
excluded.append("CANCELED")
|
|
213
|
+
if not excluded:
|
|
214
|
+
return "", []
|
|
215
|
+
ph = ",".join("?" * len(excluded))
|
|
216
|
+
return f"({col} IS NULL OR {col} NOT IN ({ph}))", excluded
|