Commit 3a21926c31e7

Vincent Demeester <vincent@sbr.pm>
2026-01-12 10:34:36
feat(org-manager): add append-content command for TODO content management
- Enable programmatic content addition to TODOs without manual editing - Automatically adjust heading levels for proper org-mode hierarchy - Reduce risk of corrupting org syntax when appending large content blocks Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 11b3960
Changed files (4)
dots
.config
dots/.config/claude/skills/Org/tools/batch-functions.el
@@ -298,6 +298,101 @@ Returns the text content without the heading line or properties."
 
 ;;; Write Operations
 
+(defun org-batch--adjust-heading-levels (content parent-level)
+  "Adjust heading levels in CONTENT to be relative to PARENT-LEVEL.
+Converts markdown headers (#, ##, ###) and org headers (*, **, ***)
+to the appropriate level relative to the parent heading.
+Parent level 2 (**) means # becomes ***, ## becomes ****, etc."
+  (with-temp-buffer
+    (insert content)
+    (goto-char (point-min))
+    ;; First, convert markdown headings to org format with adjusted levels
+    (while (re-search-forward "^\\(#+\\)\\( .*\\)$" nil t)
+      (let* ((markdown-level (length (match-string 1)))
+             (header-text (match-string 2))
+             ;; Subheading should be parent + markdown level
+             (new-level (+ parent-level markdown-level))
+             (org-stars (make-string new-level ?*)))
+        ;; Replace with a temporary marker to avoid re-processing
+        (replace-match (concat "ORG_HEADING_MARKER:" org-stars header-text))))
+    ;; Now process existing org headings (* Header) - adjust their level
+    (goto-char (point-min))
+    (while (re-search-forward "^\\(\\*+\\)\\( .*\\)$" nil t)
+      (let* ((org-level (length (match-string 1)))
+             (header-text (match-string 2))
+             ;; Subheading should be parent + org level
+             (new-level (+ parent-level org-level))
+             (org-stars (make-string new-level ?*)))
+        (replace-match (concat "ORG_HEADING_MARKER:" org-stars header-text))))
+    ;; Remove the temporary markers
+    (goto-char (point-min))
+    (while (re-search-forward "ORG_HEADING_MARKER:" nil t)
+      (replace-match ""))
+    (buffer-string)))
+
+(defun org-batch-append-content (file heading content)
+  "Append CONTENT to TODO with HEADING in FILE.
+Adds content at the end of the heading's body, before any subheadings.
+Automatically adjusts heading levels in content (# becomes ###, etc).
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        ;; Get parent heading level for content adjustment
+        (let* ((parent-level (org-current-level))
+               (section-end (save-excursion
+                             (org-end-of-subtree t t)
+                             (point)))
+               ;; Adjust content heading levels
+               (adjusted-content (org-batch--adjust-heading-levels content parent-level)))
+          ;; Find the insertion point:
+          ;; - After properties drawer
+          ;; - After SCHEDULED/DEADLINE lines
+          ;; - Before any subheadings
+          ;; - At end of existing content
+          (forward-line 1)
+          ;; Skip properties drawer
+          (when (looking-at "^[ \t]*:PROPERTIES:")
+            (re-search-forward "^[ \t]*:END:" section-end t)
+            (forward-line 1))
+          ;; Skip SCHEDULED/DEADLINE/CLOSED lines
+          (while (looking-at "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\|CLOSED\\):")
+            (forward-line 1))
+          ;; Skip logbook drawer if present
+          (when (looking-at "^[ \t]*:LOGBOOK:")
+            (re-search-forward "^[ \t]*:END:" section-end t)
+            (forward-line 1))
+          ;; Find end of content (before any subheading)
+          (let ((content-end (save-excursion
+                              (if (re-search-forward "^\\*" section-end t)
+                                  (match-beginning 0)
+                                section-end))))
+            (goto-char content-end)
+            ;; Skip back over trailing blank lines
+            (skip-chars-backward "\n\t ")
+            (unless (bolp) (forward-line 1))
+            ;; Ensure we have a blank line before content if there's existing content
+            (unless (or (= (point) (save-excursion (org-back-to-heading) (forward-line 1) (point)))
+                       (looking-back "\\`\\|^[ \t]*\n" nil))
+              (insert "\n"))
+            ;; Insert the adjusted content
+            (insert adjusted-content)
+            ;; Ensure content ends with newline
+            (unless (bolp) (insert "\n"))
+            ;; Add blank line after content if subheadings follow
+            (when (looking-at "^\\*")
+              (unless (looking-back "\n\n" nil)
+                (insert "\n")))
+            (write-region (point-min) (point-max) file)
+            (setq found t)))
+        found))))
+
 (defun org-batch-update-state (file heading new-state)
   "Update TODO state for HEADING in FILE to NEW-STATE.
 Returns t on success, nil if heading not found."
dots/.config/claude/skills/Org/tools/org-manager
@@ -133,6 +133,12 @@ WRITE COMMANDS:
   add <file> <heading> --section=NAME [--scheduled=DATE] [--priority=N] [--tags=TAG1,TAG2]
       Add new TODO item
 
+  append-content <file> <heading> <content-file>
+      Append content from file to existing TODO
+      Content should be in org-mode format (not markdown)
+      Adds content after properties/scheduling, before subheadings
+      Automatically adjusts heading levels (# or * → relative to parent)
+
   update-state <file> <heading> <new-state>
       Change TODO state (NEXT, STRT, TODO, WAIT, DONE, CANX)
 
@@ -587,6 +593,33 @@ cmd_add() {
     run_elisp "$elisp"
 }
 
+cmd_append_content() {
+    local file="$1"
+    local heading="$2"
+    local content_file="$3"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+    [[ -f "$content_file" ]] || error "Content file not found: $content_file"
+
+    # Read content from file
+    local content
+    content=$(<"$content_file")
+
+    # Escape quotes and backslashes for elisp
+    content="${content//\\/\\\\}"
+    content="${content//\"/\\\"}"
+
+    local elisp="(progn
+      (let ((result (org-batch-append-content \"$file\" \"$heading\" \"$content\")))
+        (if result
+            (org-batch-output-json t (list :content-appended t :heading \"$heading\"))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
 cmd_update_state() {
     local file="$1"
     local heading="$2"
@@ -1332,6 +1365,9 @@ main() {
         add)
             cmd_add "$@"
             ;;
+        append-content)
+            cmd_append_content "$@"
+            ;;
         update-state)
             cmd_update_state "$@"
             ;;
dots/.config/claude/skills/TODOs/SKILL.md
@@ -321,9 +321,20 @@ This skill integrates with the **Org skill** for programmatic org-mode manipulat
 ### Tool Location
 `~/.config/claude/skills/Org/tools/org-manager`
 
-### Common Operations
+**ALWAYS use org-manager for TODO operations.** It provides reliable, JSON-formatted operations on org-mode files via Emacs batch mode.
 
-**List TODOs:**
+### Quick Reference
+
+All commands return JSON: `{"success": true, "data": [...]}`
+
+Use `jq` for parsing:
+```bash
+org-manager list ~/desktop/org/todos.org --state=NEXT | jq -r '.data[] | "[\(.todo)] \(.heading)"'
+```
+
+### READ COMMANDS
+
+**List TODOs with filters:**
 ```bash
 # All NEXT tasks
 org-manager list ~/desktop/org/todos.org --state=NEXT
@@ -347,6 +358,12 @@ org-manager by-section ~/desktop/org/todos.org "Work"
 org-manager add ~/desktop/org/todos.org "Task description" \
   --section=Work --priority=2 --scheduled=2025-12-10
 
+# Append content from file to existing TODO
+# Content is added after properties/scheduling, before subheadings
+# IMPORTANT: Content should be in org-mode format
+# Heading levels are automatically adjusted (# becomes ***, * becomes ***, etc.)
+org-manager append-content ~/desktop/org/todos.org "Task heading" /tmp/notes.org
+
 # Update state
 org-manager update-state ~/desktop/org/todos.org "Task heading" DONE
 
@@ -360,6 +377,15 @@ org-manager priority ~/desktop/org/todos.org "Task heading" 2
 org-manager archive ~/desktop/org/todos.org
 ```
 
+**IMPORTANT NOTE about append-content:**
+- Content files should be in **org-mode format**, not markdown
+- Use org syntax: `*bold*`, `/italic/`, `[[url][text]]`, `=code=`
+- Heading level adjustment is provided as a convenience:
+  - Markdown headings (`#`) will be converted to org headings (`*`)
+  - Org headings (`*`) will have their level adjusted
+  - Other markdown syntax (bold, links, code) will NOT be converted
+- Best practice: Write content in org-mode format from the start
+
 **Statistics:**
 ```bash
 # Count by state
CLAUDE.md
@@ -51,6 +51,8 @@ Secrets are managed using agenix:
 
 Everything should happen using `make` (and `Makefile` accross the repository). You can use `make help` to figure out what it does.
 
+**IMPORTANT: Never use `home-manager switch` or `nixos-rebuild` commands directly. Always use `make switch` or the appropriate make targets.**
+
 ### Building and Deploying Systems
 
 ```bash