main
1;;; org-ql-batch-functions.el --- Org-mode batch operations using org-ql -*- lexical-binding: t -*-
2
3;; Copyright (C) 2026 Vincent Demeester
4
5;; Author: Vincent Demeester <vincent@sbr.pm>
6;; Keywords: org, batch, automation, org-ql
7;; Version: 1.0.0
8;; Package-Requires: ((emacs "27.1") (org-ql "0.8"))
9
10;;; Commentary:
11
12;; Reimplementation of org-batch-functions.el using org-ql for queries.
13;; Benefits:
14;; - More powerful query language (30+ predicates)
15;; - Less code for complex queries
16;; - Composable queries with and/or/not
17;; - Built-in caching for performance
18;;
19;; This file provides drop-in replacements for read operations.
20;; Write operations are kept from the original (org-ql is read-only).
21
22;;; Code:
23
24(require 'org-ql)
25(require 'org-element)
26(require 'json)
27
28;;; Configuration
29
30;; Don't override user's init.el settings for org-mode
31;; Priority and todo keywords should be configured in init.el
32
33;;; Utility Functions
34
35(defun org-ql-batch--element-to-alist ()
36 "Convert current heading to JSON-friendly alist.
37Must be called with point on a heading."
38 (let* ((element (org-element-at-point))
39 (priority-char (org-element-property :priority element))
40 (priority-num (when priority-char (- priority-char 48))))
41 `((heading . ,(org-get-heading t t t t))
42 (todo . ,(org-get-todo-state))
43 (priority . ,priority-num)
44 (tags . ,(org-get-tags nil t))
45 (level . ,(org-current-level))
46 (scheduled . ,(org-entry-get nil "SCHEDULED"))
47 (deadline . ,(org-entry-get nil "DEADLINE")))))
48
49(defun org-ql-batch--build-priority-regexp (priorities)
50 "Build regexp to match PRIORITIES (list of numbers 1-5)."
51 (when priorities
52 (format "\\[#[%s]\\]"
53 (mapconcat #'number-to-string priorities ""))))
54
55;;; Read Operations - Reimplemented with org-ql
56
57(defun org-ql-batch-list-todos (file &optional filter-state filter-priority filter-tags)
58 "List TODOs from FILE with optional filters using org-ql.
59FILTER-STATE: String like \"NEXT\" or \"TODO\", or list of states
60FILTER-PRIORITY: Number 1-5 or list of numbers
61FILTER-TAGS: List of tag strings (match any)
62
63This replaces `org-batch-list-todos' with ~50% less code."
64 (let* ((states (cond ((null filter-state) '("TODO" "NEXT" "STRT" "WAIT" "DONE" "CANX"))
65 ((listp filter-state) filter-state)
66 ((stringp filter-state)
67 (if (string-match-p "," filter-state)
68 (split-string filter-state ",")
69 (list filter-state)))
70 (t (list filter-state))))
71 (priority-list (cond ((null filter-priority) nil)
72 ((listp filter-priority) filter-priority)
73 (t (list filter-priority))))
74 (priority-regexp (org-ql-batch--build-priority-regexp priority-list))
75 ;; Build org-ql query dynamically
76 (query `(and (todo ,@states)
77 ,@(when priority-regexp
78 `((regexp ,priority-regexp)))
79 ,@(when filter-tags
80 `((tags-local ,@filter-tags))))))
81 (org-ql-select file query
82 :action #'org-ql-batch--element-to-alist)))
83
84(defun org-ql-batch-scheduled-today (file &optional date)
85 "Get items scheduled for DATE (default today) from FILE.
86DATE should be \"YYYY-MM-DD\" or \"today\".
87
88This replaces `org-batch-scheduled-today' - cleaner with org-ql predicates."
89 (let ((target-date (if (or (null date) (string= date "today"))
90 'today
91 (date-to-time date))))
92 (org-ql-select file
93 `(scheduled :on ,target-date)
94 :action #'org-ql-batch--element-to-alist)))
95
96(defun org-ql-batch-by-section (file section-name)
97 "Get all TODOs under SECTION-NAME (level 1 heading) in FILE.
98
99Uses org-ql's `ancestors' predicate - much cleaner than manual tracking!"
100 (org-ql-select file
101 `(and (todo)
102 (ancestors (and (level 1)
103 (heading ,section-name))))
104 :action #'org-ql-batch--element-to-alist))
105
106(defun org-ql-batch-search (file search-term)
107 "Search for SEARCH-TERM in FILE content.
108
109Uses org-ql's `regexp' predicate."
110 (org-ql-select file
111 `(and (todo)
112 (regexp ,(regexp-quote search-term)))
113 :action (lambda ()
114 (let ((alist (org-ql-batch--element-to-alist)))
115 (cons (cons 'matched-in
116 (if (string-match-p (regexp-quote search-term)
117 (alist-get 'heading alist))
118 "heading"
119 "content"))
120 alist)))))
121
122(defun org-ql-batch-get-sections (file)
123 "Get list of all level-1 sections in FILE."
124 (org-ql-select file
125 '(level 1)
126 :action (lambda () (org-get-heading t t t t))))
127
128(defun org-ql-batch-get-children (file heading-name)
129 "Get all direct children TODOs of HEADING-NAME in FILE.
130
131Uses org-ql's `parent' predicate - elegant solution!"
132 (org-ql-select file
133 `(and (todo)
134 (parent (heading ,heading-name)))
135 :action #'org-ql-batch--element-to-alist))
136
137(defun org-ql-batch-get-overdue (file)
138 "Get all tasks with DEADLINE before today from FILE.
139
140Uses org-ql's `deadline' predicate with :before modifier."
141 (org-ql-select file
142 '(and (todo "TODO" "NEXT" "STRT" "WAIT")
143 (deadline :before today))
144 :action #'org-ql-batch--element-to-alist))
145
146(defun org-ql-batch-get-upcoming (file &optional days)
147 "Get tasks scheduled or due in next DAYS from FILE.
148DAYS defaults to 7.
149
150Uses org-ql's date predicates - very clean!"
151 (let ((days-count (or days 7)))
152 (org-ql-select file
153 `(and (todo "TODO" "NEXT" "STRT" "WAIT")
154 (or (scheduled :to ,days-count)
155 (deadline :to ,days-count)))
156 :action #'org-ql-batch--element-to-alist)))
157
158(defun org-ql-batch-get-recurring-tasks (file)
159 "Get all tasks with repeaters in FILE.
160
161Uses regexp to match org repeater syntax."
162 (org-ql-select file
163 '(and (todo)
164 (regexp "[.+]?\\+[0-9]+[hdwmy]"))
165 :action (lambda ()
166 (let* ((alist (org-ql-batch--element-to-alist))
167 (scheduled (alist-get 'scheduled alist))
168 (deadline (alist-get 'deadline alist))
169 (repeater (or (and scheduled
170 (string-match "[.+]?\\+[0-9]+[hdwmy]" scheduled)
171 (match-string 0 scheduled))
172 (and deadline
173 (string-match "[.+]?\\+[0-9]+[hdwmy]" deadline)
174 (match-string 0 deadline)))))
175 (cons (cons 'repeater repeater) alist)))))
176
177(defun org-ql-batch-get-blocked-tasks (file)
178 "Get all tasks that have BLOCKER property in FILE.
179
180Uses org-ql's `property' predicate."
181 (org-ql-select file
182 '(and (todo)
183 (property "BLOCKER"))
184 :action (lambda ()
185 (let ((alist (org-ql-batch--element-to-alist))
186 (blocker (org-entry-get nil "BLOCKER")))
187 (cons (cons 'blocker blocker) alist)))))
188
189(defun org-ql-batch-count-by-state (file)
190 "Count TODOs in FILE by state.
191
192Uses org-ql with grouping."
193 (let ((counts '((total . 0) (TODO . 0) (NEXT . 0) (STRT . 0)
194 (WAIT . 0) (DONE . 0) (CANX . 0))))
195 (org-ql-select file '(todo)
196 :action (lambda ()
197 (let* ((state (org-get-todo-state))
198 (state-sym (intern state)))
199 (cl-incf (alist-get 'total counts))
200 (when (assoc state-sym counts)
201 (cl-incf (alist-get state-sym counts))))))
202 counts))
203
204;;; Advanced Queries - New capabilities with org-ql
205
206(defun org-ql-batch-clocked-today (file)
207 "Get tasks that were clocked today.
208
209NEW: Not available in original - org-ql makes this easy!"
210 (org-ql-select file
211 '(clocked :on today)
212 :action #'org-ql-batch--element-to-alist))
213
214(defun org-ql-batch-habits (file)
215 "Get all habits from FILE.
216
217NEW: Easy habit filtering with org-ql!"
218 (org-ql-select file
219 '(habit)
220 :action #'org-ql-batch--element-to-alist))
221
222(defun org-ql-batch-stale-tasks (file &optional days)
223 "Get TODO tasks not touched in DAYS (default 30).
224
225NEW: Find forgotten tasks!"
226 (let ((days-ago (or days 30)))
227 (org-ql-select file
228 `(and (todo "TODO")
229 (not (ts :from ,(- days-ago))))
230 :action #'org-ql-batch--element-to-alist)))
231
232(defun org-ql-batch-by-path (file path-pattern)
233 "Get TODOs matching outline PATH-PATTERN.
234
235NEW: Query by outline path like 'Projects/Work/Tekton'."
236 (org-ql-select file
237 `(and (todo)
238 (path ,path-pattern))
239 :action #'org-ql-batch--element-to-alist))
240
241(defun org-ql-batch-with-property (file property &optional value)
242 "Get TODOs with PROPERTY (optionally matching VALUE).
243
244NEW: Easy property-based queries!"
245 (org-ql-select file
246 (if value
247 `(and (todo) (property ,property ,value))
248 `(and (todo) (property ,property)))
249 :action #'org-ql-batch--element-to-alist))
250
251;;; Statistics - Simplified with org-ql
252
253(defun org-ql-batch-get-statistics (file)
254 "Get comprehensive statistics about TODOs in FILE.
255
256Combines multiple org-ql queries for efficiency."
257 (let ((by-state (org-ql-batch-count-by-state file))
258 (scheduled-count (length (org-ql-select file '(and (todo) (scheduled)))))
259 (deadline-count (length (org-ql-select file '(and (todo) (deadline)))))
260 (overdue-count (length (org-ql-batch-get-overdue file)))
261 (by-priority '())
262 (by-tag '()))
263 ;; Count by priority
264 (dolist (p '(1 2 3 4 5))
265 (let ((regexp (format "\\[#%d\\]" p)))
266 (push (cons p (length (org-ql-select file `(and (todo) (regexp ,regexp)))))
267 by-priority)))
268 ;; Count by tag (get all unique tags first, then count)
269 (let ((all-tags '()))
270 (org-ql-select file '(todo)
271 :action (lambda ()
272 (dolist (tag (org-get-tags nil t))
273 (if (assoc tag all-tags #'string=)
274 (cl-incf (cdr (assoc tag all-tags #'string=)))
275 (push (cons tag 1) all-tags)))))
276 (setq by-tag (sort all-tags (lambda (a b) (> (cdr a) (cdr b))))))
277 ;; Return stats
278 `((total . ,(alist-get 'total by-state))
279 (by_state . ,by-state)
280 (by_priority . ,(nreverse by-priority))
281 (by_tag . ,by-tag)
282 (scheduled_count . ,scheduled-count)
283 (deadline_count . ,deadline-count)
284 (overdue_count . ,overdue-count))))
285
286;;; Output Functions (same as original)
287
288(defun org-ql-batch-output-json (success data &optional error)
289 "Output JSON response.
290SUCCESS: boolean
291DATA: data to include in response
292ERROR: error message if any"
293 (let ((response (if success
294 `((success . ,success) (data . ,data))
295 `((success . :json-false) (error . ,error)))))
296 (princ (json-encode response))
297 (terpri)))
298
299(defun org-ql-batch-output-error (message)
300 "Output error MESSAGE in JSON format."
301 (org-ql-batch-output-json nil nil message))
302
303(provide 'org-ql-batch-functions)
304;;; org-ql-batch-functions.el ends here