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

219 lines
6.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | 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.
```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
<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
```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