diff --git a/docs/plans/2026-06-21-edit-book-actions.md b/docs/plans/2026-06-21-edit-book-actions.md new file mode 100644 index 0000000..c8da94a --- /dev/null +++ b/docs/plans/2026-06-21-edit-book-actions.md @@ -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 +" + + + +Spine — note + + + +
+
+
spine · add note
+

%s

+
+ + + + + +
+
+
+ +" + 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}} + + {{/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 +```