diff --git a/docs/specs/2026-06-20-spine-org-data-model-design.md b/docs/specs/2026-06-20-spine-org-data-model-design.md new file mode 100644 index 0000000..dcc027d --- /dev/null +++ b/docs/specs/2026-06-20-spine-org-data-model-design.md @@ -0,0 +1,218 @@ +# 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