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))
+16 -10
View File
@@ -76,26 +76,30 @@
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 18px;">
<div>
<form method="post" action="/edit">
<input type="hidden" name="id" value="{{id}}">
<input type="hidden" name="action" value="shelf">
<label style="font-size: 13px; color: var(--text-secondary); display: block; margin-bottom: 4px;">
<i class="ti ti-folder" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Shelf
</label>
<select>
<select name="value" onchange="this.form.submit()">
{{#shelf_options}}
<option{{#selected}} selected{{/selected}}>{{name}}</option>
{{/shelf_options}}
</select>
</div>
<div>
</form>
<form method="post" action="/edit">
<input type="hidden" name="id" value="{{id}}">
<input type="hidden" name="action" value="status">
<label style="font-size: 13px; color: var(--text-secondary); display: block; margin-bottom: 4px;">
<i class="ti ti-bookmark" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Status
</label>
<select>
<select name="value" onchange="this.form.submit()">
{{#status_options}}
<option{{#selected}} selected{{/selected}}>{{name}}</option>
{{/status_options}}
</select>
</div>
</form>
</div>
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 16px;">
@@ -132,10 +136,12 @@
<span style="flex: 1; font-size: 14px; line-height: 1.5;">{{text}}</span>
</div>
{{/notes}}
<div style="display: flex; gap: 8px;">
<input type="text" placeholder="Add a note…" style="flex: 1;" />
<button style="width: auto; padding: 0 12px; border: 0.5px solid var(--border-secondary); border-radius: var(--radius-md);"><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
</div>
<form method="post" action="/edit" style="display: flex; gap: 8px; margin-top: 14px;">
<input type="hidden" name="id" value="{{id}}">
<input type="hidden" name="action" value="note">
<input type="text" name="text" placeholder="Add a note…" style="flex: 1;" required />
<button type="submit" style="width: auto; padding: 0 12px; border: 0.5px solid var(--border-secondary); border-radius: var(--radius-md);"><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
</form>
</div>
{{/notes}}
+86
View File
@@ -101,3 +101,89 @@
(model (spine-index-model books nil nil)))
(should-not (ht-get model "minibuffer"))))
(spine-edit-test--cleanup)))
(defun spine-edit-test--two-shelf-setup ()
"Create a test Org file with two shelves and a book on the first."
(with-temp-file spine-org-file
(insert "* Fiction\n** WANT Edit Test Book\n:PROPERTIES:\n:AUTHOR: Test Author\n:ID: test-0001\n:END:\n* Science Fiction\n"))
t)
(ert-deftest spine-move-book-moves-to-target-shelf ()
"spine-move-book moves the book to the target shelf."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--two-shelf-setup)
(spine-move-book "test-0001" "Science Fiction")
(let* ((books (spine-books))
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should book)
(should (equal (plist-get book :shelf) "Science Fiction"))))
(spine-edit-test--cleanup)))
(ert-deftest spine-move-book-keeps-properties ()
"spine-move-book preserves book properties after move."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--two-shelf-setup)
(spine-move-book "test-0001" "Science Fiction")
(let* ((books (spine-books))
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should book)
(should (equal (plist-get book :title) "WANT Edit Test Book"))
(should (equal (plist-get book :author) "Test Author"))))
(spine-edit-test--cleanup)))
(ert-deftest spine-move-book-errors-on-missing-book ()
"spine-move-book errors when book not found."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--two-shelf-setup)
(should-error (spine-move-book "nonexistent" "Science Fiction")))
(spine-edit-test--cleanup)))
(ert-deftest spine-move-book-errors-on-missing-shelf ()
"spine-move-book errors when target shelf not found."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--two-shelf-setup)
(should-error (spine-move-book "test-0001" "Nonexistent Shelf")))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-status-changes-todo ()
"spine-set-status changes the TODO keyword."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(spine-set-status "test-0001" "reading")
(let* ((books (spine-books))
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should book)
(should (equal (plist-get book :title) "READING Edit Test Book"))))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-status-clears-todo ()
"spine-set-status with empty string removes TODO keyword."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(spine-set-status "test-0001" "")
(let* ((books (spine-books))
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should book)
(should (equal (plist-get book :title) "Edit Test Book"))))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-status-errors-on-missing-book ()
"spine-set-status errors when book not found."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(should-error (spine-set-status "nonexistent" "reading")))
(spine-edit-test--cleanup)))