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

+7 -76
View File
@@ -2,7 +2,6 @@ import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { createWebSocket } from "./websocket";
import {
getCurrentLibraryId,
initLibrarySwitcher,
switchWithTransition,
} from "./library-switcher";
@@ -54,81 +53,15 @@ function initializeCollectionWebSocket(): void {
});
}
async function loadCollections(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/collections", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
renderCollectionsGrid(data.collections || []);
}
} catch (error) {
console.error("Failed to load collections:", error);
}
}
function renderCollectionsGrid(collections: CollectionData[]): void {
const container = document.getElementById("collections-list");
if (!container) return;
if (collections.length === 0) {
container.innerHTML = `
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
<p class="mb-4">Create collections to organize your books</p>
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML"
class="btn-primary px-4 py-2 rounded-lg">Create Your First Collection</button>
</div>`;
return;
}
const libraryId = getCurrentLibraryId();
const libraryParam = libraryId ? `?library_id=${libraryId}` : "";
container.innerHTML = collections
.map(
(col) => `
<div @click="navigateToCollection($el)" data-href="/collections/${col.id}${libraryParam}" class="block">
<div class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary);"
data-color="${col.color}">
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">${col.icon}</div>
<div class="flex space-x-2">
<button hx-get="/collections/${col.id}/edit-modal" hx-target="#modal-container"
hx-swap="innerHTML" class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);">✏️</button>
<button hx-delete="/api/collections/${col.id}" hx-redirect="/collections"
hx-confirm="Are you sure you want to delete this collection?"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);">🗑️</button>
</div>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">${col.name}</h3>
<p class="text-sm mb-4" style="color: var(--text-secondary)">${col.description}</p>
${col.book_count > 0 ? `<p class="text-xs" style="color: var(--text-secondary)">${col.book_count} books</p>` : ""}
</div>
</div>
`,
)
.join("");
initColorSelection();
Alpine.initTree(container);
}
function renderCollectionBooks(books: BookInfo[]): void {
const container = document.getElementById("books-container");
if (!container) return;
const dataEl = document.getElementById("collection-data");
const isSystem = dataEl?.dataset.isSystem === "true";
// Smart collections are rule-managed: no hand-pick actions either way.
const canEditMembership =
!isSystem && dataEl?.dataset.isSmart !== "true";
if (books.length === 0) {
container.innerHTML = `<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>`;
@@ -140,14 +73,14 @@ function renderCollectionBooks(books: BookInfo[]): void {
(book) => `
<div class="card p-4 rounded-2xl collection-book-card" data-title="${book.title}" data-author="${book.author || ""}" data-media-id="${book.media_item_id}">
<div class="flex gap-4">
${isSystem ? "" : `
${canEditMembership ? `
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input type="checkbox" class="w-5 h-5"
:checked="selectedBooks.includes('${book.media_item_id}')"
@change="toggleSelection('${book.media_item_id}')"/>
</label>
</div>`}
</div>` : ""}
<div class="flex-1 min-w-0">
<a href="/media/${book.media_item_id}">
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">${book.title}</h3>
@@ -162,12 +95,12 @@ function renderCollectionBooks(books: BookInfo[]): void {
</a>
</div>
</div>
${isSystem ? "" : `
${canEditMembership ? `
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button @click="requestRemoveBook('${book.media_item_id}')" class="btn btn-secondary w-full">
🗑️ Remove from Collection
</button>
</div>`}
</div>` : ""}
</div>
`,
)
@@ -658,7 +591,6 @@ export {
initializeCollectionWebSocket,
initCollectionsPage,
loadCollectionRules,
loadCollections,
navigateToCollection,
populateIconGrid,
selectColor,
@@ -680,7 +612,6 @@ Alpine.data("collections", () => ({
initializeCollectionWebSocket,
initCollectionsPage,
loadCollectionRules,
loadCollections,
navigateToCollection,
populateIconGrid,
selectColor,