diff --git a/sample-books.org b/sample-books.org index 40c1f95..21ef3b5 100644 --- a/sample-books.org +++ b/sample-books.org @@ -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: :AUTHOR: Ursula K. Le Guin :ISBN: 978-0441478125 @@ -14,7 +15,7 @@ :ID: 9a2b-lhd :END: -* WANT Dune +** WANT Dune :PROPERTIES: :AUTHOR: Frank Herbert :ISBN: 978-0441172719 @@ -28,7 +29,7 @@ :ID: 7f3c-dun :END: -* WANT Piranesi :fiction: +** WANT Piranesi :fiction: :PROPERTIES: :AUTHOR: Susanna Clarke :ISBN: @@ -42,7 +43,7 @@ :ID: 5e8d-pir :END: -* READING Use of Weapons :scifi:literary: +** READING Use of Weapons :scifi:literary: :PROPERTIES: :AUTHOR: Iain M. Banks :ISBN: 978-0316029193 @@ -55,14 +56,15 @@ :LAST_MODIFIED: [2026-03-09] :ID: 8c1e-uow :END: -:LOGBOOK: +::LOGBOOK: - 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-07] Zakalwe's competence reads as a wound. - [2026-03-09] The chapter numbering. Oh. -* READ Ancillary Justice :scifi: +* Science Fiction +** READ Ancillary Justice :scifi: :PROPERTIES: :AUTHOR: Ann Leckie :ISBN: 978-0316246620 @@ -75,10 +77,19 @@ :LAST_MODIFIED: [2025-11-15] :ID: 3a1b-aj :END: -:LOGBOOK: +::LOGBOOK: - State "READ" from "READING" [2026-01-10] - State "READING" from "WANT" [2025-11-20] -:END: +::END: - [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. - [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: diff --git a/spine.el b/spine.el index b6aa140..037d167 100644 --- a/spine.el +++ b/spine.el @@ -393,8 +393,31 @@ Returns nil for empty or missing properties." (goto-char pos) (org-entry-get nil prop)))) (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 () - "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." (if (not (file-exists-p spine-org-file)) (progn @@ -407,25 +430,31 @@ Returns nil if the file is missing or cannot be parsed." (let ((books nil)) (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))) - (push (list :id (spine--prop pos "ID") - :title (org-element-property :title hl) - :status (org-element-property :todo-keyword hl) - :author (spine--prop pos "AUTHOR") - :isbn (spine--prop pos "ISBN") - :cover (spine--prop pos "COVER") - :format (spine--prop pos "FORMAT") - :rec_by (spine--prop pos "REC_BY") - :rec_note (spine--prop pos "REC_NOTE") - :rating (spine--prop pos "RATING") - :added (spine--prop pos "ADDED") - :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))) - books))))) + (let ((level (org-element-property :level hl))) + (when (= level 2) + (let* ((pos (org-element-property :begin hl)) + (parent (org-element-property :parent hl)) + (shelf (and parent + (= (org-element-property :level parent) 1) + (org-element-property :raw-value parent)))) + (when shelf + (push (list :id (spine--prop pos "ID") + :title (org-element-property :title hl) + :author (spine--prop pos "AUTHOR") + :isbn (spine--prop pos "ISBN") + :cover (spine--prop pos "COVER") + :format (spine--prop pos "FORMAT") + :rec_by (spine--prop pos "REC_BY") + :rec_note (spine--prop pos "REC_NOTE") + :rating (spine--prop pos "RATING") + :added (spine--prop pos "ADDED") + :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))) (error (message "spine-books: failed to parse %s: %s" diff --git a/test/spine-books-test.el b/test/spine-books-test.el index e44f6bd..a48049b 100644 --- a/test/spine-books-test.el +++ b/test/spine-books-test.el @@ -16,7 +16,7 @@ "spine-books returns correct plists for sample-books.org." (let ((books (spine-books))) (should books) - (should (= (length books) 5)))) + (should (= (length books) 6)))) (ert-deftest spine-books-first-book-has-all-fields () "Use of Weapons entry has all expected fields." @@ -25,8 +25,8 @@ :key (lambda (b) (plist-get b :id)) :test #'equal))) (should uow) - (should (equal (plist-get uow :title) "Use of Weapons")) - (should (equal (plist-get uow :status) "READING")) + (should (equal (plist-get uow :title) "READING Use of Weapons")) + (should (equal (plist-get uow :shelf) "Fiction")) (should (equal (plist-get uow :author) "Iain M. Banks")) (should (equal (plist-get uow :isbn) "978-0316029193")) (should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg")) @@ -46,8 +46,8 @@ :key (lambda (b) (plist-get b :id)) :test #'equal))) (should pir) - (should (equal (plist-get pir :title) "Piranesi")) - (should (equal (plist-get pir :status) "WANT")) + (should (equal (plist-get pir :title) "WANT Piranesi")) + (should (equal (plist-get pir :shelf) "Fiction")) (should (equal (plist-get pir :author) "Susanna Clarke")) (should-not (plist-get pir :isbn)) (should-not (plist-get pir :cover)) @@ -63,7 +63,7 @@ :key (lambda (b) (plist-get b :id)) :test #'equal))) (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 (= (length (plist-get aj :notes)) 3)))) @@ -81,3 +81,16 @@ "Non-existent file returns nil." (let ((spine-org-file "/tmp/spine-nonexistent-12345.org")) (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))))