feat: rewrite spine-index-model for shelf grouping
This commit is contained in:
@@ -63,119 +63,86 @@ Returns the rendered string."
|
||||
(ht ("app_title" "spine")
|
||||
("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.
|
||||
FILTER controls which books are shown:
|
||||
nil - concise: READING + 5 most recent WANT, others as summary links
|
||||
all - full listing, no truncation
|
||||
read - only READ books
|
||||
want - only WANT books
|
||||
SHELF-FILTER limits to one shelf by name (string). nil = all shelves.
|
||||
SELECTED-ID expands the matching book's detail section."
|
||||
(let* ((status-labels
|
||||
'(("WANT" . "on deck")
|
||||
("READING" . "reading")
|
||||
("READ" . "read")
|
||||
("ABANDONED" . "abandoned")))
|
||||
(format-icons
|
||||
(let* ((format-icons
|
||||
'(("hardcover" . "ti-book")
|
||||
("ebook" . "ti-device-tablet")
|
||||
("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))
|
||||
(total (length books))
|
||||
(reading-count 0))
|
||||
(all-shelves (or (spine-shelves) '()))
|
||||
(total (length books)))
|
||||
;; Group books by shelf
|
||||
(dolist (book books)
|
||||
(when (equal "READING" (plist-get book :status))
|
||||
(cl-incf reading-count)))
|
||||
(dolist (book books)
|
||||
(let ((status (or (plist-get book :status) "WANT")))
|
||||
(push book (gethash status grouped))))
|
||||
(let ((shelf (or (plist-get book :shelf) "Uncategorized")))
|
||||
(push book (gethash shelf grouped))))
|
||||
(let ((groups nil)
|
||||
(summary-groups nil))
|
||||
;; Concise view: sort all books by :LAST_MODIFIED:, take top 5
|
||||
(when (null effective-filter)
|
||||
(clrhash grouped)
|
||||
(dolist (b (cl-subseq (sort (copy-sequence books)
|
||||
(lambda (a b)
|
||||
(let ((a-date (plist-get a :last_modified))
|
||||
(b-date (plist-get b :last_modified)))
|
||||
(cond
|
||||
((null a-date) nil)
|
||||
((null b-date) t)
|
||||
(t (string> a-date b-date))))))
|
||||
0 (min (length books) 5)))
|
||||
(let ((status (or (plist-get b :status) "WANT")))
|
||||
(push b (gethash status grouped)))))
|
||||
(dolist (status order)
|
||||
(let ((group-books (nreverse (gethash status grouped))))
|
||||
(when group-books
|
||||
(if (and (null effective-filter)
|
||||
(member status '("READ" "ABANDONED")))
|
||||
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||
("count" (length group-books))
|
||||
("href" (format "/index?filter=%s"
|
||||
(downcase status))))
|
||||
summary-groups)
|
||||
(when (or (null effective-filter)
|
||||
(string= (downcase status) effective-filter)
|
||||
(string= effective-filter "all"))
|
||||
(let ((book-models nil))
|
||||
(dolist (book group-books)
|
||||
(let* ((id (plist-get book :id))
|
||||
(selected (equal id selected-id))
|
||||
(fmt (plist-get book :format))
|
||||
(rating (plist-get book :rating))
|
||||
(notes (plist-get book :notes))
|
||||
(status (or (plist-get book :status) "WANT"))
|
||||
(model
|
||||
(shelf-nav nil))
|
||||
;; Build shelf nav from all shelves (including empty)
|
||||
(dolist (shelf all-shelves)
|
||||
(push (ht ("name" shelf)
|
||||
("href" (format "/index?shelf=%s" shelf))
|
||||
("current" (equal shelf shelf-filter)))
|
||||
shelf-nav))
|
||||
(setq shelf-nav (nreverse shelf-nav))
|
||||
;; Build shelf groups
|
||||
(dolist (shelf all-shelves)
|
||||
(let ((shelf-books (nreverse (gethash shelf grouped))))
|
||||
(when (or (null shelf-filter) (equal shelf shelf-filter))
|
||||
(let ((book-models nil))
|
||||
(dolist (book shelf-books)
|
||||
(let* ((id (plist-get book :id))
|
||||
(selected (equal id selected-id))
|
||||
(fmt (plist-get book :format))
|
||||
(rating (plist-get book :rating))
|
||||
(notes (plist-get book :notes))
|
||||
(model
|
||||
(ht
|
||||
("format_icon"
|
||||
(or (cdr (assoc fmt format-icons)) ""))
|
||||
("title" (plist-get book :title))
|
||||
("author" (or (plist-get book :author) ""))
|
||||
("tags_inline"
|
||||
(let ((tags (plist-get book :tags)))
|
||||
(when tags
|
||||
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
||||
tags " "))))
|
||||
("rec_via" (plist-get book :rec_by))
|
||||
("rating_display"
|
||||
(when (and rating (> (length rating) 0))
|
||||
(make-string (string-to-number rating) ?\u2605)))
|
||||
("selected" selected)
|
||||
("detail"
|
||||
(when selected
|
||||
(ht
|
||||
("format_icon"
|
||||
(or (cdr (assoc fmt format-icons)) ""))
|
||||
("status_class" (downcase status))
|
||||
("status_label" (downcase status))
|
||||
("title" (plist-get book :title))
|
||||
("author" (or (plist-get book :author) ""))
|
||||
("tags_inline"
|
||||
(let ((tags (plist-get book :tags)))
|
||||
(when tags
|
||||
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
||||
tags " "))))
|
||||
("rec_via" (plist-get book :rec_by))
|
||||
("rating_display"
|
||||
(when (and rating (> (length rating) 0))
|
||||
(make-string (string-to-number rating) ?\u2605)))
|
||||
("selected" selected)
|
||||
("detail"
|
||||
(when selected
|
||||
(ht
|
||||
("meta"
|
||||
(let ((added (plist-get book :added))
|
||||
(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)))))))
|
||||
("meta"
|
||||
(let ((added (plist-get book :added))
|
||||
(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" shelf)
|
||||
("count" (length shelf-books))
|
||||
("books" (nreverse book-models)))
|
||||
groups)))))
|
||||
(ht ("app_title" "spine")
|
||||
("total_books" total)
|
||||
("reading_count" reading-count)
|
||||
("current_filter" effective-filter)
|
||||
("summary_groups" (nreverse summary-groups))
|
||||
("shelf_count" (length all-shelves))
|
||||
("current_shelf" shelf-filter)
|
||||
("shelf_nav" shelf-nav)
|
||||
("groups" (nreverse groups))
|
||||
("minibuffer"
|
||||
(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))
|
||||
(let ((book (gethash selected-id books-by-id)))
|
||||
(when book
|
||||
(let ((status (or (plist-get book :status) "WANT")))
|
||||
(ht ("actions"
|
||||
(append
|
||||
(cond
|
||||
((equal status "WANT")
|
||||
(list
|
||||
(ht ("command" "status") ("label" "Mark reading")
|
||||
("href" (format "/edit?id=%s&action=status&value=READING" selected-id)))
|
||||
(ht ("command" "note") ("label" "Add note")
|
||||
("href" (format "/edit?id=%s&action=note" selected-id)))))
|
||||
((equal status "READING")
|
||||
(list
|
||||
(ht ("command" "status") ("label" "Mark read")
|
||||
("href" (format "/edit?id=%s&action=status&value=READ" selected-id)))
|
||||
(ht ("command" "status") ("label" "Abandon")
|
||||
("href" (format "/edit?id=%s&action=status&value=ABANDONED" selected-id)))
|
||||
(ht ("command" "note") ("label" "Add note")
|
||||
("href" (format "/edit?id=%s&action=note" selected-id)))))
|
||||
((equal status "READ")
|
||||
(list
|
||||
(ht ("command" "status") ("label" "Read again")
|
||||
("href" (format "/edit?id=%s&action=status&value=READING" selected-id)))
|
||||
(ht ("command" "note") ("label" "Add note")
|
||||
("href" (format "/edit?id=%s&action=note" selected-id)))))
|
||||
((equal status "ABANDONED")
|
||||
(list
|
||||
(ht ("command" "status") ("label" "Try again")
|
||||
("href" (format "/edit?id=%s&action=status&value=READING" selected-id)))))
|
||||
(t nil))
|
||||
nil))))))))
|
||||
)))))
|
||||
(ht ("actions"
|
||||
(list
|
||||
(ht ("command" "note") ("label" "Add note")
|
||||
("href" (format "/edit?id=%s&action=note" selected-id)))
|
||||
(ht ("command" "rating") ("label" "Set rating")
|
||||
("href" (format "/edit?id=%s&action=rating" selected-id)))))))))))
|
||||
))))
|
||||
(defun spine-add-book (&rest args)
|
||||
"Add a new book to shelf `:shelf' in `spine-org-file'.
|
||||
Keyword arguments: :title :author :shelf :format :isbn :cover
|
||||
|
||||
@@ -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 'cl-lib)
|
||||
@@ -14,90 +14,61 @@
|
||||
(defconst spine-index-model-test--books (spine-books)
|
||||
"Books fixture loaded once from sample-books.org.")
|
||||
|
||||
(ert-deftest spine-index-model-concise-hides-read-and-abandoned ()
|
||||
"Concise view (filter=nil) omits READ/ABANDONED groups, adds summary-groups."
|
||||
(ert-deftest spine-index-model-groups-by-shelf ()
|
||||
"Index model groups books by :shelf."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(groups (ht-get model "groups"))
|
||||
(summaries (ht-get model "summary_groups"))
|
||||
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||
(should-not (member "read" group-labels))
|
||||
(should-not (member "abandoned" group-labels))
|
||||
(should summaries)
|
||||
(should (= (length summaries) 1))
|
||||
(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"))))
|
||||
(groups (ht-get model "groups")))
|
||||
(should groups)
|
||||
(should (= (length groups) 3)) ;; Fiction, Science Fiction, Non-Fiction
|
||||
(let ((labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||
(should (member "Fiction" labels))
|
||||
(should (member "Science Fiction" labels))
|
||||
(should (member "Non-Fiction" labels)))))
|
||||
|
||||
(ert-deftest spine-index-model-concise-includes-reading ()
|
||||
"Concise view includes READING group with all reading books."
|
||||
(ert-deftest spine-index-model-shelf-counts ()
|
||||
"Each shelf group has the correct number of books."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(groups (ht-get model "groups"))
|
||||
(reading-group (cl-find "reading" groups
|
||||
:key (lambda (g) (ht-get g "label"))
|
||||
:test #'string=)))
|
||||
(should reading-group)
|
||||
(should (= (ht-get reading-group "count") 1))
|
||||
(should (string= (ht-get (car (ht-get reading-group "books")) "title")
|
||||
"Use of Weapons"))))
|
||||
(groups (ht-get model "groups")))
|
||||
(dolist (g groups)
|
||||
(let ((label (ht-get g "label"))
|
||||
(count (ht-get g "count")))
|
||||
(cond
|
||||
((equal label "Fiction")
|
||||
(should (= count 4))
|
||||
(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 ()
|
||||
"Concise view limits WANT group to 5 books, sorted by :LAST_MODIFIED: desc."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(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"))
|
||||
(ert-deftest spine-index-model-filters-by-shelf ()
|
||||
"filter=shelf-name shows only that shelf's books."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "Fiction"))
|
||||
(groups (ht-get model "groups")))
|
||||
(should (= (length groups) 1))
|
||||
(should (string= (ht-get (car groups) "label") "read"))
|
||||
(should (= (ht-get (car groups) "count") 1))))
|
||||
(should (equal (ht-get (car groups) "label") "Fiction"))))
|
||||
|
||||
(ert-deftest spine-index-model-filter-want-returns-only-want ()
|
||||
"filter=want returns only the WANT group."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "want"))
|
||||
(groups (ht-get model "groups")))
|
||||
(should (= (length groups) 1))
|
||||
(should (string= (ht-get (car groups) "label") "on deck"))
|
||||
(should (= (ht-get (car groups) "count") 3))))
|
||||
(ert-deftest spine-index-model-includes-all-shelves-in-nav ()
|
||||
"Shelf nav includes all shelves, including empties."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(shelf-nav (ht-get model "shelf_nav")))
|
||||
(should (= (length shelf-nav) 3))
|
||||
(should (member "Non-Fiction" (mapcar (lambda (n) (ht-get n "name")) shelf-nav)))))
|
||||
|
||||
(ert-deftest spine-index-model-invalid-filter-falls-back-to-concise ()
|
||||
"Unrecognised filter value behaves like nil (concise view)."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "bogus"))
|
||||
(groups (ht-get model "groups"))
|
||||
(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-current-shelf ()
|
||||
"current_shelf is set when filter is active."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "Science Fiction")))
|
||||
(should (equal (ht-get model "current_shelf") "Science Fiction"))))
|
||||
|
||||
(ert-deftest spine-index-model-includes-current-filter ()
|
||||
"Model includes current_filter key matching the filter argument."
|
||||
(should (not (ht-get (spine-index-model spine-index-model-test--books) "current_filter")))
|
||||
(should (equal (ht-get (spine-index-model spine-index-model-test--books "all") "current_filter") "all"))
|
||||
(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-no-current-shelf-without-filter ()
|
||||
"current_shelf is nil when no filter."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books)))
|
||||
(should-not (ht-get model "current_shelf"))))
|
||||
|
||||
(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