feat: implement rule application engine with full-text variant support, batch entry processing, and tag visualization in UI.
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user