Compare commits

...

12 Commits

Author SHA1 Message Date
jbrechtel 7f3769fcd8 feat: add SPINE_ORG env var to override org file path
Add SPINE_ORG environment variable support to scripts/run. When set,
the value is passed as spine-org-file via --eval before loading spine.el.

Usage:
  SPINE_ORG=~/my-books.org ./scripts/run
  SPINE_ORG=/work/personal-local/spine/sample-books.org ./scripts/run localhost:9090

When unset, defaults to ~/spine/books.org (existing behavior).
2026-06-23 22:19:51 -04:00
jbrechtel bbd9a455cd feat: wire book detail page with working shelf/status moves and note posting
- Add spine-move-book: move book between shelves by cutting/pasting headline
- Add spine-set-status: change TODO keyword (WANT/READING/READ/none)
- Add action=shelf and action=status cases to httpd/edit POST handler
- Wrap shelf/status selects in forms with onchange auto-submit to /edit
- Wire note composer as POST form to /edit with action=note
- Redirect all edit POSTs back to /book?id=X
- 7 new tests for move-book and set-status (error handling, property preservation)
2026-06-22 22:38:14 -04:00
jbrechtel cf511ac190 feat: add scripts/restart — stop spine silently then start it 2026-06-22 22:31:41 -04:00
jbrechtel ca03e87a46 refactor: redesign book detail page to match journal mockup styling
- Replace Pico CSS with index-style custom CSS variables
- Redesign layout: back breadcrumb, info table, shelf/status selects
- Update spine-book-model: add shelf_name, format_icon/label, tags_display,
  date display fields, rating display, status/shelf options
- Remove unused fields: meta, progress_label, formats array
- Update spine-book-model-test.el: 23 tests cover all new fields
- Add --text-danger CSS variable for remove button
2026-06-22 22:30:59 -04:00
jbrechtel df1a7aebc9 feat: add book detail view with spine-book-model, httpd/book handler, and title links
- Add spine--format-defs defconst shared between index and book models
- Add spine-book-model with status extraction, format selector, notes, recommendation
- Add templates/book.mustache (Pico CSS, journal-style layout)
- Add httpd/book handler serving the detail page
- Link book titles in index view to /book?id=X
- Add spine-book-model-test.el with 13 tests
2026-06-22 22:15:51 -04:00
jbrechtel c4248dcc89 feat: update HTTP handlers and templates for shelf model 2026-06-21 22:58:52 -04:00
jbrechtel 346fd7af5b feat: update HTTP handlers for shelf model 2026-06-21 22:55:20 -04:00
jbrechtel de057de0bf feat: rewrite spine-index-model for shelf grouping 2026-06-21 22:54:48 -04:00
jbrechtel e9ea0efa35 feat: update spine-add-form-model for shelf list 2026-06-21 22:53:05 -04:00
jbrechtel 6a961b8460 feat: remove spine-set-status, update edit functions for level-2 headlines 2026-06-21 22:52:49 -04:00
jbrechtel 609e168932 feat: adapt spine-add-book for shelf-based nesting 2026-06-21 22:52:00 -04:00
jbrechtel 858fc6c13b feat: add spine-shelves, adapt spine-books for shelf nesting 2026-06-21 22:51:01 -04:00
17 changed files with 2867 additions and 415 deletions
@@ -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;">&larr; 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,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
+21 -10
View File
@@ -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
@@ -14,7 +15,7 @@
:ID: 9a2b-lhd
:END:
* WANT Dune
** WANT Dune
:PROPERTIES:
:AUTHOR: Frank Herbert
:ISBN: 978-0441172719
@@ -28,7 +29,7 @@
:ID: 7f3c-dun
:END:
* WANT Piranesi :fiction:
** WANT Piranesi :fiction:
:PROPERTIES:
:AUTHOR: Susanna Clarke
:ISBN:
@@ -42,7 +43,7 @@
:ID: 5e8d-pir
:END:
* READING Use of Weapons :scifi:literary:
** READING Use of Weapons :scifi:literary:
:PROPERTIES:
:AUTHOR: Iain M. Banks
:ISBN: 978-0316029193
@@ -55,14 +56,15 @@
: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 :scifi:
* Science Fiction
** READ Ancillary Justice :scifi:
:PROPERTIES:
:AUTHOR: Ann Leckie
:ISBN: 978-0316246620
@@ -75,10 +77,19 @@
: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:
+10
View File
@@ -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
View File
@@ -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
+362 -223
View File
@@ -59,134 +59,195 @@ Returns the rendered string."
(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<)))))
"Build view model for templates/add.mustache from current BOOKS."
(ht ("app_title" "spine")
("shelves" (or (spine-shelves) '()))))
(defun spine-index-model (books &optional filter selected-id)
(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.
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
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"))
(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))
(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 ((shelf (or (plist-get book :shelf) "Uncategorized")))
(push book (gethash shelf grouped))))
(let ((groups nil)
(summary-groups nil))
;; Concise view: sort all books by :LAST_MODIFIED:, take top 5
(when (null effective-filter)
(clrhash grouped)
(dolist (b (cl-subseq (sort (copy-sequence books)
(lambda (a b)
(let ((a-date (plist-get a :last_modified))
(b-date (plist-get b :last_modified)))
(cond
((null a-date) nil)
((null b-date) t)
(t (string> a-date b-date))))))
0 (min (length books) 5)))
(let ((status (or (plist-get b :status) "WANT")))
(push b (gethash status grouped)))))
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(if (and (null effective-filter)
(member status '("READ" "ABANDONED")))
(push (ht ("label" (cdr (assoc status status-labels)))
("count" (length group-books))
("href" (format "/index?filter=%s"
(downcase status))))
summary-groups)
(when (or (null effective-filter)
(string= (downcase status) effective-filter)
(string= effective-filter "all"))
(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
(shelf-nav nil))
;; Build shelf nav from all shelves (including empty)
(dolist (shelf all-shelves)
(push (ht ("name" shelf)
("href" (format "/index?shelf=%s" shelf))
("current" (equal shelf shelf-filter)))
shelf-nav))
(setq shelf-nav (nreverse shelf-nav))
;; Build shelf groups
(dolist (shelf all-shelves)
(let ((shelf-books (nreverse (gethash shelf grouped))))
(when (or (null shelf-filter) (equal shelf shelf-filter))
(let ((book-models nil))
(dolist (book shelf-books)
(let* ((id (plist-get book :id))
(selected (equal id selected-id))
(fmt (plist-get book :format))
(rating (plist-get book :rating))
(notes (plist-get book :notes))
(model
(ht
("format_icon"
(or (nth 1 (assoc fmt spine--format-defs)) ""))
("title" (plist-get book :title))
("author" (or (plist-get book :author) ""))
("tags_inline"
(let ((tags (plist-get book :tags)))
(when tags
(mapconcat (lambda (tag) (concat ":" tag ":"))
tags " "))))
("rec_via" (plist-get book :rec_by))
("rating_display"
(when (and rating (> (length rating) 0))
(make-string (string-to-number rating) ?\u2605)))
("selected" selected)
("book_url" (format "/book?id=%s" id))
("detail"
(when selected
(ht
("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 \302\267 %s" added fmt))
(added (format "started %s" added))
(fmt (format "format: %s" fmt))
(t ""))))
("notes"
(when notes
(cl-loop for (date text) in notes
collect (ht
("date" (format "[%s]" date))
("text" text))))))))
)))
(push model book-models)))
(push (ht ("label" (cdr (assoc status status-labels)))
("count" (length group-books))
("books" (nreverse book-models)))
groups)))))))
("meta"
(let ((added (plist-get book :added))
(fmt (plist-get book :format)))
(cond
((and added fmt)
(format "started %s \302\267 %s" added fmt))
(added (format "started %s" added))
(fmt (format "format: %s" fmt))
(t ""))))
("notes"
(when notes
(cl-loop for (date text) in notes
collect (ht
("date" (format "[%s]" date))
("text" text))))))))
)))
(push model book-models)))
(push (ht ("label" shelf)
("count" (length shelf-books))
("books" (nreverse book-models)))
groups)))))
(ht ("app_title" "spine")
("total_books" total)
("reading_count" reading-count)
("current_filter" effective-filter)
("summary_groups" (nreverse summary-groups))
("shelf_count" (length all-shelves))
("current_shelf" shelf-filter)
("shelf_nav" shelf-nav)
("groups" (nreverse groups))
("minibuffer"
(when selected-id
@@ -195,45 +256,21 @@ SELECTED-ID expands the matching book's detail section."
(puthash (plist-get b :id) b books-by-id))
(let ((book (gethash selected-id books-by-id)))
(when book
(let ((status (or (plist-get book :status) "WANT")))
(ht ("actions"
(append
(cond
((equal status "WANT")
(list
(ht ("command" "status") ("label" "Mark reading")
("href" (format "/edit?id=%s&action=status&value=READING" selected-id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" selected-id)))))
((equal status "READING")
(list
(ht ("command" "status") ("label" "Mark read")
("href" (format "/edit?id=%s&action=status&value=READ" selected-id)))
(ht ("command" "status") ("label" "Abandon")
("href" (format "/edit?id=%s&action=status&value=ABANDONED" selected-id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" selected-id)))))
((equal status "READ")
(list
(ht ("command" "status") ("label" "Read again")
("href" (format "/edit?id=%s&action=status&value=READING" selected-id)))
(ht ("command" "note") ("label" "Add note")
("href" (format "/edit?id=%s&action=note" selected-id)))))
((equal status "ABANDONED")
(list
(ht ("command" "status") ("label" "Try again")
("href" (format "/edit?id=%s&action=status&value=READING" selected-id)))))
(t nil))
nil))))))))
)))))
(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 WANT book to `spine-org-file'.
Keyword arguments: :title :author :category :format :isbn :cover
"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
Signals an error on failure."
: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))
(category (plist-get args :category))
(format (plist-get args :format))
(isbn (plist-get args :isbn))
(cover (plist-get args :cover))
@@ -243,20 +280,32 @@ Signals an error on failure."
(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)))
;; 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")))
(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
(goto-char (point-max))
;; Insert new headline
;; 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 TODO state
(org-todo "WANT")
;; Set properties (only non-empty)
(when (and author (> (length author) 0))
(org-set-property "AUTHOR" author))
@@ -279,9 +328,6 @@ Signals an error on failure."
(substring (sha1 (concat title (number-to-string (random)))) 0 4)))
;; Set last modified
(org-set-property "LAST_MODIFIED" (format-time-string "[%Y-%m-%d]"))
;; 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))
@@ -293,30 +339,6 @@ Signals an error on failure."
t)
(kill-buffer))))))
(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)))))
(defun spine-add-note (id text &optional date)
"Append a reading note to the book with ID.
@@ -331,7 +353,7 @@ TEXT is the note content. DATE is an optional YYYY-MM-DD string
(progn
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 1)
(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))
@@ -356,7 +378,7 @@ Signals an error if the book is not found."
(progn
(org-element-map (org-element-parse-buffer 'headline) 'headline
(lambda (hl)
(when (= (org-element-property :level hl) 1)
(when (= (org-element-property :level hl) 2)
(let ((pos (org-element-property :begin hl)))
(when (equal id (spine--prop pos "ID"))
(goto-char pos)
@@ -368,6 +390,81 @@ Signals an error if the book is not found."
(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."
@@ -393,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
@@ -407,25 +527,31 @@ 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)))
(push (list :id (spine--prop pos "ID")
:title (org-element-property :title hl)
:status (org-element-property :todo-keyword hl)
:author (spine--prop pos "AUTHOR")
:isbn (spine--prop pos "ISBN")
:cover (spine--prop pos "COVER")
:format (spine--prop pos "FORMAT")
:rec_by (spine--prop pos "REC_BY")
:rec_note (spine--prop pos "REC_NOTE")
:rating (spine--prop pos "RATING")
:added (spine--prop pos "ADDED")
: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)))))
(let ((level (org-element-property :level hl)))
(when (= level 2)
(let* ((pos (org-element-property :begin hl))
(parent (org-element-property :parent hl))
(shelf (and parent
(= (org-element-property :level parent) 1)
(org-element-property :raw-value parent))))
(when shelf
(push (list :id (spine--prop pos "ID")
:title (org-element-property :title hl)
:author (spine--prop pos "AUTHOR")
:isbn (spine--prop pos "ISBN")
:cover (spine--prop pos "COVER")
:format (spine--prop pos "FORMAT")
:rec_by (spine--prop pos "REC_BY")
:rec_note (spine--prop pos "REC_NOTE")
:rating (spine--prop pos "RATING")
:added (spine--prop pos "ADDED")
:last_modified (spine--prop pos "LAST_MODIFIED")
:tags (org-element-property :tags hl)
:notes (spine--extract-notes
(org-element-property :begin hl)
(org-element-property :end hl))
:shelf shelf)
books)))))))
(nreverse books)))
(error
(message "spine-books: failed to parse %s: %s"
@@ -457,10 +583,10 @@ Set by test harnesses that only need the model functions.")
(defservlet index text/html (path query request)
(let* ((books (spine-books))
(filter (cadr (assoc "filter" query))))
(shelf-filter (cadr (assoc "shelf" query))))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books filter (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)
@@ -471,7 +597,7 @@ Set by test harnesses that only need the model functions.")
(spine-add-book
:title (cadr (assoc "title" query))
:author (cadr (assoc "author" query))
:category (cadr (assoc "category" query))
:shelf (cadr (assoc "shelf" query))
:format (cadr (assoc "format" query))
:isbn (cadr (assoc "isbn" query))
:cover (cadr (assoc "cover" query))
@@ -484,17 +610,18 @@ Set by test harnesses that only need the model functions.")
(let* ((books (spine-books))
(model (if books
(spine-add-form-model books)
(ht ("app_title" "spine")))))
(ht ("app_title" "spine" "shelves" '())))))
(insert (spine-render "add.mustache" model)))))))
(defun httpd/edit (proc uri-path query request)
"Handle /edit: GET shows prompt page, POST processes actions.
"Handle /edit: POST processes actions; GET shows prompt pages.
Query params:
id - book ID
action - status | note | rating
value - target status / rating value (for status/rating actions)
action - note | rating | shelf | status
text - note text (for note action)
date - optional date string (for note/read actions)"
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)))
@@ -506,11 +633,13 @@ Query params:
(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))
(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))
@@ -544,6 +673,16 @@ Query params:
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))
+5 -4
View File
@@ -60,10 +60,11 @@
<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" /></label>
<datalist id="authors-list">{{#existing_authors}}<option value="{{.}}">{{/existing_authors}}</datalist>
<label>Category <input type="text" name="category" list="categories-list" /></label>
<datalist id="categories-list">{{#existing_categories}}<option value="{{.}}">{{/existing_categories}}</datalist>
<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>
+167
View File
@@ -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>
+7 -22
View File
@@ -40,10 +40,6 @@
.group { padding:10px 14px 4px; font-size:11px; letter-spacing:0.04em; color: var(--text-tertiary); }
.row { display:flex; align-items:center; gap:10px; padding:5px 14px; font-size:13px; }
.glyph { font-size:15px; color:var(--text-tertiary); width:16px; }
.pill { padding:1px 7px; border-radius:var(--radius-md); font-size:11px; }
.pill.want { background:var(--bg-info); color:var(--text-info); }
.pill.reading { background:var(--bg-warning); color:var(--text-warning); }
.pill.read { background:var(--bg-success); color:var(--text-success); }
.title { flex:1; min-width:0; }
.muted { color:var(--text-tertiary); }
.tags { color:var(--text-info); font-size:12px; }
@@ -66,31 +62,24 @@
<div class="frame">
<div class="titlebar">
<span style="font-weight:500;">spine</span>
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
<span class="muted">{{total_books}} books · {{shelf_count}} shelves</span>
</div>
{{#current_filter}}
<div class="filter-bar">
<a href="/index">Concise view</a>
</div>
{{/current_filter}}
{{^current_filter}}
<div class="filter-bar">
<a href="/index?filter=all">All books ({{total_books}})</a>
<a href="/index"{{^current_shelf}} style="font-weight:600"{{/current_shelf}}>All</a>
{{#shelf_nav}}
&nbsp;·&nbsp;
<a href="/index?filter=want">Want list</a>
&nbsp;·&nbsp;
<a href="/index?filter=read">Read ({{reading_count}})</a>
<a href="{{href}}"{{#current}} style="font-weight:600"{{/current}}>{{name}}</a>
{{/shelf_nav}}
</div>
{{/current_filter}}
{{#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}}
@@ -106,10 +95,6 @@
{{/books}}
{{/groups}}
{{#summary_groups}}
<div class="group"><a href="{{href}}" style="color:var(--text-info);text-decoration:none">{{label}} · {{count}}</a></div>
{{/summary_groups}}
{{#minibuffer}}
<div class="minibuffer">
<span class="muted">M-x</span>
+40 -10
View File
@@ -16,18 +16,20 @@
(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 creates a new book under the given shelf."
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Test Book" :author "Author Name")
(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 :status) "WANT"))
(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)))
@@ -36,6 +38,8 @@
(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"
@@ -45,8 +49,8 @@
:rec_by "Friend"
:rec_note "You'll love this"
:date_added "2026-06-21"
:category "scifi"
:initial_note "Looking forward to this")
:initial_note "Looking forward to this"
:shelf "Fiction")
(let* ((books (spine-books))
(book (car books)))
(should (equal (plist-get book :title) "Full Test"))
@@ -57,7 +61,6 @@
(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)))
@@ -67,10 +70,13 @@
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Minimal Book")
(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))
@@ -81,7 +87,14 @@
"spine-add-book signals error when title is missing."
(spine-add-book-test--cleanup)
(unwind-protect
(should-error (spine-add-book :author "No Title"))
(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 ()
@@ -89,10 +102,27 @@
(spine-add-book-test--cleanup)
(unwind-protect
(progn
(spine-add-book :title "Book One")
(spine-add-book :title "Book Two")
(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)))
+200
View File
@@ -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"))))
+19 -6
View File
@@ -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))))
+104 -55
View File
@@ -12,44 +12,15 @@
(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"))
"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-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 (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)))
(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)))
(ert-deftest spine-add-note-appends ()
"spine-add-note appends a note with date and text."
(spine-edit-test--cleanup)
@@ -102,39 +73,117 @@
(should (equal (plist-get updated :rating) "4")))))
(spine-edit-test--cleanup)))
(ert-deftest spine-set-status-missing-id ()
"spine-set-status signals error for unknown ID."
(should-error (spine-set-status "nonexistent-id" "READING")))
"Selected WANT book has Mark reading and Add note actions."
(spine-edit-test--cleanup)
(ert-deftest spine-edit-model-minibuffer-reading ()
"Selected READING book has Mark read, Abandon, Add note actions."
(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)))
(spine-set-status id "READING")
(let ((model (spine-index-model (spine-books) nil id)))
(let ((mb (ht-get model "minibuffer")))
(should mb)
(let ((actions (ht-get mb "actions")))
(should (= (length actions) 3))
(should (string= (ht-get (nth 0 actions) "label") "Mark read"))
(should (string= (ht-get (nth 1 actions) "label") "Abandon"))
(should (string= (ht-get (nth 2 actions) "label") "Add note")))))))
(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-minibuffer-nil-when-no-selection ()
"No minibuffer when no book is selected."
(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 ((model (spine-index-model (spine-books))))
(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)))
+50 -79
View File
@@ -1,4 +1,4 @@
;;; spine-index-model-test.el — ERT tests for spine-index-model with filter
;;; spine-index-model-test.el — ERT tests for spine-index-model with shelf grouping
(require 'ert)
(require 'cl-lib)
@@ -14,90 +14,61 @@
(defconst spine-index-model-test--books (spine-books)
"Books fixture loaded once from sample-books.org.")
(ert-deftest spine-index-model-concise-hides-read-and-abandoned ()
"Concise view (filter=nil) omits READ/ABANDONED groups, adds summary-groups."
(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"))
(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))
(should (string= (ht-get (car summaries) "label") "read"))
(should (= (ht-get (car summaries) "count") 1))
(should (string= (ht-get (car summaries) "href") "/index?filter=read"))))
(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-concise-includes-reading ()
"Concise view includes READING group with all reading books."
(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"))
(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))
(should (string= (ht-get (car (ht-get reading-group "books")) "title")
"Use of Weapons"))))
(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-concise-want-limited-to-five ()
"Concise view limits WANT group to 5 books, sorted by :LAST_MODIFIED: 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))
(should (= (length books) 3))
;; Piranesi (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")))))
(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))))
(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"))
(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 (string= (ht-get (car groups) "label") "read"))
(should (= (ht-get (car groups) "count") 1))))
(should (equal (ht-get (car groups) "label") "Fiction"))))
(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))))
(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-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)
(should-not (member "read" group-labels))
(should (member "reading" group-labels))
(should (member "on deck" group-labels))))
(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-includes-current-filter ()
"Model includes current_filter key matching the filter argument."
(should (not (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")))
(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))))