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
+7
-76
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user