From bbd9a455cd1941700ed871b489dd94f956c4ddb1 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Mon, 22 Jun 2026 22:38:14 -0400 Subject: [PATCH] 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) --- spine.el | 89 ++++++++++++++++++++++++++++++++++++++--- templates/book.mustache | 26 +++++++----- test/spine-edit-test.el | 86 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 15 deletions(-) diff --git a/spine.el b/spine.el index e434f07..e515ecc 100644 --- a/spine.el +++ b/spine.el @@ -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)) diff --git a/templates/book.mustache b/templates/book.mustache index bd9d5ba..0f37a03 100644 --- a/templates/book.mustache +++ b/templates/book.mustache @@ -76,26 +76,30 @@
-
+
+ + - {{#shelf_options}} {{name}} {{/shelf_options}} -
-
+ +
+ + - {{#status_options}} {{name}} {{/status_options}} -
+
@@ -132,10 +136,12 @@ {{text}}
{{/notes}} -
- - -
+
+ + + + +
{{/notes}} diff --git a/test/spine-edit-test.el b/test/spine-edit-test.el index c27762b..1b29832 100644 --- a/test/spine-edit-test.el +++ b/test/spine-edit-test.el @@ -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)))