diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 8d8a2e8..3911697 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -722,3 +722,52 @@ func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) { } h.writeJSON(w, http.StatusOK, map[string]string{"message": "rule deleted"}) } + +func (h *Handler) ApplyRule(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + rule, err := h.db.GetRule(id) + if err != nil { + h.writeError(w, http.StatusNotFound, "rule not found") + return + } + + evaluator := rules.NewEvaluator() + matchedCount := 0 + queuedJobs := 0 + + // Paginate through ALL entries in batches of 500 + batchSize := 500 + offset := 0 + for { + entries, total, err := h.db.ListEntries(db.EntryFilter{ + View: "all", + Limit: batchSize, + Offset: offset, + }) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to query entries: "+err.Error()) + return + } + + for _, entry := range entries { + if evaluator.Evaluate(*rule, entry) { + matchedCount++ + for _, action := range rule.Actions { + h.pool.Dispatch(r.Context(), action, entry, 0) + queuedJobs++ + } + } + } + + offset += batchSize + if offset >= total || len(entries) == 0 { + break + } + } + + h.writeJSON(w, http.StatusOK, map[string]interface{}{ + "message": fmt.Sprintf("Rule applied to %d matching post(s). %d action(s) queued.", matchedCount, queuedJobs), + "matched_count": matchedCount, + "queued_jobs": queuedJobs, + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index aa1aa56..9af9f94 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -75,6 +75,7 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http r.Post("/rules", h.CreateRule) r.Put("/rules/{id}", h.UpdateRule) r.Delete("/rules/{id}", h.DeleteRule) + r.Post("/rules/{id}/apply", h.ApplyRule) // OPML r.Post("/opml/import", h.ImportOPML) diff --git a/internal/db/db.go b/internal/db/db.go index c5acb2d..ac33430 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -725,6 +725,22 @@ func (d *DB) ListEntries(filter EntryFilter) ([]models.FeedEntry, int, error) { entries = append(entries, e) } + // Batch-load variants for all returned entries + if len(entries) > 0 { + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = e.ID + } + variantMap, err := d.batchLoadVariants(ids) + if err == nil { + for i := range entries { + if v, ok := variantMap[entries[i].ID]; ok { + entries[i].Variants = v + } + } + } + } + return entries, total, nil } @@ -810,6 +826,55 @@ func (d *DB) ListVariantsForEntry(entryID string) (map[string]models.EntryVarian return variants, nil } +// batchLoadVariants loads lightweight variants (tags, ai_summary keys, etc.) for multiple entries. +// Excludes large text variants like readability_full_text for performance. +func (d *DB) batchLoadVariants(entryIDs []string) (map[string]map[string]models.EntryVariant, error) { + if len(entryIDs) == 0 { + return nil, nil + } + placeholders := make([]string, len(entryIDs)) + args := make([]interface{}, len(entryIDs)) + for i, id := range entryIDs { + placeholders[i] = "?" + args[i] = id + } + query := ` + SELECT entry_id, variant_key, content_type, + CASE WHEN variant_key IN ('readability_full_text') THEN '' ELSE COALESCE(text_value, '') END, + COALESCE(blob_path, ''), metadata, created_at, updated_at + FROM entry_variants + WHERE entry_id IN (` + strings.Join(placeholders, ",") + `) + ` + rows, err := d.Query(query, args...) + if err != nil { + return nil, err + } + defer func() { + _ = rows.Close() + }() + + result := make(map[string]map[string]models.EntryVariant) + for rows.Next() { + var v models.EntryVariant + var metaStr sql.NullString + err := rows.Scan( + &v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt, + ) + if err != nil { + return nil, err + } + v.Metadata = make(map[string]interface{}) + if metaStr.Valid && metaStr.String != "" { + _ = json.Unmarshal([]byte(metaStr.String), &v.Metadata) + } + if result[v.EntryID] == nil { + result[v.EntryID] = make(map[string]models.EntryVariant) + } + result[v.EntryID][v.VariantKey] = v + } + return result, nil +} + func (d *DB) SetVariant(v models.EntryVariant) error { now := time.Now().UTC() metaBytes, _ := json.Marshal(v.Metadata) diff --git a/internal/rules/evaluator.go b/internal/rules/evaluator.go index 60167bf..dd45739 100644 --- a/internal/rules/evaluator.go +++ b/internal/rules/evaluator.go @@ -59,10 +59,19 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface case "url": return entry.URL case "content", "raw_content", "rss_content": + var parts []string if entry.Content != "" { - return entry.Content + parts = append(parts, entry.Content) } - return entry.RawContent + if entry.RawContent != "" && entry.RawContent != entry.Content { + parts = append(parts, entry.RawContent) + } + if entry.Variants != nil { + if v, ok := entry.Variants["readability_full_text"]; ok && v.TextValue != "" { + parts = append(parts, v.TextValue) + } + } + return strings.Join(parts, "\n") case "is_read": return entry.IsRead case "is_starred": @@ -127,13 +136,17 @@ func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) case "neq", "!=": return !strings.EqualFold(actualStr, expectedStr) case "contains": - return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr)) + expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n") + return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean)) case "not_contains": - return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr)) + expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n") + return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean)) case "starts_with": - return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedStr)) + expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n") + return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedClean)) case "ends_with": - return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedStr)) + expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n") + return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedClean)) case "contains_any": tokens := parseStringList(expected) for _, token := range tokens { @@ -204,19 +217,31 @@ func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bo func parseStringList(val interface{}) []string { var result []string + clean := func(s string) string { + s = strings.TrimSpace(s) + s = strings.Trim(s, "\"'`") + return strings.TrimSpace(s) + } + switch v := val.(type) { case []interface{}: for _, item := range v { - result = append(result, strings.TrimSpace(fmt.Sprintf("%v", item))) + itemStr := clean(fmt.Sprintf("%v", item)) + if itemStr != "" { + result = append(result, itemStr) + } } case []string: for _, item := range v { - result = append(result, strings.TrimSpace(item)) + itemStr := clean(item) + if itemStr != "" { + result = append(result, itemStr) + } } case string: parts := strings.Split(v, ",") for _, part := range parts { - trimmed := strings.TrimSpace(part) + trimmed := clean(part) if trimmed != "" { result = append(result, trimmed) } diff --git a/web/src/components/SettingsView.tsx b/web/src/components/SettingsView.tsx index f56a2bb..ae148c2 100644 --- a/web/src/components/SettingsView.tsx +++ b/web/src/components/SettingsView.tsx @@ -24,6 +24,7 @@ import { Shield, Clock, Timer, + ChevronDown, } from 'lucide-react'; interface SettingsViewProps { @@ -76,6 +77,8 @@ export const SettingsView: React.FC = ({ const [rulesLoading, setRulesLoading] = useState(false); const [showRuleModal, setShowRuleModal] = useState(false); const [editingRuleId, setEditingRuleId] = useState(null); + const [saveLoading, setSaveLoading] = useState(false); + const [saveDropdownOpen, setSaveDropdownOpen] = useState(false); // Form State for Automation Rule Builder const [ruleName, setRuleName] = useState(''); @@ -120,7 +123,7 @@ export const SettingsView: React.FC = ({ // Custom Delete Confirmation Modal State const [deleteConfirm, setDeleteConfirm] = useState<{ - type: 'feed' | 'category'; + type: 'feed' | 'category' | 'rule'; id: string; title: string; } | null>(null); @@ -210,6 +213,7 @@ export const SettingsView: React.FC = ({ variantValue: '', contentType: 'text', }); + setSaveDropdownOpen(false); setShowRuleModal(true); }; @@ -286,14 +290,14 @@ export const SettingsView: React.FC = ({ contentType: cType, }); + setSaveDropdownOpen(false); setShowRuleModal(true); }; - const handleSaveRule = async (e: React.FormEvent) => { - e.preventDefault(); + const handleSaveRule = async (e?: React.FormEvent, applyToExisting: boolean = false) => { + if (e) e.preventDefault(); if (!ruleName.trim()) { - alert('Please provide a descriptive rule name.'); - return; + return; // Inline validation below the input field already shows the error } // Compile Conditions @@ -338,7 +342,10 @@ export const SettingsView: React.FC = ({ return; } + setSaveLoading(true); + setSaveDropdownOpen(false); try { + let targetRuleId = editingRuleId; if (editingRuleId) { await api.updateRule(editingRuleId, { name: ruleName.trim(), @@ -348,22 +355,31 @@ export const SettingsView: React.FC = ({ actions: compiledActions, is_active: true, }); - setMessage(`Rule "${ruleName}" updated successfully.`); } else { - await api.createRule({ + const created = await api.createRule({ name: ruleName.trim(), trigger: ruleTrigger, match_mode: ruleMatchMode, conditions: compiledConditions, actions: compiledActions, }); - setMessage(`Rule "${ruleName}" created successfully.`); + targetRuleId = created.id; } + + if (applyToExisting && targetRuleId) { + const res = await api.applyRule(targetRuleId); + setMessage(`Rule "${ruleName}" saved and applied to ${res.matched_count} matching post(s).`); + } else { + setMessage(`Rule "${ruleName}" ${editingRuleId ? 'updated' : 'created'} successfully.`); + } + setShowRuleModal(false); loadRules(); - setTimeout(() => setMessage(null), 3000); + setTimeout(() => setMessage(null), 3500); } catch (err: any) { alert(err.message || 'Failed to save rule'); + } finally { + setSaveLoading(false); } }; @@ -383,16 +399,6 @@ export const SettingsView: React.FC = ({ } }; - 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[] = []; @@ -509,9 +515,13 @@ export const SettingsView: React.FC = ({ if (deleteConfirm.type === 'feed') { await api.deleteFeed(deleteConfirm.id); setMessage(`Unsubscribed from "${deleteConfirm.title}".`); - } else { + } else if (deleteConfirm.type === 'category') { await api.deleteCategory(deleteConfirm.id); setMessage(`Deleted category "${deleteConfirm.title}".`); + } else if (deleteConfirm.type === 'rule') { + await api.deleteRule(deleteConfirm.id); + setMessage(`Deleted rule "${deleteConfirm.title}".`); + loadRules(); } onRefreshData(); setTimeout(() => setMessage(null), 3000); @@ -909,8 +919,9 @@ export const SettingsView: React.FC = ({ width: 34, height: 34, borderRadius: '8px', - background: rule.is_active ? 'rgba(2, 132, 199, 0.1)' : 'var(--bg-tertiary)', - color: rule.is_active ? '#0284c7' : 'var(--text-muted)', + background: rule.is_active ? 'var(--bg-tertiary)' : 'var(--bg-app)', + border: '1px solid var(--border)', + color: rule.is_active ? 'var(--text-main)' : 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -918,7 +929,7 @@ export const SettingsView: React.FC = ({ marginTop: 2, }} > - +
@@ -936,11 +947,11 @@ export const SettingsView: React.FC = ({ {/* Natural Language Sentence Preview */}
- When {summary.trigger}{' '} + When {summary.trigger}{' '} matching{' '} - {summary.conditions}{' '} + {summary.conditions}{' '} ➜ Then{' '} - {summary.actions} + {summary.actions}
@@ -964,7 +975,14 @@ export const SettingsView: React.FC = ({ -
-
+ handleSaveRule(e, false)} + style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }} + > +
{/* Rule Name */}
- + setRuleName(e.target.value)} - required + style={!ruleName.trim() ? { borderColor: 'var(--danger)' } : undefined} /> + {!ruleName.trim() && ( +
+ Please provide a descriptive rule name. +
+ )}
{/* LEVEL 1: TRIGGER SELECTION */} -
+
- 1 - When This Trigger Occurs + + 1 + + + When This Trigger Occurs +
@@ -1485,8 +1552,8 @@ export const SettingsView: React.FC = ({ gap: '0.5rem', padding: '0.65rem 0.85rem', borderRadius: 6, - border: `1.5px solid ${ruleTrigger === 'on_post_created' ? '#0284c7' : 'var(--border)'}`, - background: ruleTrigger === 'on_post_created' ? 'rgba(2, 132, 199, 0.05)' : 'var(--bg-app)', + border: `1.5px solid ${ruleTrigger === 'on_post_created' ? 'var(--border-dark)' : 'var(--border)'}`, + background: ruleTrigger === 'on_post_created' ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', }} > @@ -1495,9 +1562,12 @@ export const SettingsView: React.FC = ({ name="rule_trigger" checked={ruleTrigger === 'on_post_created'} onChange={() => setRuleTrigger('on_post_created')} + style={{ accentColor: '#a1a1aa' }} />
-
New Post Ingested
+
+ New Post Ingested +
Trigger on new RSS arrival
@@ -1509,8 +1579,8 @@ export const SettingsView: React.FC = ({ gap: '0.5rem', padding: '0.65rem 0.85rem', borderRadius: 6, - border: `1.5px solid ${ruleTrigger === 'on_variant_updated' ? '#0284c7' : 'var(--border)'}`, - background: ruleTrigger === 'on_variant_updated' ? 'rgba(2, 132, 199, 0.05)' : 'var(--bg-app)', + border: `1.5px solid ${ruleTrigger === 'on_variant_updated' ? 'var(--border-dark)' : 'var(--border)'}`, + background: ruleTrigger === 'on_variant_updated' ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', }} > @@ -1519,18 +1589,34 @@ export const SettingsView: React.FC = ({ name="rule_trigger" checked={ruleTrigger === 'on_variant_updated'} onChange={() => setRuleTrigger('on_variant_updated')} + style={{ accentColor: '#a1a1aa' }} />
-
Variant Changed
+
+ Variant Changed +
Trigger on enrichment update
{ruleTrigger === 'on_variant_updated' && ( -
+
- + = ({
{/* LEVEL 2: CONDITIONS & FILTER SCOPE */} -
+
- 2 - Match Conditions (Filter Scope) + + 2 + + + Match Conditions (Filter Scope) +
@@ -1580,8 +1690,24 @@ export const SettingsView: React.FC = ({
{builderConditions.map((cond, idx) => ( -
-
+
+
{/* Field Selector */} setFeedPickerSearch(e.target.value)} - style={{ border: 'none', background: 'transparent', outline: 'none', fontSize: '0.75rem', width: '100%', color: 'var(--text-main)' }} + style={{ + border: 'none', + background: 'transparent', + outline: 'none', + fontSize: '0.75rem', + width: '100%', + color: 'var(--text-main)', + }} />
@@ -1746,11 +1905,19 @@ export const SettingsView: React.FC = ({
-
+
{cond.selectedFeedIds.length} of {feeds.length} feeds selected
-
+
{feeds .filter((f) => f.title.toLowerCase().includes(feedPickerSearch.toLowerCase())) .map((feed) => { @@ -1764,7 +1931,8 @@ export const SettingsView: React.FC = ({ gap: '0.4rem', padding: '0.3rem 0.45rem', borderRadius: 4, - background: isChecked ? 'rgba(2, 132, 199, 0.08)' : 'transparent', + background: isChecked ? 'var(--bg-tertiary)' : 'transparent', + border: `1px solid ${isChecked ? 'var(--border-dark)' : 'transparent'}`, fontSize: '0.75rem', cursor: 'pointer', overflow: 'hidden', @@ -1781,9 +1949,17 @@ export const SettingsView: React.FC = ({ updated[idx] = { ...cond, selectedFeedIds: next }; setBuilderConditions(updated); }} - style={{ accentColor: '#0284c7' }} + style={{ accentColor: '#a1a1aa' }} /> - + {feed.title} @@ -1825,9 +2001,9 @@ export const SettingsView: React.FC = ({ setBuilderConditions(updated); }} style={{ - border: `1px solid ${isChecked ? '#0284c7' : 'var(--border)'}`, - background: isChecked ? 'rgba(2, 132, 199, 0.1)' : 'var(--bg-card)', - color: isChecked ? '#0284c7' : 'var(--text-main)', + border: `1px solid ${isChecked ? 'var(--border-dark)' : 'var(--border)'}`, + background: isChecked ? 'var(--bg-tertiary)' : 'var(--bg-app)', + color: isChecked ? 'var(--text-main)' : 'var(--text-secondary)', padding: '0.2rem 0.6rem', borderRadius: 4, fontSize: '0.75rem', @@ -1879,7 +2055,11 @@ export const SettingsView: React.FC = ({ { const updated = [...builderConditions]; @@ -1916,10 +2096,32 @@ export const SettingsView: React.FC = ({
{/* LEVEL 3: ACTION EXECUTION PIPELINE */} -
+
- 3 - Execute Action Pipeline + + 3 + + + Execute Action Pipeline +
@@ -1928,10 +2130,10 @@ export const SettingsView: React.FC = ({ type="checkbox" checked={builderActions.scrapeFullText} onChange={(e) => setBuilderActions({ ...builderActions, scrapeFullText: e.target.checked })} - style={{ accentColor: '#0284c7', width: 16, height: 16 }} + style={{ accentColor: '#a1a1aa', width: 16, height: 16 }} />
- ⚡ Scrape Full Text (Readability) + ⚡ Scrape Full Text (Readability)
Extracts clean full-text markdown and saves to readability_full_text variant.
@@ -1943,11 +2145,13 @@ export const SettingsView: React.FC = ({ type="checkbox" checked={builderActions.markStarred} onChange={(e) => setBuilderActions({ ...builderActions, markStarred: e.target.checked })} - style={{ accentColor: '#0284c7', width: 16, height: 16 }} + style={{ accentColor: '#a1a1aa', width: 16, height: 16 }} />
- ⭐ Mark as Starred -
Automatically star matching posts for priority reading.
+ ⭐ Mark as Starred +
+ Automatically star matching posts for priority reading. +
@@ -1956,10 +2160,10 @@ export const SettingsView: React.FC = ({ type="checkbox" checked={builderActions.markRead} onChange={(e) => setBuilderActions({ ...builderActions, markRead: e.target.checked })} - style={{ accentColor: '#0284c7', width: 16, height: 16 }} + style={{ accentColor: '#a1a1aa', width: 16, height: 16 }} />
- ✓ Mark as Read (Auto-Archive) + ✓ Mark as Read (Auto-Archive)
Automatically mark as read on ingestion.
@@ -1969,16 +2173,20 @@ export const SettingsView: React.FC = ({ type="checkbox" checked={builderActions.setVariant} onChange={(e) => setBuilderActions({ ...builderActions, setVariant: e.target.checked })} - style={{ accentColor: '#0284c7', width: 16, height: 16, marginTop: 2 }} + style={{ accentColor: '#a1a1aa', width: 16, height: 16, marginTop: 2 }} />
- 🏷️ Set Custom Variant / Tag -
Attach extensible variant metadata to the entry.
+ 🏷️ Set Custom Variant / Tag +
+ Attach extensible variant metadata to the entry. +
{builderActions.setVariant && (
- + = ({ />
- + = ({ />
- +