Compare commits
5 Commits
17f928ffa8
...
c505a3c239
| Author | SHA1 | Date | |
|---|---|---|---|
| c505a3c239 | |||
| 6e821de7b1 | |||
| 719751cc84 | |||
| 95dc4b5db4 | |||
| 8c8b004500 |
@@ -173,7 +173,8 @@ SELECTED-ID expands the matching book's detail section."
|
||||
(cl-loop for (date text) in notes
|
||||
collect (ht
|
||||
("date" (format "[%s]" date))
|
||||
("text" text)))))))))))
|
||||
("text" text))))))))
|
||||
)))
|
||||
(push model book-models)))
|
||||
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||
("count" (length group-books))
|
||||
@@ -184,7 +185,45 @@ SELECTED-ID expands the matching book's detail section."
|
||||
("reading_count" reading-count)
|
||||
("current_filter" effective-filter)
|
||||
("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)
|
||||
"Add a new WANT book to `spine-org-file'.
|
||||
Keyword arguments: :title :author :category :format :isbn :cover
|
||||
@@ -250,6 +289,81 @@ Signals an error on failure."
|
||||
t)
|
||||
(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)
|
||||
"Extract reading notes from headline body between HL-BEGIN and HL-END.
|
||||
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")))))
|
||||
(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)
|
||||
"Redirect root to /index."
|
||||
(httpd-redirect proc "/index" 302))
|
||||
|
||||
@@ -132,8 +132,11 @@
|
||||
{{#minibuffer}}
|
||||
<footer class="minibuffer">
|
||||
<span class="muted">M-x</span>
|
||||
<span>{{command}}</span>
|
||||
<span class="text">{{text}}<span class="caret">▏</span></span>
|
||||
<span style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
{{#actions}}
|
||||
<a href="{{href}}" class="pill" style="text-decoration:none">{{label}}</a>
|
||||
{{/actions}}
|
||||
</span>
|
||||
</footer>
|
||||
{{/minibuffer}}
|
||||
</article>
|
||||
|
||||
@@ -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)))
|
||||
Reference in New Issue
Block a user