docs: add edit book actions implementation plan

This commit is contained in:
2026-06-21 09:40:16 -04:00
parent 41a8ba24d1
commit 17f928ffa8
+600
View File
@@ -0,0 +1,600 @@
# Edit Book Actions Implementation Plan
**Goal:** Allow users to change a book's status, add reading notes, and set ratings
from the agenda view via context-sensitive minibuffer actions.
**Architecture:** New Org write functions (`spine-set-status`, `spine-add-note`,
`spine-set-rating`) follow the same `find-file-noselect` + `save-buffer` + `kill-buffer`
pattern as `spine-add-book`. A single `/edit` handler dispatches based on `action`
query param. The index model gains a `minibuffer` field with action links computed
from the selected book's status.
**Tech Stack:** Emacs Lisp (backend + model), Mustache (template)
---
## File overview
**Modify:**
- `spine.el` — 3 new write functions + `httpd/edit` handler + minibuffer in model
- `templates/index.mustache` — minibuffer renders action links
**Create:**
- `test/spine-edit-test.el` — tests for write functions + model minibuffer
---
### Task 1: Write failing tests for Org write functions
**Files:**
- Create: `test/spine-edit-test.el`
- [ ] **Step 1: Create test file with setup**
```elisp
;;; spine-edit-test.el — ERT tests for spine-edit functions
(require 'ert)
(require 'cl-lib)
(setq spine-skip-server-start t)
(setq spine-org-file (expand-file-name "test-edit-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-edit-test--setup ()
"Create a test Org file with one WANT book."
(spine-add-book :title "Edit Test Book" :author "Test Author"))
(defun spine-edit-test--cleanup ()
"Remove the test Org file."
(ignore-errors (delete-file spine-org-file)))
```
- [ ] **Step 2: Test spine-set-status transitions WANT to READING**
```elisp
(ert-deftest spine-set-status-want-to-reading ()
"spine-set-status changes TODO from WANT to READING."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-status id "READING")
(let ((updated-books (spine-books))
(updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :status) "READING")))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 3: Test spine-set-status transitions READING to READ**
```elisp
(ert-deftest spine-set-status-reading-to-read ()
"spine-set-status changes TODO from READING to READ."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-status id "READING")
(spine-set-status id "READ")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :status) "READ")))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 4: Test spine-add-note appends a note**
```elisp
(ert-deftest spine-add-note-appends ()
"spine-add-note appends a note with date and text."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-add-note id "Great book!" "2026-06-21")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(let ((notes (plist-get updated :notes)))
(should notes)
(should (equal (caar notes) "2026-06-21"))
(should (equal (cadar notes) "Great book!"))))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 5: Test spine-add-note defaults to today's date**
```elisp
(ert-deftest spine-add-note-default-date ()
"spine-add-note uses today's date when none provided."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id))
(today (format-time-string "%Y-%m-%d")))
(spine-add-note id "Note without date")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(let ((notes (plist-get updated :notes)))
(should notes)
(should (equal (caar notes) today))
(should (equal (cadar notes) "Note without date"))))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 6: Test spine-set-rating**
```elisp
(ert-deftest spine-set-rating-sets-rating ()
"spine-set-rating sets the RATING property."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-rating id "4")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :rating) "4")))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 7: Test spine-set-status with missing ID signals error**
```elisp
(ert-deftest spine-set-status-missing-id ()
"spine-set-status signals error for unknown ID."
(should-error (spine-set-status "nonexistent-id" "READING")))
```
- [ ] **Step 8: Run tests to verify they fail**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all tests fail because `spine-set-status` etc. are not defined yet.
- [ ] **Step 9: Commit test file**
```bash
git add test/spine-edit-test.el
git commit -m "test: add failing tests for spine-set-status, spine-add-note, spine-set-rating"
```
---
### Task 2: Implement Org write functions
**Files:**
- Modify: `spine.el` (insert after `spine-add-book`, before `spine--extract-notes`)
- [ ] **Step 1: Add spine-set-status**
Insert after line 251 (end of `spine-add-book`):
```elisp
(defun spine-set-status (id status)
"Set the TODO state of book with ID to STATUS.
STATUS should be one of: WANT, READING, READ, ABANDONED.
Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file)))
(unless (file-exists-p org-file)
(error "spine-set-status: file not found: %s" org-file))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(progn
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 1)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char pos)
(org-todo status)))))
nil 'headline)
(unless (eq (point) (point-min))
(save-buffer)
t)
(when (eq (point) (point-min))
(error "spine-set-status: no book found with ID %s" id)))
(kill-buffer)))))
```
- [ ] **Step 2: Run tests to verify spine-set-status passes**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: spine-set-status tests pass, spine-add-note and spine-set-rating still fail.
- [ ] **Step 3: Add spine-add-note**
Insert after `spine-set-status`:
```elisp
(defun spine-add-note (id text &optional date)
"Append a reading note to the book with ID.
TEXT is the note content. DATE is an optional YYYY-MM-DD string
\(defaults to today). Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file))
(date-str (or date (format-time-string "%Y-%m-%d"))))
(unless (file-exists-p org-file)
(error "spine-add-note: file not found: %s" org-file))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(progn
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 1)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char (org-entry-end-position))
(insert (format "\n- [%s] %s\n" date-str text))))))
nil 'headline)
(unless (eq (point) (point-min))
(save-buffer)
t)
(when (eq (point) (point-min))
(error "spine-add-note: no book found with ID %s" id)))
(kill-buffer)))))
```
- [ ] **Step 4: Run tests to verify spine-add-note passes**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: spine-set-status and spine-add-note tests pass, spine-set-rating still fails.
- [ ] **Step 5: Add spine-set-rating**
Insert after `spine-add-note`:
```elisp
(defun spine-set-rating (id rating)
"Set the RATING property of book with ID to RATING (1-5 string).
Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file)))
(unless (file-exists-p org-file)
(error "spine-set-rating: file not found: %s" org-file))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(progn
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 1)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char pos)
(org-set-property "RATING" rating)))))
nil 'headline)
(unless (eq (point) (point-min))
(save-buffer)
t)
(when (eq (point) (point-min))
(error "spine-set-rating: no book found with ID %s" id)))
(kill-buffer)))))
```
- [ ] **Step 6: Run all edit tests to verify they pass**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all 6 tests pass.
- [ ] **Step 7: Run existing tests to confirm nothing broke**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-index-model-test.el \
-l test/spine-books-test.el \
-l test/spine-add-book-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all pass.
- [ ] **Step 8: Commit**
```bash
git add spine.el
git commit -m "feat: add spine-set-status, spine-add-note, spine-set-rating"
```
---
### Task 3: Extend spine-index-model with minibuffer actions
**Files:**
- Modify: `spine.el``spine-index-model` function
- [ ] **Step 1: Add minibuffer computation to per-book model**
In the per-book model building block (around line 158, after `"selected" selected`), add the minibuffer actions when the book is selected. Insert after the `detail` ht block and before closing the model ht:
Inside the `(let* (... (model (ht ...)))` block, after the `"detail"` key-value pair and before the closing `)))` of the let*, add:
```elisp
("minibuffer"
(when selected
(let ((status (plist-get book :status)))
(ht ("actions"
(append
(cond
((equal status "WANT")
(list
(ht ("command" "status") ("label" "Mark reading")
("href" (format "/edit?id=%s&action=status&value=READING" id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" id)))))
((equal status "READING")
(list
(ht ("command" "status") ("label" "Mark read")
("href" (format "/edit?id=%s&action=status&value=READ" id)))
(ht ("command" "status") ("label" "Abandon")
("href" (format "/edit?id=%s&action=status&value=ABANDONED" id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" id)))))
((equal status "READ")
(list
(ht ("command" "status") ("label" "Read again")
("href" (format "/edit?id=%s&action=status&value=READING" id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" id)))))
((equal status "ABANDONED")
(list
(ht ("command" "status") ("label" "Try again")
("href" (format "/edit?id=%s&action=status&value=READING" id)))))
(t nil))
nil))))))
```
- [ ] **Step 2: Write a test for minibuffer model**
Append to `test/spine-edit-test.el`:
```elisp
(ert-deftest spine-edit-model-minibuffer-want ()
"Selected WANT book has Mark reading and Add note actions."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id))
(model (spine-index-model books nil id))
(groups (ht-get model "groups")))
(cl-labels ((find-book-model (groups id)
(cl-block nil
(dolist (g groups)
(dolist (b (ht-get g "books"))
(when (string= (ht-get b "title") "Edit Test Book")
(cl-return b)))))))
(let ((bm (find-book-model groups id)))
(should bm)
(let ((mb (ht-get bm "minibuffer")))
(should mb)
(let ((actions (ht-get mb "actions")))
(should (= (length actions) 2))
(should (string= (ht-get (nth 0 actions) "label") "Mark reading"))
(should (string= (ht-get (nth 1 actions) "label") "Add note"))))))))
(spine-edit-test--cleanup)))
```
- [ ] **Step 3: Run tests**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
-l test/spine-index-model-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all pass.
- [ ] **Step 4: Commit**
```bash
git add spine.el test/spine-edit-test.el
git commit -m "feat: add minibuffer actions to spine-index-model for selected books"
```
---
### Task 4: Add httpd/edit handler
**Files:**
- Modify: `spine.el` — add handler after `httpd/add`
- [ ] **Step 1: Add httpd/edit handler**
Insert after `httpd/add` (after line 369):
```elisp
(defun httpd/edit (proc uri-path query request)
"Handle /edit: GET shows prompt page, POST processes actions.
Query params:
id - book ID
action - status | note | rating
value - target status / rating value (for status/rating actions)
text - note text (for note action)
date - optional date string (for note/read actions)"
(let ((method (caar request))
(id (cadr (assoc "id" query)))
(action (cadr (assoc "action" query)))
(value (cadr (assoc "value" query)))
(text (cadr (assoc "text" query)))
(date (cadr (assoc "date" query))))
(if (equal method "POST")
(progn
(cond
((equal action "note")
(spine-add-note id text date))
((equal action "status")
(spine-set-status id value))
((equal action "rating")
(spine-set-rating id value)))
(httpd-redirect proc (format "/index?id=%s" id) 303))
;; GET: show prompt page for actions needing input
(if (and id (equal action "note"))
;; Show a simple note-prompt page
(let* ((books (spine-books))
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))
(title (if book (plist-get book :title) "Unknown")))
(httpd-with-buffer proc "text/html"
(insert (format
"<!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 — note</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>spine · add note</strong></header>
<p><em>%s</em></p>
<form method=\"post\" action=\"/edit\">
<input type=\"hidden\" name=\"id\" value=\"%s\"/>
<input type=\"hidden\" name=\"action\" value=\"note\"/>
<label>Date <input type=\"date\" name=\"date\" value=\"%s\"/></label>
<label>Note <textarea name=\"text\" rows=\"3\" required autofocus></textarea></label>
<button type=\"submit\">Add note</button>
</form>
</article>
</main>
</body>
</html>"
title id (format-time-string "%Y-%m-%d"))))))
;; Unknown action or missing id: redirect to index
(httpd-redirect proc "/index" 302)))))
```
- [ ] **Step 2: Test the handler via curl (server test)**
```bash
cd /work/personal-local/spine
SPINE_ORG_FILE=$(pwd)/sample-books.org emacs --batch -l spine.el \
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
--eval "(httpd-start)" 2>&1 &
sleep 2
# Test note prompt page renders
curl -s "http://localhost:8080/edit?id=8c1e-uow&action=note" 2>/dev/null | grep -oP "add note|Use of Weapons|textarea" | sort | uniq -c
```
Expected: shows "add note", "Use of Weapons", "textarea".
- [ ] **Step 3: Kill the server**
```bash
pkill -f "emacs.*spine.el" 2>/dev/null; sleep 1
```
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: add /edit handler with note prompt page"
```
---
### Task 5: Update template minibuffer section
**Files:**
- Modify: `templates/index.mustache`
- [ ] **Step 1: Replace minibuffer placeholder with action links**
Find the existing minibuffer section (lines ~127-133) and replace with:
```mustache
{{#minibuffer}}
<footer class="minibuffer">
<span class="muted">M-x</span>
<span style="display:flex;gap:.5rem;flex-wrap:wrap">
{{#actions}}
<a href="{{href}}" class="pill" style="text-decoration:none">{{label}}</a>
{{/actions}}
</span>
</footer>
{{/minibuffer}}
```
Remove any empty minibuffer template section if it existed (the current template has a minibuffer section with `command`/`text` fields that should be replaced).
- [ ] **Step 2: Verify template renders correctly**
```bash
cd /work/personal-local/spine
emacs --batch --eval '(setq spine-skip-server-start t)' \
--eval '(setq spine-org-file "/work/personal-local/spine/sample-books.org")' \
-l spine.el \
--eval '(let* ((books (spine-books)) (model (spine-index-model books nil "8c1e-uow")) (html (spine-render "index.mustache" model))) (with-temp-file "/tmp/spine-edit-test.html" (insert html)))' 2>&1
grep -oP "M-x|Mark read|Abandon|Add note" /tmp/spine-edit-test.html | sort | uniq -c
```
Expected: shows "M-x", "Mark read", "Abandon", "Add note" (for a READING book).
- [ ] **Step 3: Commit**
```bash
git add templates/index.mustache
git commit -m "feat: update minibuffer to render context-sensitive action links"
```
---
### Task 6: Final integration and test run
- [ ] **Step 1: Run all tests**
```bash
cd /work/personal-local/spine
emacs --batch -l test/spine-edit-test.el \
-l test/spine-index-model-test.el \
-l test/spine-books-test.el \
-l test/spine-add-book-test.el \
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
```
Expected: all tests pass.
- [ ] **Step 2: Push to main**
```bash
git push origin main
```