diff --git a/cmd/server/main.go b/cmd/server/main.go
index 146de06..302f1a1 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -16,6 +16,7 @@ import (
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
"go.uber.org/zap"
@@ -50,9 +51,50 @@ func main() {
bus := events.NewEventBus()
pool := worker.NewPool(database, bus, 5, 100)
- // Register Scraper Action
+ // Register Core Action Handlers
scraper := readability.NewScraper(15 * time.Second)
pool.RegisterHandler("readability_scrape", scraper.Handler())
+
+ pool.RegisterHandler("set_variant", func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
+ key, _ := params["variant_key"].(string)
+ if key == "" {
+ key, _ = params["key"].(string)
+ }
+ if key == "" {
+ return nil, nil
+ }
+ val, _ := params["value"].(string)
+ contentType, _ := params["content_type"].(string)
+ if contentType == "" {
+ contentType = "text"
+ }
+ variant := models.EntryVariant{
+ EntryID: entry.ID,
+ VariantKey: key,
+ ContentType: contentType,
+ TextValue: val,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ return &worker.ActionResult{
+ ResultVariant: &variant,
+ }, nil
+ })
+
+ pool.RegisterHandler("mark_read", func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
+ trueVal := true
+ return &worker.ActionResult{
+ MarkRead: &trueVal,
+ }, nil
+ })
+
+ pool.RegisterHandler("mark_starred", func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
+ trueVal := true
+ return &worker.ActionResult{
+ MarkStarred: &trueVal,
+ }, nil
+ })
+
pool.Start(ctx)
// 4. Initialize Rules Engine
diff --git a/docs/005_rules_and_hooks_specification.md b/docs/005_rules_and_hooks_specification.md
new file mode 100644
index 0000000..402a64a
--- /dev/null
+++ b/docs/005_rules_and_hooks_specification.md
@@ -0,0 +1,150 @@
+# RSSPipe Rules & Automation Hooks Specification (`005_rules_and_hooks_specification.md`)
+
+## 1. Overview & Vision
+
+The **Rules & Hooks Engine** is the reactive event-processing backbone of RSSPipe. It connects the ingestion lifecycle with the **Multi-Variant Data Store** (`entry_variants`), allowing users to automate content enrichment (scraping, tagging, AI, audio), triage (read, starred, archive), and notifications without writing code.
+
+---
+
+## 2. Core Mental Model: 3-Level Execution Flow
+
+Every Rule is evaluated deterministically using a three-level hierarchy:
+
+```mermaid
+graph TD
+ A[Level 1: Event Trigger] --> B{Level 2: Conditions Evaluator}
+ B -- All / Any Match --> C[Level 3: Action Execution Pipeline]
+ B -- No Match --> D[No Action]
+ C --> E[Store Variant / Mutate Status / Dispatch Event]
+ E -. New Variant Event .-> A
+```
+
+---
+
+## 3. Level 1: Event Triggers
+
+The trigger specifies **when** a rule is evaluated.
+
+| Trigger Type | Identifier | Description | Sub-Options / Parameters |
+| :--- | :--- | :--- | :--- |
+| **New Entry Ingested** | `new_entry` (`on_post_created`) | Fires immediately after an article is discovered and parsed by crawler/OPML. | None |
+| **Variant Changed** | `variant_change` (`on_variant_updated`) | Fires when a specific variant key is added, updated, or deleted. | **Sub-Choice (Event Type)**: • `key_added` (Key didn't exist, now created) • `value_changed` (Key existed, value modified) • `key_deleted` (Variant removed) **Target Key Filter**: • Specific `variant_key` (e.g. `keywords`, `readability_full_text`, `ai_summary`) or `*` (any variant). |
+| **Cron Schedule (Future)** | `cron` | Recurring background schedule (e.g., daily digests, archival runs). | Cron expression (e.g. `0 8 * * *`) |
+| **Manual Execution** | `manual` | Triggered on-demand via UI or API. | None |
+
+---
+
+## 4. Level 2: Conditions & Allowlisted Fields
+
+Rules evaluate conditions against an **allowlist of strongly typed fields**. The UI dynamically presents appropriate operators and input controls based on the field type.
+
+### 4.1 Allowlisted Fields & Operators
+
+| Field Name | Type | Allowed Operators | UI Input Type & Behavior | Example Evaluation |
+| :--- | :--- | :--- | :--- | :--- |
+| `feed_id` | `string` / `set` | `==`, `!=`, `in`, `not_in` | **Multi-Select Feed Picker** with search, "Select All", and "Invert" buttons. | `feed_id in ["feed-1", "feed-2", ...]` (Applies to 24/30 feeds in 2 clicks) |
+| `category_id` | `string` / `set` | `==`, `!=`, `in`, `not_in` | **Category Dropdown / Multi-Select Chips**. | `category_id in ["cat-tech", "cat-ai"]` |
+| `title` | `string` | `contains`, `not_contains`, `==`, `!=`, `matches` (regex), `starts_with`, `ends_with` | Text input or comma-separated token badges. | `title contains "DeepSeek"` |
+| `raw_content` / `content` | `string` | `contains`, `not_contains`, `matches` (regex) | Text input. | `content contains "breaking news"` |
+| `author` | `string` | `contains`, `not_contains`, `==`, `!=` | Text input. | `author == "Paul Graham"` |
+| `created_at` / `published_at` | `timestamp` | `>=`, `<=`, `within_last_hours`, `older_than_days` | Number + unit dropdown (e.g. "Within last 24 hours"). | `published_at within_last_hours 48` |
+| `is_read` | `boolean` | `==`, `!=` | Toggle (Read / Unread). | `is_read == false` |
+| `is_starred` | `boolean` | `==`, `!=` | Toggle (Starred / Not Starred). | `is_starred == true` |
+| `variants.` | `variant_value` | `exists`, `not_exists`, `contains`, `not_contains`, `==`, `!=` | Key name + value input. | `variants.keywords contains "LLM"` |
+
+### 4.2 Match Modes
+
+- **`all` (AND)**: Every condition in the rule must evaluate to true.
+- **`any` (OR)**: At least one condition must evaluate to true.
+
+---
+
+## 5. Level 3: Action Execution Pipeline
+
+When a rule's conditions are satisfied, one or more actions execute sequentially.
+
+### 5.1 Supported Core Actions
+
+| Action Identifier | Parameters | Description |
+| :--- | :--- | :--- |
+| **`set_variant`** | `variant_key` (string), `value` (string/json/yaml), `content_type` (string) | Adds or updates a key-value variant (e.g., adding tags, keywords, custom annotations). |
+| **`readability_scrape`** | None | Fetches webpage full-text HTML and stores in `entry_variants(key='readability_full_text')`. |
+| **`mark_read`** | None | Sets `is_read = true` (auto-triage/archive). |
+| **`mark_starred`** | None | Sets `is_starred = true`. |
+
+---
+
+## 6. Safety Architecture: Endless Loop Prevention
+
+Because actions like `set_variant` emit `on_variant_updated` events, rules could theoretically trigger cyclic loops (e.g., Rule A triggers on `variant_change` for `key="tags"` and adds a new tag to `key="tags"`).
+
+### Loop Guard Mechanisms
+
+1. **Static Validation (Rule Creation Time)**:
+ - If Trigger is `variant_change` on `key="X"` and an Action is `set_variant` on `key="X"`, the UI / API warns and rejects identical recursive rules unless an explicit guard predicate (e.g., `value not contains "Y"`) is specified.
+2. **Runtime Execution Context & Depth Guard**:
+ - Every event emitted by the Event Bus carries a `RecursionDepth: int` (max depth = 5) and `ExecutionPath: []string` tracking rule IDs.
+ - If an event exceeds depth 5 or encounters the same Rule ID in its call stack, execution is aborted with an error log: `WARN: Cyclic rule execution aborted for rule: {rule_id}`.
+
+---
+
+## 7. UI/UX Design Specification for Settings
+
+### 7.1 Rule List View
+
+- Clean card layout grouped by Trigger type (`New Entry`, `Variant Change`, `Cron`).
+- Card shows a natural language sentence:
+ > *"When **New Post Arrives** in **24 Feeds** and **Title contains 'AI'** $\rightarrow$ **Scrape Full Text**, **Mark Starred**"*
+- Toggle switch (Active/Inactive), Edit button, Test button, Delete button.
+
+### 7.2 Interactive Rule Builder Modal Layout
+
+```
+┌────────────────────────────────────────────────────────────────────────┐
+│ Create Automation Rule [ ✕ ] │
+├────────────────────────────────────────────────────────────────────────┤
+│ Rule Name: [ Auto-Scrape Tech Feeds ] │
+│ │
+│ 1. WHEN (Trigger) │
+│ (●) New Entry Arrives ( ) Variant Changed ( ) Schedule │
+│ │
+│ [If Variant Changed Selected] │
+│ Event: [ Key Added ▼ ] Target Key: [ keywords ] │
+│ │
+│ 2. IF (Conditions) Match: [ All Conditions (AND) ]│
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Field: [ Feed ID ▼ ] Op: [ In List (24 Feeds) ▼ ] │ │
+│ │ [ Quick Select: All | None | Invert ] │ │
+│ │ [✓] Hacker News [✓] 36Kr [✓] FT Chinese [✓] InfoQ [ ] Douban│ │
+│ │ [ ✕ Remove]│ │
+│ ├─────────────────────────────────────────────────────────────────┤ │
+│ │ Field: [ Title ▼ ] Op: [ Contains Any ▼ ] │ │
+│ │ Value: [ AI, LLM, Machine Learning ] │ │
+│ │ [ ✕ Remove]│ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ [ + Add Condition ] │
+│ │
+│ 3. THEN DO (Actions) │
+│ [✓] ⚡ Scrape Full Text (Readability) │
+│ [✓] ⭐ Mark as Starred │
+│ [ ] 🏷️ Set Variant / Tag -> Key: [tags ] Val: [ai-digest ] │
+│ [ ] ✓ Mark as Read │
+│ │
+│ [ Cancel ] [ Create Rule ] │
+└────────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 8. Backlog Roadmap for Rules & Hooks
+
+The following capabilities are reserved for upcoming iterations:
+
+1. **Auto-Delete / Retention Rule Action** (`delete_entry`):
+ - Scope: Automatically delete matching entries older than $N$ days (requires retention policy discussion).
+2. **Aggregated Shares & Auto-Share Action** (`share_entry`):
+ - Scope: Action to automatically generate public share links for high-priority articles, plus a dedicated `/shares` dashboard aggregating all shared items.
+3. **Advanced AI & Audio Pipeline Actions**:
+ - `generate_ai_summary`: Calls LLM to summarize content $\rightarrow$ stores in `entry_variants.ai_summary`.
+ - `generate_tts`: Synthesizes article voice audio $\rightarrow$ stores `.mp3` blob in `./data/blobs/{id}/tts.mp3`.
+ - `generate_podcast`: Generates dual-speaker conversational audio discussions.
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index e6aa3df..8d8a2e8 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -17,6 +17,7 @@ import (
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/opml"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
"github.com/go-chi/chi/v5"
)
@@ -662,8 +663,13 @@ func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(body.Trigger) == "" {
body.Trigger = "on_post_created"
}
- if strings.TrimSpace(body.MatchMode) == "" {
- body.MatchMode = "all"
+ if err := rules.ValidateRuleSafety(models.Rule{
+ Trigger: body.Trigger,
+ Conditions: body.Conditions,
+ Actions: body.Actions,
+ }); err != nil {
+ h.writeError(w, http.StatusBadRequest, err.Error())
+ return
}
rule, err := h.db.CreateRule(body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions)
@@ -691,6 +697,15 @@ func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
return
}
+ if err := rules.ValidateRuleSafety(models.Rule{
+ Trigger: body.Trigger,
+ Conditions: body.Conditions,
+ Actions: body.Actions,
+ }); err != nil {
+ h.writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
rule, err := h.db.UpdateRule(id, body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions, body.IsActive)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
diff --git a/internal/rules/evaluator.go b/internal/rules/evaluator.go
index 8fbc78e..60167bf 100644
--- a/internal/rules/evaluator.go
+++ b/internal/rules/evaluator.go
@@ -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
+}
diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go
index ff4ee4b..8eaa56b 100644
--- a/internal/rules/rules_test.go
+++ b/internal/rules/rules_test.go
@@ -25,16 +25,17 @@ func TestRuleEvaluator(t *testing.T) {
TextValue: `["go", "compiler", "release"]`,
},
},
- PublishedAt: time.Now(),
+ PublishedAt: time.Now().Add(-2 * time.Hour),
+ CreatedAt: time.Now().Add(-2 * time.Hour),
}
- // Test 1: Title contains Go AND FeedID == feed-hn
+ // Test 1: Title contains Go AND FeedID in list
rule1 := models.Rule{
ID: "r1",
- Name: "Tech Feeds",
+ Name: "Tech Feeds in list",
MatchMode: "all",
Conditions: []models.RuleCondition{
- {Field: "feed_id", Operator: "eq", Value: "feed-hn"},
+ {Field: "feed_id", Operator: "in", Value: []string{"feed-hn", "feed-36kr", "feed-ft"}},
{Field: "title", Operator: "contains", Value: "Go 1.24"},
},
}
@@ -42,29 +43,76 @@ func TestRuleEvaluator(t *testing.T) {
t.Error("expected rule1 to match entry")
}
- // Test 2: Negative condition (author != Python Team)
+ // Test 2: not_in operator
rule2 := models.Rule{
ID: "r2",
- Name: "Author check",
+ Name: "Feed not in noise",
MatchMode: "all",
Conditions: []models.RuleCondition{
- {Field: "author", Operator: "neq", Value: "Python Team"},
+ {Field: "feed_id", Operator: "not_in", Value: "feed-spam,feed-ads"},
},
}
if !eval.Evaluate(rule2, entry) {
t.Error("expected rule2 to match entry")
}
- // Test 3: Variant matching (variants.keywords contains compiler)
+ // Test 3: contains_any operator
rule3 := models.Rule{
ID: "r3",
- Name: "Keywords check",
+ Name: "Title match any keyword",
MatchMode: "all",
Conditions: []models.RuleCondition{
- {Field: "variants.keywords", Operator: "contains", Value: "compiler"},
+ {Field: "title", Operator: "contains_any", Value: "Rust, Python, Go 1.24"},
},
}
if !eval.Evaluate(rule3, entry) {
- t.Error("expected rule3 to match entry variant")
+ t.Error("expected rule3 to match entry with contains_any")
+ }
+
+ // Test 4: exists operator on variants
+ rule4 := models.Rule{
+ ID: "r4",
+ Name: "Keywords exist",
+ MatchMode: "all",
+ Conditions: []models.RuleCondition{
+ {Field: "variants.keywords", Operator: "exists"},
+ {Field: "variants.nonexistent", Operator: "not_exists"},
+ },
+ }
+ if !eval.Evaluate(rule4, entry) {
+ t.Error("expected rule4 to evaluate variant existence correctly")
+ }
+
+ // Test 5: Timestamp within_last_hours
+ rule5 := models.Rule{
+ ID: "r5",
+ Name: "Recent article",
+ MatchMode: "all",
+ Conditions: []models.RuleCondition{
+ {Field: "published_at", Operator: "within_last_hours", Value: 24},
+ },
+ }
+ if !eval.Evaluate(rule5, entry) {
+ t.Error("expected rule5 to match article within last 24h")
+ }
+
+ // Test 6: Safety validator
+ unsafeRule := models.Rule{
+ Trigger: "variant_change",
+ Conditions: []models.RuleCondition{
+ {Field: "variants.tags", Operator: "exists"},
+ },
+ Actions: []models.RuleAction{
+ {
+ Type: "set_variant",
+ Params: map[string]interface{}{
+ "variant_key": "tags",
+ "value": "new-tag",
+ },
+ },
+ },
+ }
+ if err := ValidateRuleSafety(unsafeRule); err == nil {
+ t.Error("expected ValidateRuleSafety to detect cyclic loop")
}
}
diff --git a/web/src/components/SettingsView.tsx b/web/src/components/SettingsView.tsx
index 37183cb..f56a2bb 100644
--- a/web/src/components/SettingsView.tsx
+++ b/web/src/components/SettingsView.tsx
@@ -1,11 +1,9 @@
import React, { useState, useEffect } from 'react';
-import { Category, Feed, Rule, Stats, User } from '../types';
+import { Category, Feed, Rule, RuleAction, RuleCondition, Stats, User } from '../types';
import { api } from '../services/api';
import {
Rss,
Folder,
- FileSpreadsheet,
- Shield,
Trash2,
RefreshCw,
Plus,
@@ -17,13 +15,15 @@ import {
Edit2,
AlertCircle,
X,
- Clock,
- Timer,
- Eye,
Lock,
Key,
Sparkles,
Zap,
+ Eye,
+ FileSpreadsheet,
+ Shield,
+ Clock,
+ Timer,
} from 'lucide-react';
interface SettingsViewProps {
@@ -35,6 +35,25 @@ interface SettingsViewProps {
onOpenAddFeed: () => void;
}
+interface BuilderCondition {
+ id: string;
+ field: string;
+ operator: string;
+ value: string;
+ selectedFeedIds: string[];
+ selectedCatIds: string[];
+}
+
+interface BuilderActionsState {
+ scrapeFullText: boolean;
+ markStarred: boolean;
+ markRead: boolean;
+ setVariant: boolean;
+ variantKey: string;
+ variantValue: string;
+ contentType: string;
+}
+
export const SettingsView: React.FC = ({
currentUser,
feeds,
@@ -56,13 +75,37 @@ export const SettingsView: React.FC = ({
const [rules, setRules] = useState([]);
const [rulesLoading, setRulesLoading] = useState(false);
const [showRuleModal, setShowRuleModal] = useState(false);
+ const [editingRuleId, setEditingRuleId] = useState(null);
+
+ // Form State for Automation Rule Builder
const [ruleName, setRuleName] = useState('');
- const [ruleTrigger, setRuleTrigger] = useState('on_post_created');
- const [ruleMatchMode, setRuleMatchMode] = useState('all');
- const [ruleField, setRuleField] = useState('feed_id');
- const [ruleOp, setRuleOp] = useState('eq');
- const [ruleVal, setRuleVal] = useState('');
- const [ruleActionType, setRuleActionType] = useState('readability_scrape');
+ const [ruleTrigger, setRuleTrigger] = useState<'on_post_created' | 'on_variant_updated'>('on_post_created');
+ const [variantSubEvent, setVariantSubEvent] = useState<'key_added' | 'value_changed' | 'key_deleted'>('key_added');
+ const [targetVariantKey, setTargetVariantKey] = useState('keywords');
+ const [ruleMatchMode, setRuleMatchMode] = useState<'all' | 'any'>('all');
+
+ const [builderConditions, setBuilderConditions] = useState([
+ {
+ id: 'cond-1',
+ field: 'feed_id',
+ operator: 'in',
+ value: '',
+ selectedFeedIds: [],
+ selectedCatIds: [],
+ },
+ ]);
+
+ const [feedPickerSearch, setFeedPickerSearch] = useState('');
+
+ const [builderActions, setBuilderActions] = useState({
+ scrapeFullText: true,
+ markStarred: false,
+ markRead: false,
+ setVariant: false,
+ variantKey: 'tags',
+ variantValue: '',
+ contentType: 'text',
+ });
// Edit Feed Modal State
const [editingFeed, setEditingFeed] = useState(null);
@@ -140,33 +183,187 @@ export const SettingsView: React.FC = ({
}
};
- const handleCreateRule = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!ruleName.trim()) return;
- try {
- await api.createRule({
- name: ruleName.trim(),
- trigger: ruleTrigger,
- match_mode: ruleMatchMode,
- conditions: [
- {
- field: ruleField,
- operator: ruleOp,
- value: ruleVal.trim(),
- },
- ],
- actions: [
- {
- type: ruleActionType,
- },
- ],
+ const openCreateRuleModal = () => {
+ setEditingRuleId(null);
+ setRuleName('');
+ setRuleTrigger('on_post_created');
+ setVariantSubEvent('key_added');
+ setTargetVariantKey('keywords');
+ setRuleMatchMode('all');
+ setBuilderConditions([
+ {
+ id: 'cond-1',
+ field: 'feed_id',
+ operator: 'in',
+ value: '',
+ selectedFeedIds: [],
+ selectedCatIds: [],
+ },
+ ]);
+ setFeedPickerSearch('');
+ setBuilderActions({
+ scrapeFullText: true,
+ markStarred: false,
+ markRead: false,
+ setVariant: false,
+ variantKey: 'tags',
+ variantValue: '',
+ contentType: 'text',
+ });
+ setShowRuleModal(true);
+ };
+
+ const openEditRuleModal = (rule: Rule) => {
+ setEditingRuleId(rule.id);
+ setRuleName(rule.name);
+ setRuleTrigger(rule.trigger === 'on_variant_updated' ? 'on_variant_updated' : 'on_post_created');
+ setRuleMatchMode(rule.match_mode === 'any' ? 'any' : 'all');
+
+ const mappedConds: BuilderCondition[] = (rule.conditions || []).map((c, idx) => {
+ let feedIds: string[] = [];
+ let catIds: string[] = [];
+ let strVal = '';
+
+ if (c.field === 'feed_id') {
+ if (Array.isArray(c.value)) feedIds = c.value;
+ else if (typeof c.value === 'string') feedIds = c.value.split(',').map((s) => s.trim()).filter(Boolean);
+ } else if (c.field === 'category_id') {
+ if (Array.isArray(c.value)) catIds = c.value;
+ else if (typeof c.value === 'string') catIds = c.value.split(',').map((s) => s.trim()).filter(Boolean);
+ } else {
+ strVal = typeof c.value === 'object' ? JSON.stringify(c.value) : String(c.value ?? '');
+ }
+
+ return {
+ id: `cond-${idx}-${Date.now()}`,
+ field: c.field,
+ operator: c.operator,
+ value: strVal,
+ selectedFeedIds: feedIds,
+ selectedCatIds: catIds,
+ };
+ });
+
+ if (mappedConds.length === 0) {
+ mappedConds.push({
+ id: 'cond-1',
+ field: 'feed_id',
+ operator: 'in',
+ value: '',
+ selectedFeedIds: [],
+ selectedCatIds: [],
});
+ }
+ setBuilderConditions(mappedConds);
+
+ let scrape = false;
+ let starred = false;
+ let read = false;
+ let setVar = false;
+ let vKey = 'tags';
+ let vVal = '';
+ let cType = 'text';
+
+ rule.actions?.forEach((a) => {
+ if (a.type === 'readability_scrape') scrape = true;
+ if (a.type === 'mark_starred') starred = true;
+ if (a.type === 'mark_read') read = true;
+ if (a.type === 'set_variant') {
+ setVar = true;
+ vKey = String(a.params?.variant_key || a.params?.key || 'tags');
+ vVal = String(a.params?.value || '');
+ cType = String(a.params?.content_type || 'text');
+ }
+ });
+
+ setBuilderActions({
+ scrapeFullText: scrape,
+ markStarred: starred,
+ markRead: read,
+ setVariant: setVar,
+ variantKey: vKey,
+ variantValue: vVal,
+ contentType: cType,
+ });
+
+ setShowRuleModal(true);
+ };
+
+ const handleSaveRule = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!ruleName.trim()) {
+ alert('Please provide a descriptive rule name.');
+ return;
+ }
+
+ // Compile Conditions
+ const compiledConditions: RuleCondition[] = builderConditions.map((cond) => {
+ let finalVal: any = cond.value;
+ if (cond.field === 'feed_id' && (cond.operator === 'in' || cond.operator === 'not_in')) {
+ finalVal = cond.selectedFeedIds;
+ } else if (cond.field === 'category_id' && (cond.operator === 'in' || cond.operator === 'not_in')) {
+ finalVal = cond.selectedCatIds;
+ }
+ return {
+ field: cond.field,
+ operator: cond.operator,
+ value: finalVal,
+ };
+ });
+
+ // Compile Actions
+ const compiledActions: RuleAction[] = [];
+ if (builderActions.scrapeFullText) {
+ compiledActions.push({ type: 'readability_scrape' });
+ }
+ if (builderActions.markStarred) {
+ compiledActions.push({ type: 'mark_starred' });
+ }
+ if (builderActions.markRead) {
+ compiledActions.push({ type: 'mark_read' });
+ }
+ if (builderActions.setVariant && builderActions.variantKey.trim()) {
+ compiledActions.push({
+ type: 'set_variant',
+ params: {
+ variant_key: builderActions.variantKey.trim(),
+ value: builderActions.variantValue,
+ content_type: builderActions.contentType || 'text',
+ },
+ });
+ }
+
+ if (compiledActions.length === 0) {
+ alert('Please select at least one action to execute.');
+ return;
+ }
+
+ try {
+ if (editingRuleId) {
+ await api.updateRule(editingRuleId, {
+ name: ruleName.trim(),
+ trigger: ruleTrigger,
+ match_mode: ruleMatchMode,
+ conditions: compiledConditions,
+ actions: compiledActions,
+ is_active: true,
+ });
+ setMessage(`Rule "${ruleName}" updated successfully.`);
+ } else {
+ await api.createRule({
+ name: ruleName.trim(),
+ trigger: ruleTrigger,
+ match_mode: ruleMatchMode,
+ conditions: compiledConditions,
+ actions: compiledActions,
+ });
+ setMessage(`Rule "${ruleName}" created successfully.`);
+ }
setShowRuleModal(false);
- setRuleName('');
- setRuleVal('');
loadRules();
+ setTimeout(() => setMessage(null), 3000);
} catch (err: any) {
- alert(err.message || 'Failed to create rule');
+ alert(err.message || 'Failed to save rule');
}
};
@@ -196,6 +393,61 @@ export const SettingsView: React.FC = ({
}
};
+ const getNaturalLanguageSummary = (rule: Rule) => {
+ const triggerText = rule.trigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Ingested';
+ const condTexts: string[] = [];
+
+ if (rule.conditions && rule.conditions.length > 0) {
+ rule.conditions.forEach((c) => {
+ if (c.field === 'feed_id') {
+ const ids = Array.isArray(c.value) ? c.value : String(c.value).split(',');
+ if (c.operator === 'in') {
+ condTexts.push(`in ${ids.length} Feeds`);
+ } else if (c.operator === 'not_in') {
+ condTexts.push(`except ${ids.length} Feeds`);
+ } else if (c.operator === 'eq' || c.operator === '==') {
+ const f = feeds.find((item) => item.id === c.value);
+ condTexts.push(`in feed "${f?.title || c.value}"`);
+ } else {
+ condTexts.push(`feed ${c.operator} "${c.value}"`);
+ }
+ } else if (c.field === 'category_id') {
+ condTexts.push(`Category ${c.operator} "${c.value}"`);
+ } else if (c.field === 'title') {
+ condTexts.push(`Title ${c.operator.replace('_', ' ')} "${c.value}"`);
+ } else if (c.field === 'content' || c.field === 'raw_content') {
+ condTexts.push(`Content ${c.operator.replace('_', ' ')} "${c.value}"`);
+ } else if (c.field.startsWith('variants.')) {
+ condTexts.push(`${c.field} ${c.operator.replace('_', ' ')} "${c.value || ''}"`);
+ } else {
+ condTexts.push(`${c.field} ${c.operator} "${c.value}"`);
+ }
+ });
+ }
+
+ const condJoined =
+ condTexts.length > 0
+ ? rule.match_mode === 'any'
+ ? condTexts.join(' OR ')
+ : condTexts.join(' AND ')
+ : 'All Feeds (Global)';
+
+ const actionTexts: string[] = [];
+ rule.actions?.forEach((a) => {
+ if (a.type === 'readability_scrape') actionTexts.push('Scrape Full Text');
+ else if (a.type === 'mark_starred') actionTexts.push('Star');
+ else if (a.type === 'mark_read') actionTexts.push('Mark Read');
+ else if (a.type === 'set_variant') actionTexts.push(`Set Variant [${a.params?.variant_key || 'tags'}]`);
+ else actionTexts.push(a.type);
+ });
+
+ return {
+ trigger: triggerText,
+ conditions: condJoined,
+ actions: actionTexts.join(', ') || 'No Action',
+ };
+ };
+
// Filter feeds
const filteredFeeds = feeds.filter(
(f) =>
@@ -617,11 +869,11 @@ export const SettingsView: React.FC = ({
Automation Rules & Pipeline Hooks
- Filter feeds and automatically trigger enrichments (Readability Scrape, Star, Read) on post arrival or variant updates.
+ Filter feeds and automatically trigger enrichments (Readability Scrape, Star, Read, Tagging) on post arrival or variant updates.