# Spine Data Model & Index Page — Implementation Plan **Goal:** Build the model layer (`spine-books`) and index page view so the app reads a real `spine.org` file and displays books grouped by status. **Architecture:** Model/view split. `spine-books` parses `spine.org` into plists via `org-element` + `org-entry-get`. `spine-render-index` takes those plists and an optional selected ID, returns HTML matching mockup A's class structure. A new `/` handler replaces the `/hello` smoke test. A `spine-skip-server-start` guard lets tests load `spine.el` without starting the HTTP server. **Tech Stack:** Emacs Lisp, `org-element` (built-in), `org-entry-get` (built-in), `ert` (built-in testing). --- ## File structure | File | Responsibility | |------|---------------| | `sample-books.org` | Test data: 5 books across all statuses | | `spine.el` | Modified: new functions, handler, skip-server-start guard | | `test/spine-books-test.el` | ERT tests for `spine-books` | --- ### Task 1: Create sample test data **Files:** - Create: `sample-books.org` - [ ] **Step 1: Write `sample-books.org`** Five books covering all four statuses, plus an entry with minimal properties. ```org #+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a) * WANT The Left Hand of Darkness :PROPERTIES: :AUTHOR: Ursula K. Le Guin :ISBN: 978-0441478125 :COVER: covers/left-hand.jpg :FORMAT: ebook :REC_BY: Alex :REC_NOTE: Le Guin at her most human :RATING: :ADDED: [2026-04-01] :ID: 9a2b-lhd :END: * WANT Dune :PROPERTIES: :AUTHOR: Frank Herbert :ISBN: 978-0441172719 :COVER: covers/dune.jpg :FORMAT: hardcover :REC_BY: Everyone :REC_NOTE: :RATING: :ADDED: [2026-05-10] :ID: 7f3c-dun :END: * WANT Piranesi :PROPERTIES: :AUTHOR: Susanna Clarke :ISBN: :COVER: :FORMAT: :REC_BY: :REC_NOTE: :RATING: :ADDED: [2026-06-01] :ID: 5e8d-pir :END: * READING Use of Weapons :PROPERTIES: :AUTHOR: Iain M. Banks :ISBN: 978-0316029193 :COVER: covers/use-of-weapons.jpg :FORMAT: audiobook :REC_BY: Priya :REC_NOTE: If you liked Player of Games, this one will wreck you :RATING: :ADDED: [2026-02-20] :ID: 8c1e-uow :END: :LOGBOOK: - State "READING" from "WANT" [2026-03-01] :END: - [2026-03-02] The two-track structure is doing something I can't name yet. - [2026-03-07] Zakalwe's competence reads as a wound. - [2026-03-09] The chapter numbering. Oh. * READ Ancillary Justice :PROPERTIES: :AUTHOR: Ann Leckie :ISBN: 978-0316246620 :COVER: covers/ancillary.jpg :FORMAT: ebook :REC_BY: :REC_NOTE: :RATING: 5 :ADDED: [2025-11-15] :ID: 3a1b-aj :END: :LOGBOOK: - State "READ" from "READING" [2026-01-10] - State "READING" from "WANT" [2025-11-20] :END: - [2025-11-22] The pronoun game is a genuinely fresh take on identity. - [2025-12-05] Justice of Toren's multi-body perspective is unsettling. - [2026-01-08] The tea set. Perfect ending. ``` This file exercises: - **WANT books**: 3 (Left Hand, Dune, Piranesi) — tests group header, count, different property densities - **READING books**: 1 (Use of Weapons) — tests notes extraction, recommendation trail - **READ books**: 1 (Ancillary Justice) — tests rating, LOGBOOK - **ABANDONED books**: 0 — tests empty group omission - **Minimal properties**: Piranesi has no ISBN, cover, format, recommendation, or notes - [ ] **Step 2: Verify file exists** Run: `cat sample-books.org | head -3` Expected: `#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)` - [ ] **Step 3: Commit** ```bash git add sample-books.org git commit -m "test: add sample-books.org test data" ``` --- ### Task 2: Add server-start guard to spine.el **Files:** - Modify: `spine.el` - [ ] **Step 1: Add `spine-skip-server-start` variable and wrap server start** Insert after the template rendering section (after line 44, before `;; --- handlers`): ```elisp (defvar spine-skip-server-start nil "When non-nil, do not start the HTTP server. Set by test harnesses that only need the model functions.") ``` Change the server start block (lines 53–58) to: ```elisp ;; --- start ------------------------------------------------------------ (unless spine-skip-server-start (setq httpd-host spine-host) (setq httpd-port spine-port) (httpd-start) (message "Spine listening on http://%s:%d" (or spine-host "localhost") spine-port)) ``` - [ ] **Step 2: Verify batch load with guard** Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el` Expected: exits cleanly, no "Spine listening" message, no errors. - [ ] **Step 3: Verify server still works normally without guard** Run the normal `scripts/run` flow and curl `/hello` — `/hello` still exists at this point since the handler hasn't been replaced yet. ```bash scripts/run 8080 sleep 3 curl -s http://localhost:8080/hello scripts/stop ``` Expected: HTML containing `

Hello from Emacs.

`. - [ ] **Step 4: Commit** ```bash git add spine.el git commit -m "feat: add spine-skip-server-start guard for testing" ``` --- ### Task 3: Implement spine-books model function **Files:** - Modify: `spine.el` - [ ] **Step 1: Add `cl-lib` require** At the top of `spine.el`, after `(require 'ht)`, add: ```elisp (require 'cl-lib) ``` `cl-lib` is built into Emacs and provides `cl-return-from`. - [ ] **Step 2: Add `spine-org-file` config variable** After `spine-port` and `spine-host` defvars (after line 27), add: ```elisp (defvar spine-org-file "~/spine/books.org" "Path to the spine.org data file.") ``` - [ ] **Step 3: Add `spine--h` HTML escaping helper** After `spine-render`, add: ```elisp (defun spine--h (text) "HTML-escape TEXT." (when text (let ((s text)) (setq s (replace-regexp-in-string "&" "&" s)) (setq s (replace-regexp-in-string "<" "<" s)) (setq s (replace-regexp-in-string ">" ">" s)) (setq s (replace-regexp-in-string "\"" """ s)) s))) ``` - [ ] **Step 4: Add `spine--extract-notes` helper** After `spine--h`, add: ```elisp (defun spine--extract-notes (hl-begin hl-end) "Extract reading notes from headline body between HL-BEGIN and HL-END. Returns list of (date-string text-string) pairs." (let ((notes nil)) (save-excursion (goto-char hl-begin) ;; Skip past PROPERTIES and LOGBOOK drawers (while (re-search-forward "^[ \t]*:\\(PROPERTIES\\|LOGBOOK\\):" hl-end t) (re-search-forward "^[ \t]*:END:" hl-end t)) ;; Collect note lines: "- [YYYY-MM-DD] text" (while (re-search-forward "^- \\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)\\] \\(.*\\)" hl-end t) (push (list (match-string 1) (match-string 2)) notes))) (nreverse notes))) ``` - [ ] **Step 5: Add `spine-books` function** After `spine--extract-notes`, add: ```elisp (defun spine-books () "Return a list of plists, one per top-level headline in `spine-org-file'. Returns nil if the file is missing or cannot be parsed." (unless (file-exists-p spine-org-file) (message "spine-books: file not found: %s" spine-org-file) (cl-return-from spine-books nil)) (condition-case err (with-temp-buffer (insert-file-contents spine-org-file) (org-mode) (let ((books nil)) (org-element-map (org-element-parse-buffer 'headline) 'headline (lambda (hl) (when (= (org-element-property :level hl) 1) (let ((pos (org-element-property :begin hl))) (push (list :id (save-excursion (goto-char pos) (org-entry-get nil "ID")) :title (org-element-property :title hl) :status (org-element-property :todo-keyword hl) :author (save-excursion (goto-char pos) (org-entry-get nil "AUTHOR")) :isbn (save-excursion (goto-char pos) (org-entry-get nil "ISBN")) :cover (save-excursion (goto-char pos) (org-entry-get nil "COVER")) :format (save-excursion (goto-char pos) (org-entry-get nil "FORMAT")) :rec_by (save-excursion (goto-char pos) (org-entry-get nil "REC_BY")) :rec_note (save-excursion (goto-char pos) (org-entry-get nil "REC_NOTE")) :rating (save-excursion (goto-char pos) (org-entry-get nil "RATING")) :added (save-excursion (goto-char pos) (org-entry-get nil "ADDED")) :tags (org-element-property :tags hl) :notes (spine--extract-notes (org-element-property :begin hl) (org-element-property :end hl))) books))))) (nreverse books))) (error (message "spine-books: failed to parse %s: %s" spine-org-file (error-message-string err)) nil))) ``` - [ ] **Step 6: Verify batch load still works** Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el` Expected: exits cleanly. - [ ] **Step 7: Verify spine-books returns data from sample file** Run: ```bash emacs --quick --batch \ --eval "(setq spine-skip-server-start t spine-org-file \"sample-books.org\")" \ --load spine.el \ --eval "(progn (let ((books (spine-books))) (message \"Found %d books\" (length books))))" ``` Expected: `Found 5 books` in output. - [ ] **Step 8: Commit** ```bash git add spine.el git commit -m "feat: add spine-books model function" ``` --- ### Task 4: Test spine-books with ERT **Files:** - Create: `test/spine-books-test.el` - [ ] **Step 1: Create `test/` directory** ```bash mkdir -p test ``` - [ ] **Step 2: Write `test/spine-books-test.el`** ```elisp ;;; spine-books-test.el — ERT tests for spine-books (require 'ert) (require 'cl-lib) ;; Load spine.el without starting the server (setq spine-skip-server-start t) (setq spine-org-file (expand-file-name "sample-books.org" (file-name-directory load-file-name))) (load-file (expand-file-name "spine.el" (file-name-directory load-file-name))) (ert-deftest spine-books-parses-sample () "spine-books returns correct plists for sample-books.org." (let ((books (spine-books))) (should books) (should (= (length books) 5)))) (ert-deftest spine-books-first-book-has-all-fields () "Use of Weapons entry has all expected fields." (let* ((books (spine-books)) (uow (cl-find "8c1e-uow" books :key (lambda (b) (plist-get b :id)) :test #'equal))) (should uow) (should (equal (plist-get uow :title) "Use of Weapons")) (should (equal (plist-get uow :status) "READING")) (should (equal (plist-get uow :author) "Iain M. Banks")) (should (equal (plist-get uow :isbn) "978-0316029193")) (should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg")) (should (equal (plist-get uow :format) "audiobook")) (should (equal (plist-get uow :rec_by) "Priya")) (should (equal (plist-get uow :rec_note) "If you liked Player of Games, this one will wreck you")) (should (equal (plist-get uow :tags) '("scifi" "literary"))) (should (equal (plist-get uow :notes) '(("2026-03-02" "The two-track structure is doing something I can't name yet.") ("2026-03-07" "Zakalwe's competence reads as a wound.") ("2026-03-09" "The chapter numbering. Oh.")))))) (ert-deftest spine-books-missing-properties-are-nil () "Piranesi entry has nil for non-present properties." (let* ((books (spine-books)) (pir (cl-find "5e8d-pir" books :key (lambda (b) (plist-get b :id)) :test #'equal))) (should pir) (should (equal (plist-get pir :title) "Piranesi")) (should (equal (plist-get pir :status) "WANT")) (should (equal (plist-get pir :author) "Susanna Clarke")) ;; ISBN, cover, format, rec_by, rec_note should all be nil or "" (should-not (plist-get pir :isbn)) (should-not (plist-get pir :cover)) (should-not (plist-get pir :format)) (should-not (plist-get pir :rec_by)) (should-not (plist-get pir :rec_note)) ;; Notes should be empty (should-not (plist-get pir :notes)))) (ert-deftest spine-books-read-book-has-rating () "Ancillary Justice has rating 5." (let* ((books (spine-books)) (aj (cl-find "3a1b-aj" books :key (lambda (b) (plist-get b :id)) :test #'equal))) (should aj) (should (equal (plist-get aj :status) "READ")) (should (equal (plist-get aj :rating) "5")) (should (= (length (plist-get aj :notes)) 3)))) (ert-deftest spine-books-empty-file-returns-empty-list () "Empty Org file returns empty list, not nil." (let* ((tmp-file (make-temp-file "spine-test-" nil ".org")) (spine-org-file tmp-file)) (unwind-protect (let ((books (spine-books))) (should (listp books)) (should (= (length books) 0))) (delete-file tmp-file)))) (ert-deftest spine-books-missing-file-returns-nil () "Non-existent file returns nil." (let ((spine-org-file "/tmp/spine-nonexistent-12345.org")) (should-not (spine-books)))) ``` - [ ] **Step 3: Run the tests** ```bash emacs --quick --batch \ --directory "$(pwd)" \ --load test/spine-books-test.el \ -f ert-run-tests-batch-and-exit ``` Expected: all 6 tests pass. - [ ] **Step 4: Commit** ```bash git add test/spine-books-test.el git commit -m "test: add ERT tests for spine-books" ``` --- ### Task 5: Implement view functions **Files:** - Modify: `spine.el` - [ ] **Step 1: Add `spine-render-index` function** After `spine-books`, add: ```elisp (defun spine-render-index (books &optional selected-id) "Render the index page HTML for BOOKS, expanding the book matching SELECTED-ID." (let ((order '("WANT" "READING" "READ" "ABANDONED")) (grouped (make-hash-table :test 'equal))) ;; Group books by status (dolist (book books) (let* ((status (or (plist-get book :status) "WANT"))) (push book (gethash status grouped)))) ;; Render (with-temp-buffer (insert "\n\n\n") (insert "\n") (insert "\n") (insert "Spine\n\n\n") (insert "
\n") (insert "
Spine
\n") ;; Each group in order (dolist (status order) (let ((group-books (nreverse (gethash status grouped)))) (when group-books (insert (format "
%s (%d)
\n" status (length group-books))) (dolist (book group-books) (let ((selected (equal (plist-get book :id) selected-id))) (insert (format "
\n" (if selected " selected" ""))) ;; Format glyph (let ((fmt (plist-get book :format))) (insert (format "%s\n" (cond ((equal fmt "hardcover") "[H]") ((equal fmt "ebook") "[E]") ((equal fmt "audiobook") "[A]") (t ""))))) ;; Title + author (insert "") (insert (spine--h (plist-get book :title))) (let ((author (plist-get book :author))) (when author (insert (format " %s" (spine--h author))))) (insert "\n") ;; Tags (let ((tags (plist-get book :tags))) (when tags (insert (format "%s\n" (mapconcat (lambda (t) (concat ":" t ":")) tags " "))))) ;; Recommendation trail (let ((rec-by (plist-get book :rec_by)) (rec-note (plist-get book :rec_note))) (when (and rec-by (> (length rec-by) 0)) (insert (format "%s%s\n" (spine--h rec-by) (if (and rec-note (> (length rec-note) 0)) (concat ": " (spine--h rec-note)) ""))))) ;; Status pill (let ((status (or (plist-get book :status) "WANT"))) (insert (format "%s\n" (downcase status) status))) ;; Expanded detail (when selected (insert "
\n") (dolist (note (plist-get book :notes)) (insert (format "
[%s] %s
\n" (spine--h (car note)) (spine--h (cadr note))))) (let ((rating (plist-get book :rating))) (when (and rating (> (length rating) 0)) (insert (format "
Rating: %s/5
\n" (spine--h rating))))) (let ((isbn (plist-get book :isbn))) (when (and isbn (> (length isbn) 0)) (insert (format "
ISBN: %s
\n" (spine--h isbn))))) (let ((fmt (plist-get book :format))) (when (and fmt (> (length fmt) 0)) (insert (format "
Format: %s
\n" (spine--h fmt))))) (let ((added (plist-get book :added))) (when (and added (> (length added) 0)) (insert (format "
Added: %s
\n" (spine--h added))))) (insert "
\n")) (insert "
\n")))))) (insert "
\n\n\n") (buffer-string)))) ``` - [ ] **Step 2: Add `spine-render-empty-state` function** After `spine-render-index`, add: ```elisp (defun spine-render-empty-state () "Render a page indicating no books file was found." (concat "\n\n\n" "\n" "\n" "Spine\n\n\n" "
\n" "
Spine
\n" "
No books yet.
\n" "
\n\n\n")) ``` - [ ] **Step 3: Verify batch load** Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el` Expected: exits cleanly, no errors. - [ ] **Step 4: Commit** ```bash git add spine.el git commit -m "feat: add spine-render-index and empty-state view functions" ``` --- ### Task 6: Wire up the index handler **Files:** - Modify: `spine.el` - [ ] **Step 1: Replace `/hello` handler with `/` index handler** Replace the `hello` handler (lines 48–51) with: ```elisp (defservlet index text/html () (let ((books (spine-books))) (if books (insert (spine-render-index books (ht-get (ht-from-query-string (httpd-query-string)) "id"))) (insert (spine-render-empty-state))))) ``` - [ ] **Step 2: Verify batch load** Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el` Expected: exits cleanly. - [ ] **Step 3: Commit** ```bash git add spine.el git commit -m "feat: wire up / index handler replacing /hello" ``` --- ### Task 7: Smoke test end-to-end **Files:** - No changes — verification only. - [ ] **Step 1: Start server with sample data** ```bash emacs --quick \ --eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \ --load spine.el & EMACS_PID=$! sleep 5 ``` - [ ] **Step 2: Verify index page returns HTML with real data** ```bash curl -s http://localhost:8080/ | head -20 ``` Expected: HTML document with: - `Spine` - `
WANT (3)
` - `
READING (1)
` - `
READ (1)
` - No `ABANDONED` group (zero books) - "Use of Weapons" visible in output - Recommendation trail "Priya: If you liked..." - [ ] **Step 3: Verify expanded detail via ?id=** ```bash curl -s "http://localhost:8080/?id=8c1e-uow" | grep -A5 "detail" ``` Expected: expanded detail section with reading notes for Use of Weapons: - `[2026-03-02] The two-track structure...` - `[2026-03-07] Zakalwe's competence...` - `[2026-03-09] The chapter numbering. Oh.` - [ ] **Step 4: Verify bogus ?id= does not crash** ```bash curl -s "http://localhost:8080/?id=nonexistent" ``` Expected: normal index page, no expanded detail, HTTP 200. - [ ] **Step 5: Verify empty state when file is missing** ```bash kill $EMACS_PID 2>/dev/null sleep 1 emacs --quick \ --eval "(setq spine-org-file \"/tmp/nonexistent-books.org\")" \ --load spine.el & EMACS_PID=$! sleep 5 curl -s http://localhost:8080/ ``` Expected: HTML containing `No books yet.` - [ ] **Step 6: Stop server** ```bash kill $EMACS_PID 2>/dev/null ``` - [ ] **Step 7: Verify with scripts/run** ```bash # Override spine-org-file via the run script (pass it as an extra eval) SPINE_ORG="$(pwd)/sample-books.org" emacs --quick --daemon=spine \ --eval "(setq spine-org-file \"$SPINE_ORG\")" \ --load spine.el sleep 3 curl -s http://localhost:8080/ | grep -c "WANT" # Expected: 1 (the group header "WANT (3)") emacsclient --socket-name=spine --eval '(kill-emacs)' ``` The output should contain `1`. --- ### Task 8: Final verification and cleanup **Files:** - No changes — verification only. - [ ] **Step 1: Run full ERT test suite** ```bash emacs --quick --batch \ --directory "$(pwd)" \ --load test/spine-books-test.el \ -f ert-run-tests-batch-and-exit ``` Expected: all 6 tests pass. - [ ] **Step 2: Verify git status is clean** ```bash git status ``` Expected: `nothing to commit, working tree clean` - [ ] **Step 3: Review commit log** ```bash git log --oneline ``` Expected: ~7 commits covering sample data, server guard, model, tests, view, handler.