Files
spine/docs/plans/2026-06-21-add-book-form.md
T

16 KiB

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

<!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
cat templates/add.mustache | head -3

Expected: <!DOCTYPE html>

  • Step 3: Commit
git add templates/add.mustache
git commit -m "feat: add add-book form template"

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):

    <header>
      <strong>{{app_title}}</strong>
      <span class="muted">{{total_books}} books · {{reading_count}} reading</span>
    </header>

Replace with:

    <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
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.

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
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:

(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:

(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
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el

Expected: exits cleanly.

  • Step 4: Commit
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

;;; 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
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
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

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
curl -s http://localhost:8080/add | grep -c "Add book"

Expected: 4 (title, button, etc.)

  • Step 3: Verify "Add book" link on index
curl -s http://localhost:8080/index | grep -c "Add book"

Expected: 1

  • Step 4: Submit a new book via form
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
curl -s http://localhost:8080/index | grep "Neuromancer"

Expected: Neuromancer visible in output.

  • Step 6: Stop server
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

# 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
git status --short
git log --oneline -4