No books yet.
\n" ";;; spine.el — personal reading tracker, served from Emacs ;; --- package bootstrap ------------------------------------------------ (require 'package) (add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t) (package-initialize) (dolist (pkg '(simple-httpd mustache ht)) (unless (package-installed-p pkg) (package-refresh-contents) (package-install pkg))) (require 'simple-httpd) (require 'mustache) (require 'ht) (require 'cl-lib) ;; --- configuration ---------------------------------------------------- (defvar spine-port 8080 "Port for the Spine HTTP server.") (defvar spine-host nil "Host for the Spine HTTP server. 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)) "Directory containing .mustache templates.") ;; --- template rendering ----------------------------------------------- (defun spine-render (name context) "Render templates/NAME.mustache with CONTEXT (an ht hash table). Returns the rendered string." (mustache-render (with-temp-buffer (insert-file-contents (expand-file-name name spine-template-dir)) (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-add-form-model (books) "Build view model for templates/add.mustache from current BOOKS." (ht ("app_title" "spine") ("shelves" (or (spine-shelves) '())))) (defconst spine--format-defs '(("hardcover" "ti-book" "Hardcover") ("ebook" "ti-device-tablet" "eBook") ("audiobook" "ti-headphones" "Audiobook")) "Alist of (format-key icon label) for book format display.") (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 (if include-year "%e %b %Y" "%e %b") (date-to-time clean)) (error org-date-str))))) (defun spine--parse-title-status (title) "Return (CLEAN-TITLE STATUS-CLASS STATUS-LABEL) from a book TITLE. TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"." (let ((case-fold-search nil)) (if (string-match "\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'" title) (let ((kw (match-string 1 title)) (rest (match-string 2 title))) (list rest (downcase kw) (capitalize (downcase kw)))) (list title "" "")))) (defun spine-book-model (book) "Build view model `ht` for a single BOOK plist, for templates/book.mustache." (let* ((id (plist-get book :id)) (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)) (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)) (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)) ("edit_url" (format "/edit?id=%s&action=note&id=%s" id (car n))))) notes)) (recommendation (when (and rec-by (> (length rec-by) 0)) (let ((initials (mapconcat (lambda (s) (when (> (length s) 0) (upcase (substring s 0 1)))) (split-string rec-by " +" t) ""))) (ht ("initials" initials) ("by" rec-by) ("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) ("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))))) (defun spine-index-model (books &optional shelf-filter selected-id) "Build the view model ht for templates/index.mustache from BOOKS. SHELF-FILTER limits to one shelf by name (string). nil = all shelves. SELECTED-ID expands the matching book's detail section." (let* ((grouped (make-hash-table :test 'equal)) (all-shelves (or (spine-shelves) '())) (total (length books))) ;; Group books by shelf (dolist (book books) (let ((shelf (or (plist-get book :shelf) "Uncategorized"))) (push book (gethash shelf grouped)))) (let ((groups nil) (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 (nth 1 (assoc fmt spine--format-defs)) "")) ("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) ("book_url" (format "/book?id=%s" id)) ("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" shelf) ("count" (length shelf-books)) ("books" (nreverse book-models))) groups))))) (ht ("app_title" "spine") ("total_books" total) ("shelf_count" (length all-shelves)) ("current_shelf" shelf-filter) ("shelf_nav" shelf-nav) ("groups" (nreverse groups)) ("minibuffer" (when selected-id (let* ((books-by-id (make-hash-table :test 'equal))) (dolist (b books) (puthash (plist-get b :id) b books-by-id)) (let ((book (gethash selected-id books-by-id))) (when book (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 :rec_by :rec_note :date_added :initial_note :shelf is required. Signals an error if the shelf doesn't exist." (let ((title (plist-get args :title)) (shelf (plist-get args :shelf)) (author (plist-get args :author)) (format (plist-get args :format)) (isbn (plist-get args :isbn)) (cover (plist-get args :cover)) (rec-by (plist-get args :rec_by)) (rec-note (plist-get args :rec_note)) (date-added (plist-get args :date_added)) (initial-note (plist-get args :initial_note))) (unless title (error "spine-add-book: title is required")) (unless shelf (error "spine-add-book: :shelf is required")) (let ((org-file (expand-file-name spine-org-file))) (unless (file-exists-p org-file) (error "spine-add-book: file %s does not exist; create shelves first" org-file)) (with-current-buffer (find-file-noselect org-file) (unwind-protect (progn ;; Find the shelf headline (goto-char (point-min)) (let ((shelf-found nil)) (while (and (not shelf-found) (re-search-forward (format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote shelf)) nil t)) (when (= (org-current-level) 1) (setq shelf-found t))) (unless shelf-found (error "spine-add-book: shelf \"%s\" not found" shelf))) ;; Go to end of shelf section (org-end-of-subtree) ;; Insert new book headline at level 2 (org-insert-heading nil t) (org-demote-subtree) (insert title) ;; Set properties (only non-empty) (when (and author (> (length author) 0)) (org-set-property "AUTHOR" author)) (when (and format (> (length format) 0)) (org-set-property "FORMAT" format)) (when (and isbn (> (length isbn) 0)) (org-set-property "ISBN" isbn)) (when (and cover (> (length cover) 0)) (org-set-property "COVER" cover)) (when (and rec-by (> (length rec-by) 0)) (org-set-property "REC_BY" rec-by)) (when (and rec-note (> (length rec-note) 0)) (org-set-property "REC_NOTE" rec-note)) (when (and date-added (> (length date-added) 0)) (org-set-property "ADDED" (format "[%s]" date-added))) ;; Set ID (org-set-property "ID" (format "%04x-%s" (random 65535) (substring (sha1 (concat title (number-to-string (random)))) 0 4))) ;; Set last modified (org-set-property "LAST_MODIFIED" (format-time-string "[%Y-%m-%d]")) ;; Add initial note (when (and initial-note (> (length initial-note) 0)) (let ((date (if (and date-added (> (length date-added) 0)) date-added (format-time-string "%Y-%m-%d")))) (goto-char (org-entry-end-position)) (insert (format "\n- [%s] %s\n" date initial-note)))) (save-buffer) t) (kill-buffer)))))) (defun spine-add-note (id text &optional date) "Append a reading note to the book with ID. TEXT is the note content. DATE is an optional YYYY-MM-DD string \(defaults to today). Signals an error if the book is not found." (let ((org-file (expand-file-name spine-org-file)) (date-str (or date (format-time-string "%Y-%m-%d")))) (unless (file-exists-p org-file) (error "spine-add-note: file not found: %s" org-file)) (with-current-buffer (find-file-noselect org-file) (unwind-protect (progn (org-element-map (org-element-parse-buffer 'headline) 'headline (lambda (hl) (when (= (org-element-property :level hl) 2) (let ((hl-id (spine--prop (org-element-property :begin hl) "ID"))) (when (equal id hl-id) (goto-char (org-element-property :end hl)) (skip-chars-backward " \t\n") (insert (format "\n- [%s] %s\n" date-str text)))))) nil 'headline) (unless (eq (point) (point-min)) (save-buffer) t) (when (eq (point) (point-min)) (error "spine-add-note: no book found with ID %s" id))) (kill-buffer))))) (defun spine-set-rating (id rating) "Set the RATING property of book with ID to RATING (1-5 string). Signals an error if the book is not found." (let ((org-file (expand-file-name spine-org-file))) (unless (file-exists-p org-file) (error "spine-set-rating: file not found: %s" org-file)) (with-current-buffer (find-file-noselect org-file) (unwind-protect (progn (org-element-map (org-element-parse-buffer 'headline) 'headline (lambda (hl) (when (= (org-element-property :level hl) 2) (let ((pos (org-element-property :begin hl))) (when (equal id (spine--prop pos "ID")) (goto-char pos) (org-set-property "RATING" rating))))) nil 'headline) (unless (eq (point) (point-min)) (save-buffer) t) (when (eq (point) (point-min)) (error "spine-set-rating: no book found with ID %s" id))) (kill-buffer))))) (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-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 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 (message "spine-books: file not found: %s" spine-org-file) 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) (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" spine-org-file (error-message-string err)) nil)))) (defun spine-render-empty-state () "Render a page indicating no books file was found." (concat "\n\n
\n" "\n" "\n" "No books yet.
\n" "%s