# Spine — data model and index page Extends [`spine-spec.md`](../../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 ```org #+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. ```org - [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. ```org :LOGBOOK: - State "READING" from "WANT" [2026-02-20] :END: ``` ### Full example entry ```org * 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. ```elisp (spine-books) → list of plists ``` Per-book plist shape: ```elisp (: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:** ```html
Spine
WANT (3)
READING (1)
``` **Empty state:** when `spine-books` returns `nil` → page with "No books yet." message. ### Handler ```elisp (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 ```elisp (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-ql` integration - Search or filtering beyond status grouping ## Acceptance - [ ] `spine-books` parses a sample `spine.org` and 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