Commit 4918a5c85082
Changed files (9)
dots
.config
claude
skills
Org
tools
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