| 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 'mailcap) |
| 23 | (require 'subr-x) |
| 24 | (require 'auth-source-pass) |
| 25 | |
| 26 | (defgroup posts nil |
| 27 | "Andromeda/posts." |
| 28 | :group 'tools) |
| 29 | |
| 30 | (defcustom posts-pass-entry "andromeda/posts-api-key" |
| 31 | "Name of the `pass' entry holding the API key." |
| 32 | :type 'string |
| 33 | :group 'posts) |
| 34 | |
| 35 | (defun posts--api-key () |
| 36 | "Return the API key from password-store." |
| 37 | ;; Encode so the header is unibyte; otherwise url-http refuses to mix |
| 38 | ;; it with a binary request body. |
| 39 | (encode-coding-string |
| 40 | (or (auth-source-pass-get 'secret posts-pass-entry) |
| 41 | (user-error "No pass entry `%s'" posts-pass-entry)) |
| 42 | 'utf-8)) |
| 43 | |
| 44 | (defcustom posts-endpoint "https://posts.stevedylan.dev/api/posts" |
| 45 | "URL that receives the JSON post." |
| 46 | :type 'string |
| 47 | :group 'posts) |
| 48 | |
| 49 | (defcustom posts-files-endpoint "https://posts.stevedylan.dev/api/files" |
| 50 | "URL that receives multipart file uploads." |
| 51 | :type 'string |
| 52 | :group 'posts) |
| 53 | |
| 54 | (defconst posts--max-file-size (* 10 1024 1024) |
| 55 | "Largest file the server accepts, in bytes.") |
| 56 | |
| 57 | ;;;; Fields |
| 58 | |
| 59 | (defcustom posts-fields |
| 60 | '(("title") |
| 61 | ("status" :default "draft" :choices ("draft" "published"))) |
| 62 | "Header fields read from the top of the compose buffer as `name: value'. |
| 63 | Each entry is (NAME . PLIST). PLIST keys: `:required', `:default', |
| 64 | `:choices'. Add an entry to support a new field; it is sent as a JSON |
| 65 | key of the same name." |
| 66 | :type '(alist :key-type string :value-type plist) |
| 67 | :group 'posts) |
| 68 | |
| 69 | (defun posts--parse-buffer () |
| 70 | "Return (FIELDS . CONTENT) parsed from the current buffer. |
| 71 | FIELDS is an alist of raw (NAME . VALUE) strings from the leading |
| 72 | `name: value' lines. CONTENT is everything after them." |
| 73 | (save-excursion |
| 74 | (goto-char (point-min)) |
| 75 | (let (fields) |
| 76 | (while (looking-at "^\\([[:alnum:]_-]+\\):[ \t]*\\(.*?\\)[ \t]*$") |
| 77 | (push (cons (downcase (match-string-no-properties 1)) |
| 78 | (match-string-no-properties 2)) |
| 79 | fields) |
| 80 | (forward-line 1)) |
| 81 | (cons (nreverse fields) |
| 82 | (string-trim |
| 83 | (buffer-substring-no-properties (point) (point-max))))))) |
| 84 | |
| 85 | (defun posts--resolve-fields (parsed) |
| 86 | "Validate PARSED against `posts-fields'; return alist with defaults applied." |
| 87 | (dolist (p parsed) |
| 88 | (unless (assoc (car p) posts-fields) |
| 89 | (user-error "Unknown field `%s'" (car p)))) |
| 90 | (delq nil |
| 91 | (mapcar |
| 92 | (lambda (spec) |
| 93 | (let* ((name (car spec)) |
| 94 | (opts (cdr spec)) |
| 95 | (raw (cdr (assoc name parsed))) |
| 96 | (val (if (and raw (not (string-empty-p raw))) |
| 97 | raw |
| 98 | (plist-get opts :default))) |
| 99 | (choices (plist-get opts :choices))) |
| 100 | (when (and (plist-get opts :required) (null val)) |
| 101 | (user-error "Field `%s' is required" name)) |
| 102 | (when (and val choices (not (member val choices))) |
| 103 | (user-error "Bad %s `%s' (one of: %s)" |
| 104 | name val (string-join choices ", "))) |
| 105 | (and val (cons name val)))) |
| 106 | posts-fields))) |
| 107 | |
| 108 | (defun posts--payload (fields content) |
| 109 | "Build the JSON plist from FIELDS alist and CONTENT." |
| 110 | (append (mapcan (lambda (f) (list (intern (concat ":" (car f))) (cdr f))) |
| 111 | fields) |
| 112 | (list :content content))) |
| 113 | |
| 114 | ;;;; Sending |
| 115 | |
| 116 | (defun posts (fields content) |
| 117 | "Send a post to `posts-endpoint' as JSON. |
| 118 | FIELDS is an alist of (NAME . VALUE) strings, CONTENT the body." |
| 119 | (interactive |
| 120 | (let ((title (read-string "Title (optional): "))) |
| 121 | (list (delq nil (list (and (not (string-empty-p title)) |
| 122 | (cons "title" title)) |
| 123 | (cons "status" "draft"))) |
| 124 | (read-string "Content: ")))) |
| 125 | (let ((url-request-method "POST") |
| 126 | (url-request-extra-headers |
| 127 | `(("Content-Type" . "application/json; charset=utf-8") |
| 128 | ("X-API-Key" . ,(posts--api-key)))) |
| 129 | (url-request-data |
| 130 | (encode-coding-string |
| 131 | (json-serialize (posts--payload fields content)) |
| 132 | 'utf-8))) |
| 133 | (url-retrieve |
| 134 | posts-endpoint |
| 135 | (lambda (status) |
| 136 | (unwind-protect |
| 137 | (if (plist-get status :error) |
| 138 | (message "Posts error: %S" (plist-get status :error)) |
| 139 | (goto-char (point-min)) |
| 140 | (re-search-forward "\r?\n\r?\n" nil t) |
| 141 | (condition-case nil |
| 142 | (let ((resp (json-parse-buffer :object-type 'alist))) |
| 143 | (message "Posted: https://stevedylan.dev/now/%S" (alist-get 'slug resp))) |
| 144 | (error (message "Posted (non-JSON response)")))) |
| 145 | (kill-buffer (current-buffer)))) |
| 146 | nil t))) |
| 147 | |
| 148 | ;;;; Uploading files |
| 149 | |
| 150 | (defun posts--multipart-body (boundary file name) |
| 151 | "Return a unibyte multipart/form-data body for FILE using BOUNDARY. |
| 152 | NAME, when non-nil, is sent as the `name' field." |
| 153 | (let ((type (or (mailcap-file-name-to-mime-type file) |
| 154 | "application/octet-stream")) |
| 155 | (filename (replace-regexp-in-string |
| 156 | "\"" "\\\\\"" |
| 157 | (encode-coding-string (file-name-nondirectory file) 'utf-8)))) |
| 158 | (with-temp-buffer |
| 159 | (set-buffer-multibyte nil) |
| 160 | (when name |
| 161 | (insert "--" boundary "\r\n" |
| 162 | "Content-Disposition: form-data; name=\"name\"\r\n\r\n" |
| 163 | (encode-coding-string name 'utf-8) "\r\n")) |
| 164 | (insert "--" boundary "\r\n" |
| 165 | "Content-Disposition: form-data; name=\"file\"; filename=\"" |
| 166 | filename "\"\r\n" |
| 167 | "Content-Type: " type "\r\n\r\n") |
| 168 | (insert-file-contents-literally file) |
| 169 | (goto-char (point-max)) |
| 170 | (insert "\r\n--" boundary "--\r\n") |
| 171 | (buffer-string)))) |
| 172 | |
| 173 | (defun posts--response-json () |
| 174 | "Parse the JSON body of the current HTTP response buffer, or nil." |
| 175 | (goto-char (point-min)) |
| 176 | (when (re-search-forward "\r?\n\r?\n" nil t) |
| 177 | (ignore-errors (json-parse-buffer :object-type 'alist)))) |
| 178 | |
| 179 | (defun posts-upload-file (file &optional name callback) |
| 180 | "Upload FILE to `posts-files-endpoint'. |
| 181 | NAME, if non-empty, sets the stored filename. On success the file's |
| 182 | URL is copied to the kill ring and CALLBACK, if non-nil, is called |
| 183 | with the parsed response alist." |
| 184 | (interactive |
| 185 | (list (read-file-name "Upload file: " nil nil t) |
| 186 | (read-string "Name (optional): "))) |
| 187 | (setq file (expand-file-name file)) |
| 188 | (unless (file-regular-p file) |
| 189 | (user-error "Not a regular file: %s" file)) |
| 190 | (when (> (file-attribute-size (file-attributes file)) posts--max-file-size) |
| 191 | (user-error "File exceeds 10 MB: %s" file)) |
| 192 | (when (and name (string-empty-p (string-trim name))) |
| 193 | (setq name nil)) |
| 194 | (let* ((boundary (format "posts-%s" (md5 (format "%s%s" (random) (float-time))))) |
| 195 | (url-request-method "POST") |
| 196 | (url-request-extra-headers |
| 197 | `(("Content-Type" . ,(encode-coding-string |
| 198 | (concat "multipart/form-data; boundary=" boundary) |
| 199 | 'utf-8)) |
| 200 | ("X-API-Key" . ,(posts--api-key)))) |
| 201 | (url-request-data (posts--multipart-body boundary file name))) |
| 202 | (message "Uploading %s..." (file-name-nondirectory file)) |
| 203 | (url-retrieve |
| 204 | posts-files-endpoint |
| 205 | (lambda (status) |
| 206 | (unwind-protect |
| 207 | (let ((err (plist-get status :error)) |
| 208 | (resp (posts--response-json))) |
| 209 | (cond |
| 210 | ((equal err '(error http 409)) |
| 211 | (message "Upload failed: name `%s' is already taken" name)) |
| 212 | (err |
| 213 | (message "Upload error: %S%s" err |
| 214 | (if resp (format " %S" resp) ""))) |
| 215 | ((null (alist-get 'url resp)) |
| 216 | (message "Uploaded (unexpected response)")) |
| 217 | (t |
| 218 | (kill-new (alist-get 'url resp)) |
| 219 | (message "Uploaded: %s (copied)" (alist-get 'url resp)) |
| 220 | (when callback (funcall callback resp))))) |
| 221 | (kill-buffer (current-buffer)))) |
| 222 | nil t))) |
| 223 | |
| 224 | ;;;; Compose buffer |
| 225 | |
| 226 | (defvar posts-compose-mode-map |
| 227 | (let ((m (make-sparse-keymap))) |
| 228 | (define-key m (kbd "C-c C-c") #'posts-compose-submit) |
| 229 | (define-key m (kbd "C-c C-k") #'posts-compose-cancel) |
| 230 | (define-key m (kbd "C-c C-a") #'posts-compose-attach) |
| 231 | m) |
| 232 | "Keymap for `posts-compose-mode'.") |
| 233 | |
| 234 | (define-derived-mode posts-compose-mode text-mode "Posts" |
| 235 | "Compose a post. Leading `name: value' lines are fields, the rest is content. |
| 236 | \\{posts-compose-mode-map}" |
| 237 | (setq header-line-format |
| 238 | "Fields as `name: value' lines, then content. C-c C-c send, C-c C-a attach file, C-c C-k cancel")) |
| 239 | |
| 240 | ;;;###autoload |
| 241 | (defun posts-compose () |
| 242 | "Open a buffer to compose a post." |
| 243 | (interactive) |
| 244 | (pop-to-buffer (get-buffer-create "*posts-compose*")) |
| 245 | (erase-buffer) |
| 246 | (posts-compose-mode) |
| 247 | (dolist (spec posts-fields) |
| 248 | (insert (car spec) ": " (or (plist-get (cdr spec) :default) "") "\n")) |
| 249 | (insert "\n") |
| 250 | (goto-char (point-min)) |
| 251 | (end-of-line)) |
| 252 | |
| 253 | (defun posts-compose-submit () |
| 254 | "Parse the compose buffer and send it." |
| 255 | (interactive) |
| 256 | (let* ((parsed (posts--parse-buffer)) |
| 257 | (fields (posts--resolve-fields (car parsed))) |
| 258 | (content (cdr parsed))) |
| 259 | (when (string-empty-p content) |
| 260 | (user-error "Content is empty")) |
| 261 | (posts fields content) |
| 262 | (quit-window t))) |
| 263 | |
| 264 | (defun posts-compose-attach (file &optional name) |
| 265 | "Upload FILE (stored as NAME) and insert a Markdown link at point. |
| 266 | Images are inserted as `'." |
| 267 | (interactive |
| 268 | (list (read-file-name "Attach file: " nil nil t) |
| 269 | (read-string "Name (optional): "))) |
| 270 | (let ((marker (point-marker))) |
| 271 | (posts-upload-file |
| 272 | file name |
| 273 | (lambda (resp) |
| 274 | (when (buffer-live-p (marker-buffer marker)) |
| 275 | (with-current-buffer (marker-buffer marker) |
| 276 | (save-excursion |
| 277 | (goto-char marker) |
| 278 | (insert (format "%s[%s](%s)" |
| 279 | (if (string-prefix-p "image/" (or (alist-get 'content_type resp) "")) |
| 280 | "!" "") |
| 281 | (or (alist-get 'original_name resp) |
| 282 | (alist-get 'filename resp) "") |
| 283 | (alist-get 'url resp)))))))))) |
| 284 | |
| 285 | (defun posts-compose-cancel () |
| 286 | "Discard the compose buffer." |
| 287 | (interactive) |
| 288 | (quit-window t)) |
| 289 | |
| 290 | (provide 'posts) |
| 291 | ;;; posts.el ends here |