docs: add add-book form implementation plan

This commit is contained in:
2026-06-21 08:12:49 -04:00
parent 62638382ac
commit 72f2f053a1
+555
View File
@@ -0,0 +1,555 @@
# Add Book Form — Implementation Plan
**Goal:** Add a form at `/add` to create new books in `spine.org`, plus the write function and "Add book" link on the index page.
**Architecture:** Mustache template for the form, `spine-add-book` function writes to the Org file using standard Org commands, `/add` servlet handles both GET (show form) and POST (process submission + redirect).
**Tech Stack:** Emacs Lisp, `org-element` / Org commands, Mustache templates.
---
## File structure
| File | Responsibility |
|------|---------------|
| `templates/add.mustache` | New — form template with Pico CSS + Tabler Icons shell |
| `templates/index.mustache` | Modified — add "Add book" button in header |
| `spine.el` | New functions: `spine-add-book`, `spine-add-form-model`. New handler: `/add`. |
| `test/spine-add-book-test.el` | New — ERT tests for `spine-add-book` |
---
### Task 1: Create the add-book form template
**Files:**
- Create: `templates/add.mustache`
- [ ] **Step 1: Write `templates/add.mustache`**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Spine — Add Book</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
</head>
<body>
<main class="container">
<article style="max-width: 560px; margin-inline: auto;">
<header>
<strong>{{app_title}} · add book</strong>
</header>
<form method="post" action="/add">
<label>
Date added
<input type="date" name="date_added" />
</label>
<label>
Title *
<input type="text" name="title" required autofocus />
</label>
<label>
Author
<input type="text" name="author" list="authors-list" />
<datalist id="authors-list">
{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}
</datalist>
</label>
<label>
Category
<input type="text" name="category" list="categories-list" />
<datalist id="categories-list">
{{#existing_categories}}<option value="{{.}}">{{/existing_categories}}
</datalist>
</label>
<label>
Format
<select name="format">
<option value=""></option>
<option value="hardcover">Hardcover</option>
<option value="ebook">eBook</option>
<option value="audiobook">Audiobook</option>
</select>
</label>
<label>
ISBN
<input type="text" name="isbn" />
</label>
<label>
Cover (path)
<input type="text" name="cover" placeholder="covers/filename.jpg" />
</label>
<label>
Recommended by
<input type="text" name="rec_by" />
</label>
<label>
Why it's on your radar
<input type="text" name="rec_note" />
</label>
<label>
Initial note
<textarea name="initial_note" rows="3" placeholder="First impressions…"></textarea>
</label>
<button type="submit">Add book</button>
</form>
</article>
</main>
</body>
</html>
```
- [ ] **Step 2: Verify template file exists**
```bash
cat templates/add.mustache | head -3
```
Expected: `<!DOCTYPE html>`
- [ ] **Step 3: Commit**
```bash
git add templates/add.mustache
git commit -m "feat: add add-book form template"
```
---
### Task 2: Add "Add book" link to index page
**Files:**
- Modify: `templates/index.mustache`
- [ ] **Step 1: Add a link in the index header**
Find the `<header>` section in `templates/index.mustache` (line 79-82):
```html
<header>
<strong>{{app_title}}</strong>
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
</header>
```
Replace with:
```html
<header>
<nav>
<ul><li><strong>{{app_title}}</strong></li></ul>
<ul>
<li><span class="muted">{{total_books}} books · {{reading_count}} reading</span></li>
<li><a role="button" href="/add" class="secondary">Add book</a></li>
</ul>
</nav>
</header>
```
- [ ] **Step 2: Verify the template renders**
```bash
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el \
--eval "(progn (message \"Has Add book: %s\" (if (string-match \"Add book\" (spine-render \"index.mustache\" (spine-index-model (spine-books) nil))) \"yes\" \"NO\")))"
```
(defun spine-add-book (&rest args)
"Add a new WANT book to `spine-org-file'.
Keyword arguments: :title :author :category :format :isbn :cover
:rec_by :rec_note :date_added :initial_note
Signals an error on failure."
(let ((title (plist-get args :title))
(author (plist-get args :author))
(category (plist-get args :category))
(format (plist-get args :format))
(isbn (plist-get args :isbn))
(cover (plist-get args :cover))
(rec-by (plist-get args :rec_by))
(rec-note (plist-get args :rec_note))
(date-added (plist-get args :date_added))
(initial-note (plist-get args :initial_note)))
(unless title
(error "spine-add-book: title is required"))
(let ((org-file (expand-file-name spine-org-file)))
;; Create file if it doesn't exist
(unless (file-exists-p org-file)
(with-temp-file org-file
(insert "#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)\n\n")))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(progn
(goto-char (point-max))
;; Insert new headline
(org-insert-heading nil t)
(insert title)
;; Set TODO state
(org-todo "WANT")
;; Set properties (only non-empty)
(when (and author (> (length author) 0))
(org-set-property "AUTHOR" author))
(when (and format (> (length format) 0))
(org-set-property "FORMAT" format))
(when (and isbn (> (length isbn) 0))
(org-set-property "ISBN" isbn))
(when (and cover (> (length cover) 0))
(org-set-property "COVER" cover))
(when (and rec-by (> (length rec-by) 0))
(org-set-property "REC_BY" rec-by))
(when (and rec-note (> (length rec-note) 0))
(org-set-property "REC_NOTE" rec-note))
(when (and date-added (> (length date-added) 0))
(org-set-property "ADDED" (format "[%s]" date-added)))
;; Set ID
(org-set-property "ID"
(format "%04x-%s"
(random 65535)
(substring (sha1 (concat title (random) "")) 0 4)))
;; Set tags
(when (and category (> (length category) 0))
(org-set-tags (list category)))
;; Add initial note
(when (and initial-note (> (length initial-note) 0))
(let ((date (if (and date-added (> (length date-added) 0))
date-added
(format-time-string "%Y-%m-%d"))))
(goto-char (org-entry-end-position))
(insert (format "\n- [%s] %s\n" date initial-note)))))
(save-buffer)
t)
(kill-buffer))))))
```
- [ ] **Step 2: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly, no errors.
```bash
rm -f /tmp/spine-test-add.org
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"/tmp/spine-test-add.org\")" \
--load spine.el \
--eval "(progn (spine-add-book :title \"Test Book\" :author \"Test Author\" :format \"ebook\" :category \"scifi\" :date_added \"2026-06-21\" :initial_note \"This is a test\") (let ((books (spine-books))) (message \"Book count: %d\" (length books)) (message \"Title: %s\" (plist-get (car books) :title)) (message \"ID: %s\" (plist-get (car books) :id)) (message \"Notes: %S\" (plist-get (car books) :notes))))" 2>&1
```
Expected: `Book count: 1`, `Title: Test Book`, ID present, one note.
- [ ] **Step 4: Clean up and commit**
```bash
rm -f /tmp/spine-test-add.org
git add spine.el
git commit -m "feat: add spine-add-book write function"
```
---
### Task 4: Wire up /add handler and spine-add-form-model
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Add `spine-add-form-model` function**
Insert before `spine-index-model`:
```elisp
(defun spine-add-form-model (books)
"Build the view model ht for templates/add.mustache from BOOKS."
(let ((authors nil)
(categories nil))
(dolist (book books)
(let ((author (plist-get book :author)))
(when (and author (> (length author) 0)
(not (member author authors)))
(push author authors)))
(dolist (tag (or (plist-get book :tags) nil))
(unless (member tag categories)
(push tag categories))))
(ht ("app_title" "spine")
("existing_authors" (sort authors #'string<))
("existing_categories" (sort categories #'string<)))))
```
- [ ] **Step 2: Add `/add` servlet**
Replace or insert after the existing `index` servlet handler:
```elisp
(defservlet add text/html (path query request)
(let ((method (cadr (car request))))
(if (equal method "POST")
(progn
(spine-add-book
:title (cdr (assoc "title" query))
:author (cdr (assoc "author" query))
:category (cdr (assoc "category" query))
:format (cdr (assoc "format" query))
:isbn (cdr (assoc "isbn" query))
:cover (cdr (assoc "cover" query))
:rec_by (cdr (assoc "rec_by" query))
:rec_note (cdr (assoc "rec_note" query))
:date_added (cdr (assoc "date_added" query))
:initial_note (cdr (assoc "initial_note" query)))
(httpd-redirect t "/index" 303))
(let* ((books (spine-books))
(model (if books
(spine-add-form-model books)
(ht ("app_title" "spine")))))
(insert (spine-render "add.mustache" model))))))
```
- [ ] **Step 3: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly.
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: wire up /add handler with form model"
```
---
### Task 5: Test spine-add-book with ERT
**Files:**
- Create: `test/spine-add-book-test.el`
- [ ] **Step 1: Write `test/spine-add-book-test.el`**
```elisp
;;; spine-add-book-test.el — ERT tests for spine-add-book
(require 'ert)
(require 'cl-lib)
(setq spine-skip-server-start t)
(setq spine-org-file (expand-file-name "test-add-temp.org"
(file-name-directory load-file-name)))
(load-file (expand-file-name "../spine.el"
(file-name-directory load-file-name)))
(defun spine-add-book-test--cleanup ()
"Remove the test Org file if it exists."
(ignore-errors (delete-file spine-org-file)))
(ert-deftest spine-add-book-creates-headline ()
"spine-add-book creates a new WANT headline."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Test Book" :author "Author Name")
(let ((books (spine-books)))
(should books)
(should (= (length books) 1))
(let ((book (car books)))
(should (equal (plist-get book :title) "Test Book"))
(should (equal (plist-get book :status) "WANT"))
(should (equal (plist-get book :author) "Author Name"))
(should (plist-get book :id)))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-sets-all-fields ()
"spine-add-book sets all provided fields."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book
:title "Full Test"
:author "Jane Doe"
:format "hardcover"
:isbn "978-1234567890"
:cover "covers/test.jpg"
:rec_by "Friend"
:rec_note "You'll love this"
:date_added "2026-06-21"
:category "scifi"
:initial_note "Looking forward to this")
(let* ((books (spine-books))
(book (car books)))
(should (equal (plist-get book :title) "Full Test"))
(should (equal (plist-get book :author) "Jane Doe"))
(should (equal (plist-get book :format) "hardcover"))
(should (equal (plist-get book :isbn) "978-1234567890"))
(should (equal (plist-get book :cover) "covers/test.jpg"))
(should (equal (plist-get book :rec_by) "Friend"))
(should (equal (plist-get book :rec_note) "You'll love this"))
(should (equal (plist-get book :added) "[2026-06-21]"))
(should (equal (plist-get book :tags) '("scifi")))
(should (equal (plist-get book :notes)
'(("2026-06-21" "Looking forward to this"))))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-omits-empty-fields ()
"spine-add-book does not set empty/omitted fields."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Minimal Book")
(let* ((books (spine-books))
(book (car books)))
(should (equal (plist-get book :title) "Minimal Book"))
(should-not (plist-get book :author))
(should-not (plist-get book :format))
(should-not (plist-get book :isbn))
(should-not (plist-get book :tags))))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-requires-title ()
"spine-add-book signals error when title is missing."
(spine-add-book-test--cleanup)
(unwind-protect
(should-error (spine-add-book :author "No Title"))
(spine-add-book-test--cleanup)))
(ert-deftest spine-add-book-generates-unique-ids ()
"spine-add-book generates different IDs for each book."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Book One")
(spine-add-book :title "Book Two")
(let* ((books (spine-books))
(id1 (plist-get (nth 0 books) :id))
(id2 (plist-get (nth 1 books) :id)))
(should (not (equal id1 id2)))))
(spine-add-book-test--cleanup)))
```
- [ ] **Step 2: Run the tests**
```bash
emacs --quick --batch \
--directory "$(pwd)" \
--load test/spine-add-book-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: all 5 tests pass.
- [ ] **Step 3: Commit**
```bash
git add test/spine-add-book-test.el
git commit -m "test: add ERT tests for spine-add-book"
```
---
### Task 6: Smoke test end-to-end
**Files:**
- No changes — verification only.
- [ ] **Step 1: Start server with temp Org file**
```bash
rm -f /tmp/spine-add-test.org
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
pkill -f "emacs --quick" 2>/dev/null
sleep 1
emacs --quick \
--eval "(setq spine-org-file \"/tmp/spine-add-test.org\")" \
--load spine.el &
sleep 5
```
- [ ] **Step 2: Verify form page loads**
```bash
curl -s http://localhost:8080/add | grep -c "Add book"
```
Expected: `4` (title, button, etc.)
- [ ] **Step 3: Verify "Add book" link on index**
```bash
curl -s http://localhost:8080/index | grep -c "Add book"
```
Expected: `1`
- [ ] **Step 4: Submit a new book via form**
```bash
curl -s -X POST http://localhost:8080/add \
-d "title=Neuromancer" \
-d "author=William Gibson" \
-d "format=ebook" \
-d "category=scifi" \
-d "date_added=2026-06-21" \
-d "initial_note=The sky was the color of television" \
-o /dev/null -w "HTTP %{http_code}\n" \
-L
```
Expected: HTTP 200 (after redirect).
- [ ] **Step 5: Verify new book appears on index**
```bash
curl -s http://localhost:8080/index | grep "Neuromancer"
```
Expected: `Neuromancer` visible in output.
- [ ] **Step 6: Stop server**
```bash
kill %1 2>/dev/null
rm -f /tmp/spine-add-test.org
```
---
### Task 7: Run all tests and final verification
**Files:**
- No changes — verification only.
- [ ] **Step 1: Run all ERT test suites**
```bash
# Run spine-books tests
emacs --quick --batch --directory "$(pwd)" \
--load test/spine-books-test.el \
-f ert-run-tests-batch-and-exit
# Run spine-add-book tests
emacs --quick --batch --directory "$(pwd)" \
--load test/spine-add-book-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: 6/6 + 5/5 = all pass.
- [ ] **Step 2: Git status**
```bash
git status --short
git log --oneline -4
```