main
  1;;; pi-org-todos.el --- Pi coding agent org-mode TODO interface -*- lexical-binding: t -*-
  2
  3;; Copyright (C) 2026 Vincent Demeester
  4
  5;; Author: Vincent Demeester <vincent@sbr.pm>
  6;; Keywords: org, pi, automation
  7;; Version: 1.0.0
  8;; Package-Requires: ((emacs "27.1") (org-ql "0.8"))
  9
 10;;; Commentary:
 11
 12;; Thin wrapper around org-batch-functions.el for use with Pi coding agent.
 13;; All functions return JSON strings for easy parsing by emacsclient.
 14;;
 15;; Usage from Pi (TypeScript):
 16;;   emacsclient --eval '(pi/org-todo-list)'
 17;;   emacsclient --eval '(pi/org-todo-search "NixOS")'
 18;;   emacsclient --eval '(pi/org-todo-done "Review PR")'
 19;;
 20;; All functions use ORG_TODO_FILE environment variable or default file.
 21
 22;;; Code:
 23
 24(require 'org-batch-functions)
 25(require 'json)
 26
 27;;; Configuration
 28
 29(defvar pi/org-todo-default-file
 30  (expand-file-name "~/desktop/org/todos.org")
 31  "Default org file for TODO operations.")
 32
 33(defun pi/org-todo--get-file (&optional file)
 34  "Get the org file to use. FILE overrides, then env var, then default."
 35  (or file
 36      (getenv "ORG_TODO_FILE")
 37      pi/org-todo-default-file))
 38
 39(defun pi/org-todo--json-response (success data &optional error)
 40  "Create JSON response string.
 41SUCCESS: boolean
 42DATA: data to include
 43ERROR: error message if any"
 44  (json-encode
 45   (if success
 46       `((success . t) (data . ,data))
 47     `((success . :json-false) (error . ,(or error "Unknown error"))))))
 48
 49(defun pi/org-todo--safe-call (func &rest args)
 50  "Safely call FUNC with ARGS, return JSON response."
 51  (condition-case err
 52      (let ((result (apply func args)))
 53        (pi/org-todo--json-response t result))
 54    (error
 55     (pi/org-todo--json-response nil nil (error-message-string err)))))
 56
 57;;; Read Operations
 58
 59(defun pi/org-todo-list (&optional file states)
 60  "List TODOs from FILE with optional STATES filter.
 61STATES: comma-separated string like \"TODO,NEXT\" or nil for active tasks.
 62Returns JSON string."
 63  (let* ((f (pi/org-todo--get-file file))
 64         (state-list (or states "TODO,NEXT,STRT")))
 65    (pi/org-todo--safe-call #'org-batch-list-todos f state-list)))
 66
 67(defun pi/org-todo-list-all (&optional file)
 68  "List all TODOs from FILE including done/cancelled.
 69Returns JSON string."
 70  (let ((f (pi/org-todo--get-file file)))
 71    (pi/org-todo--safe-call #'org-batch-list-todos f nil)))
 72
 73(defun pi/org-todo-scheduled (&optional file date)
 74  "Get items scheduled for DATE from FILE.
 75DATE: \"YYYY-MM-DD\" or \"today\" (default).
 76Returns JSON string."
 77  (let ((f (pi/org-todo--get-file file))
 78        (d (or date "today")))
 79    (pi/org-todo--safe-call #'org-batch-scheduled-today f d)))
 80
 81(defun pi/org-todo-upcoming (&optional file days)
 82  "Get tasks scheduled or due in next DAYS from FILE.
 83DAYS defaults to 7.
 84Returns JSON string."
 85  (let ((f (pi/org-todo--get-file file))
 86        (n (or days 7)))
 87    (pi/org-todo--safe-call #'org-batch-get-upcoming f n)))
 88
 89(defun pi/org-todo-overdue (&optional file)
 90  "Get overdue tasks from FILE.
 91Returns JSON string."
 92  (let ((f (pi/org-todo--get-file file)))
 93    (pi/org-todo--safe-call #'org-batch-get-overdue f)))
 94
 95(defun pi/org-todo-search (query &optional file with-content)
 96  "Search TODOs for QUERY in FILE.
 97WITH-CONTENT: if non-nil, include full content.
 98Returns JSON string."
 99  (let ((f (pi/org-todo--get-file file)))
100    (pi/org-todo--safe-call #'org-batch-search f query with-content)))
101
102(defun pi/org-todo-get (heading &optional file)
103  "Get full content of TODO with HEADING from FILE.
104Returns JSON string."
105  (let ((f (pi/org-todo--get-file file)))
106    (pi/org-todo--safe-call #'org-batch-get-todo-content f heading)))
107
108(defun pi/org-todo-sections (&optional file)
109  "Get list of top-level sections from FILE.
110Returns JSON string."
111  (let ((f (pi/org-todo--get-file file)))
112    (pi/org-todo--safe-call #'org-batch-get-sections f)))
113
114(defun pi/org-todo-by-section (section &optional file)
115  "Get TODOs in SECTION from FILE.
116Returns JSON string."
117  (let ((f (pi/org-todo--get-file file)))
118    (pi/org-todo--safe-call #'org-batch-by-section f section)))
119
120(defun pi/org-todo--fix-numeric-keys (alist)
121  "Convert numeric keys in ALIST to strings for JSON compatibility."
122  (mapcar (lambda (pair)
123            (let ((key (car pair))
124                  (val (cdr pair)))
125              (cons (if (numberp key) (number-to-string key) key)
126                    (if (and (listp val) (not (null val)) (consp (car val)))
127                        (pi/org-todo--fix-numeric-keys val)
128                      val))))
129          alist))
130
131(defun pi/org-todo-statistics (&optional file)
132  "Get TODO statistics from FILE.
133Returns JSON string with counts by state, priority, tags."
134  (let ((f (pi/org-todo--get-file file)))
135    (condition-case err
136        (let* ((stats (org-batch-get-statistics f))
137               ;; Fix by_priority which has numeric keys
138               (fixed-stats (pi/org-todo--fix-numeric-keys stats)))
139          (pi/org-todo--json-response t fixed-stats))
140      (error
141       (pi/org-todo--json-response nil nil (error-message-string err))))))
142
143;;; Write Operations
144
145(defun pi/org-todo-done (heading &optional file)
146  "Mark TODO with HEADING as DONE in FILE.
147Returns JSON string."
148  (let ((f (pi/org-todo--get-file file)))
149    (condition-case err
150        (if (org-batch-update-state f heading "DONE")
151            (pi/org-todo--json-response t `((heading . ,heading) (state . "DONE")))
152          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
153      (error
154       (pi/org-todo--json-response nil nil (error-message-string err))))))
155
156(defun pi/org-todo-state (heading state &optional file)
157  "Set TODO with HEADING to STATE in FILE.
158STATE: \"TODO\", \"NEXT\", \"STRT\", \"WAIT\", \"DONE\", \"CANX\"
159Returns JSON string."
160  (let ((f (pi/org-todo--get-file file)))
161    (condition-case err
162        (if (org-batch-update-state f heading state)
163            (pi/org-todo--json-response t `((heading . ,heading) (state . ,state)))
164          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
165      (error
166       (pi/org-todo--json-response nil nil (error-message-string err))))))
167
168(defun pi/org-todo-schedule (heading date &optional file)
169  "Schedule TODO with HEADING for DATE in FILE.
170DATE: \"YYYY-MM-DD\" format.
171Returns JSON string."
172  (let ((f (pi/org-todo--get-file file)))
173    (condition-case err
174        (if (org-batch-schedule-task f heading date)
175            (pi/org-todo--json-response t `((heading . ,heading) (scheduled . ,date)))
176          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
177      (error
178       (pi/org-todo--json-response nil nil (error-message-string err))))))
179
180(defun pi/org-todo-deadline (heading date &optional file)
181  "Set deadline for TODO with HEADING to DATE in FILE.
182DATE: \"YYYY-MM-DD\" format.
183Returns JSON string."
184  (let ((f (pi/org-todo--get-file file)))
185    (condition-case err
186        (if (org-batch-set-deadline f heading date)
187            (pi/org-todo--json-response t `((heading . ,heading) (deadline . ,date)))
188          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
189      (error
190       (pi/org-todo--json-response nil nil (error-message-string err))))))
191
192(defun pi/org-todo-priority (heading priority &optional file)
193  "Set PRIORITY (1-5) for TODO with HEADING in FILE.
194Returns JSON string."
195  (let ((f (pi/org-todo--get-file file)))
196    (condition-case err
197        (if (org-batch-set-priority f heading priority)
198            (pi/org-todo--json-response t `((heading . ,heading) (priority . ,priority)))
199          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
200      (error
201       (pi/org-todo--json-response nil nil (error-message-string err))))))
202
203(defun pi/org-todo-add (heading section &optional file scheduled priority tags)
204  "Add new TODO with HEADING in SECTION of FILE.
205SCHEDULED: \"YYYY-MM-DD\" or nil
206PRIORITY: 1-5 or nil
207TAGS: list of tag strings or nil
208Returns JSON string."
209  (let ((f (pi/org-todo--get-file file)))
210    (condition-case err
211        (if (org-batch-add-todo f section heading scheduled priority tags)
212            (pi/org-todo--json-response t `((heading . ,heading)
213                                            (section . ,section)
214                                            (scheduled . ,scheduled)
215                                            (priority . ,priority)
216                                            (tags . ,tags)))
217          (pi/org-todo--json-response nil nil (format "Section not found: %s" section)))
218      (error
219       (pi/org-todo--json-response nil nil (error-message-string err))))))
220
221(defun pi/org-todo-append (heading content &optional file)
222  "Append CONTENT to TODO with HEADING in FILE.
223CONTENT: org-mode formatted text.
224Returns JSON string."
225  (let ((f (pi/org-todo--get-file file)))
226    (condition-case err
227        (if (org-batch-append-content f heading content)
228            (pi/org-todo--json-response t `((heading . ,heading) (appended . t)))
229          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
230      (error
231       (pi/org-todo--json-response nil nil (error-message-string err))))))
232
233(defun pi/org-todo-archive-done (&optional file)
234  "Archive all DONE and CANX items in FILE.
235Returns JSON string with count."
236  (let ((f (pi/org-todo--get-file file)))
237    (condition-case err
238        (let ((count (org-batch-archive-done f)))
239          (pi/org-todo--json-response t `((archived . ,count))))
240      (error
241       (pi/org-todo--json-response nil nil (error-message-string err))))))
242
243;;; Tag Operations
244
245(defun pi/org-todo-add-tags (heading tags &optional file)
246  "Add TAGS to TODO with HEADING in FILE.
247TAGS: list of tag strings.
248Returns JSON string."
249  (let ((f (pi/org-todo--get-file file)))
250    (condition-case err
251        (if (org-batch-add-tags f heading tags)
252            (pi/org-todo--json-response t `((heading . ,heading) (tags_added . ,tags)))
253          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
254      (error
255       (pi/org-todo--json-response nil nil (error-message-string err))))))
256
257(defun pi/org-todo-remove-tags (heading tags &optional file)
258  "Remove TAGS from TODO with HEADING in FILE.
259TAGS: list of tag strings.
260Returns JSON string."
261  (let ((f (pi/org-todo--get-file file)))
262    (condition-case err
263        (if (org-batch-remove-tags f heading tags)
264            (pi/org-todo--json-response t `((heading . ,heading) (tags_removed . ,tags)))
265          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
266      (error
267       (pi/org-todo--json-response nil nil (error-message-string err))))))
268
269(defun pi/org-todo-all-tags (&optional file)
270  "List all unique tags used in FILE.
271Returns JSON string."
272  (let ((f (pi/org-todo--get-file file)))
273    (pi/org-todo--safe-call #'org-batch-list-all-tags f)))
274
275;;; Inbox Operations
276
277(defun pi/org-todo-inbox-all (&optional file)
278  "Get all entries from inbox FILE (both TODOs and plain entries like links).
279Returns JSON string."
280  (let ((f (or file 
281               (getenv "ORG_INBOX_FILE") 
282               (expand-file-name "~/desktop/org/inbox.org"))))
283    (pi/org-todo--safe-call #'org-batch-get-all-entries f 1)))
284
285;;; Property Operations
286
287(defun pi/org-todo-get-property (heading property &optional file)
288  "Get PROPERTY value for TODO with HEADING in FILE.
289Returns JSON string."
290  (let ((f (pi/org-todo--get-file file)))
291    (condition-case err
292        (let ((value (org-batch-get-property f heading property)))
293          (pi/org-todo--json-response t `((heading . ,heading)
294                                          (property . ,property)
295                                          (value . ,value))))
296      (error
297       (pi/org-todo--json-response nil nil (error-message-string err))))))
298
299(defun pi/org-todo-set-property (heading property value &optional file)
300  "Set PROPERTY to VALUE for TODO with HEADING in FILE.
301Returns JSON string."
302  (let ((f (pi/org-todo--get-file file)))
303    (condition-case err
304        (if (org-batch-set-property f heading property value)
305            (pi/org-todo--json-response t `((heading . ,heading)
306                                            (property . ,property)
307                                            (value . ,value)))
308          (pi/org-todo--json-response nil nil (format "Heading not found: %s" heading)))
309      (error
310       (pi/org-todo--json-response nil nil (error-message-string err))))))
311
312;;; Refile Operations
313
314(defun pi/org-todo-get-refile-targets (&optional file)
315  "Get refile targets (top-level sections) from FILE.
316Returns JSON string with available sections."
317  (let ((f (pi/org-todo--get-file file)))
318    (pi/org-todo--safe-call #'org-batch-get-refile-targets f)))
319
320(defun pi/org-todo-refile (source-heading target-section &optional source-file target-file target-position source-position)
321  "Refile SOURCE-HEADING from source to TARGET-SECTION in target file.
322SOURCE-FILE defaults to inbox.org, TARGET-FILE defaults to todos.org.
323TARGET-POSITION, if provided, is used directly instead of searching for TARGET-SECTION.
324SOURCE-POSITION, if provided, is used directly instead of searching for SOURCE-HEADING.
325Returns JSON string."
326  (let ((src (or source-file 
327                 (expand-file-name "~/desktop/org/inbox.org")))
328        (tgt (pi/org-todo--get-file target-file)))
329    (condition-case err
330        (progn
331          (if target-position
332              (org-batch-refile-entry-at-pos src source-heading tgt target-section target-position source-position)
333            (org-batch-refile-entry src source-heading tgt target-section))
334          (pi/org-todo--json-response t 
335            `((heading . ,source-heading)
336              (target . ,target-section)
337              (source-file . ,src)
338              (target-file . ,tgt))))
339      (error
340       (pi/org-todo--json-response nil nil (error-message-string err))))))
341
342(provide 'pi-org-todos)
343;;; pi-org-todos.el ends here