Files
spine/spine.el
T
jbrechtel ca03e87a46 refactor: redesign book detail page to match journal mockup styling
- Replace Pico CSS with index-style custom CSS variables
- Redesign layout: back breadcrumb, info table, shelf/status selects
- Update spine-book-model: add shelf_name, format_icon/label, tags_display,
  date display fields, rating display, status/shelf options
- Remove unused fields: meta, progress_label, formats array
- Update spine-book-model-test.el: 23 tests cover all new fields
- Add --text-danger CSS variable for remove button
2026-06-22 22:30:59 -04:00

621 lines
26 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 view model for templates/add.mustache from current BOOKS."
(ht ("app_title" "spine")
("shelves" (or (spine-shelves) '()))))
(defconst spine--format-defs
'(("hardcover" "ti-book" "Hardcover")
("ebook" "ti-device-tablet" "eBook")
("audiobook" "ti-headphones" "Audiobook"))
"Alist of (format-key icon label) for book format display.")
(defconst spine--status-defs
'(("want" "Want") ("reading" "Reading") ("read" "Read"))
"Alist of (key label) for reading status options.")
(defun spine--format-date (org-date-str &optional include-year)
"Format Org date \"[2026-02-20]\" to \"20 Feb\".
With optional INCLUDE-YEAR, returns \"20 Feb 2026\"."
(when (and org-date-str (> (length org-date-str) 0))
(let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str)))
(condition-case nil
(format-time-string (if include-year "%e %b %Y" "%e %b") (date-to-time clean))
(error org-date-str)))))
(defun spine--parse-title-status (title)
"Return (CLEAN-TITLE STATUS-CLASS STATUS-LABEL) from a book TITLE.
TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"."
(let ((case-fold-search nil))
(if (string-match
"\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'"
title)
(let ((kw (match-string 1 title))
(rest (match-string 2 title)))
(list rest (downcase kw) (capitalize (downcase kw))))
(list title "" ""))))
(defun spine-book-model (book)
"Build view model `ht` for a single BOOK plist, for templates/book.mustache."
(let* ((id (plist-get book :id))
(raw-title (or (plist-get book :title) ""))
(author (or (plist-get book :author) ""))
(fmt (plist-get book :format))
(added (plist-get book :added))
(notes (plist-get book :notes))
(rec-by (plist-get book :rec_by))
(rec-note (plist-get book :rec_note))
(tags (plist-get book :tags))
(rating (plist-get book :rating))
(shelf-name (plist-get book :shelf))
(title-info (spine--parse-title-status raw-title))
(clean-title (nth 0 title-info))
(status-class (nth 1 title-info))
(status-label (nth 2 title-info))
(format-def (and fmt (assoc fmt spine--format-defs)))
(format-icon (if format-def (nth 1 format-def) "ti-book"))
(format-label (if format-def (nth 2 format-def) ""))
(rating-display
(cond
((and rating (> (length rating) 0))
(make-string (string-to-number rating) ?\u2605))
((equal status-class "read") "rated —")
(t "rate when finished")))
(has-rating (and rating (> (length rating) 0)))
(notes-model
(mapcar (lambda (n)
(ht ("date" (car n))
("text" (cadr n))
("edit_url" (format "/edit?id=%s&action=note&id=%s" id (car n)))))
notes))
(recommendation
(when (and rec-by (> (length rec-by) 0))
(let ((initials
(mapconcat
(lambda (s)
(when (> (length s) 0)
(upcase (substring s 0 1))))
(split-string rec-by " +" t) "")))
(ht ("initials" initials)
("by" rec-by)
("note" (or rec-note ""))))))
(status-options
(mapcar (lambda (sd)
(ht ("name" (cadr sd))
("selected" (equal (car sd) status-class))))
spine--status-defs))
(shelf-options
(let ((shelves (or (spine-shelves) '())))
(mapcar (lambda (s)
(ht ("name" s)
("selected" (and shelf-name (equal s shelf-name)))))
shelves))))
(ht ("cover_icon" "ti-book")
("id" id)
("title" clean-title)
("author" author)
("status_class" status-class)
("status_label" status-label)
("shelf_name" (or shelf-name ""))
("format_icon" format-icon)
("format_label" format-label)
("tags_display"
(if tags (mapconcat (lambda (tag) (format "<span class=\"tag\">%s</span>" tag)) tags " ") ""))
("added_display" (if added (spine--format-date added t) ""))
("started_display" (if added (spine--format-date added t) ""))
("finished_display" "—")
("rating_display" rating-display)
("has_rating" has-rating)
("status_options" status-options)
("shelf_options" shelf-options)
("notes" notes-model)
("recommendation" recommendation)
("back_url" (format "/index?id=%s" id)))))
(defun spine-index-model (books &optional shelf-filter selected-id)
"Build the view model ht for templates/index.mustache from BOOKS.
SHELF-FILTER limits to one shelf by name (string). nil = all shelves.
SELECTED-ID expands the matching book's detail section."
(let* ((grouped (make-hash-table :test 'equal))
(all-shelves (or (spine-shelves) '()))
(total (length books)))
;; Group books by shelf
(dolist (book books)
(let ((shelf (or (plist-get book :shelf) "Uncategorized")))
(push book (gethash shelf grouped))))
(let ((groups nil)
(shelf-nav nil))
;; Build shelf nav from all shelves (including empty)
(dolist (shelf all-shelves)
(push (ht ("name" shelf)
("href" (format "/index?shelf=%s" shelf))
("current" (equal shelf shelf-filter)))
shelf-nav))
(setq shelf-nav (nreverse shelf-nav))
;; Build shelf groups
(dolist (shelf all-shelves)
(let ((shelf-books (nreverse (gethash shelf grouped))))
(when (or (null shelf-filter) (equal shelf shelf-filter))
(let ((book-models nil))
(dolist (book shelf-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))
(model
(ht
("format_icon"
(or (nth 1 (assoc fmt spine--format-defs)) ""))
("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)
("book_url" (format "/book?id=%s" id))
("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" shelf)
("count" (length shelf-books))
("books" (nreverse book-models)))
groups)))))
(ht ("app_title" "spine")
("total_books" total)
("shelf_count" (length all-shelves))
("current_shelf" shelf-filter)
("shelf_nav" shelf-nav)
("groups" (nreverse groups))
("minibuffer"
(when selected-id
(let* ((books-by-id (make-hash-table :test 'equal)))
(dolist (b books)
(puthash (plist-get b :id) b books-by-id))
(let ((book (gethash selected-id books-by-id)))
(when book
(ht ("actions"
(list
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" selected-id)))
(ht ("command" "rating") ("label" "Set rating")
("href" (format "/edit?id=%s&action=rating" selected-id)))))))))))
))))
(defun spine-add-book (&rest args)
"Add a new book to shelf `:shelf' in `spine-org-file'.
Keyword arguments: :title :author :shelf :format :isbn :cover
:rec_by :rec_note :date_added :initial_note
:shelf is required. Signals an error if the shelf doesn't exist."
(let ((title (plist-get args :title))
(shelf (plist-get args :shelf))
(author (plist-get args :author))
(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"))
(unless shelf
(error "spine-add-book: :shelf is required"))
(let ((org-file (expand-file-name spine-org-file)))
(unless (file-exists-p org-file)
(error "spine-add-book: file %s does not exist; create shelves first"
org-file))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(progn
;; Find the shelf headline
(goto-char (point-min))
(let ((shelf-found nil))
(while (and (not shelf-found)
(re-search-forward
(format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote shelf))
nil t))
(when (= (org-current-level) 1)
(setq shelf-found t)))
(unless shelf-found
(error "spine-add-book: shelf \"%s\" not found" shelf)))
;; Go to end of shelf section
(org-end-of-subtree)
;; Insert new book headline at level 2
(org-insert-heading nil t)
(org-demote-subtree)
(insert title)
;; 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 last modified
(org-set-property "LAST_MODIFIED" (format-time-string "[%Y-%m-%d]"))
;; 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-add-note (id text &optional date)
"Append a reading note to the book with ID.
TEXT is the note content. DATE is an optional YYYY-MM-DD string
\(defaults to today). Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file))
(date-str (or date (format-time-string "%Y-%m-%d"))))
(unless (file-exists-p org-file)
(error "spine-add-note: file not found: %s" org-file))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(progn
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 2)
(let ((hl-id (spine--prop (org-element-property :begin hl) "ID")))
(when (equal id hl-id)
(goto-char (org-element-property :end hl))
(skip-chars-backward " \t\n")
(insert (format "\n- [%s] %s\n" date-str text))))))
nil 'headline)
(unless (eq (point) (point-min))
(save-buffer)
t)
(when (eq (point) (point-min))
(error "spine-add-note: no book found with ID %s" id)))
(kill-buffer)))))
(defun spine-set-rating (id rating)
"Set the RATING property of book with ID to RATING (1-5 string).
Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file)))
(unless (file-exists-p org-file)
(error "spine-set-rating: file not found: %s" org-file))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(progn
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 2)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char pos)
(org-set-property "RATING" rating)))))
nil 'headline)
(unless (eq (point) (point-min))
(save-buffer)
t)
(when (eq (point) (point-min))
(error "spine-set-rating: no book found with ID %s" id)))
(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-shelves ()
"Return a list of shelf names (strings), one per level-1 headline.
Includes empty shelves (shelves with no books)."
(if (not (file-exists-p spine-org-file))
(progn
(message "spine-shelves: file not found: %s" spine-org-file)
nil)
(condition-case err
(with-temp-buffer
(insert-file-contents spine-org-file)
(org-mode)
(let ((shelves nil))
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 1)
(push (org-element-property :raw-value hl) shelves))))
(nreverse shelves)))
(error
(message "spine-shelves: failed to parse %s: %s"
spine-org-file (error-message-string err))
nil))))
(defun spine-books ()
"Return a list of plists, one per book in `spine-org-file'.
Books are level-2 headlines nested under level-1 shelf headlines.
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)
(let ((level (org-element-property :level hl)))
(when (= level 2)
(let* ((pos (org-element-property :begin hl))
(parent (org-element-property :parent hl))
(shelf (and parent
(= (org-element-property :level parent) 1)
(org-element-property :raw-value parent))))
(when shelf
(push (list :id (spine--prop pos "ID")
:title (org-element-property :title 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")
:last_modified (spine--prop pos "LAST_MODIFIED")
:tags (org-element-property :tags hl)
:notes (spine--extract-notes
(org-element-property :begin hl)
(org-element-property :end hl))
:shelf shelf)
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))
(shelf-filter (cadr (assoc "shelf" query))))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books shelf-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))
:shelf (cadr (assoc "shelf" 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" "shelves" '())))))
(insert (spine-render "add.mustache" model)))))))
(defun httpd/edit (proc uri-path query request)
"Handle /edit: GET shows prompt page, POST processes actions.
Query params:
id - book ID
action - note | rating
text - note text (for note action)
date - optional date string (for note action)
value - rating value (for rating action)"
(let ((method (caar request))
(id (cadr (assoc "id" query)))
(action (cadr (assoc "action" query)))
(value (cadr (assoc "value" query)))
(text (cadr (assoc "text" query)))
(date (cadr (assoc "date" query))))
(if (equal method "POST")
(progn
(cond
((equal action "note")
(spine-add-note id text date))
((equal action "rating")
(spine-set-rating id value)))
(httpd-redirect proc (format "/index?id=%s" id) 303))
;; GET: show prompt page for actions needing input
(if (and id (equal action "note"))
(let* ((books (spine-books))
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))
(title (if book (plist-get book :title) "Unknown")))
(httpd-with-buffer proc "text/html"
(insert (format
"<!DOCTYPE html>
<html lang=\"en\">
<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>
<meta name=\"color-scheme\" content=\"light dark\"/>
<title>Spine — note</title>
<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css\"/>
</head>
<body>
<main class=\"container\">
<article style=\"max-width:560px;margin-inline:auto;\">
<header><strong>spine · add note</strong></header>
<p><em>%s</em></p>
<form method=\"post\" action=\"/edit\">
<input type=\"hidden\" name=\"id\" value=\"%s\"/>
<input type=\"hidden\" name=\"action\" value=\"note\"/>
<label>Date <input type=\"date\" name=\"date\" value=\"%s\"/></label>
<label>Note <textarea name=\"text\" rows=\"3\" required autofocus></textarea></label>
<button type=\"submit\">Add note</button>
</form>
</article>
</main>
</body>
</html>"
title id (format-time-string "%Y-%m-%d"))))))
;; Unknown action or missing id: redirect to index
(httpd-redirect proc "/index" 302))))
(defun httpd/book (proc uri-path query request)
"Show the journal-style detail page for a single book."
(let* ((id (cadr (assoc "id" query)))
(books (spine-books))
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal)))
(if book
(httpd-with-buffer proc "text/html"
(insert (spine-render "book.mustache" (spine-book-model book))))
(httpd-redirect proc "/index" 302))))
(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