- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission. - Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication. - Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse). - Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization. - Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5). - Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations. - Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets. - Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels. - Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar. - Add architectural documentation and worklog in docs/009_worklog.md.
477 lines
13 KiB
Go
477 lines
13 KiB
Go
package rules
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
|
)
|
|
|
|
type Evaluator struct{}
|
|
|
|
func NewEvaluator() *Evaluator {
|
|
return &Evaluator{}
|
|
}
|
|
|
|
// Evaluate checks whether a single FeedEntry satisfies a Rule's conditions (legacy / static evaluation).
|
|
func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
|
|
return e.EvaluateTransition(rule, nil, &entry, nil)
|
|
}
|
|
|
|
// EvaluateTransition evaluates a rule against the (OldState, NewState, EntryDiff) transition delta.
|
|
func (e *Evaluator) EvaluateTransition(rule models.Rule, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
|
if newEntry == nil {
|
|
return false
|
|
}
|
|
if len(rule.Conditions) == 0 {
|
|
return true
|
|
}
|
|
|
|
matchMode := strings.ToLower(rule.MatchMode)
|
|
if matchMode == "" || matchMode == "all" {
|
|
for _, cond := range rule.Conditions {
|
|
if !e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// matchMode == "any"
|
|
for _, cond := range rule.Conditions {
|
|
if e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (e *Evaluator) evaluateTransitionCondition(cond models.RuleCondition, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
|
opLower := strings.ToLower(strings.TrimSpace(cond.Operator))
|
|
|
|
// 1. Transition predicates: became_present, changed, removed
|
|
switch opLower {
|
|
case "became_present", "added":
|
|
return e.checkBecamePresent(cond.Field, oldEntry, newEntry, diff)
|
|
case "changed", "updated":
|
|
return e.checkFieldChanged(cond.Field, oldEntry, newEntry, diff)
|
|
case "removed", "deleted":
|
|
return e.checkFieldRemoved(cond.Field, oldEntry, newEntry, diff)
|
|
}
|
|
|
|
// 2. Standard state guards & value comparisons evaluated on NewState
|
|
actualValue := e.resolveField(cond.Field, newEntry)
|
|
return e.compare(actualValue, opLower, cond.Value)
|
|
}
|
|
|
|
func (e *Evaluator) checkBecamePresent(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
|
if newEntry == nil {
|
|
return false
|
|
}
|
|
|
|
// If diff is available, check direct addition
|
|
if strings.HasPrefix(field, "variants.") {
|
|
vKey := strings.TrimPrefix(field, "variants.")
|
|
if strings.Contains(vKey, ".") {
|
|
vKey = strings.Split(vKey, ".")[0]
|
|
}
|
|
if diff != nil && diff.HasVariantAddition(vKey) {
|
|
return true
|
|
}
|
|
// Fallback comparison between oldEntry and newEntry
|
|
oldHas := oldEntry != nil && oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != ""
|
|
newHas := newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != ""
|
|
return !oldHas && newHas
|
|
}
|
|
|
|
if field == "metadata.tags" || field == "tags" {
|
|
oldTags := getTagsSlice(oldEntry)
|
|
newTags := getTagsSlice(newEntry)
|
|
return len(oldTags) == 0 && len(newTags) > 0
|
|
}
|
|
|
|
oldVal := e.resolveField(field, oldEntry)
|
|
newVal := e.resolveField(field, newEntry)
|
|
return (oldVal == nil || fmt.Sprintf("%v", oldVal) == "") && (newVal != nil && fmt.Sprintf("%v", newVal) != "")
|
|
}
|
|
|
|
func (e *Evaluator) checkFieldChanged(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
|
if oldEntry == nil || newEntry == nil {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(field, "variants.") {
|
|
vKey := strings.TrimPrefix(field, "variants.")
|
|
if strings.Contains(vKey, ".") {
|
|
vKey = strings.Split(vKey, ".")[0]
|
|
}
|
|
if diff != nil && (diff.HasVariantAddition(vKey) || diff.HasVariantChange(vKey)) {
|
|
return true
|
|
}
|
|
}
|
|
oldVal := e.resolveField(field, oldEntry)
|
|
newVal := e.resolveField(field, newEntry)
|
|
return !reflect.DeepEqual(oldVal, newVal)
|
|
}
|
|
|
|
func (e *Evaluator) checkFieldRemoved(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
|
if oldEntry == nil {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(field, "variants.") {
|
|
vKey := strings.TrimPrefix(field, "variants.")
|
|
if diff != nil {
|
|
for _, r := range diff.VariantsRemoved {
|
|
if r == vKey {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
oldHas := oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != ""
|
|
newHas := newEntry != nil && newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != ""
|
|
return oldHas && !newHas
|
|
}
|
|
oldVal := e.resolveField(field, oldEntry)
|
|
newVal := e.resolveField(field, newEntry)
|
|
return (oldVal != nil && fmt.Sprintf("%v", oldVal) != "") && (newVal == nil || fmt.Sprintf("%v", newVal) == "")
|
|
}
|
|
|
|
func (e *Evaluator) resolveField(field string, entry *models.FeedEntry) interface{} {
|
|
if entry == nil {
|
|
return nil
|
|
}
|
|
|
|
fieldLower := strings.ToLower(strings.TrimSpace(field))
|
|
switch fieldLower {
|
|
case "feed_id":
|
|
return entry.FeedID
|
|
case "feed_title":
|
|
return entry.FeedTitle
|
|
case "title":
|
|
return entry.Title
|
|
case "author":
|
|
return entry.Author
|
|
case "url":
|
|
return entry.URL
|
|
case "content", "raw_content", "rss_content":
|
|
var parts []string
|
|
if entry.Title != "" {
|
|
parts = append(parts, entry.Title)
|
|
}
|
|
if entry.Content != "" {
|
|
parts = append(parts, entry.Content)
|
|
}
|
|
if entry.RawContent != "" && entry.RawContent != entry.Content {
|
|
parts = append(parts, entry.RawContent)
|
|
}
|
|
if entry.Variants != nil {
|
|
if v, ok := entry.Variants["readability_full_text"]; ok && v.TextValue != "" {
|
|
parts = append(parts, v.TextValue)
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
case "is_read":
|
|
return entry.IsRead
|
|
case "is_starred":
|
|
return entry.IsStarred
|
|
case "published_at":
|
|
return entry.PublishedAt
|
|
case "created_at":
|
|
return entry.CreatedAt
|
|
case "tags", "metadata.tags":
|
|
return getTagsSlice(entry)
|
|
}
|
|
|
|
// Variant resolution: "variants.ai_summary.word_count", "variants.ai_summary", "variants.readability_full_text"
|
|
if strings.HasPrefix(fieldLower, "variants.") {
|
|
path := strings.TrimPrefix(fieldLower, "variants.")
|
|
parts := strings.Split(path, ".")
|
|
vKey := parts[0]
|
|
|
|
if entry.Variants != nil {
|
|
if v, ok := entry.Variants[vKey]; ok {
|
|
if len(parts) > 1 {
|
|
subProp := parts[1]
|
|
if subProp == "word_count" {
|
|
return float64(len(strings.Fields(v.TextValue)))
|
|
}
|
|
if subProp == "char_count" || subProp == "length" {
|
|
return float64(len(v.TextValue))
|
|
}
|
|
if v.Metadata != nil {
|
|
return v.Metadata[subProp]
|
|
}
|
|
}
|
|
return v.TextValue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool {
|
|
opLower := strings.ToLower(strings.TrimSpace(op))
|
|
|
|
// Handle is_empty / is_present / exists / not_exists
|
|
if opLower == "is_empty" || opLower == "not_exists" {
|
|
if actual == nil {
|
|
return true
|
|
}
|
|
if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" {
|
|
return true
|
|
}
|
|
if slice, ok := actual.([]string); ok && len(slice) == 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
if opLower == "is_present" || opLower == "exists" {
|
|
if actual == nil {
|
|
return false
|
|
}
|
|
if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" {
|
|
return false
|
|
}
|
|
if slice, ok := actual.([]string); ok && len(slice) == 0 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
if actual == nil {
|
|
return opLower == "neq" || opLower == "!=" || opLower == "not_in" || opLower == "not_contains"
|
|
}
|
|
|
|
// Handle numeric comparisons (word_count >= 500, etc.)
|
|
if actualNum, ok := toFloat64(actual); ok == nil {
|
|
if expNum, okExp := toFloat64(expected); okExp == nil {
|
|
switch opLower {
|
|
case ">=":
|
|
return actualNum >= expNum
|
|
case ">":
|
|
return actualNum > expNum
|
|
case "<=":
|
|
return actualNum <= expNum
|
|
case "<":
|
|
return actualNum < expNum
|
|
case "eq", "==":
|
|
return actualNum == expNum
|
|
case "neq", "!=":
|
|
return actualNum != expNum
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle Time comparisons
|
|
if actualTime, ok := actual.(time.Time); ok {
|
|
return e.compareTime(actualTime, opLower, expected)
|
|
}
|
|
|
|
// Handle slice containment (e.g. tags contains "Tech")
|
|
if actualSlice, ok := actual.([]string); ok {
|
|
expectedStr := fmt.Sprintf("%v", expected)
|
|
switch opLower {
|
|
case "contains", "in":
|
|
for _, item := range actualSlice {
|
|
if strings.EqualFold(item, expectedStr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case "contains_any":
|
|
tokens := parseStringList(expected)
|
|
for _, item := range actualSlice {
|
|
for _, t := range tokens {
|
|
if strings.EqualFold(item, t) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
actualStr := fmt.Sprintf("%v", actual)
|
|
expectedStr := fmt.Sprintf("%v", expected)
|
|
|
|
switch opLower {
|
|
case "eq", "==":
|
|
return strings.EqualFold(actualStr, expectedStr)
|
|
case "neq", "!=":
|
|
return !strings.EqualFold(actualStr, expectedStr)
|
|
case "contains":
|
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
|
return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
|
case "not_contains":
|
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
|
return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
|
case "starts_with":
|
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
|
return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
|
case "ends_with":
|
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
|
return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
|
case "contains_any":
|
|
tokens := parseStringList(expected)
|
|
for _, token := range tokens {
|
|
if strings.Contains(strings.ToLower(actualStr), strings.ToLower(token)) {
|
|
return true
|
|
}
|
|
}
|
|
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 {
|
|
return false
|
|
}
|
|
return re.MatchString(actualStr)
|
|
default:
|
|
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 getTagsSlice(entry *models.FeedEntry) []string {
|
|
if entry == nil || entry.Metadata == nil {
|
|
return nil
|
|
}
|
|
if raw, ok := entry.Metadata["tags"]; ok {
|
|
return parseStringList(raw)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseStringList(val interface{}) []string {
|
|
var result []string
|
|
clean := func(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.Trim(s, "\"'`")
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
switch v := val.(type) {
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
itemStr := clean(fmt.Sprintf("%v", item))
|
|
if itemStr != "" {
|
|
result = append(result, itemStr)
|
|
}
|
|
}
|
|
case []string:
|
|
for _, item := range v {
|
|
itemStr := clean(item)
|
|
if itemStr != "" {
|
|
result = append(result, itemStr)
|
|
}
|
|
}
|
|
case string:
|
|
parts := strings.Split(v, ",")
|
|
for _, part := range parts {
|
|
trimmed := clean(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 == "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)
|
|
}
|
|
|
|
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
|
|
}
|