feat: render index via Mustache template with spine-index-model
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
# Spine — design brief
|
||||
|
||||
A personal library and reading tracker. Tracks books I want to read, am reading,
|
||||
have read, and what I think about them; captures notes repeatedly through a read;
|
||||
and records who recommended a book and why.
|
||||
|
||||
This document is a starting point for a coding agent. Several decisions are
|
||||
deliberately **left open** (see "Open decisions"). Do not invent answers for
|
||||
those — scaffold around them or ask.
|
||||
|
||||
## Core features
|
||||
|
||||
- Track each book through a reading lifecycle: want → reading → read (or abandoned).
|
||||
- Capture **multiple timestamped notes** across a single read, not one review at the end.
|
||||
- Record **recommendations**: who recommended a book and a short "why it's on my radar"
|
||||
note. This is context shown inline, not a search/analytics concern.
|
||||
- Flag the **format** consumed: hardcover, ebook, or audiobook.
|
||||
- Rate finished books.
|
||||
- Browse "on deck" (want-to-read) and optionally filter by category.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Source of truth is a single Org file.** No database.
|
||||
- **Emacs owns the file.** All reads and writes go through a running Emacs via
|
||||
`emacsclient`, so `org-element` does the parsing and serialization canonically.
|
||||
This avoids third-party Org parser fidelity problems on write.
|
||||
- **No read/search index initially.** Queries are simple status + tag filters
|
||||
answered live by `org-ql`. A derived SQLite index can be added later if the
|
||||
recommendation data ever needs real querying; it is explicitly out of scope now.
|
||||
- The **web layer is a thin client** over emacsclient. Its language and framework
|
||||
are undecided (see Open decisions).
|
||||
|
||||
## Data model (Org)
|
||||
|
||||
Each book is a top-level headline whose TODO state is its lifecycle status.
|
||||
|
||||
```org
|
||||
#+TITLE: Spine
|
||||
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
|
||||
#+STARTUP: logdrawer
|
||||
|
||||
* READING Use of Weapons
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Iain M. Banks
|
||||
:FORMAT: audiobook
|
||||
:REC_BY: Priya
|
||||
:REC_NOTE: If you liked Player of Games, this one will wreck you in a better way.
|
||||
: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. Banks never lets a skill be free.
|
||||
- [2026-03-09] The chapter numbering. Oh.
|
||||
```
|
||||
|
||||
Field notes:
|
||||
|
||||
- **Status**: the TODO keyword. `WANT READING | READ ABANDONED`.
|
||||
- **Status history**: comes free from the LOGBOOK drawer when
|
||||
`org-log-into-drawer` is enabled. Distinct from reading notes.
|
||||
- **Reading notes**: a plain Org list in the headline body, each item prefixed
|
||||
with an inactive timestamp `[YYYY-MM-DD]`. Appending a note is inserting one
|
||||
list item. Notes are not individually queried.
|
||||
- **Format**: single `:FORMAT:` property — `hardcover` | `ebook` | `audiobook`.
|
||||
(See Open decisions for the multi-format case.)
|
||||
- **Recommendation**: `:REC_BY:` plus optional `:REC_NOTE:`. Surfaced inline as
|
||||
context, never ranked or searched.
|
||||
- **Category**: Org **tags** on the headline (`:scifi:literary:`), not a property —
|
||||
tags filter natively and inherit.
|
||||
- **Rating**: `:RATING:` 1–5, set when moved to READ.
|
||||
|
||||
## emacsclient integration
|
||||
|
||||
### Read: on-deck list
|
||||
|
||||
```elisp
|
||||
(require 'org-ql)
|
||||
(require 'json)
|
||||
|
||||
(defun spine-ondeck (&optional tag)
|
||||
"JSON of WANT books, optionally filtered by TAG."
|
||||
(json-serialize
|
||||
(vconcat
|
||||
(org-ql-select "~/spine/books.org"
|
||||
(if tag `(and (todo "WANT") (tags ,tag)) '(todo "WANT"))
|
||||
:action
|
||||
(lambda ()
|
||||
(list :title (org-get-heading t t t t)
|
||||
:author (org-entry-get nil "AUTHOR")
|
||||
:format (org-entry-get nil "FORMAT")
|
||||
:rec_by (org-entry-get nil "REC_BY")
|
||||
:tags (vconcat (org-get-tags))))))))
|
||||
|
||||
(defun spine-ondeck-file (path &optional tag)
|
||||
"Write `spine-ondeck' output to PATH (avoids emacsclient quoting)."
|
||||
(with-temp-file path (insert (spine-ondeck tag))))
|
||||
```
|
||||
|
||||
Invoke: `emacsclient --eval '(spine-ondeck-file "/tmp/spine.json")'`, then read
|
||||
the file from the web layer. (`emacsclient --eval` prints values with `prin1`, so
|
||||
returning a JSON string directly comes back quoted/escaped — the temp-file hop
|
||||
sidesteps that. A small `simple-httpd` endpoint is the alternative; undecided.)
|
||||
|
||||
### Write: to implement
|
||||
|
||||
- `spine-add-note` — append a `[today] text` list item under a book's headline.
|
||||
- `spine-add-book` — insert a new WANT headline with initial properties.
|
||||
- `spine-set-status` / `spine-set-rating` / `spine-set-format`.
|
||||
|
||||
These set state through `org-element` / standard Org commands, not text munging.
|
||||
Not yet written.
|
||||
|
||||
## UI
|
||||
|
||||
Two directions are mocked as standalone HTML (`spine-mockup-a-agenda.html`,
|
||||
`spine-mockup-b-journal.html`). They render the same Org record through different
|
||||
front doors.
|
||||
|
||||
- **Direction A — agenda (TUI).** An org-agenda-style surface grouped by status.
|
||||
Format shows as a leading glyph, the recommendation rides on the row as "why
|
||||
it's here," the selected book expands inline to show its reading log, and
|
||||
capture happens in a minibuffer rather than a form. This is the home surface.
|
||||
|
||||
- **Direction B — journal card.** A book detail card. Format is a segmented
|
||||
control, the reading log is a thread you append to with an inline composer, and
|
||||
the recommendation has an explicit "who / why" capture form. This is the
|
||||
drill-in view.
|
||||
|
||||
**Recommended composition:** hybrid. Direction A as the home/browse surface,
|
||||
Direction B as the detail view when a book is selected. Agenda answers "what now,"
|
||||
card answers "where am I with this one." Not locked in — building one alone is
|
||||
fine.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- **Glue language + web framework — undecided.** Possibly elisp end-to-end
|
||||
(Emacs serves the UI too), possibly an external thin client shelling out to
|
||||
emacsclient. Leave this unbound; don't hardwire a stack.
|
||||
- **Read transport:** temp-file hop vs `simple-httpd` endpoint in Emacs.
|
||||
- **Per-note format:** book-level `:FORMAT:` is the default. If a book is regularly
|
||||
consumed across media (hardcover at home, audio on a commute), format may need
|
||||
to move to per-note. Single-value for now.
|
||||
- **Hybrid vs single UI direction** — see UI section.
|
||||
- **Search index:** deferred. Not now.
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
- Recommender ranking / "hit rate" analytics.
|
||||
- Full-text search across notes.
|
||||
- Any external book-metadata API (covers, ISBN lookup) — placeholder cover blocks
|
||||
in the mockups are decoration only.
|
||||
Reference in New Issue
Block a user