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:
John O'Keefe committed 2026-10-14 11:59:38 -04:00
1 parent df556d8208
commit 0a7fbd6f48
17 files changed
+1766 -440

No files matched your search

+19
View File
@@ -83,6 +83,25 @@ templ CollectionModal(collection CollectionData) {
hx-post="/api/collections"
hx-redirect="/collections"
>
<div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Type</label>
<div class="grid grid-cols-2 gap-2">
<label class="relative cursor-pointer">
<input type="radio" name="collection_type" value="static" checked class="peer sr-only"/>
<div class="p-3 rounded-lg border text-center peer-checked:border-blue-500" style="background-color: var(--bg-primary); border-color: var(--border);">
<span class="font-semibold text-sm" style="color: var(--text-primary)">Static</span>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Pick books by hand</p>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" name="collection_type" value="smart" class="peer sr-only"/>
<div class="p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400" style="background-color: var(--bg-primary); border-color: var(--border);">
<span class="font-semibold text-sm" style="color: var(--text-primary)">⚡ Smart</span>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Match books by rules — always live</p>
</div>
</label>
</div>
</div>
<div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
<input