6.1 KiB
Spine — data model and index page
Extends spine-spec.md with a concrete Org data structure
and defines the first real view: an index page that reads from spine.org and
displays books grouped by lifecycle status.
Data model (spine.org)
Each book is a top-level headline. The TODO keyword is the lifecycle state. Properties hold structured metadata. The headline body holds reading notes as a plain Org list with inactive timestamps.
TODO states
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
Properties
| Property | Required | Value |
|---|---|---|
:AUTHOR: |
yes | freeform string |
:ISBN: |
no | freeform string |
:COVER: |
no | path relative to spine.org |
:FORMAT: |
no | hardcover | ebook | audiobook |
:REC_BY: |
no | who recommended it |
:REC_NOTE: |
no | why it's on your radar |
:RATING: |
no | 1–5, set when moved to READ |
:ADDED: |
no | [YYYY-MM-DD] date added to spine |
:ID: |
yes | stable unique identifier (e.g. 8c1e-uow) |
Missing or empty properties → nil at read time. The view decides how to
render nils.
Tags
Org tags on the headline = categories (e.g. :scifi:literary:).
Tags filter and inherit natively in Org.
Body: reading notes
A plain Org list in the headline body. Each item is prefixed with an inactive
timestamp [YYYY-MM-DD]. Appending a note = inserting one list item.
- [2026-03-02] The two-track structure is doing something I can't name yet.
LOGBOOK
State-change history is captured automatically by org-log-into-drawer.
This gives us the "when read" timeline for free — each state transition gets
a timestamped entry.
:LOGBOOK:
- State "READING" from "WANT" [2026-02-20]
:END:
Full example entry
* READING Use of Weapons
:PROPERTIES:
:AUTHOR: Iain M. Banks
:ISBN: 978-0316029193
:COVER: covers/use-of-weapons.jpg
:FORMAT: audiobook
:REC_BY: Priya
:REC_NOTE: If you liked Player of Games, this one will wreck you
: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.
Architecture
Model/view split. Data extraction from Org is isolated from HTML rendering.
spine.org ──→ [spine-books] ──→ book plists ──→ [spine-render-index] ──→ HTML
Model layer: spine-books
Opens the Org file, walks top-level headlines with org-element, returns a
list of plists — one per book.
(spine-books) → list of plists
Per-book plist shape:
(:id "8c1e-uow"
:title "Use of Weapons"
:status "READING"
:author "Iain M. Banks"
:isbn "978-0316029193"
:cover "covers/use-of-weapons.jpg"
:format "audiobook"
:rec_by "Priya"
:rec_note "If you liked Player of Games…"
:rating nil
:added "[2026-02-20]"
:tags ("scifi" "literary")
:notes (("[2026-03-02]" "The two-track structure…")
("[2026-03-07]" "Zakalwe's competence…")))
Org file not found or invalid: spine-books returns nil and messages a
warning. The condition-case around the parse catches malformed Org.
Reading strategy: visit spine.org in a temp buffer, parse with
org-element-parse-buffer, walk top-level headlines. Full scan — adequate
for ~tens of books. No org-ql yet; add it later when live filtering is needed.
View layer: spine-render-index
Takes the book list and an optional selected ID, returns an HTML string. No CSS, no JavaScript — structural HTML with CSS class names matching mockup A so styles drop in later.
Grouping: books partitioned by status in this order:
WANT → READING → READ → ABANDONED.
Each group gets a header with count.
Collapsed row per book:
- Format glyph:
[H]hardcover,[E]ebook,[A]audiobook (nothing if unset) - Title + author
- Tags
- Recommendation trail (
REC_BY: REC_NOTE) when present - Status pill
Expanded detail (triggered by ?id= query param):
- Reading log: each note as date + text
- Properties: format, ISBN, rating, added date
Section structure:
<div class="frame">
<div class="titlebar">Spine</div>
<div class="group">WANT (3)</div>
<div class="row">…</div>
<div class="row">…</div>
<div class="row">…</div>
<div class="group">READING (1)</div>
<div class="row selected">
<div class="detail">
<!-- expanded notes for selected book -->
</div>
</div>
</div>
Empty state: when spine-books returns nil → page with "No books yet."
message.
Handler
(defservlet index text/html ()
(let ((books (spine-books)))
(if books
(insert (spine-render-index books
(ht-get (ht-from-query-string (httpd-query-string)) "id")))
(insert (spine-render-empty-state)))))
/→ index, all books collapsed/?id=8c1e-uow→ index with that book's detail expanded- Bogus
?id=→ no expansion, normal collapsed view
Configuration
(defvar spine-org-file "~/spine/books.org"
"Path to the spine.org data file.")
Overridable before load.
Error handling
| Case | Behavior |
|---|---|
spine.org missing |
spine-books returns nil → empty-state page |
spine.org invalid Org |
condition-case around parse, message error, return nil |
Bogus ?id= param |
no expansion, normal collapsed view |
| Package/bootstrap failures | surfaced at startup (existing scaffold behavior) |
Out of scope
- Write endpoints (add book, add note, set status/rating/format)
- CSS styling
- JavaScript interactivity
- Cover image display
org-qlintegration- Search or filtering beyond status grouping
Acceptance
spine-booksparses a samplespine.organd returns correct plists- Missing properties →
nil, not errors /?id=...expands the matching book's detail- Empty
spine.org(no headlines) → empty list, not error - Missing
spine.org→ empty-state page, no crash - Groups appear in order: WANT, READING, READ, ABANDONED
- Groups with zero books are omitted from output