From de057de0bf857093aee4debf2f5920e8472e84cf Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Sun, 21 Jun 2026 22:54:48 -0400 Subject: [PATCH] feat: rewrite spine-index-model for shelf grouping --- spine.el | 209 ++++++++++++--------------------- test/spine-index-model-test.el | 129 ++++++++------------ 2 files changed, 126 insertions(+), 212 deletions(-) diff --git a/spine.el b/spine.el index 0f2acaa..f78856d 100644 --- a/spine.el +++ b/spine.el @@ -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 diff --git a/test/spine-index-model-test.el b/test/spine-index-model-test.el index 15992d9..ec339b0 100644 --- a/test/spine-index-model-test.el +++ b/test/spine-index-model-test.el @@ -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))))