Compare commits
38 Commits
7faf0cd8ac
...
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 |
@@ -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,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,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
|
||||
+26
-10
@@ -1,6 +1,7 @@
|
||||
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
|
||||
#+TITLE: Spine
|
||||
|
||||
* WANT The Left Hand of Darkness
|
||||
* Fiction
|
||||
** WANT The Left Hand of Darkness
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Ursula K. Le Guin
|
||||
:ISBN: 978-0441478125
|
||||
@@ -10,10 +11,11 @@
|
||||
:REC_NOTE: Le Guin at her most human
|
||||
:RATING:
|
||||
:ADDED: [2026-04-01]
|
||||
:LAST_MODIFIED: [2026-04-01]
|
||||
:ID: 9a2b-lhd
|
||||
:END:
|
||||
|
||||
* WANT Dune
|
||||
** WANT Dune
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Frank Herbert
|
||||
:ISBN: 978-0441172719
|
||||
@@ -23,10 +25,11 @@
|
||||
:REC_NOTE:
|
||||
:RATING:
|
||||
:ADDED: [2026-05-10]
|
||||
:LAST_MODIFIED: [2026-05-10]
|
||||
:ID: 7f3c-dun
|
||||
:END:
|
||||
|
||||
* WANT Piranesi
|
||||
** WANT Piranesi :fiction:
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Susanna Clarke
|
||||
:ISBN:
|
||||
@@ -36,10 +39,11 @@
|
||||
:REC_NOTE:
|
||||
:RATING:
|
||||
:ADDED: [2026-06-01]
|
||||
:LAST_MODIFIED: [2026-06-01]
|
||||
:ID: 5e8d-pir
|
||||
:END:
|
||||
|
||||
* READING Use of Weapons :scifi:literary:
|
||||
** READING Use of Weapons :scifi:literary:
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Iain M. Banks
|
||||
:ISBN: 978-0316029193
|
||||
@@ -49,16 +53,18 @@
|
||||
: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:
|
||||
::LOGBOOK:
|
||||
- State "READING" from "WANT" [2026-03-01]
|
||||
:END:
|
||||
::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
|
||||
* Science Fiction
|
||||
** READ Ancillary Justice :scifi:
|
||||
:PROPERTIES:
|
||||
:AUTHOR: Ann Leckie
|
||||
:ISBN: 978-0316246620
|
||||
@@ -68,12 +74,22 @@
|
||||
:REC_NOTE:
|
||||
:RATING: 5
|
||||
:ADDED: [2025-11-15]
|
||||
:LAST_MODIFIED: [2025-11-15]
|
||||
:ID: 3a1b-aj
|
||||
:END:
|
||||
:LOGBOOK:
|
||||
::LOGBOOK:
|
||||
- State "READ" from "READING" [2026-01-10]
|
||||
- State "READING" from "WANT" [2025-11-20]
|
||||
:END:
|
||||
::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
|
||||
|
||||
@@ -57,46 +57,155 @@ Returns the rendered string."
|
||||
(setq s (replace-regexp-in-string "\"" """ s))
|
||||
s)))
|
||||
|
||||
(defun spine-index-model (books &optional selected-id)
|
||||
|
||||
(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* ((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))
|
||||
(let* ((grouped (make-hash-table :test 'equal))
|
||||
(all-shelves (or (spine-shelves) '()))
|
||||
(total (length books)))
|
||||
;; Group books by shelf
|
||||
(dolist (book books)
|
||||
(when (equal "READING" (plist-get book :status))
|
||||
(cl-incf reading-count)))
|
||||
(dolist (book books)
|
||||
(let ((status (or (plist-get book :status) "WANT")))
|
||||
(push book (gethash status grouped))))
|
||||
(let ((groups nil))
|
||||
(dolist (status order)
|
||||
(let ((group-books (nreverse (gethash status grouped))))
|
||||
(when group-books
|
||||
(let ((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 group-books)
|
||||
(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))
|
||||
(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))
|
||||
(or (nth 1 (assoc fmt spine--format-defs)) ""))
|
||||
("title" (plist-get book :title))
|
||||
("author" (or (plist-get book :author) ""))
|
||||
("tags_inline"
|
||||
@@ -109,6 +218,7 @@ SELECTED-ID expands the matching book's detail section."
|
||||
(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
|
||||
@@ -117,7 +227,7 @@ SELECTED-ID expands the matching book's detail section."
|
||||
(fmt (plist-get book :format)))
|
||||
(cond
|
||||
((and added fmt)
|
||||
(format "started %s · %s" added fmt))
|
||||
(format "started %s \302\267 %s" added fmt))
|
||||
(added (format "started %s" added))
|
||||
(fmt (format "format: %s" fmt))
|
||||
(t ""))))
|
||||
@@ -126,17 +236,235 @@ SELECTED-ID expands the matching book's detail section."
|
||||
(cl-loop for (date text) in notes
|
||||
collect (ht
|
||||
("date" (format "[%s]" date))
|
||||
("text" text)))))))))))
|
||||
("text" text))))))))
|
||||
)))
|
||||
(push model book-models)))
|
||||
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||
("count" (length group-books))
|
||||
(push (ht ("label" shelf)
|
||||
("count" (length shelf-books))
|
||||
("books" (nreverse book-models)))
|
||||
groups)))))
|
||||
(ht ("app_title" "spine")
|
||||
("total_books" total)
|
||||
("reading_count" reading-count)
|
||||
("groups" (nreverse groups))))))
|
||||
("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."
|
||||
@@ -162,8 +490,31 @@ Returns nil for empty or missing properties."
|
||||
(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 top-level headline in `spine-org-file'.
|
||||
"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
|
||||
@@ -176,11 +527,16 @@ Returns nil if the file is missing or cannot be parsed."
|
||||
(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)))
|
||||
(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)
|
||||
:status (org-element-property :todo-keyword hl)
|
||||
:author (spine--prop pos "AUTHOR")
|
||||
:isbn (spine--prop pos "ISBN")
|
||||
:cover (spine--prop pos "COVER")
|
||||
@@ -189,11 +545,13 @@ Returns nil if the file is missing or cannot be parsed."
|
||||
: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)))
|
||||
books)))))
|
||||
(org-element-property :end hl))
|
||||
:shelf shelf)
|
||||
books)))))))
|
||||
(nreverse books)))
|
||||
(error
|
||||
(message "spine-books: failed to parse %s: %s"
|
||||
@@ -224,12 +582,107 @@ Set by test harnesses that only need the model functions.")
|
||||
;; --- handlers ---------------------------------------------------------
|
||||
|
||||
(defservlet index text/html (path query request)
|
||||
(let ((books (spine-books)))
|
||||
(let* ((books (spine-books))
|
||||
(shelf-filter (cadr (assoc "shelf" query))))
|
||||
(if books
|
||||
(insert (spine-render "index.mustache"
|
||||
(spine-index-model books (cadr (assoc "id" query)))))
|
||||
(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))
|
||||
|
||||
@@ -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>
|
||||
+74
-80
@@ -4,97 +4,89 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Spine — Direction A (agenda)</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
|
||||
<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>
|
||||
.agenda { font-family: var(--pico-font-family-monospace); padding: 0; overflow: hidden; }
|
||||
.agenda > header, .agenda > footer { margin: 0; padding: .6rem .9rem; font-size: .85rem; }
|
||||
.agenda > header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.group { padding: .65rem .9rem .2rem; font-size: .7rem; letter-spacing: .04em; color: var(--pico-muted-color); }
|
||||
.row { display: flex; align-items: center; gap: .6rem; padding: .3rem .9rem; font-size: .85rem; }
|
||||
.row.selected { background: var(--pico-card-sectioning-background-color); border-left: 2px solid var(--pico-primary); }
|
||||
.row.selected .title { font-weight: 500; }
|
||||
.row.selected .glyph { color: var(--pico-color); }
|
||||
.glyph { width: 1rem; color: var(--pico-muted-color); flex-shrink: 0; }
|
||||
.title { flex: 1; min-width: 0; }
|
||||
.muted { color: var(--pico-muted-color); }
|
||||
.tags { color: var(--pico-primary); font-size: .8rem; }
|
||||
.trail { color: var(--pico-muted-color); font-size: .8rem; }
|
||||
.pill { padding: .05rem .45rem; border-radius: var(--pico-border-radius); font-size: .7rem; font-weight: 500; }
|
||||
.pill.want { background: #e6f1fb; color: #0c447c; }
|
||||
.pill.reading { background: #faeeda; color: #854f0b; }
|
||||
.pill.read { background: #e1f5ee; color: #085041; }
|
||||
.detail { padding: .5rem .9rem .7rem 2rem; background: var(--pico-card-sectioning-background-color);
|
||||
border-left: 2px solid var(--pico-primary); font-size: .8rem; }
|
||||
.logline { display: flex; gap: .5rem; margin-bottom: .3rem; }
|
||||
.logline:last-child { margin-bottom: 0; }
|
||||
.logdate { color: var(--pico-muted-color); white-space: nowrap; }
|
||||
.endpad { height: .4rem; }
|
||||
.minibuffer { display: flex; gap: .5rem; align-items: center; }
|
||||
.minibuffer .text { border-left: 2px solid var(--pico-primary); padding-left: .4rem; }
|
||||
.caret { opacity: .5; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pill.want { background: #14304a; color: #b5d4f4; }
|
||||
.pill.reading { background: #3a2c12; color: #fac775; }
|
||||
.pill.read { background: #103a30; color: #9fe1cb; }
|
||||
: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>
|
||||
{{!
|
||||
View model for this template:
|
||||
{
|
||||
app_title: string,
|
||||
total_books: number,
|
||||
reading_count: number,
|
||||
groups: [
|
||||
{
|
||||
label: string, e.g. "on deck"
|
||||
count: number,
|
||||
books: [
|
||||
{
|
||||
format_icon: string, Tabler class, e.g. "ti-headphones" (data layer maps FORMAT -> icon)
|
||||
status_class: string, "want" | "reading" | "read" (maps Org TODO state -> pill style)
|
||||
status_label: string, text shown in the pill
|
||||
title: string,
|
||||
author: string,
|
||||
tags_inline: string?, optional, e.g. ":scifi:literary:"
|
||||
rec_via: string?, optional, REC_BY -> rendered as "via {name}"
|
||||
rating_display: string?, optional, e.g. "★★★★☆"
|
||||
selected: boolean?, marks the in-focus row (expands detail)
|
||||
detail: { optional; render when a row is expanded
|
||||
meta: string, e.g. "started [2026-02-20] · hardcover · 62% · rating —"
|
||||
notes: [ { date: string, text: string } ] reading log
|
||||
}?
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
minibuffer: { command: string, text: string }? optional capture line
|
||||
}
|
||||
}}
|
||||
<main class="container">
|
||||
<article class="agenda" style="max-width: 720px; margin-inline: auto;">
|
||||
<header>
|
||||
<strong>{{app_title}}</strong>
|
||||
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
|
||||
</header>
|
||||
<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>
|
||||
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
|
||||
<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: .5rem;">{{meta}}</div>
|
||||
<div class="muted" style="margin-bottom:8px;">{{meta}}</div>
|
||||
{{#notes}}
|
||||
<div class="logline"><span class="logdate">{{date}}</span><span>{{text}}</span></div>
|
||||
{{/notes}}
|
||||
@@ -102,16 +94,18 @@
|
||||
{{/detail}}
|
||||
{{/books}}
|
||||
{{/groups}}
|
||||
<div class="endpad"></div>
|
||||
|
||||
{{#minibuffer}}
|
||||
<footer class="minibuffer">
|
||||
<div class="minibuffer">
|
||||
<span class="muted">M-x</span>
|
||||
<span>{{command}}</span>
|
||||
<span class="text">{{text}}<span class="caret">▏</span></span>
|
||||
</footer>
|
||||
<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}}
|
||||
</article>
|
||||
</main>
|
||||
</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"))))
|
||||
@@ -16,7 +16,7 @@
|
||||
"spine-books returns correct plists for sample-books.org."
|
||||
(let ((books (spine-books)))
|
||||
(should books)
|
||||
(should (= (length books) 5))))
|
||||
(should (= (length books) 6))))
|
||||
|
||||
(ert-deftest spine-books-first-book-has-all-fields ()
|
||||
"Use of Weapons entry has all expected fields."
|
||||
@@ -25,8 +25,8 @@
|
||||
: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 :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"))
|
||||
@@ -46,8 +46,8 @@
|
||||
: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 :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))
|
||||
@@ -63,7 +63,7 @@
|
||||
:key (lambda (b) (plist-get b :id))
|
||||
:test #'equal)))
|
||||
(should aj)
|
||||
(should (equal (plist-get aj :status) "READ"))
|
||||
(should (equal (plist-get aj :shelf) "Science Fiction"))
|
||||
(should (equal (plist-get aj :rating) "5"))
|
||||
(should (= (length (plist-get aj :notes)) 3))))
|
||||
|
||||
@@ -81,3 +81,16 @@
|
||||
"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