Commit 4918a5c85082

Vincent Demeester <vincent@sbr.pm>
2026-01-16 16:22:37
feat(emacs): add smart journaling with org-capture integration
Consolidate all org-mode elisp in site-lisp and add journelly.el for improved journaling workflow with smart create-or-append behavior. Changes: - Add journelly.el with org-capture integration (C-c o c j/J) - Smart create-or-append: first entry creates, subsequent append - Entry format: @ hostname in location (e.g., @ kyushu in Paris) - Auto location/weather via IP geolocation and wttr.in - Separate regular journal and Claude session entries site-lisp additions: - journelly.el: Interactive journal functions with org-capture - journelly-location-weather.el: Location/weather helpers (moved) - journelly-batch-functions.el: Batch operations (moved) - org-batch-functions.el: Org batch ops (moved, renamed) - denote-batch-functions.el: Denote batch ops (moved) - journelly-manager: CLI tool (NEW, loads from site-lisp) CLI tools updated to load from site-lisp: - org-manager: Load org-batch-functions.el from site-lisp - journelly-manager: Load journelly-batch-functions.el from site-lisp init.el: - Remove old "j" and "J" capture templates - Add (use-package journelly :after org-capture :demand t) Benefits: - Single source of truth: all elisp in site-lisp - Consistent: interactive and CLI use same code - org-capture works with muscle memory - Better format shows context (hostname + location) - No duplicate entries per day Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent db78dd3
dots/.config/claude/skills/Org/tools/journelly-manager
@@ -1,14 +1,14 @@
 #!/usr/bin/env bash
 # journelly-manager - CLI tool for Journelly journal file manipulation via Emacs batch mode
-# Copyright (C) 2025 Vincent Demeester
-# Part of Claude Code Journal skill
+# Copyright (C) 2026 Vincent Demeester
+# Loads elisp from site-lisp for consistency with interactive Emacs
 
 set -euo pipefail
 
 # Configuration
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-BATCH_FUNCTIONS="${BATCH_FUNCTIONS:-$SCRIPT_DIR/journelly-batch-functions.el}"
 EMACS="${EMACS:-emacs}"
+EMACS_DIR="${EMACS_DIR:-$HOME/.config/emacs}"
+SITE_LISP="$EMACS_DIR/site-lisp"
 
 # Debug mode
 DEBUG="${DEBUG:-0}"
@@ -58,18 +58,23 @@ check_deps() {
         error "Emacs not found. Set EMACS environment variable or install emacs."
     fi
 
-    if [[ ! -f "$BATCH_FUNCTIONS" ]]; then
-        error "journelly-batch-functions.el not found at: $BATCH_FUNCTIONS"
+    if [[ ! -d "$SITE_LISP" ]]; then
+        error "Emacs site-lisp directory not found at: $SITE_LISP"
+    fi
+
+    if [[ ! -f "$SITE_LISP/journelly-batch-functions.el" ]]; then
+        error "journelly-batch-functions.el not found in site-lisp"
     fi
 }
 
 # Run Emacs batch command
 run_batch() {
     local function_call="$1"
-    debug "Running: $EMACS --batch --load \"$BATCH_FUNCTIONS\" --eval \"$function_call\""
+    debug "Running: $EMACS --batch --directory \"$SITE_LISP\" --load journelly-batch-functions.el --eval \"$function_call\""
 
     "$EMACS" --batch \
-        --load "$BATCH_FUNCTIONS" \
+        --directory "$SITE_LISP" \
+        --load journelly-batch-functions.el \
         --eval "$function_call" 2>&1
 }
 
@@ -91,310 +96,231 @@ COMMANDS:
             --temperature=TEMP     Temperature (e.g., "15,2°C")
             --condition=COND       Weather condition (e.g., "Cloudy")
             --symbol=SYM           Weather symbol (e.g., "cloud")
-            --content-file=PATH    Read content from file instead
 
         Examples:
             journelly-manager create ~/desktop/org/Journelly.org "Home" "Today was great"
 
             journelly-manager create ~/desktop/org/Journelly.org "Kyushu" \\
                 "Work session notes" \\
-                --latitude=48.8672 --longitude=2.1851 \\
-                --temperature="15,2°C" --condition="Partly Cloudy" --symbol="cloud.sun"
+                --latitude=48.8534 --longitude=2.3488 \\
+                --temperature="15°C" --condition="Cloudy" --symbol="cloud"
 
-    append FILE CONTENT [--content-file=PATH]
-        Append to today's journal entry
+    append FILE DATE CONTENT
+        Append to existing journal entry by date (YYYY-MM-DD)
 
         Examples:
-            journelly-manager append ~/desktop/org/Journelly.org "Additional thoughts"
-            journelly-manager append ~/desktop/org/Journelly.org --content-file=/tmp/notes.txt
+            journelly-manager append ~/desktop/org/Journelly.org \\
+                "2026-01-16" "Additional thoughts"
 
     list FILE [--limit=N]
-        List recent journal entries (default: 10)
+        List recent journal entries
+
+        Options:
+            --limit=N    Number of entries to show (default: 10)
 
         Examples:
             journelly-manager list ~/desktop/org/Journelly.org
             journelly-manager list ~/desktop/org/Journelly.org --limit=20
 
     search FILE QUERY
-        Search journal entries for text
+        Search journal entries for keyword
 
         Examples:
-            journelly-manager search ~/desktop/org/Journelly.org "claude"
-            journelly-manager search ~/desktop/org/Journelly.org "homelab"
+            journelly-manager search ~/desktop/org/Journelly.org "wireguard"
 
-    get FILE DATE [TIME]
-        Get specific entry by date and optional time
+    get FILE DATE
+        Get specific entry by date (YYYY-MM-DD)
 
         Examples:
-            journelly-manager get ~/desktop/org/Journelly.org "2025-12-08"
-            journelly-manager get ~/desktop/org/Journelly.org "2025-12-08" "15:30"
+            journelly-manager get ~/desktop/org/Journelly.org "2026-01-16"
 
-GLOBAL OPTIONS:
-    --help, -h             Show this help message
-    --version              Show version information
-    --debug                Enable debug output
-
-ENVIRONMENT VARIABLES:
-    EMACS                  Path to Emacs binary (default: emacs)
-    BATCH_FUNCTIONS        Path to journelly-batch-functions.el
-    DEBUG                  Enable debug mode (1 or 0)
-    JSON_OUTPUT            Output JSON only (1 or 0)
+ENVIRONMENT:
+    EMACS           Emacs executable (default: emacs)
+    EMACS_DIR       Emacs config directory (default: ~/.config/emacs)
+    DEBUG           Set to 1 for debug output
+    JSON_OUTPUT     Set to 1 for JSON output (no colors)
 
 EXAMPLES:
-    # Create simple entry
-    journelly-manager create ~/desktop/org/Journelly.org "Home" \\
-        "Productive day working on Claude skills."
-
-    # Create entry with weather data
-    journelly-manager create ~/desktop/org/Journelly.org "Rue Jean Bourguignon" \\
-        "Evening reflection" \\
-        --latitude=48.86721 --longitude=2.18509 \\
-        --temperature="8,5°C" --condition="Clear" --symbol="moon.stars"
-
-    # Create entry with content from file
-    echo "My journal thoughts..." > /tmp/entry.txt
-    journelly-manager create ~/desktop/org/Journelly.org "Kyushu" \\
-        --content-file=/tmp/entry.txt
+    # Create entry with auto location/weather (use get-location/get-weather)
+    LOC=\$(get-location --json)
+    WEATHER=\$(get-weather --json)
+    journelly-manager create ~/desktop/org/Journelly.org \\
+        "\$(echo \$LOC | jq -r .city)" "Entry content" \\
+        --latitude="\$(echo \$LOC | jq -r .lat)" \\
+        --longitude="\$(echo \$LOC | jq -r .lon)" \\
+        --temperature="\$(echo \$WEATHER | jq -r .temperature)" \\
+        --condition="\$(echo \$WEATHER | jq -r .condition)" \\
+        --symbol="\$(echo \$WEATHER | jq -r .symbol)"
 
     # Append to today's entry
     journelly-manager append ~/desktop/org/Journelly.org \\
-        "Update: Made more progress on the project!"
+        "\$(date +%Y-%m-%d)" "More thoughts"
 
-    # List recent entries
-    journelly-manager list ~/desktop/org/Journelly.org --limit=5
+    # Search entries
+    journelly-manager search ~/desktop/org/Journelly.org "claude"
 
-    # Search for entries about work
-    journelly-manager search ~/desktop/org/Journelly.org "work"
-
-    # Get specific entry
-    journelly-manager get ~/desktop/org/Journelly.org "2025-12-08" "15:30"
-
-VERSION:
-    1.0.0
-
-AUTHOR:
-    Vincent Demeester <vincent@demeester.fr>
-
-SEE ALSO:
-    Journelly iOS App: https://journelly.com
-    Org-mode: https://orgmode.org
 EOF
+    exit 0
 }
 
-# Parse command line arguments
-parse_args() {
-    local cmd="${1:-}"
-    shift || true
-
-    case "$cmd" in
-        create)
-            cmd_create "$@"
-            ;;
-        append)
-            cmd_append "$@"
-            ;;
-        list)
-            cmd_list "$@"
-            ;;
-        search)
-            cmd_search "$@"
-            ;;
-        get)
-            cmd_get "$@"
-            ;;
-        --help|-h|help)
-            usage
-            exit 0
-            ;;
-        --version)
-            echo "journelly-manager 1.0.0"
-            exit 0
-            ;;
-        "")
-            error "No command specified. Use --help for usage."
-            ;;
-        *)
-            error "Unknown command: $cmd. Use --help for usage."
-            ;;
-    esac
-}
-
-# Command: create
+# Parse create command
 cmd_create() {
-    local file="${1:-}"
-    local location="${2:-}"
-    local content="${3:-}"
-    shift 3 || error "create requires FILE LOCATION CONTENT arguments"
+    local file="$1"
+    local location="$2"
+    local content="$3"
+    shift 3
 
-    [[ -z "$file" ]] && error "FILE argument required"
-    [[ -z "$location" ]] && error "LOCATION argument required"
-    [[ ! -f "$file" ]] && error "File not found: $file"
-
-    # Parse optional arguments
-    local latitude=""
-    local longitude=""
-    local temperature=""
-    local condition=""
-    local symbol=""
-    local content_file=""
+    local latitude="" longitude="" temperature="" condition="" symbol=""
 
+    # Parse options
     while [[ $# -gt 0 ]]; do
         case "$1" in
             --latitude=*)
                 latitude="${1#*=}"
+                shift
                 ;;
             --longitude=*)
                 longitude="${1#*=}"
+                shift
                 ;;
             --temperature=*)
                 temperature="${1#*=}"
+                shift
                 ;;
             --condition=*)
                 condition="${1#*=}"
+                shift
                 ;;
             --symbol=*)
                 symbol="${1#*=}"
-                ;;
-            --content-file=*)
-                content_file="${1#*=}"
-                [[ ! -f "$content_file" ]] && error "Content file not found: $content_file"
+                shift
                 ;;
             *)
                 error "Unknown option: $1"
                 ;;
         esac
-        shift
     done
 
     # Build Emacs Lisp call
     local elisp_call="(journelly-batch-create-entry \"$file\" \"$location\" \"$content\""
 
-    [[ -n "$latitude" ]] && elisp_call="$elisp_call \"$latitude\"" || elisp_call="$elisp_call nil"
-    [[ -n "$longitude" ]] && elisp_call="$elisp_call \"$longitude\"" || elisp_call="$elisp_call nil"
-    [[ -n "$temperature" ]] && elisp_call="$elisp_call \"$temperature\"" || elisp_call="$elisp_call nil"
-    [[ -n "$condition" ]] && elisp_call="$elisp_call \"$condition\"" || elisp_call="$elisp_call nil"
-    [[ -n "$symbol" ]] && elisp_call="$elisp_call \"$symbol\"" || elisp_call="$elisp_call nil"
-    [[ -n "$content_file" ]] && elisp_call="$elisp_call \"$content_file\"" || elisp_call="$elisp_call nil"
+    if [[ -n "$latitude" ]]; then elisp_call="$elisp_call :latitude \"$latitude\""; fi
+    if [[ -n "$longitude" ]]; then elisp_call="$elisp_call :longitude \"$longitude\""; fi
+    if [[ -n "$temperature" ]]; then elisp_call="$elisp_call :temperature \"$temperature\""; fi
+    if [[ -n "$condition" ]]; then elisp_call="$elisp_call :condition \"$condition\""; fi
+    if [[ -n "$symbol" ]]; then elisp_call="$elisp_call :symbol \"$symbol\""; fi
 
     elisp_call="$elisp_call)"
 
-    info "Creating journal entry..."
-    local result
-    result=$(run_batch "$elisp_call")
-    echo "$result"
-
-    if echo "$result" | grep -q '"success":true'; then
-        success "Journal entry created successfully"
-    fi
+    run_batch "$elisp_call"
+    success "Journal entry created"
 }
 
-# Command: append
+# Parse append command
 cmd_append() {
-    local file="${1:-}"
-    local content="${2:-}"
-    shift 2 || error "append requires FILE CONTENT arguments"
+    local file="$1"
+    local date="$2"
+    local content="$3"
 
-    [[ -z "$file" ]] && error "FILE argument required"
-    [[ ! -f "$file" ]] && error "File not found: $file"
-
-    local content_file=""
-
-    while [[ $# -gt 0 ]]; do
-        case "$1" in
-            --content-file=*)
-                content_file="${1#*=}"
-                [[ ! -f "$content_file" ]] && error "Content file not found: $content_file"
-                ;;
-            *)
-                error "Unknown option: $1"
-                ;;
-        esac
-        shift
-    done
-
-    local elisp_call="(journelly-batch-append-to-today \"$file\" \"$content\""
-    [[ -n "$content_file" ]] && elisp_call="$elisp_call \"$content_file\"" || elisp_call="$elisp_call nil"
-    elisp_call="$elisp_call)"
-
-    info "Appending to today's entry..."
-    local result
-    result=$(run_batch "$elisp_call")
-    echo "$result"
-
-    if echo "$result" | grep -q '"success":true'; then
-        success "Content appended successfully"
-    fi
+    local elisp_call="(journelly-batch-append-to-date \"$file\" \"$date\" \"$content\")"
+    run_batch "$elisp_call"
+    success "Content appended to entry"
 }
 
-# Command: list
+# Parse list command
 cmd_list() {
-    local file="${1:-}"
-    shift || error "list requires FILE argument"
+    local file="$1"
+    shift
 
-    [[ -z "$file" ]] && error "FILE argument required"
-    [[ ! -f "$file" ]] && error "File not found: $file"
-
-    local limit=""
+    local limit="10"
 
+    # Parse options
     while [[ $# -gt 0 ]]; do
         case "$1" in
             --limit=*)
                 limit="${1#*=}"
+                shift
                 ;;
             *)
                 error "Unknown option: $1"
                 ;;
         esac
-        shift
     done
 
-    local elisp_call="(journelly-batch-list-entries \"$file\""
-    [[ -n "$limit" ]] && elisp_call="$elisp_call \"$limit\"" || elisp_call="$elisp_call nil"
-    elisp_call="$elisp_call)"
-
-    info "Listing recent entries..."
+    local elisp_call="(journelly-batch-list-entries \"$file\" $limit)"
     run_batch "$elisp_call"
 }
 
-# Command: search
+# Parse search command
 cmd_search() {
-    local file="${1:-}"
-    local query="${2:-}"
-    shift 2 || error "search requires FILE QUERY arguments"
-
-    [[ -z "$file" ]] && error "FILE argument required"
-    [[ -z "$query" ]] && error "QUERY argument required"
-    [[ ! -f "$file" ]] && error "File not found: $file"
+    local file="$1"
+    local query="$2"
 
     local elisp_call="(journelly-batch-search \"$file\" \"$query\")"
-
-    info "Searching for: $query"
     run_batch "$elisp_call"
 }
 
-# Command: get
+# Parse get command
 cmd_get() {
-    local file="${1:-}"
-    local date="${2:-}"
-    local time="${3:-}"
-    shift 2 || error "get requires FILE DATE arguments"
+    local file="$1"
+    local date="$2"
 
-    [[ -z "$file" ]] && error "FILE argument required"
-    [[ -z "$date" ]] && error "DATE argument required"
-    [[ ! -f "$file" ]] && error "File not found: $file"
-
-    local elisp_call="(journelly-batch-get-entry \"$file\" \"$date\""
-    [[ -n "$time" ]] && elisp_call="$elisp_call \"$time\"" || elisp_call="$elisp_call nil"
-    elisp_call="$elisp_call)"
-
-    info "Getting entry for: $date${time:+ at $time}"
+    local elisp_call="(journelly-batch-get-entry \"$file\" \"$date\")"
     run_batch "$elisp_call"
 }
 
-# Main
+# Main command dispatcher
 main() {
-    check_deps
-    parse_args "$@"
+    if [[ $# -eq 0 ]]; then
+        usage
+    fi
+
+    local command="$1"
+    shift
+
+    case "$command" in
+        -h|--help|help)
+            usage
+            ;;
+        create)
+            check_deps
+            if [[ $# -lt 3 ]]; then
+                error "create requires: FILE LOCATION CONTENT"
+            fi
+            cmd_create "$@"
+            ;;
+        append)
+            check_deps
+            if [[ $# -lt 3 ]]; then
+                error "append requires: FILE DATE CONTENT"
+            fi
+            cmd_append "$@"
+            ;;
+        list)
+            check_deps
+            if [[ $# -lt 1 ]]; then
+                error "list requires: FILE"
+            fi
+            cmd_list "$@"
+            ;;
+        search)
+            check_deps
+            if [[ $# -lt 2 ]]; then
+                error "search requires: FILE QUERY"
+            fi
+            cmd_search "$@"
+            ;;
+        get)
+            check_deps
+            if [[ $# -lt 2 ]]; then
+                error "get requires: FILE DATE"
+            fi
+            cmd_get "$@"
+            ;;
+        *)
+            error "Unknown command: $command (try --help)"
+            ;;
+    esac
 }
 
 main "$@"
dots/.config/claude/skills/Org/tools/org-manager
@@ -1,17 +1,14 @@
 #!/usr/bin/env bash
 # org-manager - CLI tool for org-mode file manipulation via Emacs batch mode
-# Copyright (C) 2025 Vincent Demeester
-# Part of Claude Code Org skill
+# Copyright (C) 2026 Vincent Demeester
+# Loads elisp from site-lisp for consistency with interactive Emacs
 
 set -euo pipefail
 
 # Configuration
-# Resolve symlinks to get actual script location
-SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
-SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
-BATCH_FUNCTIONS="${BATCH_FUNCTIONS:-$SCRIPT_DIR/batch-functions.el}"
-DENOTE_FUNCTIONS="${DENOTE_FUNCTIONS:-$SCRIPT_DIR/denote-batch-functions.el}"
 EMACS="${EMACS:-emacs}"
+EMACS_DIR="${EMACS_DIR:-$HOME/.config/emacs}"
+SITE_LISP="$EMACS_DIR/site-lisp"
 
 # Debug mode
 DEBUG="${DEBUG:-0}"
@@ -45,12 +42,12 @@ check_deps() {
         error "Emacs not found. Set EMACS environment variable or install emacs."
     fi
 
-    if [[ ! -f "$BATCH_FUNCTIONS" ]]; then
-        error "batch-functions.el not found at: $BATCH_FUNCTIONS"
+    if [[ ! -d "$SITE_LISP" ]]; then
+        error "Emacs site-lisp directory not found at: $SITE_LISP"
     fi
 
-    if [[ ! -f "$DENOTE_FUNCTIONS" ]]; then
-        error "denote-batch-functions.el not found at: $DENOTE_FUNCTIONS"
+    if [[ ! -f "$SITE_LISP/org-batch-functions.el" ]]; then
+        error "org-batch-functions.el not found in site-lisp"
     fi
 }
 
@@ -62,11 +59,13 @@ run_elisp() {
 
     if [[ "$DEBUG" == "1" ]]; then
         "$EMACS" --batch --no-init-file \
-            --load "$BATCH_FUNCTIONS" \
+            --directory "$SITE_LISP" \
+            --load org-batch-functions.el \
             --eval "$elisp_code" 2>&1
     else
         "$EMACS" --batch --no-init-file \
-            --load "$BATCH_FUNCTIONS" \
+            --directory "$SITE_LISP" \
+            --load org-batch-functions.el \
             --eval "$elisp_code" 2>/dev/null
     fi
 }
@@ -79,11 +78,13 @@ run_denote_elisp() {
 
     if [[ "$DEBUG" == "1" ]]; then
         "$EMACS" --batch --no-init-file \
-            --load "$DENOTE_FUNCTIONS" \
+            --directory "$SITE_LISP" \
+            --load denote-batch-functions.el \
             --eval "$elisp_code" 2>&1
     else
         "$EMACS" --batch --no-init-file \
-            --load "$DENOTE_FUNCTIONS" \
+            --directory "$SITE_LISP" \
+            --load denote-batch-functions.el \
             --eval "$elisp_code" 2>/dev/null
     fi
 }
dots/.config/emacs/site-lisp/denote-batch-functions.el
@@ -0,0 +1,245 @@
+;;; denote-batch-functions.el --- Batch operations for denote notes -*- lexical-binding: t; no-byte-compile: t; -*-
+
+;; Copyright (C) 2025 Vincent Demeester
+
+;; This file provides batch mode functions for creating and manipulating
+;; denote-formatted notes from the command line using the denote package.
+;;
+;; NOTE: This file requires the denote package to be installed in your Emacs
+;; configuration. It cannot be byte-compiled in isolation.
+
+;;; Commentary:
+
+;; These functions enable Claude Code and other tools to create denote notes
+;; programmatically using Emacs batch mode. They wrap the denote package's
+;; functions for non-interactive use.
+;;
+;; Usage:
+;;   emacs --batch -l denote-batch-functions.el \
+;;     --eval "(denote-batch-create-note \"Title\" '(tag1 tag2))"
+
+;;; Code:
+
+(require 'denote)
+(require 'json)
+
+;; Ensure denote-directory is set
+(unless (boundp 'denote-directory)
+  (setq denote-directory "~/desktop/org/notes/"))
+
+;; Helper to output JSON
+(defun denote-batch--output-json (data)
+  "Output DATA as JSON to stdout."
+  (princ (json-encode data))
+  (princ "\n"))
+
+;; Main function: Create denote note using denote package
+(defun denote-batch-create-note (title keywords &optional signature category directory)
+  "Create a denote note with TITLE and KEYWORDS using denote package.
+KEYWORDS can be a list of strings or symbols (will be converted to strings).
+Optional SIGNATURE for automated notes (e.g., \"pkai\").
+Optional CATEGORY is stored in frontmatter.
+Optional DIRECTORY (defaults to denote-directory).
+
+Returns JSON with created file path."
+  (condition-case err
+      (let* ((denote-directory (or directory denote-directory))
+             ;; Convert keywords to strings if they're symbols
+             (keywords-list (mapcar (lambda (k)
+                                      (if (symbolp k)
+                                          (symbol-name k)
+                                        k))
+                                    keywords))
+             ;; Use denote to create the note
+             (filepath (denote title keywords-list 'org denote-directory nil nil signature nil)))
+
+        ;; Add category to frontmatter if provided
+        (when (and filepath category)
+          (with-current-buffer (find-file-noselect filepath)
+            (goto-char (point-min))
+            ;; Find end of frontmatter
+            (when (re-search-forward "^#\\+identifier:" nil t)
+              (end-of-line)
+              (insert (format "\n#+category: %s" category)))
+            (save-buffer)
+            (kill-buffer)))
+
+        ;; Return JSON result
+        (denote-batch--output-json
+         (list :success t
+               :filepath filepath
+               :message (format "Created note: %s" (file-name-nondirectory filepath)))))
+    (error
+     (denote-batch--output-json
+      (list :success :json-false
+            :error (error-message-string err))))))
+
+;; Create note with content from file
+(defun denote-batch-create-note-from-file (title keywords content-file &optional signature category directory)
+  "Create denote note with TITLE and KEYWORDS, reading content from CONTENT-FILE.
+KEYWORDS can be a list of strings or symbols (will be converted to strings).
+Uses denote package for creation, then appends content from file.
+Optional SIGNATURE, CATEGORY, DIRECTORY same as denote-batch-create-note."
+  (condition-case err
+      (let* ((denote-directory (or directory denote-directory))
+             ;; Convert keywords to strings if they're symbols
+             (keywords-list (mapcar (lambda (k)
+                                      (if (symbolp k)
+                                          (symbol-name k)
+                                        k))
+                                    keywords))
+             ;; Create the note using denote
+             (filepath (denote title keywords-list 'org denote-directory nil nil signature nil)))
+
+        ;; Add category if provided
+        (when category
+          (with-current-buffer (find-file-noselect filepath)
+            (goto-char (point-min))
+            (when (re-search-forward "^#\\+identifier:" nil t)
+              (end-of-line)
+              (insert (format "\n#+category: %s" category)))
+            (save-buffer)
+            (kill-buffer)))
+
+        ;; Append content from file
+        (when (file-exists-p content-file)
+          (with-current-buffer (find-file-noselect filepath)
+            (goto-char (point-max))
+            (insert-file-contents content-file)
+            (save-buffer)
+            (kill-buffer)))
+
+        ;; Return JSON result
+        (denote-batch--output-json
+         (list :success t
+               :filepath filepath
+               :message (format "Created note: %s" (file-name-nondirectory filepath)))))
+    (error
+     (denote-batch--output-json
+      (list :success :json-false
+            :error (error-message-string err))))))
+
+;; Add content to existing denote note
+(defun denote-batch-append-content (filepath content)
+  "Append CONTENT to existing denote note at FILEPATH."
+  (condition-case err
+      (progn
+        (unless (file-exists-p filepath)
+          (error "File does not exist: %s" filepath))
+        (with-current-buffer (find-file-noselect filepath)
+          (goto-char (point-max))
+          ;; Ensure we're on a new line
+          (unless (bolp)
+            (insert "\n"))
+          (insert "\n" content "\n")
+          (save-buffer)
+          (kill-buffer))
+        (denote-batch--output-json
+         (list :success t
+               :filepath filepath
+               :message "Content appended")))
+    (error
+     (denote-batch--output-json
+      (list :success :json-false
+            :error (error-message-string err))))))
+
+;; Update denote note using denote-rename functions
+(defun denote-batch-update-frontmatter (filepath &optional new-title new-keywords new-category)
+  "Update frontmatter of denote note at FILEPATH.
+Optional NEW-TITLE to change title.
+Optional NEW-KEYWORDS (list of symbols) to change keywords.
+Optional NEW-CATEGORY to update category.
+
+Uses denote-rename-file-using-front-matter when possible."
+  (condition-case err
+      (progn
+        (unless (file-exists-p filepath)
+          (error "File does not exist: %s" filepath))
+
+        (with-current-buffer (find-file-noselect filepath)
+          ;; Update title in frontmatter
+          (when new-title
+            (goto-char (point-min))
+            (when (re-search-forward "^#\\+title:[ \t]*\\(.*\\)$" nil t)
+              (replace-match new-title nil nil nil 1)))
+
+          ;; Update keywords in frontmatter
+          (when new-keywords
+            (goto-char (point-min))
+            (when (re-search-forward "^#\\+filetags:[ \t]*\\(.*\\)$" nil t)
+              (let ((tags-string (concat ":" (mapconcat #'symbol-name new-keywords ":") ":")))
+                (replace-match tags-string nil nil nil 1))))
+
+          ;; Update category
+          (when new-category
+            (goto-char (point-min))
+            (if (re-search-forward "^#\\+category:[ \t]*\\(.*\\)$" nil t)
+                (replace-match new-category nil nil nil 1)
+              ;; Add category if it doesn't exist
+              (when (re-search-forward "^#\\+identifier:" nil t)
+                (end-of-line)
+                (insert (format "\n#+category: %s" new-category)))))
+
+          (save-buffer)
+
+          ;; Use denote-rename-file-using-front-matter to update filename
+          (when (or new-title new-keywords)
+            (denote-rename-file-using-front-matter filepath))
+
+          (kill-buffer))
+
+        (denote-batch--output-json
+         (list :success t
+               :filepath filepath
+               :message "Frontmatter updated")))
+    (error
+     (denote-batch--output-json
+      (list :success :json-false
+            :error (error-message-string err))))))
+
+;; Read denote note metadata using denote functions
+(defun denote-batch-read-metadata (filepath)
+  "Read metadata from denote note at FILEPATH using denote functions.
+Returns JSON with title, keywords, identifier, signature, date, and category."
+  (condition-case err
+      (progn
+        (unless (file-exists-p filepath)
+          (error "File does not exist: %s" filepath))
+
+        ;; Use denote's built-in metadata retrieval
+        (let* ((file-type (denote-filetype-heuristics filepath))
+               (title (denote-retrieve-title-value filepath file-type))
+               (keywords (denote-extract-keywords-from-path filepath))
+               (identifier (denote-retrieve-filename-identifier filepath))
+               (signature (denote-retrieve-filename-signature filepath))
+               (date-string nil)
+               (category nil))
+
+          ;; Get date and category from frontmatter
+          (with-temp-buffer
+            (insert-file-contents filepath)
+            (goto-char (point-min))
+            (when (re-search-forward "^#\\+date:[ \t]*\\(.*\\)$" nil t)
+              (setq date-string (match-string 1)))
+            (goto-char (point-min))
+            (when (re-search-forward "^#\\+category:[ \t]*\\(.*\\)$" nil t)
+              (setq category (match-string 1))))
+
+          ;; Return JSON
+          (denote-batch--output-json
+           (list :success t
+                 :title title
+                 :keywords keywords
+                 :identifier identifier
+                 :signature (or signature "")
+                 :date date-string
+                 :category (or category "")
+                 :filepath filepath))))
+    (error
+     (denote-batch--output-json
+      (list :success :json-false
+            :error (error-message-string err))))))
+
+(provide 'denote-batch-functions)
+
+;;; denote-batch-functions.el ends here
dots/.config/emacs/site-lisp/journelly-batch-functions.el
@@ -0,0 +1,474 @@
+;;; journelly-batch-functions.el --- Batch functions for Journelly journal entries -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 Vincent Demeester
+
+;; Author: Vincent Demeester <vincent@demeester.fr>
+;; Keywords: org-mode, journelly, batch
+;; Version: 1.0.0
+
+;;; Commentary:
+
+;; Emacs batch mode functions for manipulating Journelly.org journal files.
+;; Journelly is an iOS app that stores journal entries in org-mode format.
+;;
+;; Format:
+;; - Single file with entries in reverse chronological order (newest first)
+;; - Each entry is a top-level heading: * [YYYY-MM-DD Day HH:MM] @ Location
+;; - Optional PROPERTIES drawer with GPS/weather metadata
+;; - Free-form org-mode content
+;;
+;; Functions:
+;; - journelly-batch-create-entry: Create new journal entry
+;; - journelly-batch-create-entry-auto: Create entry with automatic location/weather
+;; - journelly-batch-append-to-today: Append to today's entry
+;; - journelly-batch-list-entries: List recent entries
+;; - journelly-batch-search: Search entry content
+;; - journelly-batch-get-entry: Get specific entry by date/time
+;;
+;; Usage:
+;;   emacs --batch \
+;;     --load journelly-batch-functions.el \
+;;     --eval "(journelly-batch-create-entry \
+;;               \"~/desktop/org/Journelly.org\" \
+;;               \"Home\" \
+;;               \"Entry content\")"
+
+;;; Code:
+
+(require 'org)
+(require 'org-element)
+(require 'json)
+
+;; Load location/weather functions if available
+(let ((location-weather-file
+       (expand-file-name "journelly-location-weather.el"
+                        (file-name-directory (or load-file-name buffer-file-name)))))
+  (when (file-exists-p location-weather-file)
+    (load location-weather-file)))
+
+;; Declare functions from journelly-location-weather.el (loaded conditionally above)
+(declare-function journelly-get-location "journelly-location-weather")
+(declare-function journelly-get-weather "journelly-location-weather")
+
+;;; Utility functions
+
+(defun journelly--format-timestamp ()
+  "Generate org-mode timestamp for current time: [YYYY-MM-DD Day HH:MM]."
+  (format-time-string "[%Y-%m-%d %a %H:%M]"))
+
+(defun journelly--format-date-only ()
+  "Generate date only: YYYY-MM-DD."
+  (format-time-string "%Y-%m-%d"))
+
+(defun journelly--parse-timestamp (heading)
+  "Extract timestamp from HEADING.
+Expected format: * [YYYY-MM-DD Day HH:MM] @ Location
+Returns the timestamp string or nil."
+  (when (string-match "\\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\) \\([A-Z][a-z][a-z]\\) \\([0-9]\\{2\\}:[0-9]\\{2\\}\\)\\]" heading)
+    (match-string 0 heading)))
+
+(defun journelly--parse-location (heading)
+  "Extract location from HEADING.
+Expected format: * [YYYY-MM-DD Day HH:MM] @ Location
+Returns the location string or nil."
+  (when (string-match "@ \\(.+\\)$" heading)
+    (match-string 1 heading)))
+
+(defun journelly--make-heading (location)
+  "Create journal entry heading with current timestamp and LOCATION."
+  (format "* %s @ %s" (journelly--format-timestamp) location))
+
+(defun journelly--make-properties (latitude longitude temperature condition symbol)
+  "Create PROPERTIES drawer with GPS and weather data.
+LATITUDE, LONGITUDE, TEMPERATURE, CONDITION, SYMBOL are optional strings.
+Returns nil if no properties provided."
+  (let ((props '()))
+    (when latitude
+      (push (format ":LATITUDE: %s" latitude) props))
+    (when longitude
+      (push (format ":LONGITUDE: %s" longitude) props))
+    (when temperature
+      (push (format ":WEATHER_TEMPERATURE: %s" temperature) props))
+    (when condition
+      (push (format ":WEATHER_CONDITION: %s" condition) props))
+    (when symbol
+      (push (format ":WEATHER_SYMBOL: %s" symbol) props))
+    (when props
+      (concat ":PROPERTIES:\n"
+              (mapconcat 'identity (nreverse props) "\n")
+              "\n:END:\n"))))
+
+(defun journelly--find-header-end (buffer)
+  "Find the end of the Journelly header in BUFFER.
+Returns the position after the :end: line, or nil if not found."
+  (with-current-buffer buffer
+    (goto-char (point-min))
+    (when (re-search-forward "^:end:$" nil t)
+      (forward-line 1)
+      (point))))
+
+(defun journelly--json-response (success data &optional message)
+  "Create JSON response object.
+SUCCESS is boolean, DATA is any JSON-serializable value.
+MESSAGE is optional error/success message."
+  (let ((response `((success . ,success)
+                    (data . ,data))))
+    (when message
+      (push `(message . ,message) response))
+    (json-encode response)))
+
+(defun journelly--output-json (success data &optional message)
+  "Output JSON response to stdout.
+SUCCESS is boolean, DATA is the response data, MESSAGE is optional."
+  (princ (journelly--json-response success data message))
+  (terpri))
+
+;;; Main functions
+
+(defun journelly-batch-create-entry (file location content &optional latitude longitude temperature condition symbol content-file)
+  "Create new journal entry in FILE.
+
+Arguments:
+  FILE: Path to Journelly.org file
+  LOCATION: Location string (e.g., \"Home\", \"Kyushu\")
+  CONTENT: Entry content (can be empty string)
+  LATITUDE: Optional GPS latitude
+  LONGITUDE: Optional GPS longitude
+  TEMPERATURE: Optional temperature (e.g., \"15,2°C\")
+  CONDITION: Optional weather condition (e.g., \"Cloudy\")
+  SYMBOL: Optional weather symbol (e.g., \"cloud\")
+  CONTENT-FILE: Optional path to file containing content
+
+If CONTENT-FILE is provided, reads content from file instead of CONTENT arg.
+
+Returns JSON with success status and entry details."
+  (condition-case err
+      (let ((actual-content (if content-file
+                                (with-temp-buffer
+                                  (insert-file-contents content-file)
+                                  (buffer-string))
+                              content)))
+        (with-temp-buffer
+          (insert-file-contents file)
+
+          ;; Find where to insert (after header)
+          (let ((insert-pos (journelly--find-header-end (current-buffer))))
+            (unless insert-pos
+              (error "Could not find Journelly header end marker (:end:)"))
+
+            (goto-char insert-pos)
+
+            ;; Build entry
+            (let ((heading (journelly--make-heading location))
+                  (properties (journelly--make-properties
+                              latitude longitude temperature condition symbol))
+                  (timestamp (journelly--format-timestamp)))
+
+              ;; Insert entry
+              (insert heading "\n")
+              (when properties
+                (insert properties))
+              (when (and actual-content (not (string-empty-p actual-content)))
+                (insert actual-content)
+                (unless (string-suffix-p "\n" actual-content)
+                  (insert "\n")))
+              (insert "\n")  ;; Blank line after entry
+
+              ;; Write back to file
+              (write-region (point-min) (point-max) file)
+
+              ;; Return success
+              (journelly--output-json
+               t
+               `((timestamp . ,timestamp)
+                 (location . ,location)
+                 (has-properties . ,(if properties t :json-false))
+                 (file . ,file))
+               "Journal entry created successfully")))))
+    (error
+     (journelly--output-json nil nil (error-message-string err)))))
+
+(defun journelly-batch-append-to-today (file content &optional content-file)
+  "Append CONTENT to today's journal entry in FILE.
+
+Arguments:
+  FILE: Path to Journelly.org file
+  CONTENT: Content to append
+  CONTENT-FILE: Optional path to file containing content
+
+If no entry exists for today, returns error.
+Returns JSON with success status."
+  (condition-case err
+      (let ((actual-content (if content-file
+                                (with-temp-buffer
+                                  (insert-file-contents content-file)
+                                  (buffer-string))
+                              content))
+            (today-date (journelly--format-date-only)))
+        (with-temp-buffer
+          (insert-file-contents file)
+          (goto-char (point-min))
+
+          ;; Find today's entry
+          (let ((found nil)
+                (search-pattern (format "^\\* \\[%s " today-date)))
+            (while (and (not found)
+                       (re-search-forward search-pattern nil t))
+              (setq found t))
+
+            (unless found
+              (error "No journal entry found for today (%s)" today-date))
+
+            ;; Move to end of this entry (before next heading or end of file)
+            (forward-line 1)
+            (if (re-search-forward "^\\* \\[" nil t)
+                (progn
+                  (beginning-of-line)
+                  (backward-char 1))  ;; Before the newline
+              (goto-char (point-max)))
+
+            ;; Insert content
+            (insert "\n" actual-content)
+            (unless (string-suffix-p "\n" actual-content)
+              (insert "\n"))
+
+            ;; Write back
+            (write-region (point-min) (point-max) file)
+
+            ;; Return success
+            (journelly--output-json
+             t
+             `((date . ,today-date)
+               (file . ,file))
+             "Content appended to today's entry"))))
+    (error
+     (journelly--output-json nil nil (error-message-string err)))))
+
+(defun journelly-batch-list-entries (file &optional limit)
+  "List recent journal entries from FILE.
+
+Arguments:
+  FILE: Path to Journelly.org file
+  LIMIT: Optional number of entries to return (default 10)
+
+Returns JSON with list of entries."
+  (condition-case err
+      (let ((max-entries (or (and limit (string-to-number limit)) 10))
+            (entries '()))
+        (with-temp-buffer
+          (insert-file-contents file)
+          (goto-char (point-min))
+
+          ;; Skip header
+          (journelly--find-header-end (current-buffer))
+
+          ;; Parse entries
+          (while (and (< (length entries) max-entries)
+                     (re-search-forward "^\\* \\(\\[.*?\\]\\) @ \\(.+\\)$" nil t))
+            (let ((timestamp (match-string 1))
+                  (location (match-string 2))
+                  (has-properties nil)
+                  (content-preview ""))
+
+              ;; Check for properties
+              (save-excursion
+                (forward-line 1)
+                (when (looking-at "^:PROPERTIES:")
+                  (setq has-properties t)))
+
+              ;; Get content preview (first 100 chars)
+              (save-excursion
+                (forward-line 1)
+                (when has-properties
+                  (re-search-forward "^:END:$" nil t)
+                  (forward-line 1))
+                (let ((content-start (point)))
+                  (if (re-search-forward "^\\* \\[" nil t)
+                      (beginning-of-line)
+                    (goto-char (point-max)))
+                  (setq content-preview
+                        (string-trim
+                         (buffer-substring-no-properties content-start (point))))
+                  (when (> (length content-preview) 100)
+                    (setq content-preview
+                          (concat (substring content-preview 0 100) "...")))))
+
+              (push `((timestamp . ,timestamp)
+                     (location . ,location)
+                     (has-properties . ,(if has-properties t :json-false))
+                     (preview . ,content-preview))
+                    entries)))
+
+          ;; Return results (already in reverse chronological from file)
+          (journelly--output-json t (nreverse entries))))
+    (error
+     (journelly--output-json nil nil (error-message-string err)))))
+
+(defun journelly-batch-search (file query)
+  "Search journal entries in FILE for QUERY.
+
+Arguments:
+  FILE: Path to Journelly.org file
+  QUERY: Search string (case-insensitive)
+
+Returns JSON with matching entries."
+  (condition-case err
+      (let ((matches '())
+            (query-lower (downcase query)))
+        (with-temp-buffer
+          (insert-file-contents file)
+          (goto-char (point-min))
+
+          ;; Skip header
+          (journelly--find-header-end (current-buffer))
+
+          ;; Search entries
+          (while (re-search-forward "^\\* \\(\\[.*?\\]\\) @ \\(.+\\)$" nil t)
+            (let ((timestamp (match-string 1))
+                  (location (match-string 2))
+                  (entry-start (point))
+                  (entry-end nil)
+                  (entry-content ""))
+
+              ;; Find entry end
+              (save-excursion
+                (if (re-search-forward "^\\* \\[" nil t)
+                    (setq entry-end (match-beginning 0))
+                  (setq entry-end (point-max))))
+
+              ;; Get entry content
+              (setq entry-content
+                    (buffer-substring-no-properties entry-start entry-end))
+
+              ;; Check if query matches
+              (when (string-match-p query-lower (downcase entry-content))
+                (push `((timestamp . ,timestamp)
+                       (location . ,location)
+                       (content . ,(string-trim entry-content)))
+                      matches))))
+
+          ;; Return results
+          (journelly--output-json
+           t
+           (nreverse matches)
+           (format "Found %d matching entries" (length matches)))))
+    (error
+     (journelly--output-json nil nil (error-message-string err)))))
+
+(defun journelly-batch-get-entry (file date &optional time)
+  "Get specific journal entry from FILE by DATE and optional TIME.
+
+Arguments:
+  FILE: Path to Journelly.org file
+  DATE: Date string (YYYY-MM-DD)
+  TIME: Optional time string (HH:MM)
+
+Returns JSON with entry details or error if not found."
+  (condition-case err
+      (let ((search-pattern (if time
+                               (format "^\\* \\[%s .* %s\\]" date time)
+                             (format "^\\* \\[%s " date)))
+            (found nil))
+        (with-temp-buffer
+          (insert-file-contents file)
+          (goto-char (point-min))
+
+          ;; Skip header
+          (journelly--find-header-end (current-buffer))
+
+          ;; Search for entry
+          (when (re-search-forward search-pattern nil t)
+            (beginning-of-line)
+            (when (looking-at "^\\* \\(\\[.*?\\]\\) @ \\(.+\\)$")
+              (let ((timestamp (match-string 1))
+                    (location (match-string 2))
+                    (entry-end nil)
+                    (has-properties nil)
+                    (properties nil)
+                    (content ""))
+
+                (forward-line 1)
+
+                ;; Check for properties
+                (when (looking-at "^:PROPERTIES:")
+                  (setq has-properties t)
+                  (let ((props-start (point)))
+                    (re-search-forward "^:END:$" nil t)
+                    (setq properties
+                          (buffer-substring-no-properties props-start (point)))
+                    (forward-line 1)))
+
+                ;; Get content
+                (let ((content-start (point)))
+                  (if (re-search-forward "^\\* \\[" nil t)
+                      (setq entry-end (match-beginning 0))
+                    (setq entry-end (point-max)))
+                  (setq content
+                        (string-trim
+                         (buffer-substring-no-properties content-start entry-end))))
+
+                (setq found `((timestamp . ,timestamp)
+                            (location . ,location)
+                            (has-properties . ,(if has-properties t :json-false))
+                            (properties . ,(or properties ""))
+                            (content . ,content))))))
+
+          (if found
+              (journelly--output-json t found)
+            (journelly--output-json
+             nil
+             nil
+             (format "No entry found for %s%s"
+                    date
+                    (if time (format " at %s" time) ""))))))
+    (error
+     (journelly--output-json nil nil (error-message-string err)))))
+
+(defun journelly-batch-create-entry-auto (file content &optional content-file use-location use-weather)
+  "Create journal entry with automatic location and/or weather detection.
+
+Arguments:
+  FILE: Path to Journelly.org file
+  CONTENT: Entry content
+  CONTENT-FILE: Optional path to file containing content
+  USE-LOCATION: If non-nil, automatically detect location
+  USE-WEATHER: If non-nil, automatically detect weather
+
+Requires journelly-location-weather.el to be loaded.
+
+Returns JSON with success status and entry details."
+  (condition-case err
+      (progn
+        (unless (fboundp 'journelly-get-location)
+          (error "Location/weather functions not available. Load journelly-location-weather.el"))
+
+        (let ((location-data (when use-location (journelly-get-location)))
+              (weather-data (when use-weather (journelly-get-weather)))
+              (actual-content (if content-file
+                                 (with-temp-buffer
+                                   (insert-file-contents content-file)
+                                   (buffer-string))
+                               content)))
+
+          ;; Extract data
+          (let ((city (when location-data (cdr (assoc 'city location-data))))
+                (lat (when location-data (cdr (assoc 'lat location-data))))
+                (lon (when location-data (cdr (assoc 'lon location-data))))
+                (temp (when weather-data (cdr (assoc 'temperature weather-data))))
+                (cond (when weather-data (cdr (assoc 'condition weather-data))))
+                (symbol (when weather-data (cdr (assoc 'symbol weather-data)))))
+
+            ;; Create entry
+            (journelly-batch-create-entry
+             file
+             (or city "Unknown")
+             actual-content
+             lat lon temp cond symbol nil))))
+    (error
+     (journelly--output-json nil nil (error-message-string err)))))
+
+;;; Provide
+
+(provide 'journelly-batch-functions)
+
+;;; journelly-batch-functions.el ends here
dots/.config/emacs/site-lisp/journelly-location-weather.el
@@ -0,0 +1,257 @@
+;;; journelly-location-weather.el --- Location and weather helpers for Journelly -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 Vincent Demeester
+
+;; Author: Vincent Demeester <vincent@demeester.fr>
+;; Keywords: org-mode, journelly, location, weather
+;; Version: 1.0.0
+
+;;; Commentary:
+
+;; Emacs Lisp functions to get location and weather data for Journelly journal entries.
+;;
+;; Location:
+;; - Uses IP-based geolocation (ipinfo.io)
+;; - Returns city name and GPS coordinates
+;; - Caches results for 1 hour
+;;
+;; Weather:
+;; - Uses wttr.in weather service
+;; - Returns temperature, condition, and iOS SF Symbol
+;; - Caches results for 30 minutes
+;; - Intelligent day/night symbol mapping
+;;
+;; Functions:
+;; - journelly-get-location: Get current location via IP geolocation
+;; - journelly-get-weather: Get current weather
+;; - journelly-batch-get-location: Batch mode wrapper for location
+;; - journelly-batch-get-weather: Batch mode wrapper for weather
+;;
+;; Usage (batch mode):
+;;   emacs --batch \
+;;     --load journelly-location-weather.el \
+;;     --eval "(journelly-batch-get-location)"
+;;
+;;   emacs --batch \
+;;     --load journelly-location-weather.el \
+;;     --eval "(journelly-batch-get-weather)"
+
+;;; Code:
+
+(require 'url)
+(require 'json)
+
+;;; Configuration
+
+(defvar journelly-cache-dir
+  (expand-file-name "journal" (or (getenv "XDG_CACHE_HOME")
+                                   (expand-file-name ".cache" "~")))
+  "Directory for caching location and weather data.")
+
+(defvar journelly-location-cache-timeout 3600
+  "Location cache timeout in seconds (default: 1 hour).")
+
+(defvar journelly-weather-cache-timeout 1800
+  "Weather cache timeout in seconds (default: 30 minutes).")
+
+;;; Utility functions
+
+(defun journelly--ensure-cache-dir ()
+  "Ensure cache directory exists."
+  (unless (file-exists-p journelly-cache-dir)
+    (make-directory journelly-cache-dir t)))
+
+(defun journelly--cache-file (key)
+  "Get cache file path for KEY."
+  (expand-file-name (format "%s.json" key) journelly-cache-dir))
+
+(defun journelly--cache-valid-p (cache-file timeout)
+  "Check if CACHE-FILE is valid within TIMEOUT seconds."
+  (when (file-exists-p cache-file)
+    (let* ((file-time (nth 5 (file-attributes cache-file)))
+           (current-time (current-time))
+           (age (float-time (time-subtract current-time file-time))))
+      (< age timeout))))
+
+(defun journelly--read-cache (cache-file)
+  "Read JSON data from CACHE-FILE."
+  (when (file-exists-p cache-file)
+    (with-temp-buffer
+      (insert-file-contents cache-file)
+      (goto-char (point-min))
+      (json-read))))
+
+(defun journelly--write-cache (cache-file data)
+  "Write DATA as JSON to CACHE-FILE."
+  (journelly--ensure-cache-dir)
+  (with-temp-file cache-file
+    (insert (json-encode data))))
+
+(defun journelly--fetch-url (url)
+  "Fetch URL and return parsed JSON response."
+  (let ((url-request-method "GET")
+        (url-request-extra-headers '(("User-Agent" . "Emacs/journelly"))))
+    (with-current-buffer (url-retrieve-synchronously url t nil 10)
+      (goto-char (point-min))
+      ;; Skip HTTP headers
+      (re-search-forward "^$")
+      (forward-line)
+      (let ((json-data (json-read)))
+        (kill-buffer)
+        json-data))))
+
+(defun journelly--is-night-p ()
+  "Return t if current time is night (20:00-06:00)."
+  (let ((hour (string-to-number (format-time-string "%H"))))
+    (or (>= hour 20) (< hour 6))))
+
+;;; Location functions
+
+(defun journelly--map-weather-symbol (description &optional is-night)
+  "Map weather DESCRIPTION to iOS SF Symbol name.
+If IS-NIGHT is non-nil, return night-appropriate symbols."
+  (let ((desc (downcase description)))
+    (if is-night
+        ;; Night conditions
+        (cond
+         ((string-match-p "\\(clear\\|sunny\\)" desc) "moon.stars")
+         ((string-match-p "partly.*cloud" desc) "cloud.moon")
+         ((string-match-p "\\(rain\\|drizzle\\|shower\\)" desc) "cloud.moon.rain")
+         (t "cloud.moon"))
+      ;; Day conditions
+      (cond
+       ((string-match-p "\\(clear\\|sunny\\)" desc) "sun.max")
+       ((string-match-p "partly.*cloud" desc) "cloud.sun")
+       ((string-match-p "\\(cloudy\\|overcast\\)" desc) "cloud")
+       ((string-match-p "heavy.*rain" desc) "cloud.heavyrain")
+       ((string-match-p "\\(rain\\|shower\\)" desc) "cloud.rain")
+       ((string-match-p "\\(drizzle\\|light.*rain\\)" desc) "cloud.drizzle")
+       ((string-match-p "snow" desc) "cloud.snow")
+       ((string-match-p "sleet" desc) "cloud.sleet")
+       ((string-match-p "\\(fog\\|mist\\)" desc) "cloud.fog")
+       ((string-match-p "\\(haze\\|smoke\\)" desc) "smoke")
+       ((string-match-p "wind" desc) "wind")
+       ((string-match-p "\\(thunder\\|storm\\)" desc) "cloud.bolt")
+       (t "cloud")))))
+
+(defun journelly-get-location (&optional no-cache)
+  "Get current location via IP geolocation.
+Returns alist with city, latitude, and longitude.
+If NO-CACHE is non-nil, fetch fresh data ignoring cache."
+  (let ((cache-file (journelly--cache-file "location")))
+    (if (and (not no-cache)
+             (journelly--cache-valid-p cache-file journelly-location-cache-timeout))
+        ;; Return cached data
+        (journelly--read-cache cache-file)
+      ;; Fetch fresh data
+      (let* ((response (journelly--fetch-url "https://ipinfo.io/json"))
+             (city (cdr (assoc 'city response)))
+             (loc (cdr (assoc 'loc response)))
+             (coords (when loc (split-string loc ",")))
+             (lat (when coords (car coords)))
+             (lon (when coords (cadr coords)))
+             (data `((city . ,(or city "Unknown"))
+                    (lat . ,(or lat "0"))
+                    (lon . ,(or lon "0")))))
+        ;; Cache the result
+        (journelly--write-cache cache-file data)
+        data))))
+
+(defun journelly-get-weather (&optional location no-cache)
+  "Get current weather for LOCATION (city name or coordinates).
+If LOCATION is nil, uses current location via IP.
+Returns alist with temperature, condition, and symbol.
+If NO-CACHE is non-nil, fetch fresh data ignoring cache."
+  (let* ((loc (or location ""))
+         (cache-key (if (string-empty-p loc) "weather-auto" (format "weather-%s" loc)))
+         (cache-file (journelly--cache-file cache-key)))
+    (if (and (not no-cache)
+             (journelly--cache-valid-p cache-file journelly-weather-cache-timeout))
+        ;; Return cached data
+        (journelly--read-cache cache-file)
+      ;; Fetch fresh data
+      (let* ((url (if (string-empty-p loc)
+                     "https://wttr.in/?format=j1"
+                   (format "https://wttr.in/%s?format=j1" (url-hexify-string loc))))
+             (response (journelly--fetch-url url))
+             (current (aref (cdr (assoc 'current_condition response)) 0))
+             (temp-c (cdr (assoc 'temp_C current)))
+             (weather-desc-array (cdr (assoc 'weatherDesc current)))
+             (weather-desc (cdr (assoc 'value (aref weather-desc-array 0))))
+             (temperature (format "%s°C" temp-c))
+             (is-night (journelly--is-night-p))
+             (symbol (journelly--map-weather-symbol weather-desc is-night))
+             (data `((temperature . ,temperature)
+                    (condition . ,weather-desc)
+                    (symbol . ,symbol))))
+        ;; Cache the result
+        (journelly--write-cache cache-file data)
+        data))))
+
+;;; Batch mode functions
+
+(defun journelly-batch-get-location (&optional format no-cache)
+  "Batch mode: Get location and print to stdout.
+FORMAT can be: json (default), city, coords, lat, lon, or all.
+If NO-CACHE is non-nil, ignore cache."
+  (let* ((format-type (or format "json"))
+         (data (journelly-get-location no-cache))
+         (city (cdr (assoc 'city data)))
+         (lat (cdr (assoc 'lat data)))
+         (lon (cdr (assoc 'lon data))))
+    (cond
+     ((string= format-type "json")
+      (princ (json-encode data))
+      (terpri))
+     ((string= format-type "city")
+      (princ city)
+      (terpri))
+     ((string= format-type "coords")
+      (princ (format "%s,%s" lat lon))
+      (terpri))
+     ((string= format-type "lat")
+      (princ lat)
+      (terpri))
+     ((string= format-type "lon")
+      (princ lon)
+      (terpri))
+     ((string= format-type "all")
+      (princ (format "%s (%s,%s)" city lat lon))
+      (terpri))
+     (t
+      (error "Unknown format: %s" format-type)))))
+
+(defun journelly-batch-get-weather (&optional location format no-cache)
+  "Batch mode: Get weather and print to stdout.
+LOCATION is optional city name or coordinates.
+FORMAT can be: json (default), temperature, condition, symbol, or all.
+If NO-CACHE is non-nil, ignore cache."
+  (let* ((format-type (or format "json"))
+         (data (journelly-get-weather location no-cache))
+         (temperature (cdr (assoc 'temperature data)))
+         (condition (cdr (assoc 'condition data)))
+         (symbol (cdr (assoc 'symbol data))))
+    (cond
+     ((string= format-type "json")
+      (princ (json-encode data))
+      (terpri))
+     ((string= format-type "temperature")
+      (princ temperature)
+      (terpri))
+     ((string= format-type "condition")
+      (princ condition)
+      (terpri))
+     ((string= format-type "symbol")
+      (princ symbol)
+      (terpri))
+     ((string= format-type "all")
+      (princ (format "%s %s (%s)" temperature condition symbol))
+      (terpri))
+     (t
+      (error "Unknown format: %s" format-type)))))
+
+;;; Provide
+
+(provide 'journelly-location-weather)
+
+;;; journelly-location-weather.el ends here
dots/.config/emacs/site-lisp/journelly-manager
@@ -0,0 +1,326 @@
+#!/usr/bin/env bash
+# journelly-manager - CLI tool for Journelly journal file manipulation via Emacs batch mode
+# Copyright (C) 2026 Vincent Demeester
+# Loads elisp from site-lisp for consistency with interactive Emacs
+
+set -euo pipefail
+
+# Configuration
+EMACS="${EMACS:-emacs}"
+EMACS_DIR="${EMACS_DIR:-$HOME/.config/emacs}"
+SITE_LISP="$EMACS_DIR/site-lisp"
+
+# Debug mode
+DEBUG="${DEBUG:-0}"
+
+# Colors for output (if not outputting JSON)
+if [[ -t 1 ]] && [[ "${JSON_OUTPUT:-1}" != "1" ]]; then
+    RED='\033[0;31m'
+    GREEN='\033[0;32m'
+    YELLOW='\033[1;33m'
+    BLUE='\033[0;34m'
+    NC='\033[0m' # No Color
+else
+    RED=''
+    GREEN=''
+    YELLOW=''
+    BLUE=''
+    NC=''
+fi
+
+# Error handling
+error() {
+    echo -e "${RED}Error: $*${NC}" >&2
+    exit 1
+}
+
+debug() {
+    if [[ "$DEBUG" == "1" ]]; then
+        echo -e "${YELLOW}Debug: $*${NC}" >&2
+    fi
+}
+
+info() {
+    if [[ "${JSON_OUTPUT:-1}" != "1" ]]; then
+        echo -e "${BLUE}$*${NC}" >&2
+    fi
+}
+
+success() {
+    if [[ "${JSON_OUTPUT:-1}" != "1" ]]; then
+        echo -e "${GREEN}$*${NC}" >&2
+    fi
+}
+
+# Check dependencies
+check_deps() {
+    if ! command -v "$EMACS" &> /dev/null; then
+        error "Emacs not found. Set EMACS environment variable or install emacs."
+    fi
+
+    if [[ ! -d "$SITE_LISP" ]]; then
+        error "Emacs site-lisp directory not found at: $SITE_LISP"
+    fi
+
+    if [[ ! -f "$SITE_LISP/journelly-batch-functions.el" ]]; then
+        error "journelly-batch-functions.el not found in site-lisp"
+    fi
+}
+
+# Run Emacs batch command
+run_batch() {
+    local function_call="$1"
+    debug "Running: $EMACS --batch --directory \"$SITE_LISP\" --load journelly-batch-functions.el --eval \"$function_call\""
+
+    "$EMACS" --batch \
+        --directory "$SITE_LISP" \
+        --load journelly-batch-functions.el \
+        --eval "$function_call" 2>&1
+}
+
+# Usage information
+usage() {
+    cat <<EOF
+journelly-manager - CLI tool for managing Journelly journal files
+
+USAGE:
+    journelly-manager <command> [arguments]
+
+COMMANDS:
+    create FILE LOCATION CONTENT [options]
+        Create new journal entry
+
+        Options:
+            --latitude=LAT         GPS latitude
+            --longitude=LON        GPS longitude
+            --temperature=TEMP     Temperature (e.g., "15,2°C")
+            --condition=COND       Weather condition (e.g., "Cloudy")
+            --symbol=SYM           Weather symbol (e.g., "cloud")
+
+        Examples:
+            journelly-manager create ~/desktop/org/Journelly.org "Home" "Today was great"
+
+            journelly-manager create ~/desktop/org/Journelly.org "Kyushu" \\
+                "Work session notes" \\
+                --latitude=48.8534 --longitude=2.3488 \\
+                --temperature="15°C" --condition="Cloudy" --symbol="cloud"
+
+    append FILE DATE CONTENT
+        Append to existing journal entry by date (YYYY-MM-DD)
+
+        Examples:
+            journelly-manager append ~/desktop/org/Journelly.org \\
+                "2026-01-16" "Additional thoughts"
+
+    list FILE [--limit=N]
+        List recent journal entries
+
+        Options:
+            --limit=N    Number of entries to show (default: 10)
+
+        Examples:
+            journelly-manager list ~/desktop/org/Journelly.org
+            journelly-manager list ~/desktop/org/Journelly.org --limit=20
+
+    search FILE QUERY
+        Search journal entries for keyword
+
+        Examples:
+            journelly-manager search ~/desktop/org/Journelly.org "wireguard"
+
+    get FILE DATE
+        Get specific entry by date (YYYY-MM-DD)
+
+        Examples:
+            journelly-manager get ~/desktop/org/Journelly.org "2026-01-16"
+
+ENVIRONMENT:
+    EMACS           Emacs executable (default: emacs)
+    EMACS_DIR       Emacs config directory (default: ~/.config/emacs)
+    DEBUG           Set to 1 for debug output
+    JSON_OUTPUT     Set to 1 for JSON output (no colors)
+
+EXAMPLES:
+    # Create entry with auto location/weather (use get-location/get-weather)
+    LOC=\$(get-location --json)
+    WEATHER=\$(get-weather --json)
+    journelly-manager create ~/desktop/org/Journelly.org \\
+        "\$(echo \$LOC | jq -r .city)" "Entry content" \\
+        --latitude="\$(echo \$LOC | jq -r .lat)" \\
+        --longitude="\$(echo \$LOC | jq -r .lon)" \\
+        --temperature="\$(echo \$WEATHER | jq -r .temperature)" \\
+        --condition="\$(echo \$WEATHER | jq -r .condition)" \\
+        --symbol="\$(echo \$WEATHER | jq -r .symbol)"
+
+    # Append to today's entry
+    journelly-manager append ~/desktop/org/Journelly.org \\
+        "\$(date +%Y-%m-%d)" "More thoughts"
+
+    # Search entries
+    journelly-manager search ~/desktop/org/Journelly.org "claude"
+
+EOF
+    exit 0
+}
+
+# Parse create command
+cmd_create() {
+    local file="$1"
+    local location="$2"
+    local content="$3"
+    shift 3
+
+    local latitude="" longitude="" temperature="" condition="" symbol=""
+
+    # Parse options
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --latitude=*)
+                latitude="${1#*=}"
+                shift
+                ;;
+            --longitude=*)
+                longitude="${1#*=}"
+                shift
+                ;;
+            --temperature=*)
+                temperature="${1#*=}"
+                shift
+                ;;
+            --condition=*)
+                condition="${1#*=}"
+                shift
+                ;;
+            --symbol=*)
+                symbol="${1#*=}"
+                shift
+                ;;
+            *)
+                error "Unknown option: $1"
+                ;;
+        esac
+    done
+
+    # Build Emacs Lisp call
+    local elisp_call="(journelly-batch-create-entry \"$file\" \"$location\" \"$content\""
+
+    if [[ -n "$latitude" ]]; then elisp_call="$elisp_call :latitude \"$latitude\""; fi
+    if [[ -n "$longitude" ]]; then elisp_call="$elisp_call :longitude \"$longitude\""; fi
+    if [[ -n "$temperature" ]]; then elisp_call="$elisp_call :temperature \"$temperature\""; fi
+    if [[ -n "$condition" ]]; then elisp_call="$elisp_call :condition \"$condition\""; fi
+    if [[ -n "$symbol" ]]; then elisp_call="$elisp_call :symbol \"$symbol\""; fi
+
+    elisp_call="$elisp_call)"
+
+    run_batch "$elisp_call"
+    success "Journal entry created"
+}
+
+# Parse append command
+cmd_append() {
+    local file="$1"
+    local date="$2"
+    local content="$3"
+
+    local elisp_call="(journelly-batch-append-to-date \"$file\" \"$date\" \"$content\")"
+    run_batch "$elisp_call"
+    success "Content appended to entry"
+}
+
+# Parse list command
+cmd_list() {
+    local file="$1"
+    shift
+
+    local limit="10"
+
+    # Parse options
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --limit=*)
+                limit="${1#*=}"
+                shift
+                ;;
+            *)
+                error "Unknown option: $1"
+                ;;
+        esac
+    done
+
+    local elisp_call="(journelly-batch-list-entries \"$file\" $limit)"
+    run_batch "$elisp_call"
+}
+
+# Parse search command
+cmd_search() {
+    local file="$1"
+    local query="$2"
+
+    local elisp_call="(journelly-batch-search \"$file\" \"$query\")"
+    run_batch "$elisp_call"
+}
+
+# Parse get command
+cmd_get() {
+    local file="$1"
+    local date="$2"
+
+    local elisp_call="(journelly-batch-get-entry \"$file\" \"$date\")"
+    run_batch "$elisp_call"
+}
+
+# Main command dispatcher
+main() {
+    if [[ $# -eq 0 ]]; then
+        usage
+    fi
+
+    local command="$1"
+    shift
+
+    case "$command" in
+        -h|--help|help)
+            usage
+            ;;
+        create)
+            check_deps
+            if [[ $# -lt 3 ]]; then
+                error "create requires: FILE LOCATION CONTENT"
+            fi
+            cmd_create "$@"
+            ;;
+        append)
+            check_deps
+            if [[ $# -lt 3 ]]; then
+                error "append requires: FILE DATE CONTENT"
+            fi
+            cmd_append "$@"
+            ;;
+        list)
+            check_deps
+            if [[ $# -lt 1 ]]; then
+                error "list requires: FILE"
+            fi
+            cmd_list "$@"
+            ;;
+        search)
+            check_deps
+            if [[ $# -lt 2 ]]; then
+                error "search requires: FILE QUERY"
+            fi
+            cmd_search "$@"
+            ;;
+        get)
+            check_deps
+            if [[ $# -lt 2 ]]; then
+                error "get requires: FILE DATE"
+            fi
+            cmd_get "$@"
+            ;;
+        *)
+            error "Unknown command: $command (try --help)"
+            ;;
+    esac
+}
+
+main "$@"
dots/.config/emacs/site-lisp/journelly.el
@@ -0,0 +1,256 @@
+;;; journelly.el --- Smart Journelly capture with location/weather -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Vincent Demeester
+
+;; Author: Vincent Demeester <vincent@demeester.fr>
+;; Keywords: org-mode, journelly, journal, capture
+;; Version: 1.0.0
+
+;;; Commentary:
+
+;; Smart capture system for Journelly.org journal entries.
+;;
+;; Features:
+;; - Create-or-append behavior: first entry creates, subsequent append
+;; - Automatic location and weather via IP geolocation
+;; - Separate entries for regular journal and Claude sessions
+;; - Full org-capture integration
+;;
+;; Entry formats:
+;; - Regular: * [YYYY-MM-DD Day HH:MM] @ hostname in Location
+;; - Claude:  * [YYYY-MM-DD Day HH:MM] @ Claude session
+;;
+;; Usage:
+;;   (require 'journelly)
+;;   ;; Use org-capture: C-c o c then 'j' or 'J'
+;;   ;; Or quick functions: M-x journelly-quick-entry
+
+;;; Code:
+
+(require 'org)
+(require 'org-capture)
+
+;; Load location/weather helpers from site-lisp
+(require 'journelly-location-weather)
+
+;; Declare functions from journelly-location-weather.el
+(declare-function journelly-get-location "journelly-location-weather")
+(declare-function journelly-get-weather "journelly-location-weather")
+
+;;; Helper Functions
+
+(defun journelly--find-todays-entry ()
+  "Find today's journal entry in Journelly.org.
+Returns the position of the entry if found, nil otherwise."
+  (let ((today (format-time-string "%Y-%m-%d")))
+    (save-excursion
+      (goto-char (point-min))
+      ;; Skip the file header
+      (when (re-search-forward "^:END:" nil t)
+        (forward-line))
+      ;; Search for today's regular entry (not Claude session)
+      (when (re-search-forward
+             (format "^\\* \\[%s[^]]+\\] @ \\([^C]\\|C[^l]\\)" today) nil t)
+        (line-beginning-position)))))
+
+(defun journelly--find-todays-claude-entry ()
+  "Find today's Claude session entry in Journelly.org.
+Returns the position of the entry if found, nil otherwise."
+  (let ((today (format-time-string "%Y-%m-%d")))
+    (save-excursion
+      (goto-char (point-min))
+      ;; Skip the file header
+      (when (re-search-forward "^:END:" nil t)
+        (forward-line))
+      ;; Search for today's Claude session entry
+      (when (re-search-forward
+             (format "^\\* \\[%s.*@ Claude session" today) nil t)
+        (line-beginning-position)))))
+
+(defun journelly--get-entry-end ()
+  "Get the end position of current org entry (before next heading)."
+  (save-excursion
+    (org-end-of-subtree t)
+    (point)))
+
+(defun journelly--goto-insert-position ()
+  "Navigate to the correct insert position for new journal entries.
+Goes after the file header but before existing entries."
+  (goto-char (point-min))
+  (if (re-search-forward "^:END:" nil t)
+      (progn
+        (forward-line)
+        (point))
+    ;; No header found, go to beginning
+    (goto-char (point-min))
+    (point)))
+
+(defun journelly--create-entry-heading (&optional custom-location)
+  "Create journal entry heading with timestamp, hostname, and location.
+Format: * [YYYY-MM-DD Day HH:MM] @ hostname in Location
+If CUSTOM-LOCATION is provided, uses it instead of IP geolocation.
+Returns the heading string."
+  (let* ((location-data (when (not custom-location) (journelly-get-location)))
+         (city (or custom-location (cdr (assoc 'city location-data))))
+         (hostname (system-name))
+         (timestamp (format-time-string "[%Y-%m-%d %a %H:%M]")))
+    (if (string= custom-location "Claude session")
+        ;; Claude session - no location
+        (format "* %s @ Claude session" timestamp)
+      ;; Regular entry - hostname in location
+      (format "* %s @ %s in %s" timestamp hostname city))))
+
+(defun journelly--create-entry-properties (&optional skip-weather)
+  "Create properties drawer with location and weather metadata.
+If SKIP-WEATHER is non-nil, only includes location data.
+Returns the properties string."
+  (let* ((location-data (journelly-get-location))
+         (lat (cdr (assoc 'lat location-data)))
+         (lon (cdr (assoc 'lon location-data)))
+         (weather-data (unless skip-weather (journelly-get-weather)))
+         (temp (when weather-data (cdr (assoc 'temperature weather-data))))
+         (condition (when weather-data (cdr (assoc 'condition weather-data))))
+         (symbol (when weather-data (cdr (assoc 'symbol weather-data)))))
+    (concat ":PROPERTIES:\n"
+            (format ":LATITUDE: %s\n" lat)
+            (format ":LONGITUDE: %s\n" lon)
+            (when temp (format ":WEATHER_TEMPERATURE: %s\n" temp))
+            (when condition (format ":WEATHER_CONDITION: %s\n" condition))
+            (when symbol (format ":WEATHER_SYMBOL: %s\n" symbol))
+            ":END:")))
+
+;;; Capture Target Functions
+
+(defun journelly-capture-target ()
+  "Org capture target function for smart Journelly entries.
+Creates new entry if today's doesn't exist, or appends to existing."
+  (let ((entry-pos (journelly--find-todays-entry)))
+    (if entry-pos
+        ;; Entry exists - go to end to append
+        (progn
+          (goto-char entry-pos)
+          (goto-char (journelly--get-entry-end))
+          ;; Move back one line to insert before the blank line
+          (forward-line -1)
+          (end-of-line)
+          (insert "\n\n")
+          (point))
+      ;; Entry doesn't exist - create new one
+      (journelly--goto-insert-position)
+      (insert (journelly--create-entry-heading) "\n")
+      (insert (journelly--create-entry-properties) "\n")
+      (insert "\n")  ;; Blank line for content
+      (point))))
+
+(defun journelly-claude-capture-target ()
+  "Org capture target function for Claude session entries.
+Creates new entry if today's Claude session doesn't exist, or appends."
+  (let ((entry-pos (journelly--find-todays-claude-entry)))
+    (if entry-pos
+        ;; Entry exists - go to end to append
+        (progn
+          (goto-char entry-pos)
+          (goto-char (journelly--get-entry-end))
+          ;; Move back one line to insert before the blank line
+          (forward-line -1)
+          (end-of-line)
+          (insert "\n\n")
+          (point))
+      ;; Entry doesn't exist - create new one
+      (journelly--goto-insert-position)
+      (insert (journelly--create-entry-heading "Claude session") "\n")
+      (insert (journelly--create-entry-properties t) "\n")  ;; Skip weather for Claude
+      (insert "\n")  ;; Blank line for content
+      (point))))
+
+;;; Interactive Functions
+
+(defun journelly-quick-entry (content)
+  "Quick journal entry with CONTENT.
+Location/weather added automatically.
+Creates today's entry if it doesn't exist, or appends to existing entry."
+  (interactive "sJournal: ")
+  (with-current-buffer (find-file-noselect org-journelly-file)
+    (save-excursion
+      (journelly-capture-target)
+      (insert content))
+    (save-buffer))
+  (message "Journal entry added"))
+
+(defun journelly-claude-session (summary)
+  "Add Claude session SUMMARY to today's Claude session entry.
+Creates entry if it doesn't exist, or appends to existing entry."
+  (interactive "sClaude session: ")
+  (let ((timestamp (format-time-string "%H:%M")))
+    (with-current-buffer (find-file-noselect org-journelly-file)
+      (save-excursion
+        (journelly-claude-capture-target)
+        (insert (format "- %s :: %s" timestamp summary)))
+      (save-buffer))
+    (message "Claude session logged")))
+
+(defun journelly-open ()
+  "Open Journelly.org file and jump to today's entry or top."
+  (interactive)
+  (find-file org-journelly-file)
+  (let ((entry-pos (journelly--find-todays-entry)))
+    (if entry-pos
+        (goto-char entry-pos)
+      ;; No entry today, go to insert position
+      (journelly--goto-insert-position)))
+  (recenter-top-bottom 0))
+
+;;; Capture Templates Setup
+
+(defun journelly-setup-capture-templates ()
+  "Setup org-capture templates for Journelly.
+Call this after org-capture is loaded and org-journelly-file is defined."
+
+  ;; Remove old journelly templates if they exist
+  (setq org-capture-templates
+        (seq-remove (lambda (x) (member (car x) '("j" "J")))
+                    org-capture-templates))
+
+  ;; Smart default journal entry (creates or appends)
+  (add-to-list 'org-capture-templates
+               `("j" "📝 Journal entry" plain
+                 (file+function ,org-journelly-file journelly-capture-target)
+                 "%?"
+                 :empty-lines 0
+                 :unnarrowed t)
+               t)
+
+  ;; Claude session entry (creates or appends)
+  (add-to-list 'org-capture-templates
+               `("J" "🤖 Claude session" plain
+                 (file+function ,org-journelly-file journelly-claude-capture-target)
+                 "- %(format-time-string \"%H:%M\") :: %?"
+                 :empty-lines 0
+                 :unnarrowed t)
+               t))
+
+;;; Keybindings
+
+(defun journelly-setup-keybindings ()
+  "Setup keybindings for Journelly functions."
+  (global-set-key (kbd "C-c j j") 'journelly-quick-entry)
+  (global-set-key (kbd "C-c j J") 'journelly-claude-session)
+  (global-set-key (kbd "C-c j o") 'journelly-open))
+
+;;; Auto-setup
+
+;; Setup keybindings when loaded
+(journelly-setup-keybindings)
+
+;; Setup capture templates after org-capture is loaded
+(with-eval-after-load 'org-capture
+  (journelly-setup-capture-templates))
+
+;; Setup register for quick access
+(with-eval-after-load 'emacs
+  (when (boundp 'org-journelly-file)
+    (set-register ?j `(file . ,org-journelly-file))))
+
+(provide 'journelly)
+
+;;; journelly.el ends here
dots/.config/emacs/site-lisp/org-batch-functions.el
@@ -0,0 +1,1140 @@
+;;; batch-functions.el --- Org-mode batch operations -*- lexical-binding: t -*-
+
+;; Copyright (C) 2025 Vincent Demeester
+
+;; Author: Vincent Demeester <vincent@sbr.pm>
+;; Keywords: org, batch, automation
+;; Version: 1.0.0
+
+;;; Commentary:
+
+;; Elisp functions for batch-mode org-mode file manipulation.
+;; Provides read and write operations on org files without GUI.
+;; Used by org-manager CLI tool and Claude Code skills.
+
+;;; Code:
+
+(require 'org)
+(require 'org-element)
+(require 'json)
+
+;;; Configuration
+
+(setq org-todo-keywords
+      '((sequence "STRT(s)" "NEXT(n)" "TODO(t)" "WAIT(w)" "|" "DONE(d!)" "CANX(c@/!)")))
+
+(setq org-priority-highest ?1  ; Highest priority (character '1' = ASCII 49)
+      org-priority-lowest ?5   ; Lowest priority (character '5' = ASCII 53)
+      org-priority-default ?4) ; Default priority (character '4' = ASCII 52)
+
+;; Silence interactive prompts
+(setq org-use-fast-todo-selection nil
+      org-log-done nil  ; Will be set per-operation as needed
+      org-agenda-inhibit-startup t)
+
+;;; Utility Functions
+
+(defun org-batch--format-timestamp (timestamp)
+  "Format TIMESTAMP element to string."
+  (when timestamp
+    (org-element-property :raw-value timestamp)))
+
+(defun org-batch--priority-to-number (priority-char)
+  "Convert PRIORITY-CHAR to number (1-5).
+Priority '1'=1, '2'=2, '3'=3, '4'=4, '5'=5."
+  (when priority-char
+    (- priority-char 48)))  ; '1'(49) → 1, '2'(50) → 2, ..., '5'(53) → 5
+
+(defun org-batch--number-to-priority (num)
+  "Convert NUM (1-5) to priority character.
+1='1', 2='2', 3='3', 4='4', 5='5'."
+  (when (and num (>= num 1) (<= num 5))
+    (+ num 48)))  ; 1 → '1'(49), 2 → '2'(50), ..., 5 → '5'(53)
+
+(defun org-batch--element-to-alist (element)
+  "Convert org ELEMENT to JSON-friendly alist."
+  `((heading . ,(org-element-property :raw-value element))
+    (todo . ,(org-element-property :todo-keyword element))
+    (priority . ,(org-batch--priority-to-number
+                  (org-element-property :priority element)))
+    (tags . ,(org-element-property :tags element))
+    (level . ,(org-element-property :level element))
+    (scheduled . ,(org-batch--format-timestamp
+                   (org-element-property :scheduled element)))
+    (deadline . ,(org-batch--format-timestamp
+                  (org-element-property :deadline element)))))
+
+;;; Read Operations
+
+(defun org-batch-list-todos (file &optional filter-state filter-priority filter-tags)
+  "List TODOs from FILE with optional filters.
+FILTER-STATE: String like \"NEXT\" or \"TODO\"
+FILTER-PRIORITY: Number 1-5 or list of numbers
+FILTER-Tags: List of tag strings (match any)"
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((todos '())
+          (priority-list (if (listp filter-priority)
+                             filter-priority
+                           (when filter-priority (list filter-priority)))))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl))
+                (priority (org-batch--priority-to-number
+                           (org-element-property :priority hl)))
+                (tags (org-element-property :tags hl)))
+            (when (and todo
+                       ;; State filter
+                       (or (null filter-state)
+                           (string= todo filter-state))
+                       ;; Priority filter
+                       (or (null priority-list)
+                           (member priority priority-list))
+                       ;; Tag filter (match any)
+                       (or (null filter-tags)
+                           (and tags (seq-intersection filter-tags tags))))
+              (push (org-batch--element-to-alist hl) todos)))))
+      (nreverse todos))))
+
+(defun org-batch-scheduled-today (file &optional date)
+  "Get items scheduled for DATE (default today) from FILE.
+DATE should be in format \"YYYY-MM-DD\" or \"today\"."
+  (let* ((target-date (if (or (null date) (string= date "today"))
+                          (format-time-string "%Y-%m-%d")
+                        date))
+         (items '()))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((scheduled (org-element-property :scheduled hl)))
+            (when scheduled
+              (let ((sched-val (org-element-property :raw-value scheduled)))
+                (when (string-match target-date sched-val)
+                  (push (org-batch--element-to-alist hl) items))))))))
+    (nreverse items)))
+
+(defun org-batch-by-section (file section-name)
+  "Get all TODOs under SECTION-NAME (level 1 heading) in FILE."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((in-section nil)
+          (todos '()))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((level (org-element-property :level hl))
+                (heading (org-element-property :raw-value hl))
+                (todo (org-element-property :todo-keyword hl)))
+            ;; Track which section we're in
+            (when (= level 1)
+              (setq in-section (string= heading section-name)))
+            ;; Collect TODOs in this section (level > 1)
+            (when (and in-section todo (> level 1))
+              (push (org-batch--element-to-alist hl) todos)))))
+      (nreverse todos))))
+
+(defun org-batch-count-by-state (file)
+  "Count TODOs in FILE by state.
+Returns alist with counts for each state."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((counts '((total . 0) (TODO . 0) (NEXT . 0) (STRT . 0) (WAIT . 0) (DONE . 0) (CANX . 0))))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl)))
+            (when todo
+              (setcdr (assoc 'total counts) (1+ (cdr (assoc 'total counts))))
+              (let ((state-entry (assoc (intern todo) counts)))
+                (when state-entry
+                  (setcdr state-entry (1+ (cdr state-entry)))))))))
+      counts)))
+
+(defun org-batch-search (file search-term)
+  "Search for SEARCH-TERM in FILE content.
+Returns list of matching headlines with context."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((matches '())
+          (search-regexp (regexp-quote search-term)))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let* ((begin (org-element-property :begin hl))
+                 (end (org-element-property :end hl))
+                 (content (buffer-substring-no-properties begin end)))
+            (when (string-match-p search-regexp content)
+              (push (cons (cons 'matched-in
+                                (if (string-match-p search-regexp
+                                                    (org-element-property :raw-value hl))
+                                    "heading"
+                                  "content"))
+                          (org-batch--element-to-alist hl))
+                    matches)))))
+      (nreverse matches))))
+
+(defun org-batch-get-sections (file)
+  "Get list of all level-1 sections in FILE."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((sections '()))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (when (= 1 (org-element-property :level hl))
+            (push (org-element-property :raw-value hl) sections))))
+      (nreverse sections))))
+
+(defun org-batch-get-children (file heading-name)
+  "Get all direct children TODOs of HEADING-NAME in FILE.
+Returns only immediate children (level = parent + 1), not all descendants."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((children '())
+          (parent-level nil)
+          (in-subtree nil))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((heading (org-element-property :raw-value hl))
+                (level (org-element-property :level hl)))
+            (cond
+             ;; Found the parent heading
+             ((string= heading heading-name)
+              (setq parent-level level
+                    in-subtree t))
+             ;; We're past the parent's subtree (same or lower level)
+             ((and in-subtree parent-level (<= level parent-level))
+              (setq in-subtree nil))
+             ;; We're in the subtree and this is a direct child
+             ((and in-subtree parent-level (= level (1+ parent-level)))
+              (push (org-batch--element-to-alist hl) children))))))
+      (nreverse children))))
+
+(defun org-batch-get-todo-content (file heading-name)
+  "Get full content of TODO with HEADING-NAME in FILE.
+Returns alist with metadata, properties, and body content.
+Returns nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((found nil)
+          (result nil))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (when (and (not found)
+                     (string= (org-element-property :raw-value hl) heading-name))
+            (setq found t)
+            ;; Build result with metadata
+            (let* ((basic-data (org-batch--element-to-alist hl))
+                   (properties (org-batch--extract-properties hl))
+                   (content (org-batch--extract-content hl)))
+              (setq result (append basic-data
+                                   (list (cons 'properties properties)
+                                         (cons 'content content))))))))
+      result)))
+
+(defun org-batch--extract-properties (element)
+  "Extract properties drawer from ELEMENT as alist."
+  (let ((properties '())
+        (begin (org-element-property :begin element))
+        (end (org-element-property :contents-end element)))
+    (when (and begin end)
+      (save-excursion
+        (goto-char begin)
+        (forward-line 1)  ; Skip heading line
+        ;; Look for :PROPERTIES: drawer
+        (when (re-search-forward "^[ \t]*:PROPERTIES:[ \t]*$" end t)
+          (let ((drawer-start (point)))
+            (when (re-search-forward "^[ \t]*:END:[ \t]*$" end t)
+              (let ((drawer-end (match-beginning 0)))
+                (goto-char drawer-start)
+                ;; Extract each property
+                (while (re-search-forward "^[ \t]*:\\([^:\n]+\\):[ \t]*\\(.*\\)$" drawer-end t)
+                  (let ((key (match-string 1))
+                        (value (match-string 2)))
+                    (push (cons key value) properties)))))))))
+    (nreverse properties)))
+
+(defun org-batch--extract-content (element)
+  "Extract body content from ELEMENT (excluding properties drawer).
+Returns the text content without the heading line or properties."
+  (let ((end (org-element-property :contents-end element))
+        (contents-begin (org-element-property :contents-begin element)))
+    (if (and contents-begin end)
+        (save-excursion
+          (let ((content-text (buffer-substring-no-properties contents-begin end)))
+            ;; Remove properties drawer if present
+            (with-temp-buffer
+              (insert content-text)
+              (goto-char (point-min))
+              ;; Remove SCHEDULED/DEADLINE lines (they're in metadata)
+              (while (re-search-forward "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\|CLOSED\\):.*$" nil t)
+                (replace-match ""))
+              ;; Remove properties drawer
+              (goto-char (point-min))
+              (when (re-search-forward "^[ \t]*:PROPERTIES:[ \t]*$" nil t)
+                (let ((drawer-start (match-beginning 0)))
+                  (when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
+                    (delete-region drawer-start (point))
+                    ;; Remove trailing newline
+                    (when (looking-at "\n")
+                      (delete-char 1)))))
+              ;; Trim whitespace
+              (goto-char (point-min))
+              (while (re-search-forward "^[ \t]+$" nil t)
+                (replace-match ""))
+              (goto-char (point-min))
+              (skip-chars-forward "\n")
+              (delete-region (point-min) (point))
+              (goto-char (point-max))
+              (skip-chars-backward "\n")
+              (delete-region (point) (point-max))
+              (buffer-string))))
+      "")))
+
+;;; Write Operations
+
+(defun org-batch--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."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (let ((org-log-done (if (string= new-state "DONE") 'time nil)))
+          (org-todo new-state))
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-add-todo (file section heading &optional scheduled priority tags)
+  "Add new TODO to FILE in SECTION with HEADING.
+SCHEDULED: Date string \"YYYY-MM-DD\"
+PRIORITY: Number 1-5
+TAGS: List of tag strings"
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((section-regexp (concat "^\\* " (regexp-quote section) "$")))
+      (if (re-search-forward section-regexp nil t)
+          (progn
+            ;; Find end of section
+            (org-end-of-subtree t)
+            ;; Insert new TODO
+            (insert "\n** TODO ")
+            (when priority
+              (insert (format "[#%d] " priority)))
+            (insert heading)
+            (when tags
+              (insert " :" (string-join tags ":") ":"))
+            (insert "\n")
+            (when scheduled
+              (insert (format "SCHEDULED: <%s>\n" scheduled)))
+            (insert ":PROPERTIES:\n")
+            (insert (format ":CREATED: [%s]\n"
+                            (format-time-string "%Y-%m-%d %a %H:%M")))
+            (insert ":END:\n")
+            (write-region (point-min) (point-max) file)
+            t)
+        ;; Section not found
+        nil))))
+
+(defun org-batch-schedule-task (file heading date)
+  "Schedule task with HEADING in FILE for DATE.
+DATE should be \"YYYY-MM-DD\" format.
+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\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-schedule nil date)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-set-deadline (file heading date)
+  "Set deadline for task with HEADING in FILE to DATE.
+DATE should be \"YYYY-MM-DD\" format.
+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\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-deadline nil date)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-set-priority (file heading priority)
+  "Set PRIORITY (1-5) for task with HEADING in FILE.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\(\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\)\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading)))
+          (priority-cookie (format " [#%d]" priority)))
+      (when (re-search-forward heading-regexp nil t)
+        (goto-char (match-end 1))  ; Move to end of TODO keyword
+        ;; Remove existing priority if present
+        (when (looking-at " \\[#[1-5]\\]")
+          (delete-region (point) (+ (point) 5)))
+        ;; Insert new priority (note: priority-cookie already has leading space)
+        (insert priority-cookie)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-add-tags (file heading new-tags)
+  "Add NEW-TAGS to task with HEADING in FILE.
+NEW-TAGS is a list of tag strings to add (existing tags are preserved).
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (let* ((current-tags (org-get-tags))
+               (combined-tags (delete-dups (append current-tags new-tags))))
+          (org-set-tags combined-tags)
+          (write-region (point-min) (point-max) file)
+          (setq found t)))
+      found)))
+
+(defun org-batch-remove-tags (file heading tags-to-remove)
+  "Remove TAGS-TO-REMOVE from task with HEADING in FILE.
+TAGS-TO-REMOVE is a list of tag strings to remove.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (let* ((current-tags (org-get-tags))
+               (remaining-tags (seq-difference current-tags tags-to-remove)))
+          (org-set-tags remaining-tags)
+          (write-region (point-min) (point-max) file)
+          (setq found t)))
+      found)))
+
+(defun org-batch-replace-tags (file heading new-tags)
+  "Replace all tags on task with HEADING in FILE with NEW-TAGS.
+NEW-TAGS is a list of tag strings.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\) \\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-set-tags new-tags)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-list-all-tags (file)
+  "List all unique tags used in FILE.
+Returns sorted list of tag strings."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((all-tags '()))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((tags (org-element-property :tags hl)))
+            (when tags
+              (setq all-tags (append all-tags tags))))))
+      (sort (delete-dups all-tags) #'string<))))
+
+(defun org-batch-get-overdue (file)
+  "Get all tasks with DEADLINE before today from FILE.
+Returns list of overdue tasks with their metadata."
+  (let ((today (format-time-string "%Y-%m-%d"))
+        (overdue-items '()))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl))
+                (deadline (org-element-property :deadline hl)))
+            ;; Only include active TODOs with deadlines
+            (when (and todo
+                       (not (member todo '("DONE" "CANX")))
+                       deadline)
+              (let ((deadline-date (org-element-property :raw-value deadline)))
+                ;; Extract YYYY-MM-DD from deadline
+                (when (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" deadline-date)
+                  (let ((dl-str (match-string 0 deadline-date)))
+                    ;; Compare dates (string comparison works for YYYY-MM-DD)
+                    (when (string< dl-str today)
+                      (push (org-batch--element-to-alist hl) overdue-items)))))))))
+      (nreverse overdue-items))))
+
+(defun org-batch-get-upcoming (file &optional days)
+  "Get tasks with DEADLINE or SCHEDULED in next DAYS from FILE.
+DAYS defaults to 7. Returns list of upcoming tasks."
+  (let* ((days-count (or days 7))
+         (today (current-time))
+         (future-date (time-add today (days-to-time days-count)))
+         (today-str (format-time-string "%Y-%m-%d" today))
+         (future-str (format-time-string "%Y-%m-%d" future-date))
+         (upcoming-items '()))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl))
+                (scheduled (org-element-property :scheduled hl))
+                (deadline (org-element-property :deadline hl)))
+            ;; Only include active TODOs
+            (when (and todo (not (member todo '("DONE" "CANX"))))
+              (let ((dates-to-check '()))
+                ;; Collect scheduled and deadline dates
+                (when scheduled
+                  (push (org-element-property :raw-value scheduled) dates-to-check))
+                (when deadline
+                  (push (org-element-property :raw-value deadline) dates-to-check))
+                ;; Check if any date is in range
+                (dolist (date-str dates-to-check)
+                  (when (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" date-str)
+                    (let ((task-date (match-string 0 date-str)))
+                      ;; Date is upcoming if: today <= date <= future
+                      (when (and (not (string< task-date today-str))
+                                 (not (string< future-str task-date)))
+                        (push (org-batch--element-to-alist hl) upcoming-items)
+                        ;; Stop checking other dates for this task
+                        (setq dates-to-check nil))))))))))
+      ;; Remove duplicates and reverse
+      (delete-dups (nreverse upcoming-items)))))
+
+(defun org-batch-get-property (file heading property-name)
+  "Get value of PROPERTY-NAME for task with HEADING in FILE.
+Returns property value string or nil if not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (setq found (org-entry-get nil property-name)))
+      found)))
+
+(defun org-batch-set-property (file heading property-name value)
+  "Set PROPERTY-NAME to VALUE for task with HEADING in FILE.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-set-property property-name value)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-list-properties (file heading)
+  "List all properties for task with HEADING in FILE.
+Returns alist of (property . value) pairs."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((properties '())
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        ;; Get all properties using org-entry-properties
+        (let ((props (org-entry-properties nil 'standard)))
+          (dolist (prop props)
+            (let ((key (car prop))
+                  (val (cdr prop)))
+              ;; Filter out special properties we don't want to show
+              (unless (member key '("CATEGORY" "BLOCKED" "ALLTAGS" "FILE" "PRIORITY_COOKIE"
+                                   "TODO" "TAGS" "ITEM"))
+                (push (cons key val) properties))))))
+      (nreverse properties))))
+
+(defun org-batch-archive-done (file)
+  "Archive all DONE and CANX items in FILE.
+Returns count of archived items."
+  (let ((count 0)
+        (archive-location nil))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      ;; Find and archive DONE items
+      (goto-char (point-min))
+      (while (re-search-forward "^\\*+ \\(DONE\\|CANX\\) " nil t)
+        (org-back-to-heading)
+        ;; Get archive location from properties if available
+        (let ((local-archive (org-entry-get nil "ARCHIVE")))
+          (when local-archive
+            (setq archive-location local-archive)))
+        (condition-case err
+            (progn
+              (when archive-location
+                (let ((org-archive-location archive-location))
+                  (org-archive-subtree)))
+              (unless archive-location
+                (org-archive-subtree))
+              (setq count (1+ count)))
+          (error
+           (message "Failed to archive: %s" (error-message-string err)))))
+      ;; Save changes
+      (write-region (point-min) (point-max) file))
+    count))
+
+;;; Bulk Operations
+
+(defun org-batch-bulk-update-state (file filter-state new-state &optional filter-tags)
+  "Update all tasks matching FILTER-STATE in FILE to NEW-STATE.
+FILTER-TAGS: Optional list of tags to further filter tasks.
+Returns count of updated tasks."
+  (let ((count 0))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (goto-char (point-min))
+      (while (re-search-forward org-heading-regexp nil t)
+        (org-back-to-heading t)
+        (let ((todo (org-get-todo-state))
+              (tags (org-get-tags)))
+          (when (and todo
+                     (string= todo filter-state)
+                     ;; Tag filter (match any)
+                     (or (null filter-tags)
+                         (and tags (seq-intersection filter-tags tags))))
+            (let ((org-log-done (if (string= new-state "DONE") 'time nil)))
+              (org-todo new-state))
+            (setq count (1+ count))))
+        (forward-line 1))
+      (write-region (point-min) (point-max) file))
+    count))
+
+(defun org-batch-bulk-add-tags (file filter-state new-tags)
+  "Add NEW-TAGS to all tasks with FILTER-STATE in FILE.
+Returns count of updated tasks."
+  (let ((count 0))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (goto-char (point-min))
+      (while (re-search-forward org-heading-regexp nil t)
+        (org-back-to-heading t)
+        (let ((todo (org-get-todo-state)))
+          (when (and todo (string= todo filter-state))
+            (let* ((current-tags (org-get-tags))
+                   (combined-tags (delete-dups (append current-tags new-tags))))
+              (org-set-tags combined-tags))
+            (setq count (1+ count))))
+        (forward-line 1))
+      (write-region (point-min) (point-max) file))
+    count))
+
+(defun org-batch-bulk-set-priority (file filter-state priority)
+  "Set PRIORITY for all tasks with FILTER-STATE in FILE.
+Returns count of updated tasks."
+  (let ((count 0)
+        (priority-cookie (format " [#%d]" priority)))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (goto-char (point-min))
+      (while (re-search-forward (concat "^\\(\\*+ " (regexp-quote filter-state) "\\) \\(?:\\[#[1-5]\\] \\)?") nil t)
+        (goto-char (match-end 1))
+        ;; Remove existing priority if present
+        (when (looking-at " \\[#[1-5]\\]")
+          (delete-region (point) (+ (point) 5)))
+        ;; Insert new priority
+        (insert priority-cookie)
+        (setq count (1+ count)))
+      (write-region (point-min) (point-max) file))
+    count))
+
+;;; Time Tracking
+
+(defun org-batch-clock-in (file heading)
+  "Clock in to task with HEADING in FILE.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-clock-in)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-clock-out (file)
+  "Clock out of currently clocked task in FILE.
+Returns t on success, nil if no active clock found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil))
+      ;; Find active clock line (has start time but no end time)
+      (when (re-search-forward "^\\([ \t]*CLOCK: \\)\\(\\[.*?\\]\\)$" nil t)
+        (let ((indent (match-string 1))
+              (start-time (match-string 2))
+              (end-time (format-time-string "[%Y-%m-%d %a %H:%M]")))
+          ;; Calculate duration
+          (let* ((start-ts (org-parse-time-string start-time))
+                 (start-encoded (apply #'encode-time start-ts))
+                 (end-encoded (current-time))
+                 (duration-seconds (float-time (time-subtract end-encoded start-encoded)))
+                 (hours (floor (/ duration-seconds 3600)))
+                 (minutes (floor (/ (mod duration-seconds 3600) 60))))
+            ;; Replace the line with closed clock entry
+            (replace-match (format "%s%s--%s => %2d:%02d" indent start-time end-time hours minutes))
+            (write-region (point-min) (point-max) file)
+            (setq found t))))
+      found)))
+
+(defun org-batch-get-active-clock (file)
+  "Get currently active clock in FILE.
+Returns alist with heading and clock-in time, or nil if no active clock."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((result nil))
+      ;; Find active clock line (no end time)
+      (when (re-search-forward "^[ \t]*CLOCK: \\(\\[.*?\\]\\)$" nil t)
+        (let ((clock-start (match-string 1)))
+          (org-back-to-heading)
+          (let ((heading (org-element-property :raw-value (org-element-at-point))))
+            (setq result `((heading . ,heading)
+                          (clock_start . ,clock-start))))))
+      result)))
+
+(defun org-batch-get-clocked-time (file heading)
+  "Get total clocked time for HEADING in FILE.
+Returns minutes as integer."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading)))
+          (total-minutes 0))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (save-restriction
+          (org-narrow-to-subtree)
+          (org-clock-sum)
+          (setq total-minutes (get-text-property (point) :org-clock-minutes))))
+      (or total-minutes 0))))
+
+;;; Statistics & Analytics
+
+(defun org-batch-get-statistics (file)
+  "Get comprehensive statistics about TODOs in FILE.
+Returns alist with counts, priorities, tags, and time data."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((total 0)
+          (by-state '())
+          (by-priority '())
+          (by-tag '())
+          (scheduled-count 0)
+          (deadline-count 0)
+          (overdue-count 0))
+      ;; Count all TODOs and gather stats
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl))
+                (priority (org-batch--priority-to-number
+                          (org-element-property :priority hl)))
+                (tags (org-element-property :tags hl))
+                (scheduled (org-element-property :scheduled hl))
+                (deadline (org-element-property :deadline hl)))
+            (when todo
+              (setq total (1+ total))
+              ;; Count by state
+              (let ((state-sym (intern todo)))
+                (if (assoc state-sym by-state)
+                    (setcdr (assoc state-sym by-state)
+                           (1+ (cdr (assoc state-sym by-state))))
+                  (push (cons state-sym 1) by-state)))
+              ;; Count by priority
+              (when priority
+                (if (assoc priority by-priority)
+                    (setcdr (assoc priority by-priority)
+                           (1+ (cdr (assoc priority by-priority))))
+                  (push (cons priority 1) by-priority)))
+              ;; Count by tag
+              (dolist (tag tags)
+                (if (assoc tag by-tag #'string=)
+                    (setcdr (assoc tag by-tag #'string=)
+                           (1+ (cdr (assoc tag by-tag #'string=))))
+                  (push (cons tag 1) by-tag)))
+              ;; Count scheduled/deadline
+              (when scheduled (setq scheduled-count (1+ scheduled-count)))
+              (when deadline (setq deadline-count (1+ deadline-count)))
+              ;; Count overdue
+              (when (and deadline (not (member todo '("DONE" "CANX"))))
+                (let ((deadline-date (org-element-property :raw-value deadline))
+                      (today (format-time-string "%Y-%m-%d")))
+                  (when (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" deadline-date)
+                    (let ((dl-str (match-string 0 deadline-date)))
+                      (when (string< dl-str today)
+                        (setq overdue-count (1+ overdue-count)))))))))))
+      ;; Return comprehensive stats
+      `((total . ,total)
+        (by_state . ,by-state)
+        (by_priority . ,by-priority)
+        (by_tag . ,by-tag)
+        (scheduled_count . ,scheduled-count)
+        (deadline_count . ,deadline-count)
+        (overdue_count . ,overdue-count)))))
+
+(defun org-batch-get-priority-distribution (file)
+  "Get distribution of tasks by priority in FILE.
+Returns alist mapping priority (1-5) to count."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((distribution '((1 . 0) (2 . 0) (3 . 0) (4 . 0) (5 . 0))))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl))
+                (priority (org-batch--priority-to-number
+                          (org-element-property :priority hl))))
+            (when (and todo priority)
+              (let ((entry (assoc priority distribution)))
+                (when entry
+                  (setcdr entry (1+ (cdr entry)))))))))
+      distribution)))
+
+(defun org-batch-get-tag-statistics (file)
+  "Get statistics about tag usage in FILE.
+Returns sorted list of (tag . count) pairs."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((tag-counts '()))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((tags (org-element-property :tags hl)))
+            (dolist (tag tags)
+              (if (assoc tag tag-counts #'string=)
+                  (setcdr (assoc tag tag-counts #'string=)
+                         (1+ (cdr (assoc tag tag-counts #'string=))))
+                (push (cons tag 1) tag-counts))))))
+      ;; Sort by count descending
+      (sort tag-counts (lambda (a b) (> (cdr a) (cdr b)))))))
+
+;;; Export & Reporting
+
+(defun org-batch-export-csv (file output-file)
+  "Export TODOs from FILE to CSV format in OUTPUT-FILE.
+Returns t on success."
+  (let ((todos (org-batch-list-todos file)))
+    (with-temp-file output-file
+      ;; CSV header
+      (insert "heading,state,priority,tags,level,scheduled,deadline\n")
+      ;; CSV rows
+      (dolist (todo todos)
+        (insert (format "\"%s\",\"%s\",%s,\"%s\",%s,\"%s\",\"%s\"\n"
+                       (or (alist-get 'heading todo) "")
+                       (or (alist-get 'todo todo) "")
+                       (or (alist-get 'priority todo) "")
+                       (or (string-join (alist-get 'tags todo) ";") "")
+                       (or (alist-get 'level todo) "")
+                       (or (alist-get 'scheduled todo) "")
+                       (or (alist-get 'deadline todo) "")))))
+    t))
+
+(defun org-batch-export-json (file output-file)
+  "Export TODOs from FILE to JSON format in OUTPUT-FILE.
+Returns t on success."
+  (let ((todos (org-batch-list-todos file)))
+    (with-temp-file output-file
+      (insert (json-encode todos)))
+    t))
+
+;;; Recurring Tasks
+
+(defun org-batch-set-repeater (file heading repeater-spec)
+  "Set repeater REPEATER-SPEC for HEADING in FILE.
+REPEATER-SPEC should be like '+1w' or '.+2d' for org-mode repeaters.
+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\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        ;; Look for existing SCHEDULED line
+        (if (re-search-forward "^[ \t]*SCHEDULED:" (save-excursion (outline-next-heading) (point)) t)
+            ;; Update existing scheduled with repeater
+            (progn
+              (beginning-of-line)
+              (when (re-search-forward "<\\([^>]+\\)>" (line-end-position) t)
+                (let ((timestamp (match-string 1)))
+                  ;; Remove existing repeater if any
+                  (setq timestamp (replace-regexp-in-string " [.+]?\\+[0-9]+[dwmy]" "" timestamp))
+                  ;; Add new repeater
+                  (replace-match (format "<%s %s>" timestamp repeater-spec)))))
+          ;; No scheduled, add one with today's date + repeater
+          (org-back-to-heading)
+          (forward-line 1)
+          (insert (format "SCHEDULED: <%s %s>\n"
+                         (format-time-string "%Y-%m-%d %a")
+                         repeater-spec)))
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-get-recurring-tasks (file)
+  "Get all tasks with repeaters in FILE.
+Returns list of tasks with their repeater specifications."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((recurring '()))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl))
+                (scheduled (org-element-property :scheduled hl))
+                (deadline (org-element-property :deadline hl)))
+            (when todo
+              (let ((repeater nil))
+                ;; Check for repeater in scheduled
+                (when scheduled
+                  (let ((sched-val (org-element-property :raw-value scheduled)))
+                    (when (string-match "[.+]?\\+[0-9]+[dwmy]" sched-val)
+                      (setq repeater (match-string 0 sched-val)))))
+                ;; Check for repeater in deadline
+                (when (and (not repeater) deadline)
+                  (let ((dead-val (org-element-property :raw-value deadline)))
+                    (when (string-match "[.+]?\\+[0-9]+[dwmy]" dead-val)
+                      (setq repeater (match-string 0 dead-val)))))
+                (when repeater
+                  (push (cons (cons 'repeater repeater)
+                             (org-batch--element-to-alist hl))
+                        recurring)))))))
+      (nreverse recurring))))
+
+;;; Dependencies & Relationships
+
+(defun org-batch-set-blocker (file heading blocker-heading)
+  "Set BLOCKER-HEADING as a blocker for HEADING in FILE.
+Creates or updates BLOCKER property.
+Returns t on success, nil if heading not found."
+  (org-batch-set-property file heading "BLOCKER" blocker-heading))
+
+(defun org-batch-get-blocker (file heading)
+  "Get blocker for HEADING in FILE.
+Returns blocker heading name or nil if no blocker set."
+  (org-batch-get-property file heading "BLOCKER"))
+
+(defun org-batch-get-blocked-tasks (file)
+  "Get all tasks that have blockers in FILE.
+Returns list of tasks with their blocker information."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (let ((blocked '()))
+      (org-element-map (org-element-parse-buffer) 'headline
+        (lambda (hl)
+          (let ((todo (org-element-property :todo-keyword hl)))
+            (when todo
+              (let ((blocker-prop nil))
+                (save-excursion
+                  (goto-char (org-element-property :begin hl))
+                  (setq blocker-prop (org-entry-get nil "BLOCKER")))
+                (when blocker-prop
+                  (push (cons (cons 'blocker blocker-prop)
+                             (org-batch--element-to-alist hl))
+                        blocked)))))))
+      (nreverse blocked))))
+
+(defun org-batch-set-related (file heading related-heading relation-type)
+  "Set relationship between HEADING and RELATED-HEADING in FILE.
+RELATION-TYPE can be `child', `parent', `related', or `depends-on'.
+Uses org properties to track relationships.
+Returns t on success."
+  (org-batch-set-property file heading
+                         (upcase (format "RELATED_%s" relation-type))
+                         related-heading))
+
+(defun org-batch-get-related (file heading)
+  "Get all related tasks for HEADING in FILE.
+Returns alist of relationship types and related task names."
+  (let ((props (org-batch-list-properties file heading))
+        (related '()))
+    (dolist (prop props)
+      (when (string-match "^RELATED_\\(.*\\)$" (car prop))
+        (let ((rel-type (downcase (match-string 1 (car prop))))
+              (rel-value (cdr prop)))
+          (push (cons (intern rel-type) rel-value) related))))
+    related))
+
+;;; Output Functions
+
+(defun org-batch-output-json (success data &optional error)
+  "Output JSON response.
+SUCCESS: boolean
+DATA: data to include in response
+ERROR: error message if any"
+  (let ((response (if success
+                      `((success . ,success) (data . ,data))
+                    `((success . :json-false) (error . ,error)))))
+    (princ (json-encode response))
+    (terpri)))
+
+(defun org-batch-output-error (message)
+  "Output error MESSAGE in JSON format."
+  (org-batch-output-json nil nil message))
+
+(provide 'batch-functions)
+;;; batch-functions.el ends here
dots/.config/emacs/init.el
@@ -1809,16 +1809,6 @@ minibuffer, even without explicitly focusing it."
   :commands (org-capture)
   :bind (("C-c o c" . org-capture))
   :config
-  (add-to-list 'org-capture-templates
-	       `("J" "🗞 Journal entry" item
-		 (file+datetree ,org-journal-file)
-		 "%U %?\n%i")
-	       t)
-  (add-to-list 'org-capture-templates
-	       `("j" "🗞  Journelly" entry
-		 (file ,org-journelly-file)
-		 "* %U @ %^{Hostname}\n%?" :prepend t)
-	       t) ;; FIXME: 
   (add-to-list 'org-capture-templates
 	       `("t" "📥 Tasks")
 	       t)
@@ -1890,6 +1880,11 @@ Use this function via a hook."
   (declare-function vde/window-delete-popup-frame "init")
   (add-hook 'org-capture-after-finalize-hook #'vde/window-delete-popup-frame))
 
+;; Journelly - Smart journal capture with location/weather
+(use-package journelly
+  :after org-capture
+  :demand t)
+
 (use-package org-habit
   :after org
   :custom