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:
@@ -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
@@ -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}}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user