# 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 Spine — Add Book
{{app_title}} · add book
``` - [ ] **Step 2: Verify template file exists** ```bash cat templates/add.mustache | head -3 ``` Expected: `` - [ ] **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 `
` section in `templates/index.mustache` (line 79-82): ```html
{{app_title}} {{total_books}} books · {{reading_count}} reading
``` Replace with: ```html
``` - [ ] **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 ```