feat: wire book detail page with working shelf/status moves and note posting

- Add spine-move-book: move book between shelves by cutting/pasting headline
- Add spine-set-status: change TODO keyword (WANT/READING/READ/none)
- Add action=shelf and action=status cases to httpd/edit POST handler
- Wrap shelf/status selects in forms with onchange auto-submit to /edit
- Wire note composer as POST form to /edit with action=note
- Redirect all edit POSTs back to /book?id=X
- 7 new tests for move-book and set-status (error handling, property preservation)
This commit is contained in:
2026-06-22 22:38:14 -04:00
parent cf511ac190
commit bbd9a455cd
3 changed files with 186 additions and 15 deletions
+84 -5
View File
@@ -390,6 +390,81 @@ Signals an error if the book is not found."
(when (eq (point) (point-min))
(error "spine-set-rating: no book found with ID %s" id)))
(kill-buffer)))))
(defun spine-move-book (id target-shelf)
"Move book with ID to TARGET-SHELF.
The book's level-2 headline is moved under the level-1 TARGET-SHELF headline.
Signals an error if the book or target shelf is not found."
(let ((org-file (expand-file-name spine-org-file))
(shelf-re (format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote target-shelf))))
(unless (file-exists-p org-file)
(error "spine-move-book: file not found: %s" org-file))
(with-current-buffer (find-file-noselect org-file)
(unwind-protect
(let (book-begin book-end book-text)
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 2)
(let ((hl-id (spine--prop (org-element-property :begin hl) "ID")))
(when (equal id hl-id)
(setq book-begin (org-element-property :begin hl))
(setq book-end (org-element-property :end hl))))))
nil 'headline)
(unless book-begin
(error "spine-move-book: no book found with ID %s" id))
(setq book-text (buffer-substring-no-properties book-begin book-end))
(delete-region book-begin book-end)
;; Re-find target shelf (positions shifted after deletion)
(goto-char (point-min))
(let ((shelf-found nil))
(while (and (not shelf-found)
(re-search-forward shelf-re nil t))
(when (= (org-current-level) 1)
(setq shelf-found t)))
(unless shelf-found
(error "spine-move-book: shelf \"%s\" not found" target-shelf))
(org-end-of-subtree)
(skip-chars-backward " \t\n")
(insert "\n")
(insert book-text)
(unless (bolp) (insert "\n"))
(save-buffer)
t))
(kill-buffer)))))
(defun spine-set-status (id new-status)
"Change the TODO keyword of book with ID to NEW-STATUS.
NEW-STATUS is one of \"want\", \"reading\", \"read\", or \"\" to remove.
Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file))
(new-kw (car (rassoc new-status
'(("WANT" . "want") ("READING" . "reading") ("READ" . "read"))))))
(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) 2)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char pos)
(let* ((raw (org-get-heading t t t t))
(clean (if (string-match
"\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'"
raw)
(match-string 2 raw)
raw)))
(org-edit-headline
(if new-kw (format "%s %s" new-kw clean) clean)))))))
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)))))
(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."
@@ -540,13 +615,13 @@ Set by test harnesses that only need the model functions.")
(defun httpd/edit (proc uri-path query request)
"Handle /edit: GET shows prompt page, POST processes actions.
"Handle /edit: POST processes actions; GET shows prompt pages.
Query params:
id - book ID
action - note | rating
action - note | rating | shelf | status
text - note text (for note action)
date - optional date string (for note action)
value - rating value (for rating action)"
value - rating value / shelf name / status key (for rating/shelf/status)"
(let ((method (caar request))
(id (cadr (assoc "id" query)))
(action (cadr (assoc "action" query)))
@@ -559,8 +634,12 @@ Query params:
((equal action "note")
(spine-add-note id text date))
((equal action "rating")
(spine-set-rating id value)))
(httpd-redirect proc (format "/index?id=%s" id) 303))
(spine-set-rating id value))
((equal action "shelf")
(spine-move-book id value))
((equal action "status")
(spine-set-status id value)))
(httpd-redirect proc (format "/book?id=%s" id) 303))
;; GET: show prompt page for actions needing input
(if (and id (equal action "note"))
(let* ((books (spine-books))