Files
spine/spine.el
T

385 lines
16 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-add-form-model (books)
"Build the view model ht for templates/add.mustache from BOOKS."
(let ((authors nil)
(categories nil))
(dolist (book books)
(let ((author (plist-get book :author)))
(when (and author (> (length author) 0)
(not (member author authors)))
(push author authors)))
(dolist (tag (or (plist-get book :tags) nil))
(unless (member tag categories)
(push tag categories))))
(ht ("app_title" "spine")
("existing_authors" (sort authors #'string<))
("existing_categories" (sort categories #'string<)))))
(defun spine-index-model (books &optional filter selected-id)
"Build the view model ht for templates/index.mustache from BOOKS.
FILTER controls which books are shown:
nil - concise: READING + 5 most recent WANT, others as summary links
all - full listing, no truncation
read - only READ books
want - only WANT books
SELECTED-ID expands the matching book's detail section."
(let* ((status-labels
'(("WANT" . "on deck")
("READING" . "reading")
("READ" . "read")
("ABANDONED" . "abandoned")))
(format-icons
'(("hardcover" . "ti-book")
("ebook" . "ti-device-tablet")
("audiobook" . "ti-headphones")))
(order '("WANT" "READING" "READ" "ABANDONED"))
(valid-filters '("all" "read" "want"))
(effective-filter (if (member filter valid-filters) filter nil))
(grouped (make-hash-table :test 'equal))
(total (length books))
(reading-count 0))
(dolist (book books)
(when (equal "READING" (plist-get book :status))
(cl-incf reading-count)))
(dolist (book books)
(let ((status (or (plist-get book :status) "WANT")))
(push book (gethash status grouped))))
(let ((groups nil)
(summary-groups nil))
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(if (and (null effective-filter)
(member status '("READ" "ABANDONED")))
(push (ht ("label" (cdr (assoc status status-labels)))
("count" (length group-books))
("href" (format "/index?filter=%s"
(downcase status))))
summary-groups)
(when (or (null effective-filter)
(string= (downcase status) effective-filter)
(string= effective-filter "all"))
(when (and (null effective-filter)
(string= status "WANT"))
(setq group-books
(cl-subseq (sort group-books
(lambda (a b)
(let ((a-date (plist-get a :added))
(b-date (plist-get b :added)))
(cond
((null a-date) nil)
((null b-date) t)
(t (string> a-date b-date))))))
0 (min (length group-books) 5))))
(let ((book-models nil))
(dolist (book group-books)
(let* ((id (plist-get book :id))
(selected (equal id selected-id))
(fmt (plist-get book :format))
(rating (plist-get book :rating))
(notes (plist-get book :notes))
(status (or (plist-get book :status) "WANT"))
(model
(ht
("format_icon"
(or (cdr (assoc fmt format-icons)) ""))
("status_class" (downcase status))
("status_label" (downcase status))
("title" (plist-get book :title))
("author" (or (plist-get book :author) ""))
("tags_inline"
(let ((tags (plist-get book :tags)))
(when tags
(mapconcat (lambda (tag) (concat ":" tag ":"))
tags " "))))
("rec_via" (plist-get book :rec_by))
("rating_display"
(when (and rating (> (length rating) 0))
(make-string (string-to-number rating) ?\u2605)))
("selected" selected)
("detail"
(when selected
(ht
("meta"
(let ((added (plist-get book :added))
(fmt (plist-get book :format)))
(cond
((and added fmt)
(format "started %s \302\267 %s" added fmt))
(added (format "started %s" added))
(fmt (format "format: %s" fmt))
(t ""))))
("notes"
(when notes
(cl-loop for (date text) in notes
collect (ht
("date" (format "[%s]" date))
("text" text)))))))))))
(push model book-models)))
(push (ht ("label" (cdr (assoc status status-labels)))
("count" (length group-books))
("books" (nreverse book-models)))
groups)))))))
(ht ("app_title" "spine")
("total_books" total)
("reading_count" reading-count)
("current_filter" (or effective-filter ""))
("summary_groups" (nreverse summary-groups))
("groups" (nreverse groups))))))
(defun spine-add-book (&rest args)
"Add a new WANT book to `spine-org-file'.
Keyword arguments: :title :author :category :format :isbn :cover
:rec_by :rec_note :date_added :initial_note
Signals an error on failure."
(let ((title (plist-get args :title))
(author (plist-get args :author))
(category (plist-get args :category))
(format (plist-get args :format))
(isbn (plist-get args :isbn))
(cover (plist-get args :cover))
(rec-by (plist-get args :rec_by))
(rec-note (plist-get args :rec_note))
(date-added (plist-get args :date_added))
(initial-note (plist-get args :initial_note)))
(unless title
(error "spine-add-book: title is required"))
(let ((org-file (expand-file-name spine-org-file)))
;; Create file if it doesn't exist
(unless (file-exists-p org-file)
(with-temp-file org-file
(insert "#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)\n\n")))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(progn
(goto-char (point-max))
;; Insert new headline
(org-insert-heading nil t)
(insert title)
;; Set TODO state
(org-todo "WANT")
;; Set properties (only non-empty)
(when (and author (> (length author) 0))
(org-set-property "AUTHOR" author))
(when (and format (> (length format) 0))
(org-set-property "FORMAT" format))
(when (and isbn (> (length isbn) 0))
(org-set-property "ISBN" isbn))
(when (and cover (> (length cover) 0))
(org-set-property "COVER" cover))
(when (and rec-by (> (length rec-by) 0))
(org-set-property "REC_BY" rec-by))
(when (and rec-note (> (length rec-note) 0))
(org-set-property "REC_NOTE" rec-note))
(when (and date-added (> (length date-added) 0))
(org-set-property "ADDED" (format "[%s]" date-added)))
;; Set ID
(org-set-property "ID"
(format "%04x-%s"
(random 65535)
(substring (sha1 (concat title (number-to-string (random)))) 0 4)))
;; Set tags
(when (and category (> (length category) 0))
(org-set-tags (list category)))
;; Add initial note
(when (and initial-note (> (length initial-note) 0))
(let ((date (if (and date-added (> (length date-added) 0))
date-added
(format-time-string "%Y-%m-%d"))))
(goto-char (org-entry-end-position))
(insert (format "\n- [%s] %s\n" date initial-note))))
(save-buffer)
t)
(kill-buffer))))))
(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-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"
"<link rel=\"stylesheet\""
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n"
"</head>\n<body>\n"
"<main class=\"container\">\n"
"<article>\n"
"<header><strong>spine</strong></header>\n"
"<p>No books yet.</p>\n"
"</article>\n"
"</main>\n</body>\n</html>"))
(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))
(filter (cadr (assoc "filter" query))))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books filter (cadr (assoc "id" query)))))
(insert (spine-render-empty-state)))))
(defun httpd/add (proc uri-path query request)
"Handle /add: GET shows form, POST creates a book."
(let ((method (caar request)))
(if (equal method "POST")
(progn
(spine-add-book
:title (cadr (assoc "title" query))
:author (cadr (assoc "author" query))
:category (cadr (assoc "category" query))
:format (cadr (assoc "format" query))
:isbn (cadr (assoc "isbn" query))
:cover (cadr (assoc "cover" query))
:rec_by (cadr (assoc "rec_by" query))
:rec_note (cadr (assoc "rec_note" query))
:date_added (cadr (assoc "date_added" query))
:initial_note (cadr (assoc "initial_note" query)))
(httpd-redirect proc "/index" 303))
(httpd-with-buffer proc "text/html"
(let* ((books (spine-books))
(model (if books
(spine-add-form-model books)
(ht ("app_title" "spine")))))
(insert (spine-render "add.mustache" model)))))))
(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