doom/scripts/posts.el 6.0 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 '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