Compare commits

...

54 Commits

Author SHA1 Message Date
jbrechtel 7f3769fcd8 feat: add SPINE_ORG env var to override org file path
Add SPINE_ORG environment variable support to scripts/run. When set,
the value is passed as spine-org-file via --eval before loading spine.el.

Usage:
  SPINE_ORG=~/my-books.org ./scripts/run
  SPINE_ORG=/work/personal-local/spine/sample-books.org ./scripts/run localhost:9090

When unset, defaults to ~/spine/books.org (existing behavior).
2026-06-23 22:19:51 -04:00
jbrechtel bbd9a455cd feat: wire book detail page with working shelf/status moves and note posting
- Add spine-move-book: move book between shelves by cutting/pasting headline
- Add spine-set-status: change TODO keyword (WANT/READING/READ/none)
- Add action=shelf and action=status cases to httpd/edit POST handler
- Wrap shelf/status selects in forms with onchange auto-submit to /edit
- Wire note composer as POST form to /edit with action=note
- Redirect all edit POSTs back to /book?id=X
- 7 new tests for move-book and set-status (error handling, property preservation)
2026-06-22 22:38:14 -04:00
jbrechtel cf511ac190 feat: add scripts/restart — stop spine silently then start it 2026-06-22 22:31:41 -04:00
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
jbrechtel df1a7aebc9 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
2026-06-22 22:15:51 -04:00
jbrechtel c4248dcc89 feat: update HTTP handlers and templates for shelf model 2026-06-21 22:58:52 -04:00
jbrechtel 346fd7af5b feat: update HTTP handlers for shelf model 2026-06-21 22:55:20 -04:00
jbrechtel de057de0bf feat: rewrite spine-index-model for shelf grouping 2026-06-21 22:54:48 -04:00
jbrechtel e9ea0efa35 feat: update spine-add-form-model for shelf list 2026-06-21 22:53:05 -04:00
jbrechtel 6a961b8460 feat: remove spine-set-status, update edit functions for level-2 headlines 2026-06-21 22:52:49 -04:00
jbrechtel 609e168932 feat: adapt spine-add-book for shelf-based nesting 2026-06-21 22:52:00 -04:00
jbrechtel 858fc6c13b feat: add spine-shelves, adapt spine-books for shelf nesting 2026-06-21 22:51:01 -04:00
jbrechtel 6e2a796be9 feat: replace Pico CSS with mockup custom CSS in both templates 2026-06-21 22:03:29 -04:00
jbrechtel 653ee3442d feat: concise view sorts all books by LAST_MODIFIED, add property to sample data 2026-06-21 22:02:12 -04:00
jbrechtel fa6be9ff03 feat: add LAST_MODIFIED property tracked on book creation and edits 2026-06-21 21:58:16 -04:00
jbrechtel c505a3c239 feat: add root-level minibuffer actions and update template 2026-06-21 11:56:05 -04:00
jbrechtel 6e821de7b1 feat: add /edit handler with note prompt page and action dispatch 2026-06-21 11:51:27 -04:00
jbrechtel 719751cc84 feat: add minibuffer actions to spine-index-model for selected books 2026-06-21 11:47:50 -04:00
jbrechtel 95dc4b5db4 feat: add spine-set-status, spine-add-note, spine-set-rating 2026-06-21 11:43:56 -04:00
jbrechtel 8c8b004500 test: add failing tests for spine-set-status, spine-add-note, spine-set-rating 2026-06-21 11:42:28 -04:00
jbrechtel 17f928ffa8 docs: add edit book actions implementation plan 2026-06-21 09:40:16 -04:00
jbrechtel 41a8ba24d1 docs: add edit book actions design spec 2026-06-21 09:38:53 -04:00
jbrechtel 31833bf38e docs: add AGENTS.md with workflow conventions 2026-06-21 09:14:06 -04:00
jbrechtel 439f9d3eed fix: use nil for current_filter when no filter active, fix test 2026-06-21 09:11:20 -04:00
jbrechtel 4cf11fdd02 feat: add filter nav bar and summary group links to index template 2026-06-21 09:09:29 -04:00
jbrechtel 8e07d0efec feat: pass filter query param from index handler to model 2026-06-21 09:09:14 -04:00
jbrechtel 126330c1dc feat: add filter parameter to spine-index-model for concise default view 2026-06-21 09:08:54 -04:00
jbrechtel d00732a8ca test: add failing tests for filtered spine-index-model 2026-06-21 08:46:55 -04:00
jbrechtel fbe49d729b docs: add concise default view design spec 2026-06-21 08:34:58 -04:00
jbrechtel b6b0539b82 test: add tags to Piranesi and Ancillary Justice in sample data 2026-06-21 08:22:13 -04:00
jbrechtel 16bbccf67b fix: use raw defun httpd/add for proper redirect handling 2026-06-21 08:21:53 -04:00
jbrechtel 9259c1139d test: add ERT tests for spine-add-book 2026-06-21 08:19:29 -04:00
jbrechtel da8a20a7e1 feat: wire up /add handler with form model 2026-06-21 08:18:48 -04:00
jbrechtel 9aacccb2ee feat: add spine-add-book write function 2026-06-21 08:18:18 -04:00
jbrechtel a8ecf952c6 feat: add 'Add book' link to index header 2026-06-21 08:16:14 -04:00
jbrechtel a12e57ca9b feat: add add-book form template 2026-06-21 08:15:55 -04:00
jbrechtel 72f2f053a1 docs: add add-book form implementation plan 2026-06-21 08:12:49 -04:00
jbrechtel 62638382ac docs: add add-book form design spec 2026-06-21 08:10:40 -04:00
jbrechtel 7faf0cd8ac feat: render index via Mustache template with spine-index-model 2026-06-21 07:38:32 -04:00
jbrechtel 67e41ad110 docs: add mustache templating implementation plan 2026-06-21 07:37:29 -04:00
jbrechtel c7971eb297 docs: add mustache templating design spec 2026-06-21 07:31:18 -04:00
jbrechtel a6e2c99b62 feat: restyle index page with Pico CSS and emoji glyphs 2026-06-20 17:55:33 -04:00
jbrechtel 112ea4df07 feat: restyle empty-state page with Pico CSS 2026-06-20 17:55:04 -04:00
jbrechtel bbb40efd1a docs: add Pico CSS styling implementation plan 2026-06-20 17:53:47 -04:00
jbrechtel 1ad0631f5b docs: add Pico CSS styling design spec 2026-06-20 17:51:23 -04:00
jbrechtel 24f24620d3 fix: route / to /index, use cadr for query alist, rename lambda t param 2026-06-20 17:02:57 -04:00
jbrechtel 794ef3ffc9 feat: wire up / index handler replacing /hello 2026-06-20 16:59:19 -04:00
jbrechtel 94c310a0b3 feat: add spine-render-index and empty-state view functions 2026-06-20 16:59:06 -04:00
jbrechtel cc74ca5cfa test: add ERT tests for spine-books, fix cl-return-from issue 2026-06-20 16:58:45 -04:00
jbrechtel f25dffa183 feat: add spine-books model function with empty-prop nil handling 2026-06-20 16:57:57 -04:00
jbrechtel a93efd3617 feat: add spine-skip-server-start guard for testing 2026-06-20 16:56:46 -04:00
jbrechtel 362a83ca42 test: add sample-books.org test data 2026-06-20 16:56:25 -04:00
jbrechtel d476a4eacc docs: add data model and index page implementation plan 2026-06-20 16:54:53 -04:00
jbrechtel de64d71936 docs: add data model and index page design spec 2026-06-20 16:52:31 -04:00
35 changed files with 8114 additions and 15 deletions
+28
View File
@@ -0,0 +1,28 @@
# Spine — Agent Workflow
## Branch Strategy
This project does not use pull requests or feature branches. All work is committed
directly to `main` and pushed.
## Workflow
1. **Commit early, commit often** — small, focused commits with clear messages.
2. **Push after each logical batch** — don't accumulate un-pushed work.
3. **Test before push** — run the relevant test suite; don't push known failures.
## Commit Messages
Use conventional commits:
- `feat:` for new features
- `fix:` for bug fixes
- `test:` for test additions/changes
- `docs:` for documentation
- `refactor:` for code restructuring
## Verification
Run tests before pushing:
```bash
emacs --batch -l test/<test-file>.el --eval "(ert-run-tests-batch-and-exit)"
```
+9
View File
@@ -0,0 +1,9 @@
# Overview
Spine is a tool for managing your personal library. Spine keeps track of books you want to read, are reading and have read. Spine lets you track notes about the books and why you were interested in them in the first place.
# Design
Spine is primarily a web application where you view and enter details about books. To ensure your data is fully portable and independent of Spine itself, your book data is backed by a single Org file ( http://orgmode.org/ )
The application itself is written in Elisp and runs inside of Emacs but serves up its interface as HTML derived from your `spine.org` file.
@@ -0,0 +1,238 @@
# Mustache Template Rendering — Implementation Plan
**Goal:** Render the index page via Mustache (`spine-render`) using the mockup-A template, replacing inline HTML generation.
**Architecture:** Move `spine-mockup-a-agenda.mustache``templates/index.mustache`. Add `spine-index-model` function to build the view model `ht`. Update the index handler. Remove old `spine-render-index`.
**Tech Stack:** Emacs Lisp, `mustache`, `ht`.
---
## File structure
| File | Responsibility |
|------|---------------|
| `templates/index.mustache` | Index page Mustache template (moved from root) |
| `spine.el` | Add `spine-index-model`, update handler, remove `spine-render-index` |
---
### Task 1: Move template and add spine-index-model
**Files:**
- Create: `templates/index.mustache`
- Modify: `spine.el`
- Delete: `spine-mockup-a-agenda.mustache`
- [ ] **Step 1: Move the template**
```bash
mv spine-mockup-a-agenda.mustache templates/index.mustache
```
- [ ] **Step 2: Add `spine-index-model` function to `spine.el`**
Insert after `spine--h` (after the HTML escaping helper), before `spine-render-index`:
```elisp
(defun spine-index-model (books &optional selected-id)
"Build the view model ht for templates/index.mustache from 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"))
(grouped (make-hash-table :test 'equal))
(total (length books))
(reading-count 0))
;; Count reading books
(dolist (book books)
(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))))
;; Build groups list for template
(let ((groups nil))
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(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 (t) (concat ":" t ":"))
tags " "))))
("rec_via" (plist-get book :rec_by))
("rating_display"
(when (and rating (> (length rating) 0))
(let ((n (string-to-number rating)))
(make-string n ?★))))
("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 · %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)
("groups" (nreverse groups))))))
```
- [ ] **Step 3: Remove old `spine-render-index` function**
Delete the `spine-render-index` function entirely.
- [ ] **Step 4: Update the index handler**
Replace the handler's call from `spine-render-index` to `spine-render` + `spine-index-model`:
```elisp
(defservlet index text/html (path query request)
(let ((books (spine-books)))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books (cadr (assoc "id" query)))))
(insert (spine-render-empty-state)))))
```
- [ ] **Step 5: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly.
- [ ] **Step 6: Verify template renders**
```bash
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el \
--eval "(progn (message \"HTML length: %d\" (length (spine-render \"index.mustache\" (spine-index-model (spine-books) nil)))))"
```
Expected: non-zero length, no errors.
- [ ] **Step 7: Commit**
```bash
git add templates/index.mustache spine.el
git rm spine-mockup-a-agenda.mustache
git commit -m "feat: render index via Mustache template with spine-index-model"
```
---
### Task 2: Smoke test end-to-end
**Files:**
- No changes — verification only.
- [ ] **Step 1: Start server with sample data**
```bash
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
pkill -f "emacs --quick" 2>/dev/null
sleep 1
emacs --quick --eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" --load spine.el &
sleep 5
```
- [ ] **Step 2: Verify Pico + Tabler CDN links**
```bash
curl -s http://localhost:8080/index | grep -c "picocss"
curl -s http://localhost:8080/index | grep -c "tabler"
```
Expected: `1` and `1`.
- [ ] **Step 3: Verify groups and content**
```bash
curl -s http://localhost:8080/index | grep -oP 'on deck|reading|read'
```
Expected: `on deck`, `reading`, `read` (no `abandoned`).
- [ ] **Step 4: Verify Tabler icon glyphs**
```bash
curl -s http://localhost:8080/index | grep -oP 'ti-book|ti-headphones|ti-device-tablet'
```
- [ ] **Step 5: Verify expanded detail**
```bash
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -c "logline"
```
Expected: `3` (three reading notes).
- [ ] **Step 6: Kill server**
```bash
kill %1 2>/dev/null
```
---
### Task 3: ERT tests and final verification
**Files:**
- No changes — verification only.
- [ ] **Step 1: Run ERT tests**
```bash
emacs --quick --batch --directory "$(pwd)" --load test/spine-books-test.el -f ert-run-tests-batch-and-exit
```
Expected: all 6 tests pass.
- [ ] **Step 2: Git status**
```bash
git status --short
git log --oneline -3
```
+365
View File
@@ -0,0 +1,365 @@
# Pico CSS Styling — Implementation Plan
**Goal:** Rewrite `spine-render-index` and `spine-render-empty-state` to emit Pico CSS-styled HTML via CDN, replacing the mockup-A `<div>` structure with Pico-native semantic elements plus a small inline `<style>` block.
**Architecture:** View-layer only change. No model or handler changes. The two view functions in `spine.el` are rewritten. Pico CSS v2 loads from `jsdelivr.net` CDN. Custom styles live in a `<style>` block in `<head>`.
**Tech Stack:** Emacs Lisp (view functions only), Pico CSS v2 (CDN).
---
## File structure
| File | Responsibility |
|------|---------------|
| `spine.el` | Rewrite `spine-render-index` and `spine-render-empty-state` |
No new files. No package changes.
---
### Task 1: Rewrite spine-render-empty-state
**Files:**
- Modify: `spine.el``spine-render-empty-state`
- [ ] **Step 1: Replace `spine-render-empty-state` with Pico-styled version**
The function `spine-render-empty-state` is currently at approximately line 220 in `spine.el`. Replace the existing definition with:
```elisp
(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>"))
```
- [ ] **Step 2: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly, no errors.
- [ ] **Step 3: Commit**
```bash
git add spine.el
git commit -m "feat: restyle empty-state page with Pico CSS"
```
---
### Task 2: Rewrite spine-render-index with Pico HTML
**Files:**
- Modify: `spine.el``spine-render-index`
- [ ] **Step 1: Replace `spine-render-index` with Pico-styled version**
The function `spine-render-index` is currently at approximately line 123 in `spine.el`. Replace the entire function definition with:
```elisp
(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 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
(insert "<meta charset=\"utf-8\">\n")
(insert "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
(insert "<title>Spine</title>\n")
(insert "<link rel=\"stylesheet\""
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n")
(insert "<style>\n")
(insert ".spine-row{display:flex;align-items:center;gap:0.75rem;")
(insert "padding:0.4rem 0;border-bottom:1px solid var(--pico-muted-border-color)}\n")
(insert ".spine-row:last-child{border-bottom:none}\n")
(insert ".spine-selected{background:var(--pico-secondary-background)}\n")
(insert ".spine-glyph{width:1.5rem;text-align:center;flex-shrink:0}\n")
(insert ".spine-title{flex:1;min-width:0}\n")
(insert ".spine-trail{font-size:0.85em;color:var(--pico-muted-color)}\n")
(insert ".spine-tags{font-size:0.85em;color:var(--pico-muted-color)}\n")
(insert ".spine-detail{margin:0 0 0 2rem;border-left:2px solid var(--pico-primary)}\n")
(insert ".spine-detail p{margin-bottom:0.3rem}\n")
(insert ".spine-detail footer{margin-top:0.5rem}\n")
(insert "mark.want{background:var(--pico-mark-background);"
"color:var(--pico-mark-color)}\n")
(insert "mark.reading{background:#faeeda;color:#854f0b}\n")
(insert "mark.read{background:#e1f5ee;color:#085041}\n")
(insert "mark.abandoned{background:var(--pico-muted-color);"
"color:var(--pico-background-color)}\n")
(insert "article{margin-bottom:0}\n")
(insert "article header{padding-bottom:0.5rem;margin-bottom:0.5rem}\n")
(insert "</style>\n")
(insert "</head>\n<body>\n")
(insert "<main class=\"container\">\n")
(insert "<article>\n")
(insert (format "<header><strong>spine</strong>"
"<small>%d books · %d reading</small></header>\n"
total reading-count))
;; Each group in order
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(insert (format "<hgroup><h2>%s</h2><small>%d</small></hgroup>\n"
status (length group-books)))
(dolist (book group-books)
(let ((selected (equal (plist-get book :id) selected-id)))
(insert (format "<div class=\"spine-row%s\">\n"
(if selected " spine-selected" "")))
;; Format glyph (emoji)
(let ((fmt (plist-get book :format)))
(insert (format "<span class=\"spine-glyph\">%s</span>\n"
(cond ((equal fmt "hardcover") "📖")
((equal fmt "ebook") "📱")
((equal fmt "audiobook") "🎧")
(t "")))))
;; Title + author
(insert "<span class=\"spine-title\">")
(insert (spine--h (plist-get book :title)))
(let ((author (plist-get book :author)))
(when author
(insert (format " <small>%s</small>"
(spine--h author)))))
(insert "</span>\n")
;; Tags
(let ((tags (plist-get book :tags)))
(when tags
(insert (format "<small class=\"spine-tags\">%s</small>\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 "<span class=\"spine-trail\">%s%s</span>\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 "<mark class=\"%s\">%s</mark>\n"
(downcase status) (downcase status))))
;; Expanded detail
(when selected
(insert "<blockquote class=\"spine-detail\">\n")
(dolist (note (plist-get book :notes))
(insert (format "<p><small>[%s]</small> %s</p>\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 "<footer>%s</footer>\n"
(mapconcat #'identity (nreverse parts) " · ")))))
(insert "</blockquote>\n"))
(insert "</div>\n"))))))
(insert "</article>\n")
(insert "</main>\n</body>\n</html>\n")
(buffer-string))))
```
- [ ] **Step 2: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly, no errors.
- [ ] **Step 3: Verify rendering against sample data**
```bash
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el \
--eval "(progn (message \"render-index length: %d\" (length (spine-render-index (spine-books) nil))))"
```
Expected: non-zero length, no errors.
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: restyle index page with Pico CSS and emoji glyphs"
```
---
### Task 3: Smoke test end-to-end
**Files:**
- No changes — verification only.
- [ ] **Step 1: Kill existing server and start fresh**
```bash
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
pkill -f "emacs --quick" 2>/dev/null
sleep 1
emacs --quick \
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el &
EMACS_PID=$!
sleep 5
```
- [ ] **Step 2: Verify Pico CSS is linked**
```bash
curl -s http://localhost:8080/index | grep -c "picocss"
```
Expected: `1` (the CDN `<link>` is present).
- [ ] **Step 3: Verify no Tabler Icons reference**
```bash
curl -s http://localhost:8080/index | grep -c "tabler"
```
Expected: `0`.
- [ ] **Step 4: Verify groups in correct order**
```bash
curl -s http://localhost:8080/index | grep -oP '<h2>[^<]+'
```
Expected:
```
WANT
READING
READ
```
No ABANDONED.
- [ ] **Step 5: Verify emoji glyphs and status pills**
```bash
curl -s http://localhost:8080/index | grep -E "📖|📱|🎧" | wc -l
```
Expected: `3` (three format glyphs present — Piranesi has no format).
```bash
curl -s http://localhost:8080/index | grep -oP '<mark class="[^"]+">[^<]+'
```
Expected: `want`, `reading`, `read` pills visible.
- [ ] **Step 6: Verify expanded detail**
```bash
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -oP 'spine-detail'
```
Expected: `spine-detail` present (2 occurrences — opening and closing).
```bash
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -c "logline\|spine-detail"
```
Expected: detail section renders.
- [ ] **Step 7: Verify recommendation trail**
```bash
curl -s http://localhost:8080/index | grep "Priya"
```
Expected: output contains `Priya: If you liked Player of Games, this one will wreck you`.
- [ ] **Step 8: Verify empty state**
```bash
kill $EMACS_PID 2>/dev/null
sleep 1
emacs --quick \
--eval "(setq spine-org-file \"/tmp/nonexistent-books.org\")" \
--load spine.el &
EMACS_PID=$!
sleep 5
curl -s http://localhost:8080/index | grep -c "No books yet"
```
Expected: `1`.
- [ ] **Step 9: Stop server**
```bash
kill $EMACS_PID 2>/dev/null
```
---
### Task 4: Run ERT tests and final verification
**Files:**
- No changes — verification only.
- [ ] **Step 1: Run full ERT test suite**
```bash
emacs --quick --batch \
--directory "$(pwd)" \
--load test/spine-books-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: all 6 tests pass.
- [ ] **Step 2: Verify git status**
```bash
git status --short
```
Expected: no modified tracked files (only pre-existing untracked files).
- [ ] **Step 3: Review commit log**
```bash
git log --oneline -4
```
Expected: 2-3 new commits on top of previous work.
@@ -0,0 +1,737 @@
# Spine Data Model & Index Page — Implementation Plan
**Goal:** Build the model layer (`spine-books`) and index page view so the app reads a real `spine.org` file and displays books grouped by status.
**Architecture:** Model/view split. `spine-books` parses `spine.org` into plists via `org-element` + `org-entry-get`. `spine-render-index` takes those plists and an optional selected ID, returns HTML matching mockup A's class structure. A new `/` handler replaces the `/hello` smoke test. A `spine-skip-server-start` guard lets tests load `spine.el` without starting the HTTP server.
**Tech Stack:** Emacs Lisp, `org-element` (built-in), `org-entry-get` (built-in), `ert` (built-in testing).
---
## File structure
| File | Responsibility |
|------|---------------|
| `sample-books.org` | Test data: 5 books across all statuses |
| `spine.el` | Modified: new functions, handler, skip-server-start guard |
| `test/spine-books-test.el` | ERT tests for `spine-books` |
---
### Task 1: Create sample test data
**Files:**
- Create: `sample-books.org`
- [ ] **Step 1: Write `sample-books.org`**
Five books covering all four statuses, plus an entry with minimal properties.
```org
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
* WANT The Left Hand of Darkness
:PROPERTIES:
:AUTHOR: Ursula K. Le Guin
:ISBN: 978-0441478125
:COVER: covers/left-hand.jpg
:FORMAT: ebook
:REC_BY: Alex
:REC_NOTE: Le Guin at her most human
:RATING:
:ADDED: [2026-04-01]
:ID: 9a2b-lhd
:END:
* WANT Dune
:PROPERTIES:
:AUTHOR: Frank Herbert
:ISBN: 978-0441172719
:COVER: covers/dune.jpg
:FORMAT: hardcover
:REC_BY: Everyone
:REC_NOTE:
:RATING:
:ADDED: [2026-05-10]
:ID: 7f3c-dun
:END:
* WANT Piranesi
:PROPERTIES:
:AUTHOR: Susanna Clarke
:ISBN:
:COVER:
:FORMAT:
:REC_BY:
:REC_NOTE:
:RATING:
:ADDED: [2026-06-01]
:ID: 5e8d-pir
:END:
* READING Use of Weapons
:PROPERTIES:
:AUTHOR: Iain M. Banks
:ISBN: 978-0316029193
:COVER: covers/use-of-weapons.jpg
:FORMAT: audiobook
:REC_BY: Priya
:REC_NOTE: If you liked Player of Games, this one will wreck you
:RATING:
:ADDED: [2026-02-20]
:ID: 8c1e-uow
:END:
:LOGBOOK:
- State "READING" from "WANT" [2026-03-01]
:END:
- [2026-03-02] The two-track structure is doing something I can't name yet.
- [2026-03-07] Zakalwe's competence reads as a wound.
- [2026-03-09] The chapter numbering. Oh.
* READ Ancillary Justice
:PROPERTIES:
:AUTHOR: Ann Leckie
:ISBN: 978-0316246620
:COVER: covers/ancillary.jpg
:FORMAT: ebook
:REC_BY:
:REC_NOTE:
:RATING: 5
:ADDED: [2025-11-15]
:ID: 3a1b-aj
:END:
:LOGBOOK:
- State "READ" from "READING" [2026-01-10]
- State "READING" from "WANT" [2025-11-20]
:END:
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
- [2025-12-05] Justice of Toren's multi-body perspective is unsettling.
- [2026-01-08] The tea set. Perfect ending.
```
This file exercises:
- **WANT books**: 3 (Left Hand, Dune, Piranesi) — tests group header, count, different property densities
- **READING books**: 1 (Use of Weapons) — tests notes extraction, recommendation trail
- **READ books**: 1 (Ancillary Justice) — tests rating, LOGBOOK
- **ABANDONED books**: 0 — tests empty group omission
- **Minimal properties**: Piranesi has no ISBN, cover, format, recommendation, or notes
- [ ] **Step 2: Verify file exists**
Run: `cat sample-books.org | head -3`
Expected: `#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)`
- [ ] **Step 3: Commit**
```bash
git add sample-books.org
git commit -m "test: add sample-books.org test data"
```
---
### Task 2: Add server-start guard to spine.el
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Add `spine-skip-server-start` variable and wrap server start**
Insert after the template rendering section (after line 44, before `;; --- handlers`):
```elisp
(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.")
```
Change the server start block (lines 5358) to:
```elisp
;; --- 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))
```
- [ ] **Step 2: Verify batch load with guard**
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
Expected: exits cleanly, no "Spine listening" message, no errors.
- [ ] **Step 3: Verify server still works normally without guard**
Run the normal `scripts/run` flow and curl `/hello``/hello` still exists at this point since the handler hasn't been replaced yet.
```bash
scripts/run 8080
sleep 3
curl -s http://localhost:8080/hello
scripts/stop
```
Expected: HTML containing `<h1>Hello from Emacs.</h1>`.
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: add spine-skip-server-start guard for testing"
```
---
### Task 3: Implement spine-books model function
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Add `cl-lib` require**
At the top of `spine.el`, after `(require 'ht)`, add:
```elisp
(require 'cl-lib)
```
`cl-lib` is built into Emacs and provides `cl-return-from`.
- [ ] **Step 2: Add `spine-org-file` config variable**
After `spine-port` and `spine-host` defvars (after line 27), add:
```elisp
(defvar spine-org-file "~/spine/books.org"
"Path to the spine.org data file.")
```
- [ ] **Step 3: Add `spine--h` HTML escaping helper**
After `spine-render`, add:
```elisp
(defun spine--h (text)
"HTML-escape TEXT."
(when text
(let ((s text))
(setq s (replace-regexp-in-string "&" "&amp;" 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)))
```
- [ ] **Step 4: Add `spine--extract-notes` helper**
After `spine--h`, add:
```elisp
(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)))
```
- [ ] **Step 5: Add `spine-books` function**
After `spine--extract-notes`, add:
```elisp
(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."
(unless (file-exists-p spine-org-file)
(message "spine-books: file not found: %s" spine-org-file)
(cl-return-from spine-books 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 (save-excursion
(goto-char pos)
(org-entry-get nil "ID"))
:title (org-element-property :title hl)
:status (org-element-property :todo-keyword hl)
:author (save-excursion
(goto-char pos)
(org-entry-get nil "AUTHOR"))
:isbn (save-excursion
(goto-char pos)
(org-entry-get nil "ISBN"))
:cover (save-excursion
(goto-char pos)
(org-entry-get nil "COVER"))
:format (save-excursion
(goto-char pos)
(org-entry-get nil "FORMAT"))
:rec_by (save-excursion
(goto-char pos)
(org-entry-get nil "REC_BY"))
:rec_note (save-excursion
(goto-char pos)
(org-entry-get nil "REC_NOTE"))
:rating (save-excursion
(goto-char pos)
(org-entry-get nil "RATING"))
:added (save-excursion
(goto-char pos)
(org-entry-get nil "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)))
```
- [ ] **Step 6: Verify batch load still works**
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
Expected: exits cleanly.
- [ ] **Step 7: Verify spine-books returns data from sample file**
Run:
```bash
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"sample-books.org\")" \
--load spine.el \
--eval "(progn (let ((books (spine-books))) (message \"Found %d books\" (length books))))"
```
Expected: `Found 5 books` in output.
- [ ] **Step 8: Commit**
```bash
git add spine.el
git commit -m "feat: add spine-books model function"
```
---
### Task 4: Test spine-books with ERT
**Files:**
- Create: `test/spine-books-test.el`
- [ ] **Step 1: Create `test/` directory**
```bash
mkdir -p test
```
- [ ] **Step 2: Write `test/spine-books-test.el`**
```elisp
;;; spine-books-test.el — ERT tests for spine-books
(require 'ert)
(require 'cl-lib)
;; Load spine.el without starting the server
(setq spine-skip-server-start t)
(setq spine-org-file (expand-file-name "sample-books.org"
(file-name-directory load-file-name)))
(load-file (expand-file-name "spine.el"
(file-name-directory load-file-name)))
(ert-deftest spine-books-parses-sample ()
"spine-books returns correct plists for sample-books.org."
(let ((books (spine-books)))
(should books)
(should (= (length books) 5))))
(ert-deftest spine-books-first-book-has-all-fields ()
"Use of Weapons entry has all expected fields."
(let* ((books (spine-books))
(uow (cl-find "8c1e-uow" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should uow)
(should (equal (plist-get uow :title) "Use of Weapons"))
(should (equal (plist-get uow :status) "READING"))
(should (equal (plist-get uow :author) "Iain M. Banks"))
(should (equal (plist-get uow :isbn) "978-0316029193"))
(should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg"))
(should (equal (plist-get uow :format) "audiobook"))
(should (equal (plist-get uow :rec_by) "Priya"))
(should (equal (plist-get uow :rec_note) "If you liked Player of Games, this one will wreck you"))
(should (equal (plist-get uow :tags) '("scifi" "literary")))
(should (equal (plist-get uow :notes)
'(("2026-03-02" "The two-track structure is doing something I can't name yet.")
("2026-03-07" "Zakalwe's competence reads as a wound.")
("2026-03-09" "The chapter numbering. Oh."))))))
(ert-deftest spine-books-missing-properties-are-nil ()
"Piranesi entry has nil for non-present properties."
(let* ((books (spine-books))
(pir (cl-find "5e8d-pir" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should pir)
(should (equal (plist-get pir :title) "Piranesi"))
(should (equal (plist-get pir :status) "WANT"))
(should (equal (plist-get pir :author) "Susanna Clarke"))
;; ISBN, cover, format, rec_by, rec_note should all be nil or ""
(should-not (plist-get pir :isbn))
(should-not (plist-get pir :cover))
(should-not (plist-get pir :format))
(should-not (plist-get pir :rec_by))
(should-not (plist-get pir :rec_note))
;; Notes should be empty
(should-not (plist-get pir :notes))))
(ert-deftest spine-books-read-book-has-rating ()
"Ancillary Justice has rating 5."
(let* ((books (spine-books))
(aj (cl-find "3a1b-aj" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should aj)
(should (equal (plist-get aj :status) "READ"))
(should (equal (plist-get aj :rating) "5"))
(should (= (length (plist-get aj :notes)) 3))))
(ert-deftest spine-books-empty-file-returns-empty-list ()
"Empty Org file returns empty list, not nil."
(let* ((tmp-file (make-temp-file "spine-test-" nil ".org"))
(spine-org-file tmp-file))
(unwind-protect
(let ((books (spine-books)))
(should (listp books))
(should (= (length books) 0)))
(delete-file tmp-file))))
(ert-deftest spine-books-missing-file-returns-nil ()
"Non-existent file returns nil."
(let ((spine-org-file "/tmp/spine-nonexistent-12345.org"))
(should-not (spine-books))))
```
- [ ] **Step 3: Run the tests**
```bash
emacs --quick --batch \
--directory "$(pwd)" \
--load test/spine-books-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: all 6 tests pass.
- [ ] **Step 4: Commit**
```bash
git add test/spine-books-test.el
git commit -m "test: add ERT tests for spine-books"
```
---
### Task 5: Implement view functions
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Add `spine-render-index` function**
After `spine-books`, add:
```elisp
(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)))
;; 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 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
(insert "<meta charset=\"utf-8\">\n")
(insert "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
(insert "<title>Spine</title>\n</head>\n<body>\n")
(insert "<div class=\"frame\">\n")
(insert "<div class=\"titlebar\">Spine</div>\n")
;; Each group in order
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(insert (format "<div class=\"group\">%s (%d)</div>\n"
status (length group-books)))
(dolist (book group-books)
(let ((selected (equal (plist-get book :id) selected-id)))
(insert (format "<div class=\"row%s\">\n"
(if selected " selected" "")))
;; Format glyph
(let ((fmt (plist-get book :format)))
(insert (format "<span class=\"glyph\">%s</span>\n"
(cond ((equal fmt "hardcover") "[H]")
((equal fmt "ebook") "[E]")
((equal fmt "audiobook") "[A]")
(t "")))))
;; Title + author
(insert "<span class=\"title\">")
(insert (spine--h (plist-get book :title)))
(let ((author (plist-get book :author)))
(when author
(insert (format " <span class=\"muted\">%s</span>"
(spine--h author)))))
(insert "</span>\n")
;; Tags
(let ((tags (plist-get book :tags)))
(when tags
(insert (format "<span class=\"tags\">%s</span>\n"
(mapconcat (lambda (t) (concat ":" t ":"))
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 "<span class=\"trail\">%s%s</span>\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 "<span class=\"pill %s\">%s</span>\n"
(downcase status) status)))
;; Expanded detail
(when selected
(insert "<div class=\"detail\">\n")
(dolist (note (plist-get book :notes))
(insert (format
"<div class=\"logline\"><span class=\"logdate\">[%s]</span> %s</div>\n"
(spine--h (car note))
(spine--h (cadr note)))))
(let ((rating (plist-get book :rating)))
(when (and rating (> (length rating) 0))
(insert (format "<div>Rating: %s/5</div>\n"
(spine--h rating)))))
(let ((isbn (plist-get book :isbn)))
(when (and isbn (> (length isbn) 0))
(insert (format "<div>ISBN: %s</div>\n"
(spine--h isbn)))))
(let ((fmt (plist-get book :format)))
(when (and fmt (> (length fmt) 0))
(insert (format "<div>Format: %s</div>\n"
(spine--h fmt)))))
(let ((added (plist-get book :added)))
(when (and added (> (length added) 0))
(insert (format "<div>Added: %s</div>\n"
(spine--h added)))))
(insert "</div>\n"))
(insert "</div>\n"))))))
(insert "</div>\n</body>\n</html>\n")
(buffer-string))))
```
- [ ] **Step 2: Add `spine-render-empty-state` function**
After `spine-render-index`, add:
```elisp
(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</head>\n<body>\n"
"<div class=\"frame\">\n"
"<div class=\"titlebar\">Spine</div>\n"
"<div class=\"row\">No books yet.</div>\n"
"</div>\n</body>\n</html>\n"))
```
- [ ] **Step 3: Verify batch load**
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
Expected: exits cleanly, no errors.
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: add spine-render-index and empty-state view functions"
```
---
### Task 6: Wire up the index handler
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Replace `/hello` handler with `/` index handler**
Replace the `hello` handler (lines 4851) with:
```elisp
(defservlet index text/html ()
(let ((books (spine-books)))
(if books
(insert (spine-render-index books
(ht-get (ht-from-query-string (httpd-query-string)) "id")))
(insert (spine-render-empty-state)))))
```
- [ ] **Step 2: Verify batch load**
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
Expected: exits cleanly.
- [ ] **Step 3: Commit**
```bash
git add spine.el
git commit -m "feat: wire up / index handler replacing /hello"
```
---
### Task 7: Smoke test end-to-end
**Files:**
- No changes — verification only.
- [ ] **Step 1: Start server with sample data**
```bash
emacs --quick \
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el &
EMACS_PID=$!
sleep 5
```
- [ ] **Step 2: Verify index page returns HTML with real data**
```bash
curl -s http://localhost:8080/ | head -20
```
Expected: HTML document with:
- `<title>Spine</title>`
- `<div class="group">WANT (3)</div>`
- `<div class="group">READING (1)</div>`
- `<div class="group">READ (1)</div>`
- No `ABANDONED` group (zero books)
- "Use of Weapons" visible in output
- Recommendation trail "Priya: If you liked..."
- [ ] **Step 3: Verify expanded detail via ?id=**
```bash
curl -s "http://localhost:8080/?id=8c1e-uow" | grep -A5 "detail"
```
Expected: expanded detail section with reading notes for Use of Weapons:
- `[2026-03-02] The two-track structure...`
- `[2026-03-07] Zakalwe's competence...`
- `[2026-03-09] The chapter numbering. Oh.`
- [ ] **Step 4: Verify bogus ?id= does not crash**
```bash
curl -s "http://localhost:8080/?id=nonexistent"
```
Expected: normal index page, no expanded detail, HTTP 200.
- [ ] **Step 5: Verify empty state when file is missing**
```bash
kill $EMACS_PID 2>/dev/null
sleep 1
emacs --quick \
--eval "(setq spine-org-file \"/tmp/nonexistent-books.org\")" \
--load spine.el &
EMACS_PID=$!
sleep 5
curl -s http://localhost:8080/
```
Expected: HTML containing `No books yet.`
- [ ] **Step 6: Stop server**
```bash
kill $EMACS_PID 2>/dev/null
```
- [ ] **Step 7: Verify with scripts/run**
```bash
# Override spine-org-file via the run script (pass it as an extra eval)
SPINE_ORG="$(pwd)/sample-books.org"
emacs --quick --daemon=spine \
--eval "(setq spine-org-file \"$SPINE_ORG\")" \
--load spine.el
sleep 3
curl -s http://localhost:8080/ | grep -c "WANT"
# Expected: 1 (the group header "WANT (3)")
emacsclient --socket-name=spine --eval '(kill-emacs)'
```
The output should contain `1`.
---
### Task 8: Final verification and cleanup
**Files:**
- No changes — verification only.
- [ ] **Step 1: Run full ERT test suite**
```bash
emacs --quick --batch \
--directory "$(pwd)" \
--load test/spine-books-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: all 6 tests pass.
- [ ] **Step 2: Verify git status is clean**
```bash
git status
```
Expected: `nothing to commit, working tree clean`
- [ ] **Step 3: Review commit log**
```bash
git log --oneline
```
Expected: ~7 commits covering sample data, server guard, model, tests, view, handler.
+555
View File
@@ -0,0 +1,555 @@
# Add Book Form — Implementation Plan
**Goal:** Add a form at `/add` to create new books in `spine.org`, plus the write function and "Add book" link on the index page.
**Architecture:** Mustache template for the form, `spine-add-book` function writes to the Org file using standard Org commands, `/add` servlet handles both GET (show form) and POST (process submission + redirect).
**Tech Stack:** Emacs Lisp, `org-element` / Org commands, Mustache templates.
---
## File structure
| File | Responsibility |
|------|---------------|
| `templates/add.mustache` | New — form template with Pico CSS + Tabler Icons shell |
| `templates/index.mustache` | Modified — add "Add book" button in header |
| `spine.el` | New functions: `spine-add-book`, `spine-add-form-model`. New handler: `/add`. |
| `test/spine-add-book-test.el` | New — ERT tests for `spine-add-book` |
---
### Task 1: Create the add-book form template
**Files:**
- Create: `templates/add.mustache`
- [ ] **Step 1: Write `templates/add.mustache`**
```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 — Add Book</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>{{app_title}} · add book</strong>
</header>
<form method="post" action="/add">
<label>
Date added
<input type="date" name="date_added" />
</label>
<label>
Title *
<input type="text" name="title" required autofocus />
</label>
<label>
Author
<input type="text" name="author" list="authors-list" />
<datalist id="authors-list">
{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}
</datalist>
</label>
<label>
Category
<input type="text" name="category" list="categories-list" />
<datalist id="categories-list">
{{#existing_categories}}<option value="{{.}}">{{/existing_categories}}
</datalist>
</label>
<label>
Format
<select name="format">
<option value=""></option>
<option value="hardcover">Hardcover</option>
<option value="ebook">eBook</option>
<option value="audiobook">Audiobook</option>
</select>
</label>
<label>
ISBN
<input type="text" name="isbn" />
</label>
<label>
Cover (path)
<input type="text" name="cover" placeholder="covers/filename.jpg" />
</label>
<label>
Recommended by
<input type="text" name="rec_by" />
</label>
<label>
Why it's on your radar
<input type="text" name="rec_note" />
</label>
<label>
Initial note
<textarea name="initial_note" rows="3" placeholder="First impressions…"></textarea>
</label>
<button type="submit">Add book</button>
</form>
</article>
</main>
</body>
</html>
```
- [ ] **Step 2: Verify template file exists**
```bash
cat templates/add.mustache | head -3
```
Expected: `<!DOCTYPE html>`
- [ ] **Step 3: Commit**
```bash
git add templates/add.mustache
git commit -m "feat: add add-book form template"
```
---
### Task 2: Add "Add book" link to index page
**Files:**
- Modify: `templates/index.mustache`
- [ ] **Step 1: Add a link in the index header**
Find the `<header>` section in `templates/index.mustache` (line 79-82):
```html
<header>
<strong>{{app_title}}</strong>
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
</header>
```
Replace with:
```html
<header>
<nav>
<ul><li><strong>{{app_title}}</strong></li></ul>
<ul>
<li><span class="muted">{{total_books}} books · {{reading_count}} reading</span></li>
<li><a role="button" href="/add" class="secondary">Add book</a></li>
</ul>
</nav>
</header>
```
- [ ] **Step 2: Verify the template renders**
```bash
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el \
--eval "(progn (message \"Has Add book: %s\" (if (string-match \"Add book\" (spine-render \"index.mustache\" (spine-index-model (spine-books) nil))) \"yes\" \"NO\")))"
```
(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 (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))))))
```
- [ ] **Step 2: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly, no errors.
```bash
rm -f /tmp/spine-test-add.org
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"/tmp/spine-test-add.org\")" \
--load spine.el \
--eval "(progn (spine-add-book :title \"Test Book\" :author \"Test Author\" :format \"ebook\" :category \"scifi\" :date_added \"2026-06-21\" :initial_note \"This is a test\") (let ((books (spine-books))) (message \"Book count: %d\" (length books)) (message \"Title: %s\" (plist-get (car books) :title)) (message \"ID: %s\" (plist-get (car books) :id)) (message \"Notes: %S\" (plist-get (car books) :notes))))" 2>&1
```
Expected: `Book count: 1`, `Title: Test Book`, ID present, one note.
- [ ] **Step 4: Clean up and commit**
```bash
rm -f /tmp/spine-test-add.org
git add spine.el
git commit -m "feat: add spine-add-book write function"
```
---
### Task 4: Wire up /add handler and spine-add-form-model
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Add `spine-add-form-model` function**
Insert before `spine-index-model`:
```elisp
(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<)))))
```
- [ ] **Step 2: Add `/add` servlet**
Replace or insert after the existing `index` servlet handler:
```elisp
(defservlet add text/html (path query request)
(let ((method (cadr (car request))))
(if (equal method "POST")
(progn
(spine-add-book
:title (cdr (assoc "title" query))
:author (cdr (assoc "author" query))
:category (cdr (assoc "category" query))
:format (cdr (assoc "format" query))
:isbn (cdr (assoc "isbn" query))
:cover (cdr (assoc "cover" query))
:rec_by (cdr (assoc "rec_by" query))
:rec_note (cdr (assoc "rec_note" query))
:date_added (cdr (assoc "date_added" query))
:initial_note (cdr (assoc "initial_note" query)))
(httpd-redirect t "/index" 303))
(let* ((books (spine-books))
(model (if books
(spine-add-form-model books)
(ht ("app_title" "spine")))))
(insert (spine-render "add.mustache" model))))))
```
- [ ] **Step 3: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly.
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: wire up /add handler with form model"
```
---
### Task 5: Test spine-add-book with ERT
**Files:**
- Create: `test/spine-add-book-test.el`
- [ ] **Step 1: Write `test/spine-add-book-test.el`**
```elisp
;;; spine-add-book-test.el — ERT tests for spine-add-book
(require 'ert)
(require 'cl-lib)
(setq spine-skip-server-start t)
(setq spine-org-file (expand-file-name "test-add-temp.org"
(file-name-directory load-file-name)))
(load-file (expand-file-name "../spine.el"
(file-name-directory load-file-name)))
(defun spine-add-book-test--cleanup ()
"Remove the test Org file if it exists."
(ignore-errors (delete-file spine-org-file)))
(ert-deftest spine-add-book-creates-headline ()
"spine-add-book creates a new WANT headline."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Test Book" :author "Author Name")
(let ((books (spine-books)))
(should books)
(should (= (length books) 1))
(let ((book (car books)))
(should (equal (plist-get book :title) "Test Book"))
(should (equal (plist-get book :status) "WANT"))
(should (equal (plist-get book :author) "Author Name"))
(should (plist-get book :id)))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-sets-all-fields ()
"spine-add-book sets all provided fields."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book
:title "Full Test"
:author "Jane Doe"
:format "hardcover"
:isbn "978-1234567890"
:cover "covers/test.jpg"
:rec_by "Friend"
:rec_note "You'll love this"
:date_added "2026-06-21"
:category "scifi"
:initial_note "Looking forward to this")
(let* ((books (spine-books))
(book (car books)))
(should (equal (plist-get book :title) "Full Test"))
(should (equal (plist-get book :author) "Jane Doe"))
(should (equal (plist-get book :format) "hardcover"))
(should (equal (plist-get book :isbn) "978-1234567890"))
(should (equal (plist-get book :cover) "covers/test.jpg"))
(should (equal (plist-get book :rec_by) "Friend"))
(should (equal (plist-get book :rec_note) "You'll love this"))
(should (equal (plist-get book :added) "[2026-06-21]"))
(should (equal (plist-get book :tags) '("scifi")))
(should (equal (plist-get book :notes)
'(("2026-06-21" "Looking forward to this"))))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-omits-empty-fields ()
"spine-add-book does not set empty/omitted fields."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Minimal Book")
(let* ((books (spine-books))
(book (car books)))
(should (equal (plist-get book :title) "Minimal Book"))
(should-not (plist-get book :author))
(should-not (plist-get book :format))
(should-not (plist-get book :isbn))
(should-not (plist-get book :tags))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-requires-title ()
"spine-add-book signals error when title is missing."
(spine-add-book-test--cleanup)
(unwind-protect
(should-error (spine-add-book :author "No Title"))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-generates-unique-ids ()
"spine-add-book generates different IDs for each book."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Book One")
(spine-add-book :title "Book Two")
(let* ((books (spine-books))
(id1 (plist-get (nth 0 books) :id))
(id2 (plist-get (nth 1 books) :id)))
(should (not (equal id1 id2)))))
(spine-add-book-test--cleanup)))
```
- [ ] **Step 2: Run the tests**
```bash
emacs --quick --batch \
--directory "$(pwd)" \
--load test/spine-add-book-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: all 5 tests pass.
- [ ] **Step 3: Commit**
```bash
git add test/spine-add-book-test.el
git commit -m "test: add ERT tests for spine-add-book"
```
---
### Task 6: Smoke test end-to-end
**Files:**
- No changes — verification only.
- [ ] **Step 1: Start server with temp Org file**
```bash
rm -f /tmp/spine-add-test.org
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
pkill -f "emacs --quick" 2>/dev/null
sleep 1
emacs --quick \
--eval "(setq spine-org-file \"/tmp/spine-add-test.org\")" \
--load spine.el &
sleep 5
```
- [ ] **Step 2: Verify form page loads**
```bash
curl -s http://localhost:8080/add | grep -c "Add book"
```
Expected: `4` (title, button, etc.)
- [ ] **Step 3: Verify "Add book" link on index**
```bash
curl -s http://localhost:8080/index | grep -c "Add book"
```
Expected: `1`
- [ ] **Step 4: Submit a new book via form**
```bash
curl -s -X POST http://localhost:8080/add \
-d "title=Neuromancer" \
-d "author=William Gibson" \
-d "format=ebook" \
-d "category=scifi" \
-d "date_added=2026-06-21" \
-d "initial_note=The sky was the color of television" \
-o /dev/null -w "HTTP %{http_code}\n" \
-L
```
Expected: HTTP 200 (after redirect).
- [ ] **Step 5: Verify new book appears on index**
```bash
curl -s http://localhost:8080/index | grep "Neuromancer"
```
Expected: `Neuromancer` visible in output.
- [ ] **Step 6: Stop server**
```bash
kill %1 2>/dev/null
rm -f /tmp/spine-add-test.org
```
---
### Task 7: Run all tests and final verification
**Files:**
- No changes — verification only.
- [ ] **Step 1: Run all ERT test suites**
```bash
# Run spine-books tests
emacs --quick --batch --directory "$(pwd)" \
--load test/spine-books-test.el \
-f ert-run-tests-batch-and-exit
# Run spine-add-book tests
emacs --quick --batch --directory "$(pwd)" \
--load test/spine-add-book-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: 6/6 + 5/5 = all pass.
- [ ] **Step 2: Git status**
```bash
git status --short
git log --oneline -4
```
@@ -0,0 +1,478 @@
# Concise Default View Implementation Plan
**Goal:** Default index shows only READING + 5 most recent WANT books, with filter links to expanded views.
**Architecture:** `spine-index-model` gains an optional `filter` argument (nil = concise, "all"/"read"/"want" = expanded). The handler reads `filter` from the request query and passes it through. The template gains conditional nav links and summary-group rows. Existing group rendering unchanged.
**Tech Stack:** Emacs Lisp (model/handler), Mustache (template)
---
## File overview
**Modify:**
- `spine.el``spine-index-model` (add filter param, concise logic), `defservlet index` (pass query filter)
- `templates/index.mustache` — filter nav bar, summary-group rows
- `test/spine-index-model-test.el` — new test file for filtered model
---
### Task 1: Write failing tests for filtered `spine-index-model`
**Files:**
- Create: `test/spine-index-model-test.el`
- [ ] **Step 1: Create test file with setup**
```elisp
;;; spine-index-model-test.el — ERT tests for spine-index-model with filter
(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-index-model-test--books (spine-books)
"Books fixture loaded once from sample-books.org.")
```
- [ ] **Step 2: Write test — concise view excludes READ and ABANDONED groups, has summary**
```elisp
(ert-deftest spine-index-model-concise-hides-read-and-abandoned ()
"Concise view (filter=nil) omits READ/ABANDONED groups, adds summary-groups."
(let* ((model (spine-index-model spine-index-model-test--books))
(groups (ht-get model "groups"))
(summaries (ht-get model "summary_groups"))
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
(should-not (member "read" group-labels))
(should-not (member "abandoned" group-labels))
(should summaries)
(should (= (length summaries) 1)) ; only READ, no ABANDONED in sample data
(should (string= (ht-get (car summaries) "label") "read"))
(should (= (ht-get (car summaries) "count") 1)) ; Ancillary Justice
(should (string= (ht-get (car summaries) "href") "/index?filter=read"))))
```
- [ ] **Step 3: Write test — concise view includes READING group**
```elisp
(ert-deftest spine-index-model-concise-includes-reading ()
"Concise view includes READING group with all reading books."
(let* ((model (spine-index-model spine-index-model-test--books))
(groups (ht-get model "groups"))
(reading-group (cl-find "reading" groups
:key (lambda (g) (ht-get g "label"))
:test #'string=)))
(should reading-group)
(should (= (ht-get reading-group "count") 1)) ; Use of Weapons
(should (string= (ht-get (car (ht-get reading-group "books")) "title")
"Use of Weapons"))))
```
- [ ] **Step 4: Write test — concise view limits WANT to 5 most recent**
```elisp
(ert-deftest spine-index-model-concise-want-limited-to-five ()
"Concise view limits WANT group to 5 books, sorted by :ADDED: desc."
(let* ((model (spine-index-model spine-index-model-test--books))
(groups (ht-get model "groups"))
(want-group (cl-find "on deck" groups
:key (lambda (g) (ht-get g "label"))
:test #'string=)))
(should want-group)
(let ((books (ht-get want-group "books")))
(should (<= (length books) 5))
;; sample-books.org has 3 WANT books — all should appear, sorted newest first
(should (= (length books) 3))
;; Piranesi added 2026-06-01, Dune 2026-05-10, Left Hand 2026-04-01
(should (string= (ht-get (nth 0 books) "title") "Piranesi"))
(should (string= (ht-get (nth 1 books) "title") "Dune"))
(should (string= (ht-get (nth 2 books) "title") "The Left Hand of Darkness")))))
```
- [ ] **Step 5: Write test — filter=all returns full model (no summary, all groups)**
```elisp
(ert-deftest spine-index-model-filter-all-shows-all-groups ()
"filter=all returns all groups, no summary-groups."
(let* ((model (spine-index-model spine-index-model-test--books "all"))
(groups (ht-get model "groups"))
(summaries (ht-get model "summary_groups"))
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
(should-not summaries)
(should (member "on deck" group-labels))
(should (member "reading" group-labels))
(should (member "read" group-labels))
(should (= (length groups) 3)))) ; WANT, READING, READ (no ABANDONED in sample)
```
- [ ] **Step 6: Write test — filter=read returns only READ group**
```elisp
(ert-deftest spine-index-model-filter-read-returns-only-read ()
"filter=read returns only the READ group."
(let* ((model (spine-index-model spine-index-model-test--books "read"))
(groups (ht-get model "groups")))
(should (= (length groups) 1))
(should (string= (ht-get (car groups) "label") "read"))
(should (= (ht-get (car groups) "count") 1))))
```
- [ ] **Step 7: Write test — filter=want returns only WANT group**
```elisp
(ert-deftest spine-index-model-filter-want-returns-only-want ()
"filter=want returns only the WANT group."
(let* ((model (spine-index-model spine-index-model-test--books "want"))
(groups (ht-get model "groups")))
(should (= (length groups) 1))
(should (string= (ht-get (car groups) "label") "on deck"))
(should (= (ht-get (car groups) "count") 3))))
```
- [ ] **Step 8: Write test — invalid filter falls back to concise**
```elisp
(ert-deftest spine-index-model-invalid-filter-falls-back-to-concise ()
"Unrecognised filter value behaves like nil (concise view)."
(let* ((model (spine-index-model spine-index-model-test--books "bogus"))
(groups (ht-get model "groups"))
(summaries (ht-get model "summary_groups"))
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
(should summaries) ; has summary
(should-not (member "read" group-labels)) ; no read group
(should (member "reading" group-labels)) ; has reading
(should (member "on deck" group-labels)))) ; has want
```
- [ ] **Step 9: Write test — model includes current_filter in context**
```elisp
(ert-deftest spine-index-model-includes-current-filter ()
"Model includes :current_filter key matching the filter argument."
(should (equal (ht-get (spine-index-model spine-index-model-test--books) "current_filter") ""))
(should (equal (ht-get (spine-index-model spine-index-model-test--books "all") "current_filter") "all"))
(should (equal (ht-get (spine-index-model spine-index-model-test--books "read") "current_filter") "read"))
(should (equal (ht-get (spine-index-model spine-index-model-test--books "want") "current_filter") "want")))
```
- [ ] **Step 10: Run tests to verify they fail**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-index-model-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all tests fail because `spine-index-model` doesn't accept a second argument yet.
- [ ] **Step 11: Commit test file**
```bash
git add test/spine-index-model-test.el
git commit -m "test: add failing tests for filtered spine-index-model"
```
---
### Task 2: Implement filtered `spine-index-model`
**Files:**
- Modify: `spine.el:77-155`
- [ ] **Step 1: Update signature and add filter handling**
Swap the function at line 77:
```elisp
(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")))
;; Concise: replace read/abandoned groups with summary links
(push (ht ("label" (cdr (assoc status status-labels)))
("count" (length group-books))
("href" (format "/index?filter=%s"
(downcase status))))
summary-groups)
;; Show the group (possibly filtered to one status)
(when (or (null effective-filter)
(string= (downcase status) effective-filter)
(string= effective-filter "all"))
;; Sort WANT by ADDED descending when in concise view
(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 · %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))))))
```
- [ ] **Step 2: Run tests to verify they pass**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-index-model-test.el \
-l test/spine-books-test.el \
-l test/spine-add-book-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all tests pass (new filter tests + existing books + add-book tests).
- [ ] **Step 3: Commit**
```bash
git add spine.el
git commit -m "feat: add filter parameter to spine-index-model for concise default view"
```
---
### Task 3: Update `defservlet index` handler
**Files:**
- Modify: `spine.el:308-313`
- [ ] **Step 1: Pass query filter to model**
Swap lines 308-313:
```elisp
(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)))))
```
- [ ] **Step 2: Run existing tests to confirm handler change doesn't break anything**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-index-model-test.el \
-l test/spine-books-test.el \
-l test/spine-add-book-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all pass.
- [ ] **Step 3: Commit**
```bash
git add spine.el
git commit -m "feat: pass filter query param from index handler to model"
```
---
### Task 4: Update `templates/index.mustache` with filter links and summary groups
**Files:**
- Modify: `templates/index.mustache`
- [ ] **Step 1: Add filter nav bar after header, before groups**
Insert after the closing `</header>` (line 87) and before the `{{#groups}}` section (line 89):
```mustache
{{#current_filter}}
<nav class="filter-bar" style="padding: .4rem .9rem; font-size: .8rem;">
<a href="/index">Concise view</a>
</nav>
{{/current_filter}}
{{^current_filter}}
<nav class="filter-bar" style="padding: .4rem .9rem; font-size: .8rem;">
<span>
<a href="/index?filter=all">All books ({{total_books}})</a>
&nbsp;·&nbsp;
<a href="/index?filter=want">Want list</a>
&nbsp;·&nbsp;
<a href="/index?filter=read">Read ({{reading_count}})</a>
</span>
</nav>
{{/current_filter}}
```
- [ ] **Step 2: Add summary group rows after groups section**
Insert after the closing `{{/groups}}` (line 109) and before `<div class="endpad">` (line 110):
```mustache
{{#summary_groups}}
<div class="row"><a href="{{href}}">{{label}} · {{count}}</a></div>
{{/summary_groups}}
```
- [ ] **Step 3: Commit**
```bash
git add templates/index.mustache
git commit -m "feat: add filter nav bar and summary group links to index template"
```
---
### Task 5: Manual integration test
- [ ] **Step 1: Start the server and verify concise default view**
```bash
cd /work/personal-local/spine
SPINE_ORG_FILE=$(pwd)/sample-books.org emacs --batch -l spine.el \
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
--eval "(httpd-start)" \
--eval "(message \"Server running on port 8080\")" &
sleep 2
curl -s http://localhost:8080/index | head -50
```
Verify the output:
- Shows READING group (Use of Weapons)
- Shows WANT group (Piranesi, Dune, Left Hand)
- Does NOT show READ group (Ancillary Justice) as a full group
- Shows a summary link for "read · 1"
- Shows "All books (5)" filter nav link
- [ ] **Step 2: Test filter=all**
```bash
curl -s "http://localhost:8080/index?filter=all"
```
Verify: all books shown in their groups, no summary sections, "Concise view" link present.
- [ ] **Step 3: Test filter=read**
```bash
curl -s "http://localhost:8080/index?filter=read"
```
Verify: only Ancillary Justice shown, "Concise view" link present.
- [ ] **Step 4: Kill the server**
```bash
kill %1 2>/dev/null; wait 2>/dev/null
```
- [ ] **Step 5: Run all tests one final time**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-index-model-test.el \
-l test/spine-books-test.el \
-l test/spine-add-book-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all pass.
- [ ] **Step 6: Final commit if any fixes needed**
```bash
git commit -am "fix: address integration test findings"
```
+600
View File
@@ -0,0 +1,600 @@
# Edit Book Actions Implementation Plan
**Goal:** Allow users to change a book's status, add reading notes, and set ratings
from the agenda view via context-sensitive minibuffer actions.
**Architecture:** New Org write functions (`spine-set-status`, `spine-add-note`,
`spine-set-rating`) follow the same `find-file-noselect` + `save-buffer` + `kill-buffer`
pattern as `spine-add-book`. A single `/edit` handler dispatches based on `action`
query param. The index model gains a `minibuffer` field with action links computed
from the selected book's status.
**Tech Stack:** Emacs Lisp (backend + model), Mustache (template)
---
## File overview
**Modify:**
- `spine.el` — 3 new write functions + `httpd/edit` handler + minibuffer in model
- `templates/index.mustache` — minibuffer renders action links
**Create:**
- `test/spine-edit-test.el` — tests for write functions + model minibuffer
---
### Task 1: Write failing tests for Org write functions
**Files:**
- Create: `test/spine-edit-test.el`
- [ ] **Step 1: Create test file with setup**
```elisp
;;; spine-edit-test.el — ERT tests for spine-edit functions
(require 'ert)
(require 'cl-lib)
(setq spine-skip-server-start t)
(setq spine-org-file (expand-file-name "test-edit-temp.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)))
(defun spine-edit-test--setup ()
"Create a test Org file with one WANT book."
(spine-add-book :title "Edit Test Book" :author "Test Author"))
(defun spine-edit-test--cleanup ()
"Remove the test Org file."
(ignore-errors (delete-file spine-org-file)))
```
- [ ] **Step 2: Test spine-set-status transitions WANT to READING**
```elisp
(ert-deftest spine-set-status-want-to-reading ()
"spine-set-status changes TODO from WANT to READING."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-status id "READING")
(let ((updated-books (spine-books))
(updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :status) "READING")))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 3: Test spine-set-status transitions READING to READ**
```elisp
(ert-deftest spine-set-status-reading-to-read ()
"spine-set-status changes TODO from READING to READ."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-status id "READING")
(spine-set-status id "READ")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :status) "READ")))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 4: Test spine-add-note appends a note**
```elisp
(ert-deftest spine-add-note-appends ()
"spine-add-note appends a note with date and text."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-add-note id "Great book!" "2026-06-21")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(let ((notes (plist-get updated :notes)))
(should notes)
(should (equal (caar notes) "2026-06-21"))
(should (equal (cadar notes) "Great book!"))))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 5: Test spine-add-note defaults to today's date**
```elisp
(ert-deftest spine-add-note-default-date ()
"spine-add-note uses today's date when none provided."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id))
(today (format-time-string "%Y-%m-%d")))
(spine-add-note id "Note without date")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(let ((notes (plist-get updated :notes)))
(should notes)
(should (equal (caar notes) today))
(should (equal (cadar notes) "Note without date"))))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 6: Test spine-set-rating**
```elisp
(ert-deftest spine-set-rating-sets-rating ()
"spine-set-rating sets the RATING property."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-rating id "4")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :rating) "4")))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 7: Test spine-set-status with missing ID signals error**
```elisp
(ert-deftest spine-set-status-missing-id ()
"spine-set-status signals error for unknown ID."
(should-error (spine-set-status "nonexistent-id" "READING")))
```
- [ ] **Step 8: Run tests to verify they fail**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all tests fail because `spine-set-status` etc. are not defined yet.
- [ ] **Step 9: Commit test file**
```bash
git add test/spine-edit-test.el
git commit -m "test: add failing tests for spine-set-status, spine-add-note, spine-set-rating"
```
---
### Task 2: Implement Org write functions
**Files:**
- Modify: `spine.el` (insert after `spine-add-book`, before `spine--extract-notes`)
- [ ] **Step 1: Add spine-set-status**
Insert after line 251 (end of `spine-add-book`):
```elisp
(defun spine-set-status (id status)
"Set the TODO state of book with ID to STATUS.
STATUS should be one of: WANT, READING, READ, ABANDONED.
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-status: 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) 1)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char pos)
(org-todo status)))))
nil 'headline)
(unless (eq (point) (point-min))
(save-buffer)
t)
(when (eq (point) (point-min))
(error "spine-set-status: no book found with ID %s" id)))
(kill-buffer)))))
```
- [ ] **Step 2: Run tests to verify spine-set-status passes**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: spine-set-status tests pass, spine-add-note and spine-set-rating still fail.
- [ ] **Step 3: Add spine-add-note**
Insert after `spine-set-status`:
```elisp
(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) 1)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char (org-entry-end-position))
(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)))))
```
- [ ] **Step 4: Run tests to verify spine-add-note passes**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: spine-set-status and spine-add-note tests pass, spine-set-rating still fails.
- [ ] **Step 5: Add spine-set-rating**
Insert after `spine-add-note`:
```elisp
(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) 1)
(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)))))
```
- [ ] **Step 6: Run all edit tests to verify they pass**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all 6 tests pass.
- [ ] **Step 7: Run existing tests to confirm nothing broke**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-index-model-test.el \
-l test/spine-books-test.el \
-l test/spine-add-book-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all pass.
- [ ] **Step 8: Commit**
```bash
git add spine.el
git commit -m "feat: add spine-set-status, spine-add-note, spine-set-rating"
```
---
### Task 3: Extend spine-index-model with minibuffer actions
**Files:**
- Modify: `spine.el``spine-index-model` function
- [ ] **Step 1: Add minibuffer computation to per-book model**
In the per-book model building block (around line 158, after `"selected" selected`), add the minibuffer actions when the book is selected. Insert after the `detail` ht block and before closing the model ht:
Inside the `(let* (... (model (ht ...)))` block, after the `"detail"` key-value pair and before the closing `)))` of the let*, add:
```elisp
("minibuffer"
(when selected
(let ((status (plist-get book :status)))
(ht ("actions"
(append
(cond
((equal status "WANT")
(list
(ht ("command" "status") ("label" "Mark reading")
("href" (format "/edit?id=%s&action=status&value=READING" id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" id)))))
((equal status "READING")
(list
(ht ("command" "status") ("label" "Mark read")
("href" (format "/edit?id=%s&action=status&value=READ" id)))
(ht ("command" "status") ("label" "Abandon")
("href" (format "/edit?id=%s&action=status&value=ABANDONED" id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" id)))))
((equal status "READ")
(list
(ht ("command" "status") ("label" "Read again")
("href" (format "/edit?id=%s&action=status&value=READING" id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" id)))))
((equal status "ABANDONED")
(list
(ht ("command" "status") ("label" "Try again")
("href" (format "/edit?id=%s&action=status&value=READING" id)))))
(t nil))
nil))))))
```
- [ ] **Step 2: Write a test for minibuffer model**
Append to `test/spine-edit-test.el`:
```elisp
(ert-deftest spine-edit-model-minibuffer-want ()
"Selected WANT book has Mark reading and Add note actions."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id))
(model (spine-index-model books nil id))
(groups (ht-get model "groups")))
(cl-labels ((find-book-model (groups id)
(cl-block nil
(dolist (g groups)
(dolist (b (ht-get g "books"))
(when (string= (ht-get b "title") "Edit Test Book")
(cl-return b)))))))
(let ((bm (find-book-model groups id)))
(should bm)
(let ((mb (ht-get bm "minibuffer")))
(should mb)
(let ((actions (ht-get mb "actions")))
(should (= (length actions) 2))
(should (string= (ht-get (nth 0 actions) "label") "Mark reading"))
(should (string= (ht-get (nth 1 actions) "label") "Add note"))))))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 3: Run tests**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
-l test/spine-index-model-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all pass.
- [ ] **Step 4: Commit**
```bash
git add spine.el test/spine-edit-test.el
git commit -m "feat: add minibuffer actions to spine-index-model for selected books"
```
---
### Task 4: Add httpd/edit handler
**Files:**
- Modify: `spine.el` — add handler after `httpd/add`
- [ ] **Step 1: Add httpd/edit handler**
Insert after `httpd/add` (after line 369):
```elisp
(defun httpd/edit (proc uri-path query request)
"Handle /edit: GET shows prompt page, POST processes actions.
Query params:
id - book ID
action - status | note | rating
value - target status / rating value (for status/rating actions)
text - note text (for note action)
date - optional date string (for note/read actions)"
(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 "status")
(spine-set-status id value))
((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"))
;; Show a simple note-prompt page
(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)))))
```
- [ ] **Step 2: Test the handler via curl (server test)**
```bash
cd /work/personal-local/spine
SPINE_ORG_FILE=$(pwd)/sample-books.org emacs --batch -l spine.el \
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
--eval "(httpd-start)" 2>&1 &
sleep 2
# Test note prompt page renders
curl -s "http://localhost:8080/edit?id=8c1e-uow&action=note" 2>/dev/null | grep -oP "add note|Use of Weapons|textarea" | sort | uniq -c
```
Expected: shows "add note", "Use of Weapons", "textarea".
- [ ] **Step 3: Kill the server**
```bash
pkill -f "emacs.*spine.el" 2>/dev/null; sleep 1
```
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: add /edit handler with note prompt page"
```
---
### Task 5: Update template minibuffer section
**Files:**
- Modify: `templates/index.mustache`
- [ ] **Step 1: Replace minibuffer placeholder with action links**
Find the existing minibuffer section (lines ~127-133) and replace with:
```mustache
{{#minibuffer}}
<footer class="minibuffer">
<span class="muted">M-x</span>
<span style="display:flex;gap:.5rem;flex-wrap:wrap">
{{#actions}}
<a href="{{href}}" class="pill" style="text-decoration:none">{{label}}</a>
{{/actions}}
</span>
</footer>
{{/minibuffer}}
```
Remove any empty minibuffer template section if it existed (the current template has a minibuffer section with `command`/`text` fields that should be replaced).
- [ ] **Step 2: Verify template renders correctly**
```bash
cd /work/personal-local/spine
emacs --batch --eval '(setq spine-skip-server-start t)' \
--eval '(setq spine-org-file "/work/personal-local/spine/sample-books.org")' \
-l spine.el \
--eval '(let* ((books (spine-books)) (model (spine-index-model books nil "8c1e-uow")) (html (spine-render "index.mustache" model))) (with-temp-file "/tmp/spine-edit-test.html" (insert html)))' 2>&1
grep -oP "M-x|Mark read|Abandon|Add note" /tmp/spine-edit-test.html | sort | uniq -c
```
Expected: shows "M-x", "Mark read", "Abandon", "Add note" (for a READING book).
- [ ] **Step 3: Commit**
```bash
git add templates/index.mustache
git commit -m "feat: update minibuffer to render context-sensitive action links"
```
---
### Task 6: Final integration and test run
- [ ] **Step 1: Run all tests**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
-l test/spine-index-model-test.el \
-l test/spine-books-test.el \
-l test/spine-add-book-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all tests pass.
- [ ] **Step 2: Push to main**
```bash
git push origin main
```
@@ -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;">&larr; 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,137 @@
# Spine — Mustache Template Rendering
Replaces inline HTML generation with Mustache template rendering via
`spine-render`. The mockup-A mustache template becomes the canonical
index page template. A view-model builder function transforms `spine-books`
plists into the template's expected context shape.
## Architecture
```
spine.org ──→ [spine-books] ──→ book plists
[spine-index-model] ──→ view model (ht)
templates/index.mustache ───────────┼──→ [spine-render] ──→ HTML
```
## Template
`spine-mockup-a-agenda.mustache` moves to `templates/index.mustache`.
The template already contains:
- Pico CSS v2.1.1 CDN link
- Tabler Icons CDN link
- All custom CSS in `<style>` block (agenda layout, pills, detail, dark mode)
- Mustache sections: `{{#groups}}`, `{{#books}}`, `{{#detail}}`, `{{#notes}}`, `{{#minibuffer}}`
- `minibuffer` section kept in the template but not populated (empty context → section omitted)
## View model
A new function `spine-index-model` takes the book plists and an optional
selected-id, returns an `ht` hash table shaped for the template:
```elisp
(spine-index-model books &optional selected-id) ht
```
```json
{
"app_title": "spine",
"total_books": 5,
"reading_count": 1,
"groups": [
{
"label": "on deck",
"count": 3,
"books": [
{
"format_icon": "ti-book",
"status_class": "want",
"status_label": "want",
"title": "The Left Hand of Darkness",
"author": "Ursula K. Le Guin",
"tags_inline": null,
"rec_via": "Alex",
"rating_display": null,
"selected": false,
"detail": null
}
]
}
]
}
```
### Field mappings
| Plist key | Template field | Transform |
|---|---|---|
| `:status` | `groups[].label` | WANT→"on deck", READING→"reading", READ→"read", ABANDONED→"abandoned" |
| `:status` | `status_class` / `status_label` | lowercased |
| `:format` | `format_icon` | hardcover→`ti-book`, ebook→`ti-device-tablet`, audiobook→`ti-headphones`, nil→`""` |
| `:tags` | `tags_inline` | `("scifi" "literary")``":scifi:literary:"` (string), nil→nil |
| `:rec_by` | `rec_via` | direct string, nil→nil |
| `:rating` | `rating_display` | "5"→"★★★★★", nil→nil |
| `:id` = selected-id | `selected` | boolean |
| `:notes` | `detail.notes` | list of `{date, text}` |
| `:added` + `:format` | `detail.meta` | composite string |
### Detail meta format
```
"started [2026-02-20] · hardcover" (WANT/READING)
"finished [2026-01-10] · ebook" (READ, use most recent LOGBOOK date)
"" (no dates)
```
### Group ordering
`on deck` (WANT) → `reading` (READING) → `read` (READ) → `abandoned` (ABANDONED).
Zero-book groups omitted.
## Handler
```elisp
(defservlet index text/html (path query request)
(let ((books (spine-books)))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books (cadr (assoc "id" query)))))
(insert (spine-render-empty-state)))))
```
`spine-render-empty-state` keeps its current Pico-styled inline HTML
(no Mustache needed for a single static message).
## Templates directory
`spine-mockup-a-agenda.mustache``templates/index.mustache`
`spine-mockup-b-journal.mustache` stays in root as a reference/mockup
for the future detail view.
## Files changed
| File | Change |
|---|---|
| `templates/index.mustache` | New — moved from root mockup |
| `spine.el` | Add `spine-index-model`, update handler, remove old `spine-render-index` |
| `spine-mockup-a-agenda.mustache` | Deleted (moved) |
## Out of scope
- Populating the `minibuffer` section (capture UI)
- Mockup B / journal detail view
- Cover image display
## Acceptance
- [ ] Index page renders via Mustache, not inline HTML
- [ ] Pico CSS + Tabler Icons CDN links present
- [ ] Groups: on deck, reading, read — correct order, zero-book groups omitted
- [ ] Format shown as Tabler icon glyph
- [ ] Status pills have distinct colors per state
- [ ] `?id=...` expands matching book detail
- [ ] Recommendation appears as "via {name}"
- [ ] ERT tests still pass (model layer unchanged)
@@ -0,0 +1,163 @@
# Spine — Pico CSS styling
Styles the index page using [Pico CSS v2](https://picocss.com/) via CDN,
restructuring HTML to use Pico-native semantic elements and adding a small
custom `<style>` block for the few things Pico doesn't cover.
## CSS strategy
- **Pico CSS v2** loaded from CDN:
`https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css`
- No build step. No local CSS file. No Sass/PostCSS.
- Light/dark theme auto-switches via `prefers-color-scheme` (Pico default).
- Custom styles in a single inline `<style>` block inside `<head>`.
- **No Tabler Icons.** Format glyphs use emoji instead.
## HTML restructure
Pico styles semantic elements. The current `<div>`-heavy output is rewritten
to use Pico-native elements. Only `spine-` prefixed classes remain for
layout pieces Pico doesn't provide.
| Current element | New element | Purpose |
|---|---|---|
| `<div class="frame">` | `<article>` | Card container (Pico styles borders/padding) |
| `<div class="titlebar">` | `<header>` inside `<article>` | Title bar with book count |
| `<div class="group">WANT (3)</div>` | `<hgroup><h2>WANT</h2><small>3</small></hgroup>` | Group header with count |
| `<div class="row">` | `<div class="spine-row">` | Book row (flex, Pico has no horizontal list) |
| `<div class="detail">` | `<blockquote class="spine-detail">` | Expanded notes section |
| `<span class="pill want">` | `<mark class="want">want</mark>` | Status badge (colored per status) |
| `<span class="glyph">` | `<span class="spine-glyph">` | Format emoji |
| `<span class="title">` | `<span class="spine-title">` | Book title + author |
| `<span class="muted">` | `<small>` | Author name (Pico styles `<small>` as muted) |
| `<span class="tags">` | `<small>` with tag text | Category tags |
| `<span class="trail">` | `<span class="spine-trail">` | Recommendation trail |
## Custom CSS
In an inline `<style>` block in `<head>`:
```css
.spine-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.spine-row:last-child { border-bottom: none; }
.spine-selected { background: var(--pico-secondary-background); }
.spine-glyph { width: 1.5rem; text-align: center; flex-shrink: 0; }
.spine-title { flex: 1; min-width: 0; }
.spine-trail { font-size: 0.85em; color: var(--pico-muted-color); }
.spine-detail {
margin: 0 0 0 2rem;
border-left: 2px solid var(--pico-primary);
}
.spine-detail p { margin-bottom: 0.3rem; }
.spine-detail footer { margin-top: 0.5rem; }
/* Status pill colors */
mark.want { background: var(--pico-mark-background); color: var(--pico-mark-color); }
mark.reading { background: #faeeda; color: #854f0b; }
mark.read { background: #e1f5ee; color: #085041; }
mark.abandoned { background: var(--pico-muted-color); color: var(--pico-background-color); }
/* Pico-specific overrides */
article { margin-bottom: 0; }
article header { padding-bottom: 0.5rem; margin-bottom: 0.5rem; }
```
## Format glyphs
Emoji per format, empty string if unset:
| Format | Glyph |
|---|---|
| `hardcover` | 📖 |
| `ebook` | 📱 |
| `audiobook` | 🎧 |
| `nil` / unset | (empty) |
## Full page shell
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spine</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>custom styles</style>
</head>
<body>
<main class="container">
<article>
<header>
<strong>spine</strong>
<small>N books · M reading</small>
</header>
…groups and rows…
</article>
</main>
</body>
</html>
```
## Example output (Use of Weapons, expanded)
```html
<hgroup><h2>READING</h2><small>1</small></hgroup>
<div class="spine-row spine-selected">
<span class="spine-glyph">🎧</span>
<span class="spine-title">Use of Weapons <small>Iain M. Banks</small></span>
<small>:scifi: :literary:</small>
<span class="spine-trail">Priya: If you liked Player of Games, this one will wreck you</span>
<mark class="reading">reading</mark>
<blockquote class="spine-detail">
<p><small>[2026-03-02]</small> The two-track structure is doing something I can't name yet.</p>
<p><small>[2026-03-07]</small> Zakalwe's competence reads as a wound.</p>
<p><small>[2026-03-09]</small> The chapter numbering. Oh.</p>
<footer>Format: audiobook · ISBN: 978-0316029193 · Added: [2026-02-20]</footer>
</blockquote>
</div>
```
## Empty state
```html
<article>
<header><strong>spine</strong></header>
<p>No books yet.</p>
</article>
```
## Files changed
- `spine.el``spine-render-index` and `spine-render-empty-state` rewritten
to emit Pico-structured HTML with inline CSS
No new files. No new dependencies beyond the CDN `<link>`.
## Out of scope
- JavaScript interactivity
- Cover image display
- Write endpoints
- Per-note format
- `?id=` selection without page reload (full page reload only)
## Acceptance
- [ ] Page renders with Pico CSS applied (fonts, spacing, colors match Pico defaults)
- [ ] Light/dark mode switches with OS preference
- [ ] Groups appear in order: WANT, READING, READ — zero-book groups omitted
- [ ] `?id=...` expands the matching book's detail with reading notes
- [ ] Status pills have distinct colors per state
- [ ] Format shown as emoji glyph
- [ ] Recommendation trail shown when `REC_BY` present
- [ ] Empty state shows "No books yet."
- [ ] No console 404s (Pico CDN resolves)
- [ ] No Tabler Icons reference in output
@@ -0,0 +1,218 @@
# Spine — data model and index page
Extends [`spine-spec.md`](../../spine-spec.md) with a concrete Org data structure
and defines the first real view: an index page that reads from `spine.org` and
displays books grouped by lifecycle status.
## Data model (`spine.org`)
Each book is a top-level headline. The TODO keyword is the lifecycle state.
Properties hold structured metadata. The headline body holds reading notes
as a plain Org list with inactive timestamps.
### TODO states
```org
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
```
### Properties
| Property | Required | Value |
|---|---|---|
| `:AUTHOR:` | yes | freeform string |
| `:ISBN:` | no | freeform string |
| `:COVER:` | no | path relative to `spine.org` |
| `:FORMAT:` | no | `hardcover` \| `ebook` \| `audiobook` |
| `:REC_BY:` | no | who recommended it |
| `:REC_NOTE:` | no | why it's on your radar |
| `:RATING:` | no | 15, set when moved to `READ` |
| `:ADDED:` | no | `[YYYY-MM-DD]` date added to spine |
| `:ID:` | yes | stable unique identifier (e.g. `8c1e-uow`) |
Missing or empty properties → `nil` at read time. The view decides how to
render nils.
### Tags
Org tags on the headline = categories (e.g. `:scifi:literary:`).
Tags filter and inherit natively in Org.
### Body: reading notes
A plain Org list in the headline body. Each item is prefixed with an inactive
timestamp `[YYYY-MM-DD]`. Appending a note = inserting one list item.
```org
- [2026-03-02] The two-track structure is doing something I can't name yet.
```
### LOGBOOK
State-change history is captured automatically by `org-log-into-drawer`.
This gives us the "when read" timeline for free — each state transition gets
a timestamped entry.
```org
:LOGBOOK:
- State "READING" from "WANT" [2026-02-20]
:END:
```
### Full example entry
```org
* READING Use of Weapons
:PROPERTIES:
:AUTHOR: Iain M. Banks
:ISBN: 978-0316029193
:COVER: covers/use-of-weapons.jpg
:FORMAT: audiobook
:REC_BY: Priya
:REC_NOTE: If you liked Player of Games, this one will wreck you
:RATING:
:ADDED: [2026-02-20]
:ID: 8c1e-uow
:END:
:LOGBOOK:
- State "READING" from "WANT" [2026-02-20]
:END:
- [2026-03-02] The two-track structure is doing something I can't name yet.
- [2026-03-07] Zakalwe's competence reads as a wound.
```
## Architecture
Model/view split. Data extraction from Org is isolated from HTML rendering.
```
spine.org ──→ [spine-books] ──→ book plists ──→ [spine-render-index] ──→ HTML
```
### Model layer: `spine-books`
Opens the Org file, walks top-level headlines with `org-element`, returns a
list of plists — one per book.
```elisp
(spine-books) list of plists
```
Per-book plist shape:
```elisp
(:id "8c1e-uow"
:title "Use of Weapons"
:status "READING"
:author "Iain M. Banks"
:isbn "978-0316029193"
:cover "covers/use-of-weapons.jpg"
:format "audiobook"
:rec_by "Priya"
:rec_note "If you liked Player of Games…"
:rating nil
:added "[2026-02-20]"
:tags ("scifi" "literary")
:notes (("[2026-03-02]" "The two-track structure…")
("[2026-03-07]" "Zakalwe's competence…")))
```
**Org file not found or invalid:** `spine-books` returns `nil` and messages a
warning. The `condition-case` around the parse catches malformed Org.
**Reading strategy:** visit `spine.org` in a temp buffer, parse with
`org-element-parse-buffer`, walk top-level headlines. Full scan — adequate
for ~tens of books. No `org-ql` yet; add it later when live filtering is needed.
### View layer: `spine-render-index`
Takes the book list and an optional selected ID, returns an HTML string.
No CSS, no JavaScript — structural HTML with CSS class names matching
mockup A so styles drop in later.
**Grouping:** books partitioned by status in this order:
`WANT``READING``READ``ABANDONED`.
Each group gets a header with count.
**Collapsed row per book:**
- Format glyph: `[H]` hardcover, `[E]` ebook, `[A]` audiobook (nothing if unset)
- Title + author
- Tags
- Recommendation trail (`REC_BY: REC_NOTE`) when present
- Status pill
**Expanded detail** (triggered by `?id=` query param):
- Reading log: each note as date + text
- Properties: format, ISBN, rating, added date
**Section structure:**
```html
<div class="frame">
<div class="titlebar">Spine</div>
<div class="group">WANT (3)</div>
<div class="row"></div>
<div class="row"></div>
<div class="row"></div>
<div class="group">READING (1)</div>
<div class="row selected">
<div class="detail">
<!-- expanded notes for selected book -->
</div>
</div>
</div>
```
**Empty state:** when `spine-books` returns `nil` → page with "No books yet."
message.
### Handler
```elisp
(defservlet index text/html ()
(let ((books (spine-books)))
(if books
(insert (spine-render-index books
(ht-get (ht-from-query-string (httpd-query-string)) "id")))
(insert (spine-render-empty-state)))))
```
- `/` → index, all books collapsed
- `/?id=8c1e-uow` → index with that book's detail expanded
- Bogus `?id=` → no expansion, normal collapsed view
### Configuration
```elisp
(defvar spine-org-file "~/spine/books.org"
"Path to the spine.org data file.")
```
Overridable before load.
## Error handling
| Case | Behavior |
|---|---|
| `spine.org` missing | `spine-books` returns `nil` → empty-state page |
| `spine.org` invalid Org | `condition-case` around parse, message error, return `nil` |
| Bogus `?id=` param | no expansion, normal collapsed view |
| Package/bootstrap failures | surfaced at startup (existing scaffold behavior) |
## Out of scope
- Write endpoints (add book, add note, set status/rating/format)
- CSS styling
- JavaScript interactivity
- Cover image display
- `org-ql` integration
- Search or filtering beyond status grouping
## Acceptance
- [ ] `spine-books` parses a sample `spine.org` and returns correct plists
- [ ] Missing properties → `nil`, not errors
- [ ] `/?id=...` expands the matching book's detail
- [ ] Empty `spine.org` (no headlines) → empty list, not error
- [ ] Missing `spine.org` → empty-state page, no crash
- [ ] Groups appear in order: WANT, READING, READ, ABANDONED
- [ ] Groups with zero books are omitted from output
@@ -0,0 +1,169 @@
# Spine — Add Book Form
Adds a form to create new books in `spine.org`, plus the write function
to persist them.
## Architecture
```
"Add book" link (index page)
GET /add
templates/add.mustache ←── [spine-index-model] for datalist values
POST /add (form submission)
[spine-add-book] ──→ writes to spine.org
redirect to /index
```
## Template: `templates/add.mustache`
A new Mustache template with the Pico CSS + Tabler Icons shell (matching the
index page) and a `<form method="post" action="/add">`.
**View model:**
```json
{
"app_title": "spine",
"existing_authors": ["Ursula K. Le Guin", "Frank Herbert", ...],
"existing_categories": ["scifi", "literary", ...]
}
```
`existing_authors` and `existing_categories` are extracted from the current
`spine-books` results. Empty lists are fine — the `<datalist>` just has no
options.
**Form fields:**
| Field | Input type | Required | Notes |
|---|---|---|---|
| date_added | `<input type="date">` | no | defaults to today |
| title | `<input type="text">` | **yes** | |
| author | `<input type="text" list="authors">` | no | datalist from existing |
| category | `<input type="text" list="categories">` | no | datalist from existing tags |
| format | `<select>` | no | hardcover / ebook / audiobook / — |
| isbn | `<input type="text">` | no | |
| cover | `<input type="text">` | no | path relative to spine.org |
| rec_by | `<input type="text">` | no | recommender name |
| rec_note | `<input type="text">` | no | why it's on your radar |
| initial_note | `<textarea>` | no | becomes first reading note |
**Datalists** for author and category:
```html
<input type="text" name="author" list="authors-list" />
<datalist id="authors-list">
{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}
</datalist>
```
"Add book" link on the index page (in `templates/index.mustache`) — placed in
the `<header>` alongside the title:
```html
<a role="button" href="/add">Add book</a>
```
## Write function: `spine-add-book`
```elisp
(spine-add-book &key title author category format isbn cover
rec_by rec_note date_added initial_note)
t on success, signals error on failure
```
**Behavior:**
1. Visits `spine-org-file` in a temporary buffer
2. Inserts a new top-level headline with TODO keyword `WANT`
3. Sets properties: `:AUTHOR:`, `:FORMAT:`, `:ISBN:`, `:COVER:`, `:REC_BY:`, `:REC_NOTE:`, `:ADDED:`, `:ID:`
4. Sets tags if category provided
5. Appends initial note as `- [DATE] note text` if provided
6. Saves the buffer
7. Returns `t`
**`:ID:` generation:** short random alphanumeric, e.g. `(format "%04x-%04x" (random 65535) (random 65535))`.
**`:ADDED:` date:** if `date_added` is provided, format as `[YYYY-MM-DD]`. If nil, omit the property.
**Properties with nil/empty values are omitted** — only set properties that have non-empty values. This keeps the Org file clean.
## Handler: `/add`
```elisp
(defservlet add text/html (path query request)
(let ((method (car (car request))))
(if (equal method "POST")
;; Process form submission
(progn
(spine-add-book
:title (cdr (assoc "title" query))
:author (cdr (assoc "author" query))
:category (cdr (assoc "category" query))
:format (cdr (assoc "format" query))
:isbn (cdr (assoc "isbn" query))
:cover (cdr (assoc "cover" query))
:rec_by (cdr (assoc "rec_by" query))
:rec_note (cdr (assoc "rec_note" query))
:date_added (cdr (assoc "date_added" query))
:initial_note (cdr (assoc "initial_note" query)))
(httpd-redirect t "/index" 303))
;; GET: show the form
(let ((model (spine-add-form-model (spine-books))))
(insert (spine-render "add.mustache" model))))))
```
`spine-add-form-model` builds the view model from `spine-books` — extracts
unique authors and tags into flat lists.
**Form GET access:** `http://localhost:8080/add`
**Empty-books case:** if `spine-books` returns nil, the datalists are empty —
the form still works, you just get no suggestions.
## Error handling
| Case | Behavior |
|---|---|
| Title missing | redirect to `/add` with error message (query param) |
| Write fails | `spine-add-book` signals error, handler catches and shows error page |
| `spine.org` missing | `spine-add-book` creates a new file with the `#+TODO` header and first entry |
## Files changed
| File | Change |
|---|---|
| `templates/add.mustache` | New — add-book form template |
| `templates/index.mustache` | Modified — add "Add book" link in header |
| `spine.el` | New functions: `spine-add-book`, `spine-add-form-model`. New handler: `/add`. |
## Out of scope
- Validation beyond title required
- Edit existing book
- Delete book
- Cover image upload (text path only)
- Multi-category input (single category string → single tag for now)
- Error display on the form page (redirect on error, no inline messages)
## Styling
Uses Pico CSS form styles (`<input>`, `<select>`, `<textarea>`) which are
styled automatically by the Pico framework. No additional custom CSS needed
for the form — Pico handles layout, spacing, and theme.
## Acceptance
- [ ] "Add book" link visible on index page
- [ ] `GET /add` shows form with Pico styling
- [ ] Author and category fields have datalist suggestions from existing data
- [ ] Submitting the form creates a new `* WANT Title` headline in `spine.org`
- [ ] Properties are set correctly (title, author, format, ISBN, etc.)
- [ ] Initial note appears as `- [YYYY-MM-DD] text` in the body
- [ ] Empty optional fields are omitted (no empty properties)
- [ ] After submission, redirect to `/index` and new book is visible
- [ ] ERT tests still pass (model layer unchanged)
@@ -0,0 +1,97 @@
# Concise Default View — design spec
## Problem
The index page shows every book grouped by status. As the library grows, this becomes
too much information at a glance. The user needs a focused default showing only what's
active right now, with easy paths to browse the rest.
## Design
The index view takes an optional `filter` query parameter. With no filter, it shows a
concise default: only books in-flight (READING) and the 5 most recently added WANT
books. All other books are accessible via filter links. With a filter (`all`, `read`,
`want`), it shows the full matching subset.
## Behaviour
### Default / no filter (concise view)
- **READING group**: all READING books (unchanged from current).
- **WANT group**: sorted by `:ADDED:` date descending, limited to 5.
- **READ / ABANDONED**: replaced by summary links — e.g. "Read · 3" as a clickable
link to `/index?filter=read`.
- **Filter nav bar** shown at the top: links to `All books (N)`, `Want list`,
`Read (N)`.
### `?filter=all`
Full index, all groups, no truncation — identical to today's default.
### `?filter=read`
Only the READ group. Single group in the listing.
### `?filter=want`
Only the WANT group. Single group in the listing.
### Error handling
- An unrecognised/empty filter value is treated as `nil` (concise default).
- A WANT book with no `:ADDED:` property sorts before dated entries (treated as
oldest).
- Fewer than 5 WANT books → all are shown, no padding or placeholder rows.
## Implementation
### Model: `spine-index-model`
Signature changes from:
```
spine-index-model (books &optional selected-id)
```
to:
```
spine-index-model (books &optional filter selected-id)
```
When `filter` is nil, after building the group alist:
- Sort the `:want` group entries by `added` descending (ISO date string; nils sort
first = oldest).
- Truncate to 5 entries.
- Replace `:read` and `:abandoned` groups with a `:summary-groups` list. Each
entry is a plist matching the existing model convention:
```
(:label "read" :count 3 :href "/index?filter=read")
```
The template renders these as clickable `.row` links.
When `filter` is non-nil, `:summary-groups` is absent.
When `filter` is non-nil, return only the matching group (or all groups for `"all"`).
### Handler: `defservlet index`
Read the `filter` key from the request query alist. Pass it to `spine-index-model`.
Pass `current_filter` in the template context for conditional rendering.
### Template: `index.mustache`
- **Filter bar** (above groups): shown when `current_filter` is nil (links to expanded
views) or truthy (link back to concise view).
- **Summary groups**: new `{{#summary_groups}}` block renders a simple `.row` link per
suppressed group (READ, ABANDONED).
- Existing group rendering unchanged — still handles full listing when `filter=all`
or single-group views.
### Sorting
Within the WANT group for the concise view: sort by `added` field descending. The
`added` field is already part of each book's model entry. Nil dates sort before dated
entries (oldest).
## Out of scope
- Pagination for large WANT lists (5 is a design choice; if the user wants more, the
"All books" link is there).
- Remembering the user's last selected filter across requests (stateless is simpler).
- Keyboard-driven filter switching (plain links for now).
@@ -0,0 +1,127 @@
# Edit Book Actions — design spec
## Problem
The index page shows books grouped by status but provides no way to change a book's
status, add reading notes, or record ratings. Users must edit the Org file manually.
We need lightweight, context-sensitive edit actions accessible from the agenda view.
## Interaction model
Click a book row on the index page → the footer (minibuffer) shows action links
tailored to that book's current status. Clicking an action either performs it
immediately (status transitions) or shows a lightweight prompt page (add note,
mark read with date).
No JavaScript. All actions are plain `<a href>` links or simple HTML forms.
## Actions per status
### WANT
- **Mark reading** — immediate: sets TODO to READING
- **Add note** — prompt page: date + textarea, appends a note
### READING
- **Mark read** — prompt page: date picker (default today)
Rating is deferred (see out of scope).
- **Abandon** — immediate: sets TODO to ABANDONED
- **Add note** — prompt page: date + textarea
### READ
- **Read again** — immediate: sets TODO to READING
- **Add note** — prompt page: date + textarea
### ABANDONED
- **Try again** — immediate: sets TODO to READING
## Implementation
### New Org write functions (`spine.el`)
**`spine-set-status (id status &optional date)`**
Finds the book by `:ID:` property, changes its TODO keyword to `status`, and records
the state transition in the LOGBOOK drawer (using `org-todo`). If `date` is provided
(for READ status), sets a `:READ_DATE:` property.
**`spine-add-note (id text &optional date)`**
Finds the book by `:ID:`, appends `- [YYYY-MM-DD] text` to the headline body. Date
defaults to today if not provided. Follows the same pattern as `spine-add-book`:
`find-file-noselect`, modify, save, `kill-buffer`.
**`spine-set-rating (id rating)`**
Sets the `:RATING:` property on the book.
### Handler: `httpd/edit`
Single handler dispatching on `action` query param:
- `action=status` — calls `spine-set-status` with `id` and `value` (the target TODO
state). Redirects to `/index?id=<id>`.
- `action=note` — GET shows a prompt page with date + textarea for the book. POST
processes: calls `spine-add-note` with `id`, `text`, and optional `date`. Redirects
to `/index?id=<id>`.
- `action=rating` — calls `spine-set-rating` with `id` and `value`. Redirects to
`/index?id=<id>`.
- Unknown action or missing `id` — redirect to `/index` silently.
The prompt page for notes is a minimal HTML page (no Mustache template needed — small
enough to inline): shows the book title as context, a date input (default today), a
textarea, and a submit button. POST data includes `id`, `action`, `date`, `text`.
### Model: `spine-index-model` minibuffer extension
When a book is selected (`selected-id` matches), the model gains a `minibuffer` field:
```elisp
("minibuffer" .
(ht ("actions"
(list
(ht ("label" "Mark reading") ("href" "/edit?id=X&action=status&value=READING"))
(ht ("label" "Add note") ("href" "/edit?id=X&action=note"))))))
```
Actions are computed from the selected book's `:status` property. The exact set
follows the table in "Actions per status" above.
### Template: `index.mustache` minibuffer section
The existing minibuffer footer placeholder is replaced with a dynamic version:
```mustache
{{#minibuffer}}
<footer class="minibuffer">
<span class="muted">M-x</span>
<span style="display:flex;gap:.5rem;flex-wrap:wrap">
{{#actions}}
<a href="{{href}}" class="pill {{command}}">{{label}}</a>
{{/actions}}
</span>
</footer>
{{/minibuffer}}
```
Styled as pill links consistent with the existing pill classes. `M-x` prefix keeps
the Emacs feel.
### Testing
New test file `test/spine-edit-test.el`:
- `spine-set-status` changes TODO state correctly for each transition
- `spine-set-status` records LOGBOOK entry
- `spine-add-note` appends a note with the correct format
- `spine-add-note` defaults to today when no date provided
- `spine-set-rating` sets the rating property
- Model includes `minibuffer` field when book is selected
- Model has correct action set per status
## Out of scope
- Editing book properties (author, format, ISBN) — add-book covers creation; editing
those can come later if needed
- Deleting books — add-book is additive; no delete flow yet
- Rating prompt on the "Mark read" page — deferred; rating can be set separately
- Inline editing in the detail section — the minibuffer actions are the primary
surface for now
@@ -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.
@@ -0,0 +1,49 @@
# Parameterize spine.org Path via Environment Variable
## Problem
The org data file path (`spine-org-file`) is hardcoded to `~/spine/books.org` in
`spine.el`. Users who want to point the app at a different org file must manually
construct the full `emacs --eval` command line instead of using the `scripts/run`
convenience script.
## Design
Add `SPINE_ORG` environment variable support to `scripts/run`. When set, the
script passes the path to Emacs via `--eval` before loading `spine.el`, matching
the existing pattern for `spine-host` and `spine-port`.
### Interface
```bash
# Default (same as today): ~/spine/books.org
./scripts/run
# Custom path, default port 8080
SPINE_ORG=~/my-books.org ./scripts/run
# Custom path with host and port
SPINE_ORG=$(pwd)/sample-books.org ./scripts/run localhost:9090
```
### Affected files
- **`scripts/run`** — add SPINE_ORG env var check before launching Emacs
- **`spine.el`** — no changes needed (already uses `spine-org-file` defvar)
- **Tests** — no changes needed (already set their own `spine-org-file`)
### Implementation
In `scripts/run`, after the HOST/PORT argument parsing block (line 18), add:
```bash
if [ -n "${SPINE_ORG:-}" ]; then
EMACS_ARGS+=(--eval "(setq spine-org-file \"$SPINE_ORG\")")
fi
```
### Edge cases
- `SPINE_ORG` unset or empty → ignored, default in `spine.el` applies
- Path with spaces → quoted inside the Emacs string, handled correctly
- Relative vs absolute paths → user's responsibility; Emacs resolves relative to default-directory
+95
View File
@@ -0,0 +1,95 @@
#+TITLE: Spine
* Fiction
** WANT The Left Hand of Darkness
:PROPERTIES:
:AUTHOR: Ursula K. Le Guin
:ISBN: 978-0441478125
:COVER: covers/left-hand.jpg
:FORMAT: ebook
:REC_BY: Alex
:REC_NOTE: Le Guin at her most human
:RATING:
:ADDED: [2026-04-01]
:LAST_MODIFIED: [2026-04-01]
:ID: 9a2b-lhd
:END:
** WANT Dune
:PROPERTIES:
:AUTHOR: Frank Herbert
:ISBN: 978-0441172719
:COVER: covers/dune.jpg
:FORMAT: hardcover
:REC_BY: Everyone
:REC_NOTE:
:RATING:
:ADDED: [2026-05-10]
:LAST_MODIFIED: [2026-05-10]
:ID: 7f3c-dun
:END:
** WANT Piranesi :fiction:
:PROPERTIES:
:AUTHOR: Susanna Clarke
:ISBN:
:COVER:
:FORMAT:
:REC_BY:
:REC_NOTE:
:RATING:
:ADDED: [2026-06-01]
:LAST_MODIFIED: [2026-06-01]
:ID: 5e8d-pir
:END:
** READING Use of Weapons :scifi:literary:
:PROPERTIES:
:AUTHOR: Iain M. Banks
:ISBN: 978-0316029193
:COVER: covers/use-of-weapons.jpg
:FORMAT: audiobook
:REC_BY: Priya
:REC_NOTE: If you liked Player of Games, this one will wreck you
:RATING:
:ADDED: [2026-02-20]
:LAST_MODIFIED: [2026-03-09]
:ID: 8c1e-uow
:END:
::LOGBOOK:
- State "READING" from "WANT" [2026-03-01]
::END:
- [2026-03-02] The two-track structure is doing something I can't name yet.
- [2026-03-07] Zakalwe's competence reads as a wound.
- [2026-03-09] The chapter numbering. Oh.
* Science Fiction
** READ Ancillary Justice :scifi:
:PROPERTIES:
:AUTHOR: Ann Leckie
:ISBN: 978-0316246620
:COVER: covers/ancillary.jpg
:FORMAT: ebook
:REC_BY:
:REC_NOTE:
:RATING: 5
:ADDED: [2025-11-15]
:LAST_MODIFIED: [2025-11-15]
:ID: 3a1b-aj
:END:
::LOGBOOK:
- State "READ" from "READING" [2026-01-10]
- State "READING" from "WANT" [2025-11-20]
::END:
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
- [2025-12-05] Justice of Toren's multi-body perspective is unsettling.
- [2026-01-08] The tea set. Perfect ending.
* Non-Fiction
** The Checklist Manifesto
:PROPERTIES:
:AUTHOR: Atul Gawande
:ID: b2c1-tcm
:ADDED: [2026-06-15]
:LAST_MODIFIED: [2026-06-15]
:END:
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Stop spine if running; silently succeed if not
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null || true
# Start spine with any passed arguments
exec "$SCRIPT_DIR/run" "$@"
+5 -6
View File
@@ -15,14 +15,13 @@ if [ $# -ge 1 ] && [ -n "$1" ]; then
else else
PORT="$ARG" PORT="$ARG"
fi fi
fi
# Stop any existing spine daemon
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null || true
# Build emacs args
EMACS_ARGS=(emacs --quick --daemon=spine) EMACS_ARGS=(emacs --quick --daemon=spine)
# If SPINE_ORG env var is set, override the org file path
if [ -n "${SPINE_ORG:-}" ]; then
EMACS_ARGS+=(--eval "(setq spine-org-file \"$SPINE_ORG\")")
fi
if [ -n "$HOST" ]; then if [ -n "$HOST" ]; then
EMACS_ARGS+=(--eval "(setq spine-host \"$HOST\")") EMACS_ARGS+=(--eval "(setq spine-host \"$HOST\")")
fi fi
+124
View File
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Spine — Direction A (agenda)</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
:root {
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
--bg-warning: #faeeda; --text-warning: #854f0b;
--bg-success: #e1f5ee; --text-success: #085041;
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
--radius-md: 8px; --radius-lg: 12px;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
--bg-warning: #3a2c12; --text-warning: #fac775;
--bg-success: #103a30; --text-success: #9fe1cb;
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
font-family: var(--sans); padding: 28px 16px; }
.wrap { max-width: 720px; margin: 0 auto; }
.frame { font-family: var(--mono); border: 0.5px solid var(--border-secondary);
border-radius: var(--radius-lg); overflow: hidden; background: var(--bg-primary); }
.titlebar { display:flex; align-items:center; justify-content:space-between;
padding:10px 14px; border-bottom:0.5px solid var(--border-tertiary);
background: var(--bg-secondary); font-size:13px; }
.group { padding:10px 14px 4px; font-size:11px; letter-spacing:0.04em; color: var(--text-tertiary); }
.row { display:flex; align-items:center; gap:10px; padding:5px 14px; font-size:13px; }
.glyph { font-size:15px; color:var(--text-tertiary); width:16px; }
.pill { padding:1px 7px; border-radius:var(--radius-md); font-size:11px; }
.pill.want { background:var(--bg-info); color:var(--text-info); }
.pill.reading { background:var(--bg-warning); color:var(--text-warning); }
.pill.read { background:var(--bg-success); color:var(--text-success); }
.title { flex:1; min-width:0; }
.muted { color:var(--text-tertiary); }
.tags { color:var(--text-info); font-size:12px; }
.trail { color:var(--text-secondary); font-size:12px; }
.selected { background:var(--bg-secondary); border-left:2px solid var(--border-info); }
.detail { padding:8px 14px 12px 32px; background:var(--bg-secondary);
border-left:2px solid var(--border-info); font-size:12.5px; }
.logline { display:flex; gap:8px; margin-bottom:5px; }
.logdate { color:var(--text-tertiary); white-space:nowrap; }
.minibuffer { border-top:0.5px solid var(--border-tertiary); padding:9px 14px;
background:var(--bg-secondary); font-size:13px; display:flex; gap:8px; align-items:center; }
.caret { opacity:0.5; }
</style>
</head>
<body>
<div class="wrap">
<div class="frame">
<div class="titlebar">
<span style="font-weight:500;">spine · agenda</span>
<span class="muted">6 books · 1 reading</span>
</div>
<div class="group">on deck · 3</div>
<div class="row">
<i class="ti ti-book glyph" aria-hidden="true"></i>
<span class="pill want">want</span>
<span class="title">Tomorrow, and Tomorrow, and Tomorrow <span class="muted">· Zevin</span></span>
<span class="trail">via Priya</span>
</div>
<div class="row">
<i class="ti ti-headphones glyph" aria-hidden="true"></i>
<span class="pill want">want</span>
<span class="title">Children of Time <span class="muted">· Tchaikovsky</span></span>
<span class="tags">:scifi:</span>
</div>
<div class="row">
<i class="ti ti-device-tablet glyph" aria-hidden="true"></i>
<span class="pill want">want</span>
<span class="title">The Spear Cuts Through Water <span class="muted">· Jimenez</span></span>
<span class="trail">via book club</span>
</div>
<div class="group">reading · 1</div>
<div class="row selected">
<i class="ti ti-book glyph" style="color:var(--text-secondary);" aria-hidden="true"></i>
<span class="pill reading">reading</span>
<span class="title" style="font-weight:500;">Use of Weapons <span class="muted" style="font-weight:400;">· Banks</span></span>
<span class="tags">:scifi:literary:</span>
</div>
<div class="detail">
<div class="muted" style="margin-bottom:8px;">started [2026-02-20] · hardcover · 62% · rating —</div>
<div class="logline"><span class="logdate">[03-02]</span><span>The two-track structure is doing something I can't name yet. Suspicious of the chair.</span></div>
<div class="logline"><span class="logdate">[03-07]</span><span>Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.</span></div>
<div class="logline" style="margin-bottom:0;"><span class="logdate">[03-09]</span><span>The chapter numbering. Oh.</span></div>
</div>
<div class="group">read · 2</div>
<div class="row">
<i class="ti ti-headphones glyph" aria-hidden="true"></i>
<span class="pill read">read</span>
<span class="title">A Memory Called Empire <span class="muted">· Martine</span></span>
<span class="trail">★★★★★</span>
</div>
<div class="row" style="padding-bottom:8px;">
<i class="ti ti-book glyph" aria-hidden="true"></i>
<span class="pill read">read</span>
<span class="title">Piranesi <span class="muted">· Clarke</span></span>
<span class="trail">★★★★☆</span>
</div>
<div class="minibuffer">
<span class="muted">M-x</span>
<span>spine-note</span>
<span style="border-left:2px solid var(--text-info); padding-left:6px;">Banks lets the structure detonate the whole book<span class="caret"></span></span>
</div>
</div>
</div>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Spine — Direction B (journal)</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
:root {
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
--bg-warning: #faeeda; --text-warning: #854f0b;
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
--radius-md: 8px; --radius-lg: 12px;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
--bg-warning: #3a2c12; --text-warning: #fac775;
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
font-family: var(--sans); padding: 28px 16px; }
.wrap { max-width: 560px; margin: 0 auto; }
.surface { background: var(--bg-secondary); border-radius: var(--radius-lg); padding: 1.25rem; }
.card { background: var(--bg-primary); border:0.5px solid var(--border-tertiary);
border-radius: var(--radius-lg); padding: 1.1rem 1.25rem; }
.cover { width:44px; height:60px; border-radius:var(--radius-md); background:var(--bg-info);
display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.pill { background:var(--bg-warning); color:var(--text-warning); padding:2px 9px;
border-radius:var(--radius-md); font-size:12px; }
.seg { flex:1; text-align:center; padding:7px 0; font-size:13px; border:0.5px solid var(--border-tertiary);
border-radius:var(--radius-md); color:var(--text-secondary); }
.seg.active { border:2px solid var(--border-info); color:var(--text-info); background:var(--bg-info); }
.seclabel { font-size:12px; letter-spacing:0.03em; color:var(--text-tertiary); margin-bottom:10px; }
.thread { border-left:2px solid var(--border-tertiary); padding-left:14px; margin-bottom:14px; }
.entry { margin-bottom:12px; }
.entry:last-child { margin-bottom:0; }
.edate { font-size:12px; color:var(--text-tertiary); margin-bottom:2px; }
.etext { font-size:14px; line-height:1.5; }
input, button { font-family: var(--sans); font-size:14px; }
input { height:36px; padding:0 10px; border:0.5px solid var(--border-secondary);
border-radius:var(--radius-md); background:var(--bg-primary); color:var(--text-primary); }
input::placeholder { color:var(--text-tertiary); }
button { height:36px; padding:0 12px; border:0.5px solid var(--border-secondary);
border-radius:var(--radius-md); background:transparent; color:var(--text-primary); cursor:pointer; }
button:hover { background:var(--bg-secondary); }
.avatar { width:30px; height:30px; border-radius:50%; background:var(--bg-info);
display:flex; align-items:center; justify-content:center; font-size:12px;
color:var(--text-info); flex-shrink:0; }
</style>
</head>
<body>
<div class="wrap">
<div class="surface">
<div class="card">
<div style="display:flex; gap:14px; align-items:flex-start; margin-bottom:16px;">
<div class="cover"><i class="ti ti-book" style="font-size:22px; color:var(--text-info);" aria-hidden="true"></i></div>
<div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:16px;">Use of Weapons</div>
<div style="font-size:14px; color:var(--text-secondary); margin-bottom:6px;">Iain M. Banks</div>
<span class="pill">Reading</span>
<span style="color:var(--text-tertiary); font-size:13px; margin-left:8px;">started 20 Feb · 62%</span>
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:18px;">
<div class="seg"><i class="ti ti-book" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>Hardcover</div>
<div class="seg"><i class="ti ti-device-tablet" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>eBook</div>
<div class="seg active"><i class="ti ti-headphones" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>Audiobook</div>
</div>
<div class="seclabel">Reading log</div>
<div class="thread">
<div class="entry">
<div class="edate">2 Mar</div>
<div class="etext">The two-track structure is doing something I can't name yet. Suspicious of the chair.</div>
</div>
<div class="entry">
<div class="edate">7 Mar</div>
<div class="etext">Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.</div>
</div>
<div class="entry">
<div class="edate">9 Mar</div>
<div class="etext">The chapter numbering. Oh.</div>
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:20px;">
<input type="text" placeholder="Add a note at 62%…" style="flex:1;" />
<button><i class="ti ti-plus" style="font-size:15px; vertical-align:-2px; margin-right:4px;" aria-hidden="true"></i>Log note</button>
</div>
<div style="border-top:0.5px solid var(--border-tertiary); padding-top:14px;">
<div class="seclabel">On your radar</div>
<div style="display:flex; align-items:flex-start; gap:10px; margin-bottom:14px;">
<div class="avatar">PK</div>
<div style="font-size:14px; line-height:1.5;"><span style="font-weight:500;">Priya</span> <span style="color:var(--text-tertiary);">· 12 Jan</span><br>"If you liked Player of Games, this one will wreck you in a better way."</div>
</div>
<div style="display:flex; gap:8px;">
<input type="text" placeholder="Recommended by…" style="width:34%;" />
<input type="text" placeholder="Why it's on your radar…" style="flex:1;" />
<button aria-label="Save recommendation"><i class="ti ti-check" style="font-size:16px;" aria-hidden="true"></i></button>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
<!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 — Direction B (journal)</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>
{{!
View model for this template:
{
cover_icon: string, Tabler class for the cover placeholder, e.g. "ti-book"
title: string,
author: string,
status_class: string, "want" | "reading" | "read"
status_label: string,
meta: string, e.g. "started 20 Feb · 62%"
progress_label: string, e.g. "62%" (used in the note composer placeholder)
formats: [ { icon: string, label: string, active: boolean } ], the format selector segments
notes: [ { date: string, text: string } ], reading log
recommendation: { optional
initials: string,
by: string,
date: string,
note: string
}?
}
}}
<main class="container">
<article class="book">
<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>
<span class="pill {{status_class}}">{{status_label}}</span>
<span class="muted" style="color: var(--pico-muted-color); font-size: .85rem; margin-left: .5rem;">{{meta}}</span>
</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>
<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>
<div class="compose" role="group">
<input type="text" placeholder="Add a note at {{progress_label}}…" />
<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 />
<p class="seclabel">On your radar</p>
{{#recommendation}}
<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> <span class="muted" style="color: var(--pico-muted-color);">· {{date}}</span><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>
+155
View File
@@ -0,0 +1,155 @@
# Spine — design brief
A personal library and reading tracker. Tracks books I want to read, am reading,
have read, and what I think about them; captures notes repeatedly through a read;
and records who recommended a book and why.
This document is a starting point for a coding agent. Several decisions are
deliberately **left open** (see "Open decisions"). Do not invent answers for
those — scaffold around them or ask.
## Core features
- Track each book through a reading lifecycle: want → reading → read (or abandoned).
- Capture **multiple timestamped notes** across a single read, not one review at the end.
- Record **recommendations**: who recommended a book and a short "why it's on my radar"
note. This is context shown inline, not a search/analytics concern.
- Flag the **format** consumed: hardcover, ebook, or audiobook.
- Rate finished books.
- Browse "on deck" (want-to-read) and optionally filter by category.
## Architecture
- **Source of truth is a single Org file.** No database.
- **Emacs owns the file.** All reads and writes go through a running Emacs via
`emacsclient`, so `org-element` does the parsing and serialization canonically.
This avoids third-party Org parser fidelity problems on write.
- **No read/search index initially.** Queries are simple status + tag filters
answered live by `org-ql`. A derived SQLite index can be added later if the
recommendation data ever needs real querying; it is explicitly out of scope now.
- The **web layer is a thin client** over emacsclient. Its language and framework
are undecided (see Open decisions).
## Data model (Org)
Each book is a top-level headline whose TODO state is its lifecycle status.
```org
#+TITLE: Spine
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
#+STARTUP: logdrawer
* READING Use of Weapons
:PROPERTIES:
:AUTHOR: Iain M. Banks
:FORMAT: audiobook
:REC_BY: Priya
:REC_NOTE: If you liked Player of Games, this one will wreck you in a better way.
:RATING:
:ADDED: [2026-02-20]
:ID: 8c1e-uow
:END:
:LOGBOOK:
- State "READING" from "WANT" [2026-02-20]
:END:
- [2026-03-02] The two-track structure is doing something I can't name yet.
- [2026-03-07] Zakalwe's competence reads as a wound. Banks never lets a skill be free.
- [2026-03-09] The chapter numbering. Oh.
```
Field notes:
- **Status**: the TODO keyword. `WANT READING | READ ABANDONED`.
- **Status history**: comes free from the LOGBOOK drawer when
`org-log-into-drawer` is enabled. Distinct from reading notes.
- **Reading notes**: a plain Org list in the headline body, each item prefixed
with an inactive timestamp `[YYYY-MM-DD]`. Appending a note is inserting one
list item. Notes are not individually queried.
- **Format**: single `:FORMAT:` property — `hardcover` | `ebook` | `audiobook`.
(See Open decisions for the multi-format case.)
- **Recommendation**: `:REC_BY:` plus optional `:REC_NOTE:`. Surfaced inline as
context, never ranked or searched.
- **Category**: Org **tags** on the headline (`:scifi:literary:`), not a property —
tags filter natively and inherit.
- **Rating**: `:RATING:` 15, set when moved to READ.
## emacsclient integration
### Read: on-deck list
```elisp
(require 'org-ql)
(require 'json)
(defun spine-ondeck (&optional tag)
"JSON of WANT books, optionally filtered by TAG."
(json-serialize
(vconcat
(org-ql-select "~/spine/books.org"
(if tag `(and (todo "WANT") (tags ,tag)) '(todo "WANT"))
:action
(lambda ()
(list :title (org-get-heading t t t t)
:author (org-entry-get nil "AUTHOR")
:format (org-entry-get nil "FORMAT")
:rec_by (org-entry-get nil "REC_BY")
:tags (vconcat (org-get-tags))))))))
(defun spine-ondeck-file (path &optional tag)
"Write `spine-ondeck' output to PATH (avoids emacsclient quoting)."
(with-temp-file path (insert (spine-ondeck tag))))
```
Invoke: `emacsclient --eval '(spine-ondeck-file "/tmp/spine.json")'`, then read
the file from the web layer. (`emacsclient --eval` prints values with `prin1`, so
returning a JSON string directly comes back quoted/escaped — the temp-file hop
sidesteps that. A small `simple-httpd` endpoint is the alternative; undecided.)
### Write: to implement
- `spine-add-note` — append a `[today] text` list item under a book's headline.
- `spine-add-book` — insert a new WANT headline with initial properties.
- `spine-set-status` / `spine-set-rating` / `spine-set-format`.
These set state through `org-element` / standard Org commands, not text munging.
Not yet written.
## UI
Two directions are mocked as standalone HTML (`spine-mockup-a-agenda.html`,
`spine-mockup-b-journal.html`). They render the same Org record through different
front doors.
- **Direction A — agenda (TUI).** An org-agenda-style surface grouped by status.
Format shows as a leading glyph, the recommendation rides on the row as "why
it's here," the selected book expands inline to show its reading log, and
capture happens in a minibuffer rather than a form. This is the home surface.
- **Direction B — journal card.** A book detail card. Format is a segmented
control, the reading log is a thread you append to with an inline composer, and
the recommendation has an explicit "who / why" capture form. This is the
drill-in view.
**Recommended composition:** hybrid. Direction A as the home/browse surface,
Direction B as the detail view when a book is selected. Agenda answers "what now,"
card answers "where am I with this one." Not locked in — building one alone is
fine.
## Open decisions
- **Glue language + web framework — undecided.** Possibly elisp end-to-end
(Emacs serves the UI too), possibly an external thin client shelling out to
emacsclient. Leave this unbound; don't hardwire a stack.
- **Read transport:** temp-file hop vs `simple-httpd` endpoint in Emacs.
- **Per-note format:** book-level `:FORMAT:` is the default. If a book is regularly
consumed across media (hardcover at home, audio on a commute), format may need
to move to per-note. Single-value for now.
- **Hybrid vs single UI direction** — see UI section.
- **Search index:** deferred. Not now.
## Out of scope (for now)
- Recommender ranking / "hit rate" analytics.
- Full-text search across notes.
- Any external book-metadata API (covers, ISBN lookup) — placeholder cover blocks
in the mockups are decoration only.
+643 -5
View File
@@ -16,6 +16,7 @@
(require 'simple-httpd) (require 'simple-httpd)
(require 'mustache) (require 'mustache)
(require 'ht) (require 'ht)
(require 'cl-lib)
;; --- configuration ---------------------------------------------------- ;; --- configuration ----------------------------------------------------
@@ -26,6 +27,9 @@
nil means localhost only. Set to a string like \"0.0.0.0\" to listen on nil means localhost only. Set to a string like \"0.0.0.0\" to listen on
all interfaces.") all interfaces.")
(defvar spine-org-file "~/spine/books.org"
"Path to the spine.org data file.")
(defvar spine-template-dir (defvar spine-template-dir
(expand-file-name "templates/" (expand-file-name "templates/"
(file-name-directory load-file-name)) (file-name-directory load-file-name))
@@ -43,19 +47,653 @@ Returns the rendered string."
(buffer-string)) (buffer-string))
context)) context))
(defun spine--h (text)
"HTML-escape TEXT."
(when text
(let ((s text))
(setq s (replace-regexp-in-string "&" "&amp;" 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-move-book (id target-shelf)
"Move book with ID to TARGET-SHELF.
The book's level-2 headline is moved under the level-1 TARGET-SHELF headline.
Signals an error if the book or target shelf is not found."
(let ((org-file (expand-file-name spine-org-file))
(shelf-re (format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote target-shelf))))
(unless (file-exists-p org-file)
(error "spine-move-book: file not found: %s" org-file))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(let (book-begin book-end book-text)
(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)
(setq book-begin (org-element-property :begin hl))
(setq book-end (org-element-property :end hl))))))
nil 'headline)
(unless book-begin
(error "spine-move-book: no book found with ID %s" id))
(setq book-text (buffer-substring-no-properties book-begin book-end))
(delete-region book-begin book-end)
;; Re-find target shelf (positions shifted after deletion)
(goto-char (point-min))
(let ((shelf-found nil))
(while (and (not shelf-found)
(re-search-forward shelf-re nil t))
(when (= (org-current-level) 1)
(setq shelf-found t)))
(unless shelf-found
(error "spine-move-book: shelf \"%s\" not found" target-shelf))
(org-end-of-subtree)
(skip-chars-backward " \t\n")
(insert "\n")
(insert book-text)
(unless (bolp) (insert "\n"))
(save-buffer)
t))
(kill-buffer)))))
(defun spine-set-status (id new-status)
"Change the TODO keyword of book with ID to NEW-STATUS.
NEW-STATUS is one of \"want\", \"reading\", \"read\", or \"\" to remove.
Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file))
(new-kw (car (rassoc new-status
'(("WANT" . "want") ("READING" . "reading") ("READ" . "read"))))))
(unless (file-exists-p org-file)
(error "spine-set-status: 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)
(let* ((raw (org-get-heading t t t t))
(clean (if (string-match
"\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'"
raw)
(match-string 2 raw)
raw)))
(org-edit-headline
(if new-kw (format "%s %s" new-kw clean) clean)))))))
nil 'headline)
(unless (eq (point) (point-min))
(save-buffer)
t)
(when (eq (point) (point-min))
(error "spine-set-status: 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 --------------------------------------------------------- ;; --- handlers ---------------------------------------------------------
(defservlet hello text/html () (defservlet index text/html (path query request)
(insert (spine-render "hello.mustache" (let* ((books (spine-books))
(ht ("title" "Spine") (shelf-filter (cadr (assoc "shelf" query))))
("message" "Hello from Emacs."))))) (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: POST processes actions; GET shows prompt pages.
Query params:
id - book ID
action - note | rating | shelf | status
text - note text (for note action)
date - optional date string (for note action)
value - rating value / shelf name / status key (for rating/shelf/status)"
(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))
((equal action "shelf")
(spine-move-book id value))
((equal action "status")
(spine-set-status id value)))
(httpd-redirect proc (format "/book?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 ------------------------------------------------------------ ;; --- start ------------------------------------------------------------
(unless spine-skip-server-start
(setq httpd-host spine-host) (setq httpd-host spine-host)
(setq httpd-port spine-port) (setq httpd-port spine-port)
(httpd-start) (httpd-start)
(message "Spine listening on http://%s:%d" (message "Spine listening on http://%s:%d"
(or spine-host "localhost") spine-port) (or spine-host "localhost") spine-port))
(provide 'spine) (provide 'spine)
;;; spine.el ends here ;;; spine.el ends here
+80
View File
@@ -0,0 +1,80 @@
<!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 — add book</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
:root {
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
--bg-warning: #faeeda; --text-warning: #854f0b;
--bg-success: #e1f5ee; --text-success: #085041;
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
--radius-md: 8px; --radius-lg: 12px;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
--bg-warning: #3a2c12; --text-warning: #fac775;
--bg-success: #103a30; --text-success: #9fe1cb;
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
font-family: var(--sans); padding: 28px 16px; }
.wrap { max-width: 560px; margin: 0 auto; }
.frame { font-family: var(--mono); border: 0.5px solid var(--border-secondary);
border-radius: var(--radius-lg); overflow: hidden; background: var(--bg-primary); }
.titlebar { display:flex; align-items:center; justify-content:space-between;
padding:10px 14px; border-bottom:0.5px solid var(--border-tertiary);
background: var(--bg-secondary); font-size:13px; }
.form-body { padding:14px; font-size:13px; }
.form-body label { display:block; margin-bottom:6px; }
.form-body input, .form-body select, .form-body textarea {
font-family: var(--mono); font-size:13px; width:100%; padding:6px 8px;
border:0.5px solid var(--border-secondary); border-radius:var(--radius-md);
background:var(--bg-page); color:var(--text-primary); margin-bottom:12px;
}
.form-body button { font-family: var(--mono); font-size:13px; padding:6px 14px;
border:0.5px solid var(--border-info); border-radius:var(--radius-md);
background:var(--bg-info); color:var(--text-info); cursor:pointer; }
.form-body button:hover { background:var(--border-info); color:white; }
</style>
</head>
<body>
<div class="wrap">
<div class="frame">
<div class="titlebar">
<span style="font-weight:500;">spine · add book</span>
</div>
<div class="form-body">
<form method="post" action="/add">
<label>Date added <input type="date" name="date_added" /></label>
<label>Title * <input type="text" name="title" required autofocus /></label>
<label>Author <input type="text" name="author" /></label>
<label>Shelf * <select name="shelf" required>
<option value="">— select —</option>
{{#shelves}}<option value="{{.}}">{{.}}</option>{{/shelves}}
</select></label>
<label>Format <select name="format"><option value="">—</option><option value="hardcover">Hardcover</option><option value="ebook">eBook</option><option value="audiobook">Audiobook</option></select></label>
<label>ISBN <input type="text" name="isbn" /></label>
<label>Cover (path) <input type="text" name="cover" placeholder="covers/filename.jpg" /></label>
<label>Recommended by <input type="text" name="rec_by" /></label>
<label>Why it's on your radar <input type="text" name="rec_note" /></label>
<label>Initial note <textarea name="initial_note" rows="3" placeholder="First impressions&hellip;"></textarea></label>
<button type="submit">Add book</button>
</form>
</div>
</div>
</div>
</body>
</html>
+167
View File
@@ -0,0 +1,167 @@
<!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://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
:root {
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
--bg-warning: #faeeda; --text-warning: #854f0b;
--bg-success: #e1f5ee; --text-success: #085041;
--text-danger: #c62828;
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
--radius-md: 8px; --radius-lg: 12px;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
--bg-warning: #3a2c12; --text-warning: #fac775;
--bg-success: #103a30; --text-success: #9fe1cb;
--text-danger: #ef9a9a;
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
font-family: var(--sans); padding: 28px 16px; }
.wrap { max-width: 600px; margin: 0 auto; }
.surface { background: var(--bg-secondary); border-radius: var(--radius-lg); padding: 1.25rem; }
.card { background: var(--bg-primary); border: 0.5px solid var(--border-tertiary);
border-radius: var(--radius-lg); padding: 1.1rem 1.25rem; }
.cover { width: 48px; height: 66px; border-radius: var(--radius-md); background: var(--bg-info);
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.pill { padding: 2px 9px; border-radius: var(--radius-md); font-size: 12px; }
.pill.want { background: var(--bg-info); color: var(--text-info); }
.pill.reading { background: var(--bg-warning); color: var(--text-warning); }
.pill.read { background: var(--bg-success); color: var(--text-success); }
.tag { background: var(--bg-secondary); padding: 1px 7px; border-radius: var(--radius-md); margin-left: 4px; font-size: 12px; }
.tt { font-size: 12px; color: var(--text-tertiary); }
input, select, button { font-family: var(--sans); font-size: 13px; }
input, select { height: 34px; padding: 0 8px; border: 0.5px solid var(--border-secondary);
border-radius: var(--radius-md); background: var(--bg-primary); color: var(--text-primary); width: 100%; }
input::placeholder { color: var(--text-tertiary); }
button { height: auto; padding: 2px 4px; border: none; background: transparent; color: var(--text-secondary); cursor: pointer; }
</style>
</head>
<body>
<div class="wrap">
<div class="surface">
<div class="card">
<div style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-tertiary); margin-bottom: 14px;">
<a href="{{back_url}}" style="color: var(--text-tertiary); text-decoration: none; display: flex; align-items: center; gap: 8px;">
<i class="ti ti-arrow-left" style="font-size: 16px;" aria-hidden="true"></i>
<i class="ti ti-folder" style="font-size: 15px;" aria-hidden="true"></i>
<span>{{shelf_name}}</span>
</a>
</div>
<div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 18px;">
<div class="cover"><i class="ti {{cover_icon}}" style="font-size: 24px; color: var(--text-info);" aria-hidden="true"></i></div>
<div style="flex: 1; min-width: 0;">
<p style="font-weight: 500; font-size: 18px; margin: 0 0 2px;">{{title}}</p>
<p style="font-size: 14px; color: var(--text-secondary); margin: 0 0 8px;">{{author}}</p>
{{#status_class}}
<span class="pill {{status_class}}">{{status_label}}</span>
{{/status_class}}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 18px;">
<form method="post" action="/edit">
<input type="hidden" name="id" value="{{id}}">
<input type="hidden" name="action" value="shelf">
<label style="font-size: 13px; color: var(--text-secondary); display: block; margin-bottom: 4px;">
<i class="ti ti-folder" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Shelf
</label>
<select name="value" onchange="this.form.submit()">
{{#shelf_options}}
<option{{#selected}} selected{{/selected}}>{{name}}</option>
{{/shelf_options}}
</select>
</form>
<form method="post" action="/edit">
<input type="hidden" name="id" value="{{id}}">
<input type="hidden" name="action" value="status">
<label style="font-size: 13px; color: var(--text-secondary); display: block; margin-bottom: 4px;">
<i class="ti ti-bookmark" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Status
</label>
<select name="value" onchange="this.form.submit()">
{{#status_options}}
<option{{#selected}} selected{{/selected}}>{{name}}</option>
{{/status_options}}
</select>
</form>
</div>
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 16px;">
<table style="width: 100%; font-size: 13px; border-collapse: collapse;">
{{#format_label}}
<tr><td style="color: var(--text-secondary); padding: 4px 0; width: 38%;">Format</td>
<td style="text-align: right; padding: 4px 0;"><i class="ti {{format_icon}}" style="font-size: 15px; vertical-align: -2px; margin-right: 5px; color: var(--text-tertiary);" aria-hidden="true"></i>{{format_label}}</td></tr>
{{/format_label}}
{{#tags_display}}
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Tags</td>
<td style="text-align: right; padding: 4px 0;">{{{tags_display}}}</td></tr>
{{/tags_display}}
{{#added_display}}
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Added</td>
<td style="text-align: right; padding: 4px 0;">{{added_display}}</td></tr>
{{/added_display}}
{{#started_display}}
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Started</td>
<td style="text-align: right; padding: 4px 0;">{{started_display}}</td></tr>
{{/started_display}}
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Finished</td>
<td style="text-align: right; padding: 4px 0;{{^finished_display}} color: var(--text-tertiary);{{/finished_display}}">{{finished_display}}</td></tr>
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Rating</td>
<td style="text-align: right; padding: 4px 0;{{^has_rating}} color: var(--text-tertiary);{{/has_rating}}">{{rating_display}}</td></tr>
</table>
</div>
{{#notes}}
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 16px;">
<p style="font-size: 13px; color: var(--text-tertiary); margin: 0 0 10px;">Reading log</p>
{{#notes}}
<div style="display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px;">
<span style="font-size: 12px; color: var(--text-tertiary); white-space: nowrap; padding-top: 1px;">{{date}}</span>
<span style="flex: 1; font-size: 14px; line-height: 1.5;">{{text}}</span>
</div>
{{/notes}}
<form method="post" action="/edit" style="display: flex; gap: 8px; margin-top: 14px;">
<input type="hidden" name="id" value="{{id}}">
<input type="hidden" name="action" value="note">
<input type="text" name="text" placeholder="Add a note…" style="flex: 1;" required />
<button type="submit" style="width: auto; padding: 0 12px; border: 0.5px solid var(--border-secondary); border-radius: var(--radius-md);"><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
</form>
</div>
{{/notes}}
{{#recommendation}}
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 4px;">
<p style="font-size: 13px; color: var(--text-tertiary); margin: 0 0 10px;">On your radar</p>
<div style="display: flex; align-items: flex-start; gap: 10px;">
<div style="width: 30px; height: 30px; border-radius: 50%; background: var(--bg-info); display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--text-info); flex-shrink: 0;">{{initials}}</div>
<div style="font-size: 14px; line-height: 1.5;"><span style="font-weight: 500;">{{by}}</span><br />"{{note}}"</div>
</div>
</div>
{{/recommendation}}
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 12px; margin-top: 14px; display: flex; justify-content: space-between; align-items: center;">
<button style="color: var(--text-danger); font-size: 13px;"><i class="ti ti-trash" style="font-size: 14px; vertical-align: -2px; margin-right: 3px;" aria-hidden="true"></i>Remove</button>
<button style="color: var(--text-secondary); font-size: 13px;"><i class="ti ti-external-link" style="font-size: 14px; vertical-align: -2px; margin-right: 3px;" aria-hidden="true"></i>Open in Emacs</button>
</div>
</div>
</div>
</div>
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
<!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 — agenda</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
:root {
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
--bg-warning: #faeeda; --text-warning: #854f0b;
--bg-success: #e1f5ee; --text-success: #085041;
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
--radius-md: 8px; --radius-lg: 12px;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
--bg-warning: #3a2c12; --text-warning: #fac775;
--bg-success: #103a30; --text-success: #9fe1cb;
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
font-family: var(--sans); padding: 28px 16px; }
.wrap { max-width: 720px; margin: 0 auto; }
.frame { font-family: var(--mono); border: 0.5px solid var(--border-secondary);
border-radius: var(--radius-lg); overflow: hidden; background: var(--bg-primary); }
.titlebar { display:flex; align-items:center; justify-content:space-between;
padding:10px 14px; border-bottom:0.5px solid var(--border-tertiary);
background: var(--bg-secondary); font-size:13px; }
.group { padding:10px 14px 4px; font-size:11px; letter-spacing:0.04em; color: var(--text-tertiary); }
.row { display:flex; align-items:center; gap:10px; padding:5px 14px; font-size:13px; }
.glyph { font-size:15px; color:var(--text-tertiary); width:16px; }
.title { flex:1; min-width:0; }
.muted { color:var(--text-tertiary); }
.tags { color:var(--text-info); font-size:12px; }
.trail { color:var(--text-secondary); font-size:12px; }
.selected { background:var(--bg-secondary); border-left:2px solid var(--border-info); }
.detail { padding:8px 14px 12px 32px; background:var(--bg-secondary);
border-left:2px solid var(--border-info); font-size:12.5px; }
.logline { display:flex; gap:8px; margin-bottom:5px; }
.logdate { color:var(--text-tertiary); white-space:nowrap; }
.minibuffer { border-top:0.5px solid var(--border-tertiary); padding:9px 14px;
background:var(--bg-secondary); font-size:13px; display:flex; gap:8px; align-items:center; }
.caret { opacity:0.5; }
.filter-bar { padding:8px 14px; font-size:11px; border-bottom:0.5px solid var(--border-tertiary); }
.filter-bar a { color:var(--text-info); text-decoration:none; }
.filter-bar a:hover { text-decoration:underline; }
</style>
</head>
<body>
<div class="wrap">
<div class="frame">
<div class="titlebar">
<span style="font-weight:500;">spine</span>
<span class="muted">{{total_books}} books · {{shelf_count}} shelves</span>
</div>
<div class="filter-bar">
<a href="/index"{{^current_shelf}} style="font-weight:600"{{/current_shelf}}>All</a>
{{#shelf_nav}}
&nbsp;·&nbsp;
<a href="{{href}}"{{#current}} style="font-weight:600"{{/current}}>{{name}}</a>
{{/shelf_nav}}
</div>
{{#groups}}
<div class="group">{{label}} · {{count}}</div>
{{#books}}
<div class="row{{#selected}} selected{{/selected}}">
<i class="ti {{format_icon}} glyph" aria-hidden="true"></i>
<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}}
{{#rec_via}}<span class="trail">via {{rec_via}}</span>{{/rec_via}}
{{#rating_display}}<span class="trail">{{rating_display}}</span>{{/rating_display}}
</div>
{{#detail}}
<div class="detail">
<div class="muted" style="margin-bottom:8px;">{{meta}}</div>
{{#notes}}
<div class="logline"><span class="logdate">{{date}}</span><span>{{text}}</span></div>
{{/notes}}
</div>
{{/detail}}
{{/books}}
{{/groups}}
{{#minibuffer}}
<div class="minibuffer">
<span class="muted">M-x</span>
<span style="display:flex;gap:8px;flex-wrap:wrap">
{{#actions}}
<a href="{{href}}" class="pill" style="text-decoration:none;cursor:pointer">{{label}}</a>
{{/actions}}
</span>
</div>
{{/minibuffer}}
</div>
</div>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
;;; spine-add-book-test.el — ERT tests for spine-add-book
(require 'ert)
(require 'cl-lib)
(setq spine-skip-server-start t)
(setq spine-org-file (expand-file-name "test-add-temp.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)))
(defun spine-add-book-test--cleanup ()
"Remove the test Org file if it exists."
(ignore-errors (delete-file spine-org-file)))
(ert-deftest spine-add-book-creates-headline ()
"spine-add-book creates a new book under the given shelf."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(with-temp-file spine-org-file
(insert "* Fiction\n\n"))
(spine-add-book :title "Test Book" :author "Author Name" :shelf "Fiction")
(let ((books (spine-books)))
(should books)
(should (= (length books) 1))
(let ((book (car books)))
(should (equal (plist-get book :title) "Test Book"))
(should (equal (plist-get book :author) "Author Name"))
(should (equal (plist-get book :shelf) "Fiction"))
(should (plist-get book :id)))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-sets-all-fields ()
"spine-add-book sets all provided fields."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(with-temp-file spine-org-file
(insert "* Fiction\n\n"))
(spine-add-book
:title "Full Test"
:author "Jane Doe"
:format "hardcover"
:isbn "978-1234567890"
:cover "covers/test.jpg"
:rec_by "Friend"
:rec_note "You'll love this"
:date_added "2026-06-21"
:initial_note "Looking forward to this"
:shelf "Fiction")
(let* ((books (spine-books))
(book (car books)))
(should (equal (plist-get book :title) "Full Test"))
(should (equal (plist-get book :author) "Jane Doe"))
(should (equal (plist-get book :format) "hardcover"))
(should (equal (plist-get book :isbn) "978-1234567890"))
(should (equal (plist-get book :cover) "covers/test.jpg"))
(should (equal (plist-get book :rec_by) "Friend"))
(should (equal (plist-get book :rec_note) "You'll love this"))
(should (equal (plist-get book :added) "[2026-06-21]"))
(should (equal (plist-get book :notes)
'(("2026-06-21" "Looking forward to this"))))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-omits-empty-fields ()
"spine-add-book does not set empty/omitted fields."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(with-temp-file spine-org-file
(insert "* Fiction\n\n"))
(spine-add-book :title "Minimal Book" :shelf "Fiction")
(let* ((books (spine-books))
(book (car books)))
(should (equal (plist-get book :title) "Minimal Book"))
(should (equal (plist-get book :shelf) "Fiction"))
(should-not (plist-get book :author))
(should-not (plist-get book :format))
(should-not (plist-get book :isbn))
(should-not (plist-get book :tags))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-requires-title ()
"spine-add-book signals error when title is missing."
(spine-add-book-test--cleanup)
(unwind-protect
(should-error (spine-add-book :author "No Title" :shelf "Fiction"))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-requires-shelf ()
"spine-add-book signals error when shelf is missing."
(spine-add-book-test--cleanup)
(unwind-protect
(should-error (spine-add-book :title "No Shelf"))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-generates-unique-ids ()
"spine-add-book generates different IDs for each book."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(with-temp-file spine-org-file
(insert "* Fiction\n\n"))
(spine-add-book :title "Book One" :shelf "Fiction")
(spine-add-book :title "Book Two" :shelf "Fiction")
(let* ((books (spine-books))
(id1 (plist-get (nth 0 books) :id))
(id2 (plist-get (nth 1 books) :id)))
(should (not (equal id1 id2)))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-creates-under-correct-shelf ()
"spine-add-book places the book under the named shelf."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(with-temp-file spine-org-file
(insert "* Fiction\n\n* Non-Fiction\n\n"))
(spine-add-book :title "Sci-Fi Book" :shelf "Fiction")
(spine-add-book :title "History Book" :shelf "Non-Fiction")
(let ((books (spine-books)))
(should (= (length books) 2))
(should (equal (plist-get (nth 0 books) :shelf) "Fiction"))
(should (equal (plist-get (nth 1 books) :shelf) "Non-Fiction"))))
(spine-add-book-test--cleanup)))
+200
View File
@@ -0,0 +1,200 @@
;;; 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 (READING, audiobook, tags=scifi+literary, rec_by=Priya).")
(defconst spine-book-model-test--aj
(cl-find "3a1b-aj" (spine-books)
:key (lambda (b) (plist-get b :id))
:test #'equal)
"Ancillary Justice fixture (READ, ebook, rating=5, tags=scifi).")
(defconst spine-book-model-test--pir
(cl-find "5e8d-pir" (spine-books)
:key (lambda (b) (plist-get b :id))
:test #'equal)
"Piranesi fixture (WANT, no format, no tags, no rec).")
(defconst spine-book-model-test--tcm
(let* ((books (spine-books))
(b (cl-find "b2c1-tcm" books
:key (lambda (x) (plist-get x :id))
:test #'equal)))
b)
"The Checklist Manifesto fixture (no TODO, no format, no rec).")
;; --- Title & status ----------------------------------------------------
(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 status_class and label."
(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 status_class and label."
(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 status_class and label."
(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-no-todo-prefix ()
"No TODO prefix shows title as-is and empty status."
(let ((model (spine-book-model spine-book-model-test--tcm)))
(should (equal (ht-get model "title") "The Checklist Manifesto"))
(should (equal (ht-get model "status_class") ""))
(should (equal (ht-get model "status_label") ""))))
;; --- Metadata fields ----------------------------------------------------
(ert-deftest spine-book-model-author ()
"Author 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-shelf-name ()
"Shelf_name passed through."
(let ((model (spine-book-model spine-book-model-test--uow)))
(should (equal (ht-get model "shelf_name") "Fiction"))))
(ert-deftest spine-book-model-format-fields ()
"Format icon and label match the book's format."
(let ((model (spine-book-model spine-book-model-test--uow)))
(should (equal (ht-get model "format_icon") "ti-headphones"))
(should (equal (ht-get model "format_label") "Audiobook"))))
(ert-deftest spine-book-model-format-none ()
"No format yields empty label and default icon."
(let ((model (spine-book-model spine-book-model-test--pir)))
(should (equal (ht-get model "format_icon") "ti-book"))
(should (equal (ht-get model "format_label") ""))))
(ert-deftest spine-book-model-tags-display ()
"Tags rendered as HTML badge spans."
(let ((model (spine-book-model spine-book-model-test--uow)))
(let ((tags (ht-get model "tags_display")))
(should (string-match "scifi" tags))
(should (string-match "literary" tags)))))
(ert-deftest spine-book-model-no-tags ()
"No tags yields empty string."
(let ((model (spine-book-model spine-book-model-test--tcm)))
(should (equal (ht-get model "tags_display") ""))))
(ert-deftest spine-book-model-started-display ()
"started_display from ADDED property."
(let ((model (spine-book-model spine-book-model-test--uow)))
(should (equal (ht-get model "started_display") "20 Feb 2026"))))
(ert-deftest spine-book-model-added-display ()
"added_display from ADDED property."
(let ((model (spine-book-model spine-book-model-test--uow)))
(should (equal (ht-get model "added_display") "20 Feb 2026"))))
(ert-deftest spine-book-model-finished-default ()
"finished_display defaults to em-dash."
(let ((model (spine-book-model spine-book-model-test--uow)))
(should (equal (ht-get model "finished_display") ""))))
(ert-deftest spine-book-model-rating-display ()
"Rating produces star characters."
(let ((model (spine-book-model spine-book-model-test--aj)))
(should (equal (ht-get model "rating_display") "★★★★★"))
(should (ht-get model "has_rating"))))
(ert-deftest spine-book-model-rating-none ()
"No rating shows placeholder text."
(let ((model (spine-book-model spine-book-model-test--uow)))
(should (equal (ht-get model "rating_display") "rate when finished"))
(should-not (ht-get model "has_rating"))))
;; --- Format/status/shelf options ---------------------------------------
(ert-deftest spine-book-model-status-options ()
"status_options includes all statuses with one selected."
(let* ((model (spine-book-model spine-book-model-test--uow))
(opts (ht-get model "status_options")))
(should (= (length opts) 3))
(should (equal (ht-get (nth 0 opts) "name") "Want"))
(should-not (ht-get (nth 0 opts) "selected"))
(should (equal (ht-get (nth 1 opts) "name") "Reading"))
(should (ht-get (nth 1 opts) "selected"))
(should (equal (ht-get (nth 2 opts) "name") "Read"))
(should-not (ht-get (nth 2 opts) "selected"))))
(ert-deftest spine-book-model-shelf-options ()
"shelf_options lists all shelves with current one selected."
(let* ((model (spine-book-model spine-book-model-test--uow))
(opts (ht-get model "shelf_options")))
(should (>= (length opts) 3))
(let ((fiction (cl-find "Fiction" opts :key (lambda (o) (ht-get o "name")) :test #'equal)))
(should fiction)
(should (ht-get fiction "selected")))
(let ((scifi (cl-find "Science Fiction" opts :key (lambda (o) (ht-get o "name")) :test #'equal)))
(should scifi)
(should-not (ht-get scifi "selected")))))
;; --- Notes -------------------------------------------------------------
(ert-deftest spine-book-model-notes ()
"Notes mapped with date and text."
(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-notes-empty ()
"No notes yields empty array."
(let* ((model (spine-book-model spine-book-model-test--tcm))
(notes (ht-get model "notes")))
(should (listp notes))
(should (= (length notes) 0))))
;; --- Recommendation ----------------------------------------------------
(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"))))
;; --- Navigation --------------------------------------------------------
(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"))))
+96
View File
@@ -0,0 +1,96 @@
;;; spine-books-test.el — ERT tests for spine-books
(require 'ert)
(require 'cl-lib)
;; Load spine.el without starting the server
(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)))
(ert-deftest spine-books-parses-sample ()
"spine-books returns correct plists for sample-books.org."
(let ((books (spine-books)))
(should books)
(should (= (length books) 6))))
(ert-deftest spine-books-first-book-has-all-fields ()
"Use of Weapons entry has all expected fields."
(let* ((books (spine-books))
(uow (cl-find "8c1e-uow" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should uow)
(should (equal (plist-get uow :title) "READING Use of Weapons"))
(should (equal (plist-get uow :shelf) "Fiction"))
(should (equal (plist-get uow :author) "Iain M. Banks"))
(should (equal (plist-get uow :isbn) "978-0316029193"))
(should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg"))
(should (equal (plist-get uow :format) "audiobook"))
(should (equal (plist-get uow :rec_by) "Priya"))
(should (equal (plist-get uow :rec_note) "If you liked Player of Games, this one will wreck you"))
(should (equal (plist-get uow :tags) '("scifi" "literary")))
(should (equal (plist-get uow :notes)
'(("2026-03-02" "The two-track structure is doing something I can't name yet.")
("2026-03-07" "Zakalwe's competence reads as a wound.")
("2026-03-09" "The chapter numbering. Oh."))))))
(ert-deftest spine-books-missing-properties-are-nil ()
"Piranesi entry has nil for non-present properties."
(let* ((books (spine-books))
(pir (cl-find "5e8d-pir" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should pir)
(should (equal (plist-get pir :title) "WANT Piranesi"))
(should (equal (plist-get pir :shelf) "Fiction"))
(should (equal (plist-get pir :author) "Susanna Clarke"))
(should-not (plist-get pir :isbn))
(should-not (plist-get pir :cover))
(should-not (plist-get pir :format))
(should-not (plist-get pir :rec_by))
(should-not (plist-get pir :rec_note))
(should-not (plist-get pir :notes))))
(ert-deftest spine-books-read-book-has-rating ()
"Ancillary Justice has rating 5."
(let* ((books (spine-books))
(aj (cl-find "3a1b-aj" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should aj)
(should (equal (plist-get aj :shelf) "Science Fiction"))
(should (equal (plist-get aj :rating) "5"))
(should (= (length (plist-get aj :notes)) 3))))
(ert-deftest spine-books-empty-file-returns-empty-list ()
"Empty Org file returns empty list, not nil."
(let* ((tmp-file (make-temp-file "spine-test-" nil ".org"))
(spine-org-file tmp-file))
(unwind-protect
(let ((books (spine-books)))
(should (listp books))
(should (= (length books) 0)))
(delete-file tmp-file))))
(ert-deftest spine-books-missing-file-returns-nil ()
"Non-existent file returns nil."
(let ((spine-org-file "/tmp/spine-nonexistent-12345.org"))
(should-not (spine-books))))
(ert-deftest spine-shelves-returns-shelf-names ()
"spine-shelves returns all level-1 headlines as shelf names."
(let ((shelves (spine-shelves)))
(should (member "Fiction" shelves))
(should (member "Science Fiction" shelves))
(should (member "Non-Fiction" shelves))
(should (= (length shelves) 3))))
(ert-deftest spine-shelves-includes-empty-shelves ()
"spine-shelves includes empty shelves (shelves with no books)."
(let ((shelves (spine-shelves)))
(should (member "Non-Fiction" shelves))))
+189
View File
@@ -0,0 +1,189 @@
;;; spine-edit-test.el — ERT tests for spine-edit functions
(require 'ert)
(require 'cl-lib)
(setq spine-skip-server-start t)
(setq spine-org-file (expand-file-name "test-edit-temp.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)))
(defun spine-edit-test--setup ()
"Create a test Org file with one shelf and one book."
(with-temp-file spine-org-file
(insert "* Fiction\n** WANT Edit Test Book\n:PROPERTIES:\n:AUTHOR: Test Author\n:ID: test-0001\n:END:\n"))
t)
(defun spine-edit-test--cleanup ()
"Remove the test Org file."
(ignore-errors (delete-file spine-org-file)))
(ert-deftest spine-add-note-appends ()
"spine-add-note appends a note with date and text."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-add-note id "Great book!" "2026-06-21")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(let ((notes (plist-get updated :notes)))
(should notes)
(should (equal (caar notes) "2026-06-21"))
(should (equal (cadar notes) "Great book!"))))))
(spine-edit-test--cleanup)))
(ert-deftest spine-add-note-default-date ()
"spine-add-note uses today's date when none provided."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id))
(today (format-time-string "%Y-%m-%d")))
(spine-add-note id "Note without date")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(let ((notes (plist-get updated :notes)))
(should notes)
(should (equal (caar notes) today))
(should (equal (cadar notes) "Note without date"))))))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-rating-sets-rating ()
"spine-set-rating sets the RATING property."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-rating id "4")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :rating) "4")))))
(spine-edit-test--cleanup)))
(ert-deftest spine-edit-model-actions-include-note-and-rating ()
"Selected book shows Add note and Set rating actions."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id))
(model (spine-index-model books nil id))
(minibuffer (ht-get model "minibuffer")))
(should minibuffer)
(let ((actions (ht-get minibuffer "actions")))
(should (= (length actions) 2))
(should (equal (ht-get (nth 0 actions) "label") "Add note"))
(should (equal (ht-get (nth 1 actions) "label") "Set rating")))))
(spine-edit-test--cleanup)))
(ert-deftest spine-edit-model-no-actions-when-no-selection ()
"No minibuffer actions when no book is selected."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(model (spine-index-model books nil nil)))
(should-not (ht-get model "minibuffer"))))
(spine-edit-test--cleanup)))
(defun spine-edit-test--two-shelf-setup ()
"Create a test Org file with two shelves and a book on the first."
(with-temp-file spine-org-file
(insert "* Fiction\n** WANT Edit Test Book\n:PROPERTIES:\n:AUTHOR: Test Author\n:ID: test-0001\n:END:\n* Science Fiction\n"))
t)
(ert-deftest spine-move-book-moves-to-target-shelf ()
"spine-move-book moves the book to the target shelf."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--two-shelf-setup)
(spine-move-book "test-0001" "Science Fiction")
(let* ((books (spine-books))
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should book)
(should (equal (plist-get book :shelf) "Science Fiction"))))
(spine-edit-test--cleanup)))
(ert-deftest spine-move-book-keeps-properties ()
"spine-move-book preserves book properties after move."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--two-shelf-setup)
(spine-move-book "test-0001" "Science Fiction")
(let* ((books (spine-books))
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should book)
(should (equal (plist-get book :title) "WANT Edit Test Book"))
(should (equal (plist-get book :author) "Test Author"))))
(spine-edit-test--cleanup)))
(ert-deftest spine-move-book-errors-on-missing-book ()
"spine-move-book errors when book not found."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--two-shelf-setup)
(should-error (spine-move-book "nonexistent" "Science Fiction")))
(spine-edit-test--cleanup)))
(ert-deftest spine-move-book-errors-on-missing-shelf ()
"spine-move-book errors when target shelf not found."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--two-shelf-setup)
(should-error (spine-move-book "test-0001" "Nonexistent Shelf")))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-status-changes-todo ()
"spine-set-status changes the TODO keyword."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(spine-set-status "test-0001" "reading")
(let* ((books (spine-books))
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should book)
(should (equal (plist-get book :title) "READING Edit Test Book"))))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-status-clears-todo ()
"spine-set-status with empty string removes TODO keyword."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(spine-set-status "test-0001" "")
(let* ((books (spine-books))
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should book)
(should (equal (plist-get book :title) "Edit Test Book"))))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-status-errors-on-missing-book ()
"spine-set-status errors when book not found."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(should-error (spine-set-status "nonexistent" "reading")))
(spine-edit-test--cleanup)))
+74
View File
@@ -0,0 +1,74 @@
;;; spine-index-model-test.el — ERT tests for spine-index-model with shelf grouping
(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-index-model-test--books (spine-books)
"Books fixture loaded once from sample-books.org.")
(ert-deftest spine-index-model-groups-by-shelf ()
"Index model groups books by :shelf."
(let* ((model (spine-index-model spine-index-model-test--books))
(groups (ht-get model "groups")))
(should groups)
(should (= (length groups) 3)) ;; Fiction, Science Fiction, Non-Fiction
(let ((labels (mapcar (lambda (g) (ht-get g "label")) groups)))
(should (member "Fiction" labels))
(should (member "Science Fiction" labels))
(should (member "Non-Fiction" labels)))))
(ert-deftest spine-index-model-shelf-counts ()
"Each shelf group has the correct number of books."
(let* ((model (spine-index-model spine-index-model-test--books))
(groups (ht-get model "groups")))
(dolist (g groups)
(let ((label (ht-get g "label"))
(count (ht-get g "count")))
(cond
((equal label "Fiction")
(should (= count 4))
(should (= (length (ht-get g "books")) 4)))
((equal label "Science Fiction")
(should (= count 1))
(should (= (length (ht-get g "books")) 1)))
((equal label "Non-Fiction")
(should (= count 1))
(should (= (length (ht-get g "books")) 1))))))))
(ert-deftest spine-index-model-filters-by-shelf ()
"filter=shelf-name shows only that shelf's books."
(let* ((model (spine-index-model spine-index-model-test--books "Fiction"))
(groups (ht-get model "groups")))
(should (= (length groups) 1))
(should (equal (ht-get (car groups) "label") "Fiction"))))
(ert-deftest spine-index-model-includes-all-shelves-in-nav ()
"Shelf nav includes all shelves, including empties."
(let* ((model (spine-index-model spine-index-model-test--books))
(shelf-nav (ht-get model "shelf_nav")))
(should (= (length shelf-nav) 3))
(should (member "Non-Fiction" (mapcar (lambda (n) (ht-get n "name")) shelf-nav)))))
(ert-deftest spine-index-model-current-shelf ()
"current_shelf is set when filter is active."
(let* ((model (spine-index-model spine-index-model-test--books "Science Fiction")))
(should (equal (ht-get model "current_shelf") "Science Fiction"))))
(ert-deftest spine-index-model-no-current-shelf-without-filter ()
"current_shelf is nil when no filter."
(let* ((model (spine-index-model spine-index-model-test--books)))
(should-not (ht-get model "current_shelf"))))
(ert-deftest spine-index-model-total-and-shelf-count ()
"Titlebar shows correct totals."
(let* ((model (spine-index-model spine-index-model-test--books)))
(should (= (ht-get model "total_books") 6))
(should (= (ht-get model "shelf_count") 3))))