diff --git a/bookstack.el b/bookstack.el new file mode 100644 index 0000000..a02a208 --- /dev/null +++ b/bookstack.el @@ -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 . + +;;; 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