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:
john-okeefe committed 2026-02-01 00:49:22 -05:00
1 parent f60f850126
commit 592ccddf65
5 files changed
+256 -8

No files matched your search

+16 -4
View File
@@ -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>';
});
+4 -4
View File
@@ -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, "</h1><p style=\"color: var(--text-secondary)\">Auto-Assign Rules</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Existing Rules</h2><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-6 rounded-lg border text-center\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"text-4xl mb-2\">📋</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Create auto-assign rules to automatically add books to this collection</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" onsubmit=\"handleCreateRule(event)\"><input type=\"hidden\" id=\"collection-id\" value=\"{ collection.ID }\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-6\"><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option></select></div><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option></select></div></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"e.g., Science Fiction\"></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"testRule()\" class=\"btn-secondary px-4 py-2 rounded-lg\">🧪 Test Rule</button> <button type=\"button\" onclick=\"clearForm()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Clear</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">➕ Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 rounded-lg border mb-6\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><div claLine truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</h1><p style=\"color: var(--text-secondary)\">Auto-Assign Rules</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Existing Rules</h2><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-6 rounded-lg border text-center\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"text-4xl mb-2\">📋</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Create auto-assign rules to automatically add books to this collection</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" onsubmit=\"handleCreateRule(event)\"><input type=\"hidden\" id=\"collection-id\" value=\"{ collection.ID }\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-6\"><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option></select></div><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option></select></div></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"e.g., Science Fiction\"></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"testRule()\" class=\"btn-secondary px-4 py-2 rounded-lg\">🧪 Test Rule</button> <button type=\"button\" onclick=\"clearForm()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Clear</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">➕ Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 rounded-lg border mb-6\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><div claLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}