feat(dashboard): implement Phase 4.5 Collections Preview Endpoint
Add preview endpoint for custom section builder and rule evaluation: Handler Implementation (internal/handlers/collections.go): - PreviewCollection method: Evaluates filter rules and returns matching items without saving * Accepts library_id, rules array, manual_book_ids array, and limit * Evaluates rules against all library items using collectionService.EvaluateRules * Adds manually selected books to results * Deduplicates manual books (avoids adding same book twice) * Applies limit (default: 20, max: 100) * Returns array of BookInfo with matching items - Helper function: mediaItemsToListMediaItemsRow * Converts database.MediaItems to database.ListMediaItemsRow * Required for EvaluateRules which expects ListMediaItemsRow type Route Registration (internal/router/collections.go): - POST /api/collections/preview - Protected by JWT middleware - Part of collections API group Why This Endpoint is Necessary: - Allows users to see what books match their filter rules BEFORE saving - Avoids creating incorrect collections - Enables testing different rule combinations quickly - Reuses existing service logic (collectionService.EvaluateRules) - Client-side preview would require downloading entire library (10,000+ books) - Would duplicate 500+ lines of rule evaluation logic in TypeScript - Would create maintenance nightmare keeping Go and TypeScript in sync Bruno Test (bruno/collections/preview-collection.bru): - Tests POST /api/collections/preview endpoint - Validates status 200 response - Validates items array in response - Example request with genre filter rule This endpoint is required for both the web UI Custom Section Builder and future mobile apps.
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
meta:
|
||||||
|
name: Preview Collection
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/collections/preview'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"rules\": [\n\
|
||||||
|
{\n \"id\": \"rule1\",\n \"field\": \"genre\",\n\
|
||||||
|
\"operator\": \"equals\",\n \"value\": \"Fiction\",\n\
|
||||||
|
\"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}"
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: "test(\"status must be 200 with valid request\", function() {\n expect(res.status).to.eql(200);\n\
|
||||||
|
});\n\ntest(\"response contains items array\", function() {\n expect(res.body.items).to.exist;\n\
|
||||||
|
expect(res.body.items).to.be.an('array');\n });"
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
Preview collection with filter rules before saving.
|
||||||
|
|
||||||
|
**Endpoint**: POST /api/collections/preview
|
||||||
|
**Auth**: Required (Bearer token)
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| library_id | string | Yes | Library UUID |
|
||||||
|
| rules | array | No | Filter rules to evaluate |
|
||||||
|
| manual_book_ids | array | No | Manually selected book IDs |
|
||||||
|
| limit | int | No | Max items to return (default: 20, max: 100) |
|
||||||
|
|
||||||
|
## Example Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"library_id": "cc23c3a7-f8fb-451a-a78d-2a16df1b725a",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "rule1",
|
||||||
|
"field": "genre",
|
||||||
|
"operator": "equals",
|
||||||
|
"value": "Fiction",
|
||||||
|
"priority": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"manual_book_ids": [],
|
||||||
|
"limit": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
Returns array of matching book items.
|
||||||
|
|
||||||
|
This endpoint is used by the Custom Section Builder to show users what books will match their filter rules before they save the collection.
|
||||||
@@ -825,3 +825,135 @@ func (h *CollectionHandler) HandleBulkAddBooks(c echo.Context) error {
|
|||||||
"failed": failedCount,
|
"failed": failedCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) PreviewCollection(c echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
LibraryID string `json:"library_id"`
|
||||||
|
Rules []services.Rule `json:"rules"`
|
||||||
|
ManualBookIDs []string `json:"manual_book_ids"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||||
|
}
|
||||||
|
|
||||||
|
libUUID, err := uuid.Parse(req.LibraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Limit <= 0 || req.Limit > 100 {
|
||||||
|
req.Limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedItems []database.MediaItems
|
||||||
|
for _, item := range allItems {
|
||||||
|
listItem := mediaItemsToListMediaItemsRow(item)
|
||||||
|
evaluations := h.collectionService.EvaluateRules(listItem, req.Rules)
|
||||||
|
for _, eval := range evaluations {
|
||||||
|
if eval.Matches {
|
||||||
|
matchedItems = append(matchedItems, item)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bookID := range req.ManualBookIDs {
|
||||||
|
bookUUID, err := uuid.Parse(bookID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range allItems {
|
||||||
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
|
if itemUUID == bookUUID {
|
||||||
|
alreadyAdded := false
|
||||||
|
for _, added := range matchedItems {
|
||||||
|
addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16])
|
||||||
|
if addedUUID == bookUUID {
|
||||||
|
alreadyAdded = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !alreadyAdded {
|
||||||
|
matchedItems = append(matchedItems, item)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matchedItems) > req.Limit {
|
||||||
|
matchedItems = matchedItems[:req.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
bookCards := make([]BookInfo, len(matchedItems))
|
||||||
|
for i, item := range matchedItems {
|
||||||
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
|
bookCards[i] = BookInfo{
|
||||||
|
MediaItemID: itemUUID.String(),
|
||||||
|
Title: item.Title,
|
||||||
|
Author: textToString(item.Author),
|
||||||
|
CoverImagePath: textToString(item.CoverImagePath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow {
|
||||||
|
return database.ListMediaItemsRow{
|
||||||
|
ID: item.ID,
|
||||||
|
LibraryID: item.LibraryID,
|
||||||
|
Title: item.Title,
|
||||||
|
Author: item.Author,
|
||||||
|
Isbn: item.Isbn,
|
||||||
|
Description: item.Description,
|
||||||
|
FilePath: item.FilePath,
|
||||||
|
FileSize: item.FileSize,
|
||||||
|
MimeType: item.MimeType,
|
||||||
|
CoverImagePath: item.CoverImagePath,
|
||||||
|
Series: item.Series,
|
||||||
|
SeriesNumber: item.SeriesNumber,
|
||||||
|
Tags: item.Tags,
|
||||||
|
Asin: item.Asin,
|
||||||
|
DatePublished: item.DatePublished,
|
||||||
|
Publisher: item.Publisher,
|
||||||
|
Contributors: item.Contributors,
|
||||||
|
Language: item.Language,
|
||||||
|
Edition: item.Edition,
|
||||||
|
PageCount: item.PageCount,
|
||||||
|
Genre: item.Genre,
|
||||||
|
CopyrightYear: item.CopyrightYear,
|
||||||
|
GoodreadsID: item.GoodreadsID,
|
||||||
|
OpenlibraryID: item.OpenlibraryID,
|
||||||
|
GoogleBooksID: item.GoogleBooksID,
|
||||||
|
AddedByAdminID: item.AddedByAdminID,
|
||||||
|
CreatedAt: item.CreatedAt,
|
||||||
|
UpdatedAt: item.UpdatedAt,
|
||||||
|
FormatGroup: item.FormatGroup,
|
||||||
|
FormatMimetype: item.FormatMimetype,
|
||||||
|
IsReflowable: item.IsReflowable,
|
||||||
|
HasFixedLayout: item.HasFixedLayout,
|
||||||
|
TotalCharacters: item.TotalCharacters,
|
||||||
|
ChapterCount: item.ChapterCount,
|
||||||
|
EntitlementID: item.EntitlementID,
|
||||||
|
RevisionNumber: item.RevisionNumber,
|
||||||
|
KoboContentID: item.KoboContentID,
|
||||||
|
KoboMetadata: item.KoboMetadata,
|
||||||
|
TagsSearch: item.TagsSearch,
|
||||||
|
ContributorsSearch: item.ContributorsSearch,
|
||||||
|
FileSha256: item.FileSha256,
|
||||||
|
OpfIdentifier: item.OpfIdentifier,
|
||||||
|
OpfUuid: item.OpfUuid,
|
||||||
|
HashConfidence: item.HashConfidence,
|
||||||
|
LibraryName: "",
|
||||||
|
LibraryTypeName: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ func registerCollectionsRoutes(cfg *Config) {
|
|||||||
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
||||||
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
||||||
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
|
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
|
||||||
|
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
|
||||||
|
|
||||||
// Device shelf mapping routes
|
// Device shelf mapping routes
|
||||||
deviceCollections := protected.Group("/devices/:id/collections")
|
deviceCollections := protected.Group("/devices/:id/collections")
|
||||||
|
|||||||
Reference in New Issue
Block a user