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.
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