From 592ccddf65f4ca5a39b00f7a8360cfbb81677cce Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Feb 2026 00:49:22 -0500 Subject: [PATCH] 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 --- internal/handlers/collections.go | 143 ++++++++++++++++++++++++++ internal/handlers/collections_test.go | 92 +++++++++++++++++ internal/handlers/ebook.go | 1 + templates/collection_rules.templ | 20 +++- templates/collection_rules_templ.go | 8 +- 5 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 internal/handlers/collections_test.go diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 9572024..215975b 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -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 +} diff --git a/internal/handlers/collections_test.go b/internal/handlers/collections_test.go new file mode 100644 index 0000000..7cf2272 --- /dev/null +++ b/internal/handlers/collections_test.go @@ -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) +} diff --git a/internal/handlers/ebook.go b/internal/handlers/ebook.go index 03255d3..d07ddc2 100644 --- a/internal/handlers/ebook.go +++ b/internal/handlers/ebook.go @@ -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") diff --git a/templates/collection_rules.templ b/templates/collection_rules.templ index 142f051..dd7fc6e 100644 --- a/templates/collection_rules.templ +++ b/templates/collection_rules.templ @@ -301,13 +301,19 @@ templ CollectionRules(user User, collection CollectionDetailData) { testResultsDiv.classList.remove('hidden'); resultsList.innerHTML = '

Testing rule...

'; - 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 += '
'; result.matches.slice(0, 20).forEach(book => { - html += '
'; + html += '
'; html += 'Cover'; - html += '' + book.title + ''; + html += '
'; + html += '
' + book.title + '
'; + if (book.author) { + html += '
' + book.author + '
'; + } + html += '
' + book.match_reason + '
'; + html += '
'; html += '
'; }); diff --git a/templates/collection_rules_templ.go b/templates/collection_rules_templ.go index 4b8487c..70ac5db 100644 --- a/templates/collection_rules_templ.go +++ b/templates/collection_rules_templ.go @@ -36,7 +36,7 @@ func CollectionRules(user User, collection CollectionDetailData) templ.Component var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 9, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 9, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -57,7 +57,7 @@ func CollectionRules(user User, collection CollectionDetailData) templ.Component var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 24, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -70,13 +70,13 @@ func CollectionRules(user User, collection CollectionDetailData) templ.Component var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 26, Col: 107} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 26, Col: 107} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Auto-Assign Rules

Existing Rules

๐Ÿ“‹

No Rules Yet

Create auto-assign rules to automatically add books to this collection

Create New Rule

Uncheck to disable without deleting

Rule Test Results

Books that would be added by this rule:

Rule Examples

1

Add all Science Fiction books

Field: genre
Operator: equals
Value: Science Fiction
2

Add books from a specific series

Field: series
Operator: starts with
Value: Harry Potter
3

Add books published in a year range

Field: copyright_year
Operator: greater than
Value: 2020
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Auto-Assign Rules

Existing Rules

๐Ÿ“‹

No Rules Yet

Create auto-assign rules to automatically add books to this collection

Create New Rule

Uncheck to disable without deleting

Rule Test Results

Books that would be added by this rule:

Rule Examples

1

Add all Science Fiction books

Field: genre
Operator: equals
Value: Science Fiction
2

Add books from a specific series

Field: series
Operator: starts with
Value: Harry Potter
3

Add books published in a year range

Field: copyright_year
Operator: greater than
Value: 2020
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }