feat: implement rule engine, worker pool, event bus, and readability scraping with enhanced logging and content sanitization.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user