feat: add book detail view with spine-book-model, httpd/book handler, and title links

- Add spine--format-defs defconst shared between index and book models
- Add spine-book-model with status extraction, format selector, notes, recommendation
- Add templates/book.mustache (Pico CSS, journal-style layout)
- Add httpd/book handler serving the detail page
- Link book titles in index view to /book?id=X
- Add spine-book-model-test.el with 13 tests
This commit is contained in:
2026-06-22 22:15:51 -04:00
parent c4248dcc89
commit df1a7aebc9
8 changed files with 2153 additions and 7 deletions
+94 -6
View File
@@ -63,15 +63,92 @@ Returns the rendered string."
(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.")
(defun spine--format-date (org-date-str)
"Format Org date \"[2026-02-20]\" to \"20 Feb\"."
(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 "%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))
(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))
(title-info (spine--parse-title-status title))
(clean-title (nth 0 title-info))
(status-class (nth 1 title-info))
(status-label (nth 2 title-info))
(meta (cond
((and added fmt)
(format "started %s \302\267 %s" (spine--format-date added) fmt))
(added (format "started %s" (spine--format-date added)))
(fmt (format "format: %s" fmt))
(t "")))
(formats (mapcar
(lambda (fd)
(ht ("icon" (nth 1 fd))
("label" (nth 2 fd))
("active" (and fmt (equal (car fd) fmt)))))
spine--format-defs))
(notes-model
(mapcar (lambda (n)
(ht ("date" (car n))
("text" (cadr 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 "")))))))
(ht ("cover_icon" "ti-book")
("id" id)
("title" clean-title)
("author" author)
("status_class" status-class)
("status_label" status-label)
("meta" meta)
("progress_label" "")
("formats" formats)
("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* ((format-icons
'(("hardcover" . "ti-book")
("ebook" . "ti-device-tablet")
("audiobook" . "ti-headphones")))
(grouped (make-hash-table :test 'equal))
(let* ((grouped (make-hash-table :test 'equal))
(all-shelves (or (spine-shelves) '()))
(total (length books)))
;; Group books by shelf
@@ -101,7 +178,7 @@ SELECTED-ID expands the matching book's detail section."
(model
(ht
("format_icon"
(or (cdr (assoc fmt format-icons)) ""))
(or (nth 1 (assoc fmt spine--format-defs)) ""))
("title" (plist-get book :title))
("author" (or (plist-get book :author) ""))
("tags_inline"
@@ -114,6 +191,7 @@ SELECTED-ID expands the matching book's detail section."
(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
@@ -489,6 +567,16 @@ Query params:
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))