Files
spine/spine.el
T

143 lines
5.1 KiB
EmacsLisp

;;; spine.el — personal reading tracker, served from Emacs
;; --- package bootstrap ------------------------------------------------
(require 'package)
(add-to-list 'package-archives
'("melpa" . "https://melpa.org/packages/")
t)
(package-initialize)
(dolist (pkg '(simple-httpd mustache ht))
(unless (package-installed-p pkg)
(package-refresh-contents)
(package-install pkg)))
(require 'simple-httpd)
(require 'mustache)
(require 'ht)
(require 'cl-lib)
;; --- configuration ----------------------------------------------------
(defvar spine-port 8080
"Port for the Spine HTTP server.")
(defvar spine-host nil
"Host for the Spine HTTP server.
nil means localhost only. Set to a string like \"0.0.0.0\" to listen on
all interfaces.")
(defvar spine-org-file "~/spine/books.org"
"Path to the spine.org data file.")
(defvar spine-template-dir
(expand-file-name "templates/"
(file-name-directory load-file-name))
"Directory containing .mustache templates.")
;; --- template rendering -----------------------------------------------
(defun spine-render (name context)
"Render templates/NAME.mustache with CONTEXT (an ht hash table).
Returns the rendered string."
(mustache-render
(with-temp-buffer
(insert-file-contents
(expand-file-name name spine-template-dir))
(buffer-string))
context))
(defun spine--h (text)
"HTML-escape TEXT."
(when text
(let ((s text))
(setq s (replace-regexp-in-string "&" "&" s))
(setq s (replace-regexp-in-string "<" "&lt;" s))
(setq s (replace-regexp-in-string ">" "&gt;" s))
(setq s (replace-regexp-in-string "\"" "&quot;" s))
s)))
(defun spine--extract-notes (hl-begin hl-end)
"Extract reading notes from headline body between HL-BEGIN and HL-END.
Returns list of (date-string text-string) pairs."
(let ((notes nil))
(save-excursion
(goto-char hl-begin)
;; Skip past PROPERTIES and LOGBOOK drawers
(while (re-search-forward
"^[ \t]*:\\(PROPERTIES\\|LOGBOOK\\):" hl-end t)
(re-search-forward "^[ \t]*:END:" hl-end t))
;; Collect note lines: "- [YYYY-MM-DD] text"
(while (re-search-forward
"^- \\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)\\] \\(.*\\)"
hl-end t)
(push (list (match-string 1) (match-string 2)) notes)))
(nreverse notes)))
(defun spine--prop (pos prop)
"Get Org property PROP at headline position POS.
Returns nil for empty or missing properties."
(let ((val (save-excursion
(goto-char pos)
(org-entry-get nil prop))))
(and val (> (length val) 0) val)))
(defun spine-books ()
"Return a list of plists, one per top-level headline in `spine-org-file'.
Returns nil if the file is missing or cannot be parsed."
(unless (file-exists-p spine-org-file)
(message "spine-books: file not found: %s" spine-org-file)
(cl-return-from spine-books nil))
(condition-case err
(with-temp-buffer
(insert-file-contents spine-org-file)
(org-mode)
(let ((books nil))
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 1)
(let ((pos (org-element-property :begin hl)))
(push (list :id (spine--prop pos "ID")
:title (org-element-property :title hl)
:status (org-element-property :todo-keyword hl)
:author (spine--prop pos "AUTHOR")
:isbn (spine--prop pos "ISBN")
:cover (spine--prop pos "COVER")
:format (spine--prop pos "FORMAT")
:rec_by (spine--prop pos "REC_BY")
:rec_note (spine--prop pos "REC_NOTE")
:rating (spine--prop pos "RATING")
:added (spine--prop pos "ADDED")
:tags (org-element-property :tags hl)
:notes (spine--extract-notes
(org-element-property :begin hl)
(org-element-property :end hl)))
books)))))
(nreverse books)))
(error
(message "spine-books: failed to parse %s: %s"
spine-org-file (error-message-string err))
nil)))
(defvar spine-skip-server-start nil
"When non-nil, do not start the HTTP server.
Set by test harnesses that only need the model functions.")
;; --- handlers ---------------------------------------------------------
(defservlet hello text/html ()
(insert (spine-render "hello.mustache"
(ht ("title" "Spine")
("message" "Hello from Emacs.")))))
;; --- start ------------------------------------------------------------
(unless spine-skip-server-start
(setq httpd-host spine-host)
(setq httpd-port spine-port)
(httpd-start)
(message "Spine listening on http://%s:%d"
(or spine-host "localhost") spine-port))
(provide 'spine)
;;; spine.el ends here