Files
spine/docs/specs/2026-06-20-spine-org-data-model-design.md
T

6.1 KiB
Raw Blame History

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 15, 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: WANTREADINGREADABANDONED. 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-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