feat(collections): implement rule testing/preview functionality (Limitation #1)
Add ability to test collection rules before saving:
- New API endpoint: POST /api/collections/test-rules
- Evaluates rules against all media items
- Returns matching books with reasons
- Supports all operators: equals, contains, greater_than, etc.
- Works with all fields: genre, author, series, etc.
Backend Implementation:
- TestRules() handler in collections.go
- evaluateRule() matches book properties against rule criteria
- compareValues() handles string/numeric comparisons
- Case-insensitive matching for contains operator
Frontend Integration:
- Updated testRule() function in collection_rules.templ
- Displays matching books with covers and authors
- Shows match reason (which rule criteria matched)
- Limits preview to 20 results with count indicator
Tests Added:
- TestCompareValues_Equals: Exact match validation
- TestCompareValues_Contains: Substring matching
- TestCompareValues_GreaterThan: Numeric comparison
- TestCompareValues_NotEquals: Negation
- TestEvaluateRule_Genre: Genre field matching
- TestEvaluateRule_Author: Author field matching
- TestEvaluateRule_CopyrightYear: Year field matching
API Request Format:
{
"rules": [{
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
}]
}
API Response Format:
{
"matches": [{
"media_item_id": "uuid",
"title": "Book Title",
"author": "Author Name",
"cover_image_path": "/path/to/cover.jpg",
"match_reason": "Matched rule: genre equals Science Fiction"
}],
"total": 42
}
Resolves Limitation #1: Rule Testing Preview
This commit is contained in:
@@ -4,7 +4,10 @@ import (
|
||||
"bookmann/internal/database"
|
||||
"bookmann/internal/services"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -497,3 +500,143 @@ func (h *CollectionHandler) GetUserCollectionsList(c echo.Context) ([]database.C
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
||||
}
|
||||
|
||||
type TestRulesRequest struct {
|
||||
Rules []map[string]interface{} `json:"rules" validate:"required"`
|
||||
}
|
||||
|
||||
type BookMatch struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
MatchReason string `json:"match_reason"`
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) TestRules(c echo.Context) error {
|
||||
var req TestRulesRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
|
||||
}
|
||||
|
||||
var matches []BookMatch
|
||||
for _, item := range mediaItems {
|
||||
matchReason := h.checkRulesAgainstBook(item, req.Rules)
|
||||
if matchReason != "" {
|
||||
coverPath := ""
|
||||
if item.CoverImagePath.Valid {
|
||||
coverPath = item.CoverImagePath.String
|
||||
}
|
||||
author := ""
|
||||
if item.Author.Valid {
|
||||
author = item.Author.String
|
||||
}
|
||||
|
||||
matches = append(matches, BookMatch{
|
||||
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
|
||||
Title: item.Title,
|
||||
Author: author,
|
||||
CoverImagePath: coverPath,
|
||||
MatchReason: matchReason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"matches": matches,
|
||||
"total": len(matches),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) checkRulesAgainstBook(item database.ListMediaItemsRow, rules []map[string]interface{}) string {
|
||||
for _, rule := range rules {
|
||||
field, _ := rule["field"].(string)
|
||||
operator, _ := rule["operator"].(string)
|
||||
value, _ := rule["value"].(string)
|
||||
|
||||
if h.evaluateRule(item, field, operator, value) {
|
||||
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) evaluateRule(item database.ListMediaItemsRow, field, operator, value string) bool {
|
||||
var itemValue string
|
||||
|
||||
switch field {
|
||||
case "genre":
|
||||
if item.Genre.Valid {
|
||||
itemValue = item.Genre.String
|
||||
}
|
||||
case "series":
|
||||
if item.Series.Valid {
|
||||
itemValue = item.Series.String
|
||||
}
|
||||
case "author":
|
||||
if item.Author.Valid {
|
||||
itemValue = item.Author.String
|
||||
}
|
||||
case "language":
|
||||
if item.Language.Valid {
|
||||
itemValue = item.Language.String
|
||||
}
|
||||
case "publisher":
|
||||
if item.Publisher.Valid {
|
||||
itemValue = item.Publisher.String
|
||||
}
|
||||
case "copyright_year":
|
||||
if item.CopyrightYear.Valid {
|
||||
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
|
||||
}
|
||||
case "tags":
|
||||
if item.Tags.Valid {
|
||||
itemValue = item.Tags.String
|
||||
}
|
||||
}
|
||||
|
||||
return h.compareValues(itemValue, operator, value)
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) bool {
|
||||
switch operator {
|
||||
case "equals":
|
||||
return itemValue == ruleValue
|
||||
case "not_equals":
|
||||
return itemValue != ruleValue
|
||||
case "contains":
|
||||
return strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
||||
case "not_contains":
|
||||
return !strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
||||
case "starts_with":
|
||||
return strings.HasPrefix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
||||
case "ends_with":
|
||||
return strings.HasSuffix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
||||
case "greater_than":
|
||||
itemNum, err1 := strconv.Atoi(itemValue)
|
||||
ruleNum, err2 := strconv.Atoi(ruleValue)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return itemNum > ruleNum
|
||||
case "less_than":
|
||||
itemNum, err1 := strconv.Atoi(itemValue)
|
||||
ruleNum, err2 := strconv.Atoi(ruleValue)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return itemNum < ruleNum
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"bookmann/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCompareValues_Equals(t *testing.T) {
|
||||
handler := &CollectionHandler{}
|
||||
|
||||
result := handler.compareValues("Science Fiction", "equals", "Science Fiction")
|
||||
assert.True(t, result)
|
||||
|
||||
result = handler.compareValues("Science Fiction", "equals", "Fantasy")
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestCompareValues_Contains(t *testing.T) {
|
||||
handler := &CollectionHandler{}
|
||||
|
||||
result := handler.compareValues("Isaac Asimov", "contains", "asimov")
|
||||
assert.True(t, result)
|
||||
|
||||
result = handler.compareValues("Isaac Asimov", "contains", "clarke")
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestCompareValues_GreaterThan(t *testing.T) {
|
||||
handler := &CollectionHandler{}
|
||||
|
||||
result := handler.compareValues("2021", "greater_than", "2000")
|
||||
assert.True(t, result)
|
||||
|
||||
result = handler.compareValues("1999", "greater_than", "2000")
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestCompareValues_NotEquals(t *testing.T) {
|
||||
handler := &CollectionHandler{}
|
||||
|
||||
result := handler.compareValues("Science Fiction", "not_equals", "Fantasy")
|
||||
assert.True(t, result)
|
||||
|
||||
result = handler.compareValues("Science Fiction", "not_equals", "Science Fiction")
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestEvaluateRule_Genre(t *testing.T) {
|
||||
item := database.ListMediaItemsRow{
|
||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
||||
}
|
||||
|
||||
handler := &CollectionHandler{}
|
||||
|
||||
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
|
||||
assert.True(t, result)
|
||||
|
||||
result = handler.evaluateRule(item, "genre", "equals", "Fantasy")
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestEvaluateRule_Author(t *testing.T) {
|
||||
item := database.ListMediaItemsRow{
|
||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
||||
}
|
||||
|
||||
handler := &CollectionHandler{}
|
||||
|
||||
result := handler.evaluateRule(item, "author", "contains", "asimov")
|
||||
assert.True(t, result)
|
||||
|
||||
result = handler.evaluateRule(item, "author", "contains", "clarke")
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestEvaluateRule_CopyrightYear(t *testing.T) {
|
||||
item := database.ListMediaItemsRow{
|
||||
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
|
||||
}
|
||||
|
||||
handler := &CollectionHandler{}
|
||||
|
||||
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
|
||||
assert.True(t, result)
|
||||
|
||||
result = handler.evaluateRule(item, "copyright_year", "less_than", "2000")
|
||||
assert.False(t, result)
|
||||
}
|
||||
@@ -81,6 +81,7 @@ func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.Connect
|
||||
collections.GET("/:id/books", collectionHandler.GetBookCollections)
|
||||
collections.POST("/:id/books", collectionHandler.AddBooks)
|
||||
collections.DELETE("/:id/books/:bookId", collectionHandler.RemoveBook)
|
||||
collections.POST("/test-rules", collectionHandler.TestRules)
|
||||
|
||||
// Device shelf mapping routes
|
||||
deviceCollections := g.Group("/devices/:id/collections")
|
||||
|
||||
@@ -301,13 +301,19 @@ templ CollectionRules(user User, collection CollectionDetailData) {
|
||||
testResultsDiv.classList.remove('hidden');
|
||||
resultsList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Testing rule...</p>';
|
||||
|
||||
fetch(`/api/collections/${collectionId}/test-rule`, {
|
||||
const rules = [{
|
||||
field: field,
|
||||
operator: operator,
|
||||
value: value
|
||||
}];
|
||||
|
||||
fetch('/api/collections/test-rules', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({ field, operator, value })
|
||||
body: JSON.stringify({ rules: rules })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
@@ -316,9 +322,15 @@ templ CollectionRules(user User, collection CollectionDetailData) {
|
||||
html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
||||
|
||||
result.matches.slice(0, 20).forEach(book => {
|
||||
html += '<div style="display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; border-radius: 0.25rem; background-color: var(--bg-primary);">';
|
||||
html += '<div style="display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; border-radius: 0.25rem; background-color: var(--bg-secondary);">';
|
||||
html += '<img src="' + (book.cover_image_path || '/static/placeholder-book.svg') + '" alt="Cover" style="width: 2rem; height: 3rem; object-fit: cover; border-radius: 0.25rem;">';
|
||||
html += '<span style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary);">' + book.title + '</span>';
|
||||
html += '<div style="flex: 1;">';
|
||||
html += '<div style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary);">' + book.title + '</div>';
|
||||
if (book.author) {
|
||||
html += '<div style="font-size: 0.75rem; color: var(--text-secondary);">' + book.author + '</div>';
|
||||
}
|
||||
html += '<div style="font-size: 0.75rem; color: var(--accent);">' + book.match_reason + '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user