| 1 | ;;; posts.el --- Emacs extension for Andromeda/Posts -*- lexical-binding: t; -*- |
| 2 | ;; |
| 3 | ;; Copyright (C) 2026 Steve Simkins |
| 4 | ;; |
| 5 | ;; Author: Steve Simkins <contact@stevedylan.dev> |
| 6 | ;; Version: 0.0.1 |
| 7 | ;; Keywords: tools blogging extension |
| 8 | ;; Homepage: https://github.com/stevedylandev/andromeda |
| 9 | ;; Package-Requires: ((emacs "27.1")) |
| 10 | ;; |
| 11 | ;; This file is not part of GNU Emacs. |
| 12 | ;; |
| 13 | ;;; Commentary: |
| 14 | ;; Sends micro posts to an Andromeda/posts instance. |
| 15 | ;; Run M-x posts-compose, fill in the `name: value' fields at the top |
| 16 | ;; (see `posts-fields'), write the content below, then press C-c C-c. |
| 17 | ;; |
| 18 | ;;; Code: |
| 19 | |
| 20 | (require 'url) |
| 21 | (require 'json) |
| 22 | (require 'subr-x) |
| 23 | (require 'auth-source-pass) |
| 24 | |
| 25 | (defgroup posts nil |
| 26 | "Andromeda/posts." |
| 27 | :group 'tools) |
| 28 | |
| 29 | (defcustom posts-pass-entry "andromeda/posts-api-key" |
| 30 | "Name of the `pass' entry holding the API key." |
| 31 | :type 'string |
| 32 | :group 'posts) |
| 33 | |
| 34 | (defun posts--api-key () |
| 35 | "Return the API key from password-store." |
| 36 | (or (auth-source-pass-get 'secret posts-pass-entry) |
| 37 | (user-error "No pass entry `%s'" posts-pass-entry))) |
| 38 | |
| 39 | (defcustom posts-endpoint "https://posts.stevedylan.dev/api/posts" |
| 40 | "URL that receives the JSON post." |
| 41 | :type 'string |
| 42 | :group 'posts) |
| 43 | |
| 44 | ;;;; Fields |
| 45 | |
| 46 | (defcustom posts-fields |
| 47 | '(("title") |
| 48 | ("status" :default "draft" :choices ("draft" "published"))) |
| 49 | "Header fields read from the top of the compose buffer as `name: value'. |
| 50 | Each entry is (NAME . PLIST). PLIST keys: `:required', `:default', |
| 51 | `:choices'. Add an entry to support a new field; it is sent as a JSON |
| 52 | key of the same name." |
| 53 | :type '(alist :key-type string :value-type plist) |
| 54 | :group 'posts) |
| 55 | |
| 56 | (defun posts--parse-buffer () |
| 57 | "Return (FIELDS . CONTENT) parsed from the current buffer. |
| 58 | FIELDS is an alist of raw (NAME . VALUE) strings from the leading |
| 59 | `name: value' lines. CONTENT is everything after them." |
| 60 | (save-excursion |
| 61 | (goto-char (point-min)) |
| 62 | (let (fields) |
| 63 | (while (looking-at "^\\([[:alnum:]_-]+\\):[ \t]*\\(.*?\\)[ \t]*$") |
| 64 | (push (cons (downcase (match-string-no-properties 1)) |
| 65 | (match-string-no-properties 2)) |
| 66 | fields) |
| 67 | (forward-line 1)) |
| 68 | (cons (nreverse fields) |
| 69 | (string-trim |
| 70 | (buffer-substring-no-properties (point) (point-max))))))) |
| 71 | |
| 72 | (defun posts--resolve-fields (parsed) |
| 73 | "Validate PARSED against `posts-fields'; return alist with defaults applied." |
| 74 | (dolist (p parsed) |
| 75 | (unless (assoc (car p) posts-fields) |
| 76 | (user-error "Unknown field `%s'" (car p)))) |
| 77 | (delq nil |
| 78 | (mapcar |
| 79 | (lambda (spec) |
| 80 | (let* ((name (car spec)) |
| 81 | (opts (cdr spec)) |
| 82 | (raw (cdr (assoc name parsed))) |
| 83 | (val (if (and raw (not (string-empty-p raw))) |
| 84 | raw |
| 85 | (plist-get opts :default))) |
| 86 | (choices (plist-get opts :choices))) |
| 87 | (when (and (plist-get opts :required) (null val)) |
| 88 | (user-error "Field `%s' is required" name)) |
| 89 | (when (and val choices (not (member val choices))) |
| 90 | (user-error "Bad %s `%s' (one of: %s)" |
| 91 | name val (string-join choices ", "))) |
| 92 | (and val (cons name val)))) |
| 93 | posts-fields))) |
| 94 | |
| 95 | (defun posts--payload (fields content) |
| 96 | "Build the JSON plist from FIELDS alist and CONTENT." |
| 97 | (append (mapcan (lambda (f) (list (intern (concat ":" (car f))) (cdr f))) |
| 98 | fields) |
| 99 | (list :content content))) |
| 100 | |
| 101 | ;;;; Sending |
| 102 | |
| 103 | (defun posts (fields content) |
| 104 | "Send a post to `posts-endpoint' as JSON. |
| 105 | FIELDS is an alist of (NAME . VALUE) strings, CONTENT the body." |
| 106 | (interactive |
| 107 | (let ((title (read-string "Title (optional): "))) |
| 108 | (list (delq nil (list (and (not (string-empty-p title)) |
| 109 | (cons "title" title)) |
| 110 | (cons "status" "draft"))) |
| 111 | (read-string "Content: ")))) |
| 112 | (let ((url-request-method "POST") |
| 113 | (url-request-extra-headers |
| 114 | `(("Content-Type" . "application/json; charset=utf-8") |
| 115 | ("X-API-Key" . ,(posts--api-key)))) |
| 116 | (url-request-data |
| 117 | (encode-coding-string |
| 118 | (json-serialize (posts--payload fields content)) |
| 119 | 'utf-8))) |
| 120 | (url-retrieve |
| 121 | posts-endpoint |
| 122 | (lambda (status) |
| 123 | (unwind-protect |
| 124 | (if (plist-get status :error) |
| 125 | (message "Posts error: %S" (plist-get status :error)) |
| 126 | (goto-char (point-min)) |
| 127 | (re-search-forward "\r?\n\r?\n" nil t) |
| 128 | (condition-case nil |
| 129 | (let ((resp (json-parse-buffer :object-type 'alist))) |
| 130 | (message "Posted: https://stevedylan.dev/now/%S" (alist-get 'slug resp))) |
| 131 | (error (message "Posted (non-JSON response)")))) |
| 132 | (kill-buffer (current-buffer)))) |
| 133 | nil t))) |
| 134 | |
| 135 | ;;;; Compose buffer |
| 136 | |
| 137 | (defvar posts-compose-mode-map |
| 138 | (let ((m (make-sparse-keymap))) |
| 139 | (define-key m (kbd "C-c C-c") #'posts-compose-submit) |
| 140 | (define-key m (kbd "C-c C-k") #'posts-compose-cancel) |
| 141 | m) |
| 142 | "Keymap for `posts-compose-mode'.") |
| 143 | |
| 144 | (define-derived-mode posts-compose-mode text-mode "Posts" |
| 145 | "Compose a post. Leading `name: value' lines are fields, the rest is content. |
| 146 | \\{posts-compose-mode-map}" |
| 147 | (setq header-line-format |
| 148 | "Fields as `name: value' lines, then content. C-c C-c to send, C-c C-k to cancel")) |
| 149 | |
| 150 | ;;;###autoload |
| 151 | (defun posts-compose () |
| 152 | "Open a buffer to compose a post." |
| 153 | (interactive) |
| 154 | (pop-to-buffer (get-buffer-create "*posts-compose*")) |
| 155 | (erase-buffer) |
| 156 | (posts-compose-mode) |
| 157 | (dolist (spec posts-fields) |
| 158 | (insert (car spec) ": " (or (plist-get (cdr spec) :default) "") "\n")) |
| 159 | (insert "\n") |
| 160 | (goto-char (point-min)) |
| 161 | (end-of-line)) |
| 162 | |
| 163 | (defun posts-compose-submit () |
| 164 | "Parse the compose buffer and send it." |
| 165 | (interactive) |
| 166 | (let* ((parsed (posts--parse-buffer)) |
| 167 | (fields (posts--resolve-fields (car parsed))) |
| 168 | (content (cdr parsed))) |
| 169 | (when (string-empty-p content) |
| 170 | (user-error "Content is empty")) |
| 171 | (posts fields content) |
| 172 | (quit-window t))) |
| 173 | |
| 174 | (defun posts-compose-cancel () |
| 175 | "Discard the compose buffer." |
| 176 | (interactive) |
| 177 | (quit-window t)) |
| 178 | |
| 179 | (provide 'posts) |
| 180 | ;;; posts.el ends here |