import React, { useState, useEffect } from 'react'; import { Category, Feed, Rule, RuleAction, RuleCondition, Stats, User } from '../types'; import { api } from '../services/api'; import { Rss, Folder, Trash2, RefreshCw, Plus, Search, CheckCircle2, UploadCloud, Download, Loader2, Edit2, AlertCircle, X, Lock, Key, Sparkles, Zap, Eye, FileSpreadsheet, Shield, Clock, Timer, } from 'lucide-react'; interface SettingsViewProps { currentUser: User; feeds: Feed[]; categories: Category[]; stats: Stats | null; onRefreshData: () => void; 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, categories, stats, onRefreshData, onOpenAddFeed, }) => { const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'rules' | 'viewer' | 'opml' | 'account'>('feeds'); const [autoReadBottom, setAutoReadBottom] = useState(() => { return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false'; }); const [feedSearch, setFeedSearch] = useState(''); const [newCatTitle, setNewCatTitle] = useState(''); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); // Rules State 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' | '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); const [editTitle, setEditTitle] = useState(''); const [editCategoryId, setEditCategoryId] = useState(''); const [editInterval, setEditInterval] = useState(30); const [editTimeout, setEditTimeout] = useState(10); const [editSaving, setEditSaving] = useState(false); // Debug Log Modal State const [debugFeed, setDebugFeed] = useState(null); // Custom Delete Confirmation Modal State const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'feed' | 'category'; id: string; title: string; } | null>(null); // OPML state const [opmlFile, setOpmlFile] = useState(null); const [opmlLoading, setOpmlLoading] = useState(false); const [opmlResult, setOpmlResult] = useState(null); // Change Password state const [oldPassword, setOldPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [passwordLoading, setPasswordLoading] = useState(false); const [passwordError, setPasswordError] = useState(null); const [passwordSuccess, setPasswordSuccess] = useState(null); const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault(); setPasswordError(null); setPasswordSuccess(null); if (newPassword.length < 6) { setPasswordError('New password must be at least 6 characters long'); return; } if (newPassword !== confirmPassword) { setPasswordError('New passwords do not match'); return; } setPasswordLoading(true); try { await api.changePassword(oldPassword, newPassword); setPasswordSuccess('Password updated successfully! Please use your new password next time you log in.'); setOldPassword(''); setNewPassword(''); setConfirmPassword(''); } catch (err: any) { setPasswordError(err.message || 'Failed to update password'); } finally { setPasswordLoading(false); } }; useEffect(() => { loadRules(); }, []); const loadRules = async () => { setRulesLoading(true); try { const data = await api.listRules(); setRules(data || []); } catch { // ignore } finally { setRulesLoading(false); } }; 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); loadRules(); setTimeout(() => setMessage(null), 3000); } catch (err: any) { alert(err.message || 'Failed to save 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'); } }; 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) => f.title.toLowerCase().includes(feedSearch.toLowerCase()) || f.feed_url.toLowerCase().includes(feedSearch.toLowerCase()) || (f.category_title && f.category_title.toLowerCase().includes(feedSearch.toLowerCase())) ); const openEditModal = (feed: Feed) => { setEditingFeed(feed); setEditTitle(feed.title); setEditCategoryId(feed.category_id || ''); setEditInterval(feed.refresh_interval_minutes || 30); setEditTimeout(feed.timeout_seconds || 10); }; const handleSaveFeed = async (e: React.FormEvent) => { e.preventDefault(); if (!editingFeed) return; setEditSaving(true); try { await api.updateFeed(editingFeed.id, { title: editTitle.trim() || undefined, category_id: editCategoryId || '', refresh_interval_minutes: Number(editInterval), timeout_seconds: Number(editTimeout), }); setEditingFeed(null); onRefreshData(); setMessage(`Updated "${editTitle || editingFeed.title}" settings.`); setTimeout(() => setMessage(null), 3500); } catch (err: any) { alert(err.message || 'Failed to update feed'); } finally { setEditSaving(false); } }; const handleCreateCategory = async (e: React.FormEvent) => { e.preventDefault(); if (!newCatTitle.trim()) return; setLoading(true); try { await api.createCategory(newCatTitle.trim()); setNewCatTitle(''); onRefreshData(); setMessage(`Category "${newCatTitle}" created.`); setTimeout(() => setMessage(null), 3000); } catch (err: any) { alert(err.message || 'Failed to create category'); } finally { setLoading(false); } }; const handleConfirmDelete = async () => { if (!deleteConfirm) return; try { if (deleteConfirm.type === 'feed') { await api.deleteFeed(deleteConfirm.id); setMessage(`Unsubscribed from "${deleteConfirm.title}".`); } else { await api.deleteCategory(deleteConfirm.id); setMessage(`Deleted category "${deleteConfirm.title}".`); } onRefreshData(); setTimeout(() => setMessage(null), 3000); } catch (err: any) { alert(err.message || 'Failed to delete'); } finally { setDeleteConfirm(null); } }; const handleRefreshFeed = async (feedId: string) => { try { await api.refreshFeed(feedId); onRefreshData(); setMessage('Feed refreshed successfully.'); setTimeout(() => setMessage(null), 2500); } catch (err: any) { alert(err.message || 'Failed to refresh feed'); } }; const handleImportOPML = async () => { if (!opmlFile) return; setOpmlLoading(true); setOpmlResult(null); try { const res = await api.importOPML(opmlFile); setOpmlResult(`Successfully imported ${res.imported_count} new feeds (out of ${res.total_parsed} parsed).`); onRefreshData(); } catch (err: any) { alert(err.message || 'Failed to import OPML'); } finally { setOpmlLoading(false); } }; const handleExportOPML = () => { window.open(api.exportOPMLUrl(), '_blank'); }; return (
{/* Settings Tab Navigation (Responsive sidebar on desktop / top chips on mobile) */}
{/* Main Settings Content Area */}
{message && (
{message}
)} {/* 1. Subscriptions Management Tab */} {activeTab === 'feeds' && (

Subscription Management

Configure background pulling interval, request timeouts, and categories per feed.

setFeedSearch(e.target.value)} />
{filteredFeeds.map((feed) => ( ))}
FEED CATEGORY SCHEDULE STATUS ACTIONS
{feed.title}
{feed.feed_url}
{feed.category_title ? ( {feed.category_title} ) : ( — )}
{feed.refresh_interval_minutes || 30}m {feed.timeout_seconds || 10}s
{feed.fetch_error ? ( ) : ( ● Healthy )}
)} {/* 2. Categories Tab */} {activeTab === 'categories' && (

Category Management

Create, organize, and delete category folders.

{/* Create Category */}

Add New Category

setNewCatTitle(e.target.value)} style={{ maxWidth: 360 }} required />
{/* Existing Categories */}

Existing Categories ({categories.length})

{categories.map((cat) => (
{cat.title} {cat.feed_count || 0} feeds
))}
)} {/* Rules & Automation Hooks Tab */} {activeTab === 'rules' && (

Automation Rules & Pipeline Hooks

Filter feeds and automatically trigger enrichments (Readability Scrape, Star, Read, Tagging) on post arrival or variant updates.

{rulesLoading ? (

Loading rules...

) : rules.length === 0 ? (
⚡

No Automation Rules Defined

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

) : (
{rules.map((rule) => { const summary = getNaturalLanguageSummary(rule); return (
{rule.name} {summary.trigger} {!rule.is_active && ( (Disabled) )}
{/* Natural Language Sentence Preview */}
When {summary.trigger}{' '} matching{' '} {summary.conditions}{' '} ➜ Then{' '} {summary.actions}
); })}
)}
)} {/* 3. Viewer Preferences Tab */} {activeTab === 'viewer' && (

Viewer & Reading Preferences

Configure reading behavior, auto-read triggers, and article view options.

Auto-Mark as Read

Control how articles are automatically marked as read during your reading session.

)} {/* 4. OPML Migration Tab */} {activeTab === 'opml' && (

OPML Migration & Backup

Easily import from external RSS services or download a complete backup.

{opmlResult && (
{opmlResult}
)}

Import Subscriptions

Select an OPML XML file exported from Miniflux, Feedly, or NetNewsWire.

document.getElementById('settings-opml-file')?.click()} > { if (e.target.files && e.target.files[0]) { setOpmlFile(e.target.files[0]); } }} />
{opmlFile ? opmlFile.name : 'Click to select OPML file'}
{opmlFile && ( )}

Export Subscriptions

Download an OPML 2.0 XML file containing all your {feeds.length} subscribed feeds and folder trees.

)} {/* 4. Account & System Tab */} {activeTab === 'account' && (

Account & System Health

Single-user gateway credentials and storage metrics.

Admin Profile

Username: {currentUser.username}
User ID: {currentUser.id}
Access Mode: Single-User RSS Information Gateway

Change Password

Update your single-user administrator password. Minimum 6 characters required.

{passwordSuccess && (
{passwordSuccess}
)} {passwordError && (
{passwordError}
)}
setOldPassword(e.target.value)} required />
setNewPassword(e.target.value)} required />
setConfirmPassword(e.target.value)} required />

Storage & Engine Stats

Database Engine: SQLite 3 with WAL (Write-Ahead Logging) Mode
Total Subscribed Feeds: {stats?.total_feeds || feeds.length}
Total Unread Articles: {stats?.total_unread || 0}
Total Starred Articles: {stats?.total_starred || 0}
)}
{/* Edit Feed Modal (Pulling interval, timeout, category, title) */} {editingFeed && (
setEditingFeed(null)}>
e.stopPropagation()}>

Edit Subscription Settings

setEditTitle(e.target.value)} required />
)} {/* Debug Logs Modal (Persistent error inspector) */} {debugFeed && (
setDebugFeed(null)}>
e.stopPropagation()}>

Feed Fetch Diagnostics

Feed: {debugFeed.title}
URL: {debugFeed.feed_url}
                  {debugFeed.fetch_error || 'No error recorded.'}
                
)} {/* Solid Confirmation Modal for Deletion (Never auto-dismisses) */} {deleteConfirm && (
setDeleteConfirm(null)}>
e.stopPropagation()}>

Confirm Deletion

Are you sure you want to {deleteConfirm.type === 'feed' ? 'unsubscribe from' : 'delete category'}{' '} "{deleteConfirm.title}"? {deleteConfirm.type === 'category' && (

Feeds inside this category will remain and become uncategorized.

)}
)} {/* Create / Edit Automation Rule Modal */} {showRuleModal && (
setShowRuleModal(false)}>
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
{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'}
)}
); };