diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa7fe4e --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Overview + +Spine is a tool for managing your personal library. Spine keeps track of books you want to read, are reading and have read. Spine lets you track notes about the books and why you were interested in them in the first place. + +# Design + +Spine is primarily a web application where you view and enter details about books. To ensure your data is fully portable and independent of Spine itself, your book data is backed by a single Org file ( http://orgmode.org/ ) + +The application itself is written in Elisp and runs inside of Emacs but serves up its interface as HTML derived from your `spine.org` file. diff --git a/spine-mockup-a-agenda.html b/spine-mockup-a-agenda.html new file mode 100644 index 0000000..87616bb --- /dev/null +++ b/spine-mockup-a-agenda.html @@ -0,0 +1,124 @@ + + + + + +Spine — Direction A (agenda) + + + + +
+
+
+ spine · agenda + 6 books · 1 reading +
+ +
on deck · 3
+
+ + want + Tomorrow, and Tomorrow, and Tomorrow · Zevin + via Priya +
+
+ + want + Children of Time · Tchaikovsky + :scifi: +
+
+ + want + The Spear Cuts Through Water · Jimenez + via book club +
+ +
reading · 1
+
+ + reading + Use of Weapons · Banks + :scifi:literary: +
+
+
started [2026-02-20] · hardcover · 62% · rating —
+
[03-02]The two-track structure is doing something I can't name yet. Suspicious of the chair.
+
[03-07]Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.
+
[03-09]The chapter numbering. Oh.
+
+ +
read · 2
+
+ + read + A Memory Called Empire · Martine + ★★★★★ +
+
+ + read + Piranesi · Clarke + ★★★★☆ +
+ +
+ M-x + spine-note + Banks lets the structure detonate the whole book +
+
+
+ + diff --git a/spine-mockup-b-journal.html b/spine-mockup-b-journal.html new file mode 100644 index 0000000..d4a1eab --- /dev/null +++ b/spine-mockup-b-journal.html @@ -0,0 +1,118 @@ + + + + + +Spine — Direction B (journal) + + + + +
+
+
+ +
+
+
+
Use of Weapons
+
Iain M. Banks
+ Reading + started 20 Feb · 62% +
+
+ +
+
Hardcover
+
eBook
+
Audiobook
+
+ +
Reading log
+
+
+
2 Mar
+
The two-track structure is doing something I can't name yet. Suspicious of the chair.
+
+
+
7 Mar
+
Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.
+
+
+
9 Mar
+
The chapter numbering. Oh.
+
+
+ +
+ + +
+ +
+
On your radar
+
+
PK
+
Priya · 12 Jan
"If you liked Player of Games, this one will wreck you in a better way."
+
+
+ + + +
+
+ +
+
+
+ + diff --git a/spine-mockup-b-journal.mustache b/spine-mockup-b-journal.mustache new file mode 100644 index 0000000..0dcd58f --- /dev/null +++ b/spine-mockup-b-journal.mustache @@ -0,0 +1,117 @@ + + + + + + +Spine — Direction B (journal) + + + + + +{{! + View model for this template: + { + cover_icon: string, Tabler class for the cover placeholder, e.g. "ti-book" + title: string, + author: string, + status_class: string, "want" | "reading" | "read" + status_label: string, + meta: string, e.g. "started 20 Feb · 62%" + progress_label: string, e.g. "62%" (used in the note composer placeholder) + formats: [ { icon: string, label: string, active: boolean } ], the format selector segments + notes: [ { date: string, text: string } ], reading log + recommendation: { optional + initials: string, + by: string, + date: string, + note: string + }? + } +}} +
+
+ +
+
+
+
+

{{title}}

+

{{author}}

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

Reading log

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

On your radar

+ {{#recommendation}} +
+
{{initials}}
+
{{by}} · {{date}}
"{{note}}"
+
+ {{/recommendation}} +
+ + + +
+ +
+
+ + diff --git a/spine-spec.md b/spine-spec.md new file mode 100644 index 0000000..c8fd99b --- /dev/null +++ b/spine-spec.md @@ -0,0 +1,155 @@ +# Spine — design brief + +A personal library and reading tracker. Tracks books I want to read, am reading, +have read, and what I think about them; captures notes repeatedly through a read; +and records who recommended a book and why. + +This document is a starting point for a coding agent. Several decisions are +deliberately **left open** (see "Open decisions"). Do not invent answers for +those — scaffold around them or ask. + +## Core features + +- Track each book through a reading lifecycle: want → reading → read (or abandoned). +- Capture **multiple timestamped notes** across a single read, not one review at the end. +- Record **recommendations**: who recommended a book and a short "why it's on my radar" + note. This is context shown inline, not a search/analytics concern. +- Flag the **format** consumed: hardcover, ebook, or audiobook. +- Rate finished books. +- Browse "on deck" (want-to-read) and optionally filter by category. + +## Architecture + +- **Source of truth is a single Org file.** No database. +- **Emacs owns the file.** All reads and writes go through a running Emacs via + `emacsclient`, so `org-element` does the parsing and serialization canonically. + This avoids third-party Org parser fidelity problems on write. +- **No read/search index initially.** Queries are simple status + tag filters + answered live by `org-ql`. A derived SQLite index can be added later if the + recommendation data ever needs real querying; it is explicitly out of scope now. +- The **web layer is a thin client** over emacsclient. Its language and framework + are undecided (see Open decisions). + +## Data model (Org) + +Each book is a top-level headline whose TODO state is its lifecycle status. + +```org +#+TITLE: Spine +#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a) +#+STARTUP: logdrawer + +* READING Use of Weapons +:PROPERTIES: +:AUTHOR: Iain M. Banks +:FORMAT: audiobook +:REC_BY: Priya +:REC_NOTE: If you liked Player of Games, this one will wreck you in a better way. +:RATING: +:ADDED: [2026-02-20] +:ID: 8c1e-uow +:END: +:LOGBOOK: +- State "READING" from "WANT" [2026-02-20] +: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. Banks never lets a skill be free. +- [2026-03-09] The chapter numbering. Oh. +``` + +Field notes: + +- **Status**: the TODO keyword. `WANT READING | READ ABANDONED`. +- **Status history**: comes free from the LOGBOOK drawer when + `org-log-into-drawer` is enabled. Distinct from reading notes. +- **Reading notes**: a plain Org list in the headline body, each item prefixed + with an inactive timestamp `[YYYY-MM-DD]`. Appending a note is inserting one + list item. Notes are not individually queried. +- **Format**: single `:FORMAT:` property — `hardcover` | `ebook` | `audiobook`. + (See Open decisions for the multi-format case.) +- **Recommendation**: `:REC_BY:` plus optional `:REC_NOTE:`. Surfaced inline as + context, never ranked or searched. +- **Category**: Org **tags** on the headline (`:scifi:literary:`), not a property — + tags filter natively and inherit. +- **Rating**: `:RATING:` 1–5, set when moved to READ. + +## emacsclient integration + +### Read: on-deck list + +```elisp +(require 'org-ql) +(require 'json) + +(defun spine-ondeck (&optional tag) + "JSON of WANT books, optionally filtered by TAG." + (json-serialize + (vconcat + (org-ql-select "~/spine/books.org" + (if tag `(and (todo "WANT") (tags ,tag)) '(todo "WANT")) + :action + (lambda () + (list :title (org-get-heading t t t t) + :author (org-entry-get nil "AUTHOR") + :format (org-entry-get nil "FORMAT") + :rec_by (org-entry-get nil "REC_BY") + :tags (vconcat (org-get-tags)))))))) + +(defun spine-ondeck-file (path &optional tag) + "Write `spine-ondeck' output to PATH (avoids emacsclient quoting)." + (with-temp-file path (insert (spine-ondeck tag)))) +``` + +Invoke: `emacsclient --eval '(spine-ondeck-file "/tmp/spine.json")'`, then read +the file from the web layer. (`emacsclient --eval` prints values with `prin1`, so +returning a JSON string directly comes back quoted/escaped — the temp-file hop +sidesteps that. A small `simple-httpd` endpoint is the alternative; undecided.) + +### Write: to implement + +- `spine-add-note` — append a `[today] text` list item under a book's headline. +- `spine-add-book` — insert a new WANT headline with initial properties. +- `spine-set-status` / `spine-set-rating` / `spine-set-format`. + +These set state through `org-element` / standard Org commands, not text munging. +Not yet written. + +## UI + +Two directions are mocked as standalone HTML (`spine-mockup-a-agenda.html`, +`spine-mockup-b-journal.html`). They render the same Org record through different +front doors. + +- **Direction A — agenda (TUI).** An org-agenda-style surface grouped by status. + Format shows as a leading glyph, the recommendation rides on the row as "why + it's here," the selected book expands inline to show its reading log, and + capture happens in a minibuffer rather than a form. This is the home surface. + +- **Direction B — journal card.** A book detail card. Format is a segmented + control, the reading log is a thread you append to with an inline composer, and + the recommendation has an explicit "who / why" capture form. This is the + drill-in view. + +**Recommended composition:** hybrid. Direction A as the home/browse surface, +Direction B as the detail view when a book is selected. Agenda answers "what now," +card answers "where am I with this one." Not locked in — building one alone is +fine. + +## Open decisions + +- **Glue language + web framework — undecided.** Possibly elisp end-to-end + (Emacs serves the UI too), possibly an external thin client shelling out to + emacsclient. Leave this unbound; don't hardwire a stack. +- **Read transport:** temp-file hop vs `simple-httpd` endpoint in Emacs. +- **Per-note format:** book-level `:FORMAT:` is the default. If a book is regularly + consumed across media (hardcover at home, audio on a commute), format may need + to move to per-note. Single-value for now. +- **Hybrid vs single UI direction** — see UI section. +- **Search index:** deferred. Not now. + +## Out of scope (for now) + +- Recommender ranking / "hit rate" analytics. +- Full-text search across notes. +- Any external book-metadata API (covers, ISBN lookup) — placeholder cover blocks + in the mockups are decoration only. diff --git a/spine.el b/spine.el index 0fdf171..f1636ae 100644 --- a/spine.el +++ b/spine.el @@ -57,6 +57,86 @@ Returns the rendered string." (setq s (replace-regexp-in-string "\"" """ s)) s))) +(defun spine-index-model (books &optional selected-id) + "Build the view model ht for templates/index.mustache from BOOKS. +SELECTED-ID expands the matching book's detail section." + (let* ((status-labels + '(("WANT" . "on deck") + ("READING" . "reading") + ("READ" . "read") + ("ABANDONED" . "abandoned"))) + (format-icons + '(("hardcover" . "ti-book") + ("ebook" . "ti-device-tablet") + ("audiobook" . "ti-headphones"))) + (order '("WANT" "READING" "READ" "ABANDONED")) + (grouped (make-hash-table :test 'equal)) + (total (length books)) + (reading-count 0)) + (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 ((groups nil)) + (dolist (status order) + (let ((group-books (nreverse (gethash status grouped)))) + (when group-books + (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 + (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 · %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))))) + (ht ("app_title" "spine") + ("total_books" total) + ("reading_count" reading-count) + ("groups" (nreverse groups)))))) + (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." @@ -120,129 +200,6 @@ Returns nil if the file is missing or cannot be parsed." spine-org-file (error-message-string err)) nil)))) -(defun spine-render-index (books &optional selected-id) - "Render the index page HTML for BOOKS, expanding the book matching SELECTED-ID." - (let ((order '("WANT" "READING" "READ" "ABANDONED")) - (grouped (make-hash-table :test 'equal)) - (total 0) - (reading-count 0)) - ;; Count totals - (dolist (book books) - (cl-incf total) - (when (equal "READING" (plist-get book :status)) - (cl-incf reading-count))) - ;; Group books by status - (dolist (book books) - (let* ((status (or (plist-get book :status) "WANT"))) - (push book (gethash status grouped)))) - ;; Render - (with-temp-buffer - (insert "\n\n\n") - (insert "\n") - (insert "\n") - (insert "Spine\n") - (insert "\n") - (insert "\n") - (insert "\n\n") - (insert "
\n") - (insert "
\n") - (insert (format "
spine" - "%d books · %d reading
\n" - total reading-count)) - ;; Each group in order - (dolist (status order) - (let ((group-books (nreverse (gethash status grouped)))) - (when group-books - (insert (format "

%s

%d
\n" - status (length group-books))) - (dolist (book group-books) - (let ((selected (equal (plist-get book :id) selected-id))) - (insert (format "
\n" - (if selected " spine-selected" ""))) - ;; Format glyph (emoji) - (let ((fmt (plist-get book :format))) - (insert (format "%s\n" - (cond ((equal fmt "hardcover") "📖") - ((equal fmt "ebook") "📱") - ((equal fmt "audiobook") "🎧") - (t ""))))) - ;; Title + author - (insert "") - (insert (spine--h (plist-get book :title))) - (let ((author (plist-get book :author))) - (when author - (insert (format " %s" - (spine--h author))))) - (insert "\n") - ;; Tags - (let ((tags (plist-get book :tags))) - (when tags - (insert (format "%s\n" - (mapconcat (lambda (tag) (concat ":" tag ":")) - tags " "))))) - ;; Recommendation trail - (let ((rec-by (plist-get book :rec_by)) - (rec-note (plist-get book :rec_note))) - (when (and rec-by (> (length rec-by) 0)) - (insert - (format "%s%s\n" - (spine--h rec-by) - (if (and rec-note (> (length rec-note) 0)) - (concat ": " (spine--h rec-note)) - ""))))) - ;; Status pill - (let ((status (or (plist-get book :status) "WANT"))) - (insert (format "%s\n" - (downcase status) (downcase status)))) - ;; Expanded detail - (when selected - (insert "
\n") - (dolist (note (plist-get book :notes)) - (insert (format "

[%s] %s

\n" - (spine--h (car note)) - (spine--h (cadr note))))) - (let ((parts nil)) - (let ((rating (plist-get book :rating))) - (when (and rating (> (length rating) 0)) - (push (format "Rating: %s/5" (spine--h rating)) parts))) - (let ((isbn (plist-get book :isbn))) - (when (and isbn (> (length isbn) 0)) - (push (format "ISBN: %s" (spine--h isbn)) parts))) - (let ((fmt (plist-get book :format))) - (when (and fmt (> (length fmt) 0)) - (push (format "Format: %s" (spine--h fmt)) parts))) - (let ((added (plist-get book :added))) - (when (and added (> (length added) 0)) - (push (format "Added: %s" (spine--h added)) parts))) - (when parts - (insert (format "\n" - (mapconcat #'identity (nreverse parts) " · "))))) - (insert "
\n")) - (insert "
\n")))))) - (insert "
\n") - (insert "
\n\n\n") - (buffer-string)))) (defun spine-render-empty-state () "Render a page indicating no books file was found." @@ -269,7 +226,8 @@ Set by test harnesses that only need the model functions.") (defservlet index text/html (path query request) (let ((books (spine-books))) (if books - (insert (spine-render-index books (cadr (assoc "id" query)))) + (insert (spine-render "index.mustache" + (spine-index-model books (cadr (assoc "id" query))))) (insert (spine-render-empty-state))))) (defun httpd/ (proc uri-path query request) diff --git a/templates/index.mustache b/templates/index.mustache new file mode 100644 index 0000000..967d026 --- /dev/null +++ b/templates/index.mustache @@ -0,0 +1,117 @@ + + + + + + +Spine — Direction A (agenda) + + + + + +{{! + View model for this template: + { + app_title: string, + total_books: number, + reading_count: number, + groups: [ + { + label: string, e.g. "on deck" + count: number, + books: [ + { + format_icon: string, Tabler class, e.g. "ti-headphones" (data layer maps FORMAT -> icon) + status_class: string, "want" | "reading" | "read" (maps Org TODO state -> pill style) + status_label: string, text shown in the pill + title: string, + author: string, + tags_inline: string?, optional, e.g. ":scifi:literary:" + rec_via: string?, optional, REC_BY -> rendered as "via {name}" + rating_display: string?, optional, e.g. "★★★★☆" + selected: boolean?, marks the in-focus row (expands detail) + detail: { optional; render when a row is expanded + meta: string, e.g. "started [2026-02-20] · hardcover · 62% · rating —" + notes: [ { date: string, text: string } ] reading log + }? + } + ] + } + ], + minibuffer: { command: string, text: string }? optional capture line + } +}} +
+
+
+ {{app_title}} + {{total_books}} books · {{reading_count}} reading +
+ + {{#groups}} +
{{label}} · {{count}}
+ {{#books}} +
+ + {{status_label}} + {{title}} · {{author}} + {{#tags_inline}}{{tags_inline}}{{/tags_inline}} + {{#rec_via}}via {{rec_via}}{{/rec_via}} + {{#rating_display}}{{rating_display}}{{/rating_display}} +
+ {{#detail}} +
+
{{meta}}
+ {{#notes}} +
{{date}}{{text}}
+ {{/notes}} +
+ {{/detail}} + {{/books}} + {{/groups}} +
+ + {{#minibuffer}} + + {{/minibuffer}} +
+
+ +