Compare commits
7 Commits
6e2a796be9
...
c4248dcc89
| Author | SHA1 | Date | |
|---|---|---|---|
| c4248dcc89 | |||
| 346fd7af5b | |||
| de057de0bf | |||
| e9ea0efa35 | |||
| 6a961b8460 | |||
| 609e168932 | |||
| 858fc6c13b |
+21
-10
@@ -1,6 +1,7 @@
|
|||||||
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
|
#+TITLE: Spine
|
||||||
|
|
||||||
* WANT The Left Hand of Darkness
|
* Fiction
|
||||||
|
** WANT The Left Hand of Darkness
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:AUTHOR: Ursula K. Le Guin
|
:AUTHOR: Ursula K. Le Guin
|
||||||
:ISBN: 978-0441478125
|
:ISBN: 978-0441478125
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
:ID: 9a2b-lhd
|
:ID: 9a2b-lhd
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* WANT Dune
|
** WANT Dune
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:AUTHOR: Frank Herbert
|
:AUTHOR: Frank Herbert
|
||||||
:ISBN: 978-0441172719
|
:ISBN: 978-0441172719
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
:ID: 7f3c-dun
|
:ID: 7f3c-dun
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* WANT Piranesi :fiction:
|
** WANT Piranesi :fiction:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:AUTHOR: Susanna Clarke
|
:AUTHOR: Susanna Clarke
|
||||||
:ISBN:
|
:ISBN:
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
:ID: 5e8d-pir
|
:ID: 5e8d-pir
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
* READING Use of Weapons :scifi:literary:
|
** READING Use of Weapons :scifi:literary:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:AUTHOR: Iain M. Banks
|
:AUTHOR: Iain M. Banks
|
||||||
:ISBN: 978-0316029193
|
:ISBN: 978-0316029193
|
||||||
@@ -55,14 +56,15 @@
|
|||||||
:LAST_MODIFIED: [2026-03-09]
|
:LAST_MODIFIED: [2026-03-09]
|
||||||
:ID: 8c1e-uow
|
:ID: 8c1e-uow
|
||||||
:END:
|
:END:
|
||||||
:LOGBOOK:
|
::LOGBOOK:
|
||||||
- State "READING" from "WANT" [2026-03-01]
|
- State "READING" from "WANT" [2026-03-01]
|
||||||
:END:
|
::END:
|
||||||
- [2026-03-02] The two-track structure is doing something I can't name yet.
|
- [2026-03-02] The two-track structure is doing something I can't name yet.
|
||||||
- [2026-03-07] Zakalwe's competence reads as a wound.
|
- [2026-03-07] Zakalwe's competence reads as a wound.
|
||||||
- [2026-03-09] The chapter numbering. Oh.
|
- [2026-03-09] The chapter numbering. Oh.
|
||||||
|
|
||||||
* READ Ancillary Justice :scifi:
|
* Science Fiction
|
||||||
|
** READ Ancillary Justice :scifi:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:AUTHOR: Ann Leckie
|
:AUTHOR: Ann Leckie
|
||||||
:ISBN: 978-0316246620
|
:ISBN: 978-0316246620
|
||||||
@@ -75,10 +77,19 @@
|
|||||||
:LAST_MODIFIED: [2025-11-15]
|
:LAST_MODIFIED: [2025-11-15]
|
||||||
:ID: 3a1b-aj
|
:ID: 3a1b-aj
|
||||||
:END:
|
:END:
|
||||||
:LOGBOOK:
|
::LOGBOOK:
|
||||||
- State "READ" from "READING" [2026-01-10]
|
- State "READ" from "READING" [2026-01-10]
|
||||||
- State "READING" from "WANT" [2025-11-20]
|
- State "READING" from "WANT" [2025-11-20]
|
||||||
:END:
|
::END:
|
||||||
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
|
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
|
||||||
- [2025-12-05] Justice of Toren's multi-body perspective is unsettling.
|
- [2025-12-05] Justice of Toren's multi-body perspective is unsettling.
|
||||||
- [2026-01-08] The tea set. Perfect ending.
|
- [2026-01-08] The tea set. Perfect ending.
|
||||||
|
|
||||||
|
* Non-Fiction
|
||||||
|
** The Checklist Manifesto
|
||||||
|
:PROPERTIES:
|
||||||
|
:AUTHOR: Atul Gawande
|
||||||
|
:ID: b2c1-tcm
|
||||||
|
:ADDED: [2026-06-15]
|
||||||
|
:LAST_MODIFIED: [2026-06-15]
|
||||||
|
:END:
|
||||||
|
|||||||
@@ -59,134 +59,90 @@ Returns the rendered string."
|
|||||||
|
|
||||||
|
|
||||||
(defun spine-add-form-model (books)
|
(defun spine-add-form-model (books)
|
||||||
"Build the view model ht for templates/add.mustache from BOOKS."
|
"Build view model for templates/add.mustache from current BOOKS."
|
||||||
(let ((authors nil)
|
(ht ("app_title" "spine")
|
||||||
(categories nil))
|
("shelves" (or (spine-shelves) '()))))
|
||||||
(dolist (book books)
|
|
||||||
(let ((author (plist-get book :author)))
|
|
||||||
(when (and author (> (length author) 0)
|
|
||||||
(not (member author authors)))
|
|
||||||
(push author authors)))
|
|
||||||
(dolist (tag (or (plist-get book :tags) nil))
|
|
||||||
(unless (member tag categories)
|
|
||||||
(push tag categories))))
|
|
||||||
(ht ("app_title" "spine")
|
|
||||||
("existing_authors" (sort authors #'string<))
|
|
||||||
("existing_categories" (sort categories #'string<)))))
|
|
||||||
|
|
||||||
(defun spine-index-model (books &optional filter selected-id)
|
(defun spine-index-model (books &optional shelf-filter selected-id)
|
||||||
"Build the view model ht for templates/index.mustache from BOOKS.
|
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||||
FILTER controls which books are shown:
|
SHELF-FILTER limits to one shelf by name (string). nil = all shelves.
|
||||||
nil - concise: READING + 5 most recent WANT, others as summary links
|
|
||||||
all - full listing, no truncation
|
|
||||||
read - only READ books
|
|
||||||
want - only WANT books
|
|
||||||
SELECTED-ID expands the matching book's detail section."
|
SELECTED-ID expands the matching book's detail section."
|
||||||
(let* ((status-labels
|
(let* ((format-icons
|
||||||
'(("WANT" . "on deck")
|
|
||||||
("READING" . "reading")
|
|
||||||
("READ" . "read")
|
|
||||||
("ABANDONED" . "abandoned")))
|
|
||||||
(format-icons
|
|
||||||
'(("hardcover" . "ti-book")
|
'(("hardcover" . "ti-book")
|
||||||
("ebook" . "ti-device-tablet")
|
("ebook" . "ti-device-tablet")
|
||||||
("audiobook" . "ti-headphones")))
|
("audiobook" . "ti-headphones")))
|
||||||
(order '("WANT" "READING" "READ" "ABANDONED"))
|
|
||||||
(valid-filters '("all" "read" "want"))
|
|
||||||
(effective-filter (if (member filter valid-filters) filter nil))
|
|
||||||
(grouped (make-hash-table :test 'equal))
|
(grouped (make-hash-table :test 'equal))
|
||||||
(total (length books))
|
(all-shelves (or (spine-shelves) '()))
|
||||||
(reading-count 0))
|
(total (length books)))
|
||||||
|
;; Group books by shelf
|
||||||
(dolist (book books)
|
(dolist (book books)
|
||||||
(when (equal "READING" (plist-get book :status))
|
(let ((shelf (or (plist-get book :shelf) "Uncategorized")))
|
||||||
(cl-incf reading-count)))
|
(push book (gethash shelf grouped))))
|
||||||
(dolist (book books)
|
|
||||||
(let ((status (or (plist-get book :status) "WANT")))
|
|
||||||
(push book (gethash status grouped))))
|
|
||||||
(let ((groups nil)
|
(let ((groups nil)
|
||||||
(summary-groups nil))
|
(shelf-nav nil))
|
||||||
;; Concise view: sort all books by :LAST_MODIFIED:, take top 5
|
;; Build shelf nav from all shelves (including empty)
|
||||||
(when (null effective-filter)
|
(dolist (shelf all-shelves)
|
||||||
(clrhash grouped)
|
(push (ht ("name" shelf)
|
||||||
(dolist (b (cl-subseq (sort (copy-sequence books)
|
("href" (format "/index?shelf=%s" shelf))
|
||||||
(lambda (a b)
|
("current" (equal shelf shelf-filter)))
|
||||||
(let ((a-date (plist-get a :last_modified))
|
shelf-nav))
|
||||||
(b-date (plist-get b :last_modified)))
|
(setq shelf-nav (nreverse shelf-nav))
|
||||||
(cond
|
;; Build shelf groups
|
||||||
((null a-date) nil)
|
(dolist (shelf all-shelves)
|
||||||
((null b-date) t)
|
(let ((shelf-books (nreverse (gethash shelf grouped))))
|
||||||
(t (string> a-date b-date))))))
|
(when (or (null shelf-filter) (equal shelf shelf-filter))
|
||||||
0 (min (length books) 5)))
|
(let ((book-models nil))
|
||||||
(let ((status (or (plist-get b :status) "WANT")))
|
(dolist (book shelf-books)
|
||||||
(push b (gethash status grouped)))))
|
(let* ((id (plist-get book :id))
|
||||||
(dolist (status order)
|
(selected (equal id selected-id))
|
||||||
(let ((group-books (nreverse (gethash status grouped))))
|
(fmt (plist-get book :format))
|
||||||
(when group-books
|
(rating (plist-get book :rating))
|
||||||
(if (and (null effective-filter)
|
(notes (plist-get book :notes))
|
||||||
(member status '("READ" "ABANDONED")))
|
(model
|
||||||
(push (ht ("label" (cdr (assoc status status-labels)))
|
(ht
|
||||||
("count" (length group-books))
|
("format_icon"
|
||||||
("href" (format "/index?filter=%s"
|
(or (cdr (assoc fmt format-icons)) ""))
|
||||||
(downcase status))))
|
("title" (plist-get book :title))
|
||||||
summary-groups)
|
("author" (or (plist-get book :author) ""))
|
||||||
(when (or (null effective-filter)
|
("tags_inline"
|
||||||
(string= (downcase status) effective-filter)
|
(let ((tags (plist-get book :tags)))
|
||||||
(string= effective-filter "all"))
|
(when tags
|
||||||
(let ((book-models nil))
|
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
||||||
(dolist (book group-books)
|
tags " "))))
|
||||||
(let* ((id (plist-get book :id))
|
("rec_via" (plist-get book :rec_by))
|
||||||
(selected (equal id selected-id))
|
("rating_display"
|
||||||
(fmt (plist-get book :format))
|
(when (and rating (> (length rating) 0))
|
||||||
(rating (plist-get book :rating))
|
(make-string (string-to-number rating) ?\u2605)))
|
||||||
(notes (plist-get book :notes))
|
("selected" selected)
|
||||||
(status (or (plist-get book :status) "WANT"))
|
("detail"
|
||||||
(model
|
(when selected
|
||||||
(ht
|
(ht
|
||||||
("format_icon"
|
("meta"
|
||||||
(or (cdr (assoc fmt format-icons)) ""))
|
(let ((added (plist-get book :added))
|
||||||
("status_class" (downcase status))
|
(fmt (plist-get book :format)))
|
||||||
("status_label" (downcase status))
|
(cond
|
||||||
("title" (plist-get book :title))
|
((and added fmt)
|
||||||
("author" (or (plist-get book :author) ""))
|
(format "started %s \302\267 %s" added fmt))
|
||||||
("tags_inline"
|
(added (format "started %s" added))
|
||||||
(let ((tags (plist-get book :tags)))
|
(fmt (format "format: %s" fmt))
|
||||||
(when tags
|
(t ""))))
|
||||||
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
("notes"
|
||||||
tags " "))))
|
(when notes
|
||||||
("rec_via" (plist-get book :rec_by))
|
(cl-loop for (date text) in notes
|
||||||
("rating_display"
|
collect (ht
|
||||||
(when (and rating (> (length rating) 0))
|
("date" (format "[%s]" date))
|
||||||
(make-string (string-to-number rating) ?\u2605)))
|
("text" text))))))))
|
||||||
("selected" selected)
|
)))
|
||||||
("detail"
|
(push model book-models)))
|
||||||
(when selected
|
(push (ht ("label" shelf)
|
||||||
(ht
|
("count" (length shelf-books))
|
||||||
("meta"
|
("books" (nreverse book-models)))
|
||||||
(let ((added (plist-get book :added))
|
groups)))))
|
||||||
(fmt (plist-get book :format)))
|
|
||||||
(cond
|
|
||||||
((and added fmt)
|
|
||||||
(format "started %s \302\267 %s" added fmt))
|
|
||||||
(added (format "started %s" added))
|
|
||||||
(fmt (format "format: %s" fmt))
|
|
||||||
(t ""))))
|
|
||||||
("notes"
|
|
||||||
(when notes
|
|
||||||
(cl-loop for (date text) in notes
|
|
||||||
collect (ht
|
|
||||||
("date" (format "[%s]" date))
|
|
||||||
("text" text))))))))
|
|
||||||
)))
|
|
||||||
(push model book-models)))
|
|
||||||
(push (ht ("label" (cdr (assoc status status-labels)))
|
|
||||||
("count" (length group-books))
|
|
||||||
("books" (nreverse book-models)))
|
|
||||||
groups)))))))
|
|
||||||
(ht ("app_title" "spine")
|
(ht ("app_title" "spine")
|
||||||
("total_books" total)
|
("total_books" total)
|
||||||
("reading_count" reading-count)
|
("shelf_count" (length all-shelves))
|
||||||
("current_filter" effective-filter)
|
("current_shelf" shelf-filter)
|
||||||
("summary_groups" (nreverse summary-groups))
|
("shelf_nav" shelf-nav)
|
||||||
("groups" (nreverse groups))
|
("groups" (nreverse groups))
|
||||||
("minibuffer"
|
("minibuffer"
|
||||||
(when selected-id
|
(when selected-id
|
||||||
@@ -195,45 +151,21 @@ SELECTED-ID expands the matching book's detail section."
|
|||||||
(puthash (plist-get b :id) b books-by-id))
|
(puthash (plist-get b :id) b books-by-id))
|
||||||
(let ((book (gethash selected-id books-by-id)))
|
(let ((book (gethash selected-id books-by-id)))
|
||||||
(when book
|
(when book
|
||||||
(let ((status (or (plist-get book :status) "WANT")))
|
(ht ("actions"
|
||||||
(ht ("actions"
|
(list
|
||||||
(append
|
(ht ("command" "note") ("label" "Add note")
|
||||||
(cond
|
("href" (format "/edit?id=%s&action=note" selected-id)))
|
||||||
((equal status "WANT")
|
(ht ("command" "rating") ("label" "Set rating")
|
||||||
(list
|
("href" (format "/edit?id=%s&action=rating" selected-id)))))))))))
|
||||||
(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 book to shelf `:shelf' in `spine-org-file'.
|
||||||
Keyword arguments: :title :author :category :format :isbn :cover
|
Keyword arguments: :title :author :shelf :format :isbn :cover
|
||||||
:rec_by :rec_note :date_added :initial_note
|
:rec_by :rec_note :date_added :initial_note
|
||||||
Signals an error on failure."
|
:shelf is required. Signals an error if the shelf doesn't exist."
|
||||||
(let ((title (plist-get args :title))
|
(let ((title (plist-get args :title))
|
||||||
|
(shelf (plist-get args :shelf))
|
||||||
(author (plist-get args :author))
|
(author (plist-get args :author))
|
||||||
(category (plist-get args :category))
|
|
||||||
(format (plist-get args :format))
|
(format (plist-get args :format))
|
||||||
(isbn (plist-get args :isbn))
|
(isbn (plist-get args :isbn))
|
||||||
(cover (plist-get args :cover))
|
(cover (plist-get args :cover))
|
||||||
@@ -243,20 +175,32 @@ Signals an error on failure."
|
|||||||
(initial-note (plist-get args :initial_note)))
|
(initial-note (plist-get args :initial_note)))
|
||||||
(unless title
|
(unless title
|
||||||
(error "spine-add-book: title is required"))
|
(error "spine-add-book: title is required"))
|
||||||
|
(unless shelf
|
||||||
|
(error "spine-add-book: :shelf is required"))
|
||||||
(let ((org-file (expand-file-name spine-org-file)))
|
(let ((org-file (expand-file-name spine-org-file)))
|
||||||
;; Create file if it doesn't exist
|
|
||||||
(unless (file-exists-p org-file)
|
(unless (file-exists-p org-file)
|
||||||
(with-temp-file org-file
|
(error "spine-add-book: file %s does not exist; create shelves first"
|
||||||
(insert "#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)\n\n")))
|
org-file))
|
||||||
(with-current-buffer (find-file-noselect org-file)
|
(with-current-buffer (find-file-noselect org-file)
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
(progn
|
(progn
|
||||||
(goto-char (point-max))
|
;; Find the shelf headline
|
||||||
;; Insert new headline
|
(goto-char (point-min))
|
||||||
|
(let ((shelf-found nil))
|
||||||
|
(while (and (not shelf-found)
|
||||||
|
(re-search-forward
|
||||||
|
(format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote shelf))
|
||||||
|
nil t))
|
||||||
|
(when (= (org-current-level) 1)
|
||||||
|
(setq shelf-found t)))
|
||||||
|
(unless shelf-found
|
||||||
|
(error "spine-add-book: shelf \"%s\" not found" shelf)))
|
||||||
|
;; Go to end of shelf section
|
||||||
|
(org-end-of-subtree)
|
||||||
|
;; Insert new book headline at level 2
|
||||||
(org-insert-heading nil t)
|
(org-insert-heading nil t)
|
||||||
|
(org-demote-subtree)
|
||||||
(insert title)
|
(insert title)
|
||||||
;; Set TODO state
|
|
||||||
(org-todo "WANT")
|
|
||||||
;; Set properties (only non-empty)
|
;; Set properties (only non-empty)
|
||||||
(when (and author (> (length author) 0))
|
(when (and author (> (length author) 0))
|
||||||
(org-set-property "AUTHOR" author))
|
(org-set-property "AUTHOR" author))
|
||||||
@@ -279,9 +223,6 @@ Signals an error on failure."
|
|||||||
(substring (sha1 (concat title (number-to-string (random)))) 0 4)))
|
(substring (sha1 (concat title (number-to-string (random)))) 0 4)))
|
||||||
;; Set last modified
|
;; Set last modified
|
||||||
(org-set-property "LAST_MODIFIED" (format-time-string "[%Y-%m-%d]"))
|
(org-set-property "LAST_MODIFIED" (format-time-string "[%Y-%m-%d]"))
|
||||||
;; Set tags
|
|
||||||
(when (and category (> (length category) 0))
|
|
||||||
(org-set-tags (list category)))
|
|
||||||
;; Add initial note
|
;; Add initial note
|
||||||
(when (and initial-note (> (length initial-note) 0))
|
(when (and initial-note (> (length initial-note) 0))
|
||||||
(let ((date (if (and date-added (> (length date-added) 0))
|
(let ((date (if (and date-added (> (length date-added) 0))
|
||||||
@@ -293,30 +234,6 @@ 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)
|
(defun spine-add-note (id text &optional date)
|
||||||
"Append a reading note to the book with ID.
|
"Append a reading note to the book with ID.
|
||||||
@@ -331,7 +248,7 @@ TEXT is the note content. DATE is an optional YYYY-MM-DD string
|
|||||||
(progn
|
(progn
|
||||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||||
(lambda (hl)
|
(lambda (hl)
|
||||||
(when (= (org-element-property :level hl) 1)
|
(when (= (org-element-property :level hl) 2)
|
||||||
(let ((hl-id (spine--prop (org-element-property :begin hl) "ID")))
|
(let ((hl-id (spine--prop (org-element-property :begin hl) "ID")))
|
||||||
(when (equal id hl-id)
|
(when (equal id hl-id)
|
||||||
(goto-char (org-element-property :end hl))
|
(goto-char (org-element-property :end hl))
|
||||||
@@ -356,7 +273,7 @@ Signals an error if the book is not found."
|
|||||||
(progn
|
(progn
|
||||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||||
(lambda (hl)
|
(lambda (hl)
|
||||||
(when (= (org-element-property :level hl) 1)
|
(when (= (org-element-property :level hl) 2)
|
||||||
(let ((pos (org-element-property :begin hl)))
|
(let ((pos (org-element-property :begin hl)))
|
||||||
(when (equal id (spine--prop pos "ID"))
|
(when (equal id (spine--prop pos "ID"))
|
||||||
(goto-char pos)
|
(goto-char pos)
|
||||||
@@ -393,8 +310,31 @@ Returns nil for empty or missing properties."
|
|||||||
(goto-char pos)
|
(goto-char pos)
|
||||||
(org-entry-get nil prop))))
|
(org-entry-get nil prop))))
|
||||||
(and val (> (length val) 0) val)))
|
(and val (> (length val) 0) val)))
|
||||||
|
(defun spine-shelves ()
|
||||||
|
"Return a list of shelf names (strings), one per level-1 headline.
|
||||||
|
Includes empty shelves (shelves with no books)."
|
||||||
|
(if (not (file-exists-p spine-org-file))
|
||||||
|
(progn
|
||||||
|
(message "spine-shelves: file not found: %s" spine-org-file)
|
||||||
|
nil)
|
||||||
|
(condition-case err
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents spine-org-file)
|
||||||
|
(org-mode)
|
||||||
|
(let ((shelves nil))
|
||||||
|
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||||
|
(lambda (hl)
|
||||||
|
(when (= (org-element-property :level hl) 1)
|
||||||
|
(push (org-element-property :raw-value hl) shelves))))
|
||||||
|
(nreverse shelves)))
|
||||||
|
(error
|
||||||
|
(message "spine-shelves: failed to parse %s: %s"
|
||||||
|
spine-org-file (error-message-string err))
|
||||||
|
nil))))
|
||||||
|
|
||||||
(defun spine-books ()
|
(defun spine-books ()
|
||||||
"Return a list of plists, one per top-level headline in `spine-org-file'.
|
"Return a list of plists, one per book in `spine-org-file'.
|
||||||
|
Books are level-2 headlines nested under level-1 shelf headlines.
|
||||||
Returns nil if the file is missing or cannot be parsed."
|
Returns nil if the file is missing or cannot be parsed."
|
||||||
(if (not (file-exists-p spine-org-file))
|
(if (not (file-exists-p spine-org-file))
|
||||||
(progn
|
(progn
|
||||||
@@ -407,25 +347,31 @@ Returns nil if the file is missing or cannot be parsed."
|
|||||||
(let ((books nil))
|
(let ((books nil))
|
||||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||||
(lambda (hl)
|
(lambda (hl)
|
||||||
(when (= (org-element-property :level hl) 1)
|
(let ((level (org-element-property :level hl)))
|
||||||
(let ((pos (org-element-property :begin hl)))
|
(when (= level 2)
|
||||||
(push (list :id (spine--prop pos "ID")
|
(let* ((pos (org-element-property :begin hl))
|
||||||
:title (org-element-property :title hl)
|
(parent (org-element-property :parent hl))
|
||||||
:status (org-element-property :todo-keyword hl)
|
(shelf (and parent
|
||||||
:author (spine--prop pos "AUTHOR")
|
(= (org-element-property :level parent) 1)
|
||||||
:isbn (spine--prop pos "ISBN")
|
(org-element-property :raw-value parent))))
|
||||||
:cover (spine--prop pos "COVER")
|
(when shelf
|
||||||
:format (spine--prop pos "FORMAT")
|
(push (list :id (spine--prop pos "ID")
|
||||||
:rec_by (spine--prop pos "REC_BY")
|
:title (org-element-property :title hl)
|
||||||
:rec_note (spine--prop pos "REC_NOTE")
|
:author (spine--prop pos "AUTHOR")
|
||||||
:rating (spine--prop pos "RATING")
|
:isbn (spine--prop pos "ISBN")
|
||||||
:added (spine--prop pos "ADDED")
|
:cover (spine--prop pos "COVER")
|
||||||
:last_modified (spine--prop pos "LAST_MODIFIED")
|
:format (spine--prop pos "FORMAT")
|
||||||
:tags (org-element-property :tags hl)
|
:rec_by (spine--prop pos "REC_BY")
|
||||||
:notes (spine--extract-notes
|
:rec_note (spine--prop pos "REC_NOTE")
|
||||||
(org-element-property :begin hl)
|
:rating (spine--prop pos "RATING")
|
||||||
(org-element-property :end hl)))
|
:added (spine--prop pos "ADDED")
|
||||||
books)))))
|
:last_modified (spine--prop pos "LAST_MODIFIED")
|
||||||
|
:tags (org-element-property :tags hl)
|
||||||
|
:notes (spine--extract-notes
|
||||||
|
(org-element-property :begin hl)
|
||||||
|
(org-element-property :end hl))
|
||||||
|
:shelf shelf)
|
||||||
|
books)))))))
|
||||||
(nreverse books)))
|
(nreverse books)))
|
||||||
(error
|
(error
|
||||||
(message "spine-books: failed to parse %s: %s"
|
(message "spine-books: failed to parse %s: %s"
|
||||||
@@ -457,10 +403,10 @@ Set by test harnesses that only need the model functions.")
|
|||||||
|
|
||||||
(defservlet index text/html (path query request)
|
(defservlet index text/html (path query request)
|
||||||
(let* ((books (spine-books))
|
(let* ((books (spine-books))
|
||||||
(filter (cadr (assoc "filter" query))))
|
(shelf-filter (cadr (assoc "shelf" query))))
|
||||||
(if books
|
(if books
|
||||||
(insert (spine-render "index.mustache"
|
(insert (spine-render "index.mustache"
|
||||||
(spine-index-model books filter (cadr (assoc "id" query)))))
|
(spine-index-model books shelf-filter (cadr (assoc "id" query)))))
|
||||||
(insert (spine-render-empty-state)))))
|
(insert (spine-render-empty-state)))))
|
||||||
|
|
||||||
(defun httpd/add (proc uri-path query request)
|
(defun httpd/add (proc uri-path query request)
|
||||||
@@ -471,7 +417,7 @@ Set by test harnesses that only need the model functions.")
|
|||||||
(spine-add-book
|
(spine-add-book
|
||||||
:title (cadr (assoc "title" query))
|
:title (cadr (assoc "title" query))
|
||||||
:author (cadr (assoc "author" query))
|
:author (cadr (assoc "author" query))
|
||||||
:category (cadr (assoc "category" query))
|
:shelf (cadr (assoc "shelf" query))
|
||||||
:format (cadr (assoc "format" query))
|
:format (cadr (assoc "format" query))
|
||||||
:isbn (cadr (assoc "isbn" query))
|
:isbn (cadr (assoc "isbn" query))
|
||||||
:cover (cadr (assoc "cover" query))
|
:cover (cadr (assoc "cover" query))
|
||||||
@@ -484,17 +430,18 @@ Set by test harnesses that only need the model functions.")
|
|||||||
(let* ((books (spine-books))
|
(let* ((books (spine-books))
|
||||||
(model (if books
|
(model (if books
|
||||||
(spine-add-form-model books)
|
(spine-add-form-model books)
|
||||||
(ht ("app_title" "spine")))))
|
(ht ("app_title" "spine" "shelves" '())))))
|
||||||
(insert (spine-render "add.mustache" model)))))))
|
(insert (spine-render "add.mustache" model)))))))
|
||||||
|
|
||||||
|
|
||||||
(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: GET shows prompt page, POST processes actions.
|
||||||
Query params:
|
Query params:
|
||||||
id - book ID
|
id - book ID
|
||||||
action - status | note | rating
|
action - note | rating
|
||||||
value - target status / rating value (for status/rating actions)
|
|
||||||
text - note text (for note action)
|
text - note text (for note action)
|
||||||
date - optional date string (for note/read actions)"
|
date - optional date string (for note action)
|
||||||
|
value - rating value (for rating action)"
|
||||||
(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)))
|
||||||
@@ -506,8 +453,6 @@ Query params:
|
|||||||
(cond
|
(cond
|
||||||
((equal action "note")
|
((equal action "note")
|
||||||
(spine-add-note id text date))
|
(spine-add-note id text date))
|
||||||
((equal action "status")
|
|
||||||
(spine-set-status id value))
|
|
||||||
((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))
|
(httpd-redirect proc (format "/index?id=%s" id) 303))
|
||||||
|
|||||||
@@ -60,10 +60,11 @@
|
|||||||
<form method="post" action="/add">
|
<form method="post" action="/add">
|
||||||
<label>Date added <input type="date" name="date_added" /></label>
|
<label>Date added <input type="date" name="date_added" /></label>
|
||||||
<label>Title * <input type="text" name="title" required autofocus /></label>
|
<label>Title * <input type="text" name="title" required autofocus /></label>
|
||||||
<label>Author <input type="text" name="author" list="authors-list" /></label>
|
<label>Author <input type="text" name="author" /></label>
|
||||||
<datalist id="authors-list">{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}</datalist>
|
<label>Shelf * <select name="shelf" required>
|
||||||
<label>Category <input type="text" name="category" list="categories-list" /></label>
|
<option value="">— select —</option>
|
||||||
<datalist id="categories-list">{{#existing_categories}}<option value="{{.}}">{{/existing_categories}}</datalist>
|
{{#shelves}}<option value="{{.}}">{{.}}</option>{{/shelves}}
|
||||||
|
</select></label>
|
||||||
<label>Format <select name="format"><option value="">—</option><option value="hardcover">Hardcover</option><option value="ebook">eBook</option><option value="audiobook">Audiobook</option></select></label>
|
<label>Format <select name="format"><option value="">—</option><option value="hardcover">Hardcover</option><option value="ebook">eBook</option><option value="audiobook">Audiobook</option></select></label>
|
||||||
<label>ISBN <input type="text" name="isbn" /></label>
|
<label>ISBN <input type="text" name="isbn" /></label>
|
||||||
<label>Cover (path) <input type="text" name="cover" placeholder="covers/filename.jpg" /></label>
|
<label>Cover (path) <input type="text" name="cover" placeholder="covers/filename.jpg" /></label>
|
||||||
|
|||||||
@@ -40,10 +40,6 @@
|
|||||||
.group { padding:10px 14px 4px; font-size:11px; letter-spacing:0.04em; color: var(--text-tertiary); }
|
.group { padding:10px 14px 4px; font-size:11px; letter-spacing:0.04em; color: var(--text-tertiary); }
|
||||||
.row { display:flex; align-items:center; gap:10px; padding:5px 14px; font-size:13px; }
|
.row { display:flex; align-items:center; gap:10px; padding:5px 14px; font-size:13px; }
|
||||||
.glyph { font-size:15px; color:var(--text-tertiary); width:16px; }
|
.glyph { font-size:15px; color:var(--text-tertiary); width:16px; }
|
||||||
.pill { padding:1px 7px; border-radius:var(--radius-md); font-size:11px; }
|
|
||||||
.pill.want { background:var(--bg-info); color:var(--text-info); }
|
|
||||||
.pill.reading { background:var(--bg-warning); color:var(--text-warning); }
|
|
||||||
.pill.read { background:var(--bg-success); color:var(--text-success); }
|
|
||||||
.title { flex:1; min-width:0; }
|
.title { flex:1; min-width:0; }
|
||||||
.muted { color:var(--text-tertiary); }
|
.muted { color:var(--text-tertiary); }
|
||||||
.tags { color:var(--text-info); font-size:12px; }
|
.tags { color:var(--text-info); font-size:12px; }
|
||||||
@@ -66,30 +62,22 @@
|
|||||||
<div class="frame">
|
<div class="frame">
|
||||||
<div class="titlebar">
|
<div class="titlebar">
|
||||||
<span style="font-weight:500;">spine</span>
|
<span style="font-weight:500;">spine</span>
|
||||||
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
|
<span class="muted">{{total_books}} books · {{shelf_count}} shelves</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#current_filter}}
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<a href="/index">Concise view</a>
|
<a href="/index"{{^current_shelf}} style="font-weight:600"{{/current_shelf}}>All</a>
|
||||||
</div>
|
{{#shelf_nav}}
|
||||||
{{/current_filter}}
|
|
||||||
{{^current_filter}}
|
|
||||||
<div class="filter-bar">
|
|
||||||
<a href="/index?filter=all">All books ({{total_books}})</a>
|
|
||||||
·
|
·
|
||||||
<a href="/index?filter=want">Want list</a>
|
<a href="{{href}}"{{#current}} style="font-weight:600"{{/current}}>{{name}}</a>
|
||||||
·
|
{{/shelf_nav}}
|
||||||
<a href="/index?filter=read">Read ({{reading_count}})</a>
|
|
||||||
</div>
|
</div>
|
||||||
{{/current_filter}}
|
|
||||||
|
|
||||||
{{#groups}}
|
{{#groups}}
|
||||||
<div class="group">{{label}} · {{count}}</div>
|
<div class="group">{{label}} · {{count}}</div>
|
||||||
{{#books}}
|
{{#books}}
|
||||||
<div class="row{{#selected}} selected{{/selected}}">
|
<div class="row{{#selected}} selected{{/selected}}">
|
||||||
<i class="ti {{format_icon}} glyph" aria-hidden="true"></i>
|
<i class="ti {{format_icon}} glyph" aria-hidden="true"></i>
|
||||||
<span class="pill {{status_class}}">{{status_label}}</span>
|
|
||||||
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
|
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
|
||||||
{{#tags_inline}}<span class="tags">{{tags_inline}}</span>{{/tags_inline}}
|
{{#tags_inline}}<span class="tags">{{tags_inline}}</span>{{/tags_inline}}
|
||||||
{{#rec_via}}<span class="trail">via {{rec_via}}</span>{{/rec_via}}
|
{{#rec_via}}<span class="trail">via {{rec_via}}</span>{{/rec_via}}
|
||||||
@@ -106,10 +94,6 @@
|
|||||||
{{/books}}
|
{{/books}}
|
||||||
{{/groups}}
|
{{/groups}}
|
||||||
|
|
||||||
{{#summary_groups}}
|
|
||||||
<div class="group"><a href="{{href}}" style="color:var(--text-info);text-decoration:none">{{label}} · {{count}}</a></div>
|
|
||||||
{{/summary_groups}}
|
|
||||||
|
|
||||||
{{#minibuffer}}
|
{{#minibuffer}}
|
||||||
<div class="minibuffer">
|
<div class="minibuffer">
|
||||||
<span class="muted">M-x</span>
|
<span class="muted">M-x</span>
|
||||||
|
|||||||
+40
-10
@@ -16,18 +16,20 @@
|
|||||||
(ignore-errors (delete-file spine-org-file)))
|
(ignore-errors (delete-file spine-org-file)))
|
||||||
|
|
||||||
(ert-deftest spine-add-book-creates-headline ()
|
(ert-deftest spine-add-book-creates-headline ()
|
||||||
"spine-add-book creates a new WANT headline."
|
"spine-add-book creates a new book under the given shelf."
|
||||||
(spine-add-book-test--cleanup)
|
(spine-add-book-test--cleanup)
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
(progn
|
(progn
|
||||||
(spine-add-book :title "Test Book" :author "Author Name")
|
(with-temp-file spine-org-file
|
||||||
|
(insert "* Fiction\n\n"))
|
||||||
|
(spine-add-book :title "Test Book" :author "Author Name" :shelf "Fiction")
|
||||||
(let ((books (spine-books)))
|
(let ((books (spine-books)))
|
||||||
(should books)
|
(should books)
|
||||||
(should (= (length books) 1))
|
(should (= (length books) 1))
|
||||||
(let ((book (car books)))
|
(let ((book (car books)))
|
||||||
(should (equal (plist-get book :title) "Test Book"))
|
(should (equal (plist-get book :title) "Test Book"))
|
||||||
(should (equal (plist-get book :status) "WANT"))
|
|
||||||
(should (equal (plist-get book :author) "Author Name"))
|
(should (equal (plist-get book :author) "Author Name"))
|
||||||
|
(should (equal (plist-get book :shelf) "Fiction"))
|
||||||
(should (plist-get book :id)))))
|
(should (plist-get book :id)))))
|
||||||
(spine-add-book-test--cleanup)))
|
(spine-add-book-test--cleanup)))
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@
|
|||||||
(spine-add-book-test--cleanup)
|
(spine-add-book-test--cleanup)
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
(progn
|
(progn
|
||||||
|
(with-temp-file spine-org-file
|
||||||
|
(insert "* Fiction\n\n"))
|
||||||
(spine-add-book
|
(spine-add-book
|
||||||
:title "Full Test"
|
:title "Full Test"
|
||||||
:author "Jane Doe"
|
:author "Jane Doe"
|
||||||
@@ -45,8 +49,8 @@
|
|||||||
:rec_by "Friend"
|
:rec_by "Friend"
|
||||||
:rec_note "You'll love this"
|
:rec_note "You'll love this"
|
||||||
:date_added "2026-06-21"
|
:date_added "2026-06-21"
|
||||||
:category "scifi"
|
:initial_note "Looking forward to this"
|
||||||
:initial_note "Looking forward to this")
|
:shelf "Fiction")
|
||||||
(let* ((books (spine-books))
|
(let* ((books (spine-books))
|
||||||
(book (car books)))
|
(book (car books)))
|
||||||
(should (equal (plist-get book :title) "Full Test"))
|
(should (equal (plist-get book :title) "Full Test"))
|
||||||
@@ -57,7 +61,6 @@
|
|||||||
(should (equal (plist-get book :rec_by) "Friend"))
|
(should (equal (plist-get book :rec_by) "Friend"))
|
||||||
(should (equal (plist-get book :rec_note) "You'll love this"))
|
(should (equal (plist-get book :rec_note) "You'll love this"))
|
||||||
(should (equal (plist-get book :added) "[2026-06-21]"))
|
(should (equal (plist-get book :added) "[2026-06-21]"))
|
||||||
(should (equal (plist-get book :tags) '("scifi")))
|
|
||||||
(should (equal (plist-get book :notes)
|
(should (equal (plist-get book :notes)
|
||||||
'(("2026-06-21" "Looking forward to this"))))))
|
'(("2026-06-21" "Looking forward to this"))))))
|
||||||
(spine-add-book-test--cleanup)))
|
(spine-add-book-test--cleanup)))
|
||||||
@@ -67,10 +70,13 @@
|
|||||||
(spine-add-book-test--cleanup)
|
(spine-add-book-test--cleanup)
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
(progn
|
(progn
|
||||||
(spine-add-book :title "Minimal Book")
|
(with-temp-file spine-org-file
|
||||||
|
(insert "* Fiction\n\n"))
|
||||||
|
(spine-add-book :title "Minimal Book" :shelf "Fiction")
|
||||||
(let* ((books (spine-books))
|
(let* ((books (spine-books))
|
||||||
(book (car books)))
|
(book (car books)))
|
||||||
(should (equal (plist-get book :title) "Minimal Book"))
|
(should (equal (plist-get book :title) "Minimal Book"))
|
||||||
|
(should (equal (plist-get book :shelf) "Fiction"))
|
||||||
(should-not (plist-get book :author))
|
(should-not (plist-get book :author))
|
||||||
(should-not (plist-get book :format))
|
(should-not (plist-get book :format))
|
||||||
(should-not (plist-get book :isbn))
|
(should-not (plist-get book :isbn))
|
||||||
@@ -81,7 +87,14 @@
|
|||||||
"spine-add-book signals error when title is missing."
|
"spine-add-book signals error when title is missing."
|
||||||
(spine-add-book-test--cleanup)
|
(spine-add-book-test--cleanup)
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
(should-error (spine-add-book :author "No Title"))
|
(should-error (spine-add-book :author "No Title" :shelf "Fiction"))
|
||||||
|
(spine-add-book-test--cleanup)))
|
||||||
|
|
||||||
|
(ert-deftest spine-add-book-requires-shelf ()
|
||||||
|
"spine-add-book signals error when shelf is missing."
|
||||||
|
(spine-add-book-test--cleanup)
|
||||||
|
(unwind-protect
|
||||||
|
(should-error (spine-add-book :title "No Shelf"))
|
||||||
(spine-add-book-test--cleanup)))
|
(spine-add-book-test--cleanup)))
|
||||||
|
|
||||||
(ert-deftest spine-add-book-generates-unique-ids ()
|
(ert-deftest spine-add-book-generates-unique-ids ()
|
||||||
@@ -89,10 +102,27 @@
|
|||||||
(spine-add-book-test--cleanup)
|
(spine-add-book-test--cleanup)
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
(progn
|
(progn
|
||||||
(spine-add-book :title "Book One")
|
(with-temp-file spine-org-file
|
||||||
(spine-add-book :title "Book Two")
|
(insert "* Fiction\n\n"))
|
||||||
|
(spine-add-book :title "Book One" :shelf "Fiction")
|
||||||
|
(spine-add-book :title "Book Two" :shelf "Fiction")
|
||||||
(let* ((books (spine-books))
|
(let* ((books (spine-books))
|
||||||
(id1 (plist-get (nth 0 books) :id))
|
(id1 (plist-get (nth 0 books) :id))
|
||||||
(id2 (plist-get (nth 1 books) :id)))
|
(id2 (plist-get (nth 1 books) :id)))
|
||||||
(should (not (equal id1 id2)))))
|
(should (not (equal id1 id2)))))
|
||||||
(spine-add-book-test--cleanup)))
|
(spine-add-book-test--cleanup)))
|
||||||
|
|
||||||
|
(ert-deftest spine-add-book-creates-under-correct-shelf ()
|
||||||
|
"spine-add-book places the book under the named shelf."
|
||||||
|
(spine-add-book-test--cleanup)
|
||||||
|
(unwind-protect
|
||||||
|
(progn
|
||||||
|
(with-temp-file spine-org-file
|
||||||
|
(insert "* Fiction\n\n* Non-Fiction\n\n"))
|
||||||
|
(spine-add-book :title "Sci-Fi Book" :shelf "Fiction")
|
||||||
|
(spine-add-book :title "History Book" :shelf "Non-Fiction")
|
||||||
|
(let ((books (spine-books)))
|
||||||
|
(should (= (length books) 2))
|
||||||
|
(should (equal (plist-get (nth 0 books) :shelf) "Fiction"))
|
||||||
|
(should (equal (plist-get (nth 1 books) :shelf) "Non-Fiction"))))
|
||||||
|
(spine-add-book-test--cleanup)))
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"spine-books returns correct plists for sample-books.org."
|
"spine-books returns correct plists for sample-books.org."
|
||||||
(let ((books (spine-books)))
|
(let ((books (spine-books)))
|
||||||
(should books)
|
(should books)
|
||||||
(should (= (length books) 5))))
|
(should (= (length books) 6))))
|
||||||
|
|
||||||
(ert-deftest spine-books-first-book-has-all-fields ()
|
(ert-deftest spine-books-first-book-has-all-fields ()
|
||||||
"Use of Weapons entry has all expected fields."
|
"Use of Weapons entry has all expected fields."
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
:key (lambda (b) (plist-get b :id))
|
:key (lambda (b) (plist-get b :id))
|
||||||
:test #'equal)))
|
:test #'equal)))
|
||||||
(should uow)
|
(should uow)
|
||||||
(should (equal (plist-get uow :title) "Use of Weapons"))
|
(should (equal (plist-get uow :title) "READING Use of Weapons"))
|
||||||
(should (equal (plist-get uow :status) "READING"))
|
(should (equal (plist-get uow :shelf) "Fiction"))
|
||||||
(should (equal (plist-get uow :author) "Iain M. Banks"))
|
(should (equal (plist-get uow :author) "Iain M. Banks"))
|
||||||
(should (equal (plist-get uow :isbn) "978-0316029193"))
|
(should (equal (plist-get uow :isbn) "978-0316029193"))
|
||||||
(should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg"))
|
(should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg"))
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
:key (lambda (b) (plist-get b :id))
|
:key (lambda (b) (plist-get b :id))
|
||||||
:test #'equal)))
|
:test #'equal)))
|
||||||
(should pir)
|
(should pir)
|
||||||
(should (equal (plist-get pir :title) "Piranesi"))
|
(should (equal (plist-get pir :title) "WANT Piranesi"))
|
||||||
(should (equal (plist-get pir :status) "WANT"))
|
(should (equal (plist-get pir :shelf) "Fiction"))
|
||||||
(should (equal (plist-get pir :author) "Susanna Clarke"))
|
(should (equal (plist-get pir :author) "Susanna Clarke"))
|
||||||
(should-not (plist-get pir :isbn))
|
(should-not (plist-get pir :isbn))
|
||||||
(should-not (plist-get pir :cover))
|
(should-not (plist-get pir :cover))
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
:key (lambda (b) (plist-get b :id))
|
:key (lambda (b) (plist-get b :id))
|
||||||
:test #'equal)))
|
:test #'equal)))
|
||||||
(should aj)
|
(should aj)
|
||||||
(should (equal (plist-get aj :status) "READ"))
|
(should (equal (plist-get aj :shelf) "Science Fiction"))
|
||||||
(should (equal (plist-get aj :rating) "5"))
|
(should (equal (plist-get aj :rating) "5"))
|
||||||
(should (= (length (plist-get aj :notes)) 3))))
|
(should (= (length (plist-get aj :notes)) 3))))
|
||||||
|
|
||||||
@@ -81,3 +81,16 @@
|
|||||||
"Non-existent file returns nil."
|
"Non-existent file returns nil."
|
||||||
(let ((spine-org-file "/tmp/spine-nonexistent-12345.org"))
|
(let ((spine-org-file "/tmp/spine-nonexistent-12345.org"))
|
||||||
(should-not (spine-books))))
|
(should-not (spine-books))))
|
||||||
|
|
||||||
|
(ert-deftest spine-shelves-returns-shelf-names ()
|
||||||
|
"spine-shelves returns all level-1 headlines as shelf names."
|
||||||
|
(let ((shelves (spine-shelves)))
|
||||||
|
(should (member "Fiction" shelves))
|
||||||
|
(should (member "Science Fiction" shelves))
|
||||||
|
(should (member "Non-Fiction" shelves))
|
||||||
|
(should (= (length shelves) 3))))
|
||||||
|
|
||||||
|
(ert-deftest spine-shelves-includes-empty-shelves ()
|
||||||
|
"spine-shelves includes empty shelves (shelves with no books)."
|
||||||
|
(let ((shelves (spine-shelves)))
|
||||||
|
(should (member "Non-Fiction" shelves))))
|
||||||
|
|||||||
+18
-55
@@ -12,44 +12,15 @@
|
|||||||
(file-name-directory load-file-name)))
|
(file-name-directory load-file-name)))
|
||||||
|
|
||||||
(defun spine-edit-test--setup ()
|
(defun spine-edit-test--setup ()
|
||||||
"Create a test Org file with one WANT book."
|
"Create a test Org file with one shelf and one book."
|
||||||
(spine-add-book :title "Edit Test Book" :author "Test Author"))
|
(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"))
|
||||||
|
t)
|
||||||
|
|
||||||
(defun spine-edit-test--cleanup ()
|
(defun spine-edit-test--cleanup ()
|
||||||
"Remove the test Org file."
|
"Remove the test Org file."
|
||||||
(ignore-errors (delete-file spine-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 ()
|
(ert-deftest spine-add-note-appends ()
|
||||||
"spine-add-note appends a note with date and text."
|
"spine-add-note appends a note with date and text."
|
||||||
(spine-edit-test--cleanup)
|
(spine-edit-test--cleanup)
|
||||||
@@ -102,39 +73,31 @@
|
|||||||
(should (equal (plist-get updated :rating) "4")))))
|
(should (equal (plist-get updated :rating) "4")))))
|
||||||
(spine-edit-test--cleanup)))
|
(spine-edit-test--cleanup)))
|
||||||
|
|
||||||
(ert-deftest spine-set-status-missing-id ()
|
(ert-deftest spine-edit-model-actions-include-note-and-rating ()
|
||||||
"spine-set-status signals error for unknown ID."
|
"Selected book shows Add note and Set rating actions."
|
||||||
(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)
|
(spine-edit-test--cleanup)
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
(progn
|
(progn
|
||||||
(spine-edit-test--setup)
|
(spine-edit-test--setup)
|
||||||
(let* ((books (spine-books))
|
(let* ((books (spine-books))
|
||||||
(book (car books))
|
(book (car books))
|
||||||
(id (plist-get book :id)))
|
(id (plist-get book :id))
|
||||||
(spine-set-status id "READING")
|
(model (spine-index-model books nil id))
|
||||||
(let ((model (spine-index-model (spine-books) nil id)))
|
(minibuffer (ht-get model "minibuffer")))
|
||||||
(let ((mb (ht-get model "minibuffer")))
|
(should minibuffer)
|
||||||
(should mb)
|
(let ((actions (ht-get minibuffer "actions")))
|
||||||
(let ((actions (ht-get mb "actions")))
|
(should (= (length actions) 2))
|
||||||
(should (= (length actions) 3))
|
(should (equal (ht-get (nth 0 actions) "label") "Add note"))
|
||||||
(should (string= (ht-get (nth 0 actions) "label") "Mark read"))
|
(should (equal (ht-get (nth 1 actions) "label") "Set rating")))))
|
||||||
(should (string= (ht-get (nth 1 actions) "label") "Abandon"))
|
|
||||||
(should (string= (ht-get (nth 2 actions) "label") "Add note")))))))
|
|
||||||
(spine-edit-test--cleanup)))
|
(spine-edit-test--cleanup)))
|
||||||
|
|
||||||
(ert-deftest spine-edit-model-minibuffer-nil-when-no-selection ()
|
(ert-deftest spine-edit-model-no-actions-when-no-selection ()
|
||||||
"No minibuffer when no book is selected."
|
"No minibuffer actions when no book is selected."
|
||||||
(spine-edit-test--cleanup)
|
(spine-edit-test--cleanup)
|
||||||
(unwind-protect
|
(unwind-protect
|
||||||
(progn
|
(progn
|
||||||
(spine-edit-test--setup)
|
(spine-edit-test--setup)
|
||||||
(let ((model (spine-index-model (spine-books))))
|
(let* ((books (spine-books))
|
||||||
|
(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)))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
;;; spine-index-model-test.el — ERT tests for spine-index-model with filter
|
;;; spine-index-model-test.el — ERT tests for spine-index-model with shelf grouping
|
||||||
|
|
||||||
(require 'ert)
|
(require 'ert)
|
||||||
(require 'cl-lib)
|
(require 'cl-lib)
|
||||||
@@ -14,90 +14,61 @@
|
|||||||
(defconst spine-index-model-test--books (spine-books)
|
(defconst spine-index-model-test--books (spine-books)
|
||||||
"Books fixture loaded once from sample-books.org.")
|
"Books fixture loaded once from sample-books.org.")
|
||||||
|
|
||||||
(ert-deftest spine-index-model-concise-hides-read-and-abandoned ()
|
(ert-deftest spine-index-model-groups-by-shelf ()
|
||||||
"Concise view (filter=nil) omits READ/ABANDONED groups, adds summary-groups."
|
"Index model groups books by :shelf."
|
||||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||||
(groups (ht-get model "groups"))
|
(groups (ht-get model "groups")))
|
||||||
(summaries (ht-get model "summary_groups"))
|
(should groups)
|
||||||
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
(should (= (length groups) 3)) ;; Fiction, Science Fiction, Non-Fiction
|
||||||
(should-not (member "read" group-labels))
|
(let ((labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||||
(should-not (member "abandoned" group-labels))
|
(should (member "Fiction" labels))
|
||||||
(should summaries)
|
(should (member "Science Fiction" labels))
|
||||||
(should (= (length summaries) 1))
|
(should (member "Non-Fiction" labels)))))
|
||||||
(should (string= (ht-get (car summaries) "label") "read"))
|
|
||||||
(should (= (ht-get (car summaries) "count") 1))
|
|
||||||
(should (string= (ht-get (car summaries) "href") "/index?filter=read"))))
|
|
||||||
|
|
||||||
(ert-deftest spine-index-model-concise-includes-reading ()
|
(ert-deftest spine-index-model-shelf-counts ()
|
||||||
"Concise view includes READING group with all reading books."
|
"Each shelf group has the correct number of books."
|
||||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||||
(groups (ht-get model "groups"))
|
(groups (ht-get model "groups")))
|
||||||
(reading-group (cl-find "reading" groups
|
(dolist (g groups)
|
||||||
:key (lambda (g) (ht-get g "label"))
|
(let ((label (ht-get g "label"))
|
||||||
:test #'string=)))
|
(count (ht-get g "count")))
|
||||||
(should reading-group)
|
(cond
|
||||||
(should (= (ht-get reading-group "count") 1))
|
((equal label "Fiction")
|
||||||
(should (string= (ht-get (car (ht-get reading-group "books")) "title")
|
(should (= count 4))
|
||||||
"Use of Weapons"))))
|
(should (= (length (ht-get g "books")) 4)))
|
||||||
|
((equal label "Science Fiction")
|
||||||
|
(should (= count 1))
|
||||||
|
(should (= (length (ht-get g "books")) 1)))
|
||||||
|
((equal label "Non-Fiction")
|
||||||
|
(should (= count 1))
|
||||||
|
(should (= (length (ht-get g "books")) 1))))))))
|
||||||
|
|
||||||
(ert-deftest spine-index-model-concise-want-limited-to-five ()
|
(ert-deftest spine-index-model-filters-by-shelf ()
|
||||||
"Concise view limits WANT group to 5 books, sorted by :LAST_MODIFIED: desc."
|
"filter=shelf-name shows only that shelf's books."
|
||||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
(let* ((model (spine-index-model spine-index-model-test--books "Fiction"))
|
||||||
(groups (ht-get model "groups"))
|
|
||||||
(want-group (cl-find "on deck" groups
|
|
||||||
:key (lambda (g) (ht-get g "label"))
|
|
||||||
:test #'string=)))
|
|
||||||
(should want-group)
|
|
||||||
(let ((books (ht-get want-group "books")))
|
|
||||||
(should (<= (length books) 5))
|
|
||||||
(should (= (length books) 3))
|
|
||||||
;; Piranesi (2026-06-01), Dune (2026-05-10), Left Hand (2026-04-01)
|
|
||||||
(should (string= (ht-get (nth 0 books) "title") "Piranesi"))
|
|
||||||
(should (string= (ht-get (nth 1 books) "title") "Dune"))
|
|
||||||
(should (string= (ht-get (nth 2 books) "title") "The Left Hand of Darkness")))))
|
|
||||||
|
|
||||||
(ert-deftest spine-index-model-filter-all-shows-all-groups ()
|
|
||||||
"filter=all returns all groups, no summary-groups."
|
|
||||||
(let* ((model (spine-index-model spine-index-model-test--books "all"))
|
|
||||||
(groups (ht-get model "groups"))
|
|
||||||
(summaries (ht-get model "summary_groups"))
|
|
||||||
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
|
||||||
(should-not summaries)
|
|
||||||
(should (member "on deck" group-labels))
|
|
||||||
(should (member "reading" group-labels))
|
|
||||||
(should (member "read" group-labels))
|
|
||||||
(should (= (length groups) 3))))
|
|
||||||
|
|
||||||
(ert-deftest spine-index-model-filter-read-returns-only-read ()
|
|
||||||
"filter=read returns only the READ group."
|
|
||||||
(let* ((model (spine-index-model spine-index-model-test--books "read"))
|
|
||||||
(groups (ht-get model "groups")))
|
(groups (ht-get model "groups")))
|
||||||
(should (= (length groups) 1))
|
(should (= (length groups) 1))
|
||||||
(should (string= (ht-get (car groups) "label") "read"))
|
(should (equal (ht-get (car groups) "label") "Fiction"))))
|
||||||
(should (= (ht-get (car groups) "count") 1))))
|
|
||||||
|
|
||||||
(ert-deftest spine-index-model-filter-want-returns-only-want ()
|
(ert-deftest spine-index-model-includes-all-shelves-in-nav ()
|
||||||
"filter=want returns only the WANT group."
|
"Shelf nav includes all shelves, including empties."
|
||||||
(let* ((model (spine-index-model spine-index-model-test--books "want"))
|
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||||
(groups (ht-get model "groups")))
|
(shelf-nav (ht-get model "shelf_nav")))
|
||||||
(should (= (length groups) 1))
|
(should (= (length shelf-nav) 3))
|
||||||
(should (string= (ht-get (car groups) "label") "on deck"))
|
(should (member "Non-Fiction" (mapcar (lambda (n) (ht-get n "name")) shelf-nav)))))
|
||||||
(should (= (ht-get (car groups) "count") 3))))
|
|
||||||
|
|
||||||
(ert-deftest spine-index-model-invalid-filter-falls-back-to-concise ()
|
(ert-deftest spine-index-model-current-shelf ()
|
||||||
"Unrecognised filter value behaves like nil (concise view)."
|
"current_shelf is set when filter is active."
|
||||||
(let* ((model (spine-index-model spine-index-model-test--books "bogus"))
|
(let* ((model (spine-index-model spine-index-model-test--books "Science Fiction")))
|
||||||
(groups (ht-get model "groups"))
|
(should (equal (ht-get model "current_shelf") "Science Fiction"))))
|
||||||
(summaries (ht-get model "summary_groups"))
|
|
||||||
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
|
||||||
(should summaries)
|
|
||||||
(should-not (member "read" group-labels))
|
|
||||||
(should (member "reading" group-labels))
|
|
||||||
(should (member "on deck" group-labels))))
|
|
||||||
|
|
||||||
(ert-deftest spine-index-model-includes-current-filter ()
|
(ert-deftest spine-index-model-no-current-shelf-without-filter ()
|
||||||
"Model includes current_filter key matching the filter argument."
|
"current_shelf is nil when no filter."
|
||||||
(should (not (ht-get (spine-index-model spine-index-model-test--books) "current_filter")))
|
(let* ((model (spine-index-model spine-index-model-test--books)))
|
||||||
(should (equal (ht-get (spine-index-model spine-index-model-test--books "all") "current_filter") "all"))
|
(should-not (ht-get model "current_shelf"))))
|
||||||
(should (equal (ht-get (spine-index-model spine-index-model-test--books "read") "current_filter") "read"))
|
|
||||||
(should (equal (ht-get (spine-index-model spine-index-model-test--books "want") "current_filter") "want")))
|
(ert-deftest spine-index-model-total-and-shelf-count ()
|
||||||
|
"Titlebar shows correct totals."
|
||||||
|
(let* ((model (spine-index-model spine-index-model-test--books)))
|
||||||
|
(should (= (ht-get model "total_books") 6))
|
||||||
|
(should (= (ht-get model "shelf_count") 3))))
|
||||||
|
|||||||
Reference in New Issue
Block a user