From f25dffa183fc86e92eff6b1eb0e58e440d6e1665 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Sat, 20 Jun 2026 16:57:57 -0400 Subject: [PATCH] feat: add spine-books model function with empty-prop nil handling --- sample-books.org | 2 +- spine.el | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/sample-books.org b/sample-books.org index 56d6bfe..49266dc 100644 --- a/sample-books.org +++ b/sample-books.org @@ -39,7 +39,7 @@ :ID: 5e8d-pir :END: -* READING Use of Weapons +* READING Use of Weapons :scifi:literary: :PROPERTIES: :AUTHOR: Iain M. Banks :ISBN: 978-0316029193 diff --git a/spine.el b/spine.el index 661d27c..db62390 100644 --- a/spine.el +++ b/spine.el @@ -16,6 +16,7 @@ (require 'simple-httpd) (require 'mustache) (require 'ht) +(require 'cl-lib) ;; --- configuration ---------------------------------------------------- @@ -26,6 +27,9 @@ nil means localhost only. Set to a string like \"0.0.0.0\" to listen on all interfaces.") +(defvar spine-org-file "~/spine/books.org" + "Path to the spine.org data file.") + (defvar spine-template-dir (expand-file-name "templates/" (file-name-directory load-file-name)) @@ -43,6 +47,78 @@ Returns the rendered string." (buffer-string)) context)) +(defun spine--h (text) + "HTML-escape TEXT." + (when text + (let ((s text)) + (setq s (replace-regexp-in-string "&" "&" s)) + (setq s (replace-regexp-in-string "<" "<" s)) + (setq s (replace-regexp-in-string ">" ">" s)) + (setq s (replace-regexp-in-string "\"" """ s)) + s))) + +(defun spine--extract-notes (hl-begin hl-end) + "Extract reading notes from headline body between HL-BEGIN and HL-END. +Returns list of (date-string text-string) pairs." + (let ((notes nil)) + (save-excursion + (goto-char hl-begin) + ;; Skip past PROPERTIES and LOGBOOK drawers + (while (re-search-forward + "^[ \t]*:\\(PROPERTIES\\|LOGBOOK\\):" hl-end t) + (re-search-forward "^[ \t]*:END:" hl-end t)) + ;; Collect note lines: "- [YYYY-MM-DD] text" + (while (re-search-forward + "^- \\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)\\] \\(.*\\)" + hl-end t) + (push (list (match-string 1) (match-string 2)) notes))) + (nreverse notes))) + + +(defun spine--prop (pos prop) + "Get Org property PROP at headline position POS. +Returns nil for empty or missing properties." + (let ((val (save-excursion + (goto-char pos) + (org-entry-get nil prop)))) + (and val (> (length val) 0) val))) +(defun spine-books () + "Return a list of plists, one per top-level headline in `spine-org-file'. +Returns nil if the file is missing or cannot be parsed." + (unless (file-exists-p spine-org-file) + (message "spine-books: file not found: %s" spine-org-file) + (cl-return-from spine-books nil)) + (condition-case err + (with-temp-buffer + (insert-file-contents spine-org-file) + (org-mode) + (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") + :tags (org-element-property :tags hl) + :notes (spine--extract-notes + (org-element-property :begin hl) + (org-element-property :end hl))) + books))))) + (nreverse books))) + (error + (message "spine-books: failed to parse %s: %s" + spine-org-file (error-message-string err)) + nil))) + (defvar spine-skip-server-start nil "When non-nil, do not start the HTTP server. Set by test harnesses that only need the model functions.")