;;; 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)) (total 0) (reading-count 0)) ;; Count totals (dolist (book books) (cl-incf total) (when (equal "READING" (plist-get book :status)) (cl-incf reading-count))) ;; 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 "\n\n\n") (insert "\n") (insert "\n") (insert "Spine\n") (insert "\n") (insert "\n") (insert "\n\n") (insert "
\n") (insert "
\n") (insert (format "
spine" "%d books ยท %d reading
\n" total reading-count)) ;; Each group in order (dolist (status order) (let ((group-books (nreverse (gethash status grouped)))) (when group-books (insert (format "

%s

%d
\n" status (length group-books))) (dolist (book group-books) (let ((selected (equal (plist-get book :id) selected-id))) (insert (format "
\n" (if selected " spine-selected" ""))) ;; Format glyph (emoji) (let ((fmt (plist-get book :format))) (insert (format "%s\n" (cond ((equal fmt "hardcover") "๐Ÿ“–") ((equal fmt "ebook") "๐Ÿ“ฑ") ((equal fmt "audiobook") "๐ŸŽง") (t ""))))) ;; Title + author (insert "") (insert (spine--h (plist-get book :title))) (let ((author (plist-get book :author))) (when author (insert (format " %s" (spine--h author))))) (insert "\n") ;; Tags (let ((tags (plist-get book :tags))) (when tags (insert (format "%s\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 "%s%s\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 "%s\n" (downcase status) (downcase status)))) ;; Expanded detail (when selected (insert "
\n") (dolist (note (plist-get book :notes)) (insert (format "

[%s] %s

\n" (spine--h (car note)) (spine--h (cadr note))))) (let ((parts nil)) (let ((rating (plist-get book :rating))) (when (and rating (> (length rating) 0)) (push (format "Rating: %s/5" (spine--h rating)) parts))) (let ((isbn (plist-get book :isbn))) (when (and isbn (> (length isbn) 0)) (push (format "ISBN: %s" (spine--h isbn)) parts))) (let ((fmt (plist-get book :format))) (when (and fmt (> (length fmt) 0)) (push (format "Format: %s" (spine--h fmt)) parts))) (let ((added (plist-get book :added))) (when (and added (> (length added) 0)) (push (format "Added: %s" (spine--h added)) parts))) (when parts (insert (format "\n" (mapconcat #'identity (nreverse parts) " ยท "))))) (insert "
\n")) (insert "
\n")))))) (insert "
\n") (insert "
\n\n\n") (buffer-string)))) (defun spine-render-empty-state () "Render a page indicating no books file was found." (concat "\n\n\n" "\n" "\n" "Spine\n" "\n" "\n\n" "
\n" "
\n" "
spine
\n" "

No books yet.

\n" "
\n" "
\n\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