emacs/lisp/posts.el 10.6 K raw
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 `![](url)'."
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