auto-update-daily-20260202
1;;; org-batch-functions.el --- Org-mode batch operations -*- lexical-binding: t -*-
2
3;; Copyright (C) 2025-2026 Vincent Demeester
4
5;; Author: Vincent Demeester <vincent@sbr.pm>
6;; Keywords: org, batch, automation, org-ql
7;; Version: 2.0.0
8;; Package-Requires: ((emacs "27.1") (org-ql "0.8"))
9
10;;; Commentary:
11
12;; Elisp functions for batch-mode org-mode file manipulation.
13;; Provides read and write operations on org files without GUI.
14;; Used by org-manager CLI tool and Claude Code skills.
15;;
16;; v2.0: Read operations now use org-ql for:
17;; - More powerful queries (30+ predicates)
18;; - Cleaner, more maintainable code
19;; - Built-in caching for performance
20;; - New capabilities (clocked-today, habits, stale-tasks, by-path)
21
22;;; Code:
23
24(require 'org)
25(require 'org-element)
26(require 'json)
27(require 'org-ql)
28
29;;; Configuration
30
31(setq org-todo-keywords
32 '((sequence "STRT(s)" "NEXT(n)" "TODO(t)" "WAIT(w)" "|" "DONE(d!)" "CANX(c@/!)")))
33
34(setq org-priority-highest ?1 ; Highest priority (character '1' = ASCII 49)
35 org-priority-lowest ?5 ; Lowest priority (character '5' = ASCII 53)
36 org-priority-default ?4) ; Default priority (character '4' = ASCII 52)
37
38;; Silence interactive prompts
39(setq org-use-fast-todo-selection nil
40 org-log-done nil ; Will be set per-operation as needed
41 org-agenda-inhibit-startup t)
42
43;;; Utility Functions
44
45(defun org-batch--format-timestamp (timestamp)
46 "Format TIMESTAMP element to string."
47 (when timestamp
48 (org-element-property :raw-value timestamp)))
49
50(defun org-batch--priority-to-number (priority-char)
51 "Convert PRIORITY-CHAR to number (1-5).
52Priority '1'=1, '2'=2, '3'=3, '4'=4, '5'=5."
53 (when priority-char
54 (- priority-char 48))) ; '1'(49) → 1, '2'(50) → 2, ..., '5'(53) → 5
55
56(defun org-batch--number-to-priority (num)
57 "Convert NUM (1-5) to priority character.
581='1', 2='2', 3='3', 4='4', 5='5'."
59 (when (and num (>= num 1) (<= num 5))
60 (+ num 48))) ; 1 → '1'(49), 2 → '2'(50), ..., 5 → '5'(53)
61
62(defun org-batch--element-to-alist-at-point ()
63 "Convert current heading to JSON-friendly alist.
64Must be called with point on a heading. Used by org-ql actions."
65 (let* ((element (org-element-at-point))
66 (priority-char (org-element-property :priority element))
67 (priority-num (when priority-char (- priority-char 48))))
68 `((heading . ,(org-get-heading t t t t))
69 (todo . ,(org-get-todo-state))
70 (priority . ,priority-num)
71 (tags . ,(org-get-tags nil t))
72 (level . ,(org-current-level))
73 (scheduled . ,(org-entry-get nil "SCHEDULED"))
74 (deadline . ,(org-entry-get nil "DEADLINE")))))
75
76(defun org-batch--build-priority-regexp (priorities)
77 "Build regexp to match PRIORITIES (list of numbers 1-5)."
78 (when priorities
79 (format "\\[#[%s]\\]"
80 (mapconcat #'number-to-string priorities ""))))
81
82;;; Read Operations - Using org-ql
83
84(defun org-batch-list-todos (file &optional filter-state filter-priority filter-tags)
85 "List TODOs from FILE with optional filters using org-ql.
86FILTER-STATE: String like \"NEXT\" or \"TODO\", or comma-separated list
87FILTER-PRIORITY: Number 1-5 or list of numbers
88FILTER-TAGS: List of tag strings (match any)"
89 (let* ((states (cond ((null filter-state) '("TODO" "NEXT" "STRT" "WAIT" "DONE" "CANX"))
90 ((listp filter-state) filter-state)
91 ((stringp filter-state)
92 (if (string-match-p "," filter-state)
93 (split-string filter-state ",")
94 (list filter-state)))
95 (t (list filter-state))))
96 (priority-list (cond ((null filter-priority) nil)
97 ((listp filter-priority) filter-priority)
98 (t (list filter-priority))))
99 (priority-regexp (org-batch--build-priority-regexp priority-list))
100 ;; Build org-ql query dynamically
101 (query `(and (todo ,@states)
102 ,@(when priority-regexp
103 `((regexp ,priority-regexp)))
104 ,@(when filter-tags
105 `((tags-local ,@filter-tags))))))
106 (org-ql-select file query
107 :action #'org-batch--element-to-alist-at-point)))
108
109(defun org-batch-scheduled-today (file &optional date)
110 "Get items scheduled for DATE (default today) from FILE.
111DATE should be \"YYYY-MM-DD\" or \"today\"."
112 (let ((target-date (if (or (null date) (string= date "today"))
113 'today
114 (date-to-time date))))
115 (org-ql-select file
116 `(scheduled :on ,target-date)
117 :action #'org-batch--element-to-alist-at-point)))
118
119(defun org-batch-by-section (file section-name)
120 "Get all TODOs under SECTION-NAME (level 1 heading) in FILE.
121Uses org-ql's `ancestors' predicate for clean hierarchical queries."
122 (org-ql-select file
123 `(and (todo)
124 (ancestors (and (level 1)
125 (heading ,section-name))))
126 :action #'org-batch--element-to-alist-at-point))
127
128(defun org-batch-count-by-state (file)
129 "Count TODOs in FILE by state.
130Returns alist with counts for each state."
131 (let ((counts '((total . 0) (TODO . 0) (NEXT . 0) (STRT . 0)
132 (WAIT . 0) (DONE . 0) (CANX . 0))))
133 (org-ql-select file '(todo)
134 :action (lambda ()
135 (let* ((state (org-get-todo-state))
136 (state-sym (intern state)))
137 (cl-incf (alist-get 'total counts))
138 (when (assoc state-sym counts)
139 (cl-incf (alist-get state-sym counts))))))
140 counts))
141
142(defun org-batch-search (file search-term)
143 "Search for SEARCH-TERM in FILE content.
144Returns list of matching headlines with context."
145 (org-ql-select file
146 `(and (todo)
147 (regexp ,(regexp-quote search-term)))
148 :action (lambda ()
149 (let ((alist (org-batch--element-to-alist-at-point)))
150 (cons (cons 'matched-in
151 (if (string-match-p (regexp-quote search-term)
152 (alist-get 'heading alist))
153 "heading"
154 "content"))
155 alist)))))
156
157(defun org-batch-get-sections (file)
158 "Get list of all level-1 sections in FILE."
159 (org-ql-select file
160 '(level 1)
161 :action (lambda () (org-get-heading t t t t))))
162
163(defun org-batch-get-children (file heading-name)
164 "Get all direct children TODOs of HEADING-NAME in FILE.
165Uses org-ql's `parent' predicate."
166 (org-ql-select file
167 `(and (todo)
168 (parent (heading ,heading-name)))
169 :action #'org-batch--element-to-alist-at-point))
170
171(defun org-batch-get-todo-content (file heading-name)
172 "Get full content of TODO with HEADING-NAME in FILE.
173Returns alist with metadata, properties, and body content.
174Returns nil if heading not found."
175 (let ((results (org-ql-select file
176 `(heading ,heading-name)
177 :action (lambda ()
178 (let* ((element (org-element-at-point))
179 (basic-data (org-batch--element-to-alist-at-point))
180 (properties (org-batch--extract-properties-at-point))
181 (content (org-batch--extract-content-at-point element)))
182 (append basic-data
183 (list (cons 'properties properties)
184 (cons 'content content))))))))
185 (car results))) ; Return first match or nil
186
187(defun org-batch--extract-properties-at-point ()
188 "Extract properties drawer at point as alist."
189 (let ((props (org-entry-properties nil 'standard))
190 (properties '()))
191 (dolist (prop props)
192 (let ((key (car prop))
193 (val (cdr prop)))
194 (unless (member key '("CATEGORY" "BLOCKED" "ALLTAGS" "FILE" "PRIORITY_COOKIE"
195 "TODO" "TAGS" "ITEM"))
196 (push (cons key val) properties))))
197 (nreverse properties)))
198
199(defun org-batch--extract-content-at-point (element)
200 "Extract body content from ELEMENT (excluding properties drawer)."
201 (let ((end (org-element-property :contents-end element))
202 (contents-begin (org-element-property :contents-begin element)))
203 (if (and contents-begin end)
204 (save-excursion
205 (let ((content-text (buffer-substring-no-properties contents-begin end)))
206 (with-temp-buffer
207 (insert content-text)
208 (goto-char (point-min))
209 ;; Remove SCHEDULED/DEADLINE lines
210 (while (re-search-forward "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\|CLOSED\\):.*$" nil t)
211 (replace-match ""))
212 ;; Remove properties drawer
213 (goto-char (point-min))
214 (when (re-search-forward "^[ \t]*:PROPERTIES:[ \t]*$" nil t)
215 (let ((drawer-start (match-beginning 0)))
216 (when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
217 (delete-region drawer-start (point))
218 (when (looking-at "\n")
219 (delete-char 1)))))
220 ;; Trim whitespace
221 (goto-char (point-min))
222 (while (re-search-forward "^[ \t]+$" nil t)
223 (replace-match ""))
224 (goto-char (point-min))
225 (skip-chars-forward "\n")
226 (delete-region (point-min) (point))
227 (goto-char (point-max))
228 (skip-chars-backward "\n")
229 (delete-region (point) (point-max))
230 (buffer-string))))
231 "")))
232
233(defun org-batch-get-overdue (file)
234 "Get all tasks with DEADLINE before today from FILE.
235Uses org-ql's `deadline' predicate."
236 (org-ql-select file
237 '(and (todo "TODO" "NEXT" "STRT" "WAIT")
238 (deadline :before today))
239 :action #'org-batch--element-to-alist-at-point))
240
241(defun org-batch-get-upcoming (file &optional days)
242 "Get tasks scheduled or due in next DAYS from FILE.
243DAYS defaults to 7."
244 (let ((days-count (or days 7)))
245 (org-ql-select file
246 `(and (todo "TODO" "NEXT" "STRT" "WAIT")
247 (or (scheduled :to ,days-count)
248 (deadline :to ,days-count)))
249 :action #'org-batch--element-to-alist-at-point)))
250
251(defun org-batch-get-recurring-tasks (file)
252 "Get all tasks with repeaters in FILE."
253 (org-ql-select file
254 '(and (todo)
255 (regexp "[.+]?\\+[0-9]+[hdwmy]"))
256 :action (lambda ()
257 (let* ((alist (org-batch--element-to-alist-at-point))
258 (scheduled (alist-get 'scheduled alist))
259 (deadline (alist-get 'deadline alist))
260 (repeater (or (and scheduled
261 (string-match "[.+]?\\+[0-9]+[hdwmy]" scheduled)
262 (match-string 0 scheduled))
263 (and deadline
264 (string-match "[.+]?\\+[0-9]+[hdwmy]" deadline)
265 (match-string 0 deadline)))))
266 (cons (cons 'repeater repeater) alist)))))
267
268(defun org-batch-get-blocked-tasks (file)
269 "Get all tasks that have BLOCKER property in FILE."
270 (org-ql-select file
271 '(and (todo)
272 (property "BLOCKER"))
273 :action (lambda ()
274 (let ((alist (org-batch--element-to-alist-at-point))
275 (blocker (org-entry-get nil "BLOCKER")))
276 (cons (cons 'blocker blocker) alist)))))
277
278;;; NEW: Advanced Queries (org-ql only)
279
280(defun org-batch-clocked-today (file)
281 "Get tasks that were clocked today.
282NEW in v2.0: Not possible without org-ql."
283 (org-ql-select file
284 '(clocked :on today)
285 :action #'org-batch--element-to-alist-at-point))
286
287(defun org-batch-habits (file)
288 "Get all habits from FILE.
289NEW in v2.0."
290 (org-ql-select file
291 '(habit)
292 :action #'org-batch--element-to-alist-at-point))
293
294(defun org-batch-stale-tasks (file &optional days)
295 "Get TODO tasks not touched in DAYS (default 30).
296NEW in v2.0: Find forgotten tasks."
297 (let ((days-ago (or days 30)))
298 (org-ql-select file
299 `(and (todo "TODO")
300 (not (ts :from ,(- days-ago))))
301 :action #'org-batch--element-to-alist-at-point)))
302
303(defun org-batch-by-path (file path-pattern)
304 "Get TODOs matching outline PATH-PATTERN.
305NEW in v2.0: Query by outline path like \"Projects\"."
306 (org-ql-select file
307 `(and (todo)
308 (path ,path-pattern))
309 :action #'org-batch--element-to-alist-at-point))
310
311(defun org-batch-with-property (file property &optional value)
312 "Get TODOs with PROPERTY (optionally matching VALUE).
313NEW in v2.0."
314 (org-ql-select file
315 (if value
316 `(and (todo) (property ,property ,value))
317 `(and (todo) (property ,property)))
318 :action #'org-batch--element-to-alist-at-point))
319
320(defun org-batch-priority-items (file min-priority &optional max-priority)
321 "Get TODOs with priority between MIN-PRIORITY and MAX-PRIORITY (1-5).
322NEW in v2.0."
323 (let* ((max-p (or max-priority min-priority))
324 (priorities (number-sequence min-priority max-p))
325 (regexp (org-batch--build-priority-regexp priorities)))
326 (org-ql-select file
327 `(and (todo "TODO" "NEXT" "STRT" "WAIT")
328 (regexp ,regexp))
329 :action #'org-batch--element-to-alist-at-point)))
330
331;;; Write Operations (unchanged - org-ql is read-only)
332
333(defun org-batch--adjust-heading-levels (content parent-level)
334 "Adjust heading levels in CONTENT to be relative to PARENT-LEVEL.
335Converts markdown headers (#, ##, ###) and org headers (*, **, ***)
336to the appropriate level relative to the parent heading."
337 (with-temp-buffer
338 (insert content)
339 (goto-char (point-min))
340 ;; Convert markdown headings to org format with adjusted levels
341 (while (re-search-forward "^\\(#+\\)\\( .*\\)$" nil t)
342 (let* ((markdown-level (length (match-string 1)))
343 (header-text (match-string 2))
344 (new-level (+ parent-level markdown-level))
345 (org-stars (make-string new-level ?*)))
346 (replace-match (concat "ORG_HEADING_MARKER:" org-stars header-text))))
347 ;; Process existing org headings
348 (goto-char (point-min))
349 (while (re-search-forward "^\\(\\*+\\)\\( .*\\)$" nil t)
350 (let* ((org-level (length (match-string 1)))
351 (header-text (match-string 2))
352 (new-level (+ parent-level org-level))
353 (org-stars (make-string new-level ?*)))
354 (replace-match (concat "ORG_HEADING_MARKER:" org-stars header-text))))
355 ;; Remove markers
356 (goto-char (point-min))
357 (while (re-search-forward "ORG_HEADING_MARKER:" nil t)
358 (replace-match ""))
359 (buffer-string)))
360
361(defun org-batch-append-content (file heading content)
362 "Append CONTENT to TODO with HEADING in FILE.
363Adds content at the end of the heading's body, before any subheadings.
364Returns t on success, nil if heading not found."
365 (with-temp-buffer
366 (insert-file-contents file)
367 (org-mode)
368 (goto-char (point-min))
369 (let ((found nil)
370 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
371 (regexp-quote heading))))
372 (when (re-search-forward heading-regexp nil t)
373 (org-back-to-heading)
374 (let* ((parent-level (org-current-level))
375 (section-end (save-excursion
376 (org-end-of-subtree t t)
377 (point)))
378 (adjusted-content (org-batch--adjust-heading-levels content parent-level)))
379 (forward-line 1)
380 ;; Skip properties drawer
381 (when (looking-at "^[ \t]*:PROPERTIES:")
382 (re-search-forward "^[ \t]*:END:" section-end t)
383 (forward-line 1))
384 ;; Skip SCHEDULED/DEADLINE/CLOSED lines
385 (while (looking-at "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\|CLOSED\\):")
386 (forward-line 1))
387 ;; Skip logbook drawer
388 (when (looking-at "^[ \t]*:LOGBOOK:")
389 (re-search-forward "^[ \t]*:END:" section-end t)
390 (forward-line 1))
391 ;; Find end of content
392 (let ((content-end (save-excursion
393 (if (re-search-forward "^\\*" section-end t)
394 (match-beginning 0)
395 section-end))))
396 (goto-char content-end)
397 (skip-chars-backward "\n\t ")
398 (unless (bolp) (forward-line 1))
399 (unless (or (= (point) (save-excursion (org-back-to-heading) (forward-line 1) (point)))
400 (looking-back "\\`\\|^[ \t]*\n" nil))
401 (insert "\n"))
402 (insert adjusted-content)
403 (unless (bolp) (insert "\n"))
404 (when (looking-at "^\\*")
405 (unless (looking-back "\n\n" nil)
406 (insert "\n")))
407 (write-region (point-min) (point-max) file)
408 (setq found t)))
409 found))))
410
411(defun org-batch-update-state (file heading new-state)
412 "Update TODO state for HEADING in FILE to NEW-STATE.
413Returns t on success, nil if heading not found."
414 (with-temp-buffer
415 (insert-file-contents file)
416 (org-mode)
417 (goto-char (point-min))
418 (let ((found nil)
419 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
420 (regexp-quote heading))))
421 (when (re-search-forward heading-regexp nil t)
422 (org-back-to-heading)
423 (let ((org-log-done (if (string= new-state "DONE") 'time nil)))
424 (org-todo new-state))
425 (write-region (point-min) (point-max) file)
426 (setq found t))
427 found)))
428
429(defun org-batch-add-todo (file section heading &optional scheduled priority tags)
430 "Add new TODO to FILE in SECTION with HEADING.
431SCHEDULED: Date string \"YYYY-MM-DD\"
432PRIORITY: Number 1-5
433TAGS: List of tag strings"
434 (with-temp-buffer
435 (insert-file-contents file)
436 (org-mode)
437 (goto-char (point-min))
438 (let ((section-regexp (concat "^\\* " (regexp-quote section) "$")))
439 (if (re-search-forward section-regexp nil t)
440 (progn
441 (org-end-of-subtree t)
442 (insert "\n** TODO ")
443 (when priority
444 (insert (format "[#%d] " priority)))
445 (insert heading)
446 (when tags
447 (insert " :" (string-join tags ":") ":"))
448 (insert "\n")
449 (when scheduled
450 (insert (format "SCHEDULED: <%s>\n" scheduled)))
451 (insert ":PROPERTIES:\n")
452 (insert (format ":CREATED: [%s]\n"
453 (format-time-string "%Y-%m-%d %a %H:%M")))
454 (insert ":END:\n")
455 (write-region (point-min) (point-max) file)
456 t)
457 nil))))
458
459(defun org-batch-schedule-task (file heading date)
460 "Schedule task with HEADING in FILE for DATE.
461DATE should be \"YYYY-MM-DD\" format."
462 (with-temp-buffer
463 (insert-file-contents file)
464 (org-mode)
465 (goto-char (point-min))
466 (let ((found nil)
467 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\) \\(?:\\[#[1-5]\\] \\)?"
468 (regexp-quote heading))))
469 (when (re-search-forward heading-regexp nil t)
470 (org-back-to-heading)
471 (org-schedule nil date)
472 (write-region (point-min) (point-max) file)
473 (setq found t))
474 found)))
475
476(defun org-batch-set-deadline (file heading date)
477 "Set deadline for task with HEADING in FILE to DATE.
478DATE should be \"YYYY-MM-DD\" format."
479 (with-temp-buffer
480 (insert-file-contents file)
481 (org-mode)
482 (goto-char (point-min))
483 (let ((found nil)
484 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\) \\(?:\\[#[1-5]\\] \\)?"
485 (regexp-quote heading))))
486 (when (re-search-forward heading-regexp nil t)
487 (org-back-to-heading)
488 (org-deadline nil date)
489 (write-region (point-min) (point-max) file)
490 (setq found t))
491 found)))
492
493(defun org-batch-set-priority (file heading priority)
494 "Set PRIORITY (1-5) for task with HEADING in FILE."
495 (with-temp-buffer
496 (insert-file-contents file)
497 (org-mode)
498 (goto-char (point-min))
499 (let ((found nil)
500 (heading-regexp (concat "^\\(\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\)\\) \\(?:\\[#[1-5]\\] \\)?"
501 (regexp-quote heading)))
502 (priority-cookie (format " [#%d]" priority)))
503 (when (re-search-forward heading-regexp nil t)
504 (goto-char (match-end 1))
505 (when (looking-at " \\[#[1-5]\\]")
506 (delete-region (point) (+ (point) 5)))
507 (insert priority-cookie)
508 (write-region (point-min) (point-max) file)
509 (setq found t))
510 found)))
511
512(defun org-batch-add-tags (file heading new-tags)
513 "Add NEW-TAGS to task with HEADING in FILE.
514NEW-TAGS is a list of tag strings to add."
515 (with-temp-buffer
516 (insert-file-contents file)
517 (org-mode)
518 (goto-char (point-min))
519 (let ((found nil)
520 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
521 (regexp-quote heading))))
522 (when (re-search-forward heading-regexp nil t)
523 (org-back-to-heading)
524 (let* ((current-tags (org-get-tags))
525 (combined-tags (delete-dups (append current-tags new-tags))))
526 (org-set-tags combined-tags)
527 (write-region (point-min) (point-max) file)
528 (setq found t)))
529 found)))
530
531(defun org-batch-remove-tags (file heading tags-to-remove)
532 "Remove TAGS-TO-REMOVE from task with HEADING in FILE."
533 (with-temp-buffer
534 (insert-file-contents file)
535 (org-mode)
536 (goto-char (point-min))
537 (let ((found nil)
538 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
539 (regexp-quote heading))))
540 (when (re-search-forward heading-regexp nil t)
541 (org-back-to-heading)
542 (let* ((current-tags (org-get-tags))
543 (remaining-tags (seq-difference current-tags tags-to-remove)))
544 (org-set-tags remaining-tags)
545 (write-region (point-min) (point-max) file)
546 (setq found t)))
547 found)))
548
549(defun org-batch-replace-tags (file heading new-tags)
550 "Replace all tags on task with HEADING in FILE with NEW-TAGS."
551 (with-temp-buffer
552 (insert-file-contents file)
553 (org-mode)
554 (goto-char (point-min))
555 (let ((found nil)
556 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
557 (regexp-quote heading))))
558 (when (re-search-forward heading-regexp nil t)
559 (org-back-to-heading)
560 (org-set-tags new-tags)
561 (write-region (point-min) (point-max) file)
562 (setq found t))
563 found)))
564
565(defun org-batch-list-all-tags (file)
566 "List all unique tags used in FILE."
567 (let ((all-tags '()))
568 (org-ql-select file '(todo)
569 :action (lambda ()
570 (dolist (tag (org-get-tags nil t))
571 (cl-pushnew tag all-tags :test #'string=))))
572 (sort all-tags #'string<)))
573
574(defun org-batch-get-property (file heading property-name)
575 "Get value of PROPERTY-NAME for task with HEADING in FILE."
576 (with-temp-buffer
577 (insert-file-contents file)
578 (org-mode)
579 (goto-char (point-min))
580 (let ((found nil)
581 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
582 (regexp-quote heading))))
583 (when (re-search-forward heading-regexp nil t)
584 (org-back-to-heading)
585 (setq found (org-entry-get nil property-name)))
586 found)))
587
588(defun org-batch-set-property (file heading property-name value)
589 "Set PROPERTY-NAME to VALUE for task with HEADING in FILE."
590 (with-temp-buffer
591 (insert-file-contents file)
592 (org-mode)
593 (goto-char (point-min))
594 (let ((found nil)
595 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
596 (regexp-quote heading))))
597 (when (re-search-forward heading-regexp nil t)
598 (org-back-to-heading)
599 (org-set-property property-name value)
600 (write-region (point-min) (point-max) file)
601 (setq found t))
602 found)))
603
604(defun org-batch-list-properties (file heading)
605 "List all properties for task with HEADING in FILE."
606 (with-temp-buffer
607 (insert-file-contents file)
608 (org-mode)
609 (goto-char (point-min))
610 (let ((properties '())
611 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
612 (regexp-quote heading))))
613 (when (re-search-forward heading-regexp nil t)
614 (org-back-to-heading)
615 (let ((props (org-entry-properties nil 'standard)))
616 (dolist (prop props)
617 (let ((key (car prop))
618 (val (cdr prop)))
619 (unless (member key '("CATEGORY" "BLOCKED" "ALLTAGS" "FILE" "PRIORITY_COOKIE"
620 "TODO" "TAGS" "ITEM"))
621 (push (cons key val) properties))))))
622 (nreverse properties))))
623
624(defun org-batch-archive-done (file)
625 "Archive all DONE and CANX items in FILE.
626Returns count of archived items."
627 (let ((count 0)
628 (archive-location nil))
629 (with-temp-buffer
630 (insert-file-contents file)
631 (org-mode)
632 (goto-char (point-min))
633 (while (re-search-forward "^\\*+ \\(DONE\\|CANX\\) " nil t)
634 (org-back-to-heading)
635 (let ((local-archive (org-entry-get nil "ARCHIVE")))
636 (when local-archive
637 (setq archive-location local-archive)))
638 (condition-case nil
639 (progn
640 (if archive-location
641 (let ((org-archive-location archive-location))
642 (org-archive-subtree))
643 (org-archive-subtree))
644 (setq count (1+ count)))
645 (error nil)))
646 (write-region (point-min) (point-max) file))
647 count))
648
649;;; Bulk Operations
650
651(defun org-batch-bulk-update-state (file filter-state new-state &optional filter-tags)
652 "Update all tasks matching FILTER-STATE in FILE to NEW-STATE.
653Returns count of updated tasks."
654 (let ((count 0))
655 (with-temp-buffer
656 (insert-file-contents file)
657 (org-mode)
658 (goto-char (point-min))
659 (while (re-search-forward org-heading-regexp nil t)
660 (org-back-to-heading t)
661 (let ((todo (org-get-todo-state))
662 (tags (org-get-tags)))
663 (when (and todo
664 (string= todo filter-state)
665 (or (null filter-tags)
666 (and tags (seq-intersection filter-tags tags))))
667 (let ((org-log-done (if (string= new-state "DONE") 'time nil)))
668 (org-todo new-state))
669 (setq count (1+ count))))
670 (forward-line 1))
671 (write-region (point-min) (point-max) file))
672 count))
673
674(defun org-batch-bulk-add-tags (file filter-state new-tags)
675 "Add NEW-TAGS to all tasks with FILTER-STATE in FILE.
676Returns count of updated tasks."
677 (let ((count 0))
678 (with-temp-buffer
679 (insert-file-contents file)
680 (org-mode)
681 (goto-char (point-min))
682 (while (re-search-forward org-heading-regexp nil t)
683 (org-back-to-heading t)
684 (let ((todo (org-get-todo-state)))
685 (when (and todo (string= todo filter-state))
686 (let* ((current-tags (org-get-tags))
687 (combined-tags (delete-dups (append current-tags new-tags))))
688 (org-set-tags combined-tags))
689 (setq count (1+ count))))
690 (forward-line 1))
691 (write-region (point-min) (point-max) file))
692 count))
693
694(defun org-batch-bulk-set-priority (file filter-state priority)
695 "Set PRIORITY for all tasks with FILTER-STATE in FILE.
696Returns count of updated tasks."
697 (let ((count 0)
698 (priority-cookie (format " [#%d]" priority)))
699 (with-temp-buffer
700 (insert-file-contents file)
701 (org-mode)
702 (goto-char (point-min))
703 (while (re-search-forward (concat "^\\(\\*+ " (regexp-quote filter-state) "\\) \\(?:\\[#[1-5]\\] \\)?") nil t)
704 (goto-char (match-end 1))
705 (when (looking-at " \\[#[1-5]\\]")
706 (delete-region (point) (+ (point) 5)))
707 (insert priority-cookie)
708 (setq count (1+ count)))
709 (write-region (point-min) (point-max) file))
710 count))
711
712;;; Time Tracking
713
714(defun org-batch-clock-in (file heading)
715 "Clock in to task with HEADING in FILE."
716 (with-temp-buffer
717 (insert-file-contents file)
718 (org-mode)
719 (goto-char (point-min))
720 (let ((found nil)
721 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
722 (regexp-quote heading))))
723 (when (re-search-forward heading-regexp nil t)
724 (org-back-to-heading)
725 (org-clock-in)
726 (write-region (point-min) (point-max) file)
727 (setq found t))
728 found)))
729
730(defun org-batch-clock-out (file)
731 "Clock out of currently clocked task in FILE."
732 (with-temp-buffer
733 (insert-file-contents file)
734 (org-mode)
735 (goto-char (point-min))
736 (let ((found nil))
737 (when (re-search-forward "^\\([ \t]*CLOCK: \\)\\(\\[.*?\\]\\)$" nil t)
738 (let ((indent (match-string 1))
739 (start-time (match-string 2))
740 (end-time (format-time-string "[%Y-%m-%d %a %H:%M]")))
741 (let* ((start-ts (org-parse-time-string start-time))
742 (start-encoded (apply #'encode-time start-ts))
743 (end-encoded (current-time))
744 (duration-seconds (float-time (time-subtract end-encoded start-encoded)))
745 (hours (floor (/ duration-seconds 3600)))
746 (minutes (floor (/ (mod duration-seconds 3600) 60))))
747 (replace-match (format "%s%s--%s => %2d:%02d" indent start-time end-time hours minutes))
748 (write-region (point-min) (point-max) file)
749 (setq found t))))
750 found)))
751
752(defun org-batch-get-active-clock (file)
753 "Get currently active clock in FILE."
754 (with-temp-buffer
755 (insert-file-contents file)
756 (org-mode)
757 (goto-char (point-min))
758 (let ((result nil))
759 (when (re-search-forward "^[ \t]*CLOCK: \\(\\[.*?\\]\\)$" nil t)
760 (let ((clock-start (match-string 1)))
761 (org-back-to-heading)
762 (let ((heading (org-element-property :raw-value (org-element-at-point))))
763 (setq result `((heading . ,heading)
764 (clock_start . ,clock-start))))))
765 result)))
766
767(defun org-batch-get-clocked-time (file heading)
768 "Get total clocked time for HEADING in FILE.
769Returns minutes as integer."
770 (with-temp-buffer
771 (insert-file-contents file)
772 (org-mode)
773 (goto-char (point-min))
774 (let ((heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
775 (regexp-quote heading)))
776 (total-minutes 0))
777 (when (re-search-forward heading-regexp nil t)
778 (org-back-to-heading)
779 (save-restriction
780 (org-narrow-to-subtree)
781 (org-clock-sum)
782 (setq total-minutes (get-text-property (point) :org-clock-minutes))))
783 (or total-minutes 0))))
784
785;;; Statistics & Analytics
786
787(defun org-batch-get-statistics (file)
788 "Get comprehensive statistics about TODOs in FILE."
789 (let ((by-state (org-batch-count-by-state file))
790 (scheduled-count (length (org-ql-select file '(and (todo) (scheduled)))))
791 (deadline-count (length (org-ql-select file '(and (todo) (deadline)))))
792 (overdue-count (length (org-batch-get-overdue file)))
793 (by-priority '())
794 (by-tag '()))
795 ;; Count by priority
796 (dolist (p '(1 2 3 4 5))
797 (let ((regexp (format "\\[#%d\\]" p)))
798 (push (cons p (length (org-ql-select file `(and (todo) (regexp ,regexp)))))
799 by-priority)))
800 ;; Count by tag
801 (let ((all-tags '()))
802 (org-ql-select file '(todo)
803 :action (lambda ()
804 (dolist (tag (org-get-tags nil t))
805 (if (assoc tag all-tags #'string=)
806 (cl-incf (cdr (assoc tag all-tags #'string=)))
807 (push (cons tag 1) all-tags)))))
808 (setq by-tag (sort all-tags (lambda (a b) (> (cdr a) (cdr b))))))
809 `((total . ,(alist-get 'total by-state))
810 (by_state . ,by-state)
811 (by_priority . ,(nreverse by-priority))
812 (by_tag . ,by-tag)
813 (scheduled_count . ,scheduled-count)
814 (deadline_count . ,deadline-count)
815 (overdue_count . ,overdue-count))))
816
817(defun org-batch-get-priority-distribution (file)
818 "Get distribution of tasks by priority in FILE."
819 (let ((distribution '((1 . 0) (2 . 0) (3 . 0) (4 . 0) (5 . 0))))
820 (dolist (p '(1 2 3 4 5))
821 (let ((regexp (format "\\[#%d\\]" p)))
822 (setcdr (assoc p distribution)
823 (length (org-ql-select file `(and (todo) (regexp ,regexp)))))))
824 distribution))
825
826(defun org-batch-get-tag-statistics (file)
827 "Get statistics about tag usage in FILE."
828 (let ((tag-counts '()))
829 (org-ql-select file '(todo)
830 :action (lambda ()
831 (dolist (tag (org-get-tags nil t))
832 (if (assoc tag tag-counts #'string=)
833 (cl-incf (cdr (assoc tag tag-counts #'string=)))
834 (push (cons tag 1) tag-counts)))))
835 (sort tag-counts (lambda (a b) (> (cdr a) (cdr b))))))
836
837;;; Export & Reporting
838
839(defun org-batch-export-csv (file output-file)
840 "Export TODOs from FILE to CSV format in OUTPUT-FILE."
841 (let ((todos (org-batch-list-todos file)))
842 (with-temp-file output-file
843 (insert "heading,state,priority,tags,level,scheduled,deadline\n")
844 (dolist (todo todos)
845 (insert (format "\"%s\",\"%s\",%s,\"%s\",%s,\"%s\",\"%s\"\n"
846 (or (alist-get 'heading todo) "")
847 (or (alist-get 'todo todo) "")
848 (or (alist-get 'priority todo) "")
849 (or (string-join (alist-get 'tags todo) ";") "")
850 (or (alist-get 'level todo) "")
851 (or (alist-get 'scheduled todo) "")
852 (or (alist-get 'deadline todo) "")))))
853 t))
854
855(defun org-batch-export-json (file output-file)
856 "Export TODOs from FILE to JSON format in OUTPUT-FILE."
857 (let ((todos (org-batch-list-todos file)))
858 (with-temp-file output-file
859 (insert (json-encode todos)))
860 t))
861
862;;; Recurring Tasks
863
864(defun org-batch-set-repeater (file heading repeater-spec)
865 "Set repeater REPEATER-SPEC for HEADING in FILE."
866 (with-temp-buffer
867 (insert-file-contents file)
868 (org-mode)
869 (goto-char (point-min))
870 (let ((found nil)
871 (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\)? ?\\(?:\\[#[1-5]\\] \\)?"
872 (regexp-quote heading))))
873 (when (re-search-forward heading-regexp nil t)
874 (org-back-to-heading)
875 (if (re-search-forward "^[ \t]*SCHEDULED:" (save-excursion (outline-next-heading) (point)) t)
876 (progn
877 (beginning-of-line)
878 (when (re-search-forward "<\\([^>]+\\)>" (line-end-position) t)
879 (let ((timestamp (match-string 1)))
880 (setq timestamp (replace-regexp-in-string " [.+]?\\+[0-9]+[dwmy]" "" timestamp))
881 (replace-match (format "<%s %s>" timestamp repeater-spec)))))
882 (org-back-to-heading)
883 (forward-line 1)
884 (insert (format "SCHEDULED: <%s %s>\n"
885 (format-time-string "%Y-%m-%d %a")
886 repeater-spec)))
887 (write-region (point-min) (point-max) file)
888 (setq found t))
889 found)))
890
891;;; Dependencies & Relationships
892
893(defun org-batch-set-blocker (file heading blocker-heading)
894 "Set BLOCKER-HEADING as a blocker for HEADING in FILE."
895 (org-batch-set-property file heading "BLOCKER" blocker-heading))
896
897(defun org-batch-get-blocker (file heading)
898 "Get blocker for HEADING in FILE."
899 (org-batch-get-property file heading "BLOCKER"))
900
901(defun org-batch-set-related (file heading related-heading relation-type)
902 "Set relationship between HEADING and RELATED-HEADING in FILE.
903RELATION-TYPE can be `child', `parent', `related', or `depends-on'."
904 (org-batch-set-property file heading
905 (upcase (format "RELATED_%s" relation-type))
906 related-heading))
907
908(defun org-batch-get-related (file heading)
909 "Get all related tasks for HEADING in FILE."
910 (let ((props (org-batch-list-properties file heading))
911 (related '()))
912 (dolist (prop props)
913 (when (string-match "^RELATED_\\(.*\\)$" (car prop))
914 (let ((rel-type (downcase (match-string 1 (car prop))))
915 (rel-value (cdr prop)))
916 (push (cons (intern rel-type) rel-value) related))))
917 related))
918
919;;; Denote Operations
920
921(defun org-batch-denote-create (title tags &optional signature category directory content-file)
922 "Create a denote note with TITLE, TAGS, and optional metadata.
923SIGNATURE: Short identifier (e.g., 'pkai')
924CATEGORY: Category for the note
925DIRECTORY: Where to create the note
926CONTENT-FILE: File with initial content"
927 (let* ((dir (or directory (expand-file-name "~/desktop/org/notes")))
928 (date-str (format-time-string "%Y%m%dT%H%M%S"))
929 (slug (replace-regexp-in-string "[^a-zA-Z0-9-]" "-" (downcase title)))
930 (tags-str (if (listp tags) (mapconcat #'identity tags "_") tags))
931 (filename (format "%s--%s__%s.org" date-str slug tags-str))
932 (filepath (expand-file-name filename dir)))
933 ;; Create directory if needed
934 (unless (file-directory-p dir)
935 (make-directory dir t))
936 ;; Write the file
937 (with-temp-file filepath
938 (insert "#+title: " title "\n")
939 (insert "#+date: [" (format-time-string "%Y-%m-%d %a %H:%M") "]\n")
940 (insert "#+filetags: :" (if (listp tags) (mapconcat #'identity tags ":") tags) ":\n")
941 (when signature
942 (insert "#+identifier: " signature "\n"))
943 (when category
944 (insert "#+category: " category "\n"))
945 (insert "\n")
946 (when content-file
947 (when (file-exists-p content-file)
948 (insert-file-contents content-file))))
949 (princ (json-encode `((success . t) (filepath . ,filepath))))
950 (terpri)
951 filepath))
952
953(defun org-batch-denote-append (filepath content-file)
954 "Append content from CONTENT-FILE to denote note at FILEPATH."
955 (when (and (file-exists-p filepath) (file-exists-p content-file))
956 (with-temp-buffer
957 (insert-file-contents filepath)
958 (goto-char (point-max))
959 (insert "\n")
960 (insert-file-contents content-file)
961 (write-region (point-min) (point-max) filepath))
962 (princ (json-encode `((success . t) (appended . t))))
963 (terpri)
964 t))
965
966(defun org-batch-denote-metadata (filepath)
967 "Read metadata from denote note at FILEPATH."
968 (when (file-exists-p filepath)
969 (with-temp-buffer
970 (insert-file-contents filepath)
971 (let ((title nil) (date nil) (tags nil) (category nil) (identifier nil))
972 (goto-char (point-min))
973 (when (re-search-forward "^#\\+title: \\(.*\\)$" nil t)
974 (setq title (match-string 1)))
975 (goto-char (point-min))
976 (when (re-search-forward "^#\\+date: \\(.*\\)$" nil t)
977 (setq date (match-string 1)))
978 (goto-char (point-min))
979 (when (re-search-forward "^#\\+filetags: :\\(.*\\):$" nil t)
980 (setq tags (split-string (match-string 1) ":")))
981 (goto-char (point-min))
982 (when (re-search-forward "^#\\+category: \\(.*\\)$" nil t)
983 (setq category (match-string 1)))
984 (goto-char (point-min))
985 (when (re-search-forward "^#\\+identifier: \\(.*\\)$" nil t)
986 (setq identifier (match-string 1)))
987 (let ((result `((title . ,title)
988 (date . ,date)
989 (tags . ,tags)
990 (category . ,category)
991 (identifier . ,identifier)
992 (filepath . ,filepath))))
993 (princ (json-encode `((success . t) (data . ,result))))
994 (terpri)
995 result)))))
996
997(defun org-batch-denote-update (filepath &optional new-title new-tags new-category)
998 "Update metadata in denote note at FILEPATH."
999 (when (file-exists-p filepath)
1000 (with-temp-buffer
1001 (insert-file-contents filepath)
1002 (when new-title
1003 (goto-char (point-min))
1004 (when (re-search-forward "^#\\+title: .*$" nil t)
1005 (replace-match (concat "#+title: " new-title))))
1006 (when new-tags
1007 (goto-char (point-min))
1008 (let ((tags-str (if (listp new-tags) (mapconcat #'identity new-tags ":") new-tags)))
1009 (if (re-search-forward "^#\\+filetags: .*$" nil t)
1010 (replace-match (concat "#+filetags: :" tags-str ":"))
1011 (goto-char (point-min))
1012 (forward-line 2)
1013 (insert "#+filetags: :" tags-str ":\n"))))
1014 (when new-category
1015 (goto-char (point-min))
1016 (if (re-search-forward "^#\\+category: .*$" nil t)
1017 (replace-match (concat "#+category: " new-category))
1018 (goto-char (point-min))
1019 (forward-line 3)
1020 (insert "#+category: " new-category "\n")))
1021 (write-region (point-min) (point-max) filepath))
1022 (princ (json-encode `((success . t) (updated . t))))
1023 (terpri)
1024 t))
1025
1026;;; Output Functions
1027
1028(defun org-batch-output-json (success data &optional error)
1029 "Output JSON response.
1030SUCCESS: boolean
1031DATA: data to include in response
1032ERROR: error message if any"
1033 (let ((response (if success
1034 `((success . ,success) (data . ,data))
1035 `((success . :json-false) (error . ,error)))))
1036 (princ (json-encode response))
1037 (terpri)))
1038
1039(defun org-batch-output-error (message)
1040 "Output error MESSAGE in JSON format."
1041 (org-batch-output-json nil nil message))
1042
1043(provide 'org-batch-functions)
1044;;; org-batch-functions.el ends here