diff --git a/spine.el b/spine.el index 82ff5b0..e434f07 100644 --- a/spine.el +++ b/spine.el @@ -69,12 +69,17 @@ Returns the rendered string." ("audiobook" "ti-headphones" "Audiobook")) "Alist of (format-key icon label) for book format display.") -(defun spine--format-date (org-date-str) - "Format Org date \"[2026-02-20]\" to \"20 Feb\"." +(defconst spine--status-defs + '(("want" "Want") ("reading" "Reading") ("read" "Read")) + "Alist of (key label) for reading status options.") + +(defun spine--format-date (org-date-str &optional include-year) + "Format Org date \"[2026-02-20]\" to \"20 Feb\". +With optional INCLUDE-YEAR, returns \"20 Feb 2026\"." (when (and org-date-str (> (length org-date-str) 0)) (let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str))) (condition-case nil - (format-time-string "%e %b" (date-to-time clean)) + (format-time-string (if include-year "%e %b %Y" "%e %b") (date-to-time clean)) (error org-date-str))))) (defun spine--parse-title-status (title) @@ -92,33 +97,35 @@ TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"." (defun spine-book-model (book) "Build view model `ht` for a single BOOK plist, for templates/book.mustache." (let* ((id (plist-get book :id)) - (title (or (plist-get book :title) "")) + (raw-title (or (plist-get book :title) "")) (author (or (plist-get book :author) "")) (fmt (plist-get book :format)) (added (plist-get book :added)) (notes (plist-get book :notes)) (rec-by (plist-get book :rec_by)) (rec-note (plist-get book :rec_note)) - (title-info (spine--parse-title-status title)) + (tags (plist-get book :tags)) + (rating (plist-get book :rating)) + (shelf-name (plist-get book :shelf)) + (title-info (spine--parse-title-status raw-title)) (clean-title (nth 0 title-info)) (status-class (nth 1 title-info)) (status-label (nth 2 title-info)) - (meta (cond - ((and added fmt) - (format "started %s \302\267 %s" (spine--format-date added) fmt)) - (added (format "started %s" (spine--format-date added))) - (fmt (format "format: %s" fmt)) - (t ""))) - (formats (mapcar - (lambda (fd) - (ht ("icon" (nth 1 fd)) - ("label" (nth 2 fd)) - ("active" (and fmt (equal (car fd) fmt))))) - spine--format-defs)) + (format-def (and fmt (assoc fmt spine--format-defs))) + (format-icon (if format-def (nth 1 format-def) "ti-book")) + (format-label (if format-def (nth 2 format-def) "")) + (rating-display + (cond + ((and rating (> (length rating) 0)) + (make-string (string-to-number rating) ?\u2605)) + ((equal status-class "read") "rated —") + (t "rate when finished"))) + (has-rating (and rating (> (length rating) 0))) (notes-model (mapcar (lambda (n) (ht ("date" (car n)) - ("text" (cadr n)))) + ("text" (cadr n)) + ("edit_url" (format "/edit?id=%s&action=note&id=%s" id (car n))))) notes)) (recommendation (when (and rec-by (> (length rec-by) 0)) @@ -130,16 +137,36 @@ TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"." (split-string rec-by " +" t) ""))) (ht ("initials" initials) ("by" rec-by) - ("note" (or rec-note ""))))))) + ("note" (or rec-note "")))))) + (status-options + (mapcar (lambda (sd) + (ht ("name" (cadr sd)) + ("selected" (equal (car sd) status-class)))) + spine--status-defs)) + (shelf-options + (let ((shelves (or (spine-shelves) '()))) + (mapcar (lambda (s) + (ht ("name" s) + ("selected" (and shelf-name (equal s shelf-name))))) + shelves)))) (ht ("cover_icon" "ti-book") ("id" id) ("title" clean-title) ("author" author) ("status_class" status-class) ("status_label" status-label) - ("meta" meta) - ("progress_label" "") - ("formats" formats) + ("shelf_name" (or shelf-name "")) + ("format_icon" format-icon) + ("format_label" format-label) + ("tags_display" + (if tags (mapconcat (lambda (tag) (format "%s" tag)) tags " ") "")) + ("added_display" (if added (spine--format-date added t) "")) + ("started_display" (if added (spine--format-date added t) "")) + ("finished_display" "—") + ("rating_display" rating-display) + ("has_rating" has-rating) + ("status_options" status-options) + ("shelf_options" shelf-options) ("notes" notes-model) ("recommendation" recommendation) ("back_url" (format "/index?id=%s" id))))) diff --git a/templates/book.mustache b/templates/book.mustache index 80a4933..bd9d5ba 100644 --- a/templates/book.mustache +++ b/templates/book.mustache @@ -5,100 +5,157 @@ Spine — {{title}} - -
-
-

← Back to shelf

+
+
+
-
-
-
-
-

{{title}}

-

{{author}}

-
- {{#status_class}} - {{status_label}} - {{/status_class}} - {{#meta}} - {{meta}} - {{/meta}} + -
-
- {{#formats}} - - {{/formats}} -
+
+
+
+

{{title}}

+

{{author}}

+ {{#status_class}} + {{status_label}} + {{/status_class}} +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + {{#format_label}} + + + {{/format_label}} + {{#tags_display}} + + + {{/tags_display}} + {{#added_display}} + + + {{/added_display}} + {{#started_display}} + + + {{/started_display}} + + + + +
Format{{format_label}}
Tags{{{tags_display}}}
Added{{added_display}}
Started{{started_display}}
Finished{{finished_display}}
Rating{{rating_display}}
+
- {{#notes}} -

Reading log

-
{{#notes}} -
-
{{date}}
-
{{text}}
+
+

Reading log

+ {{#notes}} +
+ {{date}} + {{text}} +
+ {{/notes}} +
+ + +
{{/notes}} -
- {{/notes}} -
- - -
+ {{#recommendation}} +
+

On your radar

+
+
{{initials}}
+
{{by}}
"{{note}}"
+
+
+ {{/recommendation}} -
+
+ + +
- {{#recommendation}} -

On your radar

-
-
{{initials}}
-
{{by}}
"{{note}}"
- {{/recommendation}} -
- - - -
- -
-
+ + diff --git a/test/spine-book-model-test.el b/test/spine-book-model-test.el index 8bedb99..b3b2ee5 100644 --- a/test/spine-book-model-test.el +++ b/test/spine-book-model-test.el @@ -15,19 +15,29 @@ (cl-find "8c1e-uow" (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal) - "Use of Weapons fixture.") + "Use of Weapons fixture (READING, audiobook, tags=scifi+literary, rec_by=Priya).") (defconst spine-book-model-test--aj (cl-find "3a1b-aj" (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal) - "Ancillary Justice fixture.") + "Ancillary Justice fixture (READ, ebook, rating=5, tags=scifi).") (defconst spine-book-model-test--pir (cl-find "5e8d-pir" (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal) - "Piranesi fixture.") + "Piranesi fixture (WANT, no format, no tags, no rec).") + +(defconst spine-book-model-test--tcm + (let* ((books (spine-books)) + (b (cl-find "b2c1-tcm" books + :key (lambda (x) (plist-get x :id)) + :test #'equal))) + b) + "The Checklist Manifesto fixture (no TODO, no format, no rec).") + +;; --- Title & status ---------------------------------------------------- (ert-deftest spine-book-model-title-strips-todo () "Title has TODO prefix stripped." @@ -35,47 +45,123 @@ (should (equal (ht-get model "title") "Use of Weapons")))) (ert-deftest spine-book-model-status-reading () - "READING book has status_class reading." + "READING status_class and label." (let ((model (spine-book-model spine-book-model-test--uow))) (should (equal (ht-get model "status_class") "reading")) (should (equal (ht-get model "status_label") "Reading")))) (ert-deftest spine-book-model-status-read () - "READ book has status_class read." + "READ status_class and label." (let ((model (spine-book-model spine-book-model-test--aj))) (should (equal (ht-get model "status_class") "read")) (should (equal (ht-get model "status_label") "Read")))) (ert-deftest spine-book-model-status-want () - "WANT book has status_class want." + "WANT status_class and label." (let ((model (spine-book-model spine-book-model-test--pir))) (should (equal (ht-get model "status_class") "want")) (should (equal (ht-get model "status_label") "Want")))) +(ert-deftest spine-book-model-no-todo-prefix () + "No TODO prefix shows title as-is and empty status." + (let ((model (spine-book-model spine-book-model-test--tcm))) + (should (equal (ht-get model "title") "The Checklist Manifesto")) + (should (equal (ht-get model "status_class") "")) + (should (equal (ht-get model "status_label") "")))) + +;; --- Metadata fields ---------------------------------------------------- + (ert-deftest spine-book-model-author () - "Author is passed through." + "Author passed through." (let ((model (spine-book-model spine-book-model-test--uow))) (should (equal (ht-get model "author") "Iain M. Banks")))) -(ert-deftest spine-book-model-meta-with-format-and-date () - "Meta includes format and date when both present." +(ert-deftest spine-book-model-shelf-name () + "Shelf_name passed through." (let ((model (spine-book-model spine-book-model-test--uow))) - (should (string-match "started.*audiobook" (ht-get model "meta"))))) + (should (equal (ht-get model "shelf_name") "Fiction")))) -(ert-deftest spine-book-model-format-active () - "Format list has correct active flag." +(ert-deftest spine-book-model-format-fields () + "Format icon and label match the book's format." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "format_icon") "ti-headphones")) + (should (equal (ht-get model "format_label") "Audiobook")))) + +(ert-deftest spine-book-model-format-none () + "No format yields empty label and default icon." + (let ((model (spine-book-model spine-book-model-test--pir))) + (should (equal (ht-get model "format_icon") "ti-book")) + (should (equal (ht-get model "format_label") "")))) + +(ert-deftest spine-book-model-tags-display () + "Tags rendered as HTML badge spans." + (let ((model (spine-book-model spine-book-model-test--uow))) + (let ((tags (ht-get model "tags_display"))) + (should (string-match "scifi" tags)) + (should (string-match "literary" tags))))) + +(ert-deftest spine-book-model-no-tags () + "No tags yields empty string." + (let ((model (spine-book-model spine-book-model-test--tcm))) + (should (equal (ht-get model "tags_display") "")))) + +(ert-deftest spine-book-model-started-display () + "started_display from ADDED property." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "started_display") "20 Feb 2026")))) + +(ert-deftest spine-book-model-added-display () + "added_display from ADDED property." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "added_display") "20 Feb 2026")))) + +(ert-deftest spine-book-model-finished-default () + "finished_display defaults to em-dash." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "finished_display") "—")))) + +(ert-deftest spine-book-model-rating-display () + "Rating produces star characters." + (let ((model (spine-book-model spine-book-model-test--aj))) + (should (equal (ht-get model "rating_display") "★★★★★")) + (should (ht-get model "has_rating")))) + +(ert-deftest spine-book-model-rating-none () + "No rating shows placeholder text." + (let ((model (spine-book-model spine-book-model-test--uow))) + (should (equal (ht-get model "rating_display") "rate when finished")) + (should-not (ht-get model "has_rating")))) + +;; --- Format/status/shelf options --------------------------------------- + +(ert-deftest spine-book-model-status-options () + "status_options includes all statuses with one selected." (let* ((model (spine-book-model spine-book-model-test--uow)) - (formats (ht-get model "formats"))) - (should (= (length formats) 3)) - (should (equal (ht-get (nth 0 formats) "label") "Hardcover")) - (should-not (ht-get (nth 0 formats) "active")) - (should (equal (ht-get (nth 1 formats) "label") "eBook")) - (should-not (ht-get (nth 1 formats) "active")) - (should (equal (ht-get (nth 2 formats) "label") "Audiobook")) - (should (ht-get (nth 2 formats) "active")))) + (opts (ht-get model "status_options"))) + (should (= (length opts) 3)) + (should (equal (ht-get (nth 0 opts) "name") "Want")) + (should-not (ht-get (nth 0 opts) "selected")) + (should (equal (ht-get (nth 1 opts) "name") "Reading")) + (should (ht-get (nth 1 opts) "selected")) + (should (equal (ht-get (nth 2 opts) "name") "Read")) + (should-not (ht-get (nth 2 opts) "selected")))) + +(ert-deftest spine-book-model-shelf-options () + "shelf_options lists all shelves with current one selected." + (let* ((model (spine-book-model spine-book-model-test--uow)) + (opts (ht-get model "shelf_options"))) + (should (>= (length opts) 3)) + (let ((fiction (cl-find "Fiction" opts :key (lambda (o) (ht-get o "name")) :test #'equal))) + (should fiction) + (should (ht-get fiction "selected"))) + (let ((scifi (cl-find "Science Fiction" opts :key (lambda (o) (ht-get o "name")) :test #'equal))) + (should scifi) + (should-not (ht-get scifi "selected"))))) + +;; --- Notes ------------------------------------------------------------- (ert-deftest spine-book-model-notes () - "Notes are mapped correctly." + "Notes mapped with date and text." (let* ((model (spine-book-model spine-book-model-test--uow)) (notes (ht-get model "notes"))) (should (= (length notes) 3)) @@ -83,6 +169,15 @@ (should (equal (ht-get (nth 0 notes) "text") "The two-track structure is doing something I can't name yet.")))) +(ert-deftest spine-book-model-notes-empty () + "No notes yields empty array." + (let* ((model (spine-book-model spine-book-model-test--tcm)) + (notes (ht-get model "notes"))) + (should (listp notes)) + (should (= (length notes) 0)))) + +;; --- Recommendation ---------------------------------------------------- + (ert-deftest spine-book-model-recommendation () "Recommendation includes initials, by, and note." (let* ((model (spine-book-model spine-book-model-test--uow)) @@ -97,27 +192,7 @@ (let ((model (spine-book-model spine-book-model-test--aj))) (should-not (ht-get model "recommendation")))) -(ert-deftest spine-book-model-no-todo-prefix () - "Book with no TODO prefix shows title as-is and empty status." - (let* ((books (spine-books)) - (tcm (cl-find "b2c1-tcm" books - :key (lambda (b) (plist-get b :id)) - :test #'equal))) - (should tcm) - (let ((model (spine-book-model tcm))) - (should (equal (ht-get model "title") "The Checklist Manifesto")) - (should (equal (ht-get model "status_class") "")) - (should (equal (ht-get model "status_label") ""))))) - -(ert-deftest spine-book-model-has-date-no-format () - "Has date but no format → meta shows started date only." - (let* ((books (spine-books)) - (tcm (cl-find "b2c1-tcm" books - :key (lambda (b) (plist-get b :id)) - :test #'equal))) - (should tcm) - (let ((model (spine-book-model tcm))) - (should (equal (ht-get model "meta") "started 15 Jun"))))) +;; --- Navigation -------------------------------------------------------- (ert-deftest spine-book-model-back-url () "back_url links to index with this book's id."