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.
@@ -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
+ )
@@ -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
+