flake-update-20260505
   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