feat: implement rule engine, worker pool, event bus, and readability scraping with enhanced logging and content sanitization.

This commit is contained in:
2026-08-25 00:31:32 -07:00
parent 5ea176d5ad
commit d440728ca5
29 changed files with 2788 additions and 204 deletions
+364 -20
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Category, Feed, Stats, User } from '../types';
import React, { useState, useEffect } from 'react';
import { Category, Feed, Rule, Stats, User } from '../types';
import { api } from '../services/api';
import {
Rss,
@@ -22,6 +22,8 @@ import {
Eye,
Lock,
Key,
Sparkles,
Zap,
} from 'lucide-react';
interface SettingsViewProps {
@@ -41,7 +43,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
onRefreshData,
onOpenAddFeed,
}) => {
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'viewer' | 'opml' | 'account'>('feeds');
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'rules' | 'viewer' | 'opml' | 'account'>('feeds');
const [autoReadBottom, setAutoReadBottom] = useState<boolean>(() => {
return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
});
@@ -50,6 +52,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
// Rules State
const [rules, setRules] = useState<Rule[]>([]);
const [rulesLoading, setRulesLoading] = useState(false);
const [showRuleModal, setShowRuleModal] = useState(false);
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');
// Edit Feed Modal State
const [editingFeed, setEditingFeed] = useState<Feed | null>(null);
const [editTitle, setEditTitle] = useState('');
@@ -110,6 +124,78 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
}
};
useEffect(() => {
loadRules();
}, []);
const loadRules = async () => {
setRulesLoading(true);
try {
const data = await api.listRules();
setRules(data || []);
} catch {
// ignore
} finally {
setRulesLoading(false);
}
};
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,
},
],
});
setShowRuleModal(false);
setRuleName('');
setRuleVal('');
loadRules();
} catch (err: any) {
alert(err.message || 'Failed to create rule');
}
};
const handleToggleRuleActive = async (rule: Rule) => {
try {
await api.updateRule(rule.id, {
name: rule.name,
trigger: rule.trigger,
match_mode: rule.match_mode,
conditions: rule.conditions,
actions: rule.actions,
is_active: !rule.is_active,
});
loadRules();
} catch (err: any) {
alert(err.message || 'Failed to toggle rule');
}
};
const handleDeleteRule = async (id: string) => {
if (!confirm('Are you sure you want to delete this rule?')) return;
try {
await api.deleteRule(id);
loadRules();
} catch (err: any) {
alert(err.message || 'Failed to delete rule');
}
};
// Filter feeds
const filteredFeeds = feeds.filter(
(f) =>
@@ -148,14 +234,20 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
}
};
const handleRefreshFeed = async (feedId: string) => {
const handleCreateCategory = async (e: React.FormEvent) => {
e.preventDefault();
if (!newCatTitle.trim()) return;
setLoading(true);
try {
await api.refreshFeed(feedId);
await api.createCategory(newCatTitle.trim());
setNewCatTitle('');
onRefreshData();
setMessage('Feed refreshed successfully.');
setMessage(`Category "${newCatTitle}" created.`);
setTimeout(() => setMessage(null), 3000);
} catch (err: any) {
alert(err.message || 'Failed to refresh feed');
alert(err.message || 'Failed to create category');
} finally {
setLoading(false);
}
};
@@ -170,28 +262,22 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
setMessage(`Deleted category "${deleteConfirm.title}".`);
}
onRefreshData();
setTimeout(() => setMessage(null), 3500);
setTimeout(() => setMessage(null), 3000);
} catch (err: any) {
alert(err.message || 'Delete operation failed');
alert(err.message || 'Failed to delete');
} finally {
setDeleteConfirm(null);
}
};
const handleCreateCategory = async (e: React.FormEvent) => {
e.preventDefault();
if (!newCatTitle.trim()) return;
setLoading(true);
const handleRefreshFeed = async (feedId: string) => {
try {
await api.createCategory(newCatTitle.trim());
setNewCatTitle('');
await api.refreshFeed(feedId);
onRefreshData();
setMessage('Category created.');
setTimeout(() => setMessage(null), 3000);
setMessage('Feed refreshed successfully.');
setTimeout(() => setMessage(null), 2500);
} catch (err: any) {
alert(err.message || 'Failed to create category');
} finally {
setLoading(false);
alert(err.message || 'Failed to refresh feed');
}
};
@@ -234,6 +320,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
<span>Categories ({categories.length})</span>
</button>
<button
className={`settings-nav-item ${activeTab === 'rules' ? 'active' : ''}`}
onClick={() => setActiveTab('rules')}
>
<Sparkles size={15} />
<span>Rules & Hooks ({rules.length})</span>
</button>
<button
className={`settings-nav-item ${activeTab === 'viewer' ? 'active' : ''}`}
onClick={() => setActiveTab('viewer')}
@@ -514,6 +608,118 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
</div>
)}
{/* Rules & Automation Hooks Tab */}
{activeTab === 'rules' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<div>
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
Automation Rules & Pipeline Hooks
</h2>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Filter feeds and automatically trigger enrichments (Readability Scrape, Star, Read) on post arrival or variant updates.
</p>
</div>
<button className="btn-primary" onClick={() => setShowRuleModal(true)}>
<Plus size={14} />
<span>Create Rule</span>
</button>
</div>
{rulesLoading ? (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
<Loader2 size={24} className="spin" style={{ margin: '0 auto 0.5rem auto' }} />
<p>Loading rules...</p>
</div>
) : rules.length === 0 ? (
<div className="settings-card" style={{ textAlign: 'center', padding: '3rem 1rem' }}>
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>⚡</div>
<h3 style={{ fontSize: '1rem', fontWeight: 600 }}>No Automation Rules Defined</h3>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', maxWidth: 400, margin: '0.5rem auto 1.5rem auto' }}>
Create rules to automatically scrape full-text content or apply tags whenever new articles arrive from specific feeds.
</p>
<button className="btn-primary" onClick={() => setShowRuleModal(true)}>
<Plus size={14} />
<span>Create Your First Rule</span>
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{rules.map((rule) => (
<div key={rule.id} className="settings-card" style={{ padding: '1rem 1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: rule.is_active ? 'rgba(16, 185, 129, 0.1)' : 'var(--bg-tertiary)',
color: rule.is_active ? 'var(--success)' : 'var(--text-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Zap size={16} />
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{rule.name}</span>
<span className="tag-badge" style={{ fontSize: '0.7rem' }}>
Trigger: {rule.trigger}
</span>
{!rule.is_active && (
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>(Inactive)</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{rule.conditions && rule.conditions.length > 0 ? (
rule.conditions.map((c, i) => (
<span key={i} style={{ background: 'var(--bg-tertiary)', padding: '2px 6px', borderRadius: 4 }}>
If <strong>{c.field}</strong> {c.operator} <em>"{String(c.value)}"</em>
</span>
))
) : (
<span>Always matches</span>
)}
<span>➜</span>
{rule.actions?.map((a, i) => (
<span key={i} style={{ background: 'rgba(56, 189, 248, 0.1)', color: 'var(--accent)', padding: '2px 6px', borderRadius: 4, fontWeight: 500 }}>
Action: {a.type}
</span>
))}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
className={`btn-secondary`}
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
onClick={() => handleToggleRuleActive(rule)}
>
{rule.is_active ? 'Disable' : 'Enable'}
</button>
<button
className="btn-icon"
style={{ color: 'var(--danger)' }}
onClick={() => handleDeleteRule(rule.id)}
title="Delete Rule"
>
<Trash2 size={15} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 3. Viewer Preferences Tab */}
{activeTab === 'viewer' && (
<div>
@@ -975,6 +1181,144 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
</div>
</div>
)}
{/* Create Automation Rule Modal */}
{showRuleModal && (
<div className="modal-overlay" onClick={() => setShowRuleModal(false)}>
<div className="modal-card" style={{ maxWidth: 520 }} onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Sparkles size={17} style={{ color: 'var(--accent)' }} />
<h3>Create Automation / Hook Rule</h3>
</div>
<button className="btn-icon" onClick={() => setShowRuleModal(false)}>
<X size={16} />
</button>
</div>
<form onSubmit={handleCreateRule}>
<div className="modal-body">
<div className="form-group">
<label className="form-label">Rule Name</label>
<input
type="text"
className="form-input"
placeholder="e.g. Auto Scrape TechCrunch"
value={ruleName}
onChange={(e) => setRuleName(e.target.value)}
required
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div className="form-group">
<label className="form-label">Trigger Event</label>
<select
className="form-select"
value={ruleTrigger}
onChange={(e) => setRuleTrigger(e.target.value)}
>
<option value="on_post_created">On New Post Ingested</option>
<option value="on_variant_updated">On Variant Updated</option>
<option value="manual">Manual Execution</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Condition Matching</label>
<select
className="form-select"
value={ruleMatchMode}
onChange={(e) => setRuleMatchMode(e.target.value)}
>
<option value="all">All Conditions Match (AND)</option>
<option value="any">Any Condition Matches (OR)</option>
</select>
</div>
</div>
<div style={{ background: 'var(--bg-tertiary)', padding: '0.85rem', borderRadius: 8, margin: '0.5rem 0 1rem 0' }}>
<label className="form-label" style={{ marginBottom: '0.5rem' }}>Condition Predicate</label>
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '0.5rem', marginBottom: '0.5rem' }}>
<select
className="form-select"
value={ruleField}
onChange={(e) => setRuleField(e.target.value)}
>
<option value="feed_id">Feed ID</option>
<option value="feed_title">Feed Title</option>
<option value="title">Article Title</option>
<option value="author">Author</option>
<option value="url">URL</option>
<option value="variants.keywords">Keywords (Variant)</option>
<option value="variants.tags">Tags (Variant)</option>
</select>
<select
className="form-select"
value={ruleOp}
onChange={(e) => setRuleOp(e.target.value)}
>
<option value="eq">Equals (==)</option>
<option value="neq">Not Equals (!=)</option>
<option value="contains">Contains substring</option>
<option value="not_contains">Does not contain</option>
<option value="matches">Regex Match</option>
</select>
</div>
{ruleField === 'feed_id' ? (
<select
className="form-select"
value={ruleVal}
onChange={(e) => setRuleVal(e.target.value)}
required
>
<option value="">Select Subscribed Feed...</option>
{feeds.map((f) => (
<option key={f.id} value={f.id}>
{f.title}
</option>
))}
</select>
) : (
<input
type="text"
className="form-input"
placeholder="Expected value (e.g. AI, OpenAI)"
value={ruleVal}
onChange={(e) => setRuleVal(e.target.value)}
required
/>
)}
</div>
<div className="form-group">
<label className="form-label">Action To Trigger</label>
<select
className="form-select"
value={ruleActionType}
onChange={(e) => setRuleActionType(e.target.value)}
>
<option value="readability_scrape">⚡ Scrape Full-Text (Readability)</option>
<option value="mark_starred">⭐ Mark as Starred</option>
<option value="mark_read">✓ Mark as Read</option>
</select>
</div>
</div>
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<button type="button" className="btn-secondary" onClick={() => setShowRuleModal(false)}>
Cancel
</button>
<button type="submit" className="btn-primary">
Create Rule
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};