chore: updated posts script
099bb7d4
1 file(s) · +82 −23
| 12 | 12 | ;; |
|
| 13 | 13 | ;;; Commentary: |
|
| 14 | 14 | ;; Sends micro posts to an Andromeda/posts instance. |
|
| 15 | - | ;; Run M-x posts-compose, write the title on the first line and the |
|
| 16 | - | ;; content below it, then press C-c C-c to send. |
|
| 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 | 17 | ;; |
|
| 18 | 18 | ;;; Code: |
|
| 19 | 19 | ||
| 41 | 41 | :type 'string |
|
| 42 | 42 | :group 'posts) |
|
| 43 | 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 | + | ||
| 44 | 101 | ;;;; Sending |
|
| 45 | 102 | ||
| 46 | - | (defun posts (title content) |
|
| 47 | - | "Send a post with TITLE and CONTENT to `posts-endpoint' as JSON." |
|
| 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." |
|
| 48 | 106 | (interactive |
|
| 49 | - | (list (read-string "Title: ") |
|
| 50 | - | (read-string "Content: "))) |
|
| 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: ")))) |
|
| 51 | 112 | (let ((url-request-method "POST") |
|
| 52 | 113 | (url-request-extra-headers |
|
| 53 | 114 | `(("Content-Type" . "application/json; charset=utf-8") |
|
| 54 | 115 | ("X-API-Key" . ,(posts--api-key)))) |
|
| 55 | 116 | (url-request-data |
|
| 56 | 117 | (encode-coding-string |
|
| 57 | - | (json-serialize `(:title ,title :content ,content :status "draft")) |
|
| 118 | + | (json-serialize (posts--payload fields content)) |
|
| 58 | 119 | 'utf-8))) |
|
| 59 | 120 | (url-retrieve |
|
| 60 | 121 | posts-endpoint |
|
| 81 | 142 | "Keymap for `posts-compose-mode'.") |
|
| 82 | 143 | ||
| 83 | 144 | (define-derived-mode posts-compose-mode text-mode "Posts" |
|
| 84 | - | "Compose a post. First line is the title, the rest is content. |
|
| 145 | + | "Compose a post. Leading `name: value' lines are fields, the rest is content. |
|
| 85 | 146 | \\{posts-compose-mode-map}" |
|
| 86 | 147 | (setq header-line-format |
|
| 87 | - | "Title on first line. C-c C-c to send, C-c C-k to cancel")) |
|
| 148 | + | "Fields as `name: value' lines, then content. C-c C-c to send, C-c C-k to cancel")) |
|
| 88 | 149 | ||
| 89 | 150 | ;;;###autoload |
|
| 90 | 151 | (defun posts-compose () |
|
| 92 | 153 | (interactive) |
|
| 93 | 154 | (pop-to-buffer (get-buffer-create "*posts-compose*")) |
|
| 94 | 155 | (erase-buffer) |
|
| 95 | - | (posts-compose-mode)) |
|
| 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)) |
|
| 96 | 162 | ||
| 97 | 163 | (defun posts-compose-submit () |
|
| 98 | 164 | "Parse the compose buffer and send it." |
|
| 99 | 165 | (interactive) |
|
| 100 | - | (let* ((title (save-excursion |
|
| 101 | - | (goto-char (point-min)) |
|
| 102 | - | (string-trim |
|
| 103 | - | (buffer-substring-no-properties |
|
| 104 | - | (line-beginning-position) (line-end-position))))) |
|
| 105 | - | (content (save-excursion |
|
| 106 | - | (goto-char (point-min)) |
|
| 107 | - | (forward-line 1) |
|
| 108 | - | (string-trim |
|
| 109 | - | (buffer-substring-no-properties (point) (point-max)))))) |
|
| 110 | - | (when (string-empty-p title) |
|
| 111 | - | (user-error "Title is empty")) |
|
| 112 | - | (posts title content) |
|
| 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) |
|
| 113 | 172 | (quit-window t))) |
|
| 114 | 173 | ||
| 115 | 174 | (defun posts-compose-cancel () |
|