Compare commits
9 Commits
7faf0cd8ac
...
b6b0539b82
| Author | SHA1 | Date | |
|---|---|---|---|
| b6b0539b82 | |||
| 16bbccf67b | |||
| 9259c1139d | |||
| da8a20a7e1 | |||
| 9aacccb2ee | |||
| a8ecf952c6 | |||
| a12e57ca9b | |||
| 72f2f053a1 | |||
| 62638382ac |
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
# Spine — Add Book Form
|
||||
|
||||
Adds a form to create new books in `spine.org`, plus the write function
|
||||
to persist them.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
"Add book" link (index page)
|
||||
│
|
||||
GET /add
|
||||
│
|
||||
templates/add.mustache ←── [spine-index-model] for datalist values
|
||||
│
|
||||
POST /add (form submission)
|
||||
│
|
||||
[spine-add-book] ──→ writes to spine.org
|
||||
│
|
||||
redirect to /index
|
||||
```
|
||||
|
||||
## Template: `templates/add.mustache`
|
||||
|
||||
A new Mustache template with the Pico CSS + Tabler Icons shell (matching the
|
||||
index page) and a `<form method="post" action="/add">`.
|
||||
|
||||
**View model:**
|
||||
|
||||
```json
|
||||
{
|
||||
"app_title": "spine",
|
||||
"existing_authors": ["Ursula K. Le Guin", "Frank Herbert", ...],
|
||||
"existing_categories": ["scifi", "literary", ...]
|
||||
}
|
||||
```
|
||||
|
||||
`existing_authors` and `existing_categories` are extracted from the current
|
||||
`spine-books` results. Empty lists are fine — the `<datalist>` just has no
|
||||
options.
|
||||
|
||||
**Form fields:**
|
||||
|
||||
| Field | Input type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| date_added | `<input type="date">` | no | defaults to today |
|
||||
| title | `<input type="text">` | **yes** | |
|
||||
| author | `<input type="text" list="authors">` | no | datalist from existing |
|
||||
| category | `<input type="text" list="categories">` | no | datalist from existing tags |
|
||||
| format | `<select>` | no | hardcover / ebook / audiobook / — |
|
||||
| isbn | `<input type="text">` | no | |
|
||||
| cover | `<input type="text">` | no | path relative to spine.org |
|
||||
| rec_by | `<input type="text">` | no | recommender name |
|
||||
| rec_note | `<input type="text">` | no | why it's on your radar |
|
||||
| initial_note | `<textarea>` | no | becomes first reading note |
|
||||
|
||||
**Datalists** for author and category:
|
||||
|
||||
```html
|
||||
<input type="text" name="author" list="authors-list" />
|
||||
<datalist id="authors-list">
|
||||
{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}
|
||||
</datalist>
|
||||
```
|
||||
|
||||
"Add book" link on the index page (in `templates/index.mustache`) — placed in
|
||||
the `<header>` alongside the title:
|
||||
|
||||
```html
|
||||
<a role="button" href="/add">Add book</a>
|
||||
```
|
||||
|
||||
## Write function: `spine-add-book`
|
||||
|
||||
```elisp
|
||||
(spine-add-book &key title author category format isbn cover
|
||||
rec_by rec_note date_added initial_note)
|
||||
→ t on success, signals error on failure
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
1. Visits `spine-org-file` in a temporary buffer
|
||||
2. Inserts a new top-level headline with TODO keyword `WANT`
|
||||
3. Sets properties: `:AUTHOR:`, `:FORMAT:`, `:ISBN:`, `:COVER:`, `:REC_BY:`, `:REC_NOTE:`, `:ADDED:`, `:ID:`
|
||||
4. Sets tags if category provided
|
||||
5. Appends initial note as `- [DATE] note text` if provided
|
||||
6. Saves the buffer
|
||||
7. Returns `t`
|
||||
|
||||
**`:ID:` generation:** short random alphanumeric, e.g. `(format "%04x-%04x" (random 65535) (random 65535))`.
|
||||
|
||||
**`:ADDED:` date:** if `date_added` is provided, format as `[YYYY-MM-DD]`. If nil, omit the property.
|
||||
|
||||
**Properties with nil/empty values are omitted** — only set properties that have non-empty values. This keeps the Org file clean.
|
||||
|
||||
## Handler: `/add`
|
||||
|
||||
```elisp
|
||||
(defservlet add text/html (path query request)
|
||||
(let ((method (car (car request))))
|
||||
(if (equal method "POST")
|
||||
;; Process form submission
|
||||
(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))
|
||||
;; GET: show the form
|
||||
(let ((model (spine-add-form-model (spine-books))))
|
||||
(insert (spine-render "add.mustache" model))))))
|
||||
```
|
||||
|
||||
`spine-add-form-model` builds the view model from `spine-books` — extracts
|
||||
unique authors and tags into flat lists.
|
||||
|
||||
**Form GET access:** `http://localhost:8080/add`
|
||||
|
||||
**Empty-books case:** if `spine-books` returns nil, the datalists are empty —
|
||||
the form still works, you just get no suggestions.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Title missing | redirect to `/add` with error message (query param) |
|
||||
| Write fails | `spine-add-book` signals error, handler catches and shows error page |
|
||||
| `spine.org` missing | `spine-add-book` creates a new file with the `#+TODO` header and first entry |
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `templates/add.mustache` | New — add-book form template |
|
||||
| `templates/index.mustache` | Modified — add "Add book" link in header |
|
||||
| `spine.el` | New functions: `spine-add-book`, `spine-add-form-model`. New handler: `/add`. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Validation beyond title required
|
||||
- Edit existing book
|
||||
- Delete book
|
||||
- Cover image upload (text path only)
|
||||
- Multi-category input (single category string → single tag for now)
|
||||
- Error display on the form page (redirect on error, no inline messages)
|
||||
|
||||
## Styling
|
||||
|
||||
Uses Pico CSS form styles (`<input>`, `<select>`, `<textarea>`) which are
|
||||
styled automatically by the Pico framework. No additional custom CSS needed
|
||||
for the form — Pico handles layout, spacing, and theme.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] "Add book" link visible on index page
|
||||
- [ ] `GET /add` shows form with Pico styling
|
||||
- [ ] Author and category fields have datalist suggestions from existing data
|
||||
- [ ] Submitting the form creates a new `* WANT Title` headline in `spine.org`
|
||||
- [ ] Properties are set correctly (title, author, format, ISBN, etc.)
|
||||
- [ ] Initial note appears as `- [YYYY-MM-DD] text` in the body
|
||||
- [ ] Empty optional fields are omitted (no empty properties)
|
||||
- [ ] After submission, redirect to `/index` and new book is visible
|
||||
- [ ] ERT tests still pass (model layer unchanged)
|
||||
+2
-2
@@ -26,7 +26,7 @@
|
||||
:ID: 7f3c-dun
|
||||
:END:
|
||||
|
||||
* WANT Piranesi
|
||||
* WANT Piranesi :fiction:
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Susanna Clarke
|
||||
:ISBN:
|
||||
@@ -58,7 +58,7 @@
|
||||
- [2026-03-07] Zakalwe's competence reads as a wound.
|
||||
- [2026-03-09] The chapter numbering. Oh.
|
||||
|
||||
* READ Ancillary Justice
|
||||
* READ Ancillary Justice :scifi:
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Ann Leckie
|
||||
:ISBN: 978-0316246620
|
||||
|
||||
@@ -57,6 +57,23 @@ Returns the rendered string."
|
||||
(setq s (replace-regexp-in-string "\"" """ s))
|
||||
s)))
|
||||
|
||||
|
||||
(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<)))))
|
||||
|
||||
(defun spine-index-model (books &optional selected-id)
|
||||
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||
SELECTED-ID expands the matching book's detail section."
|
||||
@@ -137,6 +154,71 @@ SELECTED-ID expands the matching book's detail section."
|
||||
("reading_count" reading-count)
|
||||
("groups" (nreverse groups))))))
|
||||
|
||||
(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 (number-to-string (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))))))
|
||||
|
||||
(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."
|
||||
@@ -230,6 +312,30 @@ Set by test harnesses that only need the model functions.")
|
||||
(spine-index-model books (cadr (assoc "id" query)))))
|
||||
(insert (spine-render-empty-state)))))
|
||||
|
||||
(defun httpd/add (proc uri-path query request)
|
||||
"Handle /add: GET shows form, POST creates a book."
|
||||
(let ((method (caar request)))
|
||||
(if (equal method "POST")
|
||||
(progn
|
||||
(spine-add-book
|
||||
:title (cadr (assoc "title" query))
|
||||
:author (cadr (assoc "author" query))
|
||||
:category (cadr (assoc "category" query))
|
||||
:format (cadr (assoc "format" query))
|
||||
:isbn (cadr (assoc "isbn" query))
|
||||
:cover (cadr (assoc "cover" query))
|
||||
:rec_by (cadr (assoc "rec_by" query))
|
||||
:rec_note (cadr (assoc "rec_note" query))
|
||||
:date_added (cadr (assoc "date_added" query))
|
||||
:initial_note (cadr (assoc "initial_note" query)))
|
||||
(httpd-redirect proc "/index" 303))
|
||||
(httpd-with-buffer proc "text/html"
|
||||
(let* ((books (spine-books))
|
||||
(model (if books
|
||||
(spine-add-form-model books)
|
||||
(ht ("app_title" "spine")))))
|
||||
(insert (spine-render "add.mustache" model)))))))
|
||||
|
||||
(defun httpd/ (proc uri-path query request)
|
||||
"Redirect root to /index."
|
||||
(httpd-redirect proc "/index" 302))
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<!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>
|
||||
@@ -77,8 +77,13 @@
|
||||
<main class="container">
|
||||
<article class="agenda" style="max-width: 720px; margin-inline: auto;">
|
||||
<header>
|
||||
<strong>{{app_title}}</strong>
|
||||
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
|
||||
<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>
|
||||
|
||||
{{#groups}}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
;;; 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
|
||||
(directory-file-name
|
||||
(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)))
|
||||
Reference in New Issue
Block a user