feat: add initial impl with page fetch/edit/push

This commit is contained in:
xeals 2023-02-16 14:35:32 +11:00
parent 1d3919b7d8
commit 8267c1492c
Signed by: xeals
GPG Key ID: A498C7AF27EC6B5C

155
bookstack.el Normal file
View File

@ -0,0 +1,155 @@
;;; bookstack.el --- Remote editing for the BookStack documentation platform -*- lexical-binding: t; -*-
;; Copyright (C) 2022-2023 Alex Smith
;; Authors: Alex Smith
;; URL: https://git.xeal.me/xeals/bookstack.el
;; Created: Tue Oct 11 2022
;; Keywrods: comm hypermedia wp
;; Version: 0.1.0
;; Package-Requires: ((emacs "28.1") markdown-mode)
;; This file is not part of GNU Emacs.
;; GNU Emacs is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; GNU Emacs is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; Provides an interface to BookStack [1], an open-source documentation
;; and wiki platform.
;;
;; In this early iteration, basic editing of pages is supported.
;;
;; [1]: https://www.bookstackapp.com/
;;; Code:
;; Emacs 28.1 is required for string processing. These could be polyfilled if
;; interest arises.
(require 'markdown-mode)
(require 'json)
(require 'url-vars)
(defgroup bookstack nil
"Utilities to work with a remote BookStack server."
:prefix "bookstack-"
:group 'hypermedia
:group 'wp
:link '(url-link :tag "Gitea" "https://git.xeal.me/xeals/bookstack.el")
:link '(emacs-commentary-link :tag "Commentary" "bookstack-mode"))
(defvar bookstack-token nil
"BookStack API authentication token.")
(defvar bookstack-server nil
"BookStack server address, including protocol and port (if non-default).")
(defvar-local bookstack--buffer-page-id nil
"ID of the page being edited in the current buffer.")
(defun bookstack--retrieve (endpoint callback)
"Retrieves the ENDPOINT from the current `bookstack-server' asynchronously,
executing CALLBACK with the decoded result."
(unless bookstack-token
(user-error "%s" "`bookstack-token' must be defined"))
(unless bookstack-server
(user-error "%s" "`bookstack-server' must be defined"))
(let ((url-request-extra-headers
`(("Authorization" . ,(format "Token %s" bookstack-token))
("Content-Type" . "application/json")))
(cb
(lambda (status)
(pcase status
(`(:error (,type . ,data)) (user-error "BookStack %s error: %s" type data))
(_ (let* ((raw (car (last (string-lines (buffer-string)))))
(json (json-read-from-string raw)))
(apply callback (list json))))))))
(url-retrieve (concat bookstack-server "/api/" endpoint) cb)))
(defun bookstack--write-page (id content)
"Writes CONTENT to the remote page with ID."
(let ((url-request-method "PUT")
(url-request-data (encode-coding-string
(json-encode `((markdown . ,content)))
'utf-8)))
(bookstack--retrieve (format "pages/%d" id) (lambda (_)))))
(defun bookstack--read-page (pages)
"Reads a page from the user with `completing-read', returning its ID.
PAGES should be a sequence of pages (as returned by `bookstack-pages')."
(let* ((opts (seq-reduce
(lambda (acc page)
(push `(,(alist-get 'name page) . ,(alist-get 'id page)) acc))
pages
nil))
(page-name (progn
(switch-to-minibuffer)
(completing-read "Page: " opts))))
(cdr (assoc page-name opts))))
(defun bookstack--edit-page (id)
"Asynchronously loads a page by ID for editing into a new buffer."
(bookstack-page
id
(lambda (page)
(or page (error "Missing full page '%d'" id))
(let-alist page
(let ((buffer (get-buffer-create (format "%s.md" .name)))
;; fix line endings
(content (string-replace " " "" .markdown)))
(with-current-buffer buffer
(markdown-mode)
(setq bookstack--buffer-page-id id)
(insert content)
(goto-char (point-min)))
(switch-to-buffer buffer)
(bookstack-mode +1))))))
(defun bookstack-pages (callback)
"Retrieve all pages from `bookstack-server'."
(bookstack--retrieve
"pages"
(lambda (res) (apply callback (list (alist-get 'data res))))))
(defun bookstack-page (id callback)
"Retrieve a single page from `bookstack-server'."
(bookstack--retrieve
(format "pages/%d" id)
(lambda (res) (apply callback (list res)))))
(defun bookstack-save-buffer ()
"Pushes the current buffer to the BookStack server."
(interactive)
(let ((slug (string-replace " " "-" (downcase (file-name-base (buffer-name))))))
(bookstack--write-page bookstack--buffer-page-id (buffer-string))
(message "Wrote %s/.../page/%s" bookstack-server slug)))
(define-minor-mode bookstack-mode
"Minor mode for editing remote BookStack buffers."
:init-value nil
:lighter " BookStack"
:keymap (let ((map (make-sparse-keymap)))
(define-key map [remap save-buffer] #'bookstack-save-buffer)
map))
;;;###autoload
(defun bookstack-find-page ()
"Prompts the user to find and edit a BookStack page."
(interactive)
(bookstack-pages
(lambda (pages)
(bookstack--edit-page (bookstack--read-page pages)))))
(provide 'bookstack)
;;; bookstack.el ends here