diff --git a/docs/plans/2026-06-21-add-book-form.md b/docs/plans/2026-06-21-add-book-form.md new file mode 100644 index 0000000..37b8d79 --- /dev/null +++ b/docs/plans/2026-06-21-add-book-form.md @@ -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 + + + + + + +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 +```