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