df1a7aebc9
- 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
1048 lines
37 KiB
Markdown
1048 lines
37 KiB
Markdown
# Shelves Data Model Implementation Plan
|
|
|
|
**Goal:** Restructure spine's data model from TODO-state-based top-level books to shelf-based organization with books nested under shelves.
|
|
|
|
**Architecture:** Level-1 Org headlines become shelves (arbitrary organizing concepts). Level-2 headlines are books. `spine-books` returns books with a `:shelf` key. New `spine-shelves` returns all shelf names including empties. UI groups by shelf, nav bar lists shelves. TODO states removed; logbook handles history. Add-book requires picking an existing shelf.
|
|
|
|
**Tech Stack:** Emacs Lisp, Org-mode, mustache templates
|
|
|
|
---
|
|
|
|
### Task 1: Restructure sample-books.org
|
|
|
|
**Files:**
|
|
- Modify: `sample-books.org`
|
|
|
|
- [ ] **Step 1: Rewrite sample-books.org with shelf hierarchy**
|
|
|
|
Replace the flat headline list with shelves as level-1 headings and books as level-2. Keep all existing properties, logbooks, notes, and IDs. Remove the `#+TODO:` line. Books retain their TODO keywords for org-mode compatibility but need not — either way the app ignores them.
|
|
|
|
```org
|
|
#+TITLE: Spine
|
|
|
|
* Fiction
|
|
** WANT The Left Hand of Darkness
|
|
:PROPERTIES:
|
|
:AUTHOR: Ursula K. Le Guin
|
|
:ISBN: 978-0441478125
|
|
:COVER: covers/left-hand.jpg
|
|
:FORMAT: ebook
|
|
:REC_BY: Alex
|
|
:REC_NOTE: Le Guin at her most human
|
|
:RATING:
|
|
:ADDED: [2026-04-01]
|
|
:LAST_MODIFIED: [2026-04-01]
|
|
:ID: 9a2b-lhd
|
|
:END:
|
|
|
|
** WANT Dune
|
|
:PROPERTIES:
|
|
:AUTHOR: Frank Herbert
|
|
:ISBN: 978-0441172719
|
|
:COVER: covers/dune.jpg
|
|
:FORMAT: hardcover
|
|
:REC_BY: Everyone
|
|
:REC_NOTE:
|
|
:RATING:
|
|
:ADDED: [2026-05-10]
|
|
:LAST_MODIFIED: [2026-05-10]
|
|
:ID: 7f3c-dun
|
|
:END:
|
|
|
|
** WANT Piranesi :fiction:
|
|
:PROPERTIES:
|
|
:AUTHOR: Susanna Clarke
|
|
:ISBN:
|
|
:COVER:
|
|
:FORMAT:
|
|
:REC_BY:
|
|
:REC_NOTE:
|
|
:RATING:
|
|
:ADDED: [2026-06-01]
|
|
:LAST_MODIFIED: [2026-06-01]
|
|
:ID: 5e8d-pir
|
|
:END:
|
|
|
|
** READING Use of Weapons :scifi:literary:
|
|
:PROPERTIES:
|
|
:AUTHOR: Iain M. Banks
|
|
:ISBN: 978-0316029193
|
|
:COVER: covers/use-of-weapons.jpg
|
|
:FORMAT: audiobook
|
|
:REC_BY: Priya
|
|
:REC_NOTE: If you liked Player of Games, this one will wreck you
|
|
:RATING:
|
|
:ADDED: [2026-02-20]
|
|
:LAST_MODIFIED: [2026-03-09]
|
|
:ID: 8c1e-uow
|
|
:END:
|
|
::LOGBOOK:
|
|
- State "READING" from "WANT" [2026-03-01]
|
|
::END:
|
|
- [2026-03-02] The two-track structure is doing something I can't name yet.
|
|
- [2026-03-07] Zakalwe's competence reads as a wound.
|
|
- [2026-03-09] The chapter numbering. Oh.
|
|
|
|
* Science Fiction
|
|
** READ Ancillary Justice :scifi:
|
|
:PROPERTIES:
|
|
:AUTHOR: Ann Leckie
|
|
:ISBN: 978-0316246620
|
|
:COVER: covers/ancillary.jpg
|
|
:FORMAT: ebook
|
|
:REC_BY:
|
|
:REC_NOTE:
|
|
:RATING: 5
|
|
:ADDED: [2025-11-15]
|
|
:LAST_MODIFIED: [2025-11-15]
|
|
:ID: 3a1b-aj
|
|
:END:
|
|
::LOGBOOK:
|
|
- State "READ" from "READING" [2026-01-10]
|
|
- State "READING" from "WANT" [2025-11-20]
|
|
::END:
|
|
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
|
|
- [2025-12-05] Justice of Toren's multi-body perspective is unsettling.
|
|
- [2026-01-08] The tea set. Perfect ending.
|
|
|
|
* Non-Fiction
|
|
** WANT The Checklist Manifesto
|
|
:PROPERTIES:
|
|
:AUTHOR: Atul Gawande
|
|
:ID: b2c1-tcm
|
|
:ADDED: [2026-06-15]
|
|
:LAST_MODIFIED: [2026-06-15]
|
|
:END:
|
|
```
|
|
|
|
Added a "Non-Fiction" shelf (empty example of a shelf that could be pre-created).
|
|
|
|
- [ ] **Step 2: Run the test suite to confirm it fails**
|
|
|
|
Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: failures because `spine-books` still expects flat level-1 headlines.
|
|
|
|
---
|
|
|
|
### Task 2: Add `spine-shelves` and adapt `spine-books`
|
|
|
|
**Files:**
|
|
- Modify: `spine.el`
|
|
- Modify: `test/spine-books-test.el`
|
|
|
|
- [ ] **Step 1: Write failing test for `spine-shelves`**
|
|
|
|
Add to `test/spine-books-test.el`:
|
|
|
|
```elisp
|
|
(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))))
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: FAIL — `spine-shelves` undefined.
|
|
|
|
- [ ] **Step 3: Write `spine-shelves` implementation**
|
|
|
|
In `spine.el`, before `spine-books`, add:
|
|
|
|
```elisp
|
|
(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))))
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: PASS for the two `spine-shelves` tests.
|
|
|
|
- [ ] **Step 5: Update `spine-books` to walk nested books under shelves**
|
|
|
|
Replace the current `spine-books` body. New logic: walk level-1 headlines as shelves. For each, walk level-2 children as books. Each book plist gets a `:shelf` key. No `:status` key.
|
|
|
|
```elisp
|
|
(defun spine-books ()
|
|
"Return a list of plists, one per book in `spine-org-file'.
|
|
Books are level-2 headlines nested under level-1 shelf headlines.
|
|
Returns nil if the file is missing or cannot be parsed."
|
|
(if (not (file-exists-p spine-org-file))
|
|
(progn
|
|
(message "spine-books: file not found: %s" spine-org-file)
|
|
nil)
|
|
(condition-case err
|
|
(with-temp-buffer
|
|
(insert-file-contents spine-org-file)
|
|
(org-mode)
|
|
(let ((books nil))
|
|
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
|
(lambda (hl)
|
|
(let ((level (org-element-property :level hl)))
|
|
(when (= level 2)
|
|
(let* ((pos (org-element-property :begin hl))
|
|
(parent (org-element-property :parent hl))
|
|
(shelf (and parent
|
|
(= (org-element-property :level parent) 1)
|
|
(org-element-property :raw-value parent))))
|
|
(when shelf
|
|
(push (list :id (spine--prop pos "ID")
|
|
:title (org-element-property :raw-value hl)
|
|
:author (spine--prop pos "AUTHOR")
|
|
:isbn (spine--prop pos "ISBN")
|
|
:cover (spine--prop pos "COVER")
|
|
:format (spine--prop pos "FORMAT")
|
|
:rec_by (spine--prop pos "REC_BY")
|
|
:rec_note (spine--prop pos "REC_NOTE")
|
|
:rating (spine--prop pos "RATING")
|
|
:added (spine--prop pos "ADDED")
|
|
:last_modified (spine--prop pos "LAST_MODIFIED")
|
|
:tags (org-element-property :tags hl)
|
|
:notes (spine--extract-notes
|
|
(org-element-property :begin hl)
|
|
(org-element-property :end hl))
|
|
:shelf shelf)
|
|
books)))))))
|
|
(nreverse books)))
|
|
(error
|
|
(message "spine-books: failed to parse %s: %s"
|
|
spine-org-file (error-message-string err))
|
|
nil))))
|
|
```
|
|
|
|
Note: `org-element-property :raw-value` for plain text headings (no links within). Safer than `:title` which returns a string or list depending on content.
|
|
|
|
- [ ] **Step 6: Update `spine-books` tests in spine-books-test.el**
|
|
|
|
Replace the status assertions and add `:shelf` assertions:
|
|
|
|
In `spine-books-parses-sample`:
|
|
- Change `(= (length books) 5)` → `(= (length books) 6)` (new book added to Non-Fiction)
|
|
|
|
In `spine-books-first-book-has-all-fields`:
|
|
- Replace `:status "READING"` check with `:shelf "Fiction"` check
|
|
- Keep all other field assertions
|
|
|
|
In `spine-books-missing-properties-are-nil`:
|
|
- Add `(should (equal (plist-get pir :shelf) "Fiction"))`
|
|
- Remove `:status "WANT"` check
|
|
|
|
In `spine-books-read-book-has-rating`:
|
|
- Replace `:status "READ"` check with `:shelf "Science Fiction"` check
|
|
- Keep rating and notes assertions
|
|
|
|
- [ ] **Step 7: Run tests to verify both pass**
|
|
|
|
Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: PASS for all spine-books tests.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add sample-books.org spine.el test/spine-books-test.el
|
|
git commit -m "feat: add spine-shelves, adapt spine-books for shelf nesting"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Adapt `spine-add-book` for shelves
|
|
|
|
**Files:**
|
|
- Modify: `spine.el`
|
|
- Modify: `test/spine-add-book-test.el`
|
|
|
|
- [ ] **Step 1: Update spine-add-book-test.el tests**
|
|
|
|
Remove status checks. Add `:shelf` argument to all `spine-add-book` calls. Update assertions.
|
|
|
|
Replace `spine-add-book-creates-headline`:
|
|
```elisp
|
|
(ert-deftest spine-add-book-creates-headline ()
|
|
"spine-add-book creates a new book under the given shelf."
|
|
(spine-add-book-test--cleanup)
|
|
(unwind-protect
|
|
(progn
|
|
;; Create the file first with a shelf
|
|
(with-temp-file spine-org-file
|
|
(insert "* Fiction\n\n"))
|
|
(spine-add-book :title "Test Book" :author "Author Name" :shelf "Fiction")
|
|
(let ((books (spine-books)))
|
|
(should books)
|
|
(should (= (length books) 1))
|
|
(let ((book (car books)))
|
|
(should (equal (plist-get book :title) "Test Book"))
|
|
(should (equal (plist-get book :author) "Author Name"))
|
|
(should (equal (plist-get book :shelf) "Fiction"))
|
|
(should (plist-get book :id)))))
|
|
(spine-add-book-test--cleanup)))
|
|
```
|
|
|
|
Update `spine-add-book-sets-all-fields`:
|
|
```elisp
|
|
(ert-deftest spine-add-book-sets-all-fields ()
|
|
"spine-add-book sets all provided fields."
|
|
(spine-add-book-test--cleanup)
|
|
(unwind-protect
|
|
(progn
|
|
(with-temp-file spine-org-file
|
|
(insert "* Fiction\n\n"))
|
|
(spine-add-book
|
|
:title "Full Test"
|
|
:author "Jane Doe"
|
|
:format "hardcover"
|
|
:isbn "978-1234567890"
|
|
:cover "covers/test.jpg"
|
|
:rec_by "Friend"
|
|
:rec_note "You'll love this"
|
|
:date_added "2026-06-21"
|
|
:initial_note "Looking forward to this"
|
|
:shelf "Fiction")
|
|
(let* ((books (spine-books))
|
|
(book (car books)))
|
|
(should (equal (plist-get book :title) "Full Test"))
|
|
(should (equal (plist-get book :author) "Jane Doe"))
|
|
(should (equal (plist-get book :format) "hardcover"))
|
|
(should (equal (plist-get book :isbn) "978-1234567890"))
|
|
(should (equal (plist-get book :cover) "covers/test.jpg"))
|
|
(should (equal (plist-get book :rec_by) "Friend"))
|
|
(should (equal (plist-get book :rec_note) "You'll love this"))
|
|
(should (equal (plist-get book :added) "[2026-06-21]"))
|
|
(should (equal (plist-get book :notes)
|
|
'(("2026-06-21" "Looking forward to this"))))))
|
|
(spine-add-book-test--cleanup)))
|
|
```
|
|
|
|
Update `spine-add-book-omits-empty-fields`:
|
|
```elisp
|
|
(ert-deftest spine-add-book-omits-empty-fields ()
|
|
"spine-add-book does not set empty/omitted fields."
|
|
(spine-add-book-test--cleanup)
|
|
(unwind-protect
|
|
(progn
|
|
(with-temp-file spine-org-file
|
|
(insert "* Fiction\n\n"))
|
|
(spine-add-book :title "Minimal Book" :shelf "Fiction")
|
|
(let* ((books (spine-books))
|
|
(book (car books)))
|
|
(should (equal (plist-get book :title) "Minimal Book"))
|
|
(should (equal (plist-get book :shelf) "Fiction"))
|
|
(should-not (plist-get book :author))
|
|
(should-not (plist-get book :format))
|
|
(should-not (plist-get book :isbn))
|
|
(should-not (plist-get book :tags))))
|
|
(spine-add-book-test--cleanup)))
|
|
```
|
|
|
|
Update `spine-add-book-requires-title` — no change needed (still signals error).
|
|
|
|
Update `spine-add-book-generates-unique-ids`:
|
|
```elisp
|
|
(ert-deftest spine-add-book-generates-unique-ids ()
|
|
"spine-add-book generates different IDs for each book."
|
|
(spine-add-book-test--cleanup)
|
|
(unwind-protect
|
|
(progn
|
|
(with-temp-file spine-org-file
|
|
(insert "* Fiction\n\n"))
|
|
(spine-add-book :title "Book One" :shelf "Fiction")
|
|
(spine-add-book :title "Book Two" :shelf "Fiction")
|
|
(let* ((books (spine-books))
|
|
(id1 (plist-get (nth 0 books) :id))
|
|
(id2 (plist-get (nth 1 books) :id)))
|
|
(should (not (equal id1 id2)))))
|
|
(spine-add-book-test--cleanup)))
|
|
```
|
|
|
|
Add a test for missing shelf:
|
|
```elisp
|
|
(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)))
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify failures**
|
|
|
|
Run: `emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: FAIL — spine-add-book doesn't accept `:shelf` yet.
|
|
|
|
- [ ] **Step 3: Update `spine-add-book` implementation**
|
|
|
|
Replace the function body. Key changes:
|
|
- Accept `:shelf` keyword arg (required)
|
|
- Remove TODO state setting
|
|
- Remove user tag/category setting (tags can be added back later if needed)
|
|
- Insert under the matching shelf headline instead of at file end
|
|
- Remove `#+TODO:` file initialization
|
|
|
|
```elisp
|
|
(defun spine-add-book (&rest args)
|
|
"Add a new book to shelf `:shelf' in `spine-org-file'.
|
|
Keyword arguments: :title :author :shelf :format :isbn :cover
|
|
:rec_by :rec_note :date_added :initial_note
|
|
:shelf is required. Signals an error if the shelf doesn't exist."
|
|
(let ((title (plist-get args :title))
|
|
(shelf (plist-get args :shelf))
|
|
(author (plist-get args :author))
|
|
(format (plist-get args :format))
|
|
(isbn (plist-get args :isbn))
|
|
(cover (plist-get args :cover))
|
|
(rec-by (plist-get args :rec_by))
|
|
(rec-note (plist-get args :rec_note))
|
|
(date-added (plist-get args :date_added))
|
|
(initial-note (plist-get args :initial_note)))
|
|
(unless title
|
|
(error "spine-add-book: title is required"))
|
|
(unless shelf
|
|
(error "spine-add-book: :shelf is required"))
|
|
(let ((org-file (expand-file-name spine-org-file)))
|
|
(unless (file-exists-p org-file)
|
|
(error "spine-add-book: file %s does not exist; create shelves first"
|
|
org-file))
|
|
(with-current-buffer (find-file-noselect org-file)
|
|
(unwind-protect
|
|
(progn
|
|
;; Find the shelf headline
|
|
(goto-char (point-min))
|
|
(let ((shelf-found nil))
|
|
(while (and (not shelf-found)
|
|
(re-search-forward
|
|
(format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote shelf))
|
|
nil t))
|
|
(when (= (org-current-level) 1)
|
|
(setq shelf-found t)))
|
|
(unless shelf-found
|
|
(error "spine-add-book: shelf \"%s\" not found" shelf)))
|
|
;; Go to end of shelf section
|
|
(org-end-of-subtree)
|
|
;; Insert new book headline
|
|
(org-insert-heading nil t t)
|
|
(insert title)
|
|
;; Set properties (only non-empty)
|
|
(when (and author (> (length author) 0))
|
|
(org-set-property "AUTHOR" author))
|
|
(when (and format (> (length format) 0))
|
|
(org-set-property "FORMAT" format))
|
|
(when (and isbn (> (length isbn) 0))
|
|
(org-set-property "ISBN" isbn))
|
|
(when (and cover (> (length cover) 0))
|
|
(org-set-property "COVER" cover))
|
|
(when (and rec-by (> (length rec-by) 0))
|
|
(org-set-property "REC_BY" rec-by))
|
|
(when (and rec-note (> (length rec-note) 0))
|
|
(org-set-property "REC_NOTE" rec-note))
|
|
(when (and date-added (> (length date-added) 0))
|
|
(org-set-property "ADDED" (format "[%s]" date-added)))
|
|
;; Set ID
|
|
(org-set-property "ID"
|
|
(format "%04x-%s"
|
|
(random 65535)
|
|
(substring (sha1 (concat title (number-to-string (random)))) 0 4)))
|
|
;; Set last modified
|
|
(org-set-property "LAST_MODIFIED" (format-time-string "[%Y-%m-%d]"))
|
|
;; Add initial note
|
|
(when (and initial-note (> (length initial-note) 0))
|
|
(let ((date (if (and date-added (> (length date-added) 0))
|
|
date-added
|
|
(format-time-string "%Y-%m-%d"))))
|
|
(goto-char (org-entry-end-position))
|
|
(insert (format "\n- [%s] %s\n" date initial-note))))
|
|
(save-buffer)
|
|
t)
|
|
(kill-buffer))))))
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add spine.el test/spine-add-book-test.el
|
|
git commit -m "feat: adapt spine-add-book for shelf-based nesting"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Remove `spine-set-status` and update edit tests
|
|
|
|
**Files:**
|
|
- Modify: `spine.el`
|
|
- Modify: `test/spine-edit-test.el`
|
|
|
|
- [ ] **Step 1: Remove `spine-set-status` function**
|
|
|
|
Delete the entire `spine-set-status` function (lines 296-319 approximately). The HTTP handler `httpd/edit` will be updated in a later task.
|
|
|
|
- [ ] **Step 2: Update spine-edit-test.el**
|
|
|
|
Remove tests that use `spine-set-status`:
|
|
- `spine-set-status-want-to-reading` — delete
|
|
- `spine-set-status-reading-to-read` — delete
|
|
- `spine-set-status-missing-id` — delete
|
|
- `spine-edit-model-minibuffer-reading` — rewrite for new minibuffer (add note / set rating only)
|
|
- `spine-edit-model-minibuffer-nil-when-no-selection` — keep as `spine-edit-model-no-actions-when-no-selection`
|
|
|
|
Update `spine-edit-test--setup` to create a shelf structure:
|
|
```elisp
|
|
(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"))
|
|
(spine-books) ; force file load, verify it works
|
|
t)
|
|
```
|
|
|
|
Keep note and rating tests (`spine-add-note-appends`, `spine-add-note-default-date`, `spine-set-rating-sets-rating`) — they should still work with the new setup.
|
|
|
|
Update minibuffer tests:
|
|
```elisp
|
|
(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)))
|
|
```
|
|
|
|
- [ ] **Step 3: Run edit tests**
|
|
|
|
Run: `emacs --batch -l test/spine-edit-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: PASS for remaining tests.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add spine.el test/spine-edit-test.el
|
|
git commit -m "feat: remove spine-set-status, update edit tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Update `spine-add-form-model`
|
|
|
|
**Files:**
|
|
- Modify: `spine.el`
|
|
|
|
- [ ] **Step 1: Replace category/authors logic with shelf list**
|
|
|
|
Update `spine-add-form-model` to use `spine-shelves` for the shelf dropdown:
|
|
|
|
```elisp
|
|
(defun spine-add-form-model (books)
|
|
"Build view model for templates/add.mustache from current BOOKS."
|
|
(ht ("app_title" "spine")
|
|
("shelves" (or (spine-shelves) '()))))
|
|
```
|
|
|
|
This replaces the existing implementation that computed `existing_authors` and `existing_categories`.
|
|
|
|
- [ ] **Step 2: Run books test to verify no regression**
|
|
|
|
Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: PASS.
|
|
|
|
---
|
|
|
|
### Task 6: Rewrite `spine-index-model` for shelf grouping
|
|
|
|
**Files:**
|
|
- Modify: `spine.el`
|
|
- Modify: `test/spine-index-model-test.el`
|
|
|
|
- [ ] **Step 1: Rewrite index-model tests**
|
|
|
|
Replace all tests in `spine-index-model-test.el`:
|
|
|
|
```elisp
|
|
;;; 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))))) ;; even empty shelves appear
|
|
|
|
(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))))
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify failures**
|
|
|
|
Run: `emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: FAIL — spine-index-model still groups by status.
|
|
|
|
- [ ] **Step 3: Rewrite `spine-index-model`**
|
|
|
|
Replace the entire function:
|
|
|
|
```elisp
|
|
(defun spine-index-model (books &optional shelf-filter selected-id)
|
|
"Build the view model ht for templates/index.mustache from BOOKS.
|
|
SHELF-Filter limits to one shelf by name (string). nil = all shelves.
|
|
SELECTED-ID expands the matching book's detail section."
|
|
(let* ((format-icons
|
|
'(("hardcover" . "ti-book")
|
|
("ebook" . "ti-device-tablet")
|
|
("audiobook" . "ti-headphones")))
|
|
(grouped (make-hash-table :test 'equal))
|
|
(all-shelves (or (spine-shelves) '()))
|
|
(total (length books)))
|
|
;; Group books by shelf
|
|
(dolist (book books)
|
|
(let ((shelf (or (plist-get book :shelf) "Uncategorized")))
|
|
(push book (gethash shelf grouped))))
|
|
(let ((groups nil)
|
|
(shelf-nav nil))
|
|
;; Build shelf nav from all shelves (including empty)
|
|
(dolist (shelf all-shelves)
|
|
(push (ht ("name" shelf)
|
|
("href" (format "/index?shelf=%s" (url-encode-url shelf)))
|
|
("current" (equal shelf shelf-filter)))
|
|
shelf-nav))
|
|
(setq shelf-nav (nreverse shelf-nav))
|
|
;; Build shelf groups (only those with books, or empty if filtered)
|
|
(dolist (shelf all-shelves)
|
|
(let ((shelf-books (nreverse (gethash shelf grouped))))
|
|
(when (or (null shelf-filter) (equal shelf shelf-filter))
|
|
(let ((book-models nil))
|
|
(dolist (book shelf-books)
|
|
(let* ((id (plist-get book :id))
|
|
(selected (equal id selected-id))
|
|
(fmt (plist-get book :format))
|
|
(rating (plist-get book :rating))
|
|
(notes (plist-get book :notes))
|
|
(model
|
|
(ht
|
|
("format_icon"
|
|
(or (cdr (assoc fmt format-icons)) ""))
|
|
("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" shelf)
|
|
("count" (length shelf-books))
|
|
("books" (nreverse book-models)))
|
|
groups)))))
|
|
(ht ("app_title" "spine")
|
|
("total_books" total)
|
|
("shelf_count" (length all-shelves))
|
|
("current_shelf" shelf-filter)
|
|
("shelf_nav" shelf-nav)
|
|
("groups" (nreverse groups))
|
|
("minibuffer"
|
|
(when selected-id
|
|
(let* ((books-by-id (make-hash-table :test 'equal)))
|
|
(dolist (b books)
|
|
(puthash (plist-get b :id) b books-by-id))
|
|
(let ((book (gethash selected-id books-by-id)))
|
|
(when book
|
|
(ht ("actions"
|
|
(list
|
|
(ht ("command" "note") ("label" "Add note")
|
|
("href" (format "/edit?id=%s&action=note" selected-id)))
|
|
(ht ("command" "rating") ("label" "Set rating")
|
|
("href" (format "/edit?id=%s&action=rating" selected-id)))))))))))
|
|
))))
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: PASS for all index-model tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add spine.el test/spine-index-model-test.el
|
|
git commit -m "feat: rewrite spine-index-model for shelf grouping"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Update HTTP handlers
|
|
|
|
**Files:**
|
|
- Modify: `spine.el`
|
|
|
|
- [ ] **Step 1: Update `defservlet index`**
|
|
|
|
Update the index handler to support `?shelf=` filter instead of `?filter=`:
|
|
|
|
```elisp
|
|
(defservlet index text/html (path query request)
|
|
(let* ((books (spine-books))
|
|
(shelf-filter (cadr (assoc "shelf" query))))
|
|
(if books
|
|
(insert (spine-render "index.mustache"
|
|
(spine-index-model books shelf-filter (cadr (assoc "id" query)))))
|
|
(insert (spine-render-empty-state)))))
|
|
```
|
|
|
|
- [ ] **Step 2: Update `httpd/add`**
|
|
|
|
Update to pass `:shelf` from the form:
|
|
|
|
```elisp
|
|
(defun httpd/add (proc uri-path query request)
|
|
"Handle /add: GET shows form, POST creates a book."
|
|
(let ((method (caar request)))
|
|
(if (equal method "POST")
|
|
(progn
|
|
(spine-add-book
|
|
:title (cadr (assoc "title" query))
|
|
:author (cadr (assoc "author" query))
|
|
:shelf (cadr (assoc "shelf" query))
|
|
:format (cadr (assoc "format" query))
|
|
:isbn (cadr (assoc "isbn" query))
|
|
:cover (cadr (assoc "cover" query))
|
|
:rec_by (cadr (assoc "rec_by" query))
|
|
:rec_note (cadr (assoc "rec_note" query))
|
|
:date_added (cadr (assoc "date_added" query))
|
|
:initial_note (cadr (assoc "initial_note" query)))
|
|
(httpd-redirect proc "/index" 303))
|
|
(httpd-with-buffer proc "text/html"
|
|
(let* ((books (spine-books))
|
|
(model (if books
|
|
(spine-add-form-model books)
|
|
(ht ("app_title" "spine" "shelves" '())))))
|
|
(insert (spine-render "add.mustache" model)))))))
|
|
```
|
|
|
|
- [ ] **Step 3: Update `httpd/edit`**
|
|
|
|
Remove status action cases. Only `note` and `rating` remain:
|
|
|
|
```elisp
|
|
(defun httpd/edit (proc uri-path query request)
|
|
"Handle /edit: GET shows prompt page, POST processes actions.
|
|
Query params:
|
|
id - book ID
|
|
action - note | rating
|
|
text - note text (for note action)
|
|
date - optional date string (for note action)
|
|
value - rating value (for rating action)"
|
|
(let ((method (caar request))
|
|
(id (cadr (assoc "id" query)))
|
|
(action (cadr (assoc "action" query)))
|
|
(value (cadr (assoc "value" query)))
|
|
(text (cadr (assoc "text" query)))
|
|
(date (cadr (assoc "date" query))))
|
|
(if (equal method "POST")
|
|
(progn
|
|
(cond
|
|
((equal action "note")
|
|
(spine-add-note id text date))
|
|
((equal action "rating")
|
|
(spine-set-rating id value)))
|
|
(httpd-redirect proc (format "/index?id=%s" id) 303))
|
|
;; GET: show prompt page for actions needing input
|
|
(if (and id (equal action "note"))
|
|
(let* ((books (spine-books))
|
|
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))
|
|
(title (if book (plist-get book :title) "Unknown")))
|
|
(httpd-with-buffer proc "text/html"
|
|
(insert (format
|
|
"<!DOCTYPE html>
|
|
<html lang=\"en\">
|
|
<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>
|
|
<meta name=\"color-scheme\" content=\"light dark\"/>
|
|
<title>Spine — note</title>
|
|
<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css\"/>
|
|
</head>
|
|
<body>
|
|
<main class=\"container\">
|
|
<article style=\"max-width:560px;margin-inline:auto;\">
|
|
<header><strong>spine · add note</strong></header>
|
|
<p><em>%s</em></p>
|
|
<form method=\"post\" action=\"/edit\">
|
|
<input type=\"hidden\" name=\"id\" value=\"%s\"/>
|
|
<input type=\"hidden\" name=\"action\" value=\"note\"/>
|
|
<label>Date <input type=\"date\" name=\"date\" value=\"%s\"/></label>
|
|
<label>Note <textarea name=\"text\" rows=\"3\" required autofocus></textarea></label>
|
|
<button type=\"submit\">Add note</button>
|
|
</form>
|
|
</article>
|
|
</main>
|
|
</body>
|
|
</html>"
|
|
title id (format-time-string "%Y-%m-%d"))))))
|
|
;; Unknown action or missing id: redirect to index
|
|
(httpd-redirect proc "/index" 302)))))
|
|
```
|
|
|
|
- [ ] **Step 4: Run all tests to check for regressions**
|
|
|
|
Run: `emacs --batch -l test/spine-books-test.el -l test/spine-add-book-test.el -l test/spine-edit-test.el -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: PASS for all.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add spine.el
|
|
git commit -m "feat: update HTTP handlers for shelf model"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Update `index.mustache` template
|
|
|
|
**Files:**
|
|
- Modify: `templates/index.mustache`
|
|
|
|
- [ ] **Step 1: Replace filter bar with shelf nav**
|
|
|
|
Replace:
|
|
```mustache
|
|
{{#current_filter}}
|
|
<div class="filter-bar">
|
|
<a href="/index">Concise view</a>
|
|
</div>
|
|
{{/current_filter}}
|
|
{{^current_filter}}
|
|
<div class="filter-bar">
|
|
<a href="/index?filter=all">All books ({{total_books}})</a>
|
|
·
|
|
<a href="/index?filter=want">Want list</a>
|
|
·
|
|
<a href="/index?filter=read">Read ({{reading_count}})</a>
|
|
</div>
|
|
{{/current_filter}}
|
|
```
|
|
|
|
With:
|
|
```mustache
|
|
<div class="filter-bar">
|
|
<a href="/index"{{^current_shelf}} style="font-weight:600"{{/current_shelf}}>All</a>
|
|
{{#shelf_nav}}
|
|
·
|
|
<a href="{{href}}"{{#current}} style="font-weight:600"{{/current}}>{{name}}</a>
|
|
{{/shelf_nav}}
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 2: Update title bar**
|
|
|
|
Replace `{{total_books}} books · {{reading_count}} reading` with `{{total_books}} books · {{shelf_count}} shelves`
|
|
|
|
- [ ] **Step 3: Remove status pill from book rows**
|
|
|
|
In the row div, remove:
|
|
```mustache
|
|
<span class="pill {{status_class}}">{{status_label}}</span>
|
|
```
|
|
|
|
- [ ] **Step 4: Remove `summary_groups` section**
|
|
|
|
Remove the entire summary_groups block.
|
|
|
|
- [ ] **Step 5: Run a quick start test to verify no template errors**
|
|
|
|
Run: `emacs --batch --eval "(setq spine-skip-server-start t)" -l spine.el`
|
|
Expected: loads without error.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add templates/index.mustache
|
|
git commit -m "feat: adapt index template for shelf nav and grouping"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Update `add.mustache` template
|
|
|
|
**Files:**
|
|
- Modify: `templates/add.mustache`
|
|
|
|
- [ ] **Step 1: Replace category field with shelf dropdown**
|
|
|
|
Replace:
|
|
```mustache
|
|
<label>Category <input type="text" name="category" list="categories-list" /></label>
|
|
<datalist id="categories-list">{{#existing_categories}}<option value="{{.}}">{{/existing_categories}}</datalist>
|
|
```
|
|
|
|
With:
|
|
```mustache
|
|
<label>Shelf * <select name="shelf" required>
|
|
<option value="">— select —</option>
|
|
{{#shelves}}<option value="{{.}}">{{.}}</option>{{/shelves}}
|
|
</select></label>
|
|
```
|
|
|
|
Remove the `authors-list` datalist too — we removed it from the model:
|
|
```mustache
|
|
<datalist id="authors-list">{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}</datalist>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add templates/add.mustache
|
|
git commit -m "feat: adapt add template with shelf selector"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Final integration check
|
|
|
|
**Files:**
|
|
- All
|
|
|
|
- [ ] **Step 1: Run the full test suite**
|
|
|
|
Run: `emacs --batch -l test/spine-books-test.el -l test/spine-add-book-test.el -l test/spine-edit-test.el -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"`
|
|
Expected: All PASS.
|
|
|
|
- [ ] **Step 2: Spot-check the full flow loads**
|
|
|
|
Run: `emacs --batch --eval "(setq spine-skip-server-start nil)" -l spine.el --eval "(sleep-for 1)" --eval "(httpd-stop)"`
|
|
Expected: Spine starts and stops without error.
|