250 lines
10 KiB
EmacsLisp
250 lines
10 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 "<" "<" s))
|
|
(setq s (replace-regexp-in-string ">" ">" s))
|
|
(setq s (replace-regexp-in-string "\"" """ 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."
|
|
(if (not (file-exists-p spine-org-file))
|
|
(progn
|
|
(message "spine-books: file not found: %s" spine-org-file)
|
|
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))))
|
|
|
|
(defun spine-render-index (books &optional selected-id)
|
|
"Render the index page HTML for BOOKS, expanding the book matching SELECTED-ID."
|
|
(let ((order '("WANT" "READING" "READ" "ABANDONED"))
|
|
(grouped (make-hash-table :test 'equal)))
|
|
;; Group books by status
|
|
(dolist (book books)
|
|
(let* ((status (or (plist-get book :status) "WANT")))
|
|
(push book (gethash status grouped))))
|
|
;; Render
|
|
(with-temp-buffer
|
|
(insert "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
|
|
(insert "<meta charset=\"utf-8\">\n")
|
|
(insert "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
|
|
(insert "<title>Spine</title>\n</head>\n<body>\n")
|
|
(insert "<div class=\"frame\">\n")
|
|
(insert "<div class=\"titlebar\">Spine</div>\n")
|
|
;; Each group in order
|
|
(dolist (status order)
|
|
(let ((group-books (nreverse (gethash status grouped))))
|
|
(when group-books
|
|
(insert (format "<div class=\"group\">%s (%d)</div>\n"
|
|
status (length group-books)))
|
|
(dolist (book group-books)
|
|
(let ((selected (equal (plist-get book :id) selected-id)))
|
|
(insert (format "<div class=\"row%s\">\n"
|
|
(if selected " selected" "")))
|
|
;; Format glyph
|
|
(let ((fmt (plist-get book :format)))
|
|
(insert (format "<span class=\"glyph\">%s</span>\n"
|
|
(cond ((equal fmt "hardcover") "[H]")
|
|
((equal fmt "ebook") "[E]")
|
|
((equal fmt "audiobook") "[A]")
|
|
(t "")))))
|
|
;; Title + author
|
|
(insert "<span class=\"title\">")
|
|
(insert (spine--h (plist-get book :title)))
|
|
(let ((author (plist-get book :author)))
|
|
(when author
|
|
(insert (format " <span class=\"muted\">%s</span>"
|
|
(spine--h author)))))
|
|
(insert "</span>\n")
|
|
;; Tags
|
|
(let ((tags (plist-get book :tags)))
|
|
(when tags
|
|
(insert (format "<span class=\"tags\">%s</span>\n"
|
|
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
|
tags " ")))))
|
|
;; Recommendation trail
|
|
(let ((rec-by (plist-get book :rec_by))
|
|
(rec-note (plist-get book :rec_note)))
|
|
(when (and rec-by (> (length rec-by) 0))
|
|
(insert
|
|
(format "<span class=\"trail\">%s%s</span>\n"
|
|
(spine--h rec-by)
|
|
(if (and rec-note (> (length rec-note) 0))
|
|
(concat ": " (spine--h rec-note))
|
|
"")))))
|
|
;; Status pill
|
|
(let ((status (or (plist-get book :status) "WANT")))
|
|
(insert (format "<span class=\"pill %s\">%s</span>\n"
|
|
(downcase status) status)))
|
|
;; Expanded detail
|
|
(when selected
|
|
(insert "<div class=\"detail\">\n")
|
|
(dolist (note (plist-get book :notes))
|
|
(insert (format
|
|
"<div class=\"logline\"><span class=\"logdate\">[%s]</span> %s</div>\n"
|
|
(spine--h (car note))
|
|
(spine--h (cadr note)))))
|
|
(let ((rating (plist-get book :rating)))
|
|
(when (and rating (> (length rating) 0))
|
|
(insert (format "<div>Rating: %s/5</div>\n"
|
|
(spine--h rating)))))
|
|
(let ((isbn (plist-get book :isbn)))
|
|
(when (and isbn (> (length isbn) 0))
|
|
(insert (format "<div>ISBN: %s</div>\n"
|
|
(spine--h isbn)))))
|
|
(let ((fmt (plist-get book :format)))
|
|
(when (and fmt (> (length fmt) 0))
|
|
(insert (format "<div>Format: %s</div>\n"
|
|
(spine--h fmt)))))
|
|
(let ((added (plist-get book :added)))
|
|
(when (and added (> (length added) 0))
|
|
(insert (format "<div>Added: %s</div>\n"
|
|
(spine--h added)))))
|
|
(insert "</div>\n"))
|
|
(insert "</div>\n"))))))
|
|
(insert "</div>\n</body>\n</html>\n")
|
|
(buffer-string))))
|
|
|
|
(defun spine-render-empty-state ()
|
|
"Render a page indicating no books file was found."
|
|
(concat "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
|
|
"<meta charset=\"utf-8\">\n"
|
|
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
|
"<title>Spine</title>\n</head>\n<body>\n"
|
|
"<div class=\"frame\">\n"
|
|
"<div class=\"titlebar\">Spine</div>\n"
|
|
"<div class=\"row\">No books yet.</div>\n"
|
|
"</div>\n</body>\n</html>\n"))
|
|
|
|
(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 index text/html (path query request)
|
|
(let ((books (spine-books)))
|
|
(if books
|
|
(insert (spine-render-index books (cadr (assoc "id" query))))
|
|
(insert (spine-render-empty-state)))))
|
|
|
|
(defun httpd/ (proc uri-path query request)
|
|
"Redirect root to /index."
|
|
(httpd-redirect proc "/index" 302))
|
|
|
|
;; --- 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
|