flake-update-20260201
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 "$@"