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