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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""worklog command handlers and their internal helpers.
|
|
2
|
+
|
|
3
|
+
Split into 5 groups by responsibility: state mutations, queries / views,
|
|
4
|
+
tree / day rendering, bulk import-apply, and meta (init/config/migrate/
|
|
5
|
+
checkin/sched/dateinfo/themes/goal/recap). Each group is one .py file;
|
|
6
|
+
this __init__ re-exports everything so cli.py (and tests) can import
|
|
7
|
+
from `worklog.commands` without knowing the internal split.
|
|
8
|
+
"""
|
|
9
|
+
from .state import (
|
|
10
|
+
cmd_add,
|
|
11
|
+
cmd_log,
|
|
12
|
+
cmd_done,
|
|
13
|
+
cmd_defer,
|
|
14
|
+
cmd_start,
|
|
15
|
+
cmd_stop,
|
|
16
|
+
cmd_spent,
|
|
17
|
+
cmd_link,
|
|
18
|
+
cmd_set,
|
|
19
|
+
cmd_tick,
|
|
20
|
+
cmd_wait,
|
|
21
|
+
cmd_reopen,
|
|
22
|
+
cmd_cancel,
|
|
23
|
+
cmd_unlog,
|
|
24
|
+
cmd_relog,
|
|
25
|
+
cmd_active,
|
|
26
|
+
_ids_list,
|
|
27
|
+
_bulk_status_change,
|
|
28
|
+
_edit_in_editor,
|
|
29
|
+
)
|
|
30
|
+
from .query import (
|
|
31
|
+
cmd_show,
|
|
32
|
+
cmd_ls,
|
|
33
|
+
cmd_find,
|
|
34
|
+
cmd_focus,
|
|
35
|
+
cmd_ancestors,
|
|
36
|
+
cmd_descendants,
|
|
37
|
+
cmd_projects,
|
|
38
|
+
cmd_changes,
|
|
39
|
+
cmd_summary,
|
|
40
|
+
cmd_logs,
|
|
41
|
+
_show_one,
|
|
42
|
+
)
|
|
43
|
+
from .views import (
|
|
44
|
+
cmd_tree,
|
|
45
|
+
cmd_day,
|
|
46
|
+
_tree_by,
|
|
47
|
+
_tree_children,
|
|
48
|
+
_print_tree,
|
|
49
|
+
_print_day_activity,
|
|
50
|
+
_print_default_tree,
|
|
51
|
+
_render_day_group,
|
|
52
|
+
_sec_sort_key,
|
|
53
|
+
_sched_fires,
|
|
54
|
+
_scheduled_node_ids,
|
|
55
|
+
_date_label,
|
|
56
|
+
_cn_weekday,
|
|
57
|
+
)
|
|
58
|
+
from .bulk import (
|
|
59
|
+
cmd_import,
|
|
60
|
+
cmd_apply,
|
|
61
|
+
_import_node,
|
|
62
|
+
_import_update,
|
|
63
|
+
_parse_node_line,
|
|
64
|
+
_parse_fieldop,
|
|
65
|
+
_parse_wld,
|
|
66
|
+
_validate_fieldop,
|
|
67
|
+
_exec_update,
|
|
68
|
+
_fieldop_desc,
|
|
69
|
+
_apply_sub,
|
|
70
|
+
)
|
|
71
|
+
from .meta import (
|
|
72
|
+
cmd_init,
|
|
73
|
+
cmd_config,
|
|
74
|
+
cmd_migrate,
|
|
75
|
+
cmd_themes,
|
|
76
|
+
cmd_dateinfo,
|
|
77
|
+
cmd_goal,
|
|
78
|
+
cmd_summary_prop,
|
|
79
|
+
cmd_checkin,
|
|
80
|
+
cmd_sched,
|
|
81
|
+
_set_prop,
|
|
82
|
+
_get_prop,
|
|
83
|
+
_ensure_today_day,
|
|
84
|
+
_checkin_collect,
|
|
85
|
+
_is_interactive_tty,
|
|
86
|
+
_multi_select_tty,
|
|
87
|
+
_checkin_per_item,
|
|
88
|
+
_norm_rrule,
|
|
89
|
+
)
|
worklog/commands/bulk.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""worklog commands: bulk group."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .. import render
|
|
14
|
+
from ..helpers import (
|
|
15
|
+
_apply_top_limit,
|
|
16
|
+
_fmt_dur,
|
|
17
|
+
_is_brief,
|
|
18
|
+
_log_full,
|
|
19
|
+
_norm_sched,
|
|
20
|
+
_resolve_at_ts,
|
|
21
|
+
_resolve_concrete_date,
|
|
22
|
+
_resolve_log_tail,
|
|
23
|
+
_resolve_window,
|
|
24
|
+
_sched_anchor,
|
|
25
|
+
_sched_display,
|
|
26
|
+
_sched_kind,
|
|
27
|
+
_sched_sort_key,
|
|
28
|
+
_status_marker,
|
|
29
|
+
_term_width,
|
|
30
|
+
_truncate_log_body,
|
|
31
|
+
GENERIC_TAGS,
|
|
32
|
+
)
|
|
33
|
+
from ..queries import (
|
|
34
|
+
_ancestors_chain,
|
|
35
|
+
_check_ids_exist,
|
|
36
|
+
_collect_descendants,
|
|
37
|
+
_has_tag,
|
|
38
|
+
_insert_log,
|
|
39
|
+
_node_bucket,
|
|
40
|
+
_node_clock_min,
|
|
41
|
+
_node_exists,
|
|
42
|
+
_node_plan,
|
|
43
|
+
_node_project,
|
|
44
|
+
_node_tags,
|
|
45
|
+
_project_members,
|
|
46
|
+
_sec_group,
|
|
47
|
+
_status_filter_sql,
|
|
48
|
+
_upsert_prop,
|
|
49
|
+
)
|
|
50
|
+
from ..render import (
|
|
51
|
+
_PRI_STYLE,
|
|
52
|
+
_STATUS_STYLE,
|
|
53
|
+
_RICH_AVAIL,
|
|
54
|
+
_resolve_theme,
|
|
55
|
+
THEMES,
|
|
56
|
+
_c,
|
|
57
|
+
_hl,
|
|
58
|
+
_node_line,
|
|
59
|
+
_print_truncation_hint,
|
|
60
|
+
_snippet,
|
|
61
|
+
out,
|
|
62
|
+
)
|
|
63
|
+
from ..xdg import _resolve_db_path, _resolve_aliases_path, _xdg_data_home, _xdg_config_home
|
|
64
|
+
|
|
65
|
+
# Lazy access to cli module (for DB wrappers + module state).
|
|
66
|
+
# Used at function call time (not at import) to avoid the cli ↔ commands
|
|
67
|
+
# import cycle.
|
|
68
|
+
from .. import cli as _cli # noqa: E402
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_import(args, con):
|
|
72
|
+
"""Batch add/update: JSON {add:[...], update:[...]}; single transaction; supports nested children + ref/parent_ref."""
|
|
73
|
+
import json
|
|
74
|
+
|
|
75
|
+
raw = sys.stdin.read() if args.file == "-" else Path(args.file).read_text(encoding="utf-8")
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(raw)
|
|
78
|
+
except json.JSONDecodeError as e:
|
|
79
|
+
sys.exit(f"✗ JSON parse error: {e}")
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
sys.exit("✗ top level must be an object {add:[...], update:[...]}")
|
|
82
|
+
|
|
83
|
+
ref_map = {}
|
|
84
|
+
counters = {"add": 0, "update": 0}
|
|
85
|
+
dry = args.dry_run
|
|
86
|
+
try:
|
|
87
|
+
for spec in data.get("add", []):
|
|
88
|
+
_import_node(con, spec, None, ref_map, dry, counters)
|
|
89
|
+
for spec in data.get("update", []):
|
|
90
|
+
_import_update(con, spec, dry, counters)
|
|
91
|
+
except (ValueError, KeyError) as e:
|
|
92
|
+
con.rollback()
|
|
93
|
+
sys.exit(f"✗ import failed (rolled back): {e}")
|
|
94
|
+
|
|
95
|
+
if dry:
|
|
96
|
+
out(_c("[dry-run]", "meta") + _c(f" would add {counters['add']} · update {counters['update']} (not written)"))
|
|
97
|
+
if ref_map:
|
|
98
|
+
out(" " + _c("ref: " + ", ".join(ref_map.keys()), "meta"))
|
|
99
|
+
else:
|
|
100
|
+
con.commit()
|
|
101
|
+
out(_c("✓", "done") + _c(f" added {counters['add']} · updated {counters['update']}"))
|
|
102
|
+
if ref_map:
|
|
103
|
+
print(" ref->id: " + ", ".join(f"{k}=#{v}" for k, v in ref_map.items()))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- wl-diff format (apply) ---
|
|
107
|
+
# line format: <prefix><indent><node line> prefix: '+' add, '~' update, '-' delete, ' ' context anchor
|
|
108
|
+
# node line: [marker] [#pri] #id [kind] title :tags: (marker required, others optional)
|
|
109
|
+
# rich-field sub-lines: <indent>@log/@link/@prop <value> (attached to the previous node)
|
|
110
|
+
_MARKER_STATUS = {" ": "TODO", "x": "DONE", "/": "DOING", ">": "LATER", "?": "WAIT", "-": "CANCELED"}
|
|
111
|
+
|
|
112
|
+
def cmd_apply(args, con):
|
|
113
|
+
"""Apply wl-diff: + add (node line) / ~ update (lock-line + field-ops, only declared fields) / - delete / anchor. Single transaction + dry-run validation."""
|
|
114
|
+
raw = sys.stdin.read() if args.file == "-" else Path(args.file).read_text(encoding="utf-8")
|
|
115
|
+
try:
|
|
116
|
+
ops = _parse_wld(raw)
|
|
117
|
+
except ValueError as e:
|
|
118
|
+
sys.exit(f"✗ parse failed: {e}")
|
|
119
|
+
|
|
120
|
+
# --- validation phase (validate all, stop on errors, no write) ---
|
|
121
|
+
errors = []
|
|
122
|
+
for o in ops:
|
|
123
|
+
pfx, ln = o["op"], o["lineno"]
|
|
124
|
+
if pfx == "~":
|
|
125
|
+
if not _node_exists(con, o["id"]):
|
|
126
|
+
errors.append(f"line {ln}: #{o['id']} does not exist")
|
|
127
|
+
if not o["fieldops"]:
|
|
128
|
+
errors.append(f"line {ln}: ~ #{o['id']} has no field operations")
|
|
129
|
+
for floln, (action, field, value) in o["fieldops"]:
|
|
130
|
+
_validate_fieldop(con, floln, action, field, value, errors)
|
|
131
|
+
else:
|
|
132
|
+
f = o["fields"]
|
|
133
|
+
has_id = "id" in f
|
|
134
|
+
if pfx in ("-", " ") and not has_id:
|
|
135
|
+
errors.append(f"line {ln}: '{pfx}' requires #id")
|
|
136
|
+
if pfx == "+" and has_id:
|
|
137
|
+
errors.append(f"line {ln}: '+' add should not carry #id")
|
|
138
|
+
if has_id and pfx != "+" and not _node_exists(con, f["id"]):
|
|
139
|
+
errors.append(f"line {ln}: #{f['id']} does not exist")
|
|
140
|
+
if "marker" in f and f["marker"] not in _MARKER_STATUS:
|
|
141
|
+
errors.append(f"line {ln}: unknown marker [{f['marker']}]")
|
|
142
|
+
if errors:
|
|
143
|
+
sys.exit("✗ validation failed (not written):\n " + "\n ".join(errors))
|
|
144
|
+
|
|
145
|
+
# --- plan (guaranteed valid at this point) ---
|
|
146
|
+
plan = []
|
|
147
|
+
for o in ops:
|
|
148
|
+
pfx = o["op"]
|
|
149
|
+
if pfx == "~":
|
|
150
|
+
ch = ", ".join(_fieldop_desc(a, fld, v) for _, (a, fld, v) in o["fieldops"])
|
|
151
|
+
plan.append(f"~ #{o['id']}: {ch}")
|
|
152
|
+
elif pfx == "+":
|
|
153
|
+
f = o["fields"]
|
|
154
|
+
sub = f" (+{len(o['subs'])} sub-items)" if o["subs"] else ""
|
|
155
|
+
plan.append(f"+ {f['title']}" + (f" @depth{o['depth']}" if o["depth"] else "") + sub)
|
|
156
|
+
elif pfx == "-":
|
|
157
|
+
plan.append(f"- #{o['fields']['id']} (cascades subtree)")
|
|
158
|
+
|
|
159
|
+
if args.dry_run:
|
|
160
|
+
out(_c("[dry-run]", "meta") + _c(f" {len(plan)} operations (not written):", "header"))
|
|
161
|
+
for desc in plan:
|
|
162
|
+
out(" " + _c(desc))
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# --- execute (single transaction) ---
|
|
166
|
+
stack = {}
|
|
167
|
+
counts = {"add": 0, "update": 0, "delete": 0}
|
|
168
|
+
try:
|
|
169
|
+
for o in ops:
|
|
170
|
+
pfx = o["op"]
|
|
171
|
+
if pfx == "~":
|
|
172
|
+
_exec_update(con, o)
|
|
173
|
+
counts["update"] += 1
|
|
174
|
+
continue
|
|
175
|
+
f, depth = o["fields"], o["depth"]
|
|
176
|
+
if pfx == " ":
|
|
177
|
+
stack[depth] = f["id"]
|
|
178
|
+
continue
|
|
179
|
+
if pfx == "-":
|
|
180
|
+
# recursive subtree delete: node self-ref is ON DELETE SET NULL, only deleting the parent would orphan children, so we must explicitly collect descendants
|
|
181
|
+
ids = [f["id"]] + _collect_descendants(con, f["id"])
|
|
182
|
+
for did in ids:
|
|
183
|
+
con.execute("DELETE FROM node WHERE id = ?", (did,))
|
|
184
|
+
counts["delete"] += len(ids)
|
|
185
|
+
continue
|
|
186
|
+
# pfx == "+": add new node
|
|
187
|
+
parent_id = stack.get(depth - 1) if depth > 0 else None
|
|
188
|
+
status = _MARKER_STATUS.get(f.get("marker", " "), "TODO")
|
|
189
|
+
kind = f.get("kind", "task")
|
|
190
|
+
if status == "DONE":
|
|
191
|
+
cur = con.execute(
|
|
192
|
+
"INSERT INTO node (parent_id,title,kind,status,priority,closed_at) "
|
|
193
|
+
"VALUES (?,?,?,?,?, datetime('now','localtime'))",
|
|
194
|
+
(parent_id, f["title"], kind, status, f.get("priority")),
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
cur = con.execute(
|
|
198
|
+
"INSERT INTO node (parent_id,title,kind,status,priority) VALUES (?,?,?,?,?)",
|
|
199
|
+
(parent_id, f["title"], kind, status, f.get("priority")),
|
|
200
|
+
)
|
|
201
|
+
nid = cur.lastrowid
|
|
202
|
+
for t in f.get("tags", []):
|
|
203
|
+
con.execute("INSERT OR IGNORE INTO tag (node_id,tag) VALUES (?,?)", (nid, t))
|
|
204
|
+
for kind_, val in o["subs"]:
|
|
205
|
+
_apply_sub(con, nid, kind_, val)
|
|
206
|
+
counts["add"] += 1
|
|
207
|
+
stack[depth] = nid
|
|
208
|
+
except Exception as e:
|
|
209
|
+
con.rollback()
|
|
210
|
+
sys.exit(f"✗ apply failed (rolled back): {e}")
|
|
211
|
+
|
|
212
|
+
con.commit()
|
|
213
|
+
out(_c("✓", "done") + _c(f" added {counts['add']} · updated {counts['update']} · deleted {counts['delete']}"))
|
|
214
|
+
|
|
215
|
+
def _import_node(con, spec, parent_id, ref_map, dry, counters):
|
|
216
|
+
"""Recursively insert a node (with tags/props/links/logs/children). Returns new id (placeholder None in dry mode)."""
|
|
217
|
+
title = spec.get("title")
|
|
218
|
+
if not title:
|
|
219
|
+
raise ValueError(f"node missing title: {spec}")
|
|
220
|
+
kind = spec.get("kind", "task")
|
|
221
|
+
status = spec.get("status")
|
|
222
|
+
if not status and kind in ("task", "habit"):
|
|
223
|
+
status = "TODO"
|
|
224
|
+
sched = _norm_sched(spec.get("scheduled")) # normalize + validate (raises in dry-run)
|
|
225
|
+
# parent: explicit parent_id > parent_ref (same batch) > recursively-passed parent_id
|
|
226
|
+
pid = spec.get("parent", parent_id)
|
|
227
|
+
if spec.get("parent_ref"):
|
|
228
|
+
if spec["parent_ref"] not in ref_map:
|
|
229
|
+
raise ValueError(f"parent_ref '{spec['parent_ref']}' undefined (must appear before reference)")
|
|
230
|
+
pid = ref_map[spec["parent_ref"]]
|
|
231
|
+
closed_at = None
|
|
232
|
+
if status == "DONE":
|
|
233
|
+
closed_at = "datetime_now" # placeholder; the SQL below uses datetime('now','localtime')
|
|
234
|
+
|
|
235
|
+
if dry:
|
|
236
|
+
nid = f"<ref:{spec.get('ref', '?')}>"
|
|
237
|
+
counters["add"] += 1
|
|
238
|
+
else:
|
|
239
|
+
if closed_at:
|
|
240
|
+
cur = con.execute(
|
|
241
|
+
"INSERT INTO node (parent_id,title,kind,status,priority,scheduled_at,deadline_at,body,closed_at) "
|
|
242
|
+
"VALUES (?,?,?,?,?,?,?,?, datetime('now','localtime'))",
|
|
243
|
+
(pid, title, kind, status, spec.get("priority"), sched,
|
|
244
|
+
spec.get("deadline"), spec.get("body")),
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
cur = con.execute(
|
|
248
|
+
"INSERT INTO node (parent_id,title,kind,status,priority,scheduled_at,deadline_at,body) "
|
|
249
|
+
"VALUES (?,?,?,?,?,?,?,?)",
|
|
250
|
+
(pid, title, kind, status, spec.get("priority"), sched,
|
|
251
|
+
spec.get("deadline"), spec.get("body")),
|
|
252
|
+
)
|
|
253
|
+
nid = cur.lastrowid
|
|
254
|
+
counters["add"] += 1
|
|
255
|
+
for t in spec.get("tags", []):
|
|
256
|
+
con.execute("INSERT OR IGNORE INTO tag (node_id,tag) VALUES (?,?)", (nid, t))
|
|
257
|
+
for k, v in (spec.get("props") or {}).items():
|
|
258
|
+
_upsert_prop(con, nid, k, str(v))
|
|
259
|
+
for d in spec.get("links", []):
|
|
260
|
+
con.execute("INSERT OR IGNORE INTO link (node_id,vault_doc) VALUES (?,?)", (nid, d))
|
|
261
|
+
for entry in spec.get("logs", []):
|
|
262
|
+
_insert_log(con, nid, entry)
|
|
263
|
+
|
|
264
|
+
if spec.get("ref"):
|
|
265
|
+
ref_map[spec["ref"]] = nid
|
|
266
|
+
for child in spec.get("children", []):
|
|
267
|
+
_import_node(con, child, nid, ref_map, dry, counters)
|
|
268
|
+
return nid
|
|
269
|
+
|
|
270
|
+
def _import_update(con, spec, dry, counters):
|
|
271
|
+
nid = spec.get("id")
|
|
272
|
+
if not nid or not _node_exists(con, nid):
|
|
273
|
+
raise ValueError(f"update target #{nid} does not exist")
|
|
274
|
+
if dry:
|
|
275
|
+
counters["update"] += 1
|
|
276
|
+
return
|
|
277
|
+
if "parent" in spec and spec["parent"] is not None and not _node_exists(con, spec["parent"]):
|
|
278
|
+
raise ValueError(f"update #{nid}: parent #{spec['parent']} does not exist")
|
|
279
|
+
fields, vals = [], []
|
|
280
|
+
for col in ("status", "priority", "title", "scheduled_at", "deadline_at", "body"):
|
|
281
|
+
if col in spec:
|
|
282
|
+
fields.append(f"{col} = ?")
|
|
283
|
+
vals.append(spec[col])
|
|
284
|
+
if "parent" in spec: # move; parent_id column name differs from spec key, handled separately
|
|
285
|
+
fields.append("parent_id = ?")
|
|
286
|
+
vals.append(spec["parent"])
|
|
287
|
+
if spec.get("status") == "DONE" and "closed_at" not in spec:
|
|
288
|
+
fields.append("closed_at = datetime('now','localtime')")
|
|
289
|
+
if fields:
|
|
290
|
+
con.execute(f"UPDATE node SET {', '.join(fields)} WHERE id = ?", (*vals, nid))
|
|
291
|
+
for t in spec.get("add_tags", []):
|
|
292
|
+
con.execute("INSERT OR IGNORE INTO tag (node_id,tag) VALUES (?,?)", (nid, t))
|
|
293
|
+
for t in spec.get("remove_tags", []):
|
|
294
|
+
con.execute("DELETE FROM tag WHERE node_id = ? AND tag = ?", (nid, t))
|
|
295
|
+
for d in spec.get("add_links", []):
|
|
296
|
+
con.execute("INSERT OR IGNORE INTO link (node_id,vault_doc) VALUES (?,?)", (nid, d))
|
|
297
|
+
for entry in spec.get("add_logs", []):
|
|
298
|
+
_insert_log(con, nid, entry)
|
|
299
|
+
counters["update"] += 1
|
|
300
|
+
|
|
301
|
+
def _parse_node_line(body):
|
|
302
|
+
import re
|
|
303
|
+
|
|
304
|
+
f = {}
|
|
305
|
+
m = re.match(r"^\[([ x/>?\-])\]\s*", body)
|
|
306
|
+
if m:
|
|
307
|
+
f["marker"] = m.group(1)
|
|
308
|
+
body = body[m.end():]
|
|
309
|
+
m = re.match(r"^\[#([A-C])\]\s*", body)
|
|
310
|
+
if m:
|
|
311
|
+
f["priority"] = m.group(1)
|
|
312
|
+
body = body[m.end():]
|
|
313
|
+
m = re.match(r"^#(\d+)\s*", body)
|
|
314
|
+
if m:
|
|
315
|
+
f["id"] = int(m.group(1))
|
|
316
|
+
body = body[m.end():]
|
|
317
|
+
m = re.match(r"^\[([a-z_]+)\]\s*", body)
|
|
318
|
+
if m:
|
|
319
|
+
f["kind"] = m.group(1)
|
|
320
|
+
body = body[m.end():]
|
|
321
|
+
m = re.search(r"\s*:([\w:]+):\s*$", body)
|
|
322
|
+
if m:
|
|
323
|
+
f["tags"] = [t for t in m.group(1).split(":") if t]
|
|
324
|
+
body = body[: m.start()]
|
|
325
|
+
f["title"] = body.strip()
|
|
326
|
+
return f
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
_SETTABLE = ("status", "priority", "title", "parent", "scheduled", "deadline")
|
|
330
|
+
|
|
331
|
+
def _parse_fieldop(s):
|
|
332
|
+
"""Parse a field-operation line under a ~ block. Returns (action, field, value) or None.
|
|
333
|
+
|
|
334
|
+
set: `status DONE` / `priority A` / `title x` / `parent 6` / `scheduled 2026-06-01`
|
|
335
|
+
clear: `priority -` (value '-' clears)
|
|
336
|
+
tag: `+tag x` / `-tag x`
|
|
337
|
+
log: `+log text` (add only; log is append-only)
|
|
338
|
+
link: `+link doc` / `-link doc`
|
|
339
|
+
prop: `prop k=v` / `-prop k`
|
|
340
|
+
"""
|
|
341
|
+
import re
|
|
342
|
+
|
|
343
|
+
m = re.match(r"^([+-])(tag|link)\s+(.+)$", s)
|
|
344
|
+
if m:
|
|
345
|
+
return ("add" if m.group(1) == "+" else "remove", m.group(2), m.group(3).strip())
|
|
346
|
+
m = re.match(r"^\+log\s+(.+)$", s)
|
|
347
|
+
if m:
|
|
348
|
+
return ("add", "log", m.group(1).strip())
|
|
349
|
+
m = re.match(r"^-prop\s+(\S+)$", s)
|
|
350
|
+
if m:
|
|
351
|
+
return ("remove", "prop", m.group(1))
|
|
352
|
+
m = re.match(r"^prop\s+(\S+?)=(.*)$", s)
|
|
353
|
+
if m:
|
|
354
|
+
return ("set", "prop", (m.group(1), m.group(2).strip()))
|
|
355
|
+
m = re.match(r"^(" + "|".join(_SETTABLE) + r")\s+(.+)$", s)
|
|
356
|
+
if m:
|
|
357
|
+
val = m.group(2).strip()
|
|
358
|
+
if val == "-":
|
|
359
|
+
return ("clear", m.group(1), None)
|
|
360
|
+
return ("set", m.group(1), val)
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
def _parse_wld(text):
|
|
364
|
+
"""Parse wl-diff -> ops list.
|
|
365
|
+
|
|
366
|
+
+/-/anchor op: {op,depth,fields,subs,lineno}
|
|
367
|
+
~ op: {op:'~',id,fieldops:[(lineno,(action,field,value))],lineno}
|
|
368
|
+
raises ValueError
|
|
369
|
+
"""
|
|
370
|
+
import re
|
|
371
|
+
|
|
372
|
+
ops = []
|
|
373
|
+
cur_update = None # most recent ~ op; collects subsequent indented field-op lines
|
|
374
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
375
|
+
s = raw.lstrip()
|
|
376
|
+
# blank / comment ('#' followed by space or non-digit) -> skip; but #<digit> is a node id, not a comment
|
|
377
|
+
if not s or (s.startswith("#") and (len(s) == 1 or not s[1].isdigit())):
|
|
378
|
+
continue
|
|
379
|
+
indented = raw[:1] in (" ", "\t")
|
|
380
|
+
# indented line under a ~ context: try field-op first (+tag/-tag/-prop start with +/-, so first-char heuristic isn't enough)
|
|
381
|
+
if cur_update is not None and indented:
|
|
382
|
+
fop = _parse_fieldop(s)
|
|
383
|
+
if fop is not None:
|
|
384
|
+
cur_update["fieldops"].append((lineno, fop))
|
|
385
|
+
continue
|
|
386
|
+
# indented but not a valid field-op: if it looks like a node line (has marker), drop through as new node (ending ~); else error
|
|
387
|
+
if not re.match(r"^[+\- ]?\s*\[", s):
|
|
388
|
+
raise ValueError(f"line {lineno}: unparseable field-op '{s}' under '~' (allowed: status/priority/title/parent/scheduled/deadline/±tag/+log/±link/prop/-prop)")
|
|
389
|
+
# @ sub-line (rich fields of a +/-/anchor node)
|
|
390
|
+
m = re.match(r"^[+\- ]?\s*@(log|link|prop)\s+(.*)$", raw)
|
|
391
|
+
if m:
|
|
392
|
+
if not ops or ops[-1]["op"] == "~":
|
|
393
|
+
raise ValueError(f"line {lineno}: @{m.group(1)} has no preceding +/anchor node to attach to")
|
|
394
|
+
ops[-1]["subs"].append((m.group(1), m.group(2).strip()))
|
|
395
|
+
continue
|
|
396
|
+
m = re.match(r"^([+~\- ])(\s*)(.*)$", raw)
|
|
397
|
+
if not m:
|
|
398
|
+
raise ValueError(f"line {lineno}: cannot parse '{raw}'")
|
|
399
|
+
prefix, spaces, body = m.group(1), m.group(2), m.group(3)
|
|
400
|
+
if not body.strip():
|
|
401
|
+
continue
|
|
402
|
+
if prefix == "~":
|
|
403
|
+
idm = re.search(r"#(\d+)", body)
|
|
404
|
+
if not idm:
|
|
405
|
+
raise ValueError(f"line {lineno}: '~' requires #id (e.g. '~ #14' or single-line '~ [x] #14')")
|
|
406
|
+
nid = int(idm.group(1))
|
|
407
|
+
# single-line shorthand: parse marker/priority/title if present -> a set op for each (untouched if absent)
|
|
408
|
+
f = _parse_node_line(body)
|
|
409
|
+
inline = []
|
|
410
|
+
if "marker" in f:
|
|
411
|
+
inline.append((lineno, ("set", "status", _MARKER_STATUS.get(f["marker"], "TODO"))))
|
|
412
|
+
if "priority" in f:
|
|
413
|
+
inline.append((lineno, ("set", "priority", f["priority"])))
|
|
414
|
+
if f.get("title"):
|
|
415
|
+
inline.append((lineno, ("set", "title", f["title"])))
|
|
416
|
+
op = {"op": "~", "id": nid, "fieldops": inline, "lineno": lineno}
|
|
417
|
+
ops.append(op)
|
|
418
|
+
cur_update = op # may still accept subsequent indented field-ops (mix of single-line shorthand + complex ops)
|
|
419
|
+
continue
|
|
420
|
+
cur_update = None # +/-/anchor line ends ~ context
|
|
421
|
+
depth = len(spaces) // 2
|
|
422
|
+
fields = _parse_node_line(body)
|
|
423
|
+
if not fields["title"] and prefix != "-":
|
|
424
|
+
raise ValueError(f"line {lineno}: missing title")
|
|
425
|
+
ops.append({"op": prefix, "depth": depth, "fields": fields, "subs": [], "lineno": lineno})
|
|
426
|
+
return ops
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
_STATUSES = {"TODO", "DOING", "LATER", "WAIT", "DONE", "DEFERRED", "CANCELED"}
|
|
430
|
+
_SET_COL = {"status": "status", "priority": "priority", "title": "title",
|
|
431
|
+
"parent": "parent_id", "scheduled": "scheduled_at", "deadline": "deadline_at"}
|
|
432
|
+
|
|
433
|
+
def _validate_fieldop(con, lineno, action, field, value, errs):
|
|
434
|
+
if field == "status" and action == "set" and value not in _STATUSES:
|
|
435
|
+
errs.append(f"line {lineno}: invalid status '{value}' (valid: {'/'.join(sorted(_STATUSES))})")
|
|
436
|
+
elif field == "priority" and action == "set" and value not in ("A", "B", "C"):
|
|
437
|
+
errs.append(f"line {lineno}: invalid priority '{value}' (A/B/C)")
|
|
438
|
+
elif field == "title" and action == "clear":
|
|
439
|
+
errs.append(f"line {lineno}: title cannot be cleared")
|
|
440
|
+
elif field == "parent" and action == "set":
|
|
441
|
+
if not value.isdigit() or not _node_exists(con, int(value)):
|
|
442
|
+
errs.append(f"line {lineno}: parent #{value} does not exist")
|
|
443
|
+
elif field == "scheduled" and action == "set":
|
|
444
|
+
try:
|
|
445
|
+
_norm_sched(value)
|
|
446
|
+
except ValueError as e:
|
|
447
|
+
errs.append(f"line {lineno}: {e}")
|
|
448
|
+
|
|
449
|
+
def _exec_update(con, o):
|
|
450
|
+
"""Execute ~ field operations: only touches explicitly-declared fields; never touches anything not declared."""
|
|
451
|
+
nid = o["id"]
|
|
452
|
+
for _, (action, field, value) in o["fieldops"]:
|
|
453
|
+
if field in _SET_COL:
|
|
454
|
+
col = _SET_COL[field]
|
|
455
|
+
if action == "clear":
|
|
456
|
+
con.execute(f"UPDATE node SET {col} = NULL WHERE id = ?", (nid,))
|
|
457
|
+
else:
|
|
458
|
+
if field == "parent":
|
|
459
|
+
v = int(value)
|
|
460
|
+
elif field == "scheduled":
|
|
461
|
+
v = _norm_sched(value)
|
|
462
|
+
else:
|
|
463
|
+
v = value
|
|
464
|
+
con.execute(f"UPDATE node SET {col} = ? WHERE id = ?", (v, nid))
|
|
465
|
+
if field == "status" and value == "DONE":
|
|
466
|
+
con.execute("UPDATE node SET closed_at = datetime('now','localtime') WHERE id = ? AND closed_at IS NULL", (nid,))
|
|
467
|
+
elif field == "tag":
|
|
468
|
+
if action == "add":
|
|
469
|
+
con.execute("INSERT OR IGNORE INTO tag (node_id,tag) VALUES (?,?)", (nid, value))
|
|
470
|
+
else:
|
|
471
|
+
con.execute("DELETE FROM tag WHERE node_id = ? AND tag = ?", (nid, value))
|
|
472
|
+
elif field == "log":
|
|
473
|
+
_insert_log(con, nid, value)
|
|
474
|
+
elif field == "link":
|
|
475
|
+
if action == "add":
|
|
476
|
+
con.execute("INSERT OR IGNORE INTO link (node_id,vault_doc) VALUES (?,?)", (nid, value))
|
|
477
|
+
else:
|
|
478
|
+
con.execute("DELETE FROM link WHERE node_id = ? AND vault_doc = ?", (nid, value))
|
|
479
|
+
elif field == "prop":
|
|
480
|
+
if action == "set":
|
|
481
|
+
k, v = value
|
|
482
|
+
_upsert_prop(con, nid, k, v)
|
|
483
|
+
else:
|
|
484
|
+
con.execute("DELETE FROM prop WHERE node_id = ? AND key = ?", (nid, value))
|
|
485
|
+
|
|
486
|
+
def _fieldop_desc(action, field, value):
|
|
487
|
+
if action == "clear":
|
|
488
|
+
return f"{field}=cleared"
|
|
489
|
+
if action == "add":
|
|
490
|
+
return f"+{field} {value}"
|
|
491
|
+
if action == "remove":
|
|
492
|
+
return f"-{field} {value}"
|
|
493
|
+
if field == "prop":
|
|
494
|
+
return f"prop {value[0]}={value[1]}"
|
|
495
|
+
return f"{field}->{value}"
|
|
496
|
+
|
|
497
|
+
def _apply_sub(con, nid, kind, val):
|
|
498
|
+
if kind == "log":
|
|
499
|
+
_insert_log(con, nid, val)
|
|
500
|
+
elif kind == "link":
|
|
501
|
+
con.execute("INSERT OR IGNORE INTO link (node_id,vault_doc) VALUES (?,?)", (nid, val))
|
|
502
|
+
elif kind == "prop":
|
|
503
|
+
if "=" in val:
|
|
504
|
+
k, v = val.split("=", 1)
|
|
505
|
+
_upsert_prop(con, nid, k.strip(), v.strip())
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
_VALID_FIND_FIELDS = {"title", "body", "log", "tag", "prop", "link"}
|
|
510
|
+
_VALID_KINDS = {"task", "project", "area", "year", "quarter", "month", "week", "day",
|
|
511
|
+
"lifetime", "decade", "habit", "signal", "meetlog"}
|
|
512
|
+
|