Compare commits

..

29 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
22 changed files with 4515 additions and 278 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)"
```
@@ -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,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
+26 -10
View File
@@ -1,6 +1,7 @@
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
#+TITLE: Spine
* WANT The Left Hand of Darkness
* Fiction
** WANT The Left Hand of Darkness
:PROPERTIES:
:AUTHOR: Ursula K. Le Guin
:ISBN: 978-0441478125
@@ -10,10 +11,11 @@
:REC_NOTE: Le Guin at her most human
:RATING:
:ADDED: [2026-04-01]
:LAST_MODIFIED: [2026-04-01]
:ID: 9a2b-lhd
:END:
* WANT Dune
** WANT Dune
:PROPERTIES:
:AUTHOR: Frank Herbert
:ISBN: 978-0441172719
@@ -23,10 +25,11 @@
:REC_NOTE:
:RATING:
:ADDED: [2026-05-10]
:LAST_MODIFIED: [2026-05-10]
:ID: 7f3c-dun
:END:
* WANT Piranesi :fiction:
** WANT Piranesi :fiction:
:PROPERTIES:
:AUTHOR: Susanna Clarke
:ISBN:
@@ -36,10 +39,11 @@
:REC_NOTE:
:RATING:
:ADDED: [2026-06-01]
:LAST_MODIFIED: [2026-06-01]
:ID: 5e8d-pir
:END:
* READING Use of Weapons :scifi:literary:
** READING Use of Weapons :scifi:literary:
:PROPERTIES:
:AUTHOR: Iain M. Banks
:ISBN: 978-0316029193
@@ -49,16 +53,18 @@
: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:
::LOGBOOK:
- State "READING" from "WANT" [2026-03-01]
:END:
::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 :scifi:
* Science Fiction
** READ Ancillary Justice :scifi:
:PROPERTIES:
:AUTHOR: Ann Leckie
:ISBN: 978-0316246620
@@ -68,12 +74,22 @@
:REC_NOTE:
:RATING: 5
:ADDED: [2025-11-15]
:LAST_MODIFIED: [2025-11-15]
:ID: 3a1b-aj
:END:
:LOGBOOK:
::LOGBOOK:
- State "READ" from "READING" [2026-01-10]
- State "READING" from "WANT" [2025-11-20]
:END:
::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
PORT="$ARG"
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)
# 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
EMACS_ARGS+=(--eval "(setq spine-host \"$HOST\")")
fi
+419 -72
View File
@@ -59,61 +59,153 @@ Returns the rendered string."
(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))))
"Build view model for templates/add.mustache from current BOOKS."
(ht ("app_title" "spine")
("existing_authors" (sort authors #'string<))
("existing_categories" (sort categories #'string<)))))
("shelves" (or (spine-shelves) '()))))
(defun spine-index-model (books &optional selected-id)
(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* ((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))
(let* ((grouped (make-hash-table :test 'equal))
(all-shelves (or (spine-shelves) '()))
(total (length books)))
;; Group books by shelf
(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))
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-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 group-books)
(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))
(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))
(or (nth 1 (assoc fmt spine--format-defs)) ""))
("title" (plist-get book :title))
("author" (or (plist-get book :author) ""))
("tags_inline"
@@ -126,6 +218,7 @@ SELECTED-ID expands the matching book's detail section."
(when (and rating (> (length rating) 0))
(make-string (string-to-number rating) ?\u2605)))
("selected" selected)
("book_url" (format "/book?id=%s" id))
("detail"
(when selected
(ht
@@ -134,7 +227,7 @@ SELECTED-ID expands the matching book's detail section."
(fmt (plist-get book :format)))
(cond
((and added fmt)
(format "started %s · %s" added fmt))
(format "started %s \302\267 %s" added fmt))
(added (format "started %s" added))
(fmt (format "format: %s" fmt))
(t ""))))
@@ -143,25 +236,41 @@ SELECTED-ID expands the matching book's detail section."
(cl-loop for (date text) in notes
collect (ht
("date" (format "[%s]" date))
("text" text)))))))))))
("text" text))))))))
)))
(push model book-models)))
(push (ht ("label" (cdr (assoc status status-labels)))
("count" (length group-books))
(push (ht ("label" shelf)
("count" (length shelf-books))
("books" (nreverse book-models)))
groups)))))
(ht ("app_title" "spine")
("total_books" total)
("reading_count" reading-count)
("groups" (nreverse groups))))))
("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 WANT book to `spine-org-file'.
Keyword arguments: :title :author :category :format :isbn :cover
"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
Signals an error on failure."
: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))
(category (plist-get args :category))
(format (plist-get args :format))
(isbn (plist-get args :isbn))
(cover (plist-get args :cover))
@@ -171,20 +280,32 @@ Signals an error on failure."
(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)))
;; 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")))
(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
(goto-char (point-max))
;; Insert new headline
;; 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 TODO state
(org-todo "WANT")
;; Set properties (only non-empty)
(when (and author (> (length author) 0))
(org-set-property "AUTHOR" author))
@@ -205,9 +326,8 @@ Signals an error on failure."
(format "%04x-%s"
(random 65535)
(substring (sha1 (concat title (number-to-string (random)))) 0 4)))
;; Set tags
(when (and category (> (length category) 0))
(org-set-tags (list category)))
;; 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))
@@ -219,6 +339,132 @@ Signals an error on failure."
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."
@@ -244,8 +490,31 @@ Returns nil for empty or missing properties."
(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 top-level headline in `spine-org-file'.
"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
@@ -258,11 +527,16 @@ Returns nil if the file is missing or cannot be parsed."
(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)))
(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)
:status (org-element-property :todo-keyword hl)
:author (spine--prop pos "AUTHOR")
:isbn (spine--prop pos "ISBN")
:cover (spine--prop pos "COVER")
@@ -271,11 +545,13 @@ Returns nil if the file is missing or cannot be parsed."
: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)))
books)))))
(org-element-property :end hl))
:shelf shelf)
books)))))))
(nreverse books)))
(error
(message "spine-books: failed to parse %s: %s"
@@ -306,10 +582,11 @@ Set by test harnesses that only need the model functions.")
;; --- handlers ---------------------------------------------------------
(defservlet index text/html (path query request)
(let ((books (spine-books)))
(let* ((books (spine-books))
(shelf-filter (cadr (assoc "shelf" query))))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books (cadr (assoc "id" query)))))
(spine-index-model books shelf-filter (cadr (assoc "id" query)))))
(insert (spine-render-empty-state)))))
(defun httpd/add (proc uri-path query request)
@@ -320,7 +597,7 @@ Set by test harnesses that only need the model functions.")
(spine-add-book
:title (cadr (assoc "title" query))
:author (cadr (assoc "author" query))
:category (cadr (assoc "category" query))
:shelf (cadr (assoc "shelf" query))
:format (cadr (assoc "format" query))
:isbn (cadr (assoc "isbn" query))
:cover (cadr (assoc "cover" query))
@@ -333,9 +610,79 @@ Set by test harnesses that only need the model functions.")
(let* ((books (spine-books))
(model (if books
(spine-add-form-model books)
(ht ("app_title" "spine")))))
(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))
+67 -71
View File
@@ -4,81 +4,77 @@
<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" />
<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>
<main class="container">
<article style="max-width: 560px; margin-inline: auto;">
<header>
<strong>{{app_title}} · add book</strong>
</header>
<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" 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>
<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>
</article>
</main>
</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>
+74 -85
View File
@@ -4,102 +4,89 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Spine — Direction A (agenda)</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
<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>
.agenda { font-family: var(--pico-font-family-monospace); padding: 0; overflow: hidden; }
.agenda > header, .agenda > footer { margin: 0; padding: .6rem .9rem; font-size: .85rem; }
.agenda > header { display: flex; justify-content: space-between; align-items: center; }
.group { padding: .65rem .9rem .2rem; font-size: .7rem; letter-spacing: .04em; color: var(--pico-muted-color); }
.row { display: flex; align-items: center; gap: .6rem; padding: .3rem .9rem; font-size: .85rem; }
.row.selected { background: var(--pico-card-sectioning-background-color); border-left: 2px solid var(--pico-primary); }
.row.selected .title { font-weight: 500; }
.row.selected .glyph { color: var(--pico-color); }
.glyph { width: 1rem; color: var(--pico-muted-color); flex-shrink: 0; }
.title { flex: 1; min-width: 0; }
.muted { color: var(--pico-muted-color); }
.tags { color: var(--pico-primary); font-size: .8rem; }
.trail { color: var(--pico-muted-color); font-size: .8rem; }
.pill { padding: .05rem .45rem; border-radius: var(--pico-border-radius); font-size: .7rem; font-weight: 500; }
.pill.want { background: #e6f1fb; color: #0c447c; }
.pill.reading { background: #faeeda; color: #854f0b; }
.pill.read { background: #e1f5ee; color: #085041; }
.detail { padding: .5rem .9rem .7rem 2rem; background: var(--pico-card-sectioning-background-color);
border-left: 2px solid var(--pico-primary); font-size: .8rem; }
.logline { display: flex; gap: .5rem; margin-bottom: .3rem; }
.logline:last-child { margin-bottom: 0; }
.logdate { color: var(--pico-muted-color); white-space: nowrap; }
.endpad { height: .4rem; }
.minibuffer { display: flex; gap: .5rem; align-items: center; }
.minibuffer .text { border-left: 2px solid var(--pico-primary); padding-left: .4rem; }
.caret { opacity: .5; }
@media (prefers-color-scheme: dark) {
.pill.want { background: #14304a; color: #b5d4f4; }
.pill.reading { background: #3a2c12; color: #fac775; }
.pill.read { background: #103a30; color: #9fe1cb; }
: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>
{{!
View model for this template:
{
app_title: string,
total_books: number,
reading_count: number,
groups: [
{
label: string, e.g. "on deck"
count: number,
books: [
{
format_icon: string, Tabler class, e.g. "ti-headphones" (data layer maps FORMAT -> icon)
status_class: string, "want" | "reading" | "read" (maps Org TODO state -> pill style)
status_label: string, text shown in the pill
title: string,
author: string,
tags_inline: string?, optional, e.g. ":scifi:literary:"
rec_via: string?, optional, REC_BY -> rendered as "via {name}"
rating_display: string?, optional, e.g. "★★★★☆"
selected: boolean?, marks the in-focus row (expands detail)
detail: { optional; render when a row is expanded
meta: string, e.g. "started [2026-02-20] · hardcover · 62% · rating —"
notes: [ { date: string, text: string } ] reading log
}?
}
]
}
],
minibuffer: { command: string, text: string }? optional capture line
}
}}
<main class="container">
<article class="agenda" style="max-width: 720px; margin-inline: auto;">
<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>
<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>
<span class="pill {{status_class}}">{{status_label}}</span>
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
<a href="{{book_url}}" class="title" style="text-decoration:none;color:inherit">{{title}}</a>
<span class="muted">· {{author}}</span>
{{#tags_inline}}<span class="tags">{{tags_inline}}</span>{{/tags_inline}}
{{#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: .5rem;">{{meta}}</div>
<div class="muted" style="margin-bottom:8px;">{{meta}}</div>
{{#notes}}
<div class="logline"><span class="logdate">{{date}}</span><span>{{text}}</span></div>
{{/notes}}
@@ -107,16 +94,18 @@
{{/detail}}
{{/books}}
{{/groups}}
<div class="endpad"></div>
{{#minibuffer}}
<footer class="minibuffer">
<div class="minibuffer">
<span class="muted">M-x</span>
<span>{{command}}</span>
<span class="text">{{text}}<span class="caret">▏</span></span>
</footer>
<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}}
</article>
</main>
</div>
</div>
</body>
</html>
+40 -10
View File
@@ -16,18 +16,20 @@
(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 creates a new book under the given shelf."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Test Book" :author "Author Name")
(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 :status) "WANT"))
(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)))
@@ -36,6 +38,8 @@
(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"
@@ -45,8 +49,8 @@
:rec_by "Friend"
:rec_note "You'll love this"
:date_added "2026-06-21"
:category "scifi"
:initial_note "Looking forward to this")
:initial_note "Looking forward to this"
:shelf "Fiction")
(let* ((books (spine-books))
(book (car books)))
(should (equal (plist-get book :title) "Full Test"))
@@ -57,7 +61,6 @@
(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)))
@@ -67,10 +70,13 @@
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Minimal Book")
(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))
@@ -81,7 +87,14 @@
"spine-add-book signals error when title is missing."
(spine-add-book-test--cleanup)
(unwind-protect
(should-error (spine-add-book :author "No Title"))
(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 ()
@@ -89,10 +102,27 @@
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Book One")
(spine-add-book :title "Book Two")
(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"))))
+19 -6
View File
@@ -16,7 +16,7 @@
"spine-books returns correct plists for sample-books.org."
(let ((books (spine-books)))
(should books)
(should (= (length books) 5))))
(should (= (length books) 6))))
(ert-deftest spine-books-first-book-has-all-fields ()
"Use of Weapons entry has all expected fields."
@@ -25,8 +25,8 @@
: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 :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"))
@@ -46,8 +46,8 @@
: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 :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))
@@ -63,7 +63,7 @@
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should aj)
(should (equal (plist-get aj :status) "READ"))
(should (equal (plist-get aj :shelf) "Science Fiction"))
(should (equal (plist-get aj :rating) "5"))
(should (= (length (plist-get aj :notes)) 3))))
@@ -81,3 +81,16 @@
"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))))