fedora-csb-system-manager
   1#!/usr/bin/env bash
   2# org-manager - CLI tool for org-mode file manipulation via Emacs batch mode
   3# Copyright (C) 2026 Vincent Demeester
   4# Loads elisp from site-lisp for consistency with interactive Emacs
   5
   6set -euo pipefail
   7
   8# Configuration
   9EMACS="${EMACS:-emacs}"
  10EMACS_DIR="${EMACS_DIR:-$HOME/.config/emacs}"
  11SITE_LISP="$EMACS_DIR/site-lisp"
  12
  13# Debug mode
  14DEBUG="${DEBUG:-0}"
  15
  16# Colors for output (if not outputting JSON)
  17if [[ -t 1 ]] && [[ "${JSON_OUTPUT:-1}" != "1" ]]; then
  18    RED='\033[0;31m'
  19    YELLOW='\033[1;33m'
  20    NC='\033[0m' # No Color
  21else
  22    RED=''
  23    YELLOW=''
  24    NC=''
  25fi
  26
  27# Error handling
  28error() {
  29    echo -e "${RED}Error: $*${NC}" >&2
  30    exit 1
  31}
  32
  33debug() {
  34    if [[ "$DEBUG" == "1" ]]; then
  35        echo -e "${YELLOW}Debug: $*${NC}" >&2
  36    fi
  37}
  38
  39# Check dependencies
  40check_deps() {
  41    if ! command -v "$EMACS" &> /dev/null; then
  42        error "Emacs not found. Set EMACS environment variable or install emacs."
  43    fi
  44
  45    if [[ ! -d "$SITE_LISP" ]]; then
  46        error "Emacs site-lisp directory not found at: $SITE_LISP"
  47    fi
  48
  49    if [[ ! -f "$SITE_LISP/org-batch-functions.el" ]]; then
  50        error "org-batch-functions.el not found in site-lisp"
  51    fi
  52}
  53
  54# Run Emacs in batch mode
  55run_elisp() {
  56    local elisp_code="$1"
  57
  58    debug "Running elisp: $elisp_code"
  59
  60    if [[ "$DEBUG" == "1" ]]; then
  61        "$EMACS" --batch --no-init-file \
  62            --directory "$SITE_LISP" \
  63            --load org-batch-functions.el \
  64            --eval "$elisp_code" 2>&1
  65    else
  66        "$EMACS" --batch --no-init-file \
  67            --directory "$SITE_LISP" \
  68            --load org-batch-functions.el \
  69            --eval "$elisp_code" 2>/dev/null
  70    fi
  71}
  72
  73# Run Emacs in batch mode with denote functions
  74run_denote_elisp() {
  75    local elisp_code="$1"
  76
  77    debug "Running denote elisp: $elisp_code"
  78
  79    if [[ "$DEBUG" == "1" ]]; then
  80        "$EMACS" --batch --no-init-file \
  81            --directory "$SITE_LISP" \
  82            --load denote-batch-functions.el \
  83            --eval "$elisp_code" 2>&1
  84    else
  85        "$EMACS" --batch --no-init-file \
  86            --directory "$SITE_LISP" \
  87            --load denote-batch-functions.el \
  88            --eval "$elisp_code" 2>/dev/null
  89    fi
  90}
  91
  92# Usage information
  93usage() {
  94    cat <<EOF
  95org-manager - Org-mode file manipulation tool
  96
  97Usage: org-manager <command> <file> [options]
  98
  99READ COMMANDS:
 100  list <file> [--state=STATE] [--priority=N] [--tags=TAG1,TAG2]
 101      List TODO items with optional filters
 102
 103  scheduled <file> [--date=YYYY-MM-DD|today]
 104      Get items scheduled for date (default: today)
 105
 106  count <file> [--state=STATE]
 107      Count TODO items by state
 108
 109  search <file> <term>
 110      Search for term in file
 111
 112  by-section <file> <section>
 113      Get TODOs in specific section
 114
 115  sections <file>
 116      List all top-level sections
 117
 118  children <file> <heading>
 119      Get direct children of a specific heading
 120
 121  get <file> <heading>
 122      Get full content and metadata of a specific TODO
 123      Returns heading, state, priority, tags, scheduled, deadline, properties, and body content
 124
 125  overdue <file>
 126      Get all tasks with deadlines before today
 127      Only shows active TODOs (excludes DONE/CANX)
 128
 129  upcoming <file> [--days=N]
 130      Get tasks scheduled or due in next N days (default: 7)
 131      Shows tasks with SCHEDULED or DEADLINE dates in range
 132
 133WRITE COMMANDS:
 134  add <file> <heading> --section=NAME [--scheduled=DATE] [--priority=N] [--tags=TAG1,TAG2]
 135      Add new TODO item
 136
 137  append-content <file> <heading> <content-file>
 138      Append content from file to existing TODO
 139      Content should be in org-mode format (not markdown)
 140      Adds content after properties/scheduling, before subheadings
 141      Automatically adjusts heading levels (# or * → relative to parent)
 142
 143  update-state <file> <heading> <new-state>
 144      Change TODO state (NEXT, STRT, TODO, WAIT, DONE, CANX)
 145
 146  schedule <file> <heading> <date>
 147      Set SCHEDULED date (YYYY-MM-DD)
 148
 149  deadline <file> <heading> <date>
 150      Set DEADLINE date (YYYY-MM-DD)
 151
 152  priority <file> <heading> <priority>
 153      Set priority (1-5)
 154
 155  archive <file>
 156      Archive all DONE and CANX items
 157
 158TAG MANAGEMENT:
 159  add-tags <file> <heading> <tags>
 160      Add tags to existing TODO (comma-separated, e.g., work,urgent)
 161
 162  remove-tags <file> <heading> <tags>
 163      Remove specific tags from TODO (comma-separated)
 164
 165  replace-tags <file> <heading> <tags>
 166      Replace all tags on TODO with new set (comma-separated)
 167
 168  list-tags <file>
 169      List all unique tags used in file
 170
 171PROPERTY OPERATIONS:
 172  get-property <file> <heading> <property>
 173      Get value of a specific property
 174
 175  set-property <file> <heading> <property> <value>
 176      Set property value
 177
 178  list-properties <file> <heading>
 179      List all properties of a heading
 180
 181BULK OPERATIONS:
 182  bulk-update-state <file> <filter-state> <new-state> [filter-tags]
 183      Update all tasks matching filter-state to new-state
 184      Optional: filter by tags (comma-separated)
 185      Example: org-manager bulk-update-state todos.org TODO DONE work,urgent
 186
 187  bulk-add-tags <file> <filter-state> <tags>
 188      Add tags to all tasks with filter-state
 189      Tags: comma-separated list
 190      Example: org-manager bulk-add-tags todos.org NEXT urgent,review
 191
 192  bulk-set-priority <file> <filter-state> <priority>
 193      Set priority for all tasks with filter-state
 194      Priority: 1-5 (1=highest, 5=lowest)
 195      Example: org-manager bulk-set-priority todos.org TODO 1
 196
 197TIME TRACKING:
 198  clock-in <file> <heading>
 199      Start time tracking on a task
 200      Creates CLOCK entry in :LOGBOOK: drawer
 201
 202  clock-out <file>
 203      Stop time tracking on currently clocked task
 204      Records total time spent
 205
 206  get-active-clock <file>
 207      Get currently clocked task (if any)
 208      Returns heading and clock-in time
 209
 210  get-clocked-time <file> <heading>
 211      Get total time spent on a task (in minutes)
 212      Sums all CLOCK entries for the task
 213
 214STATISTICS & ANALYTICS:
 215  get-statistics <file>
 216      Get comprehensive statistics about all TODOs
 217      Returns counts by state, priority, tags, scheduled, overdue
 218
 219  get-priority-distribution <file>
 220      Get distribution of tasks across priorities (1-5)
 221      Shows how many tasks at each priority level
 222
 223  get-tag-statistics <file>
 224      Get tag usage statistics
 225      Returns list of tags with usage counts, sorted by frequency
 226
 227EXPORT & REPORTING:
 228  export-csv <file> <output-file>
 229      Export all TODOs to CSV format
 230      Creates spreadsheet-compatible file
 231
 232  export-json <file> <output-file>
 233      Export all TODOs to JSON format
 234      Creates machine-readable structured export
 235
 236RECURRING TASKS:
 237  set-repeater <file> <heading> <repeater-spec>
 238      Set repeater for a task (e.g., +1w for weekly, .+2d for 2 days after completion)
 239      Adds or updates SCHEDULED timestamp with repeater syntax
 240
 241  get-recurring-tasks <file>
 242      List all tasks with repeater specifications
 243      Shows tasks that recur automatically
 244
 245DEPENDENCIES & RELATIONSHIPS:
 246  set-blocker <file> <heading> <blocker-heading>
 247      Set a blocker for a task (task that must be completed first)
 248      Uses BLOCKER property to track dependency
 249
 250  get-blocker <file> <heading>
 251      Get the blocker for a specific task
 252      Returns the blocking task heading if set
 253
 254  get-blocked-tasks <file>
 255      List all tasks that have blockers set
 256      Shows tasks waiting on other tasks
 257
 258  set-related <file> <heading> <related-heading> <relation-type>
 259      Create relationship between tasks
 260      Relation types: child, parent, related, depends-on
 261      Uses RELATED_* properties
 262
 263  get-related <file> <heading>
 264      Get all relationships for a task
 265      Returns all RELATED_* properties
 266
 267DENOTE COMMANDS:
 268  denote-create <title> <tags> [--signature=SIG] [--category=CAT] [--directory=DIR] [--content=FILE]
 269      Create a denote-formatted note with proper naming and frontmatter
 270      Tags: comma-separated list (e.g., nixos,homelab,plan)
 271
 272  denote-append <filepath> <content-file>
 273      Append content to existing denote note
 274
 275  denote-metadata <filepath>
 276      Read metadata from denote note
 277
 278  denote-update <filepath> [--title=TITLE] [--tags=TAGS] [--category=CAT]
 279      Update denote note frontmatter
 280
 281OPTIONS:
 282  --state=STATE         Filter by TODO state
 283  --priority=N          Filter by priority (1-5) or list: 1,2
 284  --tags=TAG1,TAG2      Filter by tags (match any)
 285  --date=YYYY-MM-DD     Specific date (or 'today')
 286  --section=NAME        Section name for new items
 287  --scheduled=DATE      Schedule date for new items
 288
 289ENVIRONMENT:
 290  EMACS                 Path to emacs binary (default: emacs)
 291  BATCH_FUNCTIONS       Path to batch-functions.el
 292  DEBUG                 Enable debug output (1 or 0)
 293
 294EXAMPLES:
 295  # List NEXT tasks
 296  org-manager list ~/desktop/org/todos.org --state=NEXT
 297
 298  # Add high-priority task
 299  org-manager add ~/desktop/org/todos.org "Review PR" \\
 300    --section=Work --priority=2 --scheduled=2025-12-10
 301
 302  # Mark task done
 303  org-manager update-state ~/desktop/org/todos.org "Review PR" DONE
 304
 305  # Get today's schedule
 306  org-manager scheduled ~/desktop/org/todos.org
 307
 308  # Count by state
 309  org-manager count ~/desktop/org/todos.org
 310
 311  # Get children of a heading
 312  org-manager children ~/desktop/org/todos.org "Migrate aion"
 313
 314  # Get full content of a TODO
 315  org-manager get ~/desktop/org/todos.org "Review PR"
 316
 317  # Create denote note
 318  org-manager denote-create "NixOS Refactoring Plan" "nixos,refactoring,plan" \\
 319    --category=homelab --directory=~/desktop/org/notes
 320
 321  # Create automated note with signature
 322  org-manager denote-create "Session Summary" "history,session" \\
 323    --signature=pkai --category=history
 324
 325  # Read note metadata
 326  org-manager denote-metadata ~/desktop/org/notes/20251205T*.org
 327
 328OUTPUT:
 329  All commands return JSON for easy parsing:
 330  {"success": true, "data": [...]}
 331  {"success": false, "error": "message"}
 332
 333EXIT CODES:
 334  0    Success
 335  1    General error
 336  2    File not found
 337  3    Invalid arguments
 338
 339EOF
 340    exit 0
 341}
 342
 343# Parse arguments helper
 344parse_option() {
 345    local arg="$1"
 346    local prefix="$2"
 347    echo "${arg#"$prefix"}"
 348}
 349
 350# Commands
 351
 352cmd_list() {
 353    local file="$1"; shift
 354    local state="" priority="" tags=""
 355
 356    while [[ $# -gt 0 ]]; do
 357        case "$1" in
 358            --state=*)
 359                state=$(parse_option "$1" "--state=")
 360                shift
 361                ;;
 362            --priority=*)
 363                priority=$(parse_option "$1" "--priority=")
 364                shift
 365                ;;
 366            --tags=*)
 367                tags=$(parse_option "$1" "--tags=")
 368                shift
 369                ;;
 370            *)
 371                error "Unknown option: $1"
 372                ;;
 373        esac
 374    done
 375
 376    [[ -f "$file" ]] || error "File not found: $file"
 377
 378    local elisp
 379    elisp="(progn
 380      (let ((result (org-batch-list-todos \"$file\"
 381                      $([ -n "$state" ] && echo "\"$state\"" || echo "nil")
 382                      $([ -n "$priority" ] && echo "'($priority)" || echo "nil")
 383                      $([ -n "$tags" ] && echo "'(${tags//,/\" \"})" || echo "nil"))))
 384        (org-batch-output-json t result))
 385      (kill-emacs 0))"
 386
 387    run_elisp "$elisp"
 388}
 389
 390cmd_scheduled() {
 391    local file="$1"; shift
 392    local date="today"
 393
 394    while [[ $# -gt 0 ]]; do
 395        case "$1" in
 396            --date=*)
 397                date=$(parse_option "$1" "--date=")
 398                shift
 399                ;;
 400            *)
 401                error "Unknown option: $1"
 402                ;;
 403        esac
 404    done
 405
 406    [[ -f "$file" ]] || error "File not found: $file"
 407
 408    local elisp="(progn
 409      (let ((result (org-batch-scheduled-today \"$file\" \"$date\")))
 410        (org-batch-output-json t result))
 411      (kill-emacs 0))"
 412
 413    run_elisp "$elisp"
 414}
 415
 416cmd_count() {
 417    local file="$1"; shift
 418
 419    [[ -f "$file" ]] || error "File not found: $file"
 420
 421    local elisp="(progn
 422      (let ((result (org-batch-count-by-state \"$file\")))
 423        (org-batch-output-json t result))
 424      (kill-emacs 0))"
 425
 426    run_elisp "$elisp"
 427}
 428
 429cmd_search() {
 430    local file="$1"
 431    local term="$2"
 432
 433    [[ -f "$file" ]] || error "File not found: $file"
 434    [[ -n "$term" ]] || error "Search term required"
 435
 436    local elisp="(progn
 437      (let ((result (org-batch-search \"$file\" \"$term\")))
 438        (org-batch-output-json t result))
 439      (kill-emacs 0))"
 440
 441    run_elisp "$elisp"
 442}
 443
 444cmd_by_section() {
 445    local file="$1"
 446    local section="$2"
 447
 448    [[ -f "$file" ]] || error "File not found: $file"
 449    [[ -n "$section" ]] || error "Section name required"
 450
 451    local elisp="(progn
 452      (let ((result (org-batch-by-section \"$file\" \"$section\")))
 453        (org-batch-output-json t result))
 454      (kill-emacs 0))"
 455
 456    run_elisp "$elisp"
 457}
 458
 459cmd_sections() {
 460    local file="$1"
 461
 462    [[ -f "$file" ]] || error "File not found: $file"
 463
 464    local elisp="(progn
 465      (let ((result (org-batch-get-sections \"$file\")))
 466        (org-batch-output-json t result))
 467      (kill-emacs 0))"
 468
 469    run_elisp "$elisp"
 470}
 471
 472cmd_children() {
 473    local file="$1"
 474    local heading="$2"
 475
 476    [[ -f "$file" ]] || error "File not found: $file"
 477    [[ -n "$heading" ]] || error "Heading name required"
 478
 479    # Escape double quotes in heading for elisp string
 480    local heading_escaped="${heading//\"/\\\"}"
 481
 482    local elisp="(progn
 483      (let ((result (org-batch-get-children \"$file\" \"$heading_escaped\")))
 484        (org-batch-output-json t result))
 485      (kill-emacs 0))"
 486
 487    run_elisp "$elisp"
 488}
 489
 490cmd_get() {
 491    local file="$1"
 492    local heading="$2"
 493
 494    [[ -f "$file" ]] || error "File not found: $file"
 495    [[ -n "$heading" ]] || error "Heading name required"
 496
 497    # Escape double quotes in heading for elisp string
 498    local heading_escaped="${heading//\"/\\\"}"
 499
 500    local elisp="(progn
 501      (let ((result (org-batch-get-todo-content \"$file\" \"$heading_escaped\")))
 502        (if result
 503            (org-batch-output-json t result)
 504          (org-batch-output-error \"Heading not found: $heading\")))
 505      (kill-emacs 0))"
 506
 507    run_elisp "$elisp"
 508}
 509
 510cmd_overdue() {
 511    local file="$1"
 512
 513    [[ -f "$file" ]] || error "File not found: $file"
 514
 515    local elisp="(progn
 516      (let ((result (org-batch-get-overdue \"$file\")))
 517        (org-batch-output-json t result))
 518      (kill-emacs 0))"
 519
 520    run_elisp "$elisp"
 521}
 522
 523cmd_upcoming() {
 524    local file="$1"; shift
 525    local days=7
 526
 527    [[ -f "$file" ]] || error "File not found: $file"
 528
 529    while [[ $# -gt 0 ]]; do
 530        case "$1" in
 531            --days=*)
 532                days=$(parse_option "$1" "--days=")
 533                shift
 534                ;;
 535            *)
 536                error "Unknown option: $1"
 537                ;;
 538        esac
 539    done
 540
 541    local elisp="(progn
 542      (let ((result (org-batch-get-upcoming \"$file\" $days)))
 543        (org-batch-output-json t result))
 544      (kill-emacs 0))"
 545
 546    run_elisp "$elisp"
 547}
 548
 549cmd_add() {
 550    local file="$1"
 551    local heading="$2"; shift 2
 552    local section="" scheduled="" priority="" tags=""
 553
 554    [[ -f "$file" ]] || error "File not found: $file"
 555    [[ -n "$heading" ]] || error "Heading required"
 556
 557    while [[ $# -gt 0 ]]; do
 558        case "$1" in
 559            --section=*)
 560                section=$(parse_option "$1" "--section=")
 561                shift
 562                ;;
 563            --scheduled=*)
 564                scheduled=$(parse_option "$1" "--scheduled=")
 565                shift
 566                ;;
 567            --priority=*)
 568                priority=$(parse_option "$1" "--priority=")
 569                shift
 570                ;;
 571            --tags=*)
 572                tags=$(parse_option "$1" "--tags=")
 573                shift
 574                ;;
 575            *)
 576                error "Unknown option: $1"
 577                ;;
 578        esac
 579    done
 580
 581    [[ -n "$section" ]] || error "--section required"
 582
 583    local elisp
 584    elisp="(progn
 585      (let ((result (org-batch-add-todo \"$file\" \"$section\" \"$heading\"
 586                      $([ -n "$scheduled" ] && echo "\"$scheduled\"" || echo "nil")
 587                      $([ -n "$priority" ] && echo "$priority" || echo "nil")
 588                      $([ -n "$tags" ] && echo "'(${tags//,/\" \"})" || echo "nil"))))
 589        (if result
 590            (org-batch-output-json t (list :added t :heading \"$heading\"))
 591          (org-batch-output-error \"Section not found: $section\")))
 592      (kill-emacs 0))"
 593
 594    run_elisp "$elisp"
 595}
 596
 597cmd_append_content() {
 598    local file="$1"
 599    local heading="$2"
 600    local content_file="$3"
 601
 602    [[ -f "$file" ]] || error "File not found: $file"
 603    [[ -n "$heading" ]] || error "Heading required"
 604    [[ -f "$content_file" ]] || error "Content file not found: $content_file"
 605
 606    # Read content from file
 607    local content
 608    content=$(<"$content_file")
 609
 610    # Escape quotes and backslashes for elisp
 611    content="${content//\\/\\\\}"
 612    content="${content//\"/\\\"}"
 613
 614    local elisp="(progn
 615      (let ((result (org-batch-append-content \"$file\" \"$heading\" \"$content\")))
 616        (if result
 617            (org-batch-output-json t (list :content-appended t :heading \"$heading\"))
 618          (org-batch-output-error \"Heading not found: $heading\")))
 619      (kill-emacs 0))"
 620
 621    run_elisp "$elisp"
 622}
 623
 624cmd_update_state() {
 625    local file="$1"
 626    local heading="$2"
 627    local new_state="$3"
 628
 629    [[ -f "$file" ]] || error "File not found: $file"
 630    [[ -n "$heading" ]] || error "Heading required"
 631    [[ -n "$new_state" ]] || error "New state required"
 632
 633    local elisp="(progn
 634      (let ((result (org-batch-update-state \"$file\" \"$heading\" \"$new_state\")))
 635        (if result
 636            (org-batch-output-json t (list :updated t :heading \"$heading\" :state \"$new_state\"))
 637          (org-batch-output-error \"Heading not found: $heading\")))
 638      (kill-emacs 0))"
 639
 640    run_elisp "$elisp"
 641}
 642
 643cmd_schedule() {
 644    local file="$1"
 645    local heading="$2"
 646    local date="$3"
 647
 648    [[ -f "$file" ]] || error "File not found: $file"
 649    [[ -n "$heading" ]] || error "Heading required"
 650    [[ -n "$date" ]] || error "Date required (YYYY-MM-DD)"
 651
 652    local elisp="(progn
 653      (let ((result (org-batch-schedule-task \"$file\" \"$heading\" \"$date\")))
 654        (if result
 655            (org-batch-output-json t (list :scheduled t :heading \"$heading\" :date \"$date\"))
 656          (org-batch-output-error \"Heading not found: $heading\")))
 657      (kill-emacs 0))"
 658
 659    run_elisp "$elisp"
 660}
 661
 662cmd_deadline() {
 663    local file="$1"
 664    local heading="$2"
 665    local date="$3"
 666
 667    [[ -f "$file" ]] || error "File not found: $file"
 668    [[ -n "$heading" ]] || error "Heading required"
 669    [[ -n "$date" ]] || error "Date required (YYYY-MM-DD)"
 670
 671    local elisp="(progn
 672      (let ((result (org-batch-set-deadline \"$file\" \"$heading\" \"$date\")))
 673        (if result
 674            (org-batch-output-json t (list :deadline t :heading \"$heading\" :date \"$date\"))
 675          (org-batch-output-error \"Heading not found: $heading\")))
 676      (kill-emacs 0))"
 677
 678    run_elisp "$elisp"
 679}
 680
 681cmd_priority() {
 682    local file="$1"
 683    local heading="$2"
 684    local priority="$3"
 685
 686    [[ -f "$file" ]] || error "File not found: $file"
 687    [[ -n "$heading" ]] || error "Heading required"
 688    [[ -n "$priority" ]] || error "Priority required (1-5)"
 689
 690    local elisp="(progn
 691      (let ((result (org-batch-set-priority \"$file\" \"$heading\" $priority)))
 692        (if result
 693            (org-batch-output-json t (list :priority t :heading \"$heading\" :value $priority))
 694          (org-batch-output-error \"Heading not found: $heading\")))
 695      (kill-emacs 0))"
 696
 697    run_elisp "$elisp"
 698}
 699
 700cmd_archive() {
 701    local file="$1"
 702
 703    [[ -f "$file" ]] || error "File not found: $file"
 704
 705    local elisp="(progn
 706      (let ((count (org-batch-archive-done \"$file\")))
 707        (org-batch-output-json t (list :archived count)))
 708      (kill-emacs 0))"
 709
 710    run_elisp "$elisp"
 711}
 712
 713# Tag management commands
 714
 715cmd_add_tags() {
 716    local file="$1"
 717    local heading="$2"
 718    local tags="$3"
 719
 720    [[ -f "$file" ]] || error "File not found: $file"
 721    [[ -n "$heading" ]] || error "Heading required"
 722    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
 723
 724    # Convert comma-separated tags to quoted elisp list
 725    local tags_list
 726    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 727
 728    local elisp="(progn
 729      (let ((result (org-batch-add-tags \"$file\" \"$heading\" $tags_list)))
 730        (if result
 731            (org-batch-output-json t (list :tags-added t :heading \"$heading\"))
 732          (org-batch-output-error \"Heading not found: $heading\")))
 733      (kill-emacs 0))"
 734
 735    run_elisp "$elisp"
 736}
 737
 738cmd_remove_tags() {
 739    local file="$1"
 740    local heading="$2"
 741    local tags="$3"
 742
 743    [[ -f "$file" ]] || error "File not found: $file"
 744    [[ -n "$heading" ]] || error "Heading required"
 745    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
 746
 747    # Convert comma-separated tags to quoted elisp list
 748    local tags_list
 749    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 750
 751    local elisp="(progn
 752      (let ((result (org-batch-remove-tags \"$file\" \"$heading\" $tags_list)))
 753        (if result
 754            (org-batch-output-json t (list :tags-removed t :heading \"$heading\"))
 755          (org-batch-output-error \"Heading not found: $heading\")))
 756      (kill-emacs 0))"
 757
 758    run_elisp "$elisp"
 759}
 760
 761cmd_replace_tags() {
 762    local file="$1"
 763    local heading="$2"
 764    local tags="$3"
 765
 766    [[ -f "$file" ]] || error "File not found: $file"
 767    [[ -n "$heading" ]] || error "Heading required"
 768    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
 769
 770    # Convert comma-separated tags to quoted elisp list
 771    local tags_list
 772    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 773
 774    local elisp="(progn
 775      (let ((result (org-batch-replace-tags \"$file\" \"$heading\" $tags_list)))
 776        (if result
 777            (org-batch-output-json t (list :tags-replaced t :heading \"$heading\"))
 778          (org-batch-output-error \"Heading not found: $heading\")))
 779      (kill-emacs 0))"
 780
 781    run_elisp "$elisp"
 782}
 783
 784cmd_list_tags() {
 785    local file="$1"
 786
 787    [[ -f "$file" ]] || error "File not found: $file"
 788
 789    local elisp="(progn
 790      (let ((tags (org-batch-list-all-tags \"$file\")))
 791        (org-batch-output-json t tags))
 792      (kill-emacs 0))"
 793
 794    run_elisp "$elisp"
 795}
 796
 797# Property operations commands
 798
 799cmd_get_property() {
 800    local file="$1"
 801    local heading="$2"
 802    local property="$3"
 803
 804    [[ -f "$file" ]] || error "File not found: $file"
 805    [[ -n "$heading" ]] || error "Heading required"
 806    [[ -n "$property" ]] || error "Property name required"
 807
 808    local elisp="(progn
 809      (let ((value (org-batch-get-property \"$file\" \"$heading\" \"$property\")))
 810        (if value
 811            (org-batch-output-json t (list :property \"$property\" :value value))
 812          (org-batch-output-error \"Property not found or heading not found\")))
 813      (kill-emacs 0))"
 814
 815    run_elisp "$elisp"
 816}
 817
 818cmd_set_property() {
 819    local file="$1"
 820    local heading="$2"
 821    local property="$3"
 822    local value="$4"
 823
 824    [[ -f "$file" ]] || error "File not found: $file"
 825    [[ -n "$heading" ]] || error "Heading required"
 826    [[ -n "$property" ]] || error "Property name required"
 827    [[ -n "$value" ]] || error "Property value required"
 828
 829    local elisp="(progn
 830      (let ((result (org-batch-set-property \"$file\" \"$heading\" \"$property\" \"$value\")))
 831        (if result
 832            (org-batch-output-json t (list :property-set t :property \"$property\" :value \"$value\"))
 833          (org-batch-output-error \"Heading not found: $heading\")))
 834      (kill-emacs 0))"
 835
 836    run_elisp "$elisp"
 837}
 838
 839cmd_list_properties() {
 840    local file="$1"
 841    local heading="$2"
 842
 843    [[ -f "$file" ]] || error "File not found: $file"
 844    [[ -n "$heading" ]] || error "Heading required"
 845
 846    local elisp="(progn
 847      (let ((props (org-batch-list-properties \"$file\" \"$heading\")))
 848        (org-batch-output-json t props))
 849      (kill-emacs 0))"
 850
 851    run_elisp "$elisp"
 852}
 853
 854# Bulk operations
 855
 856cmd_bulk_update_state() {
 857    local file="$1"
 858    local filter_state="$2"
 859    local new_state="$3"
 860    local filter_tags="$4"
 861
 862    [[ -f "$file" ]] || error "File not found: $file"
 863    [[ -n "$filter_state" ]] || error "Filter state required (TODO, NEXT, etc.)"
 864    [[ -n "$new_state" ]] || error "New state required (DONE, NEXT, etc.)"
 865
 866    local tags_list="nil"
 867    if [[ -n "$filter_tags" ]]; then
 868        tags_list="'($(echo "$filter_tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 869    fi
 870
 871    local elisp="(progn
 872      (let ((count (org-batch-bulk-update-state \"$file\" \"$filter_state\" \"$new_state\" $tags_list)))
 873        (org-batch-output-json t (list :updated count :filter_state \"$filter_state\" :new_state \"$new_state\")))
 874      (kill-emacs 0))"
 875
 876    run_elisp "$elisp"
 877}
 878
 879cmd_bulk_add_tags() {
 880    local file="$1"
 881    local filter_state="$2"
 882    local tags="$3"
 883
 884    [[ -f "$file" ]] || error "File not found: $file"
 885    [[ -n "$filter_state" ]] || error "Filter state required (TODO, NEXT, etc.)"
 886    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
 887
 888    local tags_list
 889    tags_list="'($(echo "$tags" | sed 's/,/ /g' | sed 's/\([^ ]*\)/"\1"/g'))"
 890
 891    local elisp="(progn
 892      (let ((count (org-batch-bulk-add-tags \"$file\" \"$filter_state\" $tags_list)))
 893        (org-batch-output-json t (list :updated count :filter_state \"$filter_state\" :tags_added t)))
 894      (kill-emacs 0))"
 895
 896    run_elisp "$elisp"
 897}
 898
 899cmd_bulk_set_priority() {
 900    local file="$1"
 901    local filter_state="$2"
 902    local priority="$3"
 903
 904    [[ -f "$file" ]] || error "File not found: $file"
 905    [[ -n "$filter_state" ]] || error "Filter state required (TODO, NEXT, etc.)"
 906    [[ -n "$priority" ]] || error "Priority required (1-5)"
 907
 908    [[ "$priority" =~ ^[1-5]$ ]] || error "Priority must be 1-5"
 909
 910    local elisp="(progn
 911      (let ((count (org-batch-bulk-set-priority \"$file\" \"$filter_state\" $priority)))
 912        (org-batch-output-json t (list :updated count :filter_state \"$filter_state\" :priority $priority)))
 913      (kill-emacs 0))"
 914
 915    run_elisp "$elisp"
 916}
 917
 918# Time tracking commands
 919
 920cmd_clock_in() {
 921    local file="$1"
 922    local heading="$2"
 923
 924    [[ -f "$file" ]] || error "File not found: $file"
 925    [[ -n "$heading" ]] || error "Heading required"
 926
 927    local elisp="(progn
 928      (let ((result (org-batch-clock-in \"$file\" \"$heading\")))
 929        (if result
 930            (org-batch-output-json t (list :clocked_in t :heading \"$heading\"))
 931          (org-batch-output-error \"Heading not found: $heading\")))
 932      (kill-emacs 0))"
 933
 934    run_elisp "$elisp"
 935}
 936
 937cmd_clock_out() {
 938    local file="$1"
 939
 940    [[ -f "$file" ]] || error "File not found: $file"
 941
 942    local elisp="(progn
 943      (let ((result (org-batch-clock-out \"$file\")))
 944        (if result
 945            (org-batch-output-json t (list :clocked_out t))
 946          (org-batch-output-error \"No active clock found\")))
 947      (kill-emacs 0))"
 948
 949    run_elisp "$elisp"
 950}
 951
 952cmd_get_active_clock() {
 953    local file="$1"
 954
 955    [[ -f "$file" ]] || error "File not found: $file"
 956
 957    local elisp="(progn
 958      (let ((result (org-batch-get-active-clock \"$file\")))
 959        (if result
 960            (org-batch-output-json t result)
 961          (org-batch-output-json t nil)))
 962      (kill-emacs 0))"
 963
 964    run_elisp "$elisp"
 965}
 966
 967cmd_get_clocked_time() {
 968    local file="$1"
 969    local heading="$2"
 970
 971    [[ -f "$file" ]] || error "File not found: $file"
 972    [[ -n "$heading" ]] || error "Heading required"
 973
 974    local elisp="(progn
 975      (let ((minutes (org-batch-get-clocked-time \"$file\" \"$heading\")))
 976        (org-batch-output-json t (list :heading \"$heading\" :minutes minutes)))
 977      (kill-emacs 0))"
 978
 979    run_elisp "$elisp"
 980}
 981
 982# Statistics & Analytics commands
 983
 984cmd_get_statistics() {
 985    local file="$1"
 986
 987    [[ -f "$file" ]] || error "File not found: $file"
 988
 989    local elisp="(progn
 990      (let ((stats (org-batch-get-statistics \"$file\")))
 991        (org-batch-output-json t stats))
 992      (kill-emacs 0))"
 993
 994    run_elisp "$elisp"
 995}
 996
 997cmd_get_priority_distribution() {
 998    local file="$1"
 999
1000    [[ -f "$file" ]] || error "File not found: $file"
1001
1002    local elisp="(progn
1003      (let ((distribution (org-batch-get-priority-distribution \"$file\")))
1004        (org-batch-output-json t (list :distribution distribution)))
1005      (kill-emacs 0))"
1006
1007    run_elisp "$elisp"
1008}
1009
1010cmd_get_tag_statistics() {
1011    local file="$1"
1012
1013    [[ -f "$file" ]] || error "File not found: $file"
1014
1015    local elisp="(progn
1016      (let ((stats (org-batch-get-tag-statistics \"$file\")))
1017        (org-batch-output-json t (list :tag_stats stats)))
1018      (kill-emacs 0))"
1019
1020    run_elisp "$elisp"
1021}
1022
1023# Export commands
1024
1025cmd_export_csv() {
1026    local file="$1"
1027    local output="$2"
1028
1029    [[ -f "$file" ]] || error "File not found: $file"
1030    [[ -n "$output" ]] || error "Output file required"
1031
1032    local elisp="(progn
1033      (let ((result (org-batch-export-csv \"$file\" \"$output\")))
1034        (if result
1035            (org-batch-output-json t (list :exported t :output \"$output\"))
1036          (org-batch-output-error \"Export failed\")))
1037      (kill-emacs 0))"
1038
1039    run_elisp "$elisp"
1040}
1041
1042cmd_export_json() {
1043    local file="$1"
1044    local output="$2"
1045
1046    [[ -f "$file" ]] || error "File not found: $file"
1047    [[ -n "$output" ]] || error "Output file required"
1048
1049    local elisp="(progn
1050      (let ((result (org-batch-export-json \"$file\" \"$output\")))
1051        (if result
1052            (org-batch-output-json t (list :exported t :output \"$output\"))
1053          (org-batch-output-error \"Export failed\")))
1054      (kill-emacs 0))"
1055
1056    run_elisp "$elisp"
1057}
1058
1059# Recurring tasks commands
1060
1061cmd_set_repeater() {
1062    local file="$1"
1063    local heading="$2"
1064    local repeater="$3"
1065
1066    [[ -f "$file" ]] || error "File not found: $file"
1067    [[ -n "$heading" ]] || error "Heading required"
1068    [[ -n "$repeater" ]] || error "Repeater specification required (e.g., +1w, .+2d)"
1069
1070    local elisp="(progn
1071      (let ((result (org-batch-set-repeater \"$file\" \"$heading\" \"$repeater\")))
1072        (if result
1073            (org-batch-output-json t (list :repeater-set t :heading \"$heading\" :repeater \"$repeater\"))
1074          (org-batch-output-error \"Heading not found: $heading\")))
1075      (kill-emacs 0))"
1076
1077    run_elisp "$elisp"
1078}
1079
1080cmd_get_recurring_tasks() {
1081    local file="$1"
1082
1083    [[ -f "$file" ]] || error "File not found: $file"
1084
1085    local elisp="(progn
1086      (let ((tasks (org-batch-get-recurring-tasks \"$file\")))
1087        (org-batch-output-json t tasks))
1088      (kill-emacs 0))"
1089
1090    run_elisp "$elisp"
1091}
1092
1093# Dependencies & relationships commands
1094
1095cmd_set_blocker() {
1096    local file="$1"
1097    local heading="$2"
1098    local blocker="$3"
1099
1100    [[ -f "$file" ]] || error "File not found: $file"
1101    [[ -n "$heading" ]] || error "Heading required"
1102    [[ -n "$blocker" ]] || error "Blocker heading required"
1103
1104    local elisp="(progn
1105      (let ((result (org-batch-set-blocker \"$file\" \"$heading\" \"$blocker\")))
1106        (if result
1107            (org-batch-output-json t (list :blocker-set t :heading \"$heading\" :blocker \"$blocker\"))
1108          (org-batch-output-error \"Heading not found: $heading\")))
1109      (kill-emacs 0))"
1110
1111    run_elisp "$elisp"
1112}
1113
1114cmd_get_blocker() {
1115    local file="$1"
1116    local heading="$2"
1117
1118    [[ -f "$file" ]] || error "File not found: $file"
1119    [[ -n "$heading" ]] || error "Heading required"
1120
1121    local elisp="(progn
1122      (let ((blocker (org-batch-get-blocker \"$file\" \"$heading\")))
1123        (if blocker
1124            (org-batch-output-json t (list :heading \"$heading\" :blocker blocker))
1125          (org-batch-output-json t nil)))
1126      (kill-emacs 0))"
1127
1128    run_elisp "$elisp"
1129}
1130
1131cmd_get_blocked_tasks() {
1132    local file="$1"
1133
1134    [[ -f "$file" ]] || error "File not found: $file"
1135
1136    local elisp="(progn
1137      (let ((tasks (org-batch-get-blocked-tasks \"$file\")))
1138        (org-batch-output-json t tasks))
1139      (kill-emacs 0))"
1140
1141    run_elisp "$elisp"
1142}
1143
1144cmd_set_related() {
1145    local file="$1"
1146    local heading="$2"
1147    local related="$3"
1148    local relation_type="$4"
1149
1150    [[ -f "$file" ]] || error "File not found: $file"
1151    [[ -n "$heading" ]] || error "Heading required"
1152    [[ -n "$related" ]] || error "Related heading required"
1153    [[ -n "$relation_type" ]] || error "Relation type required (child/parent/related/depends-on)"
1154
1155    local elisp="(progn
1156      (let ((result (org-batch-set-related \"$file\" \"$heading\" \"$related\" \"$relation_type\")))
1157        (if result
1158            (org-batch-output-json t (list :related-set t :heading \"$heading\" :related \"$related\" :type \"$relation_type\"))
1159          (org-batch-output-error \"Heading not found: $heading\")))
1160      (kill-emacs 0))"
1161
1162    run_elisp "$elisp"
1163}
1164
1165cmd_get_related() {
1166    local file="$1"
1167    local heading="$2"
1168
1169    [[ -f "$file" ]] || error "File not found: $file"
1170    [[ -n "$heading" ]] || error "Heading required"
1171
1172    local elisp="(progn
1173      (let ((related (org-batch-get-related \"$file\" \"$heading\")))
1174        (org-batch-output-json t (list :heading \"$heading\" :related related)))
1175      (kill-emacs 0))"
1176
1177    run_elisp "$elisp"
1178}
1179
1180# Denote commands
1181
1182cmd_denote_create() {
1183    local title="$1"
1184    local tags="$2"; shift 2
1185    local signature="" category="" directory="" content_file=""
1186
1187    [[ -n "$title" ]] || error "Title required"
1188    [[ -n "$tags" ]] || error "Tags required (comma-separated)"
1189
1190    while [[ $# -gt 0 ]]; do
1191        case "$1" in
1192            --signature=*)
1193                signature=$(parse_option "$1" "--signature=")
1194                shift
1195                ;;
1196            --category=*)
1197                category=$(parse_option "$1" "--category=")
1198                shift
1199                ;;
1200            --directory=*)
1201                directory=$(parse_option "$1" "--directory=")
1202                shift
1203                ;;
1204            --content=*)
1205                content_file=$(parse_option "$1" "--content=")
1206                shift
1207                ;;
1208            *)
1209                error "Unknown option: $1"
1210                ;;
1211        esac
1212    done
1213
1214    # Convert comma-separated tags to elisp list
1215    local tags_list="'(${tags//,/ })"
1216
1217    # Build elisp call
1218    local elisp="(progn"
1219
1220    if [[ -n "$content_file" ]]; then
1221        [[ -f "$content_file" ]] || error "Content file not found: $content_file"
1222        elisp="$elisp
1223      (denote-batch-create-note-from-file
1224        \"$title\"
1225        $tags_list
1226        \"$content_file\""
1227    else
1228        elisp="$elisp
1229      (denote-batch-create-note
1230        \"$title\"
1231        $tags_list"
1232    fi
1233
1234    # Add optional parameters
1235    [[ -n "$signature" ]] && elisp="$elisp
1236        \"$signature\"" || elisp="$elisp
1237        nil"
1238    [[ -n "$category" ]] && elisp="$elisp
1239        \"$category\"" || elisp="$elisp
1240        nil"
1241    [[ -n "$directory" ]] && elisp="$elisp
1242        \"$directory\"" || elisp="$elisp
1243        nil"
1244
1245    elisp="$elisp)
1246      (kill-emacs 0))"
1247
1248    run_denote_elisp "$elisp"
1249}
1250
1251cmd_denote_append() {
1252    local filepath="$1"
1253    local content_file="$2"
1254
1255    [[ -f "$filepath" ]] || error "File not found: $filepath"
1256    [[ -f "$content_file" ]] || error "Content file not found: $content_file"
1257
1258    # Read content
1259    local content
1260    content=$(<"$content_file")
1261
1262    # Escape quotes for elisp
1263    content="${content//\"/\\\"}"
1264
1265    local elisp="(progn
1266      (denote-batch-append-content \"$filepath\" \"$content\")
1267      (kill-emacs 0))"
1268
1269    run_denote_elisp "$elisp"
1270}
1271
1272cmd_denote_metadata() {
1273    local filepath="$1"
1274
1275    [[ -f "$filepath" ]] || error "File not found: $filepath"
1276
1277    local elisp="(progn
1278      (denote-batch-read-metadata \"$filepath\")
1279      (kill-emacs 0))"
1280
1281    run_denote_elisp "$elisp"
1282}
1283
1284cmd_denote_update() {
1285    local filepath="$1"; shift
1286    local new_title="" new_tags="" new_category=""
1287
1288    [[ -f "$filepath" ]] || error "File not found: $filepath"
1289
1290    while [[ $# -gt 0 ]]; do
1291        case "$1" in
1292            --title=*)
1293                new_title=$(parse_option "$1" "--title=")
1294                shift
1295                ;;
1296            --tags=*)
1297                new_tags=$(parse_option "$1" "--tags=")
1298                shift
1299                ;;
1300            --category=*)
1301                new_category=$(parse_option "$1" "--category=")
1302                shift
1303                ;;
1304            *)
1305                error "Unknown option: $1"
1306                ;;
1307        esac
1308    done
1309
1310    # Convert tags if provided
1311    local tags_list="nil"
1312    [[ -n "$new_tags" ]] && tags_list="'(${new_tags//,/ })"
1313
1314    # shellcheck disable=SC2155
1315    local elisp="(progn
1316      (denote-batch-update-frontmatter \"$filepath\"
1317        $([ -n "$new_title" ] && echo "\"$new_title\"" || echo "nil")
1318        $tags_list
1319        $([ -n "$new_category" ] && echo "\"$new_category\"" || echo "nil"))
1320      (kill-emacs 0))"
1321
1322    run_denote_elisp "$elisp"
1323}
1324
1325# Main
1326main() {
1327    check_deps
1328
1329    if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
1330        usage
1331    fi
1332
1333    local command="$1"; shift
1334
1335    case "$command" in
1336        list)
1337            cmd_list "$@"
1338            ;;
1339        scheduled)
1340            cmd_scheduled "$@"
1341            ;;
1342        count)
1343            cmd_count "$@"
1344            ;;
1345        search)
1346            cmd_search "$@"
1347            ;;
1348        by-section)
1349            cmd_by_section "$@"
1350            ;;
1351        sections)
1352            cmd_sections "$@"
1353            ;;
1354        children)
1355            cmd_children "$@"
1356            ;;
1357        get)
1358            cmd_get "$@"
1359            ;;
1360        overdue)
1361            cmd_overdue "$@"
1362            ;;
1363        upcoming)
1364            cmd_upcoming "$@"
1365            ;;
1366        add)
1367            cmd_add "$@"
1368            ;;
1369        append-content)
1370            cmd_append_content "$@"
1371            ;;
1372        update-state)
1373            cmd_update_state "$@"
1374            ;;
1375        schedule)
1376            cmd_schedule "$@"
1377            ;;
1378        deadline)
1379            cmd_deadline "$@"
1380            ;;
1381        priority)
1382            cmd_priority "$@"
1383            ;;
1384        archive)
1385            cmd_archive "$@"
1386            ;;
1387        add-tags)
1388            cmd_add_tags "$@"
1389            ;;
1390        remove-tags)
1391            cmd_remove_tags "$@"
1392            ;;
1393        replace-tags)
1394            cmd_replace_tags "$@"
1395            ;;
1396        list-tags)
1397            cmd_list_tags "$@"
1398            ;;
1399        get-property)
1400            cmd_get_property "$@"
1401            ;;
1402        set-property)
1403            cmd_set_property "$@"
1404            ;;
1405        list-properties)
1406            cmd_list_properties "$@"
1407            ;;
1408        bulk-update-state)
1409            cmd_bulk_update_state "$@"
1410            ;;
1411        bulk-add-tags)
1412            cmd_bulk_add_tags "$@"
1413            ;;
1414        bulk-set-priority)
1415            cmd_bulk_set_priority "$@"
1416            ;;
1417        clock-in)
1418            cmd_clock_in "$@"
1419            ;;
1420        clock-out)
1421            cmd_clock_out "$@"
1422            ;;
1423        get-active-clock)
1424            cmd_get_active_clock "$@"
1425            ;;
1426        get-clocked-time)
1427            cmd_get_clocked_time "$@"
1428            ;;
1429        get-statistics)
1430            cmd_get_statistics "$@"
1431            ;;
1432        get-priority-distribution)
1433            cmd_get_priority_distribution "$@"
1434            ;;
1435        get-tag-statistics)
1436            cmd_get_tag_statistics "$@"
1437            ;;
1438        export-csv)
1439            cmd_export_csv "$@"
1440            ;;
1441        export-json)
1442            cmd_export_json "$@"
1443            ;;
1444        set-repeater)
1445            cmd_set_repeater "$@"
1446            ;;
1447        get-recurring-tasks)
1448            cmd_get_recurring_tasks "$@"
1449            ;;
1450        set-blocker)
1451            cmd_set_blocker "$@"
1452            ;;
1453        get-blocker)
1454            cmd_get_blocker "$@"
1455            ;;
1456        get-blocked-tasks)
1457            cmd_get_blocked_tasks "$@"
1458            ;;
1459        set-related)
1460            cmd_set_related "$@"
1461            ;;
1462        get-related)
1463            cmd_get_related "$@"
1464            ;;
1465        denote-create)
1466            cmd_denote_create "$@"
1467            ;;
1468        denote-append)
1469            cmd_denote_append "$@"
1470            ;;
1471        denote-metadata)
1472            cmd_denote_metadata "$@"
1473            ;;
1474        denote-update)
1475            cmd_denote_update "$@"
1476            ;;
1477        *)
1478            error "Unknown command: $command. Use --help for usage."
1479            ;;
1480    esac
1481}
1482
1483main "$@"