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))
|
(when (eq (point) (point-min))
|
||||||
(error "spine-set-rating: no book found with ID %s" id)))
|
(error "spine-set-rating: no book found with ID %s" id)))
|
||||||
(kill-buffer)))))
|
(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)
|
(defun spine--extract-notes (hl-begin hl-end)
|
||||||
"Extract reading notes from headline body between HL-BEGIN and HL-END.
|
"Extract reading notes from headline body between HL-BEGIN and HL-END.
|
||||||
Returns list of (date-string text-string) pairs."
|
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)
|
(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:
|
Query params:
|
||||||
id - book ID
|
id - book ID
|
||||||
action - note | rating
|
action - note | rating | shelf | status
|
||||||
text - note text (for note action)
|
text - note text (for note action)
|
||||||
date - optional date string (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))
|
(let ((method (caar request))
|
||||||
(id (cadr (assoc "id" query)))
|
(id (cadr (assoc "id" query)))
|
||||||
(action (cadr (assoc "action" query)))
|
(action (cadr (assoc "action" query)))
|
||||||
@@ -559,8 +634,12 @@ Query params:
|
|||||||
((equal action "note")
|
((equal action "note")
|
||||||
(spine-add-note id text date))
|
(spine-add-note id text date))
|
||||||
((equal action "rating")
|
((equal action "rating")
|
||||||
(spine-set-rating id value)))
|
(spine-set-rating id value))
|
||||||
(httpd-redirect proc (format "/index?id=%s" id) 303))
|
((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
|
;; GET: show prompt page for actions needing input
|
||||||
(if (and id (equal action "note"))
|
(if (and id (equal action "note"))
|
||||||
(let* ((books (spine-books))
|
(let* ((books (spine-books))
|
||||||
|
|||||||
+16
-10
@@ -76,26 +76,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 18px;">
|
<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;">
|
<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
|
<i class="ti ti-folder" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Shelf
|
||||||
</label>
|
</label>
|
||||||
<select>
|
<select name="value" onchange="this.form.submit()">
|
||||||
{{#shelf_options}}
|
{{#shelf_options}}
|
||||||
<option{{#selected}} selected{{/selected}}>{{name}}</option>
|
<option{{#selected}} selected{{/selected}}>{{name}}</option>
|
||||||
{{/shelf_options}}
|
{{/shelf_options}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</form>
|
||||||
<div>
|
<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;">
|
<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
|
<i class="ti ti-bookmark" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Status
|
||||||
</label>
|
</label>
|
||||||
<select>
|
<select name="value" onchange="this.form.submit()">
|
||||||
{{#status_options}}
|
{{#status_options}}
|
||||||
<option{{#selected}} selected{{/selected}}>{{name}}</option>
|
<option{{#selected}} selected{{/selected}}>{{name}}</option>
|
||||||
{{/status_options}}
|
{{/status_options}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 16px;">
|
<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>
|
<span style="flex: 1; font-size: 14px; line-height: 1.5;">{{text}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{/notes}}
|
{{/notes}}
|
||||||
<div style="display: flex; gap: 8px;">
|
<form method="post" action="/edit" style="display: flex; gap: 8px; margin-top: 14px;">
|
||||||
<input type="text" placeholder="Add a note…" style="flex: 1;" />
|
<input type="hidden" name="id" value="{{id}}">
|
||||||
<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>
|
<input type="hidden" name="action" value="note">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
{{/notes}}
|
{{/notes}}
|
||||||
|
|
||||||
|
|||||||
@@ -101,3 +101,89 @@
|
|||||||
(model (spine-index-model books nil nil)))
|
(model (spine-index-model books nil nil)))
|
||||||
(should-not (ht-get model "minibuffer"))))
|
(should-not (ht-get model "minibuffer"))))
|
||||||
(spine-edit-test--cleanup)))
|
(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