Compare commits
16 Commits
65a298d8be
...
7faf0cd8ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 7faf0cd8ac | |||
| 67e41ad110 | |||
| c7971eb297 | |||
| a6e2c99b62 | |||
| 112ea4df07 | |||
| bbb40efd1a | |||
| 1ad0631f5b | |||
| 24f24620d3 | |||
| 794ef3ffc9 | |||
| 94c310a0b3 | |||
| cc74ca5cfa | |||
| f25dffa183 | |||
| a93efd3617 | |||
| 362a83ca42 | |||
| d476a4eacc | |||
| de64d71936 |
@@ -0,0 +1,9 @@
|
||||
# Overview
|
||||
|
||||
Spine is a tool for managing your personal library. Spine keeps track of books you want to read, are reading and have read. Spine lets you track notes about the books and why you were interested in them in the first place.
|
||||
|
||||
# Design
|
||||
|
||||
Spine is primarily a web application where you view and enter details about books. To ensure your data is fully portable and independent of Spine itself, your book data is backed by a single Org file ( http://orgmode.org/ )
|
||||
|
||||
The application itself is written in Elisp and runs inside of Emacs but serves up its interface as HTML derived from your `spine.org` file.
|
||||
@@ -0,0 +1,238 @@
|
||||
# Mustache Template Rendering — Implementation Plan
|
||||
|
||||
**Goal:** Render the index page via Mustache (`spine-render`) using the mockup-A template, replacing inline HTML generation.
|
||||
|
||||
**Architecture:** Move `spine-mockup-a-agenda.mustache` → `templates/index.mustache`. Add `spine-index-model` function to build the view model `ht`. Update the index handler. Remove old `spine-render-index`.
|
||||
|
||||
**Tech Stack:** Emacs Lisp, `mustache`, `ht`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `templates/index.mustache` | Index page Mustache template (moved from root) |
|
||||
| `spine.el` | Add `spine-index-model`, update handler, remove `spine-render-index` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Move template and add spine-index-model
|
||||
|
||||
**Files:**
|
||||
- Create: `templates/index.mustache`
|
||||
- Modify: `spine.el`
|
||||
- Delete: `spine-mockup-a-agenda.mustache`
|
||||
|
||||
- [ ] **Step 1: Move the template**
|
||||
|
||||
```bash
|
||||
mv spine-mockup-a-agenda.mustache templates/index.mustache
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `spine-index-model` function to `spine.el`**
|
||||
|
||||
Insert after `spine--h` (after the HTML escaping helper), before `spine-render-index`:
|
||||
|
||||
```elisp
|
||||
(defun spine-index-model (books &optional selected-id)
|
||||
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||
SELECTED-ID expands the matching book's detail section."
|
||||
(let* ((status-labels '(("WANT" . "on deck")
|
||||
("READING" . "reading")
|
||||
("READ" . "read")
|
||||
("ABANDONED" . "abandoned")))
|
||||
(format-icons '(("hardcover" . "ti-book")
|
||||
("ebook" . "ti-device-tablet")
|
||||
("audiobook" . "ti-headphones")))
|
||||
(order '("WANT" "READING" "READ" "ABANDONED"))
|
||||
(grouped (make-hash-table :test 'equal))
|
||||
(total (length books))
|
||||
(reading-count 0))
|
||||
;; Count reading books
|
||||
(dolist (book books)
|
||||
(when (equal "READING" (plist-get book :status))
|
||||
(cl-incf reading-count)))
|
||||
;; Group books by status
|
||||
(dolist (book books)
|
||||
(let ((status (or (plist-get book :status) "WANT")))
|
||||
(push book (gethash status grouped))))
|
||||
;; Build groups list for template
|
||||
(let ((groups nil))
|
||||
(dolist (status order)
|
||||
(let ((group-books (nreverse (gethash status grouped))))
|
||||
(when group-books
|
||||
(let ((book-models nil))
|
||||
(dolist (book group-books)
|
||||
(let* ((id (plist-get book :id))
|
||||
(selected (equal id selected-id))
|
||||
(fmt (plist-get book :format))
|
||||
(rating (plist-get book :rating))
|
||||
(notes (plist-get book :notes))
|
||||
(status (or (plist-get book :status) "WANT"))
|
||||
(model
|
||||
(ht
|
||||
("format_icon"
|
||||
(or (cdr (assoc fmt format-icons)) ""))
|
||||
("status_class" (downcase status))
|
||||
("status_label" (downcase status))
|
||||
("title" (plist-get book :title))
|
||||
("author" (or (plist-get book :author) ""))
|
||||
("tags_inline"
|
||||
(let ((tags (plist-get book :tags)))
|
||||
(when tags
|
||||
(mapconcat (lambda (t) (concat ":" t ":"))
|
||||
tags " "))))
|
||||
("rec_via" (plist-get book :rec_by))
|
||||
("rating_display"
|
||||
(when (and rating (> (length rating) 0))
|
||||
(let ((n (string-to-number rating)))
|
||||
(make-string n ?★))))
|
||||
("selected" selected)
|
||||
("detail"
|
||||
(when selected
|
||||
(ht
|
||||
("meta"
|
||||
(let ((added (plist-get book :added))
|
||||
(fmt (plist-get book :format)))
|
||||
(cond
|
||||
((and added fmt)
|
||||
(format "started %s · %s" added fmt))
|
||||
(added (format "started %s" added))
|
||||
(fmt (format "format: %s" fmt))
|
||||
(t ""))))
|
||||
("notes"
|
||||
(when notes
|
||||
(cl-loop for (date text) in notes
|
||||
collect (ht ("date" (format "[%s]" date))
|
||||
("text" text))))))))))))
|
||||
(push model book-models)))
|
||||
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||
("count" (length group-books))
|
||||
("books" (nreverse book-models)))
|
||||
groups)))))
|
||||
(ht ("app_title" "spine")
|
||||
("total_books" total)
|
||||
("reading_count" reading-count)
|
||||
("groups" (nreverse groups))))))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove old `spine-render-index` function**
|
||||
|
||||
Delete the `spine-render-index` function entirely.
|
||||
|
||||
- [ ] **Step 4: Update the index handler**
|
||||
|
||||
Replace the handler's call from `spine-render-index` to `spine-render` + `spine-index-model`:
|
||||
|
||||
```elisp
|
||||
(defservlet index text/html (path query request)
|
||||
(let ((books (spine-books)))
|
||||
(if books
|
||||
(insert (spine-render "index.mustache"
|
||||
(spine-index-model books (cadr (assoc "id" query)))))
|
||||
(insert (spine-render-empty-state)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify batch load**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
|
||||
```
|
||||
|
||||
Expected: exits cleanly.
|
||||
|
||||
- [ ] **Step 6: Verify template renders**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch \
|
||||
--eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
|
||||
--load spine.el \
|
||||
--eval "(progn (message \"HTML length: %d\" (length (spine-render \"index.mustache\" (spine-index-model (spine-books) nil)))))"
|
||||
```
|
||||
|
||||
Expected: non-zero length, no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/index.mustache spine.el
|
||||
git rm spine-mockup-a-agenda.mustache
|
||||
git commit -m "feat: render index via Mustache template with spine-index-model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Smoke test end-to-end
|
||||
|
||||
**Files:**
|
||||
- No changes — verification only.
|
||||
|
||||
- [ ] **Step 1: Start server with sample data**
|
||||
|
||||
```bash
|
||||
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
|
||||
pkill -f "emacs --quick" 2>/dev/null
|
||||
sleep 1
|
||||
emacs --quick --eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" --load spine.el &
|
||||
sleep 5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify Pico + Tabler CDN links**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep -c "picocss"
|
||||
curl -s http://localhost:8080/index | grep -c "tabler"
|
||||
```
|
||||
|
||||
Expected: `1` and `1`.
|
||||
|
||||
- [ ] **Step 3: Verify groups and content**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep -oP 'on deck|reading|read'
|
||||
```
|
||||
|
||||
Expected: `on deck`, `reading`, `read` (no `abandoned`).
|
||||
|
||||
- [ ] **Step 4: Verify Tabler icon glyphs**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep -oP 'ti-book|ti-headphones|ti-device-tablet'
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify expanded detail**
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -c "logline"
|
||||
```
|
||||
|
||||
Expected: `3` (three reading notes).
|
||||
|
||||
- [ ] **Step 6: Kill server**
|
||||
|
||||
```bash
|
||||
kill %1 2>/dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ERT tests and final verification
|
||||
|
||||
**Files:**
|
||||
- No changes — verification only.
|
||||
|
||||
- [ ] **Step 1: Run ERT tests**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch --directory "$(pwd)" --load test/spine-books-test.el -f ert-run-tests-batch-and-exit
|
||||
```
|
||||
|
||||
Expected: all 6 tests pass.
|
||||
|
||||
- [ ] **Step 2: Git status**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git log --oneline -3
|
||||
```
|
||||
@@ -0,0 +1,365 @@
|
||||
# Pico CSS Styling — Implementation Plan
|
||||
|
||||
**Goal:** Rewrite `spine-render-index` and `spine-render-empty-state` to emit Pico CSS-styled HTML via CDN, replacing the mockup-A `<div>` structure with Pico-native semantic elements plus a small inline `<style>` block.
|
||||
|
||||
**Architecture:** View-layer only change. No model or handler changes. The two view functions in `spine.el` are rewritten. Pico CSS v2 loads from `jsdelivr.net` CDN. Custom styles live in a `<style>` block in `<head>`.
|
||||
|
||||
**Tech Stack:** Emacs Lisp (view functions only), Pico CSS v2 (CDN).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `spine.el` | Rewrite `spine-render-index` and `spine-render-empty-state` |
|
||||
|
||||
No new files. No package changes.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rewrite spine-render-empty-state
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el` — `spine-render-empty-state`
|
||||
|
||||
- [ ] **Step 1: Replace `spine-render-empty-state` with Pico-styled version**
|
||||
|
||||
The function `spine-render-empty-state` is currently at approximately line 220 in `spine.el`. Replace the existing definition with:
|
||||
|
||||
```elisp
|
||||
(defun spine-render-empty-state ()
|
||||
"Render a page indicating no books file was found."
|
||||
(concat "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
|
||||
"<meta charset=\"utf-8\">\n"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
||||
"<title>Spine</title>\n"
|
||||
"<link rel=\"stylesheet\""
|
||||
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n"
|
||||
"</head>\n<body>\n"
|
||||
"<main class=\"container\">\n"
|
||||
"<article>\n"
|
||||
"<header><strong>spine</strong></header>\n"
|
||||
"<p>No books yet.</p>\n"
|
||||
"</article>\n"
|
||||
"</main>\n</body>\n</html>"))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify batch load**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
|
||||
```
|
||||
|
||||
Expected: exits cleanly, no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: restyle empty-state page with Pico CSS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite spine-render-index with Pico HTML
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el` — `spine-render-index`
|
||||
|
||||
- [ ] **Step 1: Replace `spine-render-index` with Pico-styled version**
|
||||
|
||||
The function `spine-render-index` is currently at approximately line 123 in `spine.el`. Replace the entire function definition with:
|
||||
|
||||
```elisp
|
||||
(defun spine-render-index (books &optional selected-id)
|
||||
"Render the index page HTML for BOOKS, expanding the book matching SELECTED-ID."
|
||||
(let ((order '("WANT" "READING" "READ" "ABANDONED"))
|
||||
(grouped (make-hash-table :test 'equal))
|
||||
(total 0)
|
||||
(reading-count 0))
|
||||
;; Count totals
|
||||
(dolist (book books)
|
||||
(cl-incf total)
|
||||
(when (equal "READING" (plist-get book :status))
|
||||
(cl-incf reading-count)))
|
||||
;; Group books by status
|
||||
(dolist (book books)
|
||||
(let* ((status (or (plist-get book :status) "WANT")))
|
||||
(push book (gethash status grouped))))
|
||||
;; Render
|
||||
(with-temp-buffer
|
||||
(insert "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
|
||||
(insert "<meta charset=\"utf-8\">\n")
|
||||
(insert "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
|
||||
(insert "<title>Spine</title>\n")
|
||||
(insert "<link rel=\"stylesheet\""
|
||||
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n")
|
||||
(insert "<style>\n")
|
||||
(insert ".spine-row{display:flex;align-items:center;gap:0.75rem;")
|
||||
(insert "padding:0.4rem 0;border-bottom:1px solid var(--pico-muted-border-color)}\n")
|
||||
(insert ".spine-row:last-child{border-bottom:none}\n")
|
||||
(insert ".spine-selected{background:var(--pico-secondary-background)}\n")
|
||||
(insert ".spine-glyph{width:1.5rem;text-align:center;flex-shrink:0}\n")
|
||||
(insert ".spine-title{flex:1;min-width:0}\n")
|
||||
(insert ".spine-trail{font-size:0.85em;color:var(--pico-muted-color)}\n")
|
||||
(insert ".spine-tags{font-size:0.85em;color:var(--pico-muted-color)}\n")
|
||||
(insert ".spine-detail{margin:0 0 0 2rem;border-left:2px solid var(--pico-primary)}\n")
|
||||
(insert ".spine-detail p{margin-bottom:0.3rem}\n")
|
||||
(insert ".spine-detail footer{margin-top:0.5rem}\n")
|
||||
(insert "mark.want{background:var(--pico-mark-background);"
|
||||
"color:var(--pico-mark-color)}\n")
|
||||
(insert "mark.reading{background:#faeeda;color:#854f0b}\n")
|
||||
(insert "mark.read{background:#e1f5ee;color:#085041}\n")
|
||||
(insert "mark.abandoned{background:var(--pico-muted-color);"
|
||||
"color:var(--pico-background-color)}\n")
|
||||
(insert "article{margin-bottom:0}\n")
|
||||
(insert "article header{padding-bottom:0.5rem;margin-bottom:0.5rem}\n")
|
||||
(insert "</style>\n")
|
||||
(insert "</head>\n<body>\n")
|
||||
(insert "<main class=\"container\">\n")
|
||||
(insert "<article>\n")
|
||||
(insert (format "<header><strong>spine</strong>"
|
||||
"<small>%d books · %d reading</small></header>\n"
|
||||
total reading-count))
|
||||
;; Each group in order
|
||||
(dolist (status order)
|
||||
(let ((group-books (nreverse (gethash status grouped))))
|
||||
(when group-books
|
||||
(insert (format "<hgroup><h2>%s</h2><small>%d</small></hgroup>\n"
|
||||
status (length group-books)))
|
||||
(dolist (book group-books)
|
||||
(let ((selected (equal (plist-get book :id) selected-id)))
|
||||
(insert (format "<div class=\"spine-row%s\">\n"
|
||||
(if selected " spine-selected" "")))
|
||||
;; Format glyph (emoji)
|
||||
(let ((fmt (plist-get book :format)))
|
||||
(insert (format "<span class=\"spine-glyph\">%s</span>\n"
|
||||
(cond ((equal fmt "hardcover") "📖")
|
||||
((equal fmt "ebook") "📱")
|
||||
((equal fmt "audiobook") "🎧")
|
||||
(t "")))))
|
||||
;; Title + author
|
||||
(insert "<span class=\"spine-title\">")
|
||||
(insert (spine--h (plist-get book :title)))
|
||||
(let ((author (plist-get book :author)))
|
||||
(when author
|
||||
(insert (format " <small>%s</small>"
|
||||
(spine--h author)))))
|
||||
(insert "</span>\n")
|
||||
;; Tags
|
||||
(let ((tags (plist-get book :tags)))
|
||||
(when tags
|
||||
(insert (format "<small class=\"spine-tags\">%s</small>\n"
|
||||
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
||||
tags " ")))))
|
||||
;; Recommendation trail
|
||||
(let ((rec-by (plist-get book :rec_by))
|
||||
(rec-note (plist-get book :rec_note)))
|
||||
(when (and rec-by (> (length rec-by) 0))
|
||||
(insert
|
||||
(format "<span class=\"spine-trail\">%s%s</span>\n"
|
||||
(spine--h rec-by)
|
||||
(if (and rec-note (> (length rec-note) 0))
|
||||
(concat ": " (spine--h rec-note))
|
||||
"")))))
|
||||
;; Status pill
|
||||
(let ((status (or (plist-get book :status) "WANT")))
|
||||
(insert (format "<mark class=\"%s\">%s</mark>\n"
|
||||
(downcase status) (downcase status))))
|
||||
;; Expanded detail
|
||||
(when selected
|
||||
(insert "<blockquote class=\"spine-detail\">\n")
|
||||
(dolist (note (plist-get book :notes))
|
||||
(insert (format "<p><small>[%s]</small> %s</p>\n"
|
||||
(spine--h (car note))
|
||||
(spine--h (cadr note)))))
|
||||
(let ((parts nil))
|
||||
(let ((rating (plist-get book :rating)))
|
||||
(when (and rating (> (length rating) 0))
|
||||
(push (format "Rating: %s/5" (spine--h rating)) parts)))
|
||||
(let ((isbn (plist-get book :isbn)))
|
||||
(when (and isbn (> (length isbn) 0))
|
||||
(push (format "ISBN: %s" (spine--h isbn)) parts)))
|
||||
(let ((fmt (plist-get book :format)))
|
||||
(when (and fmt (> (length fmt) 0))
|
||||
(push (format "Format: %s" (spine--h fmt)) parts)))
|
||||
(let ((added (plist-get book :added)))
|
||||
(when (and added (> (length added) 0))
|
||||
(push (format "Added: %s" (spine--h added)) parts)))
|
||||
(when parts
|
||||
(insert (format "<footer>%s</footer>\n"
|
||||
(mapconcat #'identity (nreverse parts) " · ")))))
|
||||
(insert "</blockquote>\n"))
|
||||
(insert "</div>\n"))))))
|
||||
(insert "</article>\n")
|
||||
(insert "</main>\n</body>\n</html>\n")
|
||||
(buffer-string))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify batch load**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
|
||||
```
|
||||
|
||||
Expected: exits cleanly, no errors.
|
||||
|
||||
- [ ] **Step 3: Verify rendering against sample data**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch \
|
||||
--eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
|
||||
--load spine.el \
|
||||
--eval "(progn (message \"render-index length: %d\" (length (spine-render-index (spine-books) nil))))"
|
||||
```
|
||||
|
||||
Expected: non-zero length, no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: restyle index page with Pico CSS and emoji glyphs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Smoke test end-to-end
|
||||
|
||||
**Files:**
|
||||
- No changes — verification only.
|
||||
|
||||
- [ ] **Step 1: Kill existing server and start fresh**
|
||||
|
||||
```bash
|
||||
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
|
||||
pkill -f "emacs --quick" 2>/dev/null
|
||||
sleep 1
|
||||
emacs --quick \
|
||||
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
|
||||
--load spine.el &
|
||||
EMACS_PID=$!
|
||||
sleep 5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify Pico CSS is linked**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep -c "picocss"
|
||||
```
|
||||
|
||||
Expected: `1` (the CDN `<link>` is present).
|
||||
|
||||
- [ ] **Step 3: Verify no Tabler Icons reference**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep -c "tabler"
|
||||
```
|
||||
|
||||
Expected: `0`.
|
||||
|
||||
- [ ] **Step 4: Verify groups in correct order**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep -oP '<h2>[^<]+'
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
WANT
|
||||
READING
|
||||
READ
|
||||
```
|
||||
No ABANDONED.
|
||||
|
||||
- [ ] **Step 5: Verify emoji glyphs and status pills**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep -E "📖|📱|🎧" | wc -l
|
||||
```
|
||||
|
||||
Expected: `3` (three format glyphs present — Piranesi has no format).
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep -oP '<mark class="[^"]+">[^<]+'
|
||||
```
|
||||
|
||||
Expected: `want`, `reading`, `read` pills visible.
|
||||
|
||||
- [ ] **Step 6: Verify expanded detail**
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -oP 'spine-detail'
|
||||
```
|
||||
|
||||
Expected: `spine-detail` present (2 occurrences — opening and closing).
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -c "logline\|spine-detail"
|
||||
```
|
||||
|
||||
Expected: detail section renders.
|
||||
|
||||
- [ ] **Step 7: Verify recommendation trail**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep "Priya"
|
||||
```
|
||||
|
||||
Expected: output contains `Priya: If you liked Player of Games, this one will wreck you`.
|
||||
|
||||
- [ ] **Step 8: Verify empty state**
|
||||
|
||||
```bash
|
||||
kill $EMACS_PID 2>/dev/null
|
||||
sleep 1
|
||||
emacs --quick \
|
||||
--eval "(setq spine-org-file \"/tmp/nonexistent-books.org\")" \
|
||||
--load spine.el &
|
||||
EMACS_PID=$!
|
||||
sleep 5
|
||||
curl -s http://localhost:8080/index | grep -c "No books yet"
|
||||
```
|
||||
|
||||
Expected: `1`.
|
||||
|
||||
- [ ] **Step 9: Stop server**
|
||||
|
||||
```bash
|
||||
kill $EMACS_PID 2>/dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Run ERT tests and final verification
|
||||
|
||||
**Files:**
|
||||
- No changes — verification only.
|
||||
|
||||
- [ ] **Step 1: Run full ERT test suite**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch \
|
||||
--directory "$(pwd)" \
|
||||
--load test/spine-books-test.el \
|
||||
-f ert-run-tests-batch-and-exit
|
||||
```
|
||||
|
||||
Expected: all 6 tests pass.
|
||||
|
||||
- [ ] **Step 2: Verify git status**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no modified tracked files (only pre-existing untracked files).
|
||||
|
||||
- [ ] **Step 3: Review commit log**
|
||||
|
||||
```bash
|
||||
git log --oneline -4
|
||||
```
|
||||
|
||||
Expected: 2-3 new commits on top of previous work.
|
||||
@@ -0,0 +1,737 @@
|
||||
# Spine Data Model & Index Page — Implementation Plan
|
||||
|
||||
**Goal:** Build the model layer (`spine-books`) and index page view so the app reads a real `spine.org` file and displays books grouped by status.
|
||||
|
||||
**Architecture:** Model/view split. `spine-books` parses `spine.org` into plists via `org-element` + `org-entry-get`. `spine-render-index` takes those plists and an optional selected ID, returns HTML matching mockup A's class structure. A new `/` handler replaces the `/hello` smoke test. A `spine-skip-server-start` guard lets tests load `spine.el` without starting the HTTP server.
|
||||
|
||||
**Tech Stack:** Emacs Lisp, `org-element` (built-in), `org-entry-get` (built-in), `ert` (built-in testing).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `sample-books.org` | Test data: 5 books across all statuses |
|
||||
| `spine.el` | Modified: new functions, handler, skip-server-start guard |
|
||||
| `test/spine-books-test.el` | ERT tests for `spine-books` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create sample test data
|
||||
|
||||
**Files:**
|
||||
- Create: `sample-books.org`
|
||||
|
||||
- [ ] **Step 1: Write `sample-books.org`**
|
||||
|
||||
Five books covering all four statuses, plus an entry with minimal properties.
|
||||
|
||||
```org
|
||||
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
|
||||
|
||||
* WANT The Left Hand of Darkness
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Ursula K. Le Guin
|
||||
:ISBN: 978-0441478125
|
||||
:COVER: covers/left-hand.jpg
|
||||
:FORMAT: ebook
|
||||
:REC_BY: Alex
|
||||
:REC_NOTE: Le Guin at her most human
|
||||
:RATING:
|
||||
:ADDED: [2026-04-01]
|
||||
:ID: 9a2b-lhd
|
||||
:END:
|
||||
|
||||
* WANT Dune
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Frank Herbert
|
||||
:ISBN: 978-0441172719
|
||||
:COVER: covers/dune.jpg
|
||||
:FORMAT: hardcover
|
||||
:REC_BY: Everyone
|
||||
:REC_NOTE:
|
||||
:RATING:
|
||||
:ADDED: [2026-05-10]
|
||||
:ID: 7f3c-dun
|
||||
:END:
|
||||
|
||||
* WANT Piranesi
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Susanna Clarke
|
||||
:ISBN:
|
||||
:COVER:
|
||||
:FORMAT:
|
||||
:REC_BY:
|
||||
:REC_NOTE:
|
||||
:RATING:
|
||||
:ADDED: [2026-06-01]
|
||||
:ID: 5e8d-pir
|
||||
:END:
|
||||
|
||||
* 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-03-01]
|
||||
: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.
|
||||
- [2026-03-09] The chapter numbering. Oh.
|
||||
|
||||
* READ Ancillary Justice
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Ann Leckie
|
||||
:ISBN: 978-0316246620
|
||||
:COVER: covers/ancillary.jpg
|
||||
:FORMAT: ebook
|
||||
:REC_BY:
|
||||
:REC_NOTE:
|
||||
:RATING: 5
|
||||
:ADDED: [2025-11-15]
|
||||
:ID: 3a1b-aj
|
||||
:END:
|
||||
:LOGBOOK:
|
||||
- State "READ" from "READING" [2026-01-10]
|
||||
- State "READING" from "WANT" [2025-11-20]
|
||||
:END:
|
||||
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
|
||||
- [2025-12-05] Justice of Toren's multi-body perspective is unsettling.
|
||||
- [2026-01-08] The tea set. Perfect ending.
|
||||
```
|
||||
|
||||
This file exercises:
|
||||
- **WANT books**: 3 (Left Hand, Dune, Piranesi) — tests group header, count, different property densities
|
||||
- **READING books**: 1 (Use of Weapons) — tests notes extraction, recommendation trail
|
||||
- **READ books**: 1 (Ancillary Justice) — tests rating, LOGBOOK
|
||||
- **ABANDONED books**: 0 — tests empty group omission
|
||||
- **Minimal properties**: Piranesi has no ISBN, cover, format, recommendation, or notes
|
||||
|
||||
- [ ] **Step 2: Verify file exists**
|
||||
|
||||
Run: `cat sample-books.org | head -3`
|
||||
Expected: `#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add sample-books.org
|
||||
git commit -m "test: add sample-books.org test data"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add server-start guard to spine.el
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el`
|
||||
|
||||
- [ ] **Step 1: Add `spine-skip-server-start` variable and wrap server start**
|
||||
|
||||
Insert after the template rendering section (after line 44, before `;; --- handlers`):
|
||||
|
||||
```elisp
|
||||
(defvar spine-skip-server-start nil
|
||||
"When non-nil, do not start the HTTP server.
|
||||
Set by test harnesses that only need the model functions.")
|
||||
```
|
||||
|
||||
Change the server start block (lines 53–58) to:
|
||||
|
||||
```elisp
|
||||
;; --- start ------------------------------------------------------------
|
||||
(unless spine-skip-server-start
|
||||
(setq httpd-host spine-host)
|
||||
(setq httpd-port spine-port)
|
||||
(httpd-start)
|
||||
(message "Spine listening on http://%s:%d"
|
||||
(or spine-host "localhost") spine-port))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify batch load with guard**
|
||||
|
||||
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
|
||||
Expected: exits cleanly, no "Spine listening" message, no errors.
|
||||
|
||||
- [ ] **Step 3: Verify server still works normally without guard**
|
||||
|
||||
Run the normal `scripts/run` flow and curl `/hello` — `/hello` still exists at this point since the handler hasn't been replaced yet.
|
||||
|
||||
```bash
|
||||
scripts/run 8080
|
||||
sleep 3
|
||||
curl -s http://localhost:8080/hello
|
||||
scripts/stop
|
||||
```
|
||||
|
||||
Expected: HTML containing `<h1>Hello from Emacs.</h1>`.
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: add spine-skip-server-start guard for testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement spine-books model function
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el`
|
||||
|
||||
- [ ] **Step 1: Add `cl-lib` require**
|
||||
|
||||
At the top of `spine.el`, after `(require 'ht)`, add:
|
||||
|
||||
```elisp
|
||||
(require 'cl-lib)
|
||||
```
|
||||
|
||||
`cl-lib` is built into Emacs and provides `cl-return-from`.
|
||||
|
||||
- [ ] **Step 2: Add `spine-org-file` config variable**
|
||||
|
||||
After `spine-port` and `spine-host` defvars (after line 27), add:
|
||||
|
||||
```elisp
|
||||
(defvar spine-org-file "~/spine/books.org"
|
||||
"Path to the spine.org data file.")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `spine--h` HTML escaping helper**
|
||||
|
||||
After `spine-render`, add:
|
||||
|
||||
```elisp
|
||||
(defun spine--h (text)
|
||||
"HTML-escape TEXT."
|
||||
(when text
|
||||
(let ((s text))
|
||||
(setq s (replace-regexp-in-string "&" "&" s))
|
||||
(setq s (replace-regexp-in-string "<" "<" s))
|
||||
(setq s (replace-regexp-in-string ">" ">" s))
|
||||
(setq s (replace-regexp-in-string "\"" """ s))
|
||||
s)))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `spine--extract-notes` helper**
|
||||
|
||||
After `spine--h`, add:
|
||||
|
||||
```elisp
|
||||
(defun spine--extract-notes (hl-begin hl-end)
|
||||
"Extract reading notes from headline body between HL-BEGIN and HL-END.
|
||||
Returns list of (date-string text-string) pairs."
|
||||
(let ((notes nil))
|
||||
(save-excursion
|
||||
(goto-char hl-begin)
|
||||
;; Skip past PROPERTIES and LOGBOOK drawers
|
||||
(while (re-search-forward
|
||||
"^[ \t]*:\\(PROPERTIES\\|LOGBOOK\\):" hl-end t)
|
||||
(re-search-forward "^[ \t]*:END:" hl-end t))
|
||||
;; Collect note lines: "- [YYYY-MM-DD] text"
|
||||
(while (re-search-forward
|
||||
"^- \\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)\\] \\(.*\\)"
|
||||
hl-end t)
|
||||
(push (list (match-string 1) (match-string 2)) notes)))
|
||||
(nreverse notes)))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `spine-books` function**
|
||||
|
||||
After `spine--extract-notes`, add:
|
||||
|
||||
```elisp
|
||||
(defun spine-books ()
|
||||
"Return a list of plists, one per top-level headline in `spine-org-file'.
|
||||
Returns nil if the file is missing or cannot be parsed."
|
||||
(unless (file-exists-p spine-org-file)
|
||||
(message "spine-books: file not found: %s" spine-org-file)
|
||||
(cl-return-from spine-books nil))
|
||||
(condition-case err
|
||||
(with-temp-buffer
|
||||
(insert-file-contents spine-org-file)
|
||||
(org-mode)
|
||||
(let ((books nil))
|
||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||
(lambda (hl)
|
||||
(when (= (org-element-property :level hl) 1)
|
||||
(let ((pos (org-element-property :begin hl)))
|
||||
(push (list :id (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil "ID"))
|
||||
:title (org-element-property :title hl)
|
||||
:status (org-element-property :todo-keyword hl)
|
||||
:author (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil "AUTHOR"))
|
||||
:isbn (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil "ISBN"))
|
||||
:cover (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil "COVER"))
|
||||
:format (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil "FORMAT"))
|
||||
:rec_by (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil "REC_BY"))
|
||||
:rec_note (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil "REC_NOTE"))
|
||||
:rating (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil "RATING"))
|
||||
:added (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil "ADDED"))
|
||||
:tags (org-element-property :tags hl)
|
||||
:notes (spine--extract-notes
|
||||
(org-element-property :begin hl)
|
||||
(org-element-property :end hl)))
|
||||
books)))))
|
||||
(nreverse books)))
|
||||
(error
|
||||
(message "spine-books: failed to parse %s: %s"
|
||||
spine-org-file (error-message-string err))
|
||||
nil)))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify batch load still works**
|
||||
|
||||
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
|
||||
Expected: exits cleanly.
|
||||
|
||||
- [ ] **Step 7: Verify spine-books returns data from sample file**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
emacs --quick --batch \
|
||||
--eval "(setq spine-skip-server-start t spine-org-file \"sample-books.org\")" \
|
||||
--load spine.el \
|
||||
--eval "(progn (let ((books (spine-books))) (message \"Found %d books\" (length books))))"
|
||||
```
|
||||
|
||||
Expected: `Found 5 books` in output.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: add spine-books model function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Test spine-books with ERT
|
||||
|
||||
**Files:**
|
||||
- Create: `test/spine-books-test.el`
|
||||
|
||||
- [ ] **Step 1: Create `test/` directory**
|
||||
|
||||
```bash
|
||||
mkdir -p test
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write `test/spine-books-test.el`**
|
||||
|
||||
```elisp
|
||||
;;; spine-books-test.el — ERT tests for spine-books
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
;; Load spine.el without starting the server
|
||||
(setq spine-skip-server-start t)
|
||||
(setq spine-org-file (expand-file-name "sample-books.org"
|
||||
(file-name-directory load-file-name)))
|
||||
(load-file (expand-file-name "spine.el"
|
||||
(file-name-directory load-file-name)))
|
||||
|
||||
(ert-deftest spine-books-parses-sample ()
|
||||
"spine-books returns correct plists for sample-books.org."
|
||||
(let ((books (spine-books)))
|
||||
(should books)
|
||||
(should (= (length books) 5))))
|
||||
|
||||
(ert-deftest spine-books-first-book-has-all-fields ()
|
||||
"Use of Weapons entry has all expected fields."
|
||||
(let* ((books (spine-books))
|
||||
(uow (cl-find "8c1e-uow" books
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)))
|
||||
(should uow)
|
||||
(should (equal (plist-get uow :title) "Use of Weapons"))
|
||||
(should (equal (plist-get uow :status) "READING"))
|
||||
(should (equal (plist-get uow :author) "Iain M. Banks"))
|
||||
(should (equal (plist-get uow :isbn) "978-0316029193"))
|
||||
(should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg"))
|
||||
(should (equal (plist-get uow :format) "audiobook"))
|
||||
(should (equal (plist-get uow :rec_by) "Priya"))
|
||||
(should (equal (plist-get uow :rec_note) "If you liked Player of Games, this one will wreck you"))
|
||||
(should (equal (plist-get uow :tags) '("scifi" "literary")))
|
||||
(should (equal (plist-get uow :notes)
|
||||
'(("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.")
|
||||
("2026-03-09" "The chapter numbering. Oh."))))))
|
||||
|
||||
(ert-deftest spine-books-missing-properties-are-nil ()
|
||||
"Piranesi entry has nil for non-present properties."
|
||||
(let* ((books (spine-books))
|
||||
(pir (cl-find "5e8d-pir" books
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)))
|
||||
(should pir)
|
||||
(should (equal (plist-get pir :title) "Piranesi"))
|
||||
(should (equal (plist-get pir :status) "WANT"))
|
||||
(should (equal (plist-get pir :author) "Susanna Clarke"))
|
||||
;; ISBN, cover, format, rec_by, rec_note should all be nil or ""
|
||||
(should-not (plist-get pir :isbn))
|
||||
(should-not (plist-get pir :cover))
|
||||
(should-not (plist-get pir :format))
|
||||
(should-not (plist-get pir :rec_by))
|
||||
(should-not (plist-get pir :rec_note))
|
||||
;; Notes should be empty
|
||||
(should-not (plist-get pir :notes))))
|
||||
|
||||
(ert-deftest spine-books-read-book-has-rating ()
|
||||
"Ancillary Justice has rating 5."
|
||||
(let* ((books (spine-books))
|
||||
(aj (cl-find "3a1b-aj" books
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)))
|
||||
(should aj)
|
||||
(should (equal (plist-get aj :status) "READ"))
|
||||
(should (equal (plist-get aj :rating) "5"))
|
||||
(should (= (length (plist-get aj :notes)) 3))))
|
||||
|
||||
(ert-deftest spine-books-empty-file-returns-empty-list ()
|
||||
"Empty Org file returns empty list, not nil."
|
||||
(let* ((tmp-file (make-temp-file "spine-test-" nil ".org"))
|
||||
(spine-org-file tmp-file))
|
||||
(unwind-protect
|
||||
(let ((books (spine-books)))
|
||||
(should (listp books))
|
||||
(should (= (length books) 0)))
|
||||
(delete-file tmp-file))))
|
||||
|
||||
(ert-deftest spine-books-missing-file-returns-nil ()
|
||||
"Non-existent file returns nil."
|
||||
(let ((spine-org-file "/tmp/spine-nonexistent-12345.org"))
|
||||
(should-not (spine-books))))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the tests**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch \
|
||||
--directory "$(pwd)" \
|
||||
--load test/spine-books-test.el \
|
||||
-f ert-run-tests-batch-and-exit
|
||||
```
|
||||
|
||||
Expected: all 6 tests pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add test/spine-books-test.el
|
||||
git commit -m "test: add ERT tests for spine-books"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Implement view functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el`
|
||||
|
||||
- [ ] **Step 1: Add `spine-render-index` function**
|
||||
|
||||
After `spine-books`, add:
|
||||
|
||||
```elisp
|
||||
(defun spine-render-index (books &optional selected-id)
|
||||
"Render the index page HTML for BOOKS, expanding the book matching SELECTED-ID."
|
||||
(let ((order '("WANT" "READING" "READ" "ABANDONED"))
|
||||
(grouped (make-hash-table :test 'equal)))
|
||||
;; Group books by status
|
||||
(dolist (book books)
|
||||
(let* ((status (or (plist-get book :status) "WANT")))
|
||||
(push book (gethash status grouped))))
|
||||
;; Render
|
||||
(with-temp-buffer
|
||||
(insert "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
|
||||
(insert "<meta charset=\"utf-8\">\n")
|
||||
(insert "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
|
||||
(insert "<title>Spine</title>\n</head>\n<body>\n")
|
||||
(insert "<div class=\"frame\">\n")
|
||||
(insert "<div class=\"titlebar\">Spine</div>\n")
|
||||
;; Each group in order
|
||||
(dolist (status order)
|
||||
(let ((group-books (nreverse (gethash status grouped))))
|
||||
(when group-books
|
||||
(insert (format "<div class=\"group\">%s (%d)</div>\n"
|
||||
status (length group-books)))
|
||||
(dolist (book group-books)
|
||||
(let ((selected (equal (plist-get book :id) selected-id)))
|
||||
(insert (format "<div class=\"row%s\">\n"
|
||||
(if selected " selected" "")))
|
||||
;; Format glyph
|
||||
(let ((fmt (plist-get book :format)))
|
||||
(insert (format "<span class=\"glyph\">%s</span>\n"
|
||||
(cond ((equal fmt "hardcover") "[H]")
|
||||
((equal fmt "ebook") "[E]")
|
||||
((equal fmt "audiobook") "[A]")
|
||||
(t "")))))
|
||||
;; Title + author
|
||||
(insert "<span class=\"title\">")
|
||||
(insert (spine--h (plist-get book :title)))
|
||||
(let ((author (plist-get book :author)))
|
||||
(when author
|
||||
(insert (format " <span class=\"muted\">%s</span>"
|
||||
(spine--h author)))))
|
||||
(insert "</span>\n")
|
||||
;; Tags
|
||||
(let ((tags (plist-get book :tags)))
|
||||
(when tags
|
||||
(insert (format "<span class=\"tags\">%s</span>\n"
|
||||
(mapconcat (lambda (t) (concat ":" t ":"))
|
||||
tags " ")))))
|
||||
;; Recommendation trail
|
||||
(let ((rec-by (plist-get book :rec_by))
|
||||
(rec-note (plist-get book :rec_note)))
|
||||
(when (and rec-by (> (length rec-by) 0))
|
||||
(insert
|
||||
(format "<span class=\"trail\">%s%s</span>\n"
|
||||
(spine--h rec-by)
|
||||
(if (and rec-note (> (length rec-note) 0))
|
||||
(concat ": " (spine--h rec-note))
|
||||
"")))))
|
||||
;; Status pill
|
||||
(let ((status (or (plist-get book :status) "WANT")))
|
||||
(insert (format "<span class=\"pill %s\">%s</span>\n"
|
||||
(downcase status) status)))
|
||||
;; Expanded detail
|
||||
(when selected
|
||||
(insert "<div class=\"detail\">\n")
|
||||
(dolist (note (plist-get book :notes))
|
||||
(insert (format
|
||||
"<div class=\"logline\"><span class=\"logdate\">[%s]</span> %s</div>\n"
|
||||
(spine--h (car note))
|
||||
(spine--h (cadr note)))))
|
||||
(let ((rating (plist-get book :rating)))
|
||||
(when (and rating (> (length rating) 0))
|
||||
(insert (format "<div>Rating: %s/5</div>\n"
|
||||
(spine--h rating)))))
|
||||
(let ((isbn (plist-get book :isbn)))
|
||||
(when (and isbn (> (length isbn) 0))
|
||||
(insert (format "<div>ISBN: %s</div>\n"
|
||||
(spine--h isbn)))))
|
||||
(let ((fmt (plist-get book :format)))
|
||||
(when (and fmt (> (length fmt) 0))
|
||||
(insert (format "<div>Format: %s</div>\n"
|
||||
(spine--h fmt)))))
|
||||
(let ((added (plist-get book :added)))
|
||||
(when (and added (> (length added) 0))
|
||||
(insert (format "<div>Added: %s</div>\n"
|
||||
(spine--h added)))))
|
||||
(insert "</div>\n"))
|
||||
(insert "</div>\n"))))))
|
||||
(insert "</div>\n</body>\n</html>\n")
|
||||
(buffer-string))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `spine-render-empty-state` function**
|
||||
|
||||
After `spine-render-index`, add:
|
||||
|
||||
```elisp
|
||||
(defun spine-render-empty-state ()
|
||||
"Render a page indicating no books file was found."
|
||||
(concat "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
|
||||
"<meta charset=\"utf-8\">\n"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
||||
"<title>Spine</title>\n</head>\n<body>\n"
|
||||
"<div class=\"frame\">\n"
|
||||
"<div class=\"titlebar\">Spine</div>\n"
|
||||
"<div class=\"row\">No books yet.</div>\n"
|
||||
"</div>\n</body>\n</html>\n"))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify batch load**
|
||||
|
||||
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
|
||||
Expected: exits cleanly, no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: add spine-render-index and empty-state view functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire up the index handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el`
|
||||
|
||||
- [ ] **Step 1: Replace `/hello` handler with `/` index handler**
|
||||
|
||||
Replace the `hello` handler (lines 48–51) with:
|
||||
|
||||
```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)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify batch load**
|
||||
|
||||
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
|
||||
Expected: exits cleanly.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: wire up / index handler replacing /hello"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Smoke test end-to-end
|
||||
|
||||
**Files:**
|
||||
- No changes — verification only.
|
||||
|
||||
- [ ] **Step 1: Start server with sample data**
|
||||
|
||||
```bash
|
||||
emacs --quick \
|
||||
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
|
||||
--load spine.el &
|
||||
EMACS_PID=$!
|
||||
sleep 5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify index page returns HTML with real data**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/ | head -20
|
||||
```
|
||||
|
||||
Expected: HTML document with:
|
||||
- `<title>Spine</title>`
|
||||
- `<div class="group">WANT (3)</div>`
|
||||
- `<div class="group">READING (1)</div>`
|
||||
- `<div class="group">READ (1)</div>`
|
||||
- No `ABANDONED` group (zero books)
|
||||
- "Use of Weapons" visible in output
|
||||
- Recommendation trail "Priya: If you liked..."
|
||||
|
||||
- [ ] **Step 3: Verify expanded detail via ?id=**
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/?id=8c1e-uow" | grep -A5 "detail"
|
||||
```
|
||||
|
||||
Expected: expanded detail section with reading notes for Use of Weapons:
|
||||
- `[2026-03-02] The two-track structure...`
|
||||
- `[2026-03-07] Zakalwe's competence...`
|
||||
- `[2026-03-09] The chapter numbering. Oh.`
|
||||
|
||||
- [ ] **Step 4: Verify bogus ?id= does not crash**
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/?id=nonexistent"
|
||||
```
|
||||
|
||||
Expected: normal index page, no expanded detail, HTTP 200.
|
||||
|
||||
- [ ] **Step 5: Verify empty state when file is missing**
|
||||
|
||||
```bash
|
||||
kill $EMACS_PID 2>/dev/null
|
||||
sleep 1
|
||||
emacs --quick \
|
||||
--eval "(setq spine-org-file \"/tmp/nonexistent-books.org\")" \
|
||||
--load spine.el &
|
||||
EMACS_PID=$!
|
||||
sleep 5
|
||||
curl -s http://localhost:8080/
|
||||
```
|
||||
|
||||
Expected: HTML containing `No books yet.`
|
||||
|
||||
- [ ] **Step 6: Stop server**
|
||||
|
||||
```bash
|
||||
kill $EMACS_PID 2>/dev/null
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify with scripts/run**
|
||||
|
||||
```bash
|
||||
# Override spine-org-file via the run script (pass it as an extra eval)
|
||||
SPINE_ORG="$(pwd)/sample-books.org"
|
||||
emacs --quick --daemon=spine \
|
||||
--eval "(setq spine-org-file \"$SPINE_ORG\")" \
|
||||
--load spine.el
|
||||
sleep 3
|
||||
curl -s http://localhost:8080/ | grep -c "WANT"
|
||||
# Expected: 1 (the group header "WANT (3)")
|
||||
emacsclient --socket-name=spine --eval '(kill-emacs)'
|
||||
```
|
||||
|
||||
The output should contain `1`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final verification and cleanup
|
||||
|
||||
**Files:**
|
||||
- No changes — verification only.
|
||||
|
||||
- [ ] **Step 1: Run full ERT test suite**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch \
|
||||
--directory "$(pwd)" \
|
||||
--load test/spine-books-test.el \
|
||||
-f ert-run-tests-batch-and-exit
|
||||
```
|
||||
|
||||
Expected: all 6 tests pass.
|
||||
|
||||
- [ ] **Step 2: Verify git status is clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: `nothing to commit, working tree clean`
|
||||
|
||||
- [ ] **Step 3: Review commit log**
|
||||
|
||||
```bash
|
||||
git log --oneline
|
||||
```
|
||||
|
||||
Expected: ~7 commits covering sample data, server guard, model, tests, view, handler.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Spine — Mustache Template Rendering
|
||||
|
||||
Replaces inline HTML generation with Mustache template rendering via
|
||||
`spine-render`. The mockup-A mustache template becomes the canonical
|
||||
index page template. A view-model builder function transforms `spine-books`
|
||||
plists into the template's expected context shape.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
spine.org ──→ [spine-books] ──→ book plists
|
||||
│
|
||||
[spine-index-model] ──→ view model (ht)
|
||||
│
|
||||
templates/index.mustache ───────────┼──→ [spine-render] ──→ HTML
|
||||
```
|
||||
|
||||
## Template
|
||||
|
||||
`spine-mockup-a-agenda.mustache` moves to `templates/index.mustache`.
|
||||
|
||||
The template already contains:
|
||||
- Pico CSS v2.1.1 CDN link
|
||||
- Tabler Icons CDN link
|
||||
- All custom CSS in `<style>` block (agenda layout, pills, detail, dark mode)
|
||||
- Mustache sections: `{{#groups}}`, `{{#books}}`, `{{#detail}}`, `{{#notes}}`, `{{#minibuffer}}`
|
||||
- `minibuffer` section kept in the template but not populated (empty context → section omitted)
|
||||
|
||||
## View model
|
||||
|
||||
A new function `spine-index-model` takes the book plists and an optional
|
||||
selected-id, returns an `ht` hash table shaped for the template:
|
||||
|
||||
```elisp
|
||||
(spine-index-model books &optional selected-id) → ht
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"app_title": "spine",
|
||||
"total_books": 5,
|
||||
"reading_count": 1,
|
||||
"groups": [
|
||||
{
|
||||
"label": "on deck",
|
||||
"count": 3,
|
||||
"books": [
|
||||
{
|
||||
"format_icon": "ti-book",
|
||||
"status_class": "want",
|
||||
"status_label": "want",
|
||||
"title": "The Left Hand of Darkness",
|
||||
"author": "Ursula K. Le Guin",
|
||||
"tags_inline": null,
|
||||
"rec_via": "Alex",
|
||||
"rating_display": null,
|
||||
"selected": false,
|
||||
"detail": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Field mappings
|
||||
|
||||
| Plist key | Template field | Transform |
|
||||
|---|---|---|
|
||||
| `:status` | `groups[].label` | WANT→"on deck", READING→"reading", READ→"read", ABANDONED→"abandoned" |
|
||||
| `:status` | `status_class` / `status_label` | lowercased |
|
||||
| `:format` | `format_icon` | hardcover→`ti-book`, ebook→`ti-device-tablet`, audiobook→`ti-headphones`, nil→`""` |
|
||||
| `:tags` | `tags_inline` | `("scifi" "literary")` → `":scifi:literary:"` (string), nil→nil |
|
||||
| `:rec_by` | `rec_via` | direct string, nil→nil |
|
||||
| `:rating` | `rating_display` | "5"→"★★★★★", nil→nil |
|
||||
| `:id` = selected-id | `selected` | boolean |
|
||||
| `:notes` | `detail.notes` | list of `{date, text}` |
|
||||
| `:added` + `:format` | `detail.meta` | composite string |
|
||||
|
||||
### Detail meta format
|
||||
|
||||
```
|
||||
"started [2026-02-20] · hardcover" (WANT/READING)
|
||||
"finished [2026-01-10] · ebook" (READ, use most recent LOGBOOK date)
|
||||
"" (no dates)
|
||||
```
|
||||
|
||||
### Group ordering
|
||||
|
||||
`on deck` (WANT) → `reading` (READING) → `read` (READ) → `abandoned` (ABANDONED).
|
||||
Zero-book groups omitted.
|
||||
|
||||
## Handler
|
||||
|
||||
```elisp
|
||||
(defservlet index text/html (path query request)
|
||||
(let ((books (spine-books)))
|
||||
(if books
|
||||
(insert (spine-render "index.mustache"
|
||||
(spine-index-model books (cadr (assoc "id" query)))))
|
||||
(insert (spine-render-empty-state)))))
|
||||
```
|
||||
|
||||
`spine-render-empty-state` keeps its current Pico-styled inline HTML
|
||||
(no Mustache needed for a single static message).
|
||||
|
||||
## Templates directory
|
||||
|
||||
`spine-mockup-a-agenda.mustache` → `templates/index.mustache`
|
||||
|
||||
`spine-mockup-b-journal.mustache` stays in root as a reference/mockup
|
||||
for the future detail view.
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `templates/index.mustache` | New — moved from root mockup |
|
||||
| `spine.el` | Add `spine-index-model`, update handler, remove old `spine-render-index` |
|
||||
| `spine-mockup-a-agenda.mustache` | Deleted (moved) |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Populating the `minibuffer` section (capture UI)
|
||||
- Mockup B / journal detail view
|
||||
- Cover image display
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Index page renders via Mustache, not inline HTML
|
||||
- [ ] Pico CSS + Tabler Icons CDN links present
|
||||
- [ ] Groups: on deck, reading, read — correct order, zero-book groups omitted
|
||||
- [ ] Format shown as Tabler icon glyph
|
||||
- [ ] Status pills have distinct colors per state
|
||||
- [ ] `?id=...` expands matching book detail
|
||||
- [ ] Recommendation appears as "via {name}"
|
||||
- [ ] ERT tests still pass (model layer unchanged)
|
||||
@@ -0,0 +1,163 @@
|
||||
# Spine — Pico CSS styling
|
||||
|
||||
Styles the index page using [Pico CSS v2](https://picocss.com/) via CDN,
|
||||
restructuring HTML to use Pico-native semantic elements and adding a small
|
||||
custom `<style>` block for the few things Pico doesn't cover.
|
||||
|
||||
## CSS strategy
|
||||
|
||||
- **Pico CSS v2** loaded from CDN:
|
||||
`https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css`
|
||||
- No build step. No local CSS file. No Sass/PostCSS.
|
||||
- Light/dark theme auto-switches via `prefers-color-scheme` (Pico default).
|
||||
- Custom styles in a single inline `<style>` block inside `<head>`.
|
||||
- **No Tabler Icons.** Format glyphs use emoji instead.
|
||||
|
||||
## HTML restructure
|
||||
|
||||
Pico styles semantic elements. The current `<div>`-heavy output is rewritten
|
||||
to use Pico-native elements. Only `spine-` prefixed classes remain for
|
||||
layout pieces Pico doesn't provide.
|
||||
|
||||
| Current element | New element | Purpose |
|
||||
|---|---|---|
|
||||
| `<div class="frame">` | `<article>` | Card container (Pico styles borders/padding) |
|
||||
| `<div class="titlebar">` | `<header>` inside `<article>` | Title bar with book count |
|
||||
| `<div class="group">WANT (3)</div>` | `<hgroup><h2>WANT</h2><small>3</small></hgroup>` | Group header with count |
|
||||
| `<div class="row">` | `<div class="spine-row">` | Book row (flex, Pico has no horizontal list) |
|
||||
| `<div class="detail">` | `<blockquote class="spine-detail">` | Expanded notes section |
|
||||
| `<span class="pill want">` | `<mark class="want">want</mark>` | Status badge (colored per status) |
|
||||
| `<span class="glyph">` | `<span class="spine-glyph">` | Format emoji |
|
||||
| `<span class="title">` | `<span class="spine-title">` | Book title + author |
|
||||
| `<span class="muted">` | `<small>` | Author name (Pico styles `<small>` as muted) |
|
||||
| `<span class="tags">` | `<small>` with tag text | Category tags |
|
||||
| `<span class="trail">` | `<span class="spine-trail">` | Recommendation trail |
|
||||
|
||||
## Custom CSS
|
||||
|
||||
In an inline `<style>` block in `<head>`:
|
||||
|
||||
```css
|
||||
.spine-row {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
.spine-row:last-child { border-bottom: none; }
|
||||
.spine-selected { background: var(--pico-secondary-background); }
|
||||
.spine-glyph { width: 1.5rem; text-align: center; flex-shrink: 0; }
|
||||
.spine-title { flex: 1; min-width: 0; }
|
||||
.spine-trail { font-size: 0.85em; color: var(--pico-muted-color); }
|
||||
.spine-detail {
|
||||
margin: 0 0 0 2rem;
|
||||
border-left: 2px solid var(--pico-primary);
|
||||
}
|
||||
.spine-detail p { margin-bottom: 0.3rem; }
|
||||
.spine-detail footer { margin-top: 0.5rem; }
|
||||
|
||||
/* Status pill colors */
|
||||
mark.want { background: var(--pico-mark-background); color: var(--pico-mark-color); }
|
||||
mark.reading { background: #faeeda; color: #854f0b; }
|
||||
mark.read { background: #e1f5ee; color: #085041; }
|
||||
mark.abandoned { background: var(--pico-muted-color); color: var(--pico-background-color); }
|
||||
|
||||
/* Pico-specific overrides */
|
||||
article { margin-bottom: 0; }
|
||||
article header { padding-bottom: 0.5rem; margin-bottom: 0.5rem; }
|
||||
```
|
||||
|
||||
## Format glyphs
|
||||
|
||||
Emoji per format, empty string if unset:
|
||||
|
||||
| Format | Glyph |
|
||||
|---|---|
|
||||
| `hardcover` | 📖 |
|
||||
| `ebook` | 📱 |
|
||||
| `audiobook` | 🎧 |
|
||||
| `nil` / unset | (empty) |
|
||||
|
||||
## Full page shell
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Spine</title>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>…custom styles…</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<article>
|
||||
<header>
|
||||
<strong>spine</strong>
|
||||
<small>N books · M reading</small>
|
||||
</header>
|
||||
…groups and rows…
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Example output (Use of Weapons, expanded)
|
||||
|
||||
```html
|
||||
<hgroup><h2>READING</h2><small>1</small></hgroup>
|
||||
|
||||
<div class="spine-row spine-selected">
|
||||
<span class="spine-glyph">🎧</span>
|
||||
<span class="spine-title">Use of Weapons <small>Iain M. Banks</small></span>
|
||||
<small>:scifi: :literary:</small>
|
||||
<span class="spine-trail">Priya: If you liked Player of Games, this one will wreck you</span>
|
||||
<mark class="reading">reading</mark>
|
||||
|
||||
<blockquote class="spine-detail">
|
||||
<p><small>[2026-03-02]</small> The two-track structure is doing something I can't name yet.</p>
|
||||
<p><small>[2026-03-07]</small> Zakalwe's competence reads as a wound.</p>
|
||||
<p><small>[2026-03-09]</small> The chapter numbering. Oh.</p>
|
||||
<footer>Format: audiobook · ISBN: 978-0316029193 · Added: [2026-02-20]</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Empty state
|
||||
|
||||
```html
|
||||
<article>
|
||||
<header><strong>spine</strong></header>
|
||||
<p>No books yet.</p>
|
||||
</article>
|
||||
```
|
||||
|
||||
## Files changed
|
||||
|
||||
- `spine.el` — `spine-render-index` and `spine-render-empty-state` rewritten
|
||||
to emit Pico-structured HTML with inline CSS
|
||||
|
||||
No new files. No new dependencies beyond the CDN `<link>`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- JavaScript interactivity
|
||||
- Cover image display
|
||||
- Write endpoints
|
||||
- Per-note format
|
||||
- `?id=` selection without page reload (full page reload only)
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Page renders with Pico CSS applied (fonts, spacing, colors match Pico defaults)
|
||||
- [ ] Light/dark mode switches with OS preference
|
||||
- [ ] Groups appear in order: WANT, READING, READ — zero-book groups omitted
|
||||
- [ ] `?id=...` expands the matching book's detail with reading notes
|
||||
- [ ] Status pills have distinct colors per state
|
||||
- [ ] Format shown as emoji glyph
|
||||
- [ ] Recommendation trail shown when `REC_BY` present
|
||||
- [ ] Empty state shows "No books yet."
|
||||
- [ ] No console 404s (Pico CDN resolves)
|
||||
- [ ] No Tabler Icons reference in output
|
||||
@@ -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
|
||||
<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
|
||||
@@ -0,0 +1,79 @@
|
||||
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
|
||||
|
||||
* WANT The Left Hand of Darkness
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Ursula K. Le Guin
|
||||
:ISBN: 978-0441478125
|
||||
:COVER: covers/left-hand.jpg
|
||||
:FORMAT: ebook
|
||||
:REC_BY: Alex
|
||||
:REC_NOTE: Le Guin at her most human
|
||||
:RATING:
|
||||
:ADDED: [2026-04-01]
|
||||
:ID: 9a2b-lhd
|
||||
:END:
|
||||
|
||||
* WANT Dune
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Frank Herbert
|
||||
:ISBN: 978-0441172719
|
||||
:COVER: covers/dune.jpg
|
||||
:FORMAT: hardcover
|
||||
:REC_BY: Everyone
|
||||
:REC_NOTE:
|
||||
:RATING:
|
||||
:ADDED: [2026-05-10]
|
||||
:ID: 7f3c-dun
|
||||
:END:
|
||||
|
||||
* WANT Piranesi
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Susanna Clarke
|
||||
:ISBN:
|
||||
:COVER:
|
||||
:FORMAT:
|
||||
:REC_BY:
|
||||
:REC_NOTE:
|
||||
:RATING:
|
||||
:ADDED: [2026-06-01]
|
||||
:ID: 5e8d-pir
|
||||
:END:
|
||||
|
||||
* READING Use of Weapons :scifi:literary:
|
||||
: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-03-01]
|
||||
: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.
|
||||
- [2026-03-09] The chapter numbering. Oh.
|
||||
|
||||
* READ Ancillary Justice
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Ann Leckie
|
||||
:ISBN: 978-0316246620
|
||||
:COVER: covers/ancillary.jpg
|
||||
:FORMAT: ebook
|
||||
:REC_BY:
|
||||
:REC_NOTE:
|
||||
:RATING: 5
|
||||
:ADDED: [2025-11-15]
|
||||
:ID: 3a1b-aj
|
||||
:END:
|
||||
:LOGBOOK:
|
||||
- State "READ" from "READING" [2026-01-10]
|
||||
- State "READING" from "WANT" [2025-11-20]
|
||||
:END:
|
||||
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
|
||||
- [2025-12-05] Justice of Toren's multi-body perspective is unsettling.
|
||||
- [2026-01-08] The tea set. Perfect ending.
|
||||
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Spine — Direction A (agenda)</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
|
||||
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
|
||||
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
|
||||
--bg-warning: #faeeda; --text-warning: #854f0b;
|
||||
--bg-success: #e1f5ee; --text-success: #085041;
|
||||
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
|
||||
--radius-md: 8px; --radius-lg: 12px;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
|
||||
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
|
||||
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
|
||||
--bg-warning: #3a2c12; --text-warning: #fac775;
|
||||
--bg-success: #103a30; --text-success: #9fe1cb;
|
||||
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
|
||||
font-family: var(--sans); padding: 28px 16px; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; }
|
||||
.frame { font-family: var(--mono); border: 0.5px solid var(--border-secondary);
|
||||
border-radius: var(--radius-lg); overflow: hidden; background: var(--bg-primary); }
|
||||
.titlebar { display:flex; align-items:center; justify-content:space-between;
|
||||
padding:10px 14px; border-bottom:0.5px solid var(--border-tertiary);
|
||||
background: var(--bg-secondary); font-size:13px; }
|
||||
.group { padding:10px 14px 4px; font-size:11px; letter-spacing:0.04em; color: var(--text-tertiary); }
|
||||
.row { display:flex; align-items:center; gap:10px; padding:5px 14px; font-size:13px; }
|
||||
.glyph { font-size:15px; color:var(--text-tertiary); width:16px; }
|
||||
.pill { padding:1px 7px; border-radius:var(--radius-md); font-size:11px; }
|
||||
.pill.want { background:var(--bg-info); color:var(--text-info); }
|
||||
.pill.reading { background:var(--bg-warning); color:var(--text-warning); }
|
||||
.pill.read { background:var(--bg-success); color:var(--text-success); }
|
||||
.title { flex:1; min-width:0; }
|
||||
.muted { color:var(--text-tertiary); }
|
||||
.tags { color:var(--text-info); font-size:12px; }
|
||||
.trail { color:var(--text-secondary); font-size:12px; }
|
||||
.selected { background:var(--bg-secondary); border-left:2px solid var(--border-info); }
|
||||
.detail { padding:8px 14px 12px 32px; background:var(--bg-secondary);
|
||||
border-left:2px solid var(--border-info); font-size:12.5px; }
|
||||
.logline { display:flex; gap:8px; margin-bottom:5px; }
|
||||
.logdate { color:var(--text-tertiary); white-space:nowrap; }
|
||||
.minibuffer { border-top:0.5px solid var(--border-tertiary); padding:9px 14px;
|
||||
background:var(--bg-secondary); font-size:13px; display:flex; gap:8px; align-items:center; }
|
||||
.caret { opacity:0.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="frame">
|
||||
<div class="titlebar">
|
||||
<span style="font-weight:500;">spine · agenda</span>
|
||||
<span class="muted">6 books · 1 reading</span>
|
||||
</div>
|
||||
|
||||
<div class="group">on deck · 3</div>
|
||||
<div class="row">
|
||||
<i class="ti ti-book glyph" aria-hidden="true"></i>
|
||||
<span class="pill want">want</span>
|
||||
<span class="title">Tomorrow, and Tomorrow, and Tomorrow <span class="muted">· Zevin</span></span>
|
||||
<span class="trail">via Priya</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<i class="ti ti-headphones glyph" aria-hidden="true"></i>
|
||||
<span class="pill want">want</span>
|
||||
<span class="title">Children of Time <span class="muted">· Tchaikovsky</span></span>
|
||||
<span class="tags">:scifi:</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<i class="ti ti-device-tablet glyph" aria-hidden="true"></i>
|
||||
<span class="pill want">want</span>
|
||||
<span class="title">The Spear Cuts Through Water <span class="muted">· Jimenez</span></span>
|
||||
<span class="trail">via book club</span>
|
||||
</div>
|
||||
|
||||
<div class="group">reading · 1</div>
|
||||
<div class="row selected">
|
||||
<i class="ti ti-book glyph" style="color:var(--text-secondary);" aria-hidden="true"></i>
|
||||
<span class="pill reading">reading</span>
|
||||
<span class="title" style="font-weight:500;">Use of Weapons <span class="muted" style="font-weight:400;">· Banks</span></span>
|
||||
<span class="tags">:scifi:literary:</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<div class="muted" style="margin-bottom:8px;">started [2026-02-20] · hardcover · 62% · rating —</div>
|
||||
<div class="logline"><span class="logdate">[03-02]</span><span>The two-track structure is doing something I can't name yet. Suspicious of the chair.</span></div>
|
||||
<div class="logline"><span class="logdate">[03-07]</span><span>Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.</span></div>
|
||||
<div class="logline" style="margin-bottom:0;"><span class="logdate">[03-09]</span><span>The chapter numbering. Oh.</span></div>
|
||||
</div>
|
||||
|
||||
<div class="group">read · 2</div>
|
||||
<div class="row">
|
||||
<i class="ti ti-headphones glyph" aria-hidden="true"></i>
|
||||
<span class="pill read">read</span>
|
||||
<span class="title">A Memory Called Empire <span class="muted">· Martine</span></span>
|
||||
<span class="trail">★★★★★</span>
|
||||
</div>
|
||||
<div class="row" style="padding-bottom:8px;">
|
||||
<i class="ti ti-book glyph" aria-hidden="true"></i>
|
||||
<span class="pill read">read</span>
|
||||
<span class="title">Piranesi <span class="muted">· Clarke</span></span>
|
||||
<span class="trail">★★★★☆</span>
|
||||
</div>
|
||||
|
||||
<div class="minibuffer">
|
||||
<span class="muted">M-x</span>
|
||||
<span>spine-note</span>
|
||||
<span style="border-left:2px solid var(--text-info); padding-left:6px;">Banks lets the structure detonate the whole book<span class="caret">▏</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Spine — Direction B (journal)</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
|
||||
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
|
||||
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
|
||||
--bg-warning: #faeeda; --text-warning: #854f0b;
|
||||
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
|
||||
--radius-md: 8px; --radius-lg: 12px;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
|
||||
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
|
||||
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
|
||||
--bg-warning: #3a2c12; --text-warning: #fac775;
|
||||
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
|
||||
font-family: var(--sans); padding: 28px 16px; }
|
||||
.wrap { max-width: 560px; margin: 0 auto; }
|
||||
.surface { background: var(--bg-secondary); border-radius: var(--radius-lg); padding: 1.25rem; }
|
||||
.card { background: var(--bg-primary); border:0.5px solid var(--border-tertiary);
|
||||
border-radius: var(--radius-lg); padding: 1.1rem 1.25rem; }
|
||||
.cover { width:44px; height:60px; border-radius:var(--radius-md); background:var(--bg-info);
|
||||
display:flex; align-items:center; justify-content:center; flex-shrink:0; }
|
||||
.pill { background:var(--bg-warning); color:var(--text-warning); padding:2px 9px;
|
||||
border-radius:var(--radius-md); font-size:12px; }
|
||||
.seg { flex:1; text-align:center; padding:7px 0; font-size:13px; border:0.5px solid var(--border-tertiary);
|
||||
border-radius:var(--radius-md); color:var(--text-secondary); }
|
||||
.seg.active { border:2px solid var(--border-info); color:var(--text-info); background:var(--bg-info); }
|
||||
.seclabel { font-size:12px; letter-spacing:0.03em; color:var(--text-tertiary); margin-bottom:10px; }
|
||||
.thread { border-left:2px solid var(--border-tertiary); padding-left:14px; margin-bottom:14px; }
|
||||
.entry { margin-bottom:12px; }
|
||||
.entry:last-child { margin-bottom:0; }
|
||||
.edate { font-size:12px; color:var(--text-tertiary); margin-bottom:2px; }
|
||||
.etext { font-size:14px; line-height:1.5; }
|
||||
input, button { font-family: var(--sans); font-size:14px; }
|
||||
input { height:36px; padding:0 10px; border:0.5px solid var(--border-secondary);
|
||||
border-radius:var(--radius-md); background:var(--bg-primary); color:var(--text-primary); }
|
||||
input::placeholder { color:var(--text-tertiary); }
|
||||
button { height:36px; padding:0 12px; border:0.5px solid var(--border-secondary);
|
||||
border-radius:var(--radius-md); background:transparent; color:var(--text-primary); cursor:pointer; }
|
||||
button:hover { background:var(--bg-secondary); }
|
||||
.avatar { width:30px; height:30px; border-radius:50%; background:var(--bg-info);
|
||||
display:flex; align-items:center; justify-content:center; font-size:12px;
|
||||
color:var(--text-info); flex-shrink:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="surface">
|
||||
<div class="card">
|
||||
|
||||
<div style="display:flex; gap:14px; align-items:flex-start; margin-bottom:16px;">
|
||||
<div class="cover"><i class="ti ti-book" style="font-size:22px; color:var(--text-info);" aria-hidden="true"></i></div>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-weight:500; font-size:16px;">Use of Weapons</div>
|
||||
<div style="font-size:14px; color:var(--text-secondary); margin-bottom:6px;">Iain M. Banks</div>
|
||||
<span class="pill">Reading</span>
|
||||
<span style="color:var(--text-tertiary); font-size:13px; margin-left:8px;">started 20 Feb · 62%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; margin-bottom:18px;">
|
||||
<div class="seg"><i class="ti ti-book" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>Hardcover</div>
|
||||
<div class="seg"><i class="ti ti-device-tablet" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>eBook</div>
|
||||
<div class="seg active"><i class="ti ti-headphones" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>Audiobook</div>
|
||||
</div>
|
||||
|
||||
<div class="seclabel">Reading log</div>
|
||||
<div class="thread">
|
||||
<div class="entry">
|
||||
<div class="edate">2 Mar</div>
|
||||
<div class="etext">The two-track structure is doing something I can't name yet. Suspicious of the chair.</div>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="edate">7 Mar</div>
|
||||
<div class="etext">Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.</div>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="edate">9 Mar</div>
|
||||
<div class="etext">The chapter numbering. Oh.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; margin-bottom:20px;">
|
||||
<input type="text" placeholder="Add a note at 62%…" style="flex:1;" />
|
||||
<button><i class="ti ti-plus" style="font-size:15px; vertical-align:-2px; margin-right:4px;" aria-hidden="true"></i>Log note</button>
|
||||
</div>
|
||||
|
||||
<div style="border-top:0.5px solid var(--border-tertiary); padding-top:14px;">
|
||||
<div class="seclabel">On your radar</div>
|
||||
<div style="display:flex; align-items:flex-start; gap:10px; margin-bottom:14px;">
|
||||
<div class="avatar">PK</div>
|
||||
<div style="font-size:14px; line-height:1.5;"><span style="font-weight:500;">Priya</span> <span style="color:var(--text-tertiary);">· 12 Jan</span><br>"If you liked Player of Games, this one will wreck you in a better way."</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<input type="text" placeholder="Recommended by…" style="width:34%;" />
|
||||
<input type="text" placeholder="Why it's on your radar…" style="flex:1;" />
|
||||
<button aria-label="Save recommendation"><i class="ti ti-check" style="font-size:16px;" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Spine — Direction B (journal)</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||
<style>
|
||||
.book { max-width: 560px; margin-inline: auto; }
|
||||
.book hgroup { margin-bottom: .4rem; }
|
||||
.book hgroup h3 { margin-bottom: .1rem; }
|
||||
.book hgroup p { color: var(--pico-muted-color); margin: 0; }
|
||||
.cover { width: 44px; height: 60px; border-radius: var(--pico-border-radius);
|
||||
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.pill { padding: .1rem .5rem; border-radius: var(--pico-border-radius); font-size: .75rem; font-weight: 500; }
|
||||
.pill.want { background: #e6f1fb; color: #0c447c; }
|
||||
.pill.reading { background: #faeeda; color: #854f0b; }
|
||||
.pill.read { background: #e1f5ee; color: #085041; }
|
||||
.formats { margin-bottom: 1.1rem; }
|
||||
.formats button { --pico-font-size: .85rem; }
|
||||
.seclabel { font-size: .72rem; letter-spacing: .03em; color: var(--pico-muted-color);
|
||||
text-transform: none; margin-bottom: .6rem; }
|
||||
.thread { border-left: 2px solid var(--pico-muted-border-color); padding-left: .9rem; margin-bottom: .9rem; }
|
||||
.entry { margin-bottom: .8rem; }
|
||||
.entry:last-child { margin-bottom: 0; }
|
||||
.edate { font-size: .75rem; color: var(--pico-muted-color); margin-bottom: .15rem; }
|
||||
.etext { font-size: .9rem; line-height: 1.5; }
|
||||
.avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
|
||||
display: flex; align-items: center; justify-content: center; font-size: .75rem; }
|
||||
.compose, .recform { margin-bottom: 0; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pill.want { background: #14304a; color: #b5d4f4; }
|
||||
.pill.reading { background: #3a2c12; color: #fac775; }
|
||||
.pill.read { background: #103a30; color: #9fe1cb; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{!
|
||||
View model for this template:
|
||||
{
|
||||
cover_icon: string, Tabler class for the cover placeholder, e.g. "ti-book"
|
||||
title: string,
|
||||
author: string,
|
||||
status_class: string, "want" | "reading" | "read"
|
||||
status_label: string,
|
||||
meta: string, e.g. "started 20 Feb · 62%"
|
||||
progress_label: string, e.g. "62%" (used in the note composer placeholder)
|
||||
formats: [ { icon: string, label: string, active: boolean } ], the format selector segments
|
||||
notes: [ { date: string, text: string } ], reading log
|
||||
recommendation: { optional
|
||||
initials: string,
|
||||
by: string,
|
||||
date: string,
|
||||
note: string
|
||||
}?
|
||||
}
|
||||
}}
|
||||
<main class="container">
|
||||
<article class="book">
|
||||
|
||||
<div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 1rem;">
|
||||
<div class="cover"><i class="ti {{cover_icon}}" style="font-size: 22px;" aria-hidden="true"></i></div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<hgroup>
|
||||
<h3>{{title}}</h3>
|
||||
<p>{{author}}</p>
|
||||
</hgroup>
|
||||
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||
<span class="muted" style="color: var(--pico-muted-color); font-size: .85rem; margin-left: .5rem;">{{meta}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formats" role="group">
|
||||
{{#formats}}
|
||||
<button class="{{^active}}secondary outline{{/active}}"><i class="ti {{icon}}" style="font-size: 15px; vertical-align: -2px; margin-right: 5px;" aria-hidden="true"></i>{{label}}</button>
|
||||
{{/formats}}
|
||||
</div>
|
||||
|
||||
<p class="seclabel">Reading log</p>
|
||||
<div class="thread">
|
||||
{{#notes}}
|
||||
<div class="entry">
|
||||
<div class="edate">{{date}}</div>
|
||||
<div class="etext">{{text}}</div>
|
||||
</div>
|
||||
{{/notes}}
|
||||
</div>
|
||||
|
||||
<div class="compose" role="group">
|
||||
<input type="text" placeholder="Add a note at {{progress_label}}…" />
|
||||
<button><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<p class="seclabel">On your radar</p>
|
||||
{{#recommendation}}
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 1rem;">
|
||||
<div class="avatar">{{initials}}</div>
|
||||
<div style="font-size: .9rem; line-height: 1.5;"><strong>{{by}}</strong> <span class="muted" style="color: var(--pico-muted-color);">· {{date}}</span><br>"{{note}}"</div>
|
||||
</div>
|
||||
{{/recommendation}}
|
||||
<div class="recform" role="group">
|
||||
<input type="text" placeholder="Recommended by…" style="flex: 0 0 34%;" />
|
||||
<input type="text" placeholder="Why it's on your radar…" />
|
||||
<button aria-label="Save recommendation"><i class="ti ti-check" style="font-size: 16px;" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+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.
|
||||
@@ -16,6 +16,7 @@
|
||||
(require 'simple-httpd)
|
||||
(require 'mustache)
|
||||
(require 'ht)
|
||||
(require 'cl-lib)
|
||||
|
||||
;; --- configuration ----------------------------------------------------
|
||||
|
||||
@@ -26,6 +27,9 @@
|
||||
nil means localhost only. Set to a string like \"0.0.0.0\" to listen on
|
||||
all interfaces.")
|
||||
|
||||
(defvar spine-org-file "~/spine/books.org"
|
||||
"Path to the spine.org data file.")
|
||||
|
||||
(defvar spine-template-dir
|
||||
(expand-file-name "templates/"
|
||||
(file-name-directory load-file-name))
|
||||
@@ -43,19 +47,200 @@ Returns the rendered string."
|
||||
(buffer-string))
|
||||
context))
|
||||
|
||||
(defun spine--h (text)
|
||||
"HTML-escape TEXT."
|
||||
(when text
|
||||
(let ((s text))
|
||||
(setq s (replace-regexp-in-string "&" "&" s))
|
||||
(setq s (replace-regexp-in-string "<" "<" s))
|
||||
(setq s (replace-regexp-in-string ">" ">" s))
|
||||
(setq s (replace-regexp-in-string "\"" """ s))
|
||||
s)))
|
||||
|
||||
(defun spine-index-model (books &optional selected-id)
|
||||
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||
SELECTED-ID expands the matching book's detail section."
|
||||
(let* ((status-labels
|
||||
'(("WANT" . "on deck")
|
||||
("READING" . "reading")
|
||||
("READ" . "read")
|
||||
("ABANDONED" . "abandoned")))
|
||||
(format-icons
|
||||
'(("hardcover" . "ti-book")
|
||||
("ebook" . "ti-device-tablet")
|
||||
("audiobook" . "ti-headphones")))
|
||||
(order '("WANT" "READING" "READ" "ABANDONED"))
|
||||
(grouped (make-hash-table :test 'equal))
|
||||
(total (length books))
|
||||
(reading-count 0))
|
||||
(dolist (book books)
|
||||
(when (equal "READING" (plist-get book :status))
|
||||
(cl-incf reading-count)))
|
||||
(dolist (book books)
|
||||
(let ((status (or (plist-get book :status) "WANT")))
|
||||
(push book (gethash status grouped))))
|
||||
(let ((groups nil))
|
||||
(dolist (status order)
|
||||
(let ((group-books (nreverse (gethash status grouped))))
|
||||
(when group-books
|
||||
(let ((book-models nil))
|
||||
(dolist (book group-books)
|
||||
(let* ((id (plist-get book :id))
|
||||
(selected (equal id selected-id))
|
||||
(fmt (plist-get book :format))
|
||||
(rating (plist-get book :rating))
|
||||
(notes (plist-get book :notes))
|
||||
(status (or (plist-get book :status) "WANT"))
|
||||
(model
|
||||
(ht
|
||||
("format_icon"
|
||||
(or (cdr (assoc fmt format-icons)) ""))
|
||||
("status_class" (downcase status))
|
||||
("status_label" (downcase status))
|
||||
("title" (plist-get book :title))
|
||||
("author" (or (plist-get book :author) ""))
|
||||
("tags_inline"
|
||||
(let ((tags (plist-get book :tags)))
|
||||
(when tags
|
||||
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
||||
tags " "))))
|
||||
("rec_via" (plist-get book :rec_by))
|
||||
("rating_display"
|
||||
(when (and rating (> (length rating) 0))
|
||||
(make-string (string-to-number rating) ?\u2605)))
|
||||
("selected" selected)
|
||||
("detail"
|
||||
(when selected
|
||||
(ht
|
||||
("meta"
|
||||
(let ((added (plist-get book :added))
|
||||
(fmt (plist-get book :format)))
|
||||
(cond
|
||||
((and added fmt)
|
||||
(format "started %s · %s" added fmt))
|
||||
(added (format "started %s" added))
|
||||
(fmt (format "format: %s" fmt))
|
||||
(t ""))))
|
||||
("notes"
|
||||
(when notes
|
||||
(cl-loop for (date text) in notes
|
||||
collect (ht
|
||||
("date" (format "[%s]" date))
|
||||
("text" text)))))))))))
|
||||
(push model book-models)))
|
||||
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||
("count" (length group-books))
|
||||
("books" (nreverse book-models)))
|
||||
groups)))))
|
||||
(ht ("app_title" "spine")
|
||||
("total_books" total)
|
||||
("reading_count" reading-count)
|
||||
("groups" (nreverse groups))))))
|
||||
|
||||
(defun spine--extract-notes (hl-begin hl-end)
|
||||
"Extract reading notes from headline body between HL-BEGIN and HL-END.
|
||||
Returns list of (date-string text-string) pairs."
|
||||
(let ((notes nil))
|
||||
(save-excursion
|
||||
(goto-char hl-begin)
|
||||
;; Skip past PROPERTIES and LOGBOOK drawers
|
||||
(while (re-search-forward
|
||||
"^[ \t]*:\\(PROPERTIES\\|LOGBOOK\\):" hl-end t)
|
||||
(re-search-forward "^[ \t]*:END:" hl-end t))
|
||||
;; Collect note lines: "- [YYYY-MM-DD] text"
|
||||
(while (re-search-forward
|
||||
"^- \\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)\\] \\(.*\\)"
|
||||
hl-end t)
|
||||
(push (list (match-string 1) (match-string 2)) notes)))
|
||||
(nreverse notes)))
|
||||
|
||||
|
||||
(defun spine--prop (pos prop)
|
||||
"Get Org property PROP at headline position POS.
|
||||
Returns nil for empty or missing properties."
|
||||
(let ((val (save-excursion
|
||||
(goto-char pos)
|
||||
(org-entry-get nil prop))))
|
||||
(and val (> (length val) 0) val)))
|
||||
(defun spine-books ()
|
||||
"Return a list of plists, one per top-level headline in `spine-org-file'.
|
||||
Returns nil if the file is missing or cannot be parsed."
|
||||
(if (not (file-exists-p spine-org-file))
|
||||
(progn
|
||||
(message "spine-books: file not found: %s" spine-org-file)
|
||||
nil)
|
||||
(condition-case err
|
||||
(with-temp-buffer
|
||||
(insert-file-contents spine-org-file)
|
||||
(org-mode)
|
||||
(let ((books nil))
|
||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||
(lambda (hl)
|
||||
(when (= (org-element-property :level hl) 1)
|
||||
(let ((pos (org-element-property :begin hl)))
|
||||
(push (list :id (spine--prop pos "ID")
|
||||
:title (org-element-property :title hl)
|
||||
:status (org-element-property :todo-keyword hl)
|
||||
:author (spine--prop pos "AUTHOR")
|
||||
:isbn (spine--prop pos "ISBN")
|
||||
:cover (spine--prop pos "COVER")
|
||||
:format (spine--prop pos "FORMAT")
|
||||
:rec_by (spine--prop pos "REC_BY")
|
||||
:rec_note (spine--prop pos "REC_NOTE")
|
||||
:rating (spine--prop pos "RATING")
|
||||
:added (spine--prop pos "ADDED")
|
||||
:tags (org-element-property :tags hl)
|
||||
:notes (spine--extract-notes
|
||||
(org-element-property :begin hl)
|
||||
(org-element-property :end hl)))
|
||||
books)))))
|
||||
(nreverse books)))
|
||||
(error
|
||||
(message "spine-books: failed to parse %s: %s"
|
||||
spine-org-file (error-message-string err))
|
||||
nil))))
|
||||
|
||||
|
||||
(defun spine-render-empty-state ()
|
||||
"Render a page indicating no books file was found."
|
||||
(concat "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
|
||||
"<meta charset=\"utf-8\">\n"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
||||
"<title>Spine</title>\n"
|
||||
"<link rel=\"stylesheet\""
|
||||
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n"
|
||||
"</head>\n<body>\n"
|
||||
"<main class=\"container\">\n"
|
||||
"<article>\n"
|
||||
"<header><strong>spine</strong></header>\n"
|
||||
"<p>No books yet.</p>\n"
|
||||
"</article>\n"
|
||||
"</main>\n</body>\n</html>"))
|
||||
|
||||
(defvar spine-skip-server-start nil
|
||||
"When non-nil, do not start the HTTP server.
|
||||
Set by test harnesses that only need the model functions.")
|
||||
|
||||
;; --- handlers ---------------------------------------------------------
|
||||
|
||||
(defservlet hello text/html ()
|
||||
(insert (spine-render "hello.mustache"
|
||||
(ht ("title" "Spine")
|
||||
("message" "Hello from Emacs.")))))
|
||||
(defservlet index text/html (path query request)
|
||||
(let ((books (spine-books)))
|
||||
(if books
|
||||
(insert (spine-render "index.mustache"
|
||||
(spine-index-model books (cadr (assoc "id" query)))))
|
||||
(insert (spine-render-empty-state)))))
|
||||
|
||||
(defun httpd/ (proc uri-path query request)
|
||||
"Redirect root to /index."
|
||||
(httpd-redirect proc "/index" 302))
|
||||
|
||||
;; --- start ------------------------------------------------------------
|
||||
(setq httpd-host spine-host)
|
||||
(setq httpd-port spine-port)
|
||||
(httpd-start)
|
||||
(message "Spine listening on http://%s:%d"
|
||||
(or spine-host "localhost") spine-port)
|
||||
(unless spine-skip-server-start
|
||||
(setq httpd-host spine-host)
|
||||
(setq httpd-port spine-port)
|
||||
(httpd-start)
|
||||
(message "Spine listening on http://%s:%d"
|
||||
(or spine-host "localhost") spine-port))
|
||||
|
||||
(provide 'spine)
|
||||
;;; spine.el ends here
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Spine — Direction A (agenda)</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||
<style>
|
||||
.agenda { font-family: var(--pico-font-family-monospace); padding: 0; overflow: hidden; }
|
||||
.agenda > header, .agenda > footer { margin: 0; padding: .6rem .9rem; font-size: .85rem; }
|
||||
.agenda > header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.group { padding: .65rem .9rem .2rem; font-size: .7rem; letter-spacing: .04em; color: var(--pico-muted-color); }
|
||||
.row { display: flex; align-items: center; gap: .6rem; padding: .3rem .9rem; font-size: .85rem; }
|
||||
.row.selected { background: var(--pico-card-sectioning-background-color); border-left: 2px solid var(--pico-primary); }
|
||||
.row.selected .title { font-weight: 500; }
|
||||
.row.selected .glyph { color: var(--pico-color); }
|
||||
.glyph { width: 1rem; color: var(--pico-muted-color); flex-shrink: 0; }
|
||||
.title { flex: 1; min-width: 0; }
|
||||
.muted { color: var(--pico-muted-color); }
|
||||
.tags { color: var(--pico-primary); font-size: .8rem; }
|
||||
.trail { color: var(--pico-muted-color); font-size: .8rem; }
|
||||
.pill { padding: .05rem .45rem; border-radius: var(--pico-border-radius); font-size: .7rem; font-weight: 500; }
|
||||
.pill.want { background: #e6f1fb; color: #0c447c; }
|
||||
.pill.reading { background: #faeeda; color: #854f0b; }
|
||||
.pill.read { background: #e1f5ee; color: #085041; }
|
||||
.detail { padding: .5rem .9rem .7rem 2rem; background: var(--pico-card-sectioning-background-color);
|
||||
border-left: 2px solid var(--pico-primary); font-size: .8rem; }
|
||||
.logline { display: flex; gap: .5rem; margin-bottom: .3rem; }
|
||||
.logline:last-child { margin-bottom: 0; }
|
||||
.logdate { color: var(--pico-muted-color); white-space: nowrap; }
|
||||
.endpad { height: .4rem; }
|
||||
.minibuffer { display: flex; gap: .5rem; align-items: center; }
|
||||
.minibuffer .text { border-left: 2px solid var(--pico-primary); padding-left: .4rem; }
|
||||
.caret { opacity: .5; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pill.want { background: #14304a; color: #b5d4f4; }
|
||||
.pill.reading { background: #3a2c12; color: #fac775; }
|
||||
.pill.read { background: #103a30; color: #9fe1cb; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{!
|
||||
View model for this template:
|
||||
{
|
||||
app_title: string,
|
||||
total_books: number,
|
||||
reading_count: number,
|
||||
groups: [
|
||||
{
|
||||
label: string, e.g. "on deck"
|
||||
count: number,
|
||||
books: [
|
||||
{
|
||||
format_icon: string, Tabler class, e.g. "ti-headphones" (data layer maps FORMAT -> icon)
|
||||
status_class: string, "want" | "reading" | "read" (maps Org TODO state -> pill style)
|
||||
status_label: string, text shown in the pill
|
||||
title: string,
|
||||
author: string,
|
||||
tags_inline: string?, optional, e.g. ":scifi:literary:"
|
||||
rec_via: string?, optional, REC_BY -> rendered as "via {name}"
|
||||
rating_display: string?, optional, e.g. "★★★★☆"
|
||||
selected: boolean?, marks the in-focus row (expands detail)
|
||||
detail: { optional; render when a row is expanded
|
||||
meta: string, e.g. "started [2026-02-20] · hardcover · 62% · rating —"
|
||||
notes: [ { date: string, text: string } ] reading log
|
||||
}?
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
minibuffer: { command: string, text: string }? optional capture line
|
||||
}
|
||||
}}
|
||||
<main class="container">
|
||||
<article class="agenda" style="max-width: 720px; margin-inline: auto;">
|
||||
<header>
|
||||
<strong>{{app_title}}</strong>
|
||||
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
|
||||
</header>
|
||||
|
||||
{{#groups}}
|
||||
<div class="group">{{label}} · {{count}}</div>
|
||||
{{#books}}
|
||||
<div class="row{{#selected}} selected{{/selected}}">
|
||||
<i class="ti {{format_icon}} glyph" aria-hidden="true"></i>
|
||||
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
|
||||
{{#tags_inline}}<span class="tags">{{tags_inline}}</span>{{/tags_inline}}
|
||||
{{#rec_via}}<span class="trail">via {{rec_via}}</span>{{/rec_via}}
|
||||
{{#rating_display}}<span class="trail">{{rating_display}}</span>{{/rating_display}}
|
||||
</div>
|
||||
{{#detail}}
|
||||
<div class="detail">
|
||||
<div class="muted" style="margin-bottom: .5rem;">{{meta}}</div>
|
||||
{{#notes}}
|
||||
<div class="logline"><span class="logdate">{{date}}</span><span>{{text}}</span></div>
|
||||
{{/notes}}
|
||||
</div>
|
||||
{{/detail}}
|
||||
{{/books}}
|
||||
{{/groups}}
|
||||
<div class="endpad"></div>
|
||||
|
||||
{{#minibuffer}}
|
||||
<footer class="minibuffer">
|
||||
<span class="muted">M-x</span>
|
||||
<span>{{command}}</span>
|
||||
<span class="text">{{text}}<span class="caret">▏</span></span>
|
||||
</footer>
|
||||
{{/minibuffer}}
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
;;; spine-books-test.el — ERT tests for spine-books
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
;; Load spine.el without starting the server
|
||||
(setq spine-skip-server-start t)
|
||||
(setq spine-org-file (expand-file-name "sample-books.org"
|
||||
(file-name-directory
|
||||
(directory-file-name
|
||||
(file-name-directory load-file-name)))))
|
||||
(load-file (expand-file-name "../spine.el"
|
||||
(file-name-directory load-file-name)))
|
||||
|
||||
(ert-deftest spine-books-parses-sample ()
|
||||
"spine-books returns correct plists for sample-books.org."
|
||||
(let ((books (spine-books)))
|
||||
(should books)
|
||||
(should (= (length books) 5))))
|
||||
|
||||
(ert-deftest spine-books-first-book-has-all-fields ()
|
||||
"Use of Weapons entry has all expected fields."
|
||||
(let* ((books (spine-books))
|
||||
(uow (cl-find "8c1e-uow" books
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)))
|
||||
(should uow)
|
||||
(should (equal (plist-get uow :title) "Use of Weapons"))
|
||||
(should (equal (plist-get uow :status) "READING"))
|
||||
(should (equal (plist-get uow :author) "Iain M. Banks"))
|
||||
(should (equal (plist-get uow :isbn) "978-0316029193"))
|
||||
(should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg"))
|
||||
(should (equal (plist-get uow :format) "audiobook"))
|
||||
(should (equal (plist-get uow :rec_by) "Priya"))
|
||||
(should (equal (plist-get uow :rec_note) "If you liked Player of Games, this one will wreck you"))
|
||||
(should (equal (plist-get uow :tags) '("scifi" "literary")))
|
||||
(should (equal (plist-get uow :notes)
|
||||
'(("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.")
|
||||
("2026-03-09" "The chapter numbering. Oh."))))))
|
||||
|
||||
(ert-deftest spine-books-missing-properties-are-nil ()
|
||||
"Piranesi entry has nil for non-present properties."
|
||||
(let* ((books (spine-books))
|
||||
(pir (cl-find "5e8d-pir" books
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)))
|
||||
(should pir)
|
||||
(should (equal (plist-get pir :title) "Piranesi"))
|
||||
(should (equal (plist-get pir :status) "WANT"))
|
||||
(should (equal (plist-get pir :author) "Susanna Clarke"))
|
||||
(should-not (plist-get pir :isbn))
|
||||
(should-not (plist-get pir :cover))
|
||||
(should-not (plist-get pir :format))
|
||||
(should-not (plist-get pir :rec_by))
|
||||
(should-not (plist-get pir :rec_note))
|
||||
(should-not (plist-get pir :notes))))
|
||||
|
||||
(ert-deftest spine-books-read-book-has-rating ()
|
||||
"Ancillary Justice has rating 5."
|
||||
(let* ((books (spine-books))
|
||||
(aj (cl-find "3a1b-aj" books
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)))
|
||||
(should aj)
|
||||
(should (equal (plist-get aj :status) "READ"))
|
||||
(should (equal (plist-get aj :rating) "5"))
|
||||
(should (= (length (plist-get aj :notes)) 3))))
|
||||
|
||||
(ert-deftest spine-books-empty-file-returns-empty-list ()
|
||||
"Empty Org file returns empty list, not nil."
|
||||
(let* ((tmp-file (make-temp-file "spine-test-" nil ".org"))
|
||||
(spine-org-file tmp-file))
|
||||
(unwind-protect
|
||||
(let ((books (spine-books)))
|
||||
(should (listp books))
|
||||
(should (= (length books) 0)))
|
||||
(delete-file tmp-file))))
|
||||
|
||||
(ert-deftest spine-books-missing-file-returns-nil ()
|
||||
"Non-existent file returns nil."
|
||||
(let ((spine-org-file "/tmp/spine-nonexistent-12345.org"))
|
||||
(should-not (spine-books))))
|
||||
Reference in New Issue
Block a user