Compare commits

...

5 Commits

3 changed files with 318 additions and 4 deletions
+173 -2
View File
@@ -173,7 +173,8 @@ SELECTED-ID expands the matching book's detail section."
(cl-loop for (date text) in notes (cl-loop for (date text) in notes
collect (ht collect (ht
("date" (format "[%s]" date)) ("date" (format "[%s]" date))
("text" text))))))))))) ("text" text))))))))
)))
(push model book-models))) (push model book-models)))
(push (ht ("label" (cdr (assoc status status-labels))) (push (ht ("label" (cdr (assoc status status-labels)))
("count" (length group-books)) ("count" (length group-books))
@@ -184,7 +185,45 @@ SELECTED-ID expands the matching book's detail section."
("reading_count" reading-count) ("reading_count" reading-count)
("current_filter" effective-filter) ("current_filter" effective-filter)
("summary_groups" (nreverse summary-groups)) ("summary_groups" (nreverse summary-groups))
("groups" (nreverse groups)))))) ("groups" (nreverse groups))
("minibuffer"
(when selected-id
(let* ((books-by-id (make-hash-table :test 'equal)))
(dolist (b books)
(puthash (plist-get b :id) b books-by-id))
(let ((book (gethash selected-id books-by-id)))
(when book
(let ((status (or (plist-get book :status) "WANT")))
(ht ("actions"
(append
(cond
((equal status "WANT")
(list
(ht ("command" "status") ("label" "Mark reading")
("href" (format "/edit?id=%s&action=status&value=READING" selected-id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" selected-id)))))
((equal status "READING")
(list
(ht ("command" "status") ("label" "Mark read")
("href" (format "/edit?id=%s&action=status&value=READ" selected-id)))
(ht ("command" "status") ("label" "Abandon")
("href" (format "/edit?id=%s&action=status&value=ABANDONED" selected-id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" selected-id)))))
((equal status "READ")
(list
(ht ("command" "status") ("label" "Read again")
("href" (format "/edit?id=%s&action=status&value=READING" selected-id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" selected-id)))))
((equal status "ABANDONED")
(list
(ht ("command" "status") ("label" "Try again")
("href" (format "/edit?id=%s&action=status&value=READING" selected-id)))))
(t nil))
nil))))))))
)))))
(defun spine-add-book (&rest args) (defun spine-add-book (&rest args)
"Add a new WANT book to `spine-org-file'. "Add a new WANT book to `spine-org-file'.
Keyword arguments: :title :author :category :format :isbn :cover Keyword arguments: :title :author :category :format :isbn :cover
@@ -250,6 +289,81 @@ Signals an error on failure."
t) t)
(kill-buffer)))))) (kill-buffer))))))
(defun spine-set-status (id status)
"Set the TODO state of book with ID to STATUS.
STATUS should be one of: WANT, READING, READ, ABANDONED.
Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file)))
(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) 1)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char pos)
(org-todo status)))))
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-add-note (id text &optional date)
"Append a reading note to the book with ID.
TEXT is the note content. DATE is an optional YYYY-MM-DD string
\(defaults to today). Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file))
(date-str (or date (format-time-string "%Y-%m-%d"))))
(unless (file-exists-p org-file)
(error "spine-add-note: 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) 1)
(let ((hl-id (spine--prop (org-element-property :begin hl) "ID")))
(when (equal id hl-id)
(goto-char (org-element-property :end hl))
(skip-chars-backward " \t\n")
(insert (format "\n- [%s] %s\n" date-str text))))))
nil 'headline)
(unless (eq (point) (point-min))
(save-buffer)
t)
(when (eq (point) (point-min))
(error "spine-add-note: no book found with ID %s" id)))
(kill-buffer)))))
(defun spine-set-rating (id rating)
"Set the RATING property of book with ID to RATING (1-5 string).
Signals an error if the book is not found."
(let ((org-file (expand-file-name spine-org-file)))
(unless (file-exists-p org-file)
(error "spine-set-rating: 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) 1)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char pos)
(org-set-property "RATING" rating)))))
nil 'headline)
(unless (eq (point) (point-min))
(save-buffer)
t)
(when (eq (point) (point-min))
(error "spine-set-rating: 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."
@@ -368,6 +482,63 @@ Set by test harnesses that only need the model functions.")
(ht ("app_title" "spine"))))) (ht ("app_title" "spine")))))
(insert (spine-render "add.mustache" model))))))) (insert (spine-render "add.mustache" model)))))))
(defun httpd/edit (proc uri-path query request)
"Handle /edit: GET shows prompt page, POST processes actions.
Query params:
id - book ID
action - status | note | rating
value - target status / rating value (for status/rating actions)
text - note text (for note action)
date - optional date string (for note/read actions)"
(let ((method (caar request))
(id (cadr (assoc "id" query)))
(action (cadr (assoc "action" query)))
(value (cadr (assoc "value" query)))
(text (cadr (assoc "text" query)))
(date (cadr (assoc "date" query))))
(if (equal method "POST")
(progn
(cond
((equal action "note")
(spine-add-note id text date))
((equal action "status")
(spine-set-status id value))
((equal action "rating")
(spine-set-rating id value)))
(httpd-redirect proc (format "/index?id=%s" id) 303))
;; GET: show prompt page for actions needing input
(if (and id (equal action "note"))
(let* ((books (spine-books))
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))
(title (if book (plist-get book :title) "Unknown")))
(httpd-with-buffer proc "text/html"
(insert (format
"<!DOCTYPE html>
<html lang=\"en\">
<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>
<meta name=\"color-scheme\" content=\"light dark\"/>
<title>Spine — note</title>
<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css\"/>
</head>
<body>
<main class=\"container\">
<article style=\"max-width:560px;margin-inline:auto;\">
<header><strong>spine · add note</strong></header>
<p><em>%s</em></p>
<form method=\"post\" action=\"/edit\">
<input type=\"hidden\" name=\"id\" value=\"%s\"/>
<input type=\"hidden\" name=\"action\" value=\"note\"/>
<label>Date <input type=\"date\" name=\"date\" value=\"%s\"/></label>
<label>Note <textarea name=\"text\" rows=\"3\" required autofocus></textarea></label>
<button type=\"submit\">Add note</button>
</form>
</article>
</main>
</body>
</html>"
title id (format-time-string "%Y-%m-%d"))))))
;; Unknown action or missing id: redirect to index
(httpd-redirect proc "/index" 302))))
(defun httpd/ (proc uri-path query request) (defun httpd/ (proc uri-path query request)
"Redirect root to /index." "Redirect root to /index."
(httpd-redirect proc "/index" 302)) (httpd-redirect proc "/index" 302))
+5 -2
View File
@@ -132,8 +132,11 @@
{{#minibuffer}} {{#minibuffer}}
<footer class="minibuffer"> <footer class="minibuffer">
<span class="muted">M-x</span> <span class="muted">M-x</span>
<span>{{command}}</span> <span style="display:flex;gap:.5rem;flex-wrap:wrap">
<span class="text">{{text}}<span class="caret">▏</span></span> {{#actions}}
<a href="{{href}}" class="pill" style="text-decoration:none">{{label}}</a>
{{/actions}}
</span>
</footer> </footer>
{{/minibuffer}} {{/minibuffer}}
</article> </article>
+140
View File
@@ -0,0 +1,140 @@
;;; spine-edit-test.el — ERT tests for spine-edit functions
(require 'ert)
(require 'cl-lib)
(setq spine-skip-server-start t)
(setq spine-org-file (expand-file-name "test-edit-temp.org"
(file-name-directory
(directory-file-name
(file-name-directory load-file-name)))))
(load-file (expand-file-name "../spine.el"
(file-name-directory load-file-name)))
(defun spine-edit-test--setup ()
"Create a test Org file with one WANT book."
(spine-add-book :title "Edit Test Book" :author "Test Author"))
(defun spine-edit-test--cleanup ()
"Remove the test Org file."
(ignore-errors (delete-file spine-org-file)))
(ert-deftest spine-set-status-want-to-reading ()
"spine-set-status changes TODO from WANT to READING."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-status id "READING")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :status) "READING")))))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-status-reading-to-read ()
"spine-set-status changes TODO from READING to READ."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-status id "READING")
(spine-set-status id "READ")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :status) "READ")))))
(spine-edit-test--cleanup)))
(ert-deftest spine-add-note-appends ()
"spine-add-note appends a note with date and text."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-add-note id "Great book!" "2026-06-21")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(let ((notes (plist-get updated :notes)))
(should notes)
(should (equal (caar notes) "2026-06-21"))
(should (equal (cadar notes) "Great book!"))))))
(spine-edit-test--cleanup)))
(ert-deftest spine-add-note-default-date ()
"spine-add-note uses today's date when none provided."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id))
(today (format-time-string "%Y-%m-%d")))
(spine-add-note id "Note without date")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(let ((notes (plist-get updated :notes)))
(should notes)
(should (equal (caar notes) today))
(should (equal (cadar notes) "Note without date"))))))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-rating-sets-rating ()
"spine-set-rating sets the RATING property."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-rating id "4")
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
(should updated)
(should (equal (plist-get updated :rating) "4")))))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-status-missing-id ()
"spine-set-status signals error for unknown ID."
(should-error (spine-set-status "nonexistent-id" "READING")))
"Selected WANT book has Mark reading and Add note actions."
(spine-edit-test--cleanup)
(ert-deftest spine-edit-model-minibuffer-reading ()
"Selected READING book has Mark read, Abandon, Add note actions."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let* ((books (spine-books))
(book (car books))
(id (plist-get book :id)))
(spine-set-status id "READING")
(let ((model (spine-index-model (spine-books) nil id)))
(let ((mb (ht-get model "minibuffer")))
(should mb)
(let ((actions (ht-get mb "actions")))
(should (= (length actions) 3))
(should (string= (ht-get (nth 0 actions) "label") "Mark read"))
(should (string= (ht-get (nth 1 actions) "label") "Abandon"))
(should (string= (ht-get (nth 2 actions) "label") "Add note")))))))
(spine-edit-test--cleanup)))
(ert-deftest spine-edit-model-minibuffer-nil-when-no-selection ()
"No minibuffer when no book is selected."
(spine-edit-test--cleanup)
(unwind-protect
(progn
(spine-edit-test--setup)
(let ((model (spine-index-model (spine-books))))
(should-not (ht-get model "minibuffer"))))
(spine-edit-test--cleanup)))