Compare commits

...

7 Commits

8 changed files with 319 additions and 401 deletions
+21 -10
View File
@@ -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:
+161 -216
View File
@@ -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))
+5 -4
View File
@@ -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>
+5 -21
View File
@@ -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>
&nbsp;·&nbsp; &nbsp;·&nbsp;
<a href="/index?filter=want">Want list</a> <a href="{{href}}"{{#current}} style="font-weight:600"{{/current}}>{{name}}</a>
&nbsp;·&nbsp; {{/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
View File
@@ -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)))
+19 -6
View File
@@ -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
View File
@@ -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)))
+50 -79
View File
@@ -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))))