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:
@@ -0,0 +1,539 @@
|
|||||||
|
# Book Detail View Implementation Plan
|
||||||
|
|
||||||
|
**Goal:** Add a dedicated `/book?id=X` page showing full book details — header, format selector, reading log, note composer, and recommendation.
|
||||||
|
|
||||||
|
**Architecture:** New `spine-book-model` function builds the view model `ht` for one book plist. New `httpd/book` handler serves the journal-style template. Index view gets title links pointing to the book page.
|
||||||
|
|
||||||
|
**Tech Stack:** Emacs Lisp, simple-httpd, mustache.el, ht.el, Org-mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Extract shared format definitions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `spine.el`
|
||||||
|
|
||||||
|
The format-icons list is currently local inside `spine-index-model`. Extract to a top-level `defconst` so `spine-book-model` can reuse it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `spine--format-defs` defconst before `spine-index-model`**
|
||||||
|
|
||||||
|
In `spine.el`, before `spine-index-model` (around line 66), add:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(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.")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `spine-index-model` to use `spine--format-defs`**
|
||||||
|
|
||||||
|
Replace the local `format-icons` let-binding in `spine-index-model`:
|
||||||
|
|
||||||
|
```
|
||||||
|
OLD (line 70-73):
|
||||||
|
(let* ((format-icons
|
||||||
|
'(("hardcover" . "ti-book")
|
||||||
|
("ebook" . "ti-device-tablet")
|
||||||
|
("audiobook" . "ti-headphones")))
|
||||||
|
(grouped (make-hash-table :test 'equal))
|
||||||
|
```
|
||||||
|
|
||||||
|
NEW:
|
||||||
|
```elisp
|
||||||
|
(let* ((grouped (make-hash-table :test 'equal))
|
||||||
|
```
|
||||||
|
|
||||||
|
And at the format_icon line (~103), change:
|
||||||
|
```
|
||||||
|
OLD: ("format_icon" (or (cdr (assoc fmt format-icons)) ""))
|
||||||
|
NEW: ("format_icon" (or (nth 1 (assoc fmt spine--format-defs)) ""))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `book_url` to each book model in `spine-index-model`**
|
||||||
|
|
||||||
|
In the book model ht (around line 101-135), add a `book_url` field:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
("book_url" (format "/book?id=%s" id))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Write `spine-book-model` function
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `test/spine-book-model-test.el`
|
||||||
|
- Modify: `spine.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test file `test/spine-book-model-test.el`**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;;; spine-book-model-test.el — ERT tests for spine-book-model
|
||||||
|
|
||||||
|
(require 'ert)
|
||||||
|
(require 'cl-lib)
|
||||||
|
|
||||||
|
(setq spine-skip-server-start t)
|
||||||
|
(setq spine-org-file (expand-file-name "sample-books.org"
|
||||||
|
(file-name-directory
|
||||||
|
(directory-file-name
|
||||||
|
(file-name-directory load-file-name)))))
|
||||||
|
(load-file (expand-file-name "../spine.el"
|
||||||
|
(file-name-directory load-file-name)))
|
||||||
|
|
||||||
|
(defconst spine-book-model-test--uow
|
||||||
|
(cl-find "8c1e-uow" (spine-books)
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)
|
||||||
|
"Use of Weapons fixture.")
|
||||||
|
|
||||||
|
(defconst spine-book-model-test--aj
|
||||||
|
(cl-find "3a1b-aj" (spine-books)
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)
|
||||||
|
"Ancillary Justice fixture.")
|
||||||
|
|
||||||
|
(defconst spine-book-model-test--pir
|
||||||
|
(cl-find "5e8d-pir" (spine-books)
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)
|
||||||
|
"Piranesi fixture.")
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-title-strips-todo ()
|
||||||
|
"Title has TODO prefix stripped."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "title") "Use of Weapons"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-status-reading ()
|
||||||
|
"READING book has status_class reading."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "status_class") "reading"))
|
||||||
|
(should (equal (ht-get model "status_label") "Reading"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-status-read ()
|
||||||
|
"READ book has status_class read."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||||
|
(should (equal (ht-get model "status_class") "read"))
|
||||||
|
(should (equal (ht-get model "status_label") "Read"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-status-want ()
|
||||||
|
"WANT book has status_class want."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--pir)))
|
||||||
|
(should (equal (ht-get model "status_class") "want"))
|
||||||
|
(should (equal (ht-get model "status_label") "Want"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-author ()
|
||||||
|
"Author is passed through."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "author") "Iain M. Banks"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-meta-with-format-and-date ()
|
||||||
|
"Meta includes format and date when both present."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (string-match "started.*audiobook" (ht-get model "meta")))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-format-active ()
|
||||||
|
"Format list has correct active flag."
|
||||||
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
|
(formats (ht-get model "formats")))
|
||||||
|
(should (= (length formats) 3))
|
||||||
|
(should (equal (ht-get (nth 0 formats) "label") "Hardcover"))
|
||||||
|
(should-not (ht-get (nth 0 formats) "active"))
|
||||||
|
(should (equal (ht-get (nth 1 formats) "label") "eBook"))
|
||||||
|
(should-not (ht-get (nth 1 formats) "active"))
|
||||||
|
(should (equal (ht-get (nth 2 formats) "label") "Audiobook"))
|
||||||
|
(should (ht-get (nth 2 formats) "active"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-notes ()
|
||||||
|
"Notes are mapped correctly."
|
||||||
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
|
(notes (ht-get model "notes")))
|
||||||
|
(should (= (length notes) 3))
|
||||||
|
(should (equal (ht-get (nth 0 notes) "date") "2026-03-02"))
|
||||||
|
(should (equal (ht-get (nth 0 notes) "text")
|
||||||
|
"The two-track structure is doing something I can't name yet."))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-recommendation ()
|
||||||
|
"Recommendation includes initials, by, and note."
|
||||||
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
|
(rec (ht-get model "recommendation")))
|
||||||
|
(should rec)
|
||||||
|
(should (equal (ht-get rec "initials") "P"))
|
||||||
|
(should (equal (ht-get rec "by") "Priya"))
|
||||||
|
(should (string-match "Player of Games" (ht-get rec "note")))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-no-recommendation ()
|
||||||
|
"No recommendation when rec_by absent."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||||
|
(should-not (ht-get model "recommendation"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-no-todo-prefix ()
|
||||||
|
"Book with no TODO prefix shows title as-is and empty status."
|
||||||
|
(let* ((books (spine-books))
|
||||||
|
(tcm (cl-find "b2c1-tcm" books
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)))
|
||||||
|
(should tcm)
|
||||||
|
(let ((model (spine-book-model tcm)))
|
||||||
|
(should (equal (ht-get model "title") "The Checklist Manifesto"))
|
||||||
|
(should (equal (ht-get model "status_class") ""))
|
||||||
|
(should (equal (ht-get model "status_label") "")))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-no-format-or-date ()
|
||||||
|
"No format and no date → meta is empty."
|
||||||
|
(let* ((books (spine-books))
|
||||||
|
(tcm (cl-find "b2c1-tcm" books
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)))
|
||||||
|
(should tcm)
|
||||||
|
(let ((model (spine-book-model tcm)))
|
||||||
|
(should (equal (ht-get model "meta") "")))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `spine-book-model` undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `spine-book-model` in `spine.el`**
|
||||||
|
|
||||||
|
Add two helper functions and the main model function after `spine-add-form-model` (~line 64), before `spine-index-model`:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(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 · %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)))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS for all tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add spine.el test/spine-book-model-test.el
|
||||||
|
git commit -m "feat: add spine-book-model for book detail view model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create `templates/book.mustache`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `templates/book.mustache`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the mustache template**
|
||||||
|
|
||||||
|
Based on `spine-mockup-b-journal.mustache` with Pico CSS. Differences from mockup:
|
||||||
|
- Back link at top
|
||||||
|
- No progress_label in composer placeholder
|
||||||
|
- No recommendation date (not stored in data model)
|
||||||
|
- Conditional recommendation section
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!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 — {{title}}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||||
|
<style>
|
||||||
|
.book { max-width: 560px; margin-inline: auto; }
|
||||||
|
.book hgroup { margin-bottom: .4rem; }
|
||||||
|
.book hgroup h3 { margin-bottom: .1rem; }
|
||||||
|
.book hgroup p { color: var(--pico-muted-color); margin: 0; }
|
||||||
|
.cover { width: 44px; height: 60px; border-radius: var(--pico-border-radius);
|
||||||
|
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
|
||||||
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.pill { padding: .1rem .5rem; border-radius: var(--pico-border-radius); font-size: .75rem; font-weight: 500; }
|
||||||
|
.pill.want { background: #e6f1fb; color: #0c447c; }
|
||||||
|
.pill.reading { background: #faeeda; color: #854f0b; }
|
||||||
|
.pill.read { background: #e1f5ee; color: #085041; }
|
||||||
|
.formats { margin-bottom: 1.1rem; }
|
||||||
|
.formats button { --pico-font-size: .85rem; }
|
||||||
|
.seclabel { font-size: .72rem; letter-spacing: .03em; color: var(--pico-muted-color);
|
||||||
|
text-transform: none; margin-bottom: .6rem; }
|
||||||
|
.thread { border-left: 2px solid var(--pico-muted-border-color); padding-left: .9rem; margin-bottom: .9rem; }
|
||||||
|
.entry { margin-bottom: .8rem; }
|
||||||
|
.entry:last-child { margin-bottom: 0; }
|
||||||
|
.edate { font-size: .75rem; color: var(--pico-muted-color); margin-bottom: .15rem; }
|
||||||
|
.etext { font-size: .9rem; line-height: 1.5; }
|
||||||
|
.avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: .75rem; }
|
||||||
|
.compose, .recform { margin-bottom: 0; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.pill.want { background: #14304a; color: #b5d4f4; }
|
||||||
|
.pill.reading { background: #3a2c12; color: #fac775; }
|
||||||
|
.pill.read { background: #103a30; color: #9fe1cb; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<article class="book">
|
||||||
|
<p><a href="{{back_url}}" style="font-size:.85rem;">← Back to shelf</a></p>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 1rem;">
|
||||||
|
<div class="cover"><i class="ti {{cover_icon}}" style="font-size: 22px;" aria-hidden="true"></i></div>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<hgroup>
|
||||||
|
<h3>{{title}}</h3>
|
||||||
|
<p>{{author}}</p>
|
||||||
|
</hgroup>
|
||||||
|
{{#status_class}}
|
||||||
|
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||||
|
{{/status_class}}
|
||||||
|
{{#meta}}
|
||||||
|
<span class="muted" style="color: var(--pico-muted-color); font-size: .85rem; margin-left: .5rem;">{{meta}}</span>
|
||||||
|
{{/meta}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="formats" role="group">
|
||||||
|
{{#formats}}
|
||||||
|
<button class="{{^active}}secondary outline{{/active}}"><i class="ti {{icon}}" style="font-size: 15px; vertical-align: -2px; margin-right: 5px;" aria-hidden="true"></i>{{label}}</button>
|
||||||
|
{{/formats}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#notes}}
|
||||||
|
<p class="seclabel">Reading log</p>
|
||||||
|
<div class="thread">
|
||||||
|
{{#notes}}
|
||||||
|
<div class="entry">
|
||||||
|
<div class="edate">{{date}}</div>
|
||||||
|
<div class="etext">{{text}}</div>
|
||||||
|
</div>
|
||||||
|
{{/notes}}
|
||||||
|
</div>
|
||||||
|
{{/notes}}
|
||||||
|
|
||||||
|
<div class="compose" role="group">
|
||||||
|
<input type="text" placeholder="Add a note…" />
|
||||||
|
<button><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{{#recommendation}}
|
||||||
|
<p class="seclabel">On your radar</p>
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 1rem;">
|
||||||
|
<div class="avatar">{{initials}}</div>
|
||||||
|
<div style="font-size: .9rem; line-height: 1.5;"><strong>{{by}}</strong><br />"{{note}}"</div>
|
||||||
|
</div>
|
||||||
|
{{/recommendation}}
|
||||||
|
<div class="recform" role="group">
|
||||||
|
<input type="text" placeholder="Recommended by…" style="flex: 0 0 34%;" />
|
||||||
|
<input type="text" placeholder="Why it's on your radar…" />
|
||||||
|
<button aria-label="Save recommendation"><i class="ti ti-check" style="font-size: 16px;" aria-hidden="true"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't write the template yet — this step is informational. The actual template is created in the next step with `write`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the template file**
|
||||||
|
|
||||||
|
Create `templates/book.mustache` with the content above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add `httpd/book` handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `spine.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the handler function after `httpd/edit` (around line 491)**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(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))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Quick smoke test — load spine.el and check no errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emacs --batch --eval "(progn (setq spine-skip-server-start t) (load-file \"spine.el\"))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: loads without errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add templates/book.mustache spine.el
|
||||||
|
git commit -m "feat: add httpd/book handler and book detail template"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add title links in index view
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `spine.el` (already done in Task 1 Step 3 — `book_url` added)
|
||||||
|
- Modify: `templates/index.mustache`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `templates/index.mustache` to make title a link**
|
||||||
|
|
||||||
|
Find line ~81 in `templates/index.mustache`:
|
||||||
|
```
|
||||||
|
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```
|
||||||
|
<a href="{{book_url}}" class="title" style="text-decoration:none;color:inherit">{{title}}</a>
|
||||||
|
<span class="muted">· {{author}}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add templates/index.mustache
|
||||||
|
git commit -m "feat: link book titles in index to detail page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Run full test suite
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||||
|
emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||||
|
emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||||
|
emacs --batch -l test/spine-edit-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||||
|
emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "test: verify book detail view and all existing tests pass"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Self-review
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify spec coverage**
|
||||||
|
|
||||||
|
Check each spec requirement maps to a task:
|
||||||
|
- Model fields (cover_icon, title, author, status_class, status_label, meta, formats, notes, recommendation) → Task 2
|
||||||
|
- Status extraction (strip TODO prefix) → Task 2 Step 3
|
||||||
|
- Format list with active flag → Task 2 Step 3
|
||||||
|
- Recommendation with initials → Task 2 Step 3
|
||||||
|
- Handler redirect on missing book → Task 4
|
||||||
|
- Template with back link → Task 3
|
||||||
|
- No recommendation → handled by mustache `{{#recommendation}}` section
|
||||||
|
- Title links in index → Task 5
|
||||||
|
- Empty reading log → mustache `{{#notes}}` section handles empty
|
||||||
|
|
||||||
|
- [ ] **Step 2: Placeholder scan**
|
||||||
|
|
||||||
|
Verify no "TBD", "TODO", "implement later" in plan.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Type consistency check**
|
||||||
|
|
||||||
|
Verify field names (cover_icon, status_class, etc.) match between model function and template.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
|||||||
|
# Book Detail View Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-22
|
||||||
|
**Status:** Draft
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a dedicated book detail page (`/book?id=X`) following the journal-style mockup (Direction B). The page shows full book metadata, format badges, reading log, note composer, and recommendation section. Uses Pico CSS and Tabler icons, matching the add-book template style.
|
||||||
|
|
||||||
|
## Route
|
||||||
|
|
||||||
|
- **`GET /book?id=<id>`** — renders the full detail page
|
||||||
|
- No POST handling (editing flows go through existing `/edit`)
|
||||||
|
- Book not found → redirect to `/index`
|
||||||
|
|
||||||
|
## Model: `spine-book-model`
|
||||||
|
|
||||||
|
Builds an `ht` (hashtable) view model from one book plist.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Source | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cover_icon` | string | hardcoded `"ti-book"` | Placeholder cover icon |
|
||||||
|
| `title` | string | `:title` minus TODO prefix | Strip leading `WANT `, `READING `, `READ ` |
|
||||||
|
| `author` | string | `:author` | Empty string when nil |
|
||||||
|
| `status_class` | string | TODO prefix → lowercase | `"want"` / `"reading"` / `"read"` / `""` |
|
||||||
|
| `status_label` | string | TODO prefix → capitalized | `"Want"` / `"Reading"` / `"Read"` / `""` |
|
||||||
|
| `meta` | string | format + added date | e.g. `"started 20 Feb · audiobook"`. Omits date if not present, format if not present |
|
||||||
|
| `progress_label` | string | — | Empty string; reserved for future progress tracking |
|
||||||
|
| `formats` | array | all 3 known formats | `[{icon, label, active}]`. Active matches `:format` property |
|
||||||
|
| `notes` | array | `:notes` | `[{date, text}]`. Empty array when no notes |
|
||||||
|
| `recommendation` | object or nil | `:rec_by` + `:rec_note` | `{initials, by, note}`. nil when no rec_by |
|
||||||
|
|
||||||
|
### Status extraction
|
||||||
|
|
||||||
|
Book headlines have TODO keyword prefixes (`WANT`, `READING`, `READ`) as part of their title text. The model function:
|
||||||
|
|
||||||
|
1. Strips the prefix from the title for display
|
||||||
|
2. Maps recognized prefixes to status classes
|
||||||
|
|
||||||
|
### Format list
|
||||||
|
|
||||||
|
Hardcoded list of three known formats:
|
||||||
|
- `hardcover` → `ti-book` → "Hardcover"
|
||||||
|
- `ebook` → `ti-device-tablet` → "eBook"
|
||||||
|
- `audiobook` → `ti-headphones` → "Audiobook"
|
||||||
|
|
||||||
|
The book's `:format` property determines which is `active: true`; the others are `active: false`.
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
Generated from `:rec_by` (name) and `:rec_note` (text). No date stored currently.
|
||||||
|
|
||||||
|
`initials` derived from the name: uppercase first letter of each space-separated part. e.g. "Priya" → "P", "John Doe" → "JD".
|
||||||
|
|
||||||
|
## Template: `templates/book.mustache`
|
||||||
|
|
||||||
|
Based on `spine-mockup-b-journal.mustache` with adaptations:
|
||||||
|
- Pico CSS framework
|
||||||
|
- Tabler icons for icons
|
||||||
|
- Progress-related text omitted from the composer placeholder (says "Add a note…" without percentage)
|
||||||
|
- Back link at top to return to index with this book selected (`/index?id=X`)
|
||||||
|
- No recommendation date field (not stored in data model)
|
||||||
|
- Conditional rendering of recommendation section
|
||||||
|
|
||||||
|
## Handler: `httpd/book`
|
||||||
|
|
||||||
|
Registered as `httpd/book` matching the existing add/edit handler pattern:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(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
|
||||||
|
(insert (spine-render "book.mustache" (spine-book-model book)))
|
||||||
|
(httpd-redirect proc "/index" 302))))
|
||||||
|
```
|
||||||
|
| Action | File | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| New | `templates/book.mustache` | Pico CSS + Tabler detail page |
|
||||||
|
| Modify | `spine.el` | Add `spine-book-model`, `httpd/book` handler, title link in index model |
|
||||||
|
| New | `test/spine-book-model-test.el` | Tests for `spine-book-model` |
|
||||||
|
| Modify | `test/spine-index-model-test.el` | Tests for title link in index model |
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **No TODO prefix on headline** → title shown as-is, status empty
|
||||||
|
- **Unknown TODO prefix** (not WANT/READING/READ) → title shown with prefix, status empty
|
||||||
|
- **Book not found** → redirect to `/index`
|
||||||
|
- **No format property** → no format active in the selector
|
||||||
|
- **No author** → empty string
|
||||||
|
- **No notes** → empty reading log section
|
||||||
|
- **No recommendation** → section not rendered (optional block)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Test file `test/spine-book-model-test.el` covers:
|
||||||
|
- Title stripped of TODO prefix
|
||||||
|
- Status class derived correctly for WANT/READING/READ
|
||||||
|
- Meta string from format + added date
|
||||||
|
- Format list with correct active flag
|
||||||
|
- Notes mapping
|
||||||
|
- Recommendation with initials
|
||||||
|
- No recommendation when rec_by absent
|
||||||
|
- Book with no TODO prefix (handles gracefully)
|
||||||
|
- Book with no format or date (meta is empty)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Shelves data model
|
||||||
|
|
||||||
|
Replace the TODO-state-based book model with shelf-based organization.
|
||||||
|
Shelves are top-level Org headings; books are nested underneath them.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Books-as-top-level-headings with TODO states (WANT/READING/READ/ABANDONED)
|
||||||
|
made the file's structure reflect lifecycle status rather than organizing
|
||||||
|
concepts. The user wants arbitrary grouping ("shelves") as the primary
|
||||||
|
organizing axis. TODO states are removed — logbook entries (already present)
|
||||||
|
record when reading happened.
|
||||||
|
|
||||||
|
## Org data model
|
||||||
|
|
||||||
|
```org
|
||||||
|
#+TITLE: Spine
|
||||||
|
|
||||||
|
* Fiction
|
||||||
|
** Piranesi
|
||||||
|
:PROPERTIES:
|
||||||
|
:AUTHOR: Susanna Clarke
|
||||||
|
:FORMAT: ebook
|
||||||
|
:REC_BY: Alex
|
||||||
|
:REC_NOTE: Le Guin at her most human
|
||||||
|
:RATING:
|
||||||
|
:ADDED: [2026-04-01]
|
||||||
|
:LAST_MODIFIED: [2026-04-01]
|
||||||
|
:ID: 5e8d-pir
|
||||||
|
:END:
|
||||||
|
::LOGBOOK:
|
||||||
|
- State "READ" from "WANT" [2026-01-10]
|
||||||
|
::END:
|
||||||
|
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
|
||||||
|
|
||||||
|
* Non-Fiction
|
||||||
|
** The Checklist Manifesto
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Shelves are level-1 headlines (`*`)
|
||||||
|
- Books are level-2 headlines (`**`)
|
||||||
|
- Books keep their TODO keyword syntax for `org-mode` compatibility,
|
||||||
|
but the app ignores it entirely
|
||||||
|
- The `#+TODO:` line is removed — no TODO states
|
||||||
|
- Empty shelves (a level-1 headline with no children) are valid and
|
||||||
|
appear in the UI
|
||||||
|
- LOGBOOK drawers remain for historical state-change records but are
|
||||||
|
not surfaced by the UI
|
||||||
|
|
||||||
|
## Key functions
|
||||||
|
|
||||||
|
### `spine-shelves` (new)
|
||||||
|
|
||||||
|
Returns an ordered list of shelf names (strings), one per level-1 headline
|
||||||
|
that is not a COMMENT or archive target. Empty shelves are included.
|
||||||
|
|
||||||
|
### `spine-books` (modified)
|
||||||
|
|
||||||
|
Walks level-1 headlines as shelves. For each shelf, walks level-2 children
|
||||||
|
as books. Returns flat plist list; each book plist gains a `:shelf` key
|
||||||
|
with the parent headline text. No status/TODO filtering.
|
||||||
|
|
||||||
|
Places that previously checked Todo state (e.g. `spine-set-status`) are
|
||||||
|
removed — the only remaining mutable fields are notes, rating, format.
|
||||||
|
|
||||||
|
### `spine-index-model` (modified)
|
||||||
|
|
||||||
|
Groups books by `:shelf` instead of `:status`. Accepts an optional
|
||||||
|
`shelf` filter (instead of `read`/`want`/`all`) to show one shelf.
|
||||||
|
Includes shelf nav items for all shelves (even empty ones, from
|
||||||
|
`spine-shelves`).
|
||||||
|
|
||||||
|
Removes:
|
||||||
|
- Summary groups (READ/ABANDONED links)
|
||||||
|
- Concise view truncation
|
||||||
|
- Status-related book model fields (status_class, status_label)
|
||||||
|
|
||||||
|
### `spine-add-book` (modified)
|
||||||
|
|
||||||
|
Takes required `:shelf` argument. Inserts the new book heading under
|
||||||
|
the matching level-1 shelf headline instead of at file end. If the
|
||||||
|
shelf doesn't exist, signals an error (shelves are created manually).
|
||||||
|
|
||||||
|
Removes:
|
||||||
|
- `org-todo "WANT"` call
|
||||||
|
- `#+TODO:` file initialization
|
||||||
|
- Category field logic (replaced by shelf)
|
||||||
|
|
||||||
|
### Functions removed
|
||||||
|
|
||||||
|
- `spine-set-status` — no status to change. Logbook records history.
|
||||||
|
- All HTTP endpoints and UI actions that invoke it.
|
||||||
|
|
||||||
|
## UI changes
|
||||||
|
|
||||||
|
### Index view (`index.mustache`)
|
||||||
|
|
||||||
|
- **Titlebar**: `N books · M shelves`
|
||||||
|
- **Filter bar**: replaced with shelf nav — links for each shelf name
|
||||||
|
plus "All" (default). Active shelf highlighted.
|
||||||
|
- **Groups**: one section per shelf. Each shelf shows its books in the
|
||||||
|
existing row format, minus the status pill.
|
||||||
|
- **Empty shelves**: shown as a group with the shelf name and "(empty)"
|
||||||
|
subtext. No expandable books.
|
||||||
|
- **Minibuffer** (when a book is selected): simplified to two actions —
|
||||||
|
**Add note**, **Set rating**. Same for every book.
|
||||||
|
|
||||||
|
### Add form (`add.mustache`)
|
||||||
|
|
||||||
|
- "Category" free-text field replaced with "Shelf" `<select>` dropdown
|
||||||
|
populated from `spine-shelves`.
|
||||||
|
- Required. No datalist — only existing shelves.
|
||||||
|
- All other fields unchanged.
|
||||||
|
|
||||||
|
## Sample file
|
||||||
|
|
||||||
|
`sample-books.org` is restructured to the new shelf layout with
|
||||||
|
shelves like "Fiction", "Non-Fiction", "Science Fiction".
|
||||||
|
|
||||||
|
## Test changes
|
||||||
|
|
||||||
|
- `spine-books-parses-sample`: loads restructured sample, asserts flat
|
||||||
|
book list with `:shelf` keys.
|
||||||
|
- `spine-books-first-book-has-all-fields`: adds `:shelf` assertion.
|
||||||
|
- `spine-books-missing-properties-are-nil`: adds `:shelf` check.
|
||||||
|
- `spine-index-model-*`: rewritten — tests shelf grouping, shelf
|
||||||
|
filtering, empty-shelf inclusion. Status-based tests removed.
|
||||||
|
- `spine-add-book-*`: tests require `:shelf` arg. Removes status check.
|
||||||
|
Tests that books land under the correct shelf heading.
|
||||||
|
- `spine-edit-*`: keeps note/rating tests. Removes status-change tests.
|
||||||
|
Updates model-assertion tests for new minibuffer shape.
|
||||||
|
- New `spine-shelves` tests: parses shelf names, includes empty shelves.
|
||||||
@@ -63,15 +63,92 @@ Returns the rendered string."
|
|||||||
(ht ("app_title" "spine")
|
(ht ("app_title" "spine")
|
||||||
("shelves" (or (spine-shelves) '()))))
|
("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)
|
(defun spine-index-model (books &optional shelf-filter selected-id)
|
||||||
"Build the view model ht for templates/index.mustache from BOOKS.
|
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||||
SHELF-FILTER limits to one shelf by name (string). nil = all shelves.
|
SHELF-FILTER limits to one shelf by name (string). nil = all shelves.
|
||||||
SELECTED-ID expands the matching book's detail section."
|
SELECTED-ID expands the matching book's detail section."
|
||||||
(let* ((format-icons
|
(let* ((grouped (make-hash-table :test 'equal))
|
||||||
'(("hardcover" . "ti-book")
|
|
||||||
("ebook" . "ti-device-tablet")
|
|
||||||
("audiobook" . "ti-headphones")))
|
|
||||||
(grouped (make-hash-table :test 'equal))
|
|
||||||
(all-shelves (or (spine-shelves) '()))
|
(all-shelves (or (spine-shelves) '()))
|
||||||
(total (length books)))
|
(total (length books)))
|
||||||
;; Group books by shelf
|
;; Group books by shelf
|
||||||
@@ -101,7 +178,7 @@ SELECTED-ID expands the matching book's detail section."
|
|||||||
(model
|
(model
|
||||||
(ht
|
(ht
|
||||||
("format_icon"
|
("format_icon"
|
||||||
(or (cdr (assoc fmt format-icons)) ""))
|
(or (nth 1 (assoc fmt spine--format-defs)) ""))
|
||||||
("title" (plist-get book :title))
|
("title" (plist-get book :title))
|
||||||
("author" (or (plist-get book :author) ""))
|
("author" (or (plist-get book :author) ""))
|
||||||
("tags_inline"
|
("tags_inline"
|
||||||
@@ -114,6 +191,7 @@ SELECTED-ID expands the matching book's detail section."
|
|||||||
(when (and rating (> (length rating) 0))
|
(when (and rating (> (length rating) 0))
|
||||||
(make-string (string-to-number rating) ?\u2605)))
|
(make-string (string-to-number rating) ?\u2605)))
|
||||||
("selected" selected)
|
("selected" selected)
|
||||||
|
("book_url" (format "/book?id=%s" id))
|
||||||
("detail"
|
("detail"
|
||||||
(when selected
|
(when selected
|
||||||
(ht
|
(ht
|
||||||
@@ -489,6 +567,16 @@ Query params:
|
|||||||
title id (format-time-string "%Y-%m-%d"))))))
|
title id (format-time-string "%Y-%m-%d"))))))
|
||||||
;; Unknown action or missing id: redirect to index
|
;; Unknown action or missing id: redirect to index
|
||||||
(httpd-redirect proc "/index" 302))))
|
(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)
|
(defun httpd/ (proc uri-path query request)
|
||||||
"Redirect root to /index."
|
"Redirect root to /index."
|
||||||
(httpd-redirect proc "/index" 302))
|
(httpd-redirect proc "/index" 302))
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!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 — {{title}}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||||
|
<style>
|
||||||
|
.book { max-width: 560px; margin-inline: auto; }
|
||||||
|
.book hgroup { margin-bottom: .4rem; }
|
||||||
|
.book hgroup h3 { margin-bottom: .1rem; }
|
||||||
|
.book hgroup p { color: var(--pico-muted-color); margin: 0; }
|
||||||
|
.cover { width: 44px; height: 60px; border-radius: var(--pico-border-radius);
|
||||||
|
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
|
||||||
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.pill { padding: .1rem .5rem; border-radius: var(--pico-border-radius); font-size: .75rem; font-weight: 500; }
|
||||||
|
.pill.want { background: #e6f1fb; color: #0c447c; }
|
||||||
|
.pill.reading { background: #faeeda; color: #854f0b; }
|
||||||
|
.pill.read { background: #e1f5ee; color: #085041; }
|
||||||
|
.formats { margin-bottom: 1.1rem; }
|
||||||
|
.formats button { --pico-font-size: .85rem; }
|
||||||
|
.seclabel { font-size: .72rem; letter-spacing: .03em; color: var(--pico-muted-color);
|
||||||
|
text-transform: none; margin-bottom: .6rem; }
|
||||||
|
.thread { border-left: 2px solid var(--pico-muted-border-color); padding-left: .9rem; margin-bottom: .9rem; }
|
||||||
|
.entry { margin-bottom: .8rem; }
|
||||||
|
.entry:last-child { margin-bottom: 0; }
|
||||||
|
.edate { font-size: .75rem; color: var(--pico-muted-color); margin-bottom: .15rem; }
|
||||||
|
.etext { font-size: .9rem; line-height: 1.5; }
|
||||||
|
.avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: .75rem; }
|
||||||
|
.compose, .recform { margin-bottom: 0; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.pill.want { background: #14304a; color: #b5d4f4; }
|
||||||
|
.pill.reading { background: #3a2c12; color: #fac775; }
|
||||||
|
.pill.read { background: #103a30; color: #9fe1cb; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<article class="book">
|
||||||
|
<p><a href="{{back_url}}" style="font-size:.85rem;">← Back to shelf</a></p>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 1rem;">
|
||||||
|
<div class="cover"><i class="ti {{cover_icon}}" style="font-size: 22px;" aria-hidden="true"></i></div>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<hgroup>
|
||||||
|
<h3>{{title}}</h3>
|
||||||
|
<p>{{author}}</p>
|
||||||
|
</hgroup>
|
||||||
|
{{#status_class}}
|
||||||
|
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||||
|
{{/status_class}}
|
||||||
|
{{#meta}}
|
||||||
|
<span class="muted" style="color: var(--pico-muted-color); font-size: .85rem; margin-left: .5rem;">{{meta}}</span>
|
||||||
|
{{/meta}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="formats" role="group">
|
||||||
|
{{#formats}}
|
||||||
|
<button class="{{^active}}secondary outline{{/active}}"><i class="ti {{icon}}" style="font-size: 15px; vertical-align: -2px; margin-right: 5px;" aria-hidden="true"></i>{{label}}</button>
|
||||||
|
{{/formats}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#notes}}
|
||||||
|
<p class="seclabel">Reading log</p>
|
||||||
|
<div class="thread">
|
||||||
|
{{#notes}}
|
||||||
|
<div class="entry">
|
||||||
|
<div class="edate">{{date}}</div>
|
||||||
|
<div class="etext">{{text}}</div>
|
||||||
|
</div>
|
||||||
|
{{/notes}}
|
||||||
|
</div>
|
||||||
|
{{/notes}}
|
||||||
|
|
||||||
|
<div class="compose" role="group">
|
||||||
|
<input type="text" placeholder="Add a note…" />
|
||||||
|
<button><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{{#recommendation}}
|
||||||
|
<p class="seclabel">On your radar</p>
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 1rem;">
|
||||||
|
<div class="avatar">{{initials}}</div>
|
||||||
|
<div style="font-size: .9rem; line-height: 1.5;"><strong>{{by}}</strong><br />"{{note}}"</div>
|
||||||
|
</div>
|
||||||
|
{{/recommendation}}
|
||||||
|
<div class="recform" role="group">
|
||||||
|
<input type="text" placeholder="Recommended by…" style="flex: 0 0 34%;" />
|
||||||
|
<input type="text" placeholder="Why it's on your radar…" />
|
||||||
|
<button aria-label="Save recommendation"><i class="ti ti-check" style="font-size: 16px;" aria-hidden="true"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -78,7 +78,8 @@
|
|||||||
{{#books}}
|
{{#books}}
|
||||||
<div class="row{{#selected}} selected{{/selected}}">
|
<div class="row{{#selected}} selected{{/selected}}">
|
||||||
<i class="ti {{format_icon}} glyph" aria-hidden="true"></i>
|
<i class="ti {{format_icon}} glyph" aria-hidden="true"></i>
|
||||||
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
|
<a href="{{book_url}}" class="title" style="text-decoration:none;color:inherit">{{title}}</a>
|
||||||
|
<span class="muted">· {{author}}</span>
|
||||||
{{#tags_inline}}<span class="tags">{{tags_inline}}</span>{{/tags_inline}}
|
{{#tags_inline}}<span class="tags">{{tags_inline}}</span>{{/tags_inline}}
|
||||||
{{#rec_via}}<span class="trail">via {{rec_via}}</span>{{/rec_via}}
|
{{#rec_via}}<span class="trail">via {{rec_via}}</span>{{/rec_via}}
|
||||||
{{#rating_display}}<span class="trail">{{rating_display}}</span>{{/rating_display}}
|
{{#rating_display}}<span class="trail">{{rating_display}}</span>{{/rating_display}}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
;;; spine-book-model-test.el — ERT tests for spine-book-model
|
||||||
|
|
||||||
|
(require 'ert)
|
||||||
|
(require 'cl-lib)
|
||||||
|
|
||||||
|
(setq spine-skip-server-start t)
|
||||||
|
(setq spine-org-file (expand-file-name "sample-books.org"
|
||||||
|
(file-name-directory
|
||||||
|
(directory-file-name
|
||||||
|
(file-name-directory load-file-name)))))
|
||||||
|
(load-file (expand-file-name "../spine.el"
|
||||||
|
(file-name-directory load-file-name)))
|
||||||
|
|
||||||
|
(defconst spine-book-model-test--uow
|
||||||
|
(cl-find "8c1e-uow" (spine-books)
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)
|
||||||
|
"Use of Weapons fixture.")
|
||||||
|
|
||||||
|
(defconst spine-book-model-test--aj
|
||||||
|
(cl-find "3a1b-aj" (spine-books)
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)
|
||||||
|
"Ancillary Justice fixture.")
|
||||||
|
|
||||||
|
(defconst spine-book-model-test--pir
|
||||||
|
(cl-find "5e8d-pir" (spine-books)
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)
|
||||||
|
"Piranesi fixture.")
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-title-strips-todo ()
|
||||||
|
"Title has TODO prefix stripped."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "title") "Use of Weapons"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-status-reading ()
|
||||||
|
"READING book has status_class reading."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "status_class") "reading"))
|
||||||
|
(should (equal (ht-get model "status_label") "Reading"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-status-read ()
|
||||||
|
"READ book has status_class read."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||||
|
(should (equal (ht-get model "status_class") "read"))
|
||||||
|
(should (equal (ht-get model "status_label") "Read"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-status-want ()
|
||||||
|
"WANT book has status_class want."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--pir)))
|
||||||
|
(should (equal (ht-get model "status_class") "want"))
|
||||||
|
(should (equal (ht-get model "status_label") "Want"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-author ()
|
||||||
|
"Author is passed through."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "author") "Iain M. Banks"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-meta-with-format-and-date ()
|
||||||
|
"Meta includes format and date when both present."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (string-match "started.*audiobook" (ht-get model "meta")))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-format-active ()
|
||||||
|
"Format list has correct active flag."
|
||||||
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
|
(formats (ht-get model "formats")))
|
||||||
|
(should (= (length formats) 3))
|
||||||
|
(should (equal (ht-get (nth 0 formats) "label") "Hardcover"))
|
||||||
|
(should-not (ht-get (nth 0 formats) "active"))
|
||||||
|
(should (equal (ht-get (nth 1 formats) "label") "eBook"))
|
||||||
|
(should-not (ht-get (nth 1 formats) "active"))
|
||||||
|
(should (equal (ht-get (nth 2 formats) "label") "Audiobook"))
|
||||||
|
(should (ht-get (nth 2 formats) "active"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-notes ()
|
||||||
|
"Notes are mapped correctly."
|
||||||
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
|
(notes (ht-get model "notes")))
|
||||||
|
(should (= (length notes) 3))
|
||||||
|
(should (equal (ht-get (nth 0 notes) "date") "2026-03-02"))
|
||||||
|
(should (equal (ht-get (nth 0 notes) "text")
|
||||||
|
"The two-track structure is doing something I can't name yet."))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-recommendation ()
|
||||||
|
"Recommendation includes initials, by, and note."
|
||||||
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
|
(rec (ht-get model "recommendation")))
|
||||||
|
(should rec)
|
||||||
|
(should (equal (ht-get rec "initials") "P"))
|
||||||
|
(should (equal (ht-get rec "by") "Priya"))
|
||||||
|
(should (string-match "Player of Games" (ht-get rec "note")))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-no-recommendation ()
|
||||||
|
"No recommendation when rec_by absent."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||||
|
(should-not (ht-get model "recommendation"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-no-todo-prefix ()
|
||||||
|
"Book with no TODO prefix shows title as-is and empty status."
|
||||||
|
(let* ((books (spine-books))
|
||||||
|
(tcm (cl-find "b2c1-tcm" books
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)))
|
||||||
|
(should tcm)
|
||||||
|
(let ((model (spine-book-model tcm)))
|
||||||
|
(should (equal (ht-get model "title") "The Checklist Manifesto"))
|
||||||
|
(should (equal (ht-get model "status_class") ""))
|
||||||
|
(should (equal (ht-get model "status_label") "")))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-has-date-no-format ()
|
||||||
|
"Has date but no format → meta shows started date only."
|
||||||
|
(let* ((books (spine-books))
|
||||||
|
(tcm (cl-find "b2c1-tcm" books
|
||||||
|
:key (lambda (b) (plist-get b :id))
|
||||||
|
:test #'equal)))
|
||||||
|
(should tcm)
|
||||||
|
(let ((model (spine-book-model tcm)))
|
||||||
|
(should (equal (ht-get model "meta") "started 15 Jun")))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-back-url ()
|
||||||
|
"back_url links to index with this book's id."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "back_url") "/index?id=8c1e-uow"))))
|
||||||
Reference in New Issue
Block a user