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