Commit 543ee1e189fd
Changed files (6)
dots
.config
claude
skills
Org
tools
dots/.config/claude/skills/Org/tools/tests/fixtures/test-todos.org
@@ -0,0 +1,65 @@
+* Work
+** TODO Review PR #123 :work:code:
+SCHEDULED: <2025-12-23>
+:PROPERTIES:
+:CREATED: [2025-12-20 Fri 10:00]
+:PR_URL: https://github.com/example/repo/pull/123
+:END:
+
+- [ ] Check for security issues
+- [ ] Verify tests pass
+- [ ] Review commit messages
+
+Focus on error handling in auth module.
+
+** NEXT [#2] Implement authentication :work:auth:
+SCHEDULED: <2025-12-24> DEADLINE: <2025-12-30>
+:PROPERTIES:
+:CREATED: [2025-12-21 Sat 14:30]
+:END:
+
+Using OAuth2 flow.
+
+** DONE Fix bug in parser :work:bug:
+CLOSED: [2025-12-22 Sun 16:45]
+:PROPERTIES:
+:CREATED: [2025-12-22 Sun 10:00]
+:END:
+
+Parser was failing on multi-line strings.
+
+* Personal
+** TODO [#1] Buy groceries :personal:life:
+:PROPERTIES:
+:CREATED: [2025-12-23 Mon 09:00]
+:END:
+SCHEDULED: <2025-12-23>
+
+- Milk
+- Bread
+- Eggs
+
+** WAIT Call dentist :personal:health:
+:PROPERTIES:
+:CREATED: [2025-12-20 Fri 08:00]
+:PHONE: 555-1234
+:END:
+
+Waiting for office to open after holidays.
+
+** TODO Organize photos :personal:media:
+DEADLINE: <2025-12-20>
+:PROPERTIES:
+:CREATED: [2025-12-15 Mon 10:00]
+:END:
+
+This task is overdue!
+
+* Archive
+** CANX [#3] Old project :work:old:
+:PROPERTIES:
+:CREATED: [2025-11-01 Fri 10:00]
+:ARCHIVE: archive.org::* Cancelled Projects
+:END:
+
+Project was cancelled due to budget constraints.
dots/.config/claude/skills/Org/tools/tests/batch-functions-test.el
@@ -0,0 +1,281 @@
+;;; batch-functions-test.el --- Tests for org-mode batch operations -*- lexical-binding: t; no-byte-compile: t -*-
+
+;; Copyright (C) 2025 Vincent Demeester
+
+;; Author: Vincent Demeester <vincent@sbr.pm>
+;; Keywords: org, batch, automation, testing
+
+;;; Commentary:
+
+;; ERT tests for batch-functions.el
+;; Run with: emacs --batch -L .. -l batch-functions.el -l batch-functions-test.el -f ert-run-tests-batch-and-exit
+
+;;; Code:
+
+(require 'ert)
+(require 'org)
+(require 'org-element)
+
+;; Load batch-functions from parent directory
+(eval-and-compile
+ (let* ((current-file (or load-file-name buffer-file-name))
+ (parent-dir (when current-file
+ (file-name-directory (directory-file-name (file-name-directory current-file))))))
+ (when parent-dir
+ (add-to-list 'load-path parent-dir)))
+ (require 'batch-functions))
+
+;;; Test Fixtures
+
+(defvar batch-test-fixture-dir
+ (expand-file-name "fixtures" (file-name-directory load-file-name))
+ "Directory containing test fixture files.")
+
+(defvar batch-test-fixture-file
+ (expand-file-name "test-todos.org" batch-test-fixture-dir)
+ "Main test fixture file.")
+
+;;; Helper Functions
+
+(defun batch-test--count-items (items)
+ "Count number of items in ITEMS list."
+ (length items))
+
+(defun batch-test--find-item-by-heading (items heading)
+ "Find item in ITEMS with matching HEADING."
+ (seq-find (lambda (item)
+ (string= (alist-get 'heading item) heading))
+ items))
+
+(defun batch-test--with-temp-org-file (content fn)
+ "Create temp org file with CONTENT, call FN with filepath, then cleanup."
+ (let ((temp-file (make-temp-file "org-test-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file temp-file
+ (insert content))
+ (funcall fn temp-file))
+ (when (file-exists-p temp-file)
+ (delete-file temp-file)))))
+
+;;; Tests for Read Operations
+
+(ert-deftest test-org-batch-list-todos ()
+ "Test listing all TODOs from file."
+ (let ((todos (org-batch-list-todos batch-test-fixture-file)))
+ (should (> (batch-test--count-items todos) 0))
+ ;; Should find "Review PR #123"
+ (should (batch-test--find-item-by-heading todos "Review PR #123"))
+ ;; Should find "Buy groceries"
+ (should (batch-test--find-item-by-heading todos "Buy groceries"))))
+
+(ert-deftest test-org-batch-list-todos-filter-state ()
+ "Test filtering TODOs by state."
+ (let ((next-todos (org-batch-list-todos batch-test-fixture-file "NEXT")))
+ (should (> (batch-test--count-items next-todos) 0))
+ ;; All items should have NEXT state
+ (dolist (todo next-todos)
+ (should (string= (alist-get 'todo todo) "NEXT")))
+ ;; Should find "Implement authentication"
+ (should (batch-test--find-item-by-heading next-todos "Implement authentication"))))
+
+(ert-deftest test-org-batch-list-todos-filter-priority ()
+ "Test filtering TODOs by priority."
+ (let ((high-priority (org-batch-list-todos batch-test-fixture-file nil 1)))
+ (should (> (batch-test--count-items high-priority) 0))
+ ;; All items should have priority 1
+ (dolist (todo high-priority)
+ (should (= (alist-get 'priority todo) 1)))
+ ;; Should find "Buy groceries"
+ (should (batch-test--find-item-by-heading high-priority "Buy groceries"))))
+
+(ert-deftest test-org-batch-list-todos-filter-tags ()
+ "Test filtering TODOs by tags."
+ (let ((work-todos (org-batch-list-todos batch-test-fixture-file nil nil '("work"))))
+ (should (> (batch-test--count-items work-todos) 0))
+ ;; All items should have :work: tag
+ (dolist (todo work-todos)
+ (should (member "work" (alist-get 'tags todo))))
+ ;; Should find "Review PR #123"
+ (should (batch-test--find-item-by-heading work-todos "Review PR #123"))))
+
+(ert-deftest test-org-batch-scheduled-today ()
+ "Test getting scheduled items for a specific date."
+ (let ((scheduled (org-batch-scheduled-today batch-test-fixture-file "2025-12-23")))
+ (should (>= (batch-test--count-items scheduled) 1))
+ ;; Should include "Review PR #123" and "Buy groceries"
+ (should (batch-test--find-item-by-heading scheduled "Review PR #123"))))
+
+(ert-deftest test-org-batch-by-section ()
+ "Test getting TODOs by section."
+ (let ((work-section (org-batch-by-section batch-test-fixture-file "Work")))
+ (should (> (batch-test--count-items work-section) 0))
+ ;; Should find work-related tasks
+ (should (batch-test--find-item-by-heading work-section "Review PR #123")))
+
+ (let ((personal-section (org-batch-by-section batch-test-fixture-file "Personal")))
+ (should (> (batch-test--count-items personal-section) 0))
+ ;; Should find personal tasks
+ (should (batch-test--find-item-by-heading personal-section "Buy groceries"))))
+
+(ert-deftest test-org-batch-count-by-state ()
+ "Test counting TODOs by state."
+ (let ((counts (org-batch-count-by-state batch-test-fixture-file)))
+ (should (> (alist-get 'total counts) 0))
+ (should (> (alist-get 'TODO counts) 0))
+ (should (>= (alist-get 'NEXT counts) 1))
+ (should (>= (alist-get 'DONE counts) 1))
+ (should (>= (alist-get 'WAIT counts) 1))))
+
+(ert-deftest test-org-batch-search ()
+ "Test searching for content in TODOs."
+ (let ((matches (org-batch-search batch-test-fixture-file "OAuth2")))
+ (should (> (batch-test--count-items matches) 0))
+ ;; Should find "Implement authentication"
+ (should (batch-test--find-item-by-heading matches "Implement authentication"))))
+
+(ert-deftest test-org-batch-get-sections ()
+ "Test getting list of top-level sections."
+ (let ((sections (org-batch-get-sections batch-test-fixture-file)))
+ (should (>= (length sections) 3))
+ (should (member "Work" sections))
+ (should (member "Personal" sections))
+ (should (member "Archive" sections))))
+
+(ert-deftest test-org-batch-get-children ()
+ "Test getting direct children of a heading."
+ (let ((work-children (org-batch-get-children batch-test-fixture-file "Work")))
+ (should (>= (batch-test--count-items work-children) 2))
+ ;; Should find direct children only
+ (should (batch-test--find-item-by-heading work-children "Review PR #123"))
+ (should (batch-test--find-item-by-heading work-children "Implement authentication"))))
+
+;;; Tests for Write Operations
+
+(ert-deftest test-org-batch-add-todo ()
+ "Test adding a new TODO item."
+ (batch-test--with-temp-org-file
+ "* Work\n"
+ (lambda (temp-file)
+ (let ((result (org-batch-add-todo temp-file "Work" "New Task"
+ "2025-12-25" 2 '("test" "task"))))
+ (should result)
+ ;; Verify it was added
+ (let ((todos (org-batch-list-todos temp-file)))
+ (let ((new-task (batch-test--find-item-by-heading todos "New Task")))
+ (should new-task)
+ (should (string= (alist-get 'todo new-task) "TODO"))
+ (should (= (alist-get 'priority new-task) 2))
+ (should (member "test" (alist-get 'tags new-task)))
+ (should (member "task" (alist-get 'tags new-task)))))))))
+
+(ert-deftest test-org-batch-update-state ()
+ "Test updating TODO state."
+ (batch-test--with-temp-org-file
+ "* Work\n** TODO Test Task\n"
+ (lambda (temp-file)
+ (let ((result (org-batch-update-state temp-file "Test Task" "DONE")))
+ (should result)
+ ;; Verify state was updated
+ (let ((todos (org-batch-list-todos temp-file)))
+ (let ((task (batch-test--find-item-by-heading todos "Test Task")))
+ (should task)
+ (should (string= (alist-get 'todo task) "DONE"))))))))
+
+(ert-deftest test-org-batch-schedule-task ()
+ "Test scheduling a task."
+ (batch-test--with-temp-org-file
+ "* Work\n** TODO Test Task\n"
+ (lambda (temp-file)
+ (let ((result (org-batch-schedule-task temp-file "Test Task" "2025-12-25")))
+ (should result)
+ ;; Verify schedule was set
+ (let ((scheduled (org-batch-scheduled-today temp-file "2025-12-25")))
+ (should (batch-test--find-item-by-heading scheduled "Test Task")))))))
+
+(ert-deftest test-org-batch-set-priority ()
+ "Test setting task priority."
+ (batch-test--with-temp-org-file
+ "* Work\n** TODO Test Task\n"
+ (lambda (temp-file)
+ (let ((result (org-batch-set-priority temp-file "Test Task" 1)))
+ (should result)
+ ;; Verify priority was set
+ (let ((todos (org-batch-list-todos temp-file)))
+ (let ((task (batch-test--find-item-by-heading todos "Test Task")))
+ (should task)
+ (should (= (alist-get 'priority task) 1))))))))
+
+;;; Tests for Utility Functions
+
+(ert-deftest test-org-batch--priority-conversion ()
+ "Test priority number/character conversion."
+ (should (= (org-batch--priority-to-number ?1) 1))
+ (should (= (org-batch--priority-to-number ?5) 5))
+ (should (= (org-batch--number-to-priority 1) ?1))
+ (should (= (org-batch--number-to-priority 5) ?5)))
+
+;;; Placeholder Tests for New Features
+
+(ert-deftest test-org-batch-get-todo-content ()
+ "Test getting full TODO content with metadata and body."
+ (let ((result (org-batch-get-todo-content batch-test-fixture-file "Review PR #123")))
+ (should result)
+ ;; Check basic metadata
+ (should (string= (alist-get 'heading result) "Review PR #123"))
+ (should (string= (alist-get 'todo result) "TODO"))
+ (should (member "work" (alist-get 'tags result)))
+ (should (member "code" (alist-get 'tags result)))
+ ;; Check properties
+ (let ((props (alist-get 'properties result)))
+ (should props)
+ (should (assoc "CREATED" props))
+ (should (assoc "PR_URL" props))
+ (should (string-match "github.com" (cdr (assoc "PR_URL" props)))))
+ ;; Check content
+ (let ((content (alist-get 'content result)))
+ (should content)
+ (should (string-match "Check for security" content))
+ (should (string-match "error handling" content))))
+
+ ;; Test non-existent heading
+ (let ((result (org-batch-get-todo-content batch-test-fixture-file "NonExistent Task")))
+ (should-not result)))
+
+(ert-deftest test-org-batch-add-tags ()
+ "Test adding tags to existing TODO (to be implemented)."
+ :expected-result :failed
+ (should nil))
+
+(ert-deftest test-org-batch-remove-tags ()
+ "Test removing tags from TODO (to be implemented)."
+ :expected-result :failed
+ (should nil))
+
+(ert-deftest test-org-batch-list-all-tags ()
+ "Test listing all unique tags (to be implemented)."
+ :expected-result :failed
+ (should nil))
+
+(ert-deftest test-org-batch-get-overdue ()
+ "Test getting overdue tasks (to be implemented)."
+ :expected-result :failed
+ (should nil))
+
+(ert-deftest test-org-batch-get-upcoming ()
+ "Test getting upcoming tasks (to be implemented)."
+ :expected-result :failed
+ (should nil))
+
+(ert-deftest test-org-batch-get-property ()
+ "Test getting property value (to be implemented)."
+ :expected-result :failed
+ (should nil))
+
+(ert-deftest test-org-batch-set-property ()
+ "Test setting property value (to be implemented)."
+ :expected-result :failed
+ (should nil))
+
+(provide 'batch-functions-test)
+;;; batch-functions-test.el ends here
dots/.config/claude/skills/Org/tools/tests/run-tests.sh
@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+# run-tests.sh - Run ERT tests for org-manager batch functions
+# Copyright (C) 2025 Vincent Demeester
+# Part of Claude Code Org skill
+
+set -euo pipefail
+
+# Configuration
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TOOLS_DIR="$(dirname "$SCRIPT_DIR")"
+TEST_FILE="$SCRIPT_DIR/batch-functions-test.el"
+EMACS="${EMACS:-emacs}"
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+usage() {
+ cat <<EOF
+Usage: $0 [options]
+
+Run ERT tests for org-manager batch functions.
+
+Options:
+ -v, --verbose Verbose output (show all test details)
+ -s, --selector Run specific test selector (e.g., "test-org-batch-list-todos")
+ -h, --help Show this help message
+
+Examples:
+ # Run all tests
+ $0
+
+ # Run specific test
+ $0 -s test-org-batch-list-todos
+
+ # Verbose output
+ $0 -v
+
+Environment:
+ EMACS Path to emacs binary (default: emacs)
+
+EOF
+ exit 0
+}
+
+# Parse arguments
+VERBOSE=0
+SELECTOR=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -v|--verbose)
+ VERBOSE=1
+ shift
+ ;;
+ -s|--selector)
+ SELECTOR="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ ;;
+ *)
+ echo -e "${RED}Unknown option: $1${NC}"
+ usage
+ ;;
+ esac
+done
+
+# Check dependencies
+if ! command -v "$EMACS" &> /dev/null; then
+ echo -e "${RED}Error: Emacs not found${NC}"
+ echo "Set EMACS environment variable or install emacs"
+ exit 1
+fi
+
+if [[ ! -f "$TEST_FILE" ]]; then
+ echo -e "${RED}Error: Test file not found: $TEST_FILE${NC}"
+ exit 1
+fi
+
+echo -e "${YELLOW}Running org-manager tests...${NC}"
+echo "Test file: $TEST_FILE"
+echo "Emacs: $EMACS"
+
+if [[ -n "$SELECTOR" ]]; then
+ echo "Selector: $SELECTOR"
+fi
+echo ""
+
+# Build emacs command
+EMACS_CMD=(
+ "$EMACS"
+ --batch
+ --no-init-file
+ -L "$TOOLS_DIR"
+ -l "$TOOLS_DIR/batch-functions.el"
+ -l "$TEST_FILE"
+)
+
+if [[ -n "$SELECTOR" ]]; then
+ # Run specific test
+ EMACS_CMD+=(--eval "(ert-run-tests-batch-and-exit '$SELECTOR)")
+else
+ # Run all tests
+ EMACS_CMD+=(-f ert-run-tests-batch-and-exit)
+fi
+
+# Run tests
+if [[ "$VERBOSE" == "1" ]]; then
+ "${EMACS_CMD[@]}" 2>&1
+ EXIT_CODE=$?
+else
+ # Capture output and show summary
+ OUTPUT=$("${EMACS_CMD[@]}" 2>&1)
+ EXIT_CODE=$?
+
+ # Show summary
+ echo "$OUTPUT" | tail -20
+fi
+
+echo ""
+if [[ $EXIT_CODE -eq 0 ]]; then
+ echo -e "${GREEN}โ All tests passed!${NC}"
+else
+ echo -e "${RED}โ Some tests failed${NC}"
+ if [[ "$VERBOSE" == "0" ]]; then
+ echo ""
+ echo "Run with -v for detailed output"
+ fi
+fi
+
+exit $EXIT_CODE
dots/.config/claude/skills/Org/tools/batch-functions.el
@@ -212,6 +212,88 @@ Returns only immediate children (level = parent + 1), not all descendants."
(push (org-batch--element-to-alist hl) children))))))
(nreverse children))))
+(defun org-batch-get-todo-content (file heading-name)
+ "Get full content of TODO with HEADING-NAME in FILE.
+Returns alist with metadata, properties, and body content.
+Returns nil if heading not found."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (let ((found nil)
+ (result nil))
+ (org-element-map (org-element-parse-buffer) 'headline
+ (lambda (hl)
+ (when (and (not found)
+ (string= (org-element-property :raw-value hl) heading-name))
+ (setq found t)
+ ;; Build result with metadata
+ (let* ((basic-data (org-batch--element-to-alist hl))
+ (properties (org-batch--extract-properties hl))
+ (content (org-batch--extract-content hl)))
+ (setq result (append basic-data
+ (list (cons 'properties properties)
+ (cons 'content content))))))))
+ result)))
+
+(defun org-batch--extract-properties (element)
+ "Extract properties drawer from ELEMENT as alist."
+ (let ((properties '())
+ (begin (org-element-property :begin element))
+ (end (org-element-property :contents-end element)))
+ (when (and begin end)
+ (save-excursion
+ (goto-char begin)
+ (forward-line 1) ; Skip heading line
+ ;; Look for :PROPERTIES: drawer
+ (when (re-search-forward "^[ \t]*:PROPERTIES:[ \t]*$" end t)
+ (let ((drawer-start (point)))
+ (when (re-search-forward "^[ \t]*:END:[ \t]*$" end t)
+ (let ((drawer-end (match-beginning 0)))
+ (goto-char drawer-start)
+ ;; Extract each property
+ (while (re-search-forward "^[ \t]*:\\([^:\n]+\\):[ \t]*\\(.*\\)$" drawer-end t)
+ (let ((key (match-string 1))
+ (value (match-string 2)))
+ (push (cons key value) properties)))))))))
+ (nreverse properties)))
+
+(defun org-batch--extract-content (element)
+ "Extract body content from ELEMENT (excluding properties drawer).
+Returns the text content without the heading line or properties."
+ (let ((end (org-element-property :contents-end element))
+ (contents-begin (org-element-property :contents-begin element)))
+ (if (and contents-begin end)
+ (save-excursion
+ (let ((content-text (buffer-substring-no-properties contents-begin end)))
+ ;; Remove properties drawer if present
+ (with-temp-buffer
+ (insert content-text)
+ (goto-char (point-min))
+ ;; Remove SCHEDULED/DEADLINE lines (they're in metadata)
+ (while (re-search-forward "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\|CLOSED\\):.*$" nil t)
+ (replace-match ""))
+ ;; Remove properties drawer
+ (goto-char (point-min))
+ (when (re-search-forward "^[ \t]*:PROPERTIES:[ \t]*$" nil t)
+ (let ((drawer-start (match-beginning 0)))
+ (when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
+ (delete-region drawer-start (point))
+ ;; Remove trailing newline
+ (when (looking-at "\n")
+ (delete-char 1)))))
+ ;; Trim whitespace
+ (goto-char (point-min))
+ (while (re-search-forward "^[ \t]+$" nil t)
+ (replace-match ""))
+ (goto-char (point-min))
+ (skip-chars-forward "\n")
+ (delete-region (point-min) (point))
+ (goto-char (point-max))
+ (skip-chars-backward "\n")
+ (delete-region (point) (point-max))
+ (buffer-string))))
+ "")))
+
;;; Write Operations
(defun org-batch-update-state (file heading new-state)
@@ -319,6 +401,196 @@ Returns t on success, nil if heading not found."
(setq found t))
found)))
+(defun org-batch-add-tags (file heading new-tags)
+ "Add NEW-TAGS to task with HEADING in FILE.
+NEW-TAGS is a list of tag strings to add (existing tags are preserved).
+Returns t on success, nil if heading not found."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (goto-char (point-min))
+ (let ((found nil)
+ (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+ (regexp-quote heading))))
+ (when (re-search-forward heading-regexp nil t)
+ (org-back-to-heading)
+ (let* ((current-tags (org-get-tags))
+ (combined-tags (delete-dups (append current-tags new-tags))))
+ (org-set-tags combined-tags)
+ (write-region (point-min) (point-max) file)
+ (setq found t)))
+ found)))
+
+(defun org-batch-remove-tags (file heading tags-to-remove)
+ "Remove TAGS-TO-REMOVE from task with HEADING in FILE.
+TAGS-TO-REMOVE is a list of tag strings to remove.
+Returns t on success, nil if heading not found."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (goto-char (point-min))
+ (let ((found nil)
+ (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+ (regexp-quote heading))))
+ (when (re-search-forward heading-regexp nil t)
+ (org-back-to-heading)
+ (let* ((current-tags (org-get-tags))
+ (remaining-tags (seq-difference current-tags tags-to-remove)))
+ (org-set-tags remaining-tags)
+ (write-region (point-min) (point-max) file)
+ (setq found t)))
+ found)))
+
+(defun org-batch-replace-tags (file heading new-tags)
+ "Replace all tags on task with HEADING in FILE with NEW-TAGS.
+NEW-TAGS is a list of tag strings.
+Returns t on success, nil if heading not found."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (goto-char (point-min))
+ (let ((found nil)
+ (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+ (regexp-quote heading))))
+ (when (re-search-forward heading-regexp nil t)
+ (org-back-to-heading)
+ (org-set-tags new-tags)
+ (write-region (point-min) (point-max) file)
+ (setq found t))
+ found)))
+
+(defun org-batch-list-all-tags (file)
+ "List all unique tags used in FILE.
+Returns sorted list of tag strings."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (let ((all-tags '()))
+ (org-element-map (org-element-parse-buffer) 'headline
+ (lambda (hl)
+ (let ((tags (org-element-property :tags hl)))
+ (when tags
+ (setq all-tags (append all-tags tags))))))
+ (sort (delete-dups all-tags) #'string<))))
+
+(defun org-batch-get-overdue (file)
+ "Get all tasks with DEADLINE before today from FILE.
+Returns list of overdue tasks with their metadata."
+ (let ((today (format-time-string "%Y-%m-%d"))
+ (overdue-items '()))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (org-element-map (org-element-parse-buffer) 'headline
+ (lambda (hl)
+ (let ((todo (org-element-property :todo-keyword hl))
+ (deadline (org-element-property :deadline hl)))
+ ;; Only include active TODOs with deadlines
+ (when (and todo
+ (not (member todo '("DONE" "CANX")))
+ deadline)
+ (let ((deadline-date (org-element-property :raw-value deadline)))
+ ;; Extract YYYY-MM-DD from deadline
+ (when (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" deadline-date)
+ (let ((dl-str (match-string 0 deadline-date)))
+ ;; Compare dates (string comparison works for YYYY-MM-DD)
+ (when (string< dl-str today)
+ (push (org-batch--element-to-alist hl) overdue-items)))))))))
+ (nreverse overdue-items))))
+
+(defun org-batch-get-upcoming (file &optional days)
+ "Get tasks with DEADLINE or SCHEDULED in next DAYS from FILE.
+DAYS defaults to 7. Returns list of upcoming tasks."
+ (let* ((days-count (or days 7))
+ (today (current-time))
+ (future-date (time-add today (days-to-time days-count)))
+ (today-str (format-time-string "%Y-%m-%d" today))
+ (future-str (format-time-string "%Y-%m-%d" future-date))
+ (upcoming-items '()))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (org-element-map (org-element-parse-buffer) 'headline
+ (lambda (hl)
+ (let ((todo (org-element-property :todo-keyword hl))
+ (scheduled (org-element-property :scheduled hl))
+ (deadline (org-element-property :deadline hl)))
+ ;; Only include active TODOs
+ (when (and todo (not (member todo '("DONE" "CANX"))))
+ (let ((dates-to-check '()))
+ ;; Collect scheduled and deadline dates
+ (when scheduled
+ (push (org-element-property :raw-value scheduled) dates-to-check))
+ (when deadline
+ (push (org-element-property :raw-value deadline) dates-to-check))
+ ;; Check if any date is in range
+ (dolist (date-str dates-to-check)
+ (when (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" date-str)
+ (let ((task-date (match-string 0 date-str)))
+ ;; Date is upcoming if: today <= date <= future
+ (when (and (not (string< task-date today-str))
+ (not (string< future-str task-date)))
+ (push (org-batch--element-to-alist hl) upcoming-items)
+ ;; Stop checking other dates for this task
+ (setq dates-to-check nil))))))))))
+ ;; Remove duplicates and reverse
+ (delete-dups (nreverse upcoming-items)))))
+
+(defun org-batch-get-property (file heading property-name)
+ "Get value of PROPERTY-NAME for task with HEADING in FILE.
+Returns property value string or nil if not found."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (goto-char (point-min))
+ (let ((found nil)
+ (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+ (regexp-quote heading))))
+ (when (re-search-forward heading-regexp nil t)
+ (org-back-to-heading)
+ (setq found (org-entry-get nil property-name)))
+ found)))
+
+(defun org-batch-set-property (file heading property-name value)
+ "Set PROPERTY-NAME to VALUE for task with HEADING in FILE.
+Returns t on success, nil if heading not found."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (goto-char (point-min))
+ (let ((found nil)
+ (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+ (regexp-quote heading))))
+ (when (re-search-forward heading-regexp nil t)
+ (org-back-to-heading)
+ (org-set-property property-name value)
+ (write-region (point-min) (point-max) file)
+ (setq found t))
+ found)))
+
+(defun org-batch-list-properties (file heading)
+ "List all properties for task with HEADING in FILE.
+Returns alist of (property . value) pairs."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (goto-char (point-min))
+ (let ((properties '())
+ (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+ (regexp-quote heading))))
+ (when (re-search-forward heading-regexp nil t)
+ (org-back-to-heading)
+ ;; Get all properties using org-entry-properties
+ (let ((props (org-entry-properties nil 'standard)))
+ (dolist (prop props)
+ (let ((key (car prop))
+ (val (cdr prop)))
+ ;; Filter out special properties we don't want to show
+ (unless (member key '("CATEGORY" "BLOCKED" "ALLTAGS" "FILE" "PRIORITY_COOKIE"
+ "TODO" "TAGS" "ITEM"))
+ (push (cons key val) properties))))))
+ (nreverse properties))))
+
(defun org-batch-archive-done (file)
"Archive all DONE and CANX items in FILE.
Returns count of archived items."
dots/.config/claude/skills/Org/tools/org-manager
@@ -117,6 +117,18 @@ READ COMMANDS:
children <file> <heading>
Get direct children of a specific heading
+ get <file> <heading>
+ Get full content and metadata of a specific TODO
+ Returns heading, state, priority, tags, scheduled, deadline, properties, and body content
+
+ overdue <file>
+ Get all tasks with deadlines before today
+ Only shows active TODOs (excludes DONE/CANX)
+
+ upcoming <file> [--days=N]
+ Get tasks scheduled or due in next N days (default: 7)
+ Shows tasks with SCHEDULED or DEADLINE dates in range
+
WRITE COMMANDS:
add <file> <heading> --section=NAME [--scheduled=DATE] [--priority=N] [--tags=TAG1,TAG2]
Add new TODO item
@@ -136,6 +148,29 @@ WRITE COMMANDS:
archive <file>
Archive all DONE and CANX items
+TAG MANAGEMENT:
+ add-tags <file> <heading> <tags>
+ Add tags to existing TODO (comma-separated, e.g., work,urgent)
+
+ remove-tags <file> <heading> <tags>
+ Remove specific tags from TODO (comma-separated)
+
+ replace-tags <file> <heading> <tags>
+ Replace all tags on TODO with new set (comma-separated)
+
+ list-tags <file>
+ List all unique tags used in file
+
+PROPERTY OPERATIONS:
+ get-property <file> <heading> <property>
+ Get value of a specific property
+
+ set-property <file> <heading> <property> <value>
+ Set property value
+
+ list-properties <file> <heading>
+ List all properties of a heading
+
DENOTE COMMANDS:
denote-create <title> <tags> [--signature=SIG] [--category=CAT] [--directory=DIR] [--content=FILE]
Create a denote-formatted note with proper naming and frontmatter
@@ -183,6 +218,9 @@ EXAMPLES:
# Get children of a heading
org-manager children ~/desktop/org/todos.org "Migrate aion"
+ # Get full content of a TODO
+ org-manager get ~/desktop/org/todos.org "Review PR"
+
# Create denote note
org-manager denote-create "NixOS Refactoring Plan" "nixos,refactoring,plan" \\
--category=homelab --directory=~/desktop/org/notes
@@ -356,6 +394,65 @@ cmd_children() {
run_elisp "$elisp"
}
+cmd_get() {
+ local file="$1"
+ local heading="$2"
+
+ [[ -f "$file" ]] || error "File not found: $file"
+ [[ -n "$heading" ]] || error "Heading name required"
+
+ # Escape double quotes in heading for elisp string
+ local heading_escaped="${heading//\"/\\\"}"
+
+ local elisp="(progn
+ (let ((result (org-batch-get-todo-content \"$file\" \"$heading_escaped\")))
+ (if result
+ (org-batch-output-json t result)
+ (org-batch-output-error \"Heading not found: $heading\")))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
+cmd_overdue() {
+ local file="$1"
+
+ [[ -f "$file" ]] || error "File not found: $file"
+
+ local elisp="(progn
+ (let ((result (org-batch-get-overdue \"$file\")))
+ (org-batch-output-json t result))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
+cmd_upcoming() {
+ local file="$1"; shift
+ local days=7
+
+ [[ -f "$file" ]] || error "File not found: $file"
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --days=*)
+ days=$(parse_option "$1" "--days=")
+ shift
+ ;;
+ *)
+ error "Unknown option: $1"
+ ;;
+ esac
+ done
+
+ local elisp="(progn
+ (let ((result (org-batch-get-upcoming \"$file\" $days)))
+ (org-batch-output-json t result))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
cmd_add() {
local file="$1"
local heading="$2"; shift 2
@@ -493,6 +590,147 @@ cmd_archive() {
run_elisp "$elisp"
}
+# Tag management commands
+
+cmd_add_tags() {
+ local file="$1"
+ local heading="$2"
+ local tags="$3"
+
+ [[ -f "$file" ]] || error "File not found: $file"
+ [[ -n "$heading" ]] || error "Heading required"
+ [[ -n "$tags" ]] || error "Tags required (comma-separated)"
+
+ # Convert comma-separated tags to quoted elisp list
+ local tags_list
+ tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
+
+ local elisp="(progn
+ (let ((result (org-batch-add-tags \"$file\" \"$heading\" $tags_list)))
+ (if result
+ (org-batch-output-json t (list :tags-added t :heading \"$heading\"))
+ (org-batch-output-error \"Heading not found: $heading\")))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
+cmd_remove_tags() {
+ local file="$1"
+ local heading="$2"
+ local tags="$3"
+
+ [[ -f "$file" ]] || error "File not found: $file"
+ [[ -n "$heading" ]] || error "Heading required"
+ [[ -n "$tags" ]] || error "Tags required (comma-separated)"
+
+ # Convert comma-separated tags to quoted elisp list
+ local tags_list
+ tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
+
+ local elisp="(progn
+ (let ((result (org-batch-remove-tags \"$file\" \"$heading\" $tags_list)))
+ (if result
+ (org-batch-output-json t (list :tags-removed t :heading \"$heading\"))
+ (org-batch-output-error \"Heading not found: $heading\")))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
+cmd_replace_tags() {
+ local file="$1"
+ local heading="$2"
+ local tags="$3"
+
+ [[ -f "$file" ]] || error "File not found: $file"
+ [[ -n "$heading" ]] || error "Heading required"
+ [[ -n "$tags" ]] || error "Tags required (comma-separated)"
+
+ # Convert comma-separated tags to quoted elisp list
+ local tags_list
+ tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
+
+ local elisp="(progn
+ (let ((result (org-batch-replace-tags \"$file\" \"$heading\" $tags_list)))
+ (if result
+ (org-batch-output-json t (list :tags-replaced t :heading \"$heading\"))
+ (org-batch-output-error \"Heading not found: $heading\")))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
+cmd_list_tags() {
+ local file="$1"
+
+ [[ -f "$file" ]] || error "File not found: $file"
+
+ local elisp="(progn
+ (let ((tags (org-batch-list-all-tags \"$file\")))
+ (org-batch-output-json t tags))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
+# Property operations commands
+
+cmd_get_property() {
+ local file="$1"
+ local heading="$2"
+ local property="$3"
+
+ [[ -f "$file" ]] || error "File not found: $file"
+ [[ -n "$heading" ]] || error "Heading required"
+ [[ -n "$property" ]] || error "Property name required"
+
+ local elisp="(progn
+ (let ((value (org-batch-get-property \"$file\" \"$heading\" \"$property\")))
+ (if value
+ (org-batch-output-json t (list :property \"$property\" :value value))
+ (org-batch-output-error \"Property not found or heading not found\")))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
+cmd_set_property() {
+ local file="$1"
+ local heading="$2"
+ local property="$3"
+ local value="$4"
+
+ [[ -f "$file" ]] || error "File not found: $file"
+ [[ -n "$heading" ]] || error "Heading required"
+ [[ -n "$property" ]] || error "Property name required"
+ [[ -n "$value" ]] || error "Property value required"
+
+ local elisp="(progn
+ (let ((result (org-batch-set-property \"$file\" \"$heading\" \"$property\" \"$value\")))
+ (if result
+ (org-batch-output-json t (list :property-set t :property \"$property\" :value \"$value\"))
+ (org-batch-output-error \"Heading not found: $heading\")))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
+cmd_list_properties() {
+ local file="$1"
+ local heading="$2"
+
+ [[ -f "$file" ]] || error "File not found: $file"
+ [[ -n "$heading" ]] || error "Heading required"
+
+ local elisp="(progn
+ (let ((props (org-batch-list-properties \"$file\" \"$heading\")))
+ (org-batch-output-json t props))
+ (kill-emacs 0))"
+
+ run_elisp "$elisp"
+}
+
# Denote commands
cmd_denote_create() {
@@ -670,6 +908,15 @@ main() {
children)
cmd_children "$@"
;;
+ get)
+ cmd_get "$@"
+ ;;
+ overdue)
+ cmd_overdue "$@"
+ ;;
+ upcoming)
+ cmd_upcoming "$@"
+ ;;
add)
cmd_add "$@"
;;
@@ -688,6 +935,27 @@ main() {
archive)
cmd_archive "$@"
;;
+ add-tags)
+ cmd_add_tags "$@"
+ ;;
+ remove-tags)
+ cmd_remove_tags "$@"
+ ;;
+ replace-tags)
+ cmd_replace_tags "$@"
+ ;;
+ list-tags)
+ cmd_list_tags "$@"
+ ;;
+ get-property)
+ cmd_get_property "$@"
+ ;;
+ set-property)
+ cmd_set_property "$@"
+ ;;
+ list-properties)
+ cmd_list_properties "$@"
+ ;;
denote-create)
cmd_denote_create "$@"
;;
dots/.config/claude/skills/Org/SKILL.md
@@ -44,6 +44,42 @@ Provide reliable, programmatic access to org-mode files using Emacs batch mode a
# Search
./tools/org-manager search ~/desktop/org/todos.org "term"
+
+# Get full TODO content (metadata + body)
+./tools/org-manager get ~/desktop/org/todos.org "Task name"
+
+# Get overdue tasks (deadline before today)
+./tools/org-manager overdue ~/desktop/org/todos.org
+
+# Get upcoming tasks (scheduled/deadline in next N days)
+./tools/org-manager upcoming ~/desktop/org/todos.org --days=7
+```
+
+#### Tag Management
+```bash
+# List all unique tags in file
+./tools/org-manager list-tags ~/desktop/org/todos.org
+
+# Add tags to existing TODO
+./tools/org-manager add-tags ~/desktop/org/todos.org "Task name" "urgent,review"
+
+# Remove specific tags
+./tools/org-manager remove-tags ~/desktop/org/todos.org "Task name" "urgent"
+
+# Replace all tags with new set
+./tools/org-manager replace-tags ~/desktop/org/todos.org "Task name" "done,archived"
+```
+
+#### Property Operations
+```bash
+# List all properties of a heading
+./tools/org-manager list-properties ~/desktop/org/todos.org "Task name"
+
+# Get specific property value
+./tools/org-manager get-property ~/desktop/org/todos.org "Task name" "PR_URL"
+
+# Set property value
+./tools/org-manager set-property ~/desktop/org/todos.org "Task name" "STATUS" "In Progress"
```
#### Denote Operations
@@ -99,6 +135,11 @@ All commands return JSON:
- `org-batch-by-section` - Filter by section
- `org-batch-count-by-state` - Count statistics
- `org-batch-search` - Full-text search
+- `org-batch-get-children` - Get direct children of a heading
+- `org-batch-get-sections` - List all top-level sections
+- `org-batch-get-todo-content` - Get full TODO content (metadata + body + properties)
+- `org-batch-get-overdue` - Get tasks with deadline before today
+- `org-batch-get-upcoming` - Get tasks scheduled/due in next N days
- `org-batch-add-todo` - Add new TODO
- `org-batch-update-state` - Change states
- `org-batch-schedule-task` - Set SCHEDULED
@@ -106,6 +147,17 @@ All commands return JSON:
- `org-batch-set-priority` - Set priority
- `org-batch-archive-done` - Archive items
+**Tag Operations:**
+- `org-batch-add-tags` - Add tags while preserving existing
+- `org-batch-remove-tags` - Remove specific tags
+- `org-batch-replace-tags` - Replace all tags with new set
+- `org-batch-list-all-tags` - Get all unique tags in file
+
+**Property Operations:**
+- `org-batch-get-property` - Get specific property value
+- `org-batch-set-property` - Set property value
+- `org-batch-list-properties` - List all properties of a heading
+
### Denote Functions (denote-batch-functions.el)
**Note Creation and Management:**