Compare commits
54 Commits
65a298d8be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f3769fcd8 | |||
| bbd9a455cd | |||
| cf511ac190 | |||
| ca03e87a46 | |||
| df1a7aebc9 | |||
| c4248dcc89 | |||
| 346fd7af5b | |||
| de057de0bf | |||
| e9ea0efa35 | |||
| 6a961b8460 | |||
| 609e168932 | |||
| 858fc6c13b | |||
| 6e2a796be9 | |||
| 653ee3442d | |||
| fa6be9ff03 | |||
| c505a3c239 | |||
| 6e821de7b1 | |||
| 719751cc84 | |||
| 95dc4b5db4 | |||
| 8c8b004500 | |||
| 17f928ffa8 | |||
| 41a8ba24d1 | |||
| 31833bf38e | |||
| 439f9d3eed | |||
| 4cf11fdd02 | |||
| 8e07d0efec | |||
| 126330c1dc | |||
| d00732a8ca | |||
| fbe49d729b | |||
| b6b0539b82 | |||
| 16bbccf67b | |||
| 9259c1139d | |||
| da8a20a7e1 | |||
| 9aacccb2ee | |||
| a8ecf952c6 | |||
| a12e57ca9b | |||
| 72f2f053a1 | |||
| 62638382ac | |||
| 7faf0cd8ac | |||
| 67e41ad110 | |||
| c7971eb297 | |||
| a6e2c99b62 | |||
| 112ea4df07 | |||
| bbb40efd1a | |||
| 1ad0631f5b | |||
| 24f24620d3 | |||
| 794ef3ffc9 | |||
| 94c310a0b3 | |||
| cc74ca5cfa | |||
| f25dffa183 | |||
| a93efd3617 | |||
| 362a83ca42 | |||
| d476a4eacc | |||
| de64d71936 |
@@ -0,0 +1,28 @@
|
||||
# Spine — Agent Workflow
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
This project does not use pull requests or feature branches. All work is committed
|
||||
directly to `main` and pushed.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Commit early, commit often** — small, focused commits with clear messages.
|
||||
2. **Push after each logical batch** — don't accumulate un-pushed work.
|
||||
3. **Test before push** — run the relevant test suite; don't push known failures.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commits:
|
||||
- `feat:` for new features
|
||||
- `fix:` for bug fixes
|
||||
- `test:` for test additions/changes
|
||||
- `docs:` for documentation
|
||||
- `refactor:` for code restructuring
|
||||
|
||||
## Verification
|
||||
|
||||
Run tests before pushing:
|
||||
```bash
|
||||
emacs --batch -l test/<test-file>.el --eval "(ert-run-tests-batch-and-exit)"
|
||||
```
|
||||
@@ -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,555 @@
|
||||
# Add Book Form — Implementation Plan
|
||||
|
||||
**Goal:** Add a form at `/add` to create new books in `spine.org`, plus the write function and "Add book" link on the index page.
|
||||
|
||||
**Architecture:** Mustache template for the form, `spine-add-book` function writes to the Org file using standard Org commands, `/add` servlet handles both GET (show form) and POST (process submission + redirect).
|
||||
|
||||
**Tech Stack:** Emacs Lisp, `org-element` / Org commands, Mustache templates.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `templates/add.mustache` | New — form template with Pico CSS + Tabler Icons shell |
|
||||
| `templates/index.mustache` | Modified — add "Add book" button in header |
|
||||
| `spine.el` | New functions: `spine-add-book`, `spine-add-form-model`. New handler: `/add`. |
|
||||
| `test/spine-add-book-test.el` | New — ERT tests for `spine-add-book` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create the add-book form template
|
||||
|
||||
**Files:**
|
||||
- Create: `templates/add.mustache`
|
||||
|
||||
- [ ] **Step 1: Write `templates/add.mustache`**
|
||||
|
||||
```html
|
||||
<!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 — Add Book</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<article style="max-width: 560px; margin-inline: auto;">
|
||||
<header>
|
||||
<strong>{{app_title}} · add book</strong>
|
||||
</header>
|
||||
|
||||
<form method="post" action="/add">
|
||||
<label>
|
||||
Date added
|
||||
<input type="date" name="date_added" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Title *
|
||||
<input type="text" name="title" required autofocus />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Author
|
||||
<input type="text" name="author" list="authors-list" />
|
||||
<datalist id="authors-list">
|
||||
{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Category
|
||||
<input type="text" name="category" list="categories-list" />
|
||||
<datalist id="categories-list">
|
||||
{{#existing_categories}}<option value="{{.}}">{{/existing_categories}}
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Format
|
||||
<select name="format">
|
||||
<option value="">—</option>
|
||||
<option value="hardcover">Hardcover</option>
|
||||
<option value="ebook">eBook</option>
|
||||
<option value="audiobook">Audiobook</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
ISBN
|
||||
<input type="text" name="isbn" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Cover (path)
|
||||
<input type="text" name="cover" placeholder="covers/filename.jpg" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Recommended by
|
||||
<input type="text" name="rec_by" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Why it's on your radar
|
||||
<input type="text" name="rec_note" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Initial note
|
||||
<textarea name="initial_note" rows="3" placeholder="First impressions…"></textarea>
|
||||
</label>
|
||||
|
||||
<button type="submit">Add book</button>
|
||||
</form>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify template file exists**
|
||||
|
||||
```bash
|
||||
cat templates/add.mustache | head -3
|
||||
```
|
||||
|
||||
Expected: `<!DOCTYPE html>`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/add.mustache
|
||||
git commit -m "feat: add add-book form template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add "Add book" link to index page
|
||||
|
||||
**Files:**
|
||||
- Modify: `templates/index.mustache`
|
||||
|
||||
- [ ] **Step 1: Add a link in the index header**
|
||||
|
||||
Find the `<header>` section in `templates/index.mustache` (line 79-82):
|
||||
|
||||
```html
|
||||
<header>
|
||||
<strong>{{app_title}}</strong>
|
||||
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
|
||||
</header>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<header>
|
||||
<nav>
|
||||
<ul><li><strong>{{app_title}}</strong></li></ul>
|
||||
<ul>
|
||||
<li><span class="muted">{{total_books}} books · {{reading_count}} reading</span></li>
|
||||
<li><a role="button" href="/add" class="secondary">Add book</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the 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 \"Has Add book: %s\" (if (string-match \"Add book\" (spine-render \"index.mustache\" (spine-index-model (spine-books) nil))) \"yes\" \"NO\")))"
|
||||
```
|
||||
(defun spine-add-book (&rest args)
|
||||
"Add a new WANT book to `spine-org-file'.
|
||||
Keyword arguments: :title :author :category :format :isbn :cover
|
||||
:rec_by :rec_note :date_added :initial_note
|
||||
Signals an error on failure."
|
||||
(let ((title (plist-get args :title))
|
||||
(author (plist-get args :author))
|
||||
(category (plist-get args :category))
|
||||
(format (plist-get args :format))
|
||||
(isbn (plist-get args :isbn))
|
||||
(cover (plist-get args :cover))
|
||||
(rec-by (plist-get args :rec_by))
|
||||
(rec-note (plist-get args :rec_note))
|
||||
(date-added (plist-get args :date_added))
|
||||
(initial-note (plist-get args :initial_note)))
|
||||
(unless title
|
||||
(error "spine-add-book: title is required"))
|
||||
(let ((org-file (expand-file-name spine-org-file)))
|
||||
;; Create file if it doesn't exist
|
||||
(unless (file-exists-p org-file)
|
||||
(with-temp-file org-file
|
||||
(insert "#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)\n\n")))
|
||||
(with-current-buffer (find-file-noselect org-file)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(goto-char (point-max))
|
||||
;; Insert new headline
|
||||
(org-insert-heading nil t)
|
||||
(insert title)
|
||||
;; Set TODO state
|
||||
(org-todo "WANT")
|
||||
;; Set properties (only non-empty)
|
||||
(when (and author (> (length author) 0))
|
||||
(org-set-property "AUTHOR" author))
|
||||
(when (and format (> (length format) 0))
|
||||
(org-set-property "FORMAT" format))
|
||||
(when (and isbn (> (length isbn) 0))
|
||||
(org-set-property "ISBN" isbn))
|
||||
(when (and cover (> (length cover) 0))
|
||||
(org-set-property "COVER" cover))
|
||||
(when (and rec-by (> (length rec-by) 0))
|
||||
(org-set-property "REC_BY" rec-by))
|
||||
(when (and rec-note (> (length rec-note) 0))
|
||||
(org-set-property "REC_NOTE" rec-note))
|
||||
(when (and date-added (> (length date-added) 0))
|
||||
(org-set-property "ADDED" (format "[%s]" date-added)))
|
||||
;; Set ID
|
||||
(org-set-property "ID"
|
||||
(format "%04x-%s"
|
||||
(random 65535)
|
||||
(substring (sha1 (concat title (random) "")) 0 4)))
|
||||
;; Set tags
|
||||
(when (and category (> (length category) 0))
|
||||
(org-set-tags (list category)))
|
||||
;; Add initial note
|
||||
(when (and initial-note (> (length initial-note) 0))
|
||||
(let ((date (if (and date-added (> (length date-added) 0))
|
||||
date-added
|
||||
(format-time-string "%Y-%m-%d"))))
|
||||
(goto-char (org-entry-end-position))
|
||||
(insert (format "\n- [%s] %s\n" date initial-note)))))
|
||||
(save-buffer)
|
||||
t)
|
||||
(kill-buffer))))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify batch load**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
|
||||
```
|
||||
|
||||
Expected: exits cleanly, no errors.
|
||||
```bash
|
||||
rm -f /tmp/spine-test-add.org
|
||||
emacs --quick --batch \
|
||||
--eval "(setq spine-skip-server-start t spine-org-file \"/tmp/spine-test-add.org\")" \
|
||||
--load spine.el \
|
||||
--eval "(progn (spine-add-book :title \"Test Book\" :author \"Test Author\" :format \"ebook\" :category \"scifi\" :date_added \"2026-06-21\" :initial_note \"This is a test\") (let ((books (spine-books))) (message \"Book count: %d\" (length books)) (message \"Title: %s\" (plist-get (car books) :title)) (message \"ID: %s\" (plist-get (car books) :id)) (message \"Notes: %S\" (plist-get (car books) :notes))))" 2>&1
|
||||
```
|
||||
|
||||
Expected: `Book count: 1`, `Title: Test Book`, ID present, one note.
|
||||
|
||||
- [ ] **Step 4: Clean up and commit**
|
||||
|
||||
```bash
|
||||
rm -f /tmp/spine-test-add.org
|
||||
git add spine.el
|
||||
git commit -m "feat: add spine-add-book write function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire up /add handler and spine-add-form-model
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el`
|
||||
|
||||
- [ ] **Step 1: Add `spine-add-form-model` function**
|
||||
|
||||
Insert before `spine-index-model`:
|
||||
|
||||
```elisp
|
||||
(defun spine-add-form-model (books)
|
||||
"Build the view model ht for templates/add.mustache from BOOKS."
|
||||
(let ((authors nil)
|
||||
(categories nil))
|
||||
(dolist (book books)
|
||||
(let ((author (plist-get book :author)))
|
||||
(when (and author (> (length author) 0)
|
||||
(not (member author authors)))
|
||||
(push author authors)))
|
||||
(dolist (tag (or (plist-get book :tags) nil))
|
||||
(unless (member tag categories)
|
||||
(push tag categories))))
|
||||
(ht ("app_title" "spine")
|
||||
("existing_authors" (sort authors #'string<))
|
||||
("existing_categories" (sort categories #'string<)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `/add` servlet**
|
||||
|
||||
Replace or insert after the existing `index` servlet handler:
|
||||
|
||||
```elisp
|
||||
(defservlet add text/html (path query request)
|
||||
(let ((method (cadr (car request))))
|
||||
(if (equal method "POST")
|
||||
(progn
|
||||
(spine-add-book
|
||||
:title (cdr (assoc "title" query))
|
||||
:author (cdr (assoc "author" query))
|
||||
:category (cdr (assoc "category" query))
|
||||
:format (cdr (assoc "format" query))
|
||||
:isbn (cdr (assoc "isbn" query))
|
||||
:cover (cdr (assoc "cover" query))
|
||||
:rec_by (cdr (assoc "rec_by" query))
|
||||
:rec_note (cdr (assoc "rec_note" query))
|
||||
:date_added (cdr (assoc "date_added" query))
|
||||
:initial_note (cdr (assoc "initial_note" query)))
|
||||
(httpd-redirect t "/index" 303))
|
||||
(let* ((books (spine-books))
|
||||
(model (if books
|
||||
(spine-add-form-model books)
|
||||
(ht ("app_title" "spine")))))
|
||||
(insert (spine-render "add.mustache" model))))))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify batch load**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
|
||||
```
|
||||
|
||||
Expected: exits cleanly.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: wire up /add handler with form model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Test spine-add-book with ERT
|
||||
|
||||
**Files:**
|
||||
- Create: `test/spine-add-book-test.el`
|
||||
|
||||
- [ ] **Step 1: Write `test/spine-add-book-test.el`**
|
||||
|
||||
```elisp
|
||||
;;; spine-add-book-test.el — ERT tests for spine-add-book
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
(setq spine-skip-server-start t)
|
||||
(setq spine-org-file (expand-file-name "test-add-temp.org"
|
||||
(file-name-directory load-file-name)))
|
||||
(load-file (expand-file-name "../spine.el"
|
||||
(file-name-directory load-file-name)))
|
||||
|
||||
(defun spine-add-book-test--cleanup ()
|
||||
"Remove the test Org file if it exists."
|
||||
(ignore-errors (delete-file spine-org-file)))
|
||||
|
||||
(ert-deftest spine-add-book-creates-headline ()
|
||||
"spine-add-book creates a new WANT headline."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-add-book :title "Test Book" :author "Author Name")
|
||||
(let ((books (spine-books)))
|
||||
(should books)
|
||||
(should (= (length books) 1))
|
||||
(let ((book (car books)))
|
||||
(should (equal (plist-get book :title) "Test Book"))
|
||||
(should (equal (plist-get book :status) "WANT"))
|
||||
(should (equal (plist-get book :author) "Author Name"))
|
||||
(should (plist-get book :id)))))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-sets-all-fields ()
|
||||
"spine-add-book sets all provided fields."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-add-book
|
||||
:title "Full Test"
|
||||
:author "Jane Doe"
|
||||
:format "hardcover"
|
||||
:isbn "978-1234567890"
|
||||
:cover "covers/test.jpg"
|
||||
:rec_by "Friend"
|
||||
:rec_note "You'll love this"
|
||||
:date_added "2026-06-21"
|
||||
:category "scifi"
|
||||
:initial_note "Looking forward to this")
|
||||
(let* ((books (spine-books))
|
||||
(book (car books)))
|
||||
(should (equal (plist-get book :title) "Full Test"))
|
||||
(should (equal (plist-get book :author) "Jane Doe"))
|
||||
(should (equal (plist-get book :format) "hardcover"))
|
||||
(should (equal (plist-get book :isbn) "978-1234567890"))
|
||||
(should (equal (plist-get book :cover) "covers/test.jpg"))
|
||||
(should (equal (plist-get book :rec_by) "Friend"))
|
||||
(should (equal (plist-get book :rec_note) "You'll love this"))
|
||||
(should (equal (plist-get book :added) "[2026-06-21]"))
|
||||
(should (equal (plist-get book :tags) '("scifi")))
|
||||
(should (equal (plist-get book :notes)
|
||||
'(("2026-06-21" "Looking forward to this"))))))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-omits-empty-fields ()
|
||||
"spine-add-book does not set empty/omitted fields."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-add-book :title "Minimal Book")
|
||||
(let* ((books (spine-books))
|
||||
(book (car books)))
|
||||
(should (equal (plist-get book :title) "Minimal Book"))
|
||||
(should-not (plist-get book :author))
|
||||
(should-not (plist-get book :format))
|
||||
(should-not (plist-get book :isbn))
|
||||
(should-not (plist-get book :tags))))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-requires-title ()
|
||||
"spine-add-book signals error when title is missing."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(should-error (spine-add-book :author "No Title"))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-generates-unique-ids ()
|
||||
"spine-add-book generates different IDs for each book."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-add-book :title "Book One")
|
||||
(spine-add-book :title "Book Two")
|
||||
(let* ((books (spine-books))
|
||||
(id1 (plist-get (nth 0 books) :id))
|
||||
(id2 (plist-get (nth 1 books) :id)))
|
||||
(should (not (equal id1 id2)))))
|
||||
(spine-add-book-test--cleanup)))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests**
|
||||
|
||||
```bash
|
||||
emacs --quick --batch \
|
||||
--directory "$(pwd)" \
|
||||
--load test/spine-add-book-test.el \
|
||||
-f ert-run-tests-batch-and-exit
|
||||
```
|
||||
|
||||
Expected: all 5 tests pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add test/spine-add-book-test.el
|
||||
git commit -m "test: add ERT tests for spine-add-book"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Smoke test end-to-end
|
||||
|
||||
**Files:**
|
||||
- No changes — verification only.
|
||||
|
||||
- [ ] **Step 1: Start server with temp Org file**
|
||||
|
||||
```bash
|
||||
rm -f /tmp/spine-add-test.org
|
||||
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 \"/tmp/spine-add-test.org\")" \
|
||||
--load spine.el &
|
||||
sleep 5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify form page loads**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/add | grep -c "Add book"
|
||||
```
|
||||
|
||||
Expected: `4` (title, button, etc.)
|
||||
|
||||
- [ ] **Step 3: Verify "Add book" link on index**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep -c "Add book"
|
||||
```
|
||||
|
||||
Expected: `1`
|
||||
|
||||
- [ ] **Step 4: Submit a new book via form**
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8080/add \
|
||||
-d "title=Neuromancer" \
|
||||
-d "author=William Gibson" \
|
||||
-d "format=ebook" \
|
||||
-d "category=scifi" \
|
||||
-d "date_added=2026-06-21" \
|
||||
-d "initial_note=The sky was the color of television" \
|
||||
-o /dev/null -w "HTTP %{http_code}\n" \
|
||||
-L
|
||||
```
|
||||
|
||||
Expected: HTTP 200 (after redirect).
|
||||
|
||||
- [ ] **Step 5: Verify new book appears on index**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/index | grep "Neuromancer"
|
||||
```
|
||||
|
||||
Expected: `Neuromancer` visible in output.
|
||||
|
||||
- [ ] **Step 6: Stop server**
|
||||
|
||||
```bash
|
||||
kill %1 2>/dev/null
|
||||
rm -f /tmp/spine-add-test.org
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run all tests and final verification
|
||||
|
||||
**Files:**
|
||||
- No changes — verification only.
|
||||
|
||||
- [ ] **Step 1: Run all ERT test suites**
|
||||
|
||||
```bash
|
||||
# Run spine-books tests
|
||||
emacs --quick --batch --directory "$(pwd)" \
|
||||
--load test/spine-books-test.el \
|
||||
-f ert-run-tests-batch-and-exit
|
||||
|
||||
# Run spine-add-book tests
|
||||
emacs --quick --batch --directory "$(pwd)" \
|
||||
--load test/spine-add-book-test.el \
|
||||
-f ert-run-tests-batch-and-exit
|
||||
```
|
||||
|
||||
Expected: 6/6 + 5/5 = all pass.
|
||||
|
||||
- [ ] **Step 2: Git status**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git log --oneline -4
|
||||
```
|
||||
@@ -0,0 +1,478 @@
|
||||
# Concise Default View Implementation Plan
|
||||
|
||||
**Goal:** Default index shows only READING + 5 most recent WANT books, with filter links to expanded views.
|
||||
|
||||
**Architecture:** `spine-index-model` gains an optional `filter` argument (nil = concise, "all"/"read"/"want" = expanded). The handler reads `filter` from the request query and passes it through. The template gains conditional nav links and summary-group rows. Existing group rendering unchanged.
|
||||
|
||||
**Tech Stack:** Emacs Lisp (model/handler), Mustache (template)
|
||||
|
||||
---
|
||||
|
||||
## File overview
|
||||
|
||||
**Modify:**
|
||||
- `spine.el` — `spine-index-model` (add filter param, concise logic), `defservlet index` (pass query filter)
|
||||
- `templates/index.mustache` — filter nav bar, summary-group rows
|
||||
- `test/spine-index-model-test.el` — new test file for filtered model
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Write failing tests for filtered `spine-index-model`
|
||||
|
||||
**Files:**
|
||||
- Create: `test/spine-index-model-test.el`
|
||||
|
||||
- [ ] **Step 1: Create test file with setup**
|
||||
|
||||
```elisp
|
||||
;;; spine-index-model-test.el — ERT tests for spine-index-model with filter
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
(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)))
|
||||
|
||||
(defconst spine-index-model-test--books (spine-books)
|
||||
"Books fixture loaded once from sample-books.org.")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write test — concise view excludes READ and ABANDONED groups, has summary**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-index-model-concise-hides-read-and-abandoned ()
|
||||
"Concise view (filter=nil) omits READ/ABANDONED groups, adds summary-groups."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(groups (ht-get model "groups"))
|
||||
(summaries (ht-get model "summary_groups"))
|
||||
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||
(should-not (member "read" group-labels))
|
||||
(should-not (member "abandoned" group-labels))
|
||||
(should summaries)
|
||||
(should (= (length summaries) 1)) ; only READ, no ABANDONED in sample data
|
||||
(should (string= (ht-get (car summaries) "label") "read"))
|
||||
(should (= (ht-get (car summaries) "count") 1)) ; Ancillary Justice
|
||||
(should (string= (ht-get (car summaries) "href") "/index?filter=read"))))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write test — concise view includes READING group**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-index-model-concise-includes-reading ()
|
||||
"Concise view includes READING group with all reading books."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(groups (ht-get model "groups"))
|
||||
(reading-group (cl-find "reading" groups
|
||||
:key (lambda (g) (ht-get g "label"))
|
||||
:test #'string=)))
|
||||
(should reading-group)
|
||||
(should (= (ht-get reading-group "count") 1)) ; Use of Weapons
|
||||
(should (string= (ht-get (car (ht-get reading-group "books")) "title")
|
||||
"Use of Weapons"))))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write test — concise view limits WANT to 5 most recent**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-index-model-concise-want-limited-to-five ()
|
||||
"Concise view limits WANT group to 5 books, sorted by :ADDED: desc."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(groups (ht-get model "groups"))
|
||||
(want-group (cl-find "on deck" groups
|
||||
:key (lambda (g) (ht-get g "label"))
|
||||
:test #'string=)))
|
||||
(should want-group)
|
||||
(let ((books (ht-get want-group "books")))
|
||||
(should (<= (length books) 5))
|
||||
;; sample-books.org has 3 WANT books — all should appear, sorted newest first
|
||||
(should (= (length books) 3))
|
||||
;; Piranesi added 2026-06-01, Dune 2026-05-10, Left Hand 2026-04-01
|
||||
(should (string= (ht-get (nth 0 books) "title") "Piranesi"))
|
||||
(should (string= (ht-get (nth 1 books) "title") "Dune"))
|
||||
(should (string= (ht-get (nth 2 books) "title") "The Left Hand of Darkness")))))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write test — filter=all returns full model (no summary, all groups)**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-index-model-filter-all-shows-all-groups ()
|
||||
"filter=all returns all groups, no summary-groups."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "all"))
|
||||
(groups (ht-get model "groups"))
|
||||
(summaries (ht-get model "summary_groups"))
|
||||
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||
(should-not summaries)
|
||||
(should (member "on deck" group-labels))
|
||||
(should (member "reading" group-labels))
|
||||
(should (member "read" group-labels))
|
||||
(should (= (length groups) 3)))) ; WANT, READING, READ (no ABANDONED in sample)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Write test — filter=read returns only READ group**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-index-model-filter-read-returns-only-read ()
|
||||
"filter=read returns only the READ group."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "read"))
|
||||
(groups (ht-get model "groups")))
|
||||
(should (= (length groups) 1))
|
||||
(should (string= (ht-get (car groups) "label") "read"))
|
||||
(should (= (ht-get (car groups) "count") 1))))
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Write test — filter=want returns only WANT group**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-index-model-filter-want-returns-only-want ()
|
||||
"filter=want returns only the WANT group."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "want"))
|
||||
(groups (ht-get model "groups")))
|
||||
(should (= (length groups) 1))
|
||||
(should (string= (ht-get (car groups) "label") "on deck"))
|
||||
(should (= (ht-get (car groups) "count") 3))))
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Write test — invalid filter falls back to concise**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-index-model-invalid-filter-falls-back-to-concise ()
|
||||
"Unrecognised filter value behaves like nil (concise view)."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "bogus"))
|
||||
(groups (ht-get model "groups"))
|
||||
(summaries (ht-get model "summary_groups"))
|
||||
(group-labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||
(should summaries) ; has summary
|
||||
(should-not (member "read" group-labels)) ; no read group
|
||||
(should (member "reading" group-labels)) ; has reading
|
||||
(should (member "on deck" group-labels)))) ; has want
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Write test — model includes current_filter in context**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-index-model-includes-current-filter ()
|
||||
"Model includes :current_filter key matching the filter argument."
|
||||
(should (equal (ht-get (spine-index-model spine-index-model-test--books) "current_filter") ""))
|
||||
(should (equal (ht-get (spine-index-model spine-index-model-test--books "all") "current_filter") "all"))
|
||||
(should (equal (ht-get (spine-index-model spine-index-model-test--books "read") "current_filter") "read"))
|
||||
(should (equal (ht-get (spine-index-model spine-index-model-test--books "want") "current_filter") "want")))
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-index-model-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: all tests fail because `spine-index-model` doesn't accept a second argument yet.
|
||||
|
||||
- [ ] **Step 11: Commit test file**
|
||||
|
||||
```bash
|
||||
git add test/spine-index-model-test.el
|
||||
git commit -m "test: add failing tests for filtered spine-index-model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement filtered `spine-index-model`
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el:77-155`
|
||||
|
||||
- [ ] **Step 1: Update signature and add filter handling**
|
||||
|
||||
Swap the function at line 77:
|
||||
|
||||
```elisp
|
||||
(defun spine-index-model (books &optional filter selected-id)
|
||||
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||
FILTER controls which books are shown:
|
||||
nil — concise: READING + 5 most recent WANT, others as summary links
|
||||
\"all\" — full listing, no truncation
|
||||
\"read\" — only READ books
|
||||
\"want\" — only WANT 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"))
|
||||
(valid-filters '("all" "read" "want"))
|
||||
(effective-filter (if (member filter valid-filters) filter nil))
|
||||
(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)
|
||||
(summary-groups nil))
|
||||
(dolist (status order)
|
||||
(let ((group-books (nreverse (gethash status grouped))))
|
||||
(when group-books
|
||||
(if (and (null effective-filter)
|
||||
(member status '("READ" "ABANDONED")))
|
||||
;; Concise: replace read/abandoned groups with summary links
|
||||
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||
("count" (length group-books))
|
||||
("href" (format "/index?filter=%s"
|
||||
(downcase status))))
|
||||
summary-groups)
|
||||
;; Show the group (possibly filtered to one status)
|
||||
(when (or (null effective-filter)
|
||||
(string= (downcase status) effective-filter)
|
||||
(string= effective-filter "all"))
|
||||
;; Sort WANT by ADDED descending when in concise view
|
||||
(when (and (null effective-filter)
|
||||
(string= status "WANT"))
|
||||
(setq group-books
|
||||
(cl-subseq (sort group-books
|
||||
(lambda (a b)
|
||||
(let ((a-date (plist-get a :added))
|
||||
(b-date (plist-get b :added)))
|
||||
(cond
|
||||
((null a-date) nil)
|
||||
((null b-date) t)
|
||||
(t (string> a-date b-date))))))
|
||||
0 (min (length group-books) 5))))
|
||||
(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)
|
||||
("current_filter" (or effective-filter ""))
|
||||
("summary_groups" (nreverse summary-groups))
|
||||
("groups" (nreverse groups))))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-index-model-test.el \
|
||||
-l test/spine-books-test.el \
|
||||
-l test/spine-add-book-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: all tests pass (new filter tests + existing books + add-book tests).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: add filter parameter to spine-index-model for concise default view"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update `defservlet index` handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el:308-313`
|
||||
|
||||
- [ ] **Step 1: Pass query filter to model**
|
||||
|
||||
Swap lines 308-313:
|
||||
|
||||
```elisp
|
||||
(defservlet index text/html (path query request)
|
||||
(let* ((books (spine-books))
|
||||
(filter (cadr (assoc "filter" query))))
|
||||
(if books
|
||||
(insert (spine-render "index.mustache"
|
||||
(spine-index-model books filter (cadr (assoc "id" query)))))
|
||||
(insert (spine-render-empty-state)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run existing tests to confirm handler change doesn't break anything**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-index-model-test.el \
|
||||
-l test/spine-books-test.el \
|
||||
-l test/spine-add-book-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: pass filter query param from index handler to model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update `templates/index.mustache` with filter links and summary groups
|
||||
|
||||
**Files:**
|
||||
- Modify: `templates/index.mustache`
|
||||
|
||||
- [ ] **Step 1: Add filter nav bar after header, before groups**
|
||||
|
||||
Insert after the closing `</header>` (line 87) and before the `{{#groups}}` section (line 89):
|
||||
|
||||
```mustache
|
||||
{{#current_filter}}
|
||||
<nav class="filter-bar" style="padding: .4rem .9rem; font-size: .8rem;">
|
||||
<a href="/index">Concise view</a>
|
||||
</nav>
|
||||
{{/current_filter}}
|
||||
{{^current_filter}}
|
||||
<nav class="filter-bar" style="padding: .4rem .9rem; font-size: .8rem;">
|
||||
<span>
|
||||
<a href="/index?filter=all">All books ({{total_books}})</a>
|
||||
·
|
||||
<a href="/index?filter=want">Want list</a>
|
||||
·
|
||||
<a href="/index?filter=read">Read ({{reading_count}})</a>
|
||||
</span>
|
||||
</nav>
|
||||
{{/current_filter}}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add summary group rows after groups section**
|
||||
|
||||
Insert after the closing `{{/groups}}` (line 109) and before `<div class="endpad">` (line 110):
|
||||
|
||||
```mustache
|
||||
{{#summary_groups}}
|
||||
<div class="row"><a href="{{href}}">{{label}} · {{count}}</a></div>
|
||||
{{/summary_groups}}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/index.mustache
|
||||
git commit -m "feat: add filter nav bar and summary group links to index template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manual integration test
|
||||
|
||||
- [ ] **Step 1: Start the server and verify concise default view**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
SPINE_ORG_FILE=$(pwd)/sample-books.org emacs --batch -l spine.el \
|
||||
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
|
||||
--eval "(httpd-start)" \
|
||||
--eval "(message \"Server running on port 8080\")" &
|
||||
sleep 2
|
||||
curl -s http://localhost:8080/index | head -50
|
||||
```
|
||||
|
||||
Verify the output:
|
||||
- Shows READING group (Use of Weapons)
|
||||
- Shows WANT group (Piranesi, Dune, Left Hand)
|
||||
- Does NOT show READ group (Ancillary Justice) as a full group
|
||||
- Shows a summary link for "read · 1"
|
||||
- Shows "All books (5)" filter nav link
|
||||
|
||||
- [ ] **Step 2: Test filter=all**
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/index?filter=all"
|
||||
```
|
||||
|
||||
Verify: all books shown in their groups, no summary sections, "Concise view" link present.
|
||||
|
||||
- [ ] **Step 3: Test filter=read**
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/index?filter=read"
|
||||
```
|
||||
|
||||
Verify: only Ancillary Justice shown, "Concise view" link present.
|
||||
|
||||
- [ ] **Step 4: Kill the server**
|
||||
|
||||
```bash
|
||||
kill %1 2>/dev/null; wait 2>/dev/null
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run all tests one final time**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-index-model-test.el \
|
||||
-l test/spine-books-test.el \
|
||||
-l test/spine-add-book-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 6: Final commit if any fixes needed**
|
||||
|
||||
```bash
|
||||
git commit -am "fix: address integration test findings"
|
||||
```
|
||||
@@ -0,0 +1,600 @@
|
||||
# Edit Book Actions Implementation Plan
|
||||
|
||||
**Goal:** Allow users to change a book's status, add reading notes, and set ratings
|
||||
from the agenda view via context-sensitive minibuffer actions.
|
||||
|
||||
**Architecture:** New Org write functions (`spine-set-status`, `spine-add-note`,
|
||||
`spine-set-rating`) follow the same `find-file-noselect` + `save-buffer` + `kill-buffer`
|
||||
pattern as `spine-add-book`. A single `/edit` handler dispatches based on `action`
|
||||
query param. The index model gains a `minibuffer` field with action links computed
|
||||
from the selected book's status.
|
||||
|
||||
**Tech Stack:** Emacs Lisp (backend + model), Mustache (template)
|
||||
|
||||
---
|
||||
|
||||
## File overview
|
||||
|
||||
**Modify:**
|
||||
- `spine.el` — 3 new write functions + `httpd/edit` handler + minibuffer in model
|
||||
- `templates/index.mustache` — minibuffer renders action links
|
||||
|
||||
**Create:**
|
||||
- `test/spine-edit-test.el` — tests for write functions + model minibuffer
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Write failing tests for Org write functions
|
||||
|
||||
**Files:**
|
||||
- Create: `test/spine-edit-test.el`
|
||||
|
||||
- [ ] **Step 1: Create test file with setup**
|
||||
|
||||
```elisp
|
||||
;;; spine-edit-test.el — ERT tests for spine-edit functions
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
(setq spine-skip-server-start t)
|
||||
(setq spine-org-file (expand-file-name "test-edit-temp.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)))
|
||||
|
||||
(defun spine-edit-test--setup ()
|
||||
"Create a test Org file with one WANT book."
|
||||
(spine-add-book :title "Edit Test Book" :author "Test Author"))
|
||||
|
||||
(defun spine-edit-test--cleanup ()
|
||||
"Remove the test Org file."
|
||||
(ignore-errors (delete-file spine-org-file)))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test spine-set-status transitions WANT to READING**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-set-status-want-to-reading ()
|
||||
"spine-set-status changes TODO from WANT to READING."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id)))
|
||||
(spine-set-status id "READING")
|
||||
(let ((updated-books (spine-books))
|
||||
(updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should updated)
|
||||
(should (equal (plist-get updated :status) "READING")))))
|
||||
(spine-edit-test--cleanup)))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test spine-set-status transitions READING to READ**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-set-status-reading-to-read ()
|
||||
"spine-set-status changes TODO from READING to READ."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id)))
|
||||
(spine-set-status id "READING")
|
||||
(spine-set-status id "READ")
|
||||
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should updated)
|
||||
(should (equal (plist-get updated :status) "READ")))))
|
||||
(spine-edit-test--cleanup)))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test spine-add-note appends a note**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-add-note-appends ()
|
||||
"spine-add-note appends a note with date and text."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id)))
|
||||
(spine-add-note id "Great book!" "2026-06-21")
|
||||
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should updated)
|
||||
(let ((notes (plist-get updated :notes)))
|
||||
(should notes)
|
||||
(should (equal (caar notes) "2026-06-21"))
|
||||
(should (equal (cadar notes) "Great book!"))))))
|
||||
(spine-edit-test--cleanup)))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Test spine-add-note defaults to today's date**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-add-note-default-date ()
|
||||
"spine-add-note uses today's date when none provided."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id))
|
||||
(today (format-time-string "%Y-%m-%d")))
|
||||
(spine-add-note id "Note without date")
|
||||
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should updated)
|
||||
(let ((notes (plist-get updated :notes)))
|
||||
(should notes)
|
||||
(should (equal (caar notes) today))
|
||||
(should (equal (cadar notes) "Note without date"))))))
|
||||
(spine-edit-test--cleanup)))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Test spine-set-rating**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-set-rating-sets-rating ()
|
||||
"spine-set-rating sets the RATING property."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id)))
|
||||
(spine-set-rating id "4")
|
||||
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should updated)
|
||||
(should (equal (plist-get updated :rating) "4")))))
|
||||
(spine-edit-test--cleanup)))
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Test spine-set-status with missing ID signals error**
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-set-status-missing-id ()
|
||||
"spine-set-status signals error for unknown ID."
|
||||
(should-error (spine-set-status "nonexistent-id" "READING")))
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-edit-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: all tests fail because `spine-set-status` etc. are not defined yet.
|
||||
|
||||
- [ ] **Step 9: Commit test file**
|
||||
|
||||
```bash
|
||||
git add test/spine-edit-test.el
|
||||
git commit -m "test: add failing tests for spine-set-status, spine-add-note, spine-set-rating"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement Org write functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el` (insert after `spine-add-book`, before `spine--extract-notes`)
|
||||
|
||||
- [ ] **Step 1: Add spine-set-status**
|
||||
|
||||
Insert after line 251 (end of `spine-add-book`):
|
||||
|
||||
```elisp
|
||||
(defun spine-set-status (id status)
|
||||
"Set the TODO state of book with ID to STATUS.
|
||||
STATUS should be one of: WANT, READING, READ, ABANDONED.
|
||||
Signals an error if the book is not found."
|
||||
(let ((org-file (expand-file-name spine-org-file)))
|
||||
(unless (file-exists-p org-file)
|
||||
(error "spine-set-status: file not found: %s" org-file))
|
||||
(with-current-buffer (find-file-noselect org-file)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(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)))
|
||||
(when (equal id (spine--prop pos "ID"))
|
||||
(goto-char pos)
|
||||
(org-todo status)))))
|
||||
nil 'headline)
|
||||
(unless (eq (point) (point-min))
|
||||
(save-buffer)
|
||||
t)
|
||||
(when (eq (point) (point-min))
|
||||
(error "spine-set-status: no book found with ID %s" id)))
|
||||
(kill-buffer)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify spine-set-status passes**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-edit-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: spine-set-status tests pass, spine-add-note and spine-set-rating still fail.
|
||||
|
||||
- [ ] **Step 3: Add spine-add-note**
|
||||
|
||||
Insert after `spine-set-status`:
|
||||
|
||||
```elisp
|
||||
(defun spine-add-note (id text &optional date)
|
||||
"Append a reading note to the book with ID.
|
||||
TEXT is the note content. DATE is an optional YYYY-MM-DD string
|
||||
\(defaults to today). Signals an error if the book is not found."
|
||||
(let ((org-file (expand-file-name spine-org-file))
|
||||
(date-str (or date (format-time-string "%Y-%m-%d"))))
|
||||
(unless (file-exists-p org-file)
|
||||
(error "spine-add-note: file not found: %s" org-file))
|
||||
(with-current-buffer (find-file-noselect org-file)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(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)))
|
||||
(when (equal id (spine--prop pos "ID"))
|
||||
(goto-char (org-entry-end-position))
|
||||
(insert (format "\n- [%s] %s\n" date-str text))))))
|
||||
nil 'headline)
|
||||
(unless (eq (point) (point-min))
|
||||
(save-buffer)
|
||||
t)
|
||||
(when (eq (point) (point-min))
|
||||
(error "spine-add-note: no book found with ID %s" id)))
|
||||
(kill-buffer)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify spine-add-note passes**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-edit-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: spine-set-status and spine-add-note tests pass, spine-set-rating still fails.
|
||||
|
||||
- [ ] **Step 5: Add spine-set-rating**
|
||||
|
||||
Insert after `spine-add-note`:
|
||||
|
||||
```elisp
|
||||
(defun spine-set-rating (id rating)
|
||||
"Set the RATING property of book with ID to RATING (1-5 string).
|
||||
Signals an error if the book is not found."
|
||||
(let ((org-file (expand-file-name spine-org-file)))
|
||||
(unless (file-exists-p org-file)
|
||||
(error "spine-set-rating: file not found: %s" org-file))
|
||||
(with-current-buffer (find-file-noselect org-file)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(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)))
|
||||
(when (equal id (spine--prop pos "ID"))
|
||||
(goto-char pos)
|
||||
(org-set-property "RATING" rating)))))
|
||||
nil 'headline)
|
||||
(unless (eq (point) (point-min))
|
||||
(save-buffer)
|
||||
t)
|
||||
(when (eq (point) (point-min))
|
||||
(error "spine-set-rating: no book found with ID %s" id)))
|
||||
(kill-buffer)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run all edit tests to verify they pass**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-edit-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: all 6 tests pass.
|
||||
|
||||
- [ ] **Step 7: Run existing tests to confirm nothing broke**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-index-model-test.el \
|
||||
-l test/spine-books-test.el \
|
||||
-l test/spine-add-book-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: add spine-set-status, spine-add-note, spine-set-rating"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Extend spine-index-model with minibuffer actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el` — `spine-index-model` function
|
||||
|
||||
- [ ] **Step 1: Add minibuffer computation to per-book model**
|
||||
|
||||
In the per-book model building block (around line 158, after `"selected" selected`), add the minibuffer actions when the book is selected. Insert after the `detail` ht block and before closing the model ht:
|
||||
|
||||
Inside the `(let* (... (model (ht ...)))` block, after the `"detail"` key-value pair and before the closing `)))` of the let*, add:
|
||||
|
||||
```elisp
|
||||
("minibuffer"
|
||||
(when selected
|
||||
(let ((status (plist-get book :status)))
|
||||
(ht ("actions"
|
||||
(append
|
||||
(cond
|
||||
((equal status "WANT")
|
||||
(list
|
||||
(ht ("command" "status") ("label" "Mark reading")
|
||||
("href" (format "/edit?id=%s&action=status&value=READING" id)))
|
||||
(ht ("command" "note") ("label" "Add note")
|
||||
("href" (format "/edit?id=%s&action=note" id)))))
|
||||
((equal status "READING")
|
||||
(list
|
||||
(ht ("command" "status") ("label" "Mark read")
|
||||
("href" (format "/edit?id=%s&action=status&value=READ" id)))
|
||||
(ht ("command" "status") ("label" "Abandon")
|
||||
("href" (format "/edit?id=%s&action=status&value=ABANDONED" id)))
|
||||
(ht ("command" "note") ("label" "Add note")
|
||||
("href" (format "/edit?id=%s&action=note" id)))))
|
||||
((equal status "READ")
|
||||
(list
|
||||
(ht ("command" "status") ("label" "Read again")
|
||||
("href" (format "/edit?id=%s&action=status&value=READING" id)))
|
||||
(ht ("command" "note") ("label" "Add note")
|
||||
("href" (format "/edit?id=%s&action=note" id)))))
|
||||
((equal status "ABANDONED")
|
||||
(list
|
||||
(ht ("command" "status") ("label" "Try again")
|
||||
("href" (format "/edit?id=%s&action=status&value=READING" id)))))
|
||||
(t nil))
|
||||
nil))))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write a test for minibuffer model**
|
||||
|
||||
Append to `test/spine-edit-test.el`:
|
||||
|
||||
```elisp
|
||||
(ert-deftest spine-edit-model-minibuffer-want ()
|
||||
"Selected WANT book has Mark reading and Add note actions."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id))
|
||||
(model (spine-index-model books nil id))
|
||||
(groups (ht-get model "groups")))
|
||||
(cl-labels ((find-book-model (groups id)
|
||||
(cl-block nil
|
||||
(dolist (g groups)
|
||||
(dolist (b (ht-get g "books"))
|
||||
(when (string= (ht-get b "title") "Edit Test Book")
|
||||
(cl-return b)))))))
|
||||
(let ((bm (find-book-model groups id)))
|
||||
(should bm)
|
||||
(let ((mb (ht-get bm "minibuffer")))
|
||||
(should mb)
|
||||
(let ((actions (ht-get mb "actions")))
|
||||
(should (= (length actions) 2))
|
||||
(should (string= (ht-get (nth 0 actions) "label") "Mark reading"))
|
||||
(should (string= (ht-get (nth 1 actions) "label") "Add note"))))))))
|
||||
(spine-edit-test--cleanup)))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-edit-test.el \
|
||||
-l test/spine-index-model-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el test/spine-edit-test.el
|
||||
git commit -m "feat: add minibuffer actions to spine-index-model for selected books"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add httpd/edit handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el` — add handler after `httpd/add`
|
||||
|
||||
- [ ] **Step 1: Add httpd/edit handler**
|
||||
|
||||
Insert after `httpd/add` (after line 369):
|
||||
|
||||
```elisp
|
||||
(defun httpd/edit (proc uri-path query request)
|
||||
"Handle /edit: GET shows prompt page, POST processes actions.
|
||||
Query params:
|
||||
id - book ID
|
||||
action - status | note | rating
|
||||
value - target status / rating value (for status/rating actions)
|
||||
text - note text (for note action)
|
||||
date - optional date string (for note/read actions)"
|
||||
(let ((method (caar request))
|
||||
(id (cadr (assoc "id" query)))
|
||||
(action (cadr (assoc "action" query)))
|
||||
(value (cadr (assoc "value" query)))
|
||||
(text (cadr (assoc "text" query)))
|
||||
(date (cadr (assoc "date" query))))
|
||||
(if (equal method "POST")
|
||||
(progn
|
||||
(cond
|
||||
((equal action "note")
|
||||
(spine-add-note id text date))
|
||||
((equal action "status")
|
||||
(spine-set-status id value))
|
||||
((equal action "rating")
|
||||
(spine-set-rating id value)))
|
||||
(httpd-redirect proc (format "/index?id=%s" id) 303))
|
||||
;; GET: show prompt page for actions needing input
|
||||
(if (and id (equal action "note"))
|
||||
;; Show a simple note-prompt page
|
||||
(let* ((books (spine-books))
|
||||
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))
|
||||
(title (if book (plist-get book :title) "Unknown")))
|
||||
(httpd-with-buffer proc "text/html"
|
||||
(insert (format
|
||||
"<!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 — note</title>
|
||||
<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css\"/>
|
||||
</head>
|
||||
<body>
|
||||
<main class=\"container\">
|
||||
<article style=\"max-width:560px;margin-inline:auto;\">
|
||||
<header><strong>spine · add note</strong></header>
|
||||
<p><em>%s</em></p>
|
||||
<form method=\"post\" action=\"/edit\">
|
||||
<input type=\"hidden\" name=\"id\" value=\"%s\"/>
|
||||
<input type=\"hidden\" name=\"action\" value=\"note\"/>
|
||||
<label>Date <input type=\"date\" name=\"date\" value=\"%s\"/></label>
|
||||
<label>Note <textarea name=\"text\" rows=\"3\" required autofocus></textarea></label>
|
||||
<button type=\"submit\">Add note</button>
|
||||
</form>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>"
|
||||
title id (format-time-string "%Y-%m-%d"))))))
|
||||
;; Unknown action or missing id: redirect to index
|
||||
(httpd-redirect proc "/index" 302)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test the handler via curl (server test)**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
SPINE_ORG_FILE=$(pwd)/sample-books.org emacs --batch -l spine.el \
|
||||
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
|
||||
--eval "(httpd-start)" 2>&1 &
|
||||
sleep 2
|
||||
# Test note prompt page renders
|
||||
curl -s "http://localhost:8080/edit?id=8c1e-uow&action=note" 2>/dev/null | grep -oP "add note|Use of Weapons|textarea" | sort | uniq -c
|
||||
```
|
||||
|
||||
Expected: shows "add note", "Use of Weapons", "textarea".
|
||||
|
||||
- [ ] **Step 3: Kill the server**
|
||||
|
||||
```bash
|
||||
pkill -f "emacs.*spine.el" 2>/dev/null; sleep 1
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el
|
||||
git commit -m "feat: add /edit handler with note prompt page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update template minibuffer section
|
||||
|
||||
**Files:**
|
||||
- Modify: `templates/index.mustache`
|
||||
|
||||
- [ ] **Step 1: Replace minibuffer placeholder with action links**
|
||||
|
||||
Find the existing minibuffer section (lines ~127-133) and replace with:
|
||||
|
||||
```mustache
|
||||
{{#minibuffer}}
|
||||
<footer class="minibuffer">
|
||||
<span class="muted">M-x</span>
|
||||
<span style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
{{#actions}}
|
||||
<a href="{{href}}" class="pill" style="text-decoration:none">{{label}}</a>
|
||||
{{/actions}}
|
||||
</span>
|
||||
</footer>
|
||||
{{/minibuffer}}
|
||||
```
|
||||
|
||||
Remove any empty minibuffer template section if it existed (the current template has a minibuffer section with `command`/`text` fields that should be replaced).
|
||||
|
||||
- [ ] **Step 2: Verify template renders correctly**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch --eval '(setq spine-skip-server-start t)' \
|
||||
--eval '(setq spine-org-file "/work/personal-local/spine/sample-books.org")' \
|
||||
-l spine.el \
|
||||
--eval '(let* ((books (spine-books)) (model (spine-index-model books nil "8c1e-uow")) (html (spine-render "index.mustache" model))) (with-temp-file "/tmp/spine-edit-test.html" (insert html)))' 2>&1
|
||||
grep -oP "M-x|Mark read|Abandon|Add note" /tmp/spine-edit-test.html | sort | uniq -c
|
||||
```
|
||||
|
||||
Expected: shows "M-x", "Mark read", "Abandon", "Add note" (for a READING book).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/index.mustache
|
||||
git commit -m "feat: update minibuffer to render context-sensitive action links"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final integration and test run
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
```bash
|
||||
cd /work/personal-local/spine
|
||||
emacs --batch -l test/spine-edit-test.el \
|
||||
-l test/spine-index-model-test.el \
|
||||
-l test/spine-books-test.el \
|
||||
-l test/spine-add-book-test.el \
|
||||
--eval "(ert-run-tests-batch-and-exit '(not (tag :unstable)))" 2>&1
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: Push to main**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
@@ -0,0 +1,539 @@
|
||||
# Book Detail View Implementation Plan
|
||||
|
||||
**Goal:** Add a dedicated `/book?id=X` page showing full book details — header, format selector, reading log, note composer, and recommendation.
|
||||
|
||||
**Architecture:** New `spine-book-model` function builds the view model `ht` for one book plist. New `httpd/book` handler serves the journal-style template. Index view gets title links pointing to the book page.
|
||||
|
||||
**Tech Stack:** Emacs Lisp, simple-httpd, mustache.el, ht.el, Org-mode
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract shared format definitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el`
|
||||
|
||||
The format-icons list is currently local inside `spine-index-model`. Extract to a top-level `defconst` so `spine-book-model` can reuse it.
|
||||
|
||||
- [ ] **Step 1: Add `spine--format-defs` defconst before `spine-index-model`**
|
||||
|
||||
In `spine.el`, before `spine-index-model` (around line 66), add:
|
||||
|
||||
```elisp
|
||||
(defconst spine--format-defs
|
||||
'(("hardcover" "ti-book" "Hardcover")
|
||||
("ebook" "ti-device-tablet" "eBook")
|
||||
("audiobook" "ti-headphones" "Audiobook"))
|
||||
"Alist of (format-key icon label) for book format display.")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `spine-index-model` to use `spine--format-defs`**
|
||||
|
||||
Replace the local `format-icons` let-binding in `spine-index-model`:
|
||||
|
||||
```
|
||||
OLD (line 70-73):
|
||||
(let* ((format-icons
|
||||
'(("hardcover" . "ti-book")
|
||||
("ebook" . "ti-device-tablet")
|
||||
("audiobook" . "ti-headphones")))
|
||||
(grouped (make-hash-table :test 'equal))
|
||||
```
|
||||
|
||||
NEW:
|
||||
```elisp
|
||||
(let* ((grouped (make-hash-table :test 'equal))
|
||||
```
|
||||
|
||||
And at the format_icon line (~103), change:
|
||||
```
|
||||
OLD: ("format_icon" (or (cdr (assoc fmt format-icons)) ""))
|
||||
NEW: ("format_icon" (or (nth 1 (assoc fmt spine--format-defs)) ""))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `book_url` to each book model in `spine-index-model`**
|
||||
|
||||
In the book model ht (around line 101-135), add a `book_url` field:
|
||||
|
||||
```elisp
|
||||
("book_url" (format "/book?id=%s" id))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Write `spine-book-model` function
|
||||
|
||||
**Files:**
|
||||
- Create: `test/spine-book-model-test.el`
|
||||
- Modify: `spine.el`
|
||||
|
||||
- [ ] **Step 1: Write failing test file `test/spine-book-model-test.el`**
|
||||
|
||||
```elisp
|
||||
;;; spine-book-model-test.el — ERT tests for spine-book-model
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
(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)))
|
||||
|
||||
(defconst spine-book-model-test--uow
|
||||
(cl-find "8c1e-uow" (spine-books)
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)
|
||||
"Use of Weapons fixture.")
|
||||
|
||||
(defconst spine-book-model-test--aj
|
||||
(cl-find "3a1b-aj" (spine-books)
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)
|
||||
"Ancillary Justice fixture.")
|
||||
|
||||
(defconst spine-book-model-test--pir
|
||||
(cl-find "5e8d-pir" (spine-books)
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)
|
||||
"Piranesi fixture.")
|
||||
|
||||
(ert-deftest spine-book-model-title-strips-todo ()
|
||||
"Title has TODO prefix stripped."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "title") "Use of Weapons"))))
|
||||
|
||||
(ert-deftest spine-book-model-status-reading ()
|
||||
"READING book has status_class reading."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "status_class") "reading"))
|
||||
(should (equal (ht-get model "status_label") "Reading"))))
|
||||
|
||||
(ert-deftest spine-book-model-status-read ()
|
||||
"READ book has status_class read."
|
||||
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||
(should (equal (ht-get model "status_class") "read"))
|
||||
(should (equal (ht-get model "status_label") "Read"))))
|
||||
|
||||
(ert-deftest spine-book-model-status-want ()
|
||||
"WANT book has status_class want."
|
||||
(let ((model (spine-book-model spine-book-model-test--pir)))
|
||||
(should (equal (ht-get model "status_class") "want"))
|
||||
(should (equal (ht-get model "status_label") "Want"))))
|
||||
|
||||
(ert-deftest spine-book-model-author ()
|
||||
"Author is passed through."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "author") "Iain M. Banks"))))
|
||||
|
||||
(ert-deftest spine-book-model-meta-with-format-and-date ()
|
||||
"Meta includes format and date when both present."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (string-match "started.*audiobook" (ht-get model "meta")))))
|
||||
|
||||
(ert-deftest spine-book-model-format-active ()
|
||||
"Format list has correct active flag."
|
||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||
(formats (ht-get model "formats")))
|
||||
(should (= (length formats) 3))
|
||||
(should (equal (ht-get (nth 0 formats) "label") "Hardcover"))
|
||||
(should-not (ht-get (nth 0 formats) "active"))
|
||||
(should (equal (ht-get (nth 1 formats) "label") "eBook"))
|
||||
(should-not (ht-get (nth 1 formats) "active"))
|
||||
(should (equal (ht-get (nth 2 formats) "label") "Audiobook"))
|
||||
(should (ht-get (nth 2 formats) "active"))))
|
||||
|
||||
(ert-deftest spine-book-model-notes ()
|
||||
"Notes are mapped correctly."
|
||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||
(notes (ht-get model "notes")))
|
||||
(should (= (length notes) 3))
|
||||
(should (equal (ht-get (nth 0 notes) "date") "2026-03-02"))
|
||||
(should (equal (ht-get (nth 0 notes) "text")
|
||||
"The two-track structure is doing something I can't name yet."))))
|
||||
|
||||
(ert-deftest spine-book-model-recommendation ()
|
||||
"Recommendation includes initials, by, and note."
|
||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||
(rec (ht-get model "recommendation")))
|
||||
(should rec)
|
||||
(should (equal (ht-get rec "initials") "P"))
|
||||
(should (equal (ht-get rec "by") "Priya"))
|
||||
(should (string-match "Player of Games" (ht-get rec "note")))))
|
||||
|
||||
(ert-deftest spine-book-model-no-recommendation ()
|
||||
"No recommendation when rec_by absent."
|
||||
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||
(should-not (ht-get model "recommendation"))))
|
||||
|
||||
(ert-deftest spine-book-model-no-todo-prefix ()
|
||||
"Book with no TODO prefix shows title as-is and empty status."
|
||||
(let* ((books (spine-books))
|
||||
(tcm (cl-find "b2c1-tcm" books
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)))
|
||||
(should tcm)
|
||||
(let ((model (spine-book-model tcm)))
|
||||
(should (equal (ht-get model "title") "The Checklist Manifesto"))
|
||||
(should (equal (ht-get model "status_class") ""))
|
||||
(should (equal (ht-get model "status_label") "")))))
|
||||
|
||||
(ert-deftest spine-book-model-no-format-or-date ()
|
||||
"No format and no date → meta is empty."
|
||||
(let* ((books (spine-books))
|
||||
(tcm (cl-find "b2c1-tcm" books
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)))
|
||||
(should tcm)
|
||||
(let ((model (spine-book-model tcm)))
|
||||
(should (equal (ht-get model "meta") "")))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||
```
|
||||
|
||||
Expected: FAIL — `spine-book-model` undefined.
|
||||
|
||||
- [ ] **Step 3: Write `spine-book-model` in `spine.el`**
|
||||
|
||||
Add two helper functions and the main model function after `spine-add-form-model` (~line 64), before `spine-index-model`:
|
||||
|
||||
```elisp
|
||||
(defun spine--format-date (org-date-str)
|
||||
"Format Org date \"[2026-02-20]\" to \"20 Feb\"."
|
||||
(when (and org-date-str (> (length org-date-str) 0))
|
||||
(let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str)))
|
||||
(condition-case nil
|
||||
(format-time-string "%e %b" (date-to-time clean))
|
||||
(error org-date-str)))))
|
||||
|
||||
(defun spine--parse-title-status (title)
|
||||
"Return (CLEAN-TITLE STATUS-CLASS STATUS-LABEL) from a book TITLE.
|
||||
TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"."
|
||||
(let ((case-fold-search nil))
|
||||
(if (string-match
|
||||
"\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'"
|
||||
title)
|
||||
(let ((kw (match-string 1 title))
|
||||
(rest (match-string 2 title)))
|
||||
(list rest
|
||||
(downcase kw)
|
||||
(capitalize (downcase kw))))
|
||||
(list title "" ""))))
|
||||
|
||||
(defun spine-book-model (book)
|
||||
"Build view model `ht` for a single BOOK plist, for templates/book.mustache."
|
||||
(let* ((id (plist-get book :id))
|
||||
(title (or (plist-get book :title) ""))
|
||||
(author (or (plist-get book :author) ""))
|
||||
(fmt (plist-get book :format))
|
||||
(added (plist-get book :added))
|
||||
(notes (plist-get book :notes))
|
||||
(rec-by (plist-get book :rec_by))
|
||||
(rec-note (plist-get book :rec_note))
|
||||
(title-info (spine--parse-title-status title))
|
||||
(clean-title (nth 0 title-info))
|
||||
(status-class (nth 1 title-info))
|
||||
(status-label (nth 2 title-info))
|
||||
(meta (cond
|
||||
((and added fmt)
|
||||
(format "started %s · %s" (spine--format-date added) fmt))
|
||||
(added (format "started %s" (spine--format-date added)))
|
||||
(fmt (format "format: %s" fmt))
|
||||
(t "")))
|
||||
(formats (mapcar
|
||||
(lambda (fd)
|
||||
(ht ("icon" (nth 1 fd))
|
||||
("label" (nth 2 fd))
|
||||
("active" (and fmt (equal (car fd) fmt)))))
|
||||
spine--format-defs))
|
||||
(notes-model
|
||||
(mapcar (lambda (n)
|
||||
(ht ("date" (car n))
|
||||
("text" (cadr n))))
|
||||
notes))
|
||||
(recommendation
|
||||
(when (and rec-by (> (length rec-by) 0))
|
||||
(let ((initials
|
||||
(mapconcat
|
||||
(lambda (s)
|
||||
(when (> (length s) 0)
|
||||
(upcase (substring s 0 1))))
|
||||
(split-string rec-by " +" t) "")))
|
||||
(ht ("initials" initials)
|
||||
("by" rec-by)
|
||||
("note" (or rec-note "")))))))
|
||||
(ht ("cover_icon" "ti-book")
|
||||
("id" id)
|
||||
("title" clean-title)
|
||||
("author" author)
|
||||
("status_class" status-class)
|
||||
("status_label" status-label)
|
||||
("meta" meta)
|
||||
("progress_label" "")
|
||||
("formats" formats)
|
||||
("notes" notes-model)
|
||||
("recommendation" recommendation)
|
||||
("back_url" (format "/index?id=%s" id)))))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||
```
|
||||
|
||||
Expected: PASS for all tests.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add spine.el test/spine-book-model-test.el
|
||||
git commit -m "feat: add spine-book-model for book detail view model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `templates/book.mustache`
|
||||
|
||||
**Files:**
|
||||
- Create: `templates/book.mustache`
|
||||
|
||||
- [ ] **Step 1: Write the mustache template**
|
||||
|
||||
Based on `spine-mockup-b-journal.mustache` with Pico CSS. Differences from mockup:
|
||||
- Back link at top
|
||||
- No progress_label in composer placeholder
|
||||
- No recommendation date (not stored in data model)
|
||||
- Conditional recommendation section
|
||||
|
||||
```html
|
||||
<!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 — {{title}}</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>
|
||||
<main class="container">
|
||||
<article class="book">
|
||||
<p><a href="{{back_url}}" style="font-size:.85rem;">← Back to shelf</a></p>
|
||||
|
||||
<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>
|
||||
{{#status_class}}
|
||||
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||
{{/status_class}}
|
||||
{{#meta}}
|
||||
<span class="muted" style="color: var(--pico-muted-color); font-size: .85rem; margin-left: .5rem;">{{meta}}</span>
|
||||
{{/meta}}
|
||||
</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>
|
||||
|
||||
{{#notes}}
|
||||
<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>
|
||||
{{/notes}}
|
||||
|
||||
<div class="compose" role="group">
|
||||
<input type="text" placeholder="Add a note…" />
|
||||
<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 />
|
||||
|
||||
{{#recommendation}}
|
||||
<p class="seclabel">On your radar</p>
|
||||
<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><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>
|
||||
```
|
||||
|
||||
Don't write the template yet — this step is informational. The actual template is created in the next step with `write`.
|
||||
|
||||
- [ ] **Step 2: Create the template file**
|
||||
|
||||
Create `templates/book.mustache` with the content above.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add `httpd/book` handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el`
|
||||
|
||||
- [ ] **Step 1: Add the handler function after `httpd/edit` (around line 491)**
|
||||
|
||||
```elisp
|
||||
(defun httpd/book (proc uri-path query request)
|
||||
"Show the journal-style detail page for a single book."
|
||||
(let* ((id (cadr (assoc "id" query)))
|
||||
(books (spine-books))
|
||||
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(if book
|
||||
(httpd-with-buffer proc "text/html"
|
||||
(insert (spine-render "book.mustache" (spine-book-model book))))
|
||||
(httpd-redirect proc "/index" 302))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Quick smoke test — load spine.el and check no errors**
|
||||
|
||||
```bash
|
||||
emacs --batch --eval "(progn (setq spine-skip-server-start t) (load-file \"spine.el\"))"
|
||||
```
|
||||
|
||||
Expected: loads without errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/book.mustache spine.el
|
||||
git commit -m "feat: add httpd/book handler and book detail template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add title links in index view
|
||||
|
||||
**Files:**
|
||||
- Modify: `spine.el` (already done in Task 1 Step 3 — `book_url` added)
|
||||
- Modify: `templates/index.mustache`
|
||||
|
||||
- [ ] **Step 1: Update `templates/index.mustache` to make title a link**
|
||||
|
||||
Find line ~81 in `templates/index.mustache`:
|
||||
```
|
||||
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```
|
||||
<a href="{{book_url}}" class="title" style="text-decoration:none;color:inherit">{{title}}</a>
|
||||
<span class="muted">· {{author}}</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add templates/index.mustache
|
||||
git commit -m "feat: link book titles in index to detail page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Run full test suite
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
```bash
|
||||
emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||
emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||
emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||
emacs --batch -l test/spine-edit-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||
emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "test: verify book detail view and all existing tests pass"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Self-review
|
||||
|
||||
- [ ] **Step 1: Verify spec coverage**
|
||||
|
||||
Check each spec requirement maps to a task:
|
||||
- Model fields (cover_icon, title, author, status_class, status_label, meta, formats, notes, recommendation) → Task 2
|
||||
- Status extraction (strip TODO prefix) → Task 2 Step 3
|
||||
- Format list with active flag → Task 2 Step 3
|
||||
- Recommendation with initials → Task 2 Step 3
|
||||
- Handler redirect on missing book → Task 4
|
||||
- Template with back link → Task 3
|
||||
- No recommendation → handled by mustache `{{#recommendation}}` section
|
||||
- Title links in index → Task 5
|
||||
- Empty reading log → mustache `{{#notes}}` section handles empty
|
||||
|
||||
- [ ] **Step 2: Placeholder scan**
|
||||
|
||||
Verify no "TBD", "TODO", "implement later" in plan.
|
||||
|
||||
- [ ] **Step 3: Type consistency check**
|
||||
|
||||
Verify field names (cover_icon, status_class, etc.) match between model function and template.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,169 @@
|
||||
# Spine — Add Book Form
|
||||
|
||||
Adds a form to create new books in `spine.org`, plus the write function
|
||||
to persist them.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
"Add book" link (index page)
|
||||
│
|
||||
GET /add
|
||||
│
|
||||
templates/add.mustache ←── [spine-index-model] for datalist values
|
||||
│
|
||||
POST /add (form submission)
|
||||
│
|
||||
[spine-add-book] ──→ writes to spine.org
|
||||
│
|
||||
redirect to /index
|
||||
```
|
||||
|
||||
## Template: `templates/add.mustache`
|
||||
|
||||
A new Mustache template with the Pico CSS + Tabler Icons shell (matching the
|
||||
index page) and a `<form method="post" action="/add">`.
|
||||
|
||||
**View model:**
|
||||
|
||||
```json
|
||||
{
|
||||
"app_title": "spine",
|
||||
"existing_authors": ["Ursula K. Le Guin", "Frank Herbert", ...],
|
||||
"existing_categories": ["scifi", "literary", ...]
|
||||
}
|
||||
```
|
||||
|
||||
`existing_authors` and `existing_categories` are extracted from the current
|
||||
`spine-books` results. Empty lists are fine — the `<datalist>` just has no
|
||||
options.
|
||||
|
||||
**Form fields:**
|
||||
|
||||
| Field | Input type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| date_added | `<input type="date">` | no | defaults to today |
|
||||
| title | `<input type="text">` | **yes** | |
|
||||
| author | `<input type="text" list="authors">` | no | datalist from existing |
|
||||
| category | `<input type="text" list="categories">` | no | datalist from existing tags |
|
||||
| format | `<select>` | no | hardcover / ebook / audiobook / — |
|
||||
| isbn | `<input type="text">` | no | |
|
||||
| cover | `<input type="text">` | no | path relative to spine.org |
|
||||
| rec_by | `<input type="text">` | no | recommender name |
|
||||
| rec_note | `<input type="text">` | no | why it's on your radar |
|
||||
| initial_note | `<textarea>` | no | becomes first reading note |
|
||||
|
||||
**Datalists** for author and category:
|
||||
|
||||
```html
|
||||
<input type="text" name="author" list="authors-list" />
|
||||
<datalist id="authors-list">
|
||||
{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}
|
||||
</datalist>
|
||||
```
|
||||
|
||||
"Add book" link on the index page (in `templates/index.mustache`) — placed in
|
||||
the `<header>` alongside the title:
|
||||
|
||||
```html
|
||||
<a role="button" href="/add">Add book</a>
|
||||
```
|
||||
|
||||
## Write function: `spine-add-book`
|
||||
|
||||
```elisp
|
||||
(spine-add-book &key title author category format isbn cover
|
||||
rec_by rec_note date_added initial_note)
|
||||
→ t on success, signals error on failure
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
1. Visits `spine-org-file` in a temporary buffer
|
||||
2. Inserts a new top-level headline with TODO keyword `WANT`
|
||||
3. Sets properties: `:AUTHOR:`, `:FORMAT:`, `:ISBN:`, `:COVER:`, `:REC_BY:`, `:REC_NOTE:`, `:ADDED:`, `:ID:`
|
||||
4. Sets tags if category provided
|
||||
5. Appends initial note as `- [DATE] note text` if provided
|
||||
6. Saves the buffer
|
||||
7. Returns `t`
|
||||
|
||||
**`:ID:` generation:** short random alphanumeric, e.g. `(format "%04x-%04x" (random 65535) (random 65535))`.
|
||||
|
||||
**`:ADDED:` date:** if `date_added` is provided, format as `[YYYY-MM-DD]`. If nil, omit the property.
|
||||
|
||||
**Properties with nil/empty values are omitted** — only set properties that have non-empty values. This keeps the Org file clean.
|
||||
|
||||
## Handler: `/add`
|
||||
|
||||
```elisp
|
||||
(defservlet add text/html (path query request)
|
||||
(let ((method (car (car request))))
|
||||
(if (equal method "POST")
|
||||
;; Process form submission
|
||||
(progn
|
||||
(spine-add-book
|
||||
:title (cdr (assoc "title" query))
|
||||
:author (cdr (assoc "author" query))
|
||||
:category (cdr (assoc "category" query))
|
||||
:format (cdr (assoc "format" query))
|
||||
:isbn (cdr (assoc "isbn" query))
|
||||
:cover (cdr (assoc "cover" query))
|
||||
:rec_by (cdr (assoc "rec_by" query))
|
||||
:rec_note (cdr (assoc "rec_note" query))
|
||||
:date_added (cdr (assoc "date_added" query))
|
||||
:initial_note (cdr (assoc "initial_note" query)))
|
||||
(httpd-redirect t "/index" 303))
|
||||
;; GET: show the form
|
||||
(let ((model (spine-add-form-model (spine-books))))
|
||||
(insert (spine-render "add.mustache" model))))))
|
||||
```
|
||||
|
||||
`spine-add-form-model` builds the view model from `spine-books` — extracts
|
||||
unique authors and tags into flat lists.
|
||||
|
||||
**Form GET access:** `http://localhost:8080/add`
|
||||
|
||||
**Empty-books case:** if `spine-books` returns nil, the datalists are empty —
|
||||
the form still works, you just get no suggestions.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Title missing | redirect to `/add` with error message (query param) |
|
||||
| Write fails | `spine-add-book` signals error, handler catches and shows error page |
|
||||
| `spine.org` missing | `spine-add-book` creates a new file with the `#+TODO` header and first entry |
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `templates/add.mustache` | New — add-book form template |
|
||||
| `templates/index.mustache` | Modified — add "Add book" link in header |
|
||||
| `spine.el` | New functions: `spine-add-book`, `spine-add-form-model`. New handler: `/add`. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Validation beyond title required
|
||||
- Edit existing book
|
||||
- Delete book
|
||||
- Cover image upload (text path only)
|
||||
- Multi-category input (single category string → single tag for now)
|
||||
- Error display on the form page (redirect on error, no inline messages)
|
||||
|
||||
## Styling
|
||||
|
||||
Uses Pico CSS form styles (`<input>`, `<select>`, `<textarea>`) which are
|
||||
styled automatically by the Pico framework. No additional custom CSS needed
|
||||
for the form — Pico handles layout, spacing, and theme.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] "Add book" link visible on index page
|
||||
- [ ] `GET /add` shows form with Pico styling
|
||||
- [ ] Author and category fields have datalist suggestions from existing data
|
||||
- [ ] Submitting the form creates a new `* WANT Title` headline in `spine.org`
|
||||
- [ ] Properties are set correctly (title, author, format, ISBN, etc.)
|
||||
- [ ] Initial note appears as `- [YYYY-MM-DD] text` in the body
|
||||
- [ ] Empty optional fields are omitted (no empty properties)
|
||||
- [ ] After submission, redirect to `/index` and new book is visible
|
||||
- [ ] ERT tests still pass (model layer unchanged)
|
||||
@@ -0,0 +1,97 @@
|
||||
# Concise Default View — design spec
|
||||
|
||||
## Problem
|
||||
|
||||
The index page shows every book grouped by status. As the library grows, this becomes
|
||||
too much information at a glance. The user needs a focused default showing only what's
|
||||
active right now, with easy paths to browse the rest.
|
||||
|
||||
## Design
|
||||
|
||||
The index view takes an optional `filter` query parameter. With no filter, it shows a
|
||||
concise default: only books in-flight (READING) and the 5 most recently added WANT
|
||||
books. All other books are accessible via filter links. With a filter (`all`, `read`,
|
||||
`want`), it shows the full matching subset.
|
||||
|
||||
## Behaviour
|
||||
|
||||
### Default / no filter (concise view)
|
||||
|
||||
- **READING group**: all READING books (unchanged from current).
|
||||
- **WANT group**: sorted by `:ADDED:` date descending, limited to 5.
|
||||
- **READ / ABANDONED**: replaced by summary links — e.g. "Read · 3" as a clickable
|
||||
link to `/index?filter=read`.
|
||||
- **Filter nav bar** shown at the top: links to `All books (N)`, `Want list`,
|
||||
`Read (N)`.
|
||||
|
||||
### `?filter=all`
|
||||
|
||||
Full index, all groups, no truncation — identical to today's default.
|
||||
|
||||
### `?filter=read`
|
||||
|
||||
Only the READ group. Single group in the listing.
|
||||
|
||||
### `?filter=want`
|
||||
|
||||
Only the WANT group. Single group in the listing.
|
||||
|
||||
### Error handling
|
||||
|
||||
- An unrecognised/empty filter value is treated as `nil` (concise default).
|
||||
- A WANT book with no `:ADDED:` property sorts before dated entries (treated as
|
||||
oldest).
|
||||
- Fewer than 5 WANT books → all are shown, no padding or placeholder rows.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Model: `spine-index-model`
|
||||
|
||||
Signature changes from:
|
||||
```
|
||||
spine-index-model (books &optional selected-id)
|
||||
```
|
||||
to:
|
||||
```
|
||||
spine-index-model (books &optional filter selected-id)
|
||||
```
|
||||
|
||||
When `filter` is nil, after building the group alist:
|
||||
- Sort the `:want` group entries by `added` descending (ISO date string; nils sort
|
||||
first = oldest).
|
||||
- Truncate to 5 entries.
|
||||
- Replace `:read` and `:abandoned` groups with a `:summary-groups` list. Each
|
||||
entry is a plist matching the existing model convention:
|
||||
```
|
||||
(:label "read" :count 3 :href "/index?filter=read")
|
||||
```
|
||||
The template renders these as clickable `.row` links.
|
||||
When `filter` is non-nil, `:summary-groups` is absent.
|
||||
When `filter` is non-nil, return only the matching group (or all groups for `"all"`).
|
||||
|
||||
### Handler: `defservlet index`
|
||||
|
||||
Read the `filter` key from the request query alist. Pass it to `spine-index-model`.
|
||||
Pass `current_filter` in the template context for conditional rendering.
|
||||
|
||||
### Template: `index.mustache`
|
||||
|
||||
- **Filter bar** (above groups): shown when `current_filter` is nil (links to expanded
|
||||
views) or truthy (link back to concise view).
|
||||
- **Summary groups**: new `{{#summary_groups}}` block renders a simple `.row` link per
|
||||
suppressed group (READ, ABANDONED).
|
||||
- Existing group rendering unchanged — still handles full listing when `filter=all`
|
||||
or single-group views.
|
||||
|
||||
### Sorting
|
||||
|
||||
Within the WANT group for the concise view: sort by `added` field descending. The
|
||||
`added` field is already part of each book's model entry. Nil dates sort before dated
|
||||
entries (oldest).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Pagination for large WANT lists (5 is a design choice; if the user wants more, the
|
||||
"All books" link is there).
|
||||
- Remembering the user's last selected filter across requests (stateless is simpler).
|
||||
- Keyboard-driven filter switching (plain links for now).
|
||||
@@ -0,0 +1,127 @@
|
||||
# Edit Book Actions — design spec
|
||||
|
||||
## Problem
|
||||
|
||||
The index page shows books grouped by status but provides no way to change a book's
|
||||
status, add reading notes, or record ratings. Users must edit the Org file manually.
|
||||
We need lightweight, context-sensitive edit actions accessible from the agenda view.
|
||||
|
||||
## Interaction model
|
||||
|
||||
Click a book row on the index page → the footer (minibuffer) shows action links
|
||||
tailored to that book's current status. Clicking an action either performs it
|
||||
immediately (status transitions) or shows a lightweight prompt page (add note,
|
||||
mark read with date).
|
||||
|
||||
No JavaScript. All actions are plain `<a href>` links or simple HTML forms.
|
||||
|
||||
## Actions per status
|
||||
|
||||
### WANT
|
||||
- **Mark reading** — immediate: sets TODO to READING
|
||||
- **Add note** — prompt page: date + textarea, appends a note
|
||||
|
||||
### READING
|
||||
- **Mark read** — prompt page: date picker (default today)
|
||||
Rating is deferred (see out of scope).
|
||||
- **Abandon** — immediate: sets TODO to ABANDONED
|
||||
- **Add note** — prompt page: date + textarea
|
||||
|
||||
### READ
|
||||
- **Read again** — immediate: sets TODO to READING
|
||||
- **Add note** — prompt page: date + textarea
|
||||
### ABANDONED
|
||||
- **Try again** — immediate: sets TODO to READING
|
||||
|
||||
## Implementation
|
||||
|
||||
### New Org write functions (`spine.el`)
|
||||
|
||||
**`spine-set-status (id status &optional date)`**
|
||||
|
||||
Finds the book by `:ID:` property, changes its TODO keyword to `status`, and records
|
||||
the state transition in the LOGBOOK drawer (using `org-todo`). If `date` is provided
|
||||
(for READ status), sets a `:READ_DATE:` property.
|
||||
|
||||
**`spine-add-note (id text &optional date)`**
|
||||
|
||||
Finds the book by `:ID:`, appends `- [YYYY-MM-DD] text` to the headline body. Date
|
||||
defaults to today if not provided. Follows the same pattern as `spine-add-book`:
|
||||
`find-file-noselect`, modify, save, `kill-buffer`.
|
||||
|
||||
**`spine-set-rating (id rating)`**
|
||||
|
||||
Sets the `:RATING:` property on the book.
|
||||
|
||||
### Handler: `httpd/edit`
|
||||
|
||||
Single handler dispatching on `action` query param:
|
||||
|
||||
- `action=status` — calls `spine-set-status` with `id` and `value` (the target TODO
|
||||
state). Redirects to `/index?id=<id>`.
|
||||
- `action=note` — GET shows a prompt page with date + textarea for the book. POST
|
||||
processes: calls `spine-add-note` with `id`, `text`, and optional `date`. Redirects
|
||||
to `/index?id=<id>`.
|
||||
- `action=rating` — calls `spine-set-rating` with `id` and `value`. Redirects to
|
||||
`/index?id=<id>`.
|
||||
- Unknown action or missing `id` — redirect to `/index` silently.
|
||||
|
||||
The prompt page for notes is a minimal HTML page (no Mustache template needed — small
|
||||
enough to inline): shows the book title as context, a date input (default today), a
|
||||
textarea, and a submit button. POST data includes `id`, `action`, `date`, `text`.
|
||||
|
||||
### Model: `spine-index-model` minibuffer extension
|
||||
|
||||
When a book is selected (`selected-id` matches), the model gains a `minibuffer` field:
|
||||
|
||||
```elisp
|
||||
("minibuffer" .
|
||||
(ht ("actions"
|
||||
(list
|
||||
(ht ("label" "Mark reading") ("href" "/edit?id=X&action=status&value=READING"))
|
||||
(ht ("label" "Add note") ("href" "/edit?id=X&action=note"))))))
|
||||
```
|
||||
|
||||
Actions are computed from the selected book's `:status` property. The exact set
|
||||
follows the table in "Actions per status" above.
|
||||
|
||||
### Template: `index.mustache` minibuffer section
|
||||
|
||||
The existing minibuffer footer placeholder is replaced with a dynamic version:
|
||||
|
||||
```mustache
|
||||
{{#minibuffer}}
|
||||
<footer class="minibuffer">
|
||||
<span class="muted">M-x</span>
|
||||
<span style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
{{#actions}}
|
||||
<a href="{{href}}" class="pill {{command}}">{{label}}</a>
|
||||
{{/actions}}
|
||||
</span>
|
||||
</footer>
|
||||
{{/minibuffer}}
|
||||
```
|
||||
|
||||
Styled as pill links consistent with the existing pill classes. `M-x` prefix keeps
|
||||
the Emacs feel.
|
||||
|
||||
### Testing
|
||||
|
||||
New test file `test/spine-edit-test.el`:
|
||||
|
||||
- `spine-set-status` changes TODO state correctly for each transition
|
||||
- `spine-set-status` records LOGBOOK entry
|
||||
- `spine-add-note` appends a note with the correct format
|
||||
- `spine-add-note` defaults to today when no date provided
|
||||
- `spine-set-rating` sets the rating property
|
||||
- Model includes `minibuffer` field when book is selected
|
||||
- Model has correct action set per status
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Editing book properties (author, format, ISBN) — add-book covers creation; editing
|
||||
those can come later if needed
|
||||
- Deleting books — add-book is additive; no delete flow yet
|
||||
- Rating prompt on the "Mark read" page — deferred; rating can be set separately
|
||||
- Inline editing in the detail section — the minibuffer actions are the primary
|
||||
surface for now
|
||||
@@ -0,0 +1,109 @@
|
||||
# Book Detail View Design
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Status:** Draft
|
||||
|
||||
## Summary
|
||||
|
||||
Add a dedicated book detail page (`/book?id=X`) following the journal-style mockup (Direction B). The page shows full book metadata, format badges, reading log, note composer, and recommendation section. Uses Pico CSS and Tabler icons, matching the add-book template style.
|
||||
|
||||
## Route
|
||||
|
||||
- **`GET /book?id=<id>`** — renders the full detail page
|
||||
- No POST handling (editing flows go through existing `/edit`)
|
||||
- Book not found → redirect to `/index`
|
||||
|
||||
## Model: `spine-book-model`
|
||||
|
||||
Builds an `ht` (hashtable) view model from one book plist.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `cover_icon` | string | hardcoded `"ti-book"` | Placeholder cover icon |
|
||||
| `title` | string | `:title` minus TODO prefix | Strip leading `WANT `, `READING `, `READ ` |
|
||||
| `author` | string | `:author` | Empty string when nil |
|
||||
| `status_class` | string | TODO prefix → lowercase | `"want"` / `"reading"` / `"read"` / `""` |
|
||||
| `status_label` | string | TODO prefix → capitalized | `"Want"` / `"Reading"` / `"Read"` / `""` |
|
||||
| `meta` | string | format + added date | e.g. `"started 20 Feb · audiobook"`. Omits date if not present, format if not present |
|
||||
| `progress_label` | string | — | Empty string; reserved for future progress tracking |
|
||||
| `formats` | array | all 3 known formats | `[{icon, label, active}]`. Active matches `:format` property |
|
||||
| `notes` | array | `:notes` | `[{date, text}]`. Empty array when no notes |
|
||||
| `recommendation` | object or nil | `:rec_by` + `:rec_note` | `{initials, by, note}`. nil when no rec_by |
|
||||
|
||||
### Status extraction
|
||||
|
||||
Book headlines have TODO keyword prefixes (`WANT`, `READING`, `READ`) as part of their title text. The model function:
|
||||
|
||||
1. Strips the prefix from the title for display
|
||||
2. Maps recognized prefixes to status classes
|
||||
|
||||
### Format list
|
||||
|
||||
Hardcoded list of three known formats:
|
||||
- `hardcover` → `ti-book` → "Hardcover"
|
||||
- `ebook` → `ti-device-tablet` → "eBook"
|
||||
- `audiobook` → `ti-headphones` → "Audiobook"
|
||||
|
||||
The book's `:format` property determines which is `active: true`; the others are `active: false`.
|
||||
|
||||
### Recommendation
|
||||
|
||||
Generated from `:rec_by` (name) and `:rec_note` (text). No date stored currently.
|
||||
|
||||
`initials` derived from the name: uppercase first letter of each space-separated part. e.g. "Priya" → "P", "John Doe" → "JD".
|
||||
|
||||
## Template: `templates/book.mustache`
|
||||
|
||||
Based on `spine-mockup-b-journal.mustache` with adaptations:
|
||||
- Pico CSS framework
|
||||
- Tabler icons for icons
|
||||
- Progress-related text omitted from the composer placeholder (says "Add a note…" without percentage)
|
||||
- Back link at top to return to index with this book selected (`/index?id=X`)
|
||||
- No recommendation date field (not stored in data model)
|
||||
- Conditional rendering of recommendation section
|
||||
|
||||
## Handler: `httpd/book`
|
||||
|
||||
Registered as `httpd/book` matching the existing add/edit handler pattern:
|
||||
|
||||
```elisp
|
||||
(defun httpd/book (proc uri-path query request)
|
||||
"Show the journal-style detail page for a single book."
|
||||
(let* ((id (cadr (assoc "id" query)))
|
||||
(books (spine-books))
|
||||
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(if book
|
||||
(insert (spine-render "book.mustache" (spine-book-model book)))
|
||||
(httpd-redirect proc "/index" 302))))
|
||||
```
|
||||
| Action | File | Description |
|
||||
|---|---|---|
|
||||
| New | `templates/book.mustache` | Pico CSS + Tabler detail page |
|
||||
| Modify | `spine.el` | Add `spine-book-model`, `httpd/book` handler, title link in index model |
|
||||
| New | `test/spine-book-model-test.el` | Tests for `spine-book-model` |
|
||||
| Modify | `test/spine-index-model-test.el` | Tests for title link in index model |
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **No TODO prefix on headline** → title shown as-is, status empty
|
||||
- **Unknown TODO prefix** (not WANT/READING/READ) → title shown with prefix, status empty
|
||||
- **Book not found** → redirect to `/index`
|
||||
- **No format property** → no format active in the selector
|
||||
- **No author** → empty string
|
||||
- **No notes** → empty reading log section
|
||||
- **No recommendation** → section not rendered (optional block)
|
||||
|
||||
## Testing
|
||||
|
||||
Test file `test/spine-book-model-test.el` covers:
|
||||
- Title stripped of TODO prefix
|
||||
- Status class derived correctly for WANT/READING/READ
|
||||
- Meta string from format + added date
|
||||
- Format list with correct active flag
|
||||
- Notes mapping
|
||||
- Recommendation with initials
|
||||
- No recommendation when rec_by absent
|
||||
- Book with no TODO prefix (handles gracefully)
|
||||
- Book with no format or date (meta is empty)
|
||||
@@ -0,0 +1,133 @@
|
||||
# Shelves data model
|
||||
|
||||
Replace the TODO-state-based book model with shelf-based organization.
|
||||
Shelves are top-level Org headings; books are nested underneath them.
|
||||
|
||||
## Motivation
|
||||
|
||||
Books-as-top-level-headings with TODO states (WANT/READING/READ/ABANDONED)
|
||||
made the file's structure reflect lifecycle status rather than organizing
|
||||
concepts. The user wants arbitrary grouping ("shelves") as the primary
|
||||
organizing axis. TODO states are removed — logbook entries (already present)
|
||||
record when reading happened.
|
||||
|
||||
## Org data model
|
||||
|
||||
```org
|
||||
#+TITLE: Spine
|
||||
|
||||
* Fiction
|
||||
** Piranesi
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Susanna Clarke
|
||||
:FORMAT: ebook
|
||||
:REC_BY: Alex
|
||||
:REC_NOTE: Le Guin at her most human
|
||||
:RATING:
|
||||
:ADDED: [2026-04-01]
|
||||
:LAST_MODIFIED: [2026-04-01]
|
||||
:ID: 5e8d-pir
|
||||
:END:
|
||||
::LOGBOOK:
|
||||
- State "READ" from "WANT" [2026-01-10]
|
||||
::END:
|
||||
- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
|
||||
|
||||
* Non-Fiction
|
||||
** The Checklist Manifesto
|
||||
...
|
||||
```
|
||||
|
||||
- Shelves are level-1 headlines (`*`)
|
||||
- Books are level-2 headlines (`**`)
|
||||
- Books keep their TODO keyword syntax for `org-mode` compatibility,
|
||||
but the app ignores it entirely
|
||||
- The `#+TODO:` line is removed — no TODO states
|
||||
- Empty shelves (a level-1 headline with no children) are valid and
|
||||
appear in the UI
|
||||
- LOGBOOK drawers remain for historical state-change records but are
|
||||
not surfaced by the UI
|
||||
|
||||
## Key functions
|
||||
|
||||
### `spine-shelves` (new)
|
||||
|
||||
Returns an ordered list of shelf names (strings), one per level-1 headline
|
||||
that is not a COMMENT or archive target. Empty shelves are included.
|
||||
|
||||
### `spine-books` (modified)
|
||||
|
||||
Walks level-1 headlines as shelves. For each shelf, walks level-2 children
|
||||
as books. Returns flat plist list; each book plist gains a `:shelf` key
|
||||
with the parent headline text. No status/TODO filtering.
|
||||
|
||||
Places that previously checked Todo state (e.g. `spine-set-status`) are
|
||||
removed — the only remaining mutable fields are notes, rating, format.
|
||||
|
||||
### `spine-index-model` (modified)
|
||||
|
||||
Groups books by `:shelf` instead of `:status`. Accepts an optional
|
||||
`shelf` filter (instead of `read`/`want`/`all`) to show one shelf.
|
||||
Includes shelf nav items for all shelves (even empty ones, from
|
||||
`spine-shelves`).
|
||||
|
||||
Removes:
|
||||
- Summary groups (READ/ABANDONED links)
|
||||
- Concise view truncation
|
||||
- Status-related book model fields (status_class, status_label)
|
||||
|
||||
### `spine-add-book` (modified)
|
||||
|
||||
Takes required `:shelf` argument. Inserts the new book heading under
|
||||
the matching level-1 shelf headline instead of at file end. If the
|
||||
shelf doesn't exist, signals an error (shelves are created manually).
|
||||
|
||||
Removes:
|
||||
- `org-todo "WANT"` call
|
||||
- `#+TODO:` file initialization
|
||||
- Category field logic (replaced by shelf)
|
||||
|
||||
### Functions removed
|
||||
|
||||
- `spine-set-status` — no status to change. Logbook records history.
|
||||
- All HTTP endpoints and UI actions that invoke it.
|
||||
|
||||
## UI changes
|
||||
|
||||
### Index view (`index.mustache`)
|
||||
|
||||
- **Titlebar**: `N books · M shelves`
|
||||
- **Filter bar**: replaced with shelf nav — links for each shelf name
|
||||
plus "All" (default). Active shelf highlighted.
|
||||
- **Groups**: one section per shelf. Each shelf shows its books in the
|
||||
existing row format, minus the status pill.
|
||||
- **Empty shelves**: shown as a group with the shelf name and "(empty)"
|
||||
subtext. No expandable books.
|
||||
- **Minibuffer** (when a book is selected): simplified to two actions —
|
||||
**Add note**, **Set rating**. Same for every book.
|
||||
|
||||
### Add form (`add.mustache`)
|
||||
|
||||
- "Category" free-text field replaced with "Shelf" `<select>` dropdown
|
||||
populated from `spine-shelves`.
|
||||
- Required. No datalist — only existing shelves.
|
||||
- All other fields unchanged.
|
||||
|
||||
## Sample file
|
||||
|
||||
`sample-books.org` is restructured to the new shelf layout with
|
||||
shelves like "Fiction", "Non-Fiction", "Science Fiction".
|
||||
|
||||
## Test changes
|
||||
|
||||
- `spine-books-parses-sample`: loads restructured sample, asserts flat
|
||||
book list with `:shelf` keys.
|
||||
- `spine-books-first-book-has-all-fields`: adds `:shelf` assertion.
|
||||
- `spine-books-missing-properties-are-nil`: adds `:shelf` check.
|
||||
- `spine-index-model-*`: rewritten — tests shelf grouping, shelf
|
||||
filtering, empty-shelf inclusion. Status-based tests removed.
|
||||
- `spine-add-book-*`: tests require `:shelf` arg. Removes status check.
|
||||
Tests that books land under the correct shelf heading.
|
||||
- `spine-edit-*`: keeps note/rating tests. Removes status-change tests.
|
||||
Updates model-assertion tests for new minibuffer shape.
|
||||
- New `spine-shelves` tests: parses shelf names, includes empty shelves.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Parameterize spine.org Path via Environment Variable
|
||||
|
||||
## Problem
|
||||
|
||||
The org data file path (`spine-org-file`) is hardcoded to `~/spine/books.org` in
|
||||
`spine.el`. Users who want to point the app at a different org file must manually
|
||||
construct the full `emacs --eval` command line instead of using the `scripts/run`
|
||||
convenience script.
|
||||
|
||||
## Design
|
||||
|
||||
Add `SPINE_ORG` environment variable support to `scripts/run`. When set, the
|
||||
script passes the path to Emacs via `--eval` before loading `spine.el`, matching
|
||||
the existing pattern for `spine-host` and `spine-port`.
|
||||
|
||||
### Interface
|
||||
|
||||
```bash
|
||||
# Default (same as today): ~/spine/books.org
|
||||
./scripts/run
|
||||
|
||||
# Custom path, default port 8080
|
||||
SPINE_ORG=~/my-books.org ./scripts/run
|
||||
|
||||
# Custom path with host and port
|
||||
SPINE_ORG=$(pwd)/sample-books.org ./scripts/run localhost:9090
|
||||
```
|
||||
|
||||
### Affected files
|
||||
|
||||
- **`scripts/run`** — add SPINE_ORG env var check before launching Emacs
|
||||
- **`spine.el`** — no changes needed (already uses `spine-org-file` defvar)
|
||||
- **Tests** — no changes needed (already set their own `spine-org-file`)
|
||||
|
||||
### Implementation
|
||||
|
||||
In `scripts/run`, after the HOST/PORT argument parsing block (line 18), add:
|
||||
|
||||
```bash
|
||||
if [ -n "${SPINE_ORG:-}" ]; then
|
||||
EMACS_ARGS+=(--eval "(setq spine-org-file \"$SPINE_ORG\")")
|
||||
fi
|
||||
```
|
||||
|
||||
### Edge cases
|
||||
|
||||
- `SPINE_ORG` unset or empty → ignored, default in `spine.el` applies
|
||||
- Path with spaces → quoted inside the Emacs string, handled correctly
|
||||
- Relative vs absolute paths → user's responsibility; Emacs resolves relative to default-directory
|
||||
@@ -0,0 +1,95 @@
|
||||
#+TITLE: Spine
|
||||
|
||||
* Fiction
|
||||
** 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]
|
||||
:LAST_MODIFIED: [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]
|
||||
:LAST_MODIFIED: [2026-05-10]
|
||||
:ID: 7f3c-dun
|
||||
:END:
|
||||
|
||||
** WANT Piranesi :fiction:
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Susanna Clarke
|
||||
:ISBN:
|
||||
:COVER:
|
||||
:FORMAT:
|
||||
:REC_BY:
|
||||
:REC_NOTE:
|
||||
:RATING:
|
||||
:ADDED: [2026-06-01]
|
||||
:LAST_MODIFIED: [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]
|
||||
:LAST_MODIFIED: [2026-03-09]
|
||||
: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.
|
||||
|
||||
* Science Fiction
|
||||
** READ Ancillary Justice :scifi:
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Ann Leckie
|
||||
:ISBN: 978-0316246620
|
||||
:COVER: covers/ancillary.jpg
|
||||
:FORMAT: ebook
|
||||
:REC_BY:
|
||||
:REC_NOTE:
|
||||
:RATING: 5
|
||||
:ADDED: [2025-11-15]
|
||||
:LAST_MODIFIED: [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.
|
||||
|
||||
* Non-Fiction
|
||||
** The Checklist Manifesto
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Atul Gawande
|
||||
:ID: b2c1-tcm
|
||||
:ADDED: [2026-06-15]
|
||||
:LAST_MODIFIED: [2026-06-15]
|
||||
:END:
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Stop spine if running; silently succeed if not
|
||||
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null || true
|
||||
|
||||
# Start spine with any passed arguments
|
||||
exec "$SCRIPT_DIR/run" "$@"
|
||||
+5
-6
@@ -15,14 +15,13 @@ if [ $# -ge 1 ] && [ -n "$1" ]; then
|
||||
else
|
||||
PORT="$ARG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Stop any existing spine daemon
|
||||
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null || true
|
||||
|
||||
# Build emacs args
|
||||
EMACS_ARGS=(emacs --quick --daemon=spine)
|
||||
|
||||
# If SPINE_ORG env var is set, override the org file path
|
||||
if [ -n "${SPINE_ORG:-}" ]; then
|
||||
EMACS_ARGS+=(--eval "(setq spine-org-file \"$SPINE_ORG\")")
|
||||
fi
|
||||
|
||||
if [ -n "$HOST" ]; then
|
||||
EMACS_ARGS+=(--eval "(setq spine-host \"$HOST\")")
|
||||
fi
|
||||
|
||||
@@ -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,653 @@ 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-add-form-model (books)
|
||||
"Build view model for templates/add.mustache from current BOOKS."
|
||||
(ht ("app_title" "spine")
|
||||
("shelves" (or (spine-shelves) '()))))
|
||||
|
||||
(defconst spine--format-defs
|
||||
'(("hardcover" "ti-book" "Hardcover")
|
||||
("ebook" "ti-device-tablet" "eBook")
|
||||
("audiobook" "ti-headphones" "Audiobook"))
|
||||
"Alist of (format-key icon label) for book format display.")
|
||||
|
||||
(defconst spine--status-defs
|
||||
'(("want" "Want") ("reading" "Reading") ("read" "Read"))
|
||||
"Alist of (key label) for reading status options.")
|
||||
|
||||
(defun spine--format-date (org-date-str &optional include-year)
|
||||
"Format Org date \"[2026-02-20]\" to \"20 Feb\".
|
||||
With optional INCLUDE-YEAR, returns \"20 Feb 2026\"."
|
||||
(when (and org-date-str (> (length org-date-str) 0))
|
||||
(let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str)))
|
||||
(condition-case nil
|
||||
(format-time-string (if include-year "%e %b %Y" "%e %b") (date-to-time clean))
|
||||
(error org-date-str)))))
|
||||
|
||||
(defun spine--parse-title-status (title)
|
||||
"Return (CLEAN-TITLE STATUS-CLASS STATUS-LABEL) from a book TITLE.
|
||||
TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"."
|
||||
(let ((case-fold-search nil))
|
||||
(if (string-match
|
||||
"\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'"
|
||||
title)
|
||||
(let ((kw (match-string 1 title))
|
||||
(rest (match-string 2 title)))
|
||||
(list rest (downcase kw) (capitalize (downcase kw))))
|
||||
(list title "" ""))))
|
||||
|
||||
(defun spine-book-model (book)
|
||||
"Build view model `ht` for a single BOOK plist, for templates/book.mustache."
|
||||
(let* ((id (plist-get book :id))
|
||||
(raw-title (or (plist-get book :title) ""))
|
||||
(author (or (plist-get book :author) ""))
|
||||
(fmt (plist-get book :format))
|
||||
(added (plist-get book :added))
|
||||
(notes (plist-get book :notes))
|
||||
(rec-by (plist-get book :rec_by))
|
||||
(rec-note (plist-get book :rec_note))
|
||||
(tags (plist-get book :tags))
|
||||
(rating (plist-get book :rating))
|
||||
(shelf-name (plist-get book :shelf))
|
||||
(title-info (spine--parse-title-status raw-title))
|
||||
(clean-title (nth 0 title-info))
|
||||
(status-class (nth 1 title-info))
|
||||
(status-label (nth 2 title-info))
|
||||
(format-def (and fmt (assoc fmt spine--format-defs)))
|
||||
(format-icon (if format-def (nth 1 format-def) "ti-book"))
|
||||
(format-label (if format-def (nth 2 format-def) ""))
|
||||
(rating-display
|
||||
(cond
|
||||
((and rating (> (length rating) 0))
|
||||
(make-string (string-to-number rating) ?\u2605))
|
||||
((equal status-class "read") "rated —")
|
||||
(t "rate when finished")))
|
||||
(has-rating (and rating (> (length rating) 0)))
|
||||
(notes-model
|
||||
(mapcar (lambda (n)
|
||||
(ht ("date" (car n))
|
||||
("text" (cadr n))
|
||||
("edit_url" (format "/edit?id=%s&action=note&id=%s" id (car n)))))
|
||||
notes))
|
||||
(recommendation
|
||||
(when (and rec-by (> (length rec-by) 0))
|
||||
(let ((initials
|
||||
(mapconcat
|
||||
(lambda (s)
|
||||
(when (> (length s) 0)
|
||||
(upcase (substring s 0 1))))
|
||||
(split-string rec-by " +" t) "")))
|
||||
(ht ("initials" initials)
|
||||
("by" rec-by)
|
||||
("note" (or rec-note ""))))))
|
||||
(status-options
|
||||
(mapcar (lambda (sd)
|
||||
(ht ("name" (cadr sd))
|
||||
("selected" (equal (car sd) status-class))))
|
||||
spine--status-defs))
|
||||
(shelf-options
|
||||
(let ((shelves (or (spine-shelves) '())))
|
||||
(mapcar (lambda (s)
|
||||
(ht ("name" s)
|
||||
("selected" (and shelf-name (equal s shelf-name)))))
|
||||
shelves))))
|
||||
(ht ("cover_icon" "ti-book")
|
||||
("id" id)
|
||||
("title" clean-title)
|
||||
("author" author)
|
||||
("status_class" status-class)
|
||||
("status_label" status-label)
|
||||
("shelf_name" (or shelf-name ""))
|
||||
("format_icon" format-icon)
|
||||
("format_label" format-label)
|
||||
("tags_display"
|
||||
(if tags (mapconcat (lambda (tag) (format "<span class=\"tag\">%s</span>" tag)) tags " ") ""))
|
||||
("added_display" (if added (spine--format-date added t) ""))
|
||||
("started_display" (if added (spine--format-date added t) ""))
|
||||
("finished_display" "—")
|
||||
("rating_display" rating-display)
|
||||
("has_rating" has-rating)
|
||||
("status_options" status-options)
|
||||
("shelf_options" shelf-options)
|
||||
("notes" notes-model)
|
||||
("recommendation" recommendation)
|
||||
("back_url" (format "/index?id=%s" id)))))
|
||||
|
||||
(defun spine-index-model (books &optional shelf-filter selected-id)
|
||||
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||
SHELF-FILTER limits to one shelf by name (string). nil = all shelves.
|
||||
SELECTED-ID expands the matching book's detail section."
|
||||
(let* ((grouped (make-hash-table :test 'equal))
|
||||
(all-shelves (or (spine-shelves) '()))
|
||||
(total (length books)))
|
||||
;; Group books by shelf
|
||||
(dolist (book books)
|
||||
(let ((shelf (or (plist-get book :shelf) "Uncategorized")))
|
||||
(push book (gethash shelf grouped))))
|
||||
(let ((groups nil)
|
||||
(shelf-nav nil))
|
||||
;; Build shelf nav from all shelves (including empty)
|
||||
(dolist (shelf all-shelves)
|
||||
(push (ht ("name" shelf)
|
||||
("href" (format "/index?shelf=%s" shelf))
|
||||
("current" (equal shelf shelf-filter)))
|
||||
shelf-nav))
|
||||
(setq shelf-nav (nreverse shelf-nav))
|
||||
;; Build shelf groups
|
||||
(dolist (shelf all-shelves)
|
||||
(let ((shelf-books (nreverse (gethash shelf grouped))))
|
||||
(when (or (null shelf-filter) (equal shelf shelf-filter))
|
||||
(let ((book-models nil))
|
||||
(dolist (book shelf-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))
|
||||
(model
|
||||
(ht
|
||||
("format_icon"
|
||||
(or (nth 1 (assoc fmt spine--format-defs)) ""))
|
||||
("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)
|
||||
("book_url" (format "/book?id=%s" id))
|
||||
("detail"
|
||||
(when selected
|
||||
(ht
|
||||
("meta"
|
||||
(let ((added (plist-get book :added))
|
||||
(fmt (plist-get book :format)))
|
||||
(cond
|
||||
((and added fmt)
|
||||
(format "started %s \302\267 %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" shelf)
|
||||
("count" (length shelf-books))
|
||||
("books" (nreverse book-models)))
|
||||
groups)))))
|
||||
(ht ("app_title" "spine")
|
||||
("total_books" total)
|
||||
("shelf_count" (length all-shelves))
|
||||
("current_shelf" shelf-filter)
|
||||
("shelf_nav" shelf-nav)
|
||||
("groups" (nreverse groups))
|
||||
("minibuffer"
|
||||
(when selected-id
|
||||
(let* ((books-by-id (make-hash-table :test 'equal)))
|
||||
(dolist (b books)
|
||||
(puthash (plist-get b :id) b books-by-id))
|
||||
(let ((book (gethash selected-id books-by-id)))
|
||||
(when book
|
||||
(ht ("actions"
|
||||
(list
|
||||
(ht ("command" "note") ("label" "Add note")
|
||||
("href" (format "/edit?id=%s&action=note" selected-id)))
|
||||
(ht ("command" "rating") ("label" "Set rating")
|
||||
("href" (format "/edit?id=%s&action=rating" selected-id)))))))))))
|
||||
))))
|
||||
(defun spine-add-book (&rest args)
|
||||
"Add a new book to shelf `:shelf' in `spine-org-file'.
|
||||
Keyword arguments: :title :author :shelf :format :isbn :cover
|
||||
:rec_by :rec_note :date_added :initial_note
|
||||
:shelf is required. Signals an error if the shelf doesn't exist."
|
||||
(let ((title (plist-get args :title))
|
||||
(shelf (plist-get args :shelf))
|
||||
(author (plist-get args :author))
|
||||
(format (plist-get args :format))
|
||||
(isbn (plist-get args :isbn))
|
||||
(cover (plist-get args :cover))
|
||||
(rec-by (plist-get args :rec_by))
|
||||
(rec-note (plist-get args :rec_note))
|
||||
(date-added (plist-get args :date_added))
|
||||
(initial-note (plist-get args :initial_note)))
|
||||
(unless title
|
||||
(error "spine-add-book: title is required"))
|
||||
(unless shelf
|
||||
(error "spine-add-book: :shelf is required"))
|
||||
(let ((org-file (expand-file-name spine-org-file)))
|
||||
(unless (file-exists-p org-file)
|
||||
(error "spine-add-book: file %s does not exist; create shelves first"
|
||||
org-file))
|
||||
(with-current-buffer (find-file-noselect org-file)
|
||||
(unwind-protect
|
||||
(progn
|
||||
;; Find the shelf headline
|
||||
(goto-char (point-min))
|
||||
(let ((shelf-found nil))
|
||||
(while (and (not shelf-found)
|
||||
(re-search-forward
|
||||
(format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote shelf))
|
||||
nil t))
|
||||
(when (= (org-current-level) 1)
|
||||
(setq shelf-found t)))
|
||||
(unless shelf-found
|
||||
(error "spine-add-book: shelf \"%s\" not found" shelf)))
|
||||
;; Go to end of shelf section
|
||||
(org-end-of-subtree)
|
||||
;; Insert new book headline at level 2
|
||||
(org-insert-heading nil t)
|
||||
(org-demote-subtree)
|
||||
(insert title)
|
||||
;; Set properties (only non-empty)
|
||||
(when (and author (> (length author) 0))
|
||||
(org-set-property "AUTHOR" author))
|
||||
(when (and format (> (length format) 0))
|
||||
(org-set-property "FORMAT" format))
|
||||
(when (and isbn (> (length isbn) 0))
|
||||
(org-set-property "ISBN" isbn))
|
||||
(when (and cover (> (length cover) 0))
|
||||
(org-set-property "COVER" cover))
|
||||
(when (and rec-by (> (length rec-by) 0))
|
||||
(org-set-property "REC_BY" rec-by))
|
||||
(when (and rec-note (> (length rec-note) 0))
|
||||
(org-set-property "REC_NOTE" rec-note))
|
||||
(when (and date-added (> (length date-added) 0))
|
||||
(org-set-property "ADDED" (format "[%s]" date-added)))
|
||||
;; Set ID
|
||||
(org-set-property "ID"
|
||||
(format "%04x-%s"
|
||||
(random 65535)
|
||||
(substring (sha1 (concat title (number-to-string (random)))) 0 4)))
|
||||
;; Set last modified
|
||||
(org-set-property "LAST_MODIFIED" (format-time-string "[%Y-%m-%d]"))
|
||||
;; Add initial note
|
||||
(when (and initial-note (> (length initial-note) 0))
|
||||
(let ((date (if (and date-added (> (length date-added) 0))
|
||||
date-added
|
||||
(format-time-string "%Y-%m-%d"))))
|
||||
(goto-char (org-entry-end-position))
|
||||
(insert (format "\n- [%s] %s\n" date initial-note))))
|
||||
(save-buffer)
|
||||
t)
|
||||
(kill-buffer))))))
|
||||
|
||||
|
||||
(defun spine-add-note (id text &optional date)
|
||||
"Append a reading note to the book with ID.
|
||||
TEXT is the note content. DATE is an optional YYYY-MM-DD string
|
||||
\(defaults to today). Signals an error if the book is not found."
|
||||
(let ((org-file (expand-file-name spine-org-file))
|
||||
(date-str (or date (format-time-string "%Y-%m-%d"))))
|
||||
(unless (file-exists-p org-file)
|
||||
(error "spine-add-note: file not found: %s" org-file))
|
||||
(with-current-buffer (find-file-noselect org-file)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||
(lambda (hl)
|
||||
(when (= (org-element-property :level hl) 2)
|
||||
(let ((hl-id (spine--prop (org-element-property :begin hl) "ID")))
|
||||
(when (equal id hl-id)
|
||||
(goto-char (org-element-property :end hl))
|
||||
(skip-chars-backward " \t\n")
|
||||
(insert (format "\n- [%s] %s\n" date-str text))))))
|
||||
nil 'headline)
|
||||
(unless (eq (point) (point-min))
|
||||
(save-buffer)
|
||||
t)
|
||||
(when (eq (point) (point-min))
|
||||
(error "spine-add-note: no book found with ID %s" id)))
|
||||
(kill-buffer)))))
|
||||
|
||||
(defun spine-set-rating (id rating)
|
||||
"Set the RATING property of book with ID to RATING (1-5 string).
|
||||
Signals an error if the book is not found."
|
||||
(let ((org-file (expand-file-name spine-org-file)))
|
||||
(unless (file-exists-p org-file)
|
||||
(error "spine-set-rating: file not found: %s" org-file))
|
||||
(with-current-buffer (find-file-noselect org-file)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||
(lambda (hl)
|
||||
(when (= (org-element-property :level hl) 2)
|
||||
(let ((pos (org-element-property :begin hl)))
|
||||
(when (equal id (spine--prop pos "ID"))
|
||||
(goto-char pos)
|
||||
(org-set-property "RATING" rating)))))
|
||||
nil 'headline)
|
||||
(unless (eq (point) (point-min))
|
||||
(save-buffer)
|
||||
t)
|
||||
(when (eq (point) (point-min))
|
||||
(error "spine-set-rating: no book found with ID %s" id)))
|
||||
(kill-buffer)))))
|
||||
|
||||
(defun spine-move-book (id target-shelf)
|
||||
"Move book with ID to TARGET-SHELF.
|
||||
The book's level-2 headline is moved under the level-1 TARGET-SHELF headline.
|
||||
Signals an error if the book or target shelf is not found."
|
||||
(let ((org-file (expand-file-name spine-org-file))
|
||||
(shelf-re (format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote target-shelf))))
|
||||
(unless (file-exists-p org-file)
|
||||
(error "spine-move-book: file not found: %s" org-file))
|
||||
(with-current-buffer (find-file-noselect org-file)
|
||||
(unwind-protect
|
||||
(let (book-begin book-end book-text)
|
||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||
(lambda (hl)
|
||||
(when (= (org-element-property :level hl) 2)
|
||||
(let ((hl-id (spine--prop (org-element-property :begin hl) "ID")))
|
||||
(when (equal id hl-id)
|
||||
(setq book-begin (org-element-property :begin hl))
|
||||
(setq book-end (org-element-property :end hl))))))
|
||||
nil 'headline)
|
||||
(unless book-begin
|
||||
(error "spine-move-book: no book found with ID %s" id))
|
||||
(setq book-text (buffer-substring-no-properties book-begin book-end))
|
||||
(delete-region book-begin book-end)
|
||||
;; Re-find target shelf (positions shifted after deletion)
|
||||
(goto-char (point-min))
|
||||
(let ((shelf-found nil))
|
||||
(while (and (not shelf-found)
|
||||
(re-search-forward shelf-re nil t))
|
||||
(when (= (org-current-level) 1)
|
||||
(setq shelf-found t)))
|
||||
(unless shelf-found
|
||||
(error "spine-move-book: shelf \"%s\" not found" target-shelf))
|
||||
(org-end-of-subtree)
|
||||
(skip-chars-backward " \t\n")
|
||||
(insert "\n")
|
||||
(insert book-text)
|
||||
(unless (bolp) (insert "\n"))
|
||||
(save-buffer)
|
||||
t))
|
||||
(kill-buffer)))))
|
||||
|
||||
(defun spine-set-status (id new-status)
|
||||
"Change the TODO keyword of book with ID to NEW-STATUS.
|
||||
NEW-STATUS is one of \"want\", \"reading\", \"read\", or \"\" to remove.
|
||||
Signals an error if the book is not found."
|
||||
(let ((org-file (expand-file-name spine-org-file))
|
||||
(new-kw (car (rassoc new-status
|
||||
'(("WANT" . "want") ("READING" . "reading") ("READ" . "read"))))))
|
||||
(unless (file-exists-p org-file)
|
||||
(error "spine-set-status: file not found: %s" org-file))
|
||||
(with-current-buffer (find-file-noselect org-file)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||
(lambda (hl)
|
||||
(when (= (org-element-property :level hl) 2)
|
||||
(let ((pos (org-element-property :begin hl)))
|
||||
(when (equal id (spine--prop pos "ID"))
|
||||
(goto-char pos)
|
||||
(let* ((raw (org-get-heading t t t t))
|
||||
(clean (if (string-match
|
||||
"\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'"
|
||||
raw)
|
||||
(match-string 2 raw)
|
||||
raw)))
|
||||
(org-edit-headline
|
||||
(if new-kw (format "%s %s" new-kw clean) clean)))))))
|
||||
nil 'headline)
|
||||
(unless (eq (point) (point-min))
|
||||
(save-buffer)
|
||||
t)
|
||||
(when (eq (point) (point-min))
|
||||
(error "spine-set-status: no book found with ID %s" id)))
|
||||
(kill-buffer)))))
|
||||
(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-shelves ()
|
||||
"Return a list of shelf names (strings), one per level-1 headline.
|
||||
Includes empty shelves (shelves with no books)."
|
||||
(if (not (file-exists-p spine-org-file))
|
||||
(progn
|
||||
(message "spine-shelves: file not found: %s" spine-org-file)
|
||||
nil)
|
||||
(condition-case err
|
||||
(with-temp-buffer
|
||||
(insert-file-contents spine-org-file)
|
||||
(org-mode)
|
||||
(let ((shelves nil))
|
||||
(org-element-map (org-element-parse-buffer 'headline) 'headline
|
||||
(lambda (hl)
|
||||
(when (= (org-element-property :level hl) 1)
|
||||
(push (org-element-property :raw-value hl) shelves))))
|
||||
(nreverse shelves)))
|
||||
(error
|
||||
(message "spine-shelves: failed to parse %s: %s"
|
||||
spine-org-file (error-message-string err))
|
||||
nil))))
|
||||
|
||||
(defun spine-books ()
|
||||
"Return a list of plists, one per book in `spine-org-file'.
|
||||
Books are level-2 headlines nested under level-1 shelf headlines.
|
||||
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)
|
||||
(let ((level (org-element-property :level hl)))
|
||||
(when (= level 2)
|
||||
(let* ((pos (org-element-property :begin hl))
|
||||
(parent (org-element-property :parent hl))
|
||||
(shelf (and parent
|
||||
(= (org-element-property :level parent) 1)
|
||||
(org-element-property :raw-value parent))))
|
||||
(when shelf
|
||||
(push (list :id (spine--prop pos "ID")
|
||||
:title (org-element-property :title 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")
|
||||
:last_modified (spine--prop pos "LAST_MODIFIED")
|
||||
:tags (org-element-property :tags hl)
|
||||
:notes (spine--extract-notes
|
||||
(org-element-property :begin hl)
|
||||
(org-element-property :end hl))
|
||||
:shelf shelf)
|
||||
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))
|
||||
(shelf-filter (cadr (assoc "shelf" query))))
|
||||
(if books
|
||||
(insert (spine-render "index.mustache"
|
||||
(spine-index-model books shelf-filter (cadr (assoc "id" query)))))
|
||||
(insert (spine-render-empty-state)))))
|
||||
|
||||
(defun httpd/add (proc uri-path query request)
|
||||
"Handle /add: GET shows form, POST creates a book."
|
||||
(let ((method (caar request)))
|
||||
(if (equal method "POST")
|
||||
(progn
|
||||
(spine-add-book
|
||||
:title (cadr (assoc "title" query))
|
||||
:author (cadr (assoc "author" query))
|
||||
:shelf (cadr (assoc "shelf" query))
|
||||
:format (cadr (assoc "format" query))
|
||||
:isbn (cadr (assoc "isbn" query))
|
||||
:cover (cadr (assoc "cover" query))
|
||||
:rec_by (cadr (assoc "rec_by" query))
|
||||
:rec_note (cadr (assoc "rec_note" query))
|
||||
:date_added (cadr (assoc "date_added" query))
|
||||
:initial_note (cadr (assoc "initial_note" query)))
|
||||
(httpd-redirect proc "/index" 303))
|
||||
(httpd-with-buffer proc "text/html"
|
||||
(let* ((books (spine-books))
|
||||
(model (if books
|
||||
(spine-add-form-model books)
|
||||
(ht ("app_title" "spine" "shelves" '())))))
|
||||
(insert (spine-render "add.mustache" model)))))))
|
||||
|
||||
|
||||
(defun httpd/edit (proc uri-path query request)
|
||||
"Handle /edit: POST processes actions; GET shows prompt pages.
|
||||
Query params:
|
||||
id - book ID
|
||||
action - note | rating | shelf | status
|
||||
text - note text (for note action)
|
||||
date - optional date string (for note action)
|
||||
value - rating value / shelf name / status key (for rating/shelf/status)"
|
||||
(let ((method (caar request))
|
||||
(id (cadr (assoc "id" query)))
|
||||
(action (cadr (assoc "action" query)))
|
||||
(value (cadr (assoc "value" query)))
|
||||
(text (cadr (assoc "text" query)))
|
||||
(date (cadr (assoc "date" query))))
|
||||
(if (equal method "POST")
|
||||
(progn
|
||||
(cond
|
||||
((equal action "note")
|
||||
(spine-add-note id text date))
|
||||
((equal action "rating")
|
||||
(spine-set-rating id value))
|
||||
((equal action "shelf")
|
||||
(spine-move-book id value))
|
||||
((equal action "status")
|
||||
(spine-set-status id value)))
|
||||
(httpd-redirect proc (format "/book?id=%s" id) 303))
|
||||
;; GET: show prompt page for actions needing input
|
||||
(if (and id (equal action "note"))
|
||||
(let* ((books (spine-books))
|
||||
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))
|
||||
(title (if book (plist-get book :title) "Unknown")))
|
||||
(httpd-with-buffer proc "text/html"
|
||||
(insert (format
|
||||
"<!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 — note</title>
|
||||
<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css\"/>
|
||||
</head>
|
||||
<body>
|
||||
<main class=\"container\">
|
||||
<article style=\"max-width:560px;margin-inline:auto;\">
|
||||
<header><strong>spine · add note</strong></header>
|
||||
<p><em>%s</em></p>
|
||||
<form method=\"post\" action=\"/edit\">
|
||||
<input type=\"hidden\" name=\"id\" value=\"%s\"/>
|
||||
<input type=\"hidden\" name=\"action\" value=\"note\"/>
|
||||
<label>Date <input type=\"date\" name=\"date\" value=\"%s\"/></label>
|
||||
<label>Note <textarea name=\"text\" rows=\"3\" required autofocus></textarea></label>
|
||||
<button type=\"submit\">Add note</button>
|
||||
</form>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>"
|
||||
title id (format-time-string "%Y-%m-%d"))))))
|
||||
;; Unknown action or missing id: redirect to index
|
||||
(httpd-redirect proc "/index" 302))))
|
||||
|
||||
(defun httpd/book (proc uri-path query request)
|
||||
"Show the journal-style detail page for a single book."
|
||||
(let* ((id (cadr (assoc "id" query)))
|
||||
(books (spine-books))
|
||||
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(if book
|
||||
(httpd-with-buffer proc "text/html"
|
||||
(insert (spine-render "book.mustache" (spine-book-model book))))
|
||||
(httpd-redirect proc "/index" 302))))
|
||||
(defun httpd/ (proc uri-path query request)
|
||||
"Redirect root to /index."
|
||||
(httpd-redirect proc "/index" 302))
|
||||
|
||||
;; --- 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)
|
||||
(or spine-host "localhost") spine-port))
|
||||
|
||||
(provide 'spine)
|
||||
;;; spine.el ends here
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<!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 — add book</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: 560px; 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; }
|
||||
.form-body { padding:14px; font-size:13px; }
|
||||
.form-body label { display:block; margin-bottom:6px; }
|
||||
.form-body input, .form-body select, .form-body textarea {
|
||||
font-family: var(--mono); font-size:13px; width:100%; padding:6px 8px;
|
||||
border:0.5px solid var(--border-secondary); border-radius:var(--radius-md);
|
||||
background:var(--bg-page); color:var(--text-primary); margin-bottom:12px;
|
||||
}
|
||||
.form-body button { font-family: var(--mono); font-size:13px; padding:6px 14px;
|
||||
border:0.5px solid var(--border-info); border-radius:var(--radius-md);
|
||||
background:var(--bg-info); color:var(--text-info); cursor:pointer; }
|
||||
.form-body button:hover { background:var(--border-info); color:white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="frame">
|
||||
<div class="titlebar">
|
||||
<span style="font-weight:500;">spine · add book</span>
|
||||
</div>
|
||||
<div class="form-body">
|
||||
<form method="post" action="/add">
|
||||
<label>Date added <input type="date" name="date_added" /></label>
|
||||
<label>Title * <input type="text" name="title" required autofocus /></label>
|
||||
<label>Author <input type="text" name="author" /></label>
|
||||
<label>Shelf * <select name="shelf" required>
|
||||
<option value="">— select —</option>
|
||||
{{#shelves}}<option value="{{.}}">{{.}}</option>{{/shelves}}
|
||||
</select></label>
|
||||
<label>Format <select name="format"><option value="">—</option><option value="hardcover">Hardcover</option><option value="ebook">eBook</option><option value="audiobook">Audiobook</option></select></label>
|
||||
<label>ISBN <input type="text" name="isbn" /></label>
|
||||
<label>Cover (path) <input type="text" name="cover" placeholder="covers/filename.jpg" /></label>
|
||||
<label>Recommended by <input type="text" name="rec_by" /></label>
|
||||
<label>Why it's on your radar <input type="text" name="rec_note" /></label>
|
||||
<label>Initial note <textarea name="initial_note" rows="3" placeholder="First impressions…"></textarea></label>
|
||||
<button type="submit">Add book</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,167 @@
|
||||
<!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 — {{title}}</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;
|
||||
--text-danger: #c62828;
|
||||
--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;
|
||||
--bg-success: #103a30; --text-success: #9fe1cb;
|
||||
--text-danger: #ef9a9a;
|
||||
--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: 600px; 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: 48px; height: 66px; border-radius: var(--radius-md); background: var(--bg-info);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.pill { padding: 2px 9px; border-radius: var(--radius-md); font-size: 12px; }
|
||||
.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); }
|
||||
.tag { background: var(--bg-secondary); padding: 1px 7px; border-radius: var(--radius-md); margin-left: 4px; font-size: 12px; }
|
||||
.tt { font-size: 12px; color: var(--text-tertiary); }
|
||||
input, select, button { font-family: var(--sans); font-size: 13px; }
|
||||
input, select { height: 34px; padding: 0 8px; border: 0.5px solid var(--border-secondary);
|
||||
border-radius: var(--radius-md); background: var(--bg-primary); color: var(--text-primary); width: 100%; }
|
||||
input::placeholder { color: var(--text-tertiary); }
|
||||
button { height: auto; padding: 2px 4px; border: none; background: transparent; color: var(--text-secondary); cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="surface">
|
||||
<div class="card">
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-tertiary); margin-bottom: 14px;">
|
||||
<a href="{{back_url}}" style="color: var(--text-tertiary); text-decoration: none; display: flex; align-items: center; gap: 8px;">
|
||||
<i class="ti ti-arrow-left" style="font-size: 16px;" aria-hidden="true"></i>
|
||||
<i class="ti ti-folder" style="font-size: 15px;" aria-hidden="true"></i>
|
||||
<span>{{shelf_name}}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 18px;">
|
||||
<div class="cover"><i class="ti {{cover_icon}}" style="font-size: 24px; color: var(--text-info);" aria-hidden="true"></i></div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p style="font-weight: 500; font-size: 18px; margin: 0 0 2px;">{{title}}</p>
|
||||
<p style="font-size: 14px; color: var(--text-secondary); margin: 0 0 8px;">{{author}}</p>
|
||||
{{#status_class}}
|
||||
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||
{{/status_class}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 18px;">
|
||||
<form method="post" action="/edit">
|
||||
<input type="hidden" name="id" value="{{id}}">
|
||||
<input type="hidden" name="action" value="shelf">
|
||||
<label style="font-size: 13px; color: var(--text-secondary); display: block; margin-bottom: 4px;">
|
||||
<i class="ti ti-folder" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Shelf
|
||||
</label>
|
||||
<select name="value" onchange="this.form.submit()">
|
||||
{{#shelf_options}}
|
||||
<option{{#selected}} selected{{/selected}}>{{name}}</option>
|
||||
{{/shelf_options}}
|
||||
</select>
|
||||
</form>
|
||||
<form method="post" action="/edit">
|
||||
<input type="hidden" name="id" value="{{id}}">
|
||||
<input type="hidden" name="action" value="status">
|
||||
<label style="font-size: 13px; color: var(--text-secondary); display: block; margin-bottom: 4px;">
|
||||
<i class="ti ti-bookmark" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Status
|
||||
</label>
|
||||
<select name="value" onchange="this.form.submit()">
|
||||
{{#status_options}}
|
||||
<option{{#selected}} selected{{/selected}}>{{name}}</option>
|
||||
{{/status_options}}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 16px;">
|
||||
<table style="width: 100%; font-size: 13px; border-collapse: collapse;">
|
||||
{{#format_label}}
|
||||
<tr><td style="color: var(--text-secondary); padding: 4px 0; width: 38%;">Format</td>
|
||||
<td style="text-align: right; padding: 4px 0;"><i class="ti {{format_icon}}" style="font-size: 15px; vertical-align: -2px; margin-right: 5px; color: var(--text-tertiary);" aria-hidden="true"></i>{{format_label}}</td></tr>
|
||||
{{/format_label}}
|
||||
{{#tags_display}}
|
||||
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Tags</td>
|
||||
<td style="text-align: right; padding: 4px 0;">{{{tags_display}}}</td></tr>
|
||||
{{/tags_display}}
|
||||
{{#added_display}}
|
||||
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Added</td>
|
||||
<td style="text-align: right; padding: 4px 0;">{{added_display}}</td></tr>
|
||||
{{/added_display}}
|
||||
{{#started_display}}
|
||||
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Started</td>
|
||||
<td style="text-align: right; padding: 4px 0;">{{started_display}}</td></tr>
|
||||
{{/started_display}}
|
||||
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Finished</td>
|
||||
<td style="text-align: right; padding: 4px 0;{{^finished_display}} color: var(--text-tertiary);{{/finished_display}}">{{finished_display}}</td></tr>
|
||||
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Rating</td>
|
||||
<td style="text-align: right; padding: 4px 0;{{^has_rating}} color: var(--text-tertiary);{{/has_rating}}">{{rating_display}}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{#notes}}
|
||||
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 16px;">
|
||||
<p style="font-size: 13px; color: var(--text-tertiary); margin: 0 0 10px;">Reading log</p>
|
||||
{{#notes}}
|
||||
<div style="display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px;">
|
||||
<span style="font-size: 12px; color: var(--text-tertiary); white-space: nowrap; padding-top: 1px;">{{date}}</span>
|
||||
<span style="flex: 1; font-size: 14px; line-height: 1.5;">{{text}}</span>
|
||||
</div>
|
||||
{{/notes}}
|
||||
<form method="post" action="/edit" style="display: flex; gap: 8px; margin-top: 14px;">
|
||||
<input type="hidden" name="id" value="{{id}}">
|
||||
<input type="hidden" name="action" value="note">
|
||||
<input type="text" name="text" placeholder="Add a note…" style="flex: 1;" required />
|
||||
<button type="submit" style="width: auto; padding: 0 12px; border: 0.5px solid var(--border-secondary); border-radius: var(--radius-md);"><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
|
||||
</form>
|
||||
</div>
|
||||
{{/notes}}
|
||||
|
||||
{{#recommendation}}
|
||||
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 4px;">
|
||||
<p style="font-size: 13px; color: var(--text-tertiary); margin: 0 0 10px;">On your radar</p>
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
||||
<div style="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;">{{initials}}</div>
|
||||
<div style="font-size: 14px; line-height: 1.5;"><span style="font-weight: 500;">{{by}}</span><br />"{{note}}"</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/recommendation}}
|
||||
|
||||
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 12px; margin-top: 14px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<button style="color: var(--text-danger); font-size: 13px;"><i class="ti ti-trash" style="font-size: 14px; vertical-align: -2px; margin-right: 3px;" aria-hidden="true"></i>Remove</button>
|
||||
<button style="color: var(--text-secondary); font-size: 13px;"><i class="ti ti-external-link" style="font-size: 14px; vertical-align: -2px; margin-right: 3px;" aria-hidden="true"></i>Open in Emacs</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!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 — 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; }
|
||||
.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; }
|
||||
.filter-bar { padding:8px 14px; font-size:11px; border-bottom:0.5px solid var(--border-tertiary); }
|
||||
.filter-bar a { color:var(--text-info); text-decoration:none; }
|
||||
.filter-bar a:hover { text-decoration:underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="frame">
|
||||
<div class="titlebar">
|
||||
<span style="font-weight:500;">spine</span>
|
||||
<span class="muted">{{total_books}} books · {{shelf_count}} shelves</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<a href="/index"{{^current_shelf}} style="font-weight:600"{{/current_shelf}}>All</a>
|
||||
{{#shelf_nav}}
|
||||
·
|
||||
<a href="{{href}}"{{#current}} style="font-weight:600"{{/current}}>{{name}}</a>
|
||||
{{/shelf_nav}}
|
||||
</div>
|
||||
|
||||
{{#groups}}
|
||||
<div class="group">{{label}} · {{count}}</div>
|
||||
{{#books}}
|
||||
<div class="row{{#selected}} selected{{/selected}}">
|
||||
<i class="ti {{format_icon}} glyph" aria-hidden="true"></i>
|
||||
<a href="{{book_url}}" class="title" style="text-decoration:none;color:inherit">{{title}}</a>
|
||||
<span class="muted">· {{author}}</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:8px;">{{meta}}</div>
|
||||
{{#notes}}
|
||||
<div class="logline"><span class="logdate">{{date}}</span><span>{{text}}</span></div>
|
||||
{{/notes}}
|
||||
</div>
|
||||
{{/detail}}
|
||||
{{/books}}
|
||||
{{/groups}}
|
||||
|
||||
{{#minibuffer}}
|
||||
<div class="minibuffer">
|
||||
<span class="muted">M-x</span>
|
||||
<span style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
{{#actions}}
|
||||
<a href="{{href}}" class="pill" style="text-decoration:none;cursor:pointer">{{label}}</a>
|
||||
{{/actions}}
|
||||
</span>
|
||||
</div>
|
||||
{{/minibuffer}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
;;; spine-add-book-test.el — ERT tests for spine-add-book
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
(setq spine-skip-server-start t)
|
||||
(setq spine-org-file (expand-file-name "test-add-temp.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)))
|
||||
|
||||
(defun spine-add-book-test--cleanup ()
|
||||
"Remove the test Org file if it exists."
|
||||
(ignore-errors (delete-file spine-org-file)))
|
||||
|
||||
(ert-deftest spine-add-book-creates-headline ()
|
||||
"spine-add-book creates a new book under the given shelf."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(with-temp-file spine-org-file
|
||||
(insert "* Fiction\n\n"))
|
||||
(spine-add-book :title "Test Book" :author "Author Name" :shelf "Fiction")
|
||||
(let ((books (spine-books)))
|
||||
(should books)
|
||||
(should (= (length books) 1))
|
||||
(let ((book (car books)))
|
||||
(should (equal (plist-get book :title) "Test Book"))
|
||||
(should (equal (plist-get book :author) "Author Name"))
|
||||
(should (equal (plist-get book :shelf) "Fiction"))
|
||||
(should (plist-get book :id)))))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-sets-all-fields ()
|
||||
"spine-add-book sets all provided fields."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(with-temp-file spine-org-file
|
||||
(insert "* Fiction\n\n"))
|
||||
(spine-add-book
|
||||
:title "Full Test"
|
||||
:author "Jane Doe"
|
||||
:format "hardcover"
|
||||
:isbn "978-1234567890"
|
||||
:cover "covers/test.jpg"
|
||||
:rec_by "Friend"
|
||||
:rec_note "You'll love this"
|
||||
:date_added "2026-06-21"
|
||||
:initial_note "Looking forward to this"
|
||||
:shelf "Fiction")
|
||||
(let* ((books (spine-books))
|
||||
(book (car books)))
|
||||
(should (equal (plist-get book :title) "Full Test"))
|
||||
(should (equal (plist-get book :author) "Jane Doe"))
|
||||
(should (equal (plist-get book :format) "hardcover"))
|
||||
(should (equal (plist-get book :isbn) "978-1234567890"))
|
||||
(should (equal (plist-get book :cover) "covers/test.jpg"))
|
||||
(should (equal (plist-get book :rec_by) "Friend"))
|
||||
(should (equal (plist-get book :rec_note) "You'll love this"))
|
||||
(should (equal (plist-get book :added) "[2026-06-21]"))
|
||||
(should (equal (plist-get book :notes)
|
||||
'(("2026-06-21" "Looking forward to this"))))))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-omits-empty-fields ()
|
||||
"spine-add-book does not set empty/omitted fields."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(with-temp-file spine-org-file
|
||||
(insert "* Fiction\n\n"))
|
||||
(spine-add-book :title "Minimal Book" :shelf "Fiction")
|
||||
(let* ((books (spine-books))
|
||||
(book (car books)))
|
||||
(should (equal (plist-get book :title) "Minimal Book"))
|
||||
(should (equal (plist-get book :shelf) "Fiction"))
|
||||
(should-not (plist-get book :author))
|
||||
(should-not (plist-get book :format))
|
||||
(should-not (plist-get book :isbn))
|
||||
(should-not (plist-get book :tags))))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-requires-title ()
|
||||
"spine-add-book signals error when title is missing."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(should-error (spine-add-book :author "No Title" :shelf "Fiction"))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-requires-shelf ()
|
||||
"spine-add-book signals error when shelf is missing."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(should-error (spine-add-book :title "No Shelf"))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-generates-unique-ids ()
|
||||
"spine-add-book generates different IDs for each book."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(with-temp-file spine-org-file
|
||||
(insert "* Fiction\n\n"))
|
||||
(spine-add-book :title "Book One" :shelf "Fiction")
|
||||
(spine-add-book :title "Book Two" :shelf "Fiction")
|
||||
(let* ((books (spine-books))
|
||||
(id1 (plist-get (nth 0 books) :id))
|
||||
(id2 (plist-get (nth 1 books) :id)))
|
||||
(should (not (equal id1 id2)))))
|
||||
(spine-add-book-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-book-creates-under-correct-shelf ()
|
||||
"spine-add-book places the book under the named shelf."
|
||||
(spine-add-book-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(with-temp-file spine-org-file
|
||||
(insert "* Fiction\n\n* Non-Fiction\n\n"))
|
||||
(spine-add-book :title "Sci-Fi Book" :shelf "Fiction")
|
||||
(spine-add-book :title "History Book" :shelf "Non-Fiction")
|
||||
(let ((books (spine-books)))
|
||||
(should (= (length books) 2))
|
||||
(should (equal (plist-get (nth 0 books) :shelf) "Fiction"))
|
||||
(should (equal (plist-get (nth 1 books) :shelf) "Non-Fiction"))))
|
||||
(spine-add-book-test--cleanup)))
|
||||
@@ -0,0 +1,200 @@
|
||||
;;; spine-book-model-test.el — ERT tests for spine-book-model
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
(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)))
|
||||
|
||||
(defconst spine-book-model-test--uow
|
||||
(cl-find "8c1e-uow" (spine-books)
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)
|
||||
"Use of Weapons fixture (READING, audiobook, tags=scifi+literary, rec_by=Priya).")
|
||||
|
||||
(defconst spine-book-model-test--aj
|
||||
(cl-find "3a1b-aj" (spine-books)
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)
|
||||
"Ancillary Justice fixture (READ, ebook, rating=5, tags=scifi).")
|
||||
|
||||
(defconst spine-book-model-test--pir
|
||||
(cl-find "5e8d-pir" (spine-books)
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)
|
||||
"Piranesi fixture (WANT, no format, no tags, no rec).")
|
||||
|
||||
(defconst spine-book-model-test--tcm
|
||||
(let* ((books (spine-books))
|
||||
(b (cl-find "b2c1-tcm" books
|
||||
:key (lambda (x) (plist-get x :id))
|
||||
:test #'equal)))
|
||||
b)
|
||||
"The Checklist Manifesto fixture (no TODO, no format, no rec).")
|
||||
|
||||
;; --- Title & status ----------------------------------------------------
|
||||
|
||||
(ert-deftest spine-book-model-title-strips-todo ()
|
||||
"Title has TODO prefix stripped."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "title") "Use of Weapons"))))
|
||||
|
||||
(ert-deftest spine-book-model-status-reading ()
|
||||
"READING status_class and label."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "status_class") "reading"))
|
||||
(should (equal (ht-get model "status_label") "Reading"))))
|
||||
|
||||
(ert-deftest spine-book-model-status-read ()
|
||||
"READ status_class and label."
|
||||
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||
(should (equal (ht-get model "status_class") "read"))
|
||||
(should (equal (ht-get model "status_label") "Read"))))
|
||||
|
||||
(ert-deftest spine-book-model-status-want ()
|
||||
"WANT status_class and label."
|
||||
(let ((model (spine-book-model spine-book-model-test--pir)))
|
||||
(should (equal (ht-get model "status_class") "want"))
|
||||
(should (equal (ht-get model "status_label") "Want"))))
|
||||
|
||||
(ert-deftest spine-book-model-no-todo-prefix ()
|
||||
"No TODO prefix shows title as-is and empty status."
|
||||
(let ((model (spine-book-model spine-book-model-test--tcm)))
|
||||
(should (equal (ht-get model "title") "The Checklist Manifesto"))
|
||||
(should (equal (ht-get model "status_class") ""))
|
||||
(should (equal (ht-get model "status_label") ""))))
|
||||
|
||||
;; --- Metadata fields ----------------------------------------------------
|
||||
|
||||
(ert-deftest spine-book-model-author ()
|
||||
"Author passed through."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "author") "Iain M. Banks"))))
|
||||
|
||||
(ert-deftest spine-book-model-shelf-name ()
|
||||
"Shelf_name passed through."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "shelf_name") "Fiction"))))
|
||||
|
||||
(ert-deftest spine-book-model-format-fields ()
|
||||
"Format icon and label match the book's format."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "format_icon") "ti-headphones"))
|
||||
(should (equal (ht-get model "format_label") "Audiobook"))))
|
||||
|
||||
(ert-deftest spine-book-model-format-none ()
|
||||
"No format yields empty label and default icon."
|
||||
(let ((model (spine-book-model spine-book-model-test--pir)))
|
||||
(should (equal (ht-get model "format_icon") "ti-book"))
|
||||
(should (equal (ht-get model "format_label") ""))))
|
||||
|
||||
(ert-deftest spine-book-model-tags-display ()
|
||||
"Tags rendered as HTML badge spans."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(let ((tags (ht-get model "tags_display")))
|
||||
(should (string-match "scifi" tags))
|
||||
(should (string-match "literary" tags)))))
|
||||
|
||||
(ert-deftest spine-book-model-no-tags ()
|
||||
"No tags yields empty string."
|
||||
(let ((model (spine-book-model spine-book-model-test--tcm)))
|
||||
(should (equal (ht-get model "tags_display") ""))))
|
||||
|
||||
(ert-deftest spine-book-model-started-display ()
|
||||
"started_display from ADDED property."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "started_display") "20 Feb 2026"))))
|
||||
|
||||
(ert-deftest spine-book-model-added-display ()
|
||||
"added_display from ADDED property."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "added_display") "20 Feb 2026"))))
|
||||
|
||||
(ert-deftest spine-book-model-finished-default ()
|
||||
"finished_display defaults to em-dash."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "finished_display") "—"))))
|
||||
|
||||
(ert-deftest spine-book-model-rating-display ()
|
||||
"Rating produces star characters."
|
||||
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||
(should (equal (ht-get model "rating_display") "★★★★★"))
|
||||
(should (ht-get model "has_rating"))))
|
||||
|
||||
(ert-deftest spine-book-model-rating-none ()
|
||||
"No rating shows placeholder text."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "rating_display") "rate when finished"))
|
||||
(should-not (ht-get model "has_rating"))))
|
||||
|
||||
;; --- Format/status/shelf options ---------------------------------------
|
||||
|
||||
(ert-deftest spine-book-model-status-options ()
|
||||
"status_options includes all statuses with one selected."
|
||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||
(opts (ht-get model "status_options")))
|
||||
(should (= (length opts) 3))
|
||||
(should (equal (ht-get (nth 0 opts) "name") "Want"))
|
||||
(should-not (ht-get (nth 0 opts) "selected"))
|
||||
(should (equal (ht-get (nth 1 opts) "name") "Reading"))
|
||||
(should (ht-get (nth 1 opts) "selected"))
|
||||
(should (equal (ht-get (nth 2 opts) "name") "Read"))
|
||||
(should-not (ht-get (nth 2 opts) "selected"))))
|
||||
|
||||
(ert-deftest spine-book-model-shelf-options ()
|
||||
"shelf_options lists all shelves with current one selected."
|
||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||
(opts (ht-get model "shelf_options")))
|
||||
(should (>= (length opts) 3))
|
||||
(let ((fiction (cl-find "Fiction" opts :key (lambda (o) (ht-get o "name")) :test #'equal)))
|
||||
(should fiction)
|
||||
(should (ht-get fiction "selected")))
|
||||
(let ((scifi (cl-find "Science Fiction" opts :key (lambda (o) (ht-get o "name")) :test #'equal)))
|
||||
(should scifi)
|
||||
(should-not (ht-get scifi "selected")))))
|
||||
|
||||
;; --- Notes -------------------------------------------------------------
|
||||
|
||||
(ert-deftest spine-book-model-notes ()
|
||||
"Notes mapped with date and text."
|
||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||
(notes (ht-get model "notes")))
|
||||
(should (= (length notes) 3))
|
||||
(should (equal (ht-get (nth 0 notes) "date") "2026-03-02"))
|
||||
(should (equal (ht-get (nth 0 notes) "text")
|
||||
"The two-track structure is doing something I can't name yet."))))
|
||||
|
||||
(ert-deftest spine-book-model-notes-empty ()
|
||||
"No notes yields empty array."
|
||||
(let* ((model (spine-book-model spine-book-model-test--tcm))
|
||||
(notes (ht-get model "notes")))
|
||||
(should (listp notes))
|
||||
(should (= (length notes) 0))))
|
||||
|
||||
;; --- Recommendation ----------------------------------------------------
|
||||
|
||||
(ert-deftest spine-book-model-recommendation ()
|
||||
"Recommendation includes initials, by, and note."
|
||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||
(rec (ht-get model "recommendation")))
|
||||
(should rec)
|
||||
(should (equal (ht-get rec "initials") "P"))
|
||||
(should (equal (ht-get rec "by") "Priya"))
|
||||
(should (string-match "Player of Games" (ht-get rec "note")))))
|
||||
|
||||
(ert-deftest spine-book-model-no-recommendation ()
|
||||
"No recommendation when rec_by absent."
|
||||
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||
(should-not (ht-get model "recommendation"))))
|
||||
|
||||
;; --- Navigation --------------------------------------------------------
|
||||
|
||||
(ert-deftest spine-book-model-back-url ()
|
||||
"back_url links to index with this book's id."
|
||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||
(should (equal (ht-get model "back_url") "/index?id=8c1e-uow"))))
|
||||
@@ -0,0 +1,96 @@
|
||||
;;; 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) 6))))
|
||||
|
||||
(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) "READING Use of Weapons"))
|
||||
(should (equal (plist-get uow :shelf) "Fiction"))
|
||||
(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) "WANT Piranesi"))
|
||||
(should (equal (plist-get pir :shelf) "Fiction"))
|
||||
(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 :shelf) "Science Fiction"))
|
||||
(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))))
|
||||
|
||||
(ert-deftest spine-shelves-returns-shelf-names ()
|
||||
"spine-shelves returns all level-1 headlines as shelf names."
|
||||
(let ((shelves (spine-shelves)))
|
||||
(should (member "Fiction" shelves))
|
||||
(should (member "Science Fiction" shelves))
|
||||
(should (member "Non-Fiction" shelves))
|
||||
(should (= (length shelves) 3))))
|
||||
|
||||
(ert-deftest spine-shelves-includes-empty-shelves ()
|
||||
"spine-shelves includes empty shelves (shelves with no books)."
|
||||
(let ((shelves (spine-shelves)))
|
||||
(should (member "Non-Fiction" shelves))))
|
||||
@@ -0,0 +1,189 @@
|
||||
;;; spine-edit-test.el — ERT tests for spine-edit functions
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
(setq spine-skip-server-start t)
|
||||
(setq spine-org-file (expand-file-name "test-edit-temp.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)))
|
||||
|
||||
(defun spine-edit-test--setup ()
|
||||
"Create a test Org file with one shelf and one book."
|
||||
(with-temp-file spine-org-file
|
||||
(insert "* Fiction\n** WANT Edit Test Book\n:PROPERTIES:\n:AUTHOR: Test Author\n:ID: test-0001\n:END:\n"))
|
||||
t)
|
||||
|
||||
(defun spine-edit-test--cleanup ()
|
||||
"Remove the test Org file."
|
||||
(ignore-errors (delete-file spine-org-file)))
|
||||
|
||||
(ert-deftest spine-add-note-appends ()
|
||||
"spine-add-note appends a note with date and text."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id)))
|
||||
(spine-add-note id "Great book!" "2026-06-21")
|
||||
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should updated)
|
||||
(let ((notes (plist-get updated :notes)))
|
||||
(should notes)
|
||||
(should (equal (caar notes) "2026-06-21"))
|
||||
(should (equal (cadar notes) "Great book!"))))))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-add-note-default-date ()
|
||||
"spine-add-note uses today's date when none provided."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id))
|
||||
(today (format-time-string "%Y-%m-%d")))
|
||||
(spine-add-note id "Note without date")
|
||||
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should updated)
|
||||
(let ((notes (plist-get updated :notes)))
|
||||
(should notes)
|
||||
(should (equal (caar notes) today))
|
||||
(should (equal (cadar notes) "Note without date"))))))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-set-rating-sets-rating ()
|
||||
"spine-set-rating sets the RATING property."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id)))
|
||||
(spine-set-rating id "4")
|
||||
(let ((updated (cl-find id (spine-books) :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should updated)
|
||||
(should (equal (plist-get updated :rating) "4")))))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-edit-model-actions-include-note-and-rating ()
|
||||
"Selected book shows Add note and Set rating actions."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(book (car books))
|
||||
(id (plist-get book :id))
|
||||
(model (spine-index-model books nil id))
|
||||
(minibuffer (ht-get model "minibuffer")))
|
||||
(should minibuffer)
|
||||
(let ((actions (ht-get minibuffer "actions")))
|
||||
(should (= (length actions) 2))
|
||||
(should (equal (ht-get (nth 0 actions) "label") "Add note"))
|
||||
(should (equal (ht-get (nth 1 actions) "label") "Set rating")))))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-edit-model-no-actions-when-no-selection ()
|
||||
"No minibuffer actions when no book is selected."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(let* ((books (spine-books))
|
||||
(model (spine-index-model books nil nil)))
|
||||
(should-not (ht-get model "minibuffer"))))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(defun spine-edit-test--two-shelf-setup ()
|
||||
"Create a test Org file with two shelves and a book on the first."
|
||||
(with-temp-file spine-org-file
|
||||
(insert "* Fiction\n** WANT Edit Test Book\n:PROPERTIES:\n:AUTHOR: Test Author\n:ID: test-0001\n:END:\n* Science Fiction\n"))
|
||||
t)
|
||||
|
||||
(ert-deftest spine-move-book-moves-to-target-shelf ()
|
||||
"spine-move-book moves the book to the target shelf."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--two-shelf-setup)
|
||||
(spine-move-book "test-0001" "Science Fiction")
|
||||
(let* ((books (spine-books))
|
||||
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should book)
|
||||
(should (equal (plist-get book :shelf) "Science Fiction"))))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-move-book-keeps-properties ()
|
||||
"spine-move-book preserves book properties after move."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--two-shelf-setup)
|
||||
(spine-move-book "test-0001" "Science Fiction")
|
||||
(let* ((books (spine-books))
|
||||
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should book)
|
||||
(should (equal (plist-get book :title) "WANT Edit Test Book"))
|
||||
(should (equal (plist-get book :author) "Test Author"))))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-move-book-errors-on-missing-book ()
|
||||
"spine-move-book errors when book not found."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--two-shelf-setup)
|
||||
(should-error (spine-move-book "nonexistent" "Science Fiction")))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-move-book-errors-on-missing-shelf ()
|
||||
"spine-move-book errors when target shelf not found."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--two-shelf-setup)
|
||||
(should-error (spine-move-book "test-0001" "Nonexistent Shelf")))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-set-status-changes-todo ()
|
||||
"spine-set-status changes the TODO keyword."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(spine-set-status "test-0001" "reading")
|
||||
(let* ((books (spine-books))
|
||||
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should book)
|
||||
(should (equal (plist-get book :title) "READING Edit Test Book"))))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-set-status-clears-todo ()
|
||||
"spine-set-status with empty string removes TODO keyword."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(spine-set-status "test-0001" "")
|
||||
(let* ((books (spine-books))
|
||||
(book (cl-find "test-0001" books :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(should book)
|
||||
(should (equal (plist-get book :title) "Edit Test Book"))))
|
||||
(spine-edit-test--cleanup)))
|
||||
|
||||
(ert-deftest spine-set-status-errors-on-missing-book ()
|
||||
"spine-set-status errors when book not found."
|
||||
(spine-edit-test--cleanup)
|
||||
(unwind-protect
|
||||
(progn
|
||||
(spine-edit-test--setup)
|
||||
(should-error (spine-set-status "nonexistent" "reading")))
|
||||
(spine-edit-test--cleanup)))
|
||||
@@ -0,0 +1,74 @@
|
||||
;;; spine-index-model-test.el — ERT tests for spine-index-model with shelf grouping
|
||||
|
||||
(require 'ert)
|
||||
(require 'cl-lib)
|
||||
|
||||
(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)))
|
||||
|
||||
(defconst spine-index-model-test--books (spine-books)
|
||||
"Books fixture loaded once from sample-books.org.")
|
||||
|
||||
(ert-deftest spine-index-model-groups-by-shelf ()
|
||||
"Index model groups books by :shelf."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(groups (ht-get model "groups")))
|
||||
(should groups)
|
||||
(should (= (length groups) 3)) ;; Fiction, Science Fiction, Non-Fiction
|
||||
(let ((labels (mapcar (lambda (g) (ht-get g "label")) groups)))
|
||||
(should (member "Fiction" labels))
|
||||
(should (member "Science Fiction" labels))
|
||||
(should (member "Non-Fiction" labels)))))
|
||||
|
||||
(ert-deftest spine-index-model-shelf-counts ()
|
||||
"Each shelf group has the correct number of books."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(groups (ht-get model "groups")))
|
||||
(dolist (g groups)
|
||||
(let ((label (ht-get g "label"))
|
||||
(count (ht-get g "count")))
|
||||
(cond
|
||||
((equal label "Fiction")
|
||||
(should (= count 4))
|
||||
(should (= (length (ht-get g "books")) 4)))
|
||||
((equal label "Science Fiction")
|
||||
(should (= count 1))
|
||||
(should (= (length (ht-get g "books")) 1)))
|
||||
((equal label "Non-Fiction")
|
||||
(should (= count 1))
|
||||
(should (= (length (ht-get g "books")) 1))))))))
|
||||
|
||||
(ert-deftest spine-index-model-filters-by-shelf ()
|
||||
"filter=shelf-name shows only that shelf's books."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "Fiction"))
|
||||
(groups (ht-get model "groups")))
|
||||
(should (= (length groups) 1))
|
||||
(should (equal (ht-get (car groups) "label") "Fiction"))))
|
||||
|
||||
(ert-deftest spine-index-model-includes-all-shelves-in-nav ()
|
||||
"Shelf nav includes all shelves, including empties."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books))
|
||||
(shelf-nav (ht-get model "shelf_nav")))
|
||||
(should (= (length shelf-nav) 3))
|
||||
(should (member "Non-Fiction" (mapcar (lambda (n) (ht-get n "name")) shelf-nav)))))
|
||||
|
||||
(ert-deftest spine-index-model-current-shelf ()
|
||||
"current_shelf is set when filter is active."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books "Science Fiction")))
|
||||
(should (equal (ht-get model "current_shelf") "Science Fiction"))))
|
||||
|
||||
(ert-deftest spine-index-model-no-current-shelf-without-filter ()
|
||||
"current_shelf is nil when no filter."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books)))
|
||||
(should-not (ht-get model "current_shelf"))))
|
||||
|
||||
(ert-deftest spine-index-model-total-and-shelf-count ()
|
||||
"Titlebar shows correct totals."
|
||||
(let* ((model (spine-index-model spine-index-model-test--books)))
|
||||
(should (= (ht-get model "total_books") 6))
|
||||
(should (= (ht-get model "shelf_count") 3))))
|
||||
Reference in New Issue
Block a user