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.

- @@ -636,85 +888,92 @@ export const SettingsView: React.FC = ({
⚡

No Automation Rules Defined

-

- Create rules to automatically scrape full-text content or apply tags whenever new articles arrive from specific feeds. +

+ Create rules to automatically scrape full-text articles across your favorite subscriptions, tag topics, or auto-triage feeds.

-
) : (
- {rules.map((rule) => ( -
-
-
-
- -
-
-
- {rule.name} - - Trigger: {rule.trigger} - - {!rule.is_active && ( - (Inactive) - )} + {rules.map((rule) => { + const summary = getNaturalLanguageSummary(rule); + return ( +
+
+
+
+
-
- {rule.conditions && rule.conditions.length > 0 ? ( - rule.conditions.map((c, i) => ( - - If {c.field} {c.operator} "{String(c.value)}" - - )) - ) : ( - Always matches - )} - ➜ - {rule.actions?.map((a, i) => ( - - Action: {a.type} +
+
+ + {rule.name} - ))} + + {summary.trigger} + + {!rule.is_active && ( + (Disabled) + )} +
+ + {/* Natural Language Sentence Preview */} +
+ When {summary.trigger}{' '} + matching{' '} + {summary.conditions}{' '} + ➜ Then{' '} + {summary.actions} +
-
-
- - +
+ + + +
-
- ))} + ); + })}
)}
@@ -1182,137 +1441,624 @@ export const SettingsView: React.FC = ({
)} - {/* Create Automation Rule Modal */} + {/* Create / Edit Automation Rule Modal */} {showRuleModal && (
setShowRuleModal(false)}> -
e.stopPropagation()}> -
-
- -

Create Automation / Hook Rule

+
e.stopPropagation()}> +
+
+ +

{editingRuleId ? 'Edit Automation Rule' : 'Create Automation Rule'}

-
-
-
- + +
+ {/* Rule Name */} +
+ setRuleName(e.target.value)} required />
-
-
- - + {/* LEVEL 1: TRIGGER SELECTION */} +
+
+ 1 + When This Trigger Occurs
-
- - -
-
+ setRuleTrigger('on_post_created')} + /> +
+
New Post Ingested
+
Trigger on new RSS arrival
+
+ -
- -
- - - + setRuleTrigger('on_variant_updated')} + /> +
+
Variant Changed
+
Trigger on enrichment update
+
+
- {ruleField === 'feed_id' ? ( - - ) : ( - setRuleVal(e.target.value)} - required - /> + {ruleTrigger === 'on_variant_updated' && ( +
+
+ + +
+ +
+ + setTargetVariantKey(e.target.value)} + /> +
+
)}
-
- - + {/* LEVEL 2: CONDITIONS & FILTER SCOPE */} +
+
+
+ 2 + Match Conditions (Filter Scope) +
+ +
+ Match: + +
+
+ +
+ {builderConditions.map((cond, idx) => ( +
+
+ {/* Field Selector */} + + + {/* Contextual Operator Selector */} + + + {/* Delete condition button */} + {builderConditions.length > 1 && ( + + )} +
+ + {/* Contextual Value Input */} + {cond.field === 'feed_id' && (cond.operator === 'in' || cond.operator === 'not_in') ? ( +
+
+
+ + setFeedPickerSearch(e.target.value)} + style={{ border: 'none', background: 'transparent', outline: 'none', fontSize: '0.75rem', width: '100%', color: 'var(--text-main)' }} + /> +
+ +
+ + + +
+
+ +
+ {cond.selectedFeedIds.length} of {feeds.length} feeds selected +
+ +
+ {feeds + .filter((f) => f.title.toLowerCase().includes(feedPickerSearch.toLowerCase())) + .map((feed) => { + const isChecked = cond.selectedFeedIds.includes(feed.id); + return ( + + ); + })} +
+
+ ) : cond.field === 'feed_id' ? ( + + ) : cond.field === 'category_id' ? ( +
+ {categories.map((cat) => { + const isChecked = cond.selectedCatIds.includes(cat.id); + return ( + + ); + })} +
+ ) : cond.field === 'published_at' ? ( +
+ { + const updated = [...builderConditions]; + updated[idx] = { ...cond, value: e.target.value }; + setBuilderConditions(updated); + }} + style={{ width: 120 }} + /> + + {cond.operator === 'older_than_days' ? 'days' : 'hours'} + +
+ ) : cond.field === 'is_read' || cond.field === 'is_starred' ? ( + + ) : cond.operator === 'exists' || cond.operator === 'not_exists' ? ( +
+ Checks if the variant key exists and is non-empty. +
+ ) : ( + { + const updated = [...builderConditions]; + updated[idx] = { ...cond, value: e.target.value }; + setBuilderConditions(updated); + }} + /> + )} +
+ ))} + + +
+
+ + {/* LEVEL 3: ACTION EXECUTION PIPELINE */} +
+
+ 3 + Execute Action Pipeline +
+ +
+ + + + + + + +
+
+ + {/* NATURAL LANGUAGE PREVIEW SUMMARY CALLOUT */} +
+
+ Rule Summary Preview +
+
+ When {ruleTrigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Arrives'}{' '} + {builderConditions.length > 0 && ( + <> + where{' '} + + {builderConditions + .map((c) => { + if (c.field === 'feed_id' && (c.operator === 'in' || c.operator === 'not_in')) { + return `${c.operator === 'in' ? 'in' : 'except'} ${c.selectedFeedIds.length} feeds`; + } + return `${c.field} ${c.operator} "${c.value}"`; + }) + .join(ruleMatchMode === 'any' ? ' OR ' : ' AND ')} + + + )} + {' '}➜ Then{' '} + + {[ + builderActions.scrapeFullText && 'Scrape Full Text', + builderActions.markStarred && 'Star Post', + builderActions.markRead && 'Mark Read', + builderActions.setVariant && `Set [${builderActions.variantKey || 'tags'}]`, + ] + .filter(Boolean) + .join(', ') || 'No Actions Selected'} + +
-
+