Commit 6cec87e2e8ab

Vincent Demeester <vincent@sbr.pm>
2025-12-23 13:09:50
feat(org): Add bulk operations for efficient batch TODO management
Implement high-priority bulk operation commands for managing multiple TODOs at once: - bulk-update-state: Update all tasks matching a state (with optional tag filter) - bulk-add-tags: Add tags to all tasks with specific state - bulk-set-priority: Set priority for all tasks with specific state Technical implementation: - Use org-heading-regexp with forward search for efficient iteration - Leverage org-get-todo-state and org-get-tags for state inspection - Apply org-todo, org-set-tags natively without parsing overhead - Return count of updated items for feedback CLI interface: - All bulk commands follow pattern: <file> <filter-state> <action> - Tag filtering supported in bulk-update-state - Comprehensive help documentation and examples Test coverage: - 4 new tests covering all bulk operations - Test tag filtering, state updates, and priority setting - All 27 tests passing Example usage: # Complete all work TODOs org-manager bulk-update-state todos.org TODO DONE work # Tag all NEXT items as urgent org-manager bulk-add-tags todos.org NEXT urgent,review # Set all TODOs to high priority org-manager bulk-set-priority todos.org TODO 1 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent af7fb27
Changed files (4)
dots/.config/claude/skills/Org/tools/tests/batch-functions-test.el
@@ -322,5 +322,69 @@ Priority mapping: '1'=1, '2'=2, '3'=3, '4'=4, '5'=5."
          (should value)
          (should (string= value "test-value")))))))
 
+;;; Tests for Bulk Operations
+
+(ert-deftest test-org-batch-bulk-update-state ()
+  "Test bulk updating state of multiple tasks."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Task 1\n** TODO Task 2 :urgent:\n** NEXT Task 3\n"
+   (lambda (temp-file)
+     (let ((count (org-batch-bulk-update-state temp-file "TODO" "DONE")))
+       (should (= count 2))
+       ;; Verify states were updated
+       (let ((todos (org-batch-list-todos temp-file)))
+         (let ((task1 (batch-test--find-item-by-heading todos "Task 1"))
+               (task2 (batch-test--find-item-by-heading todos "Task 2")))
+           (should (string= (alist-get 'todo task1) "DONE"))
+           (should (string= (alist-get 'todo task2) "DONE"))))))))
+
+(ert-deftest test-org-batch-bulk-update-state-with-tag-filter ()
+  "Test bulk updating state with tag filter."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Task 1 :urgent:\n** TODO Task 2\n** TODO Task 3 :urgent:\n"
+   (lambda (temp-file)
+     (let ((count (org-batch-bulk-update-state temp-file "TODO" "NEXT" '("urgent"))))
+       (should (= count 2))
+       ;; Verify only urgent tasks were updated
+       (let ((todos (org-batch-list-todos temp-file)))
+         (let ((task1 (batch-test--find-item-by-heading todos "Task 1"))
+               (task2 (batch-test--find-item-by-heading todos "Task 2"))
+               (task3 (batch-test--find-item-by-heading todos "Task 3")))
+           (should (string= (alist-get 'todo task1) "NEXT"))
+           (should (string= (alist-get 'todo task2) "TODO"))
+           (should (string= (alist-get 'todo task3) "NEXT"))))))))
+
+(ert-deftest test-org-batch-bulk-add-tags ()
+  "Test bulk adding tags to multiple tasks."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Task 1\n** TODO Task 2\n** NEXT Task 3\n"
+   (lambda (temp-file)
+     (let ((count (org-batch-bulk-add-tags temp-file "TODO" '("review" "urgent"))))
+       (should (= count 2))
+       ;; Verify tags were added
+       (let ((todos (org-batch-list-todos temp-file)))
+         (let ((task1 (batch-test--find-item-by-heading todos "Task 1"))
+               (task2 (batch-test--find-item-by-heading todos "Task 2")))
+           (should (member "review" (alist-get 'tags task1)))
+           (should (member "urgent" (alist-get 'tags task1)))
+           (should (member "review" (alist-get 'tags task2)))
+           (should (member "urgent" (alist-get 'tags task2)))))))))
+
+(ert-deftest test-org-batch-bulk-set-priority ()
+  "Test bulk setting priority for multiple tasks."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Task 1\n** TODO Task 2\n** NEXT Task 3\n"
+   (lambda (temp-file)
+     (let ((count (org-batch-bulk-set-priority temp-file "TODO" 1)))
+       (should (= count 2))
+       ;; Verify priorities were set
+       (let ((todos (org-batch-list-todos temp-file)))
+         (let ((task1 (batch-test--find-item-by-heading todos "Task 1"))
+               (task2 (batch-test--find-item-by-heading todos "Task 2"))
+               (task3 (batch-test--find-item-by-heading todos "Task 3")))
+           (should (= (alist-get 'priority task1) 1))
+           (should (= (alist-get 'priority task2) 1))
+           (should-not (alist-get 'priority task3))))))))
+
 (provide 'batch-functions-test)
 ;;; batch-functions-test.el ends here
dots/.config/claude/skills/Org/tools/batch-functions.el
@@ -627,6 +627,73 @@ Returns count of archived items."
       (write-region (point-min) (point-max) file))
     count))
 
+;;; Bulk Operations
+
+(defun org-batch-bulk-update-state (file filter-state new-state &optional filter-tags)
+  "Update all tasks matching FILTER-STATE in FILE to NEW-STATE.
+FILTER-TAGS: Optional list of tags to further filter tasks.
+Returns count of updated tasks."
+  (let ((count 0))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (goto-char (point-min))
+      (while (re-search-forward org-heading-regexp nil t)
+        (org-back-to-heading t)
+        (let ((todo (org-get-todo-state))
+              (tags (org-get-tags)))
+          (when (and todo
+                     (string= todo filter-state)
+                     ;; Tag filter (match any)
+                     (or (null filter-tags)
+                         (and tags (seq-intersection filter-tags tags))))
+            (let ((org-log-done (if (string= new-state "DONE") 'time nil)))
+              (org-todo new-state))
+            (setq count (1+ count))))
+        (forward-line 1))
+      (write-region (point-min) (point-max) file))
+    count))
+
+(defun org-batch-bulk-add-tags (file filter-state new-tags)
+  "Add NEW-TAGS to all tasks with FILTER-STATE in FILE.
+Returns count of updated tasks."
+  (let ((count 0))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (goto-char (point-min))
+      (while (re-search-forward org-heading-regexp nil t)
+        (org-back-to-heading t)
+        (let ((todo (org-get-todo-state)))
+          (when (and todo (string= todo filter-state))
+            (let* ((current-tags (org-get-tags))
+                   (combined-tags (delete-dups (append current-tags new-tags))))
+              (org-set-tags combined-tags))
+            (setq count (1+ count))))
+        (forward-line 1))
+      (write-region (point-min) (point-max) file))
+    count))
+
+(defun org-batch-bulk-set-priority (file filter-state priority)
+  "Set PRIORITY for all tasks with FILTER-STATE in FILE.
+Returns count of updated tasks."
+  (let ((count 0)
+        (priority-cookie (format " [#%d]" priority)))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (goto-char (point-min))
+      (while (re-search-forward (concat "^\\(\\*+ " (regexp-quote filter-state) "\\) \\(?:\\[#[1-5]\\] \\)?") nil t)
+        (goto-char (match-end 1))
+        ;; Remove existing priority if present
+        (when (looking-at " \\[#[1-5]\\]")
+          (delete-region (point) (+ (point) 5)))
+        ;; Insert new priority
+        (insert priority-cookie)
+        (setq count (1+ count)))
+      (write-region (point-min) (point-max) file))
+    count))
+
 ;;; Output Functions
 
 (defun org-batch-output-json (success data &optional error)
dots/.config/claude/skills/Org/tools/org-manager
@@ -171,6 +171,22 @@ PROPERTY OPERATIONS:
   list-properties <file> <heading>
       List all properties of a heading
 
+BULK OPERATIONS:
+  bulk-update-state <file> <filter-state> <new-state> [filter-tags]
+      Update all tasks matching filter-state to new-state
+      Optional: filter by tags (comma-separated)
+      Example: org-manager bulk-update-state todos.org TODO DONE work,urgent
+
+  bulk-add-tags <file> <filter-state> <tags>
+      Add tags to all tasks with filter-state
+      Tags: comma-separated list
+      Example: org-manager bulk-add-tags todos.org NEXT urgent,review
+
+  bulk-set-priority <file> <filter-state> <priority>
+      Set priority for all tasks with filter-state
+      Priority: 1-5 (1=highest, 5=lowest)
+      Example: org-manager bulk-set-priority todos.org TODO 1
+
 DENOTE COMMANDS:
   denote-create <title> <tags> [--signature=SIG] [--category=CAT] [--directory=DIR] [--content=FILE]
       Create a denote-formatted note with proper naming and frontmatter
@@ -731,6 +747,70 @@ cmd_list_properties() {
     run_elisp "$elisp"
 }
 
+# Bulk operations
+
+cmd_bulk_update_state() {
+    local file="$1"
+    local filter_state="$2"
+    local new_state="$3"
+    local filter_tags="$4"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$filter_state" ]] || error "Filter state required (TODO, NEXT, etc.)"
+    [[ -n "$new_state" ]] || error "New state required (DONE, NEXT, etc.)"
+
+    local tags_list="nil"
+    if [[ -n "$filter_tags" ]]; then
+        tags_list="'($(echo "$filter_tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
+    fi
+
+    local elisp="(progn
+      (let ((count (org-batch-bulk-update-state \"$file\" \"$filter_state\" \"$new_state\" $tags_list)))
+        (org-batch-output-json t (list :updated count :filter_state \"$filter_state\" :new_state \"$new_state\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_bulk_add_tags() {
+    local file="$1"
+    local filter_state="$2"
+    local tags="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$filter_state" ]] || error "Filter state required (TODO, NEXT, etc.)"
+    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
+
+    local tags_list
+    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
+
+    local elisp="(progn
+      (let ((count (org-batch-bulk-add-tags \"$file\" \"$filter_state\" $tags_list)))
+        (org-batch-output-json t (list :updated count :filter_state \"$filter_state\" :tags_added t)))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_bulk_set_priority() {
+    local file="$1"
+    local filter_state="$2"
+    local priority="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$filter_state" ]] || error "Filter state required (TODO, NEXT, etc.)"
+    [[ -n "$priority" ]] || error "Priority required (1-5)"
+
+    [[ "$priority" =~ ^[1-5]$ ]] || error "Priority must be 1-5"
+
+    local elisp="(progn
+      (let ((count (org-batch-bulk-set-priority \"$file\" \"$filter_state\" $priority)))
+        (org-batch-output-json t (list :updated count :filter_state \"$filter_state\" :priority $priority)))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
 # Denote commands
 
 cmd_denote_create() {
@@ -956,6 +1036,15 @@ main() {
         list-properties)
             cmd_list_properties "$@"
             ;;
+        bulk-update-state)
+            cmd_bulk_update_state "$@"
+            ;;
+        bulk-add-tags)
+            cmd_bulk_add_tags "$@"
+            ;;
+        bulk-set-priority)
+            cmd_bulk_set_priority "$@"
+            ;;
         denote-create)
             cmd_denote_create "$@"
             ;;
dots/.config/claude/skills/Org/SKILL.md
@@ -82,6 +82,21 @@ Provide reliable, programmatic access to org-mode files using Emacs batch mode a
 ./tools/org-manager set-property ~/desktop/org/todos.org "Task name" "STATUS" "In Progress"
 ```
 
+#### Bulk Operations
+```bash
+# Update all tasks matching a state to a new state
+./tools/org-manager bulk-update-state ~/desktop/org/todos.org "TODO" "DONE"
+
+# Update with tag filter (only tasks with specific tags)
+./tools/org-manager bulk-update-state ~/desktop/org/todos.org "TODO" "DONE" "work,urgent"
+
+# Add tags to all tasks with a specific state
+./tools/org-manager bulk-add-tags ~/desktop/org/todos.org "NEXT" "urgent,review"
+
+# Set priority for all tasks with a specific state
+./tools/org-manager bulk-set-priority ~/desktop/org/todos.org "TODO" 1
+```
+
 #### Denote Operations
 ```bash
 # Create denote-formatted note