feat: implement entry variant tagging, backend action handlers, batch variant loading, and rule application API.

This commit is contained in:
2026-08-25 22:57:42 -07:00
parent d440728ca5
commit 235966e933
6 changed files with 1382 additions and 240 deletions
+169 -28
View File
@@ -3,7 +3,9 @@ package rules
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
)
@@ -56,12 +58,19 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
return entry.Author
case "url":
return entry.URL
case "content", "rss_content":
return entry.Content
case "content", "raw_content", "rss_content":
if entry.Content != "" {
return entry.Content
}
return entry.RawContent
case "is_read":
return entry.IsRead
case "is_starred":
return entry.IsStarred
case "published_at":
return entry.PublishedAt
case "created_at":
return entry.CreatedAt
}
// Variant field resolution: "variants.keywords", "variants.readability_full_text", "variants.tags"
@@ -78,14 +87,41 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
}
func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool {
opLower := strings.ToLower(strings.TrimSpace(op))
// Handle exists / not_exists operators
if opLower == "exists" {
if actual == nil {
return false
}
if s, ok := actual.(string); ok && s == "" {
return false
}
return true
}
if opLower == "not_exists" {
if actual == nil {
return true
}
if s, ok := actual.(string); ok && s == "" {
return true
}
return false
}
if actual == nil {
return op == "neq"
return opLower == "neq" || opLower == "!=" || opLower == "not_in" || opLower == "not_contains"
}
// Handle Time/Timestamp comparisons
if actualTime, ok := actual.(time.Time); ok {
return e.compareTime(actualTime, opLower, expected)
}
actualStr := fmt.Sprintf("%v", actual)
expectedStr := fmt.Sprintf("%v", expected)
switch strings.ToLower(op) {
switch opLower {
case "eq", "==":
return strings.EqualFold(actualStr, expectedStr)
case "neq", "!=":
@@ -94,33 +130,34 @@ func (e *Evaluator) compare(actual interface{}, op string, expected interface{})
return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr))
case "not_contains":
return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr))
case "in":
// expected can be []interface{}, []string, or comma-separated string
switch v := expected.(type) {
case []interface{}:
for _, item := range v {
if strings.EqualFold(actualStr, fmt.Sprintf("%v", item)) {
return true
}
case "starts_with":
return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedStr))
case "ends_with":
return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedStr))
case "contains_any":
tokens := parseStringList(expected)
for _, token := range tokens {
if strings.Contains(strings.ToLower(actualStr), strings.ToLower(token)) {
return true
}
return false
case []string:
for _, item := range v {
if strings.EqualFold(actualStr, item) {
return true
}
}
return false
case string:
items := strings.Split(v, ",")
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(actualStr), strings.TrimSpace(item)) {
return true
}
}
return false
}
return false
case "in":
tokens := parseStringList(expected)
for _, item := range tokens {
if strings.EqualFold(actualStr, item) {
return true
}
}
return false
case "not_in":
tokens := parseStringList(expected)
for _, item := range tokens {
if strings.EqualFold(actualStr, item) {
return false
}
}
return true
case "matches", "regex":
re, err := regexp.Compile(expectedStr)
if err != nil {
@@ -131,3 +168,107 @@ func (e *Evaluator) compare(actual interface{}, op string, expected interface{})
return false
}
}
func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bool {
now := time.Now()
switch op {
case "within_last_hours":
hours, err := toFloat64(expected)
if err != nil {
return false
}
cutoff := now.Add(-time.Duration(hours * float64(time.Hour)))
return t.After(cutoff)
case "older_than_days":
days, err := toFloat64(expected)
if err != nil {
return false
}
cutoff := now.Add(-time.Duration(days * 24 * float64(time.Hour)))
return t.Before(cutoff)
case ">=", "after":
targetTime, err := parseTime(expected)
if err != nil {
return false
}
return t.After(targetTime) || t.Equal(targetTime)
case "<=", "before":
targetTime, err := parseTime(expected)
if err != nil {
return false
}
return t.Before(targetTime) || t.Equal(targetTime)
}
return false
}
func parseStringList(val interface{}) []string {
var result []string
switch v := val.(type) {
case []interface{}:
for _, item := range v {
result = append(result, strings.TrimSpace(fmt.Sprintf("%v", item)))
}
case []string:
for _, item := range v {
result = append(result, strings.TrimSpace(item))
}
case string:
parts := strings.Split(v, ",")
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
}
return result
}
func toFloat64(val interface{}) (float64, error) {
switch v := val.(type) {
case float64:
return v, nil
case int:
return float64(v), nil
case int64:
return float64(v), nil
case string:
return strconv.ParseFloat(strings.TrimSpace(v), 64)
}
return 0, fmt.Errorf("cannot convert %v to float64", val)
}
func parseTime(val interface{}) (time.Time, error) {
if t, ok := val.(time.Time); ok {
return t, nil
}
s := fmt.Sprintf("%v", val)
return time.Parse(time.RFC3339, s)
}
// ValidateRuleSafety checks for potential cyclic/endless loops at rule creation time.
func ValidateRuleSafety(rule models.Rule) error {
trigger := strings.ToLower(rule.Trigger)
// If trigger is on_variant_updated / variant_change, check for recursive set_variant on same key
if trigger == "on_variant_updated" || trigger == "variant_change" {
for _, action := range rule.Actions {
if action.Type == "set_variant" {
targetKey, _ := action.Params["variant_key"].(string)
if targetKey == "" {
targetKey, _ = action.Params["key"].(string)
}
// Check if trigger targets this exact key without safe conditions
for _, cond := range rule.Conditions {
if cond.Field == "variants."+targetKey && (cond.Operator == "exists" || cond.Operator == "eq") {
return fmt.Errorf("cyclic rule detected: rule triggers on variant '%s' and unconditionally writes to '%s'", targetKey, targetKey)
}
}
}
}
}
return nil
}