feat(collections): smart (dynamic) collections — rules evaluated live at render
Type is DERIVED, not stored: a saved ruleset makes a collection SMART,
no rules = STATIC (stored collection_items links, unchanged behavior).
No schema change — collections.auto_assign_rules JSONB already carried
the config; user's decision 2026-10-14 (handoff: live-at-render, not
refresh-on-scan; create-time Static/Smart toggle; v1 NO hybrid).
- Rule gains an enabled flag (absent = enabled, legacy rules unaffected).
- Rule CRUD: POST/PUT(DELETE) /api/collections/:id/rules[/:ruleId] — the
survivor dead effort of the rules-builder UI finally has real
endpoints (rules were never editable after creation; nothing ever
re-evaluated them). First rule converts a user collection to smart;
removing the last reverts it to static. UpdateCollectionRulesJSON
edits ONLY the rules doc so rule CRUD cannot clobber metadata.
- Live evaluation: candidates from GetVisibleLiveMediaForRules —
visible-library set (family lens enforced at source) + archived IS
NULL AND missing_scan_count = 0 — any enabled rule matches, title
order. Zero refresh hooks by design: always current by construction.
- Membership mutations (add/remove/bulk-add) are 400 on smart — v1 no
hybrid; collection_items.excluded stays the reserved future hook.
- Rules builder page (/collections/:id/rules) first time ever routable;
templ whole-attribute interpolation ("{ expr }" inside a quoted attr
value is literal text, as theme-{ user.Theme } silently proved).
- UI: create modal Static/⚡Smart cards (smart create redirects into the
rules builder), ⚡ badge + real per-card counts on the grid (smart =
live matches, static = stored-link count), smart detail renders
matched books with no hand-pick actions + an Edit Rules button.
- API: GET /:id gains is_smart + live books; GET /: keeps the Android
app's default exclusion, include_auto=true surfaces smart rows with
live counts; TestRules previews against the SAME candidates the live
render uses (visible + live, was raw first-1000).
- FIXED pre-existing regression from 7ce7321: static collection detail
with NO library_id returned zero books — QueryType != '' caught user
rows ('filter' default) and routed them to dashboard sections that
never contain them; now only real system query types go there
(handlers + SSR). App-visible symptom: /api/collections/:id without
library_id came back books:[] for every static collection.
Tests: collections_smart_test.go (conversion, disabled rules, mutation
guards, revert-to-static, validation, system-collection prohibition,
include_auto semantics). Full offline integration suite PASS.
This commit is contained in:
1 parent
df556d8208
commit
0a7fbd6f48
17 files changed
+1766
-440
No files matched your search
+60
-21
@@ -1,6 +1,18 @@
|
||||
package templates
|
||||
|
||||
import "bookhoard/internal/handlers"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bookhoard/internal/handlers"
|
||||
)
|
||||
|
||||
// pluralizeBooks renders "book"/"books" for count displays.
|
||||
func pluralizeBooks(n int) string {
|
||||
if n == 1 {
|
||||
return "book"
|
||||
}
|
||||
return "books"
|
||||
}
|
||||
|
||||
templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
@@ -75,7 +87,10 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="text-3xl">{ col.Icon }</div>
|
||||
<div class="flex gap-1">
|
||||
<div class="flex gap-1 items-center">
|
||||
if col.IsSmart {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
|
||||
}
|
||||
<button
|
||||
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
||||
hx-target="#modal-container"
|
||||
@@ -98,6 +113,12 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">{ col.Description }</p>
|
||||
<p class="text-xs mt-3 flex items-center gap-1" style="color: var(--text-secondary);">
|
||||
<span class="font-medium">{ fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)) }</span>
|
||||
if col.IsSmart {
|
||||
<span>· rules-matched live</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -131,8 +152,16 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2" style="color: var(--text-primary)">
|
||||
{ collection.Name }
|
||||
if collection.IsSmart {
|
||||
<span class="badge text-xs" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
|
||||
}
|
||||
</h1>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p>
|
||||
if collection.IsSmart {
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Membership is rules-matched live — always current, nothing to refresh.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,22 +181,31 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
/>
|
||||
</div>
|
||||
if !collection.IsSystem {
|
||||
<button
|
||||
@click="requestBulkRemove()"
|
||||
:disabled="selectedBooks.length === 0"
|
||||
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="btn btn-danger"
|
||||
<a
|
||||
href={ "/collections/" + collection.ID + "/rules" }
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove Selected</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$store.bookPicker.open()"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("plus", "h-4 w-4")
|
||||
<span>Add Books</span>
|
||||
</button>
|
||||
@Icon("filter", "h-4 w-4")
|
||||
<span>Edit Rules</span>
|
||||
</a>
|
||||
if !collection.IsSmart {
|
||||
<button
|
||||
@click="requestBulkRemove()"
|
||||
:disabled="selectedBooks.length === 0"
|
||||
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="btn btn-danger"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove Selected</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$store.bookPicker.open()"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("plus", "h-4 w-4")
|
||||
<span>Add Books</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +216,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
for _, book := range books {
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4">
|
||||
if !collection.IsSystem {
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||
<input
|
||||
@@ -221,7 +259,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem {
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
@click="requestRemoveBook('{ book.MediaItemID }')"
|
||||
@@ -236,7 +274,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem {
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div
|
||||
x-data="bookPicker"
|
||||
x-show="$store.bookPicker.isOpen"
|
||||
@@ -385,6 +423,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
data-id={ collection.ID }
|
||||
data-library-id={ libraryID }
|
||||
data-is-system={ collection.IsSystem }
|
||||
data-is-smart={ collection.IsSmart }
|
||||
style="display: none;"
|
||||
></div>
|
||||
</html>
|
||||
|
||||
Reference in new issue
Block a user