feat: rewrite spine-index-model for shelf grouping

This commit is contained in:
2026-06-21 22:54:48 -04:00
parent e9ea0efa35
commit de057de0bf
2 changed files with 126 additions and 212 deletions
+76 -133
View File
@@ -63,119 +63,86 @@ Returns the rendered string."
(ht ("app_title" "spine") (ht ("app_title" "spine")
("shelves" (or (spine-shelves) '())))) ("shelves" (or (spine-shelves) '()))))
(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
@@ -184,37 +151,13 @@ 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 book to shelf `:shelf' in `spine-org-file'. "Add a new book to shelf `:shelf' in `spine-org-file'.
Keyword arguments: :title :author :shelf :format :isbn :cover Keyword arguments: :title :author :shelf :format :isbn :cover
+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))))