summaryrefslogtreecommitdiffstats
path: root/internal/filter/filter.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/filter/filter.go')
-rw-r--r--internal/filter/filter.go245
1 files changed, 245 insertions, 0 deletions
diff --git a/internal/filter/filter.go b/internal/filter/filter.go
new file mode 100644
index 0000000..ba1f40b
--- /dev/null
+++ b/internal/filter/filter.go
@@ -0,0 +1,245 @@
+// Package filter evaluates deterministic, local-only Usenet filters.
+package filter
+
+import (
+ "fmt"
+ "path"
+ "regexp"
+ "strings"
+ "time"
+)
+
+type Field string
+
+const (
+ FieldAny Field = "any"
+ FieldFrom Field = "from"
+ FieldSubject Field = "subject"
+ FieldNewsgroups Field = "newsgroups"
+ FieldMessageID Field = "message-id"
+ FieldReferences Field = "references"
+ FieldDate Field = "date"
+ FieldBody Field = "body"
+ FieldHeader Field = "header"
+ FieldVFaceHash Field = "vface-hash"
+ FieldRead Field = "read"
+)
+
+type Operator string
+
+const (
+ OperatorContains Operator = "contains"
+ OperatorExact Operator = "exact"
+ OperatorRegexp Operator = "regexp"
+ OperatorGlob Operator = "glob"
+)
+
+type Action string
+
+const (
+ ActionKeep Action = "keep"
+ ActionHide Action = "hide"
+ ActionMarkRead Action = "mark-read"
+ ActionHighlight Action = "highlight"
+ ActionTag Action = "tag"
+ ActionMuteThread Action = "mute-thread"
+)
+
+type Rule struct {
+ ID string `json:"id"`
+ Enabled bool `json:"enabled"`
+ Field Field `json:"field"`
+ Header string `json:"header,omitempty"`
+ Operator Operator `json:"operator"`
+ Pattern string `json:"pattern"`
+ Action Action `json:"action"`
+ Tag string `json:"tag,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type Article struct {
+ From string
+ Subject string
+ Newsgroups string
+ MessageID string
+ References string
+ Date string
+ Body string
+ Read bool
+ Headers map[string]string
+}
+
+type Result struct {
+ Hidden bool
+ MarkRead bool
+ Highlight bool
+ MuteThread bool
+ Tags []string
+ MatchedRules []string
+}
+
+type InvalidRuleError struct{ Message string }
+
+func (e *InvalidRuleError) Error() string { return e.Message }
+
+func Evaluate(article Article, rules []Rule) (Result, error) {
+ var result Result
+ for _, rule := range rules {
+ if !rule.Enabled {
+ continue
+ }
+ if err := validateRule(rule); err != nil {
+ return Result{}, err
+ }
+ values := fieldValues(article, rule)
+ matched, err := match(values, rule.Operator, rule.Pattern)
+ if err != nil {
+ return Result{}, fmt.Errorf("rule %q: %w", rule.ID, err)
+ }
+ if !matched {
+ continue
+ }
+ result.MatchedRules = append(result.MatchedRules, rule.ID)
+ switch rule.Action {
+ case ActionHide:
+ result.Hidden = true
+ case ActionMarkRead:
+ result.MarkRead = true
+ case ActionHighlight:
+ result.Highlight = true
+ case ActionTag:
+ if rule.Tag != "" && !contains(result.Tags, rule.Tag) {
+ result.Tags = append(result.Tags, rule.Tag)
+ }
+ case ActionMuteThread:
+ result.MuteThread = true
+ case ActionKeep:
+ default:
+ return Result{}, &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported action %q", rule.ID, rule.Action)}
+ }
+ }
+ return result, nil
+}
+
+func validateRule(rule Rule) error {
+ if strings.TrimSpace(rule.Pattern) == "" {
+ return &InvalidRuleError{Message: fmt.Sprintf("rule %q has an empty pattern", rule.ID)}
+ }
+ switch rule.Field {
+ case FieldAny, FieldFrom, FieldSubject, FieldNewsgroups, FieldMessageID,
+ FieldReferences, FieldDate, FieldBody, FieldHeader, FieldVFaceHash, FieldRead:
+ default:
+ return &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported field %q", rule.ID, rule.Field)}
+ }
+ if rule.Field == FieldHeader && strings.TrimSpace(rule.Header) == "" {
+ return &InvalidRuleError{Message: fmt.Sprintf("rule %q needs a header name", rule.ID)}
+ }
+ switch rule.Operator {
+ case OperatorContains, OperatorExact, OperatorRegexp, OperatorGlob:
+ default:
+ return &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported operator %q", rule.ID, rule.Operator)}
+ }
+ switch rule.Action {
+ case ActionKeep, ActionHide, ActionMarkRead, ActionHighlight, ActionTag, ActionMuteThread:
+ default:
+ return &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported action %q", rule.ID, rule.Action)}
+ }
+ return nil
+}
+
+func fieldValues(article Article, rule Rule) []string {
+ if rule.Field == FieldAny {
+ return []string{article.From, article.Subject, article.Newsgroups, article.MessageID, article.References, article.Date, article.Body, vfaceValue(article.Headers)}
+ }
+ if rule.Field == FieldHeader {
+ for key, value := range article.Headers {
+ if strings.EqualFold(key, rule.Header) {
+ return []string{value}
+ }
+ }
+ return nil
+ }
+ switch rule.Field {
+ case FieldFrom:
+ return []string{article.From}
+ case FieldSubject:
+ return []string{article.Subject}
+ case FieldNewsgroups:
+ return []string{article.Newsgroups}
+ case FieldMessageID:
+ return []string{article.MessageID}
+ case FieldReferences:
+ return []string{article.References}
+ case FieldDate:
+ return []string{article.Date}
+ case FieldBody:
+ return []string{article.Body}
+ case FieldVFaceHash:
+ return []string{vfaceValue(article.Headers)}
+ case FieldRead:
+ return []string{fmt.Sprintf("%t", article.Read)}
+ default:
+ return nil
+ }
+}
+
+func vfaceValue(headers map[string]string) string {
+ for _, key := range []string{"Identity-Hash", "X-VFace-Hash", "X-VFace-PNG-SHA256"} {
+ for header, value := range headers {
+ if strings.EqualFold(header, key) {
+ return value
+ }
+ }
+ }
+ return ""
+}
+
+func match(values []string, operator Operator, pattern string) (bool, error) {
+ for _, value := range values {
+ switch operator {
+ case OperatorContains:
+ if strings.Contains(strings.ToLower(value), strings.ToLower(pattern)) {
+ return true, nil
+ }
+ case OperatorExact:
+ if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(pattern)) {
+ return true, nil
+ }
+ case OperatorRegexp:
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return false, err
+ }
+ if re.MatchString(value) {
+ return true, nil
+ }
+ case OperatorGlob:
+ matched, err := path.Match(strings.ToLower(pattern), strings.ToLower(value))
+ if err != nil {
+ return false, err
+ }
+ if matched {
+ return true, nil
+ }
+ }
+ }
+ return false, nil
+}
+
+func contains(values []string, wanted string) bool {
+ for _, value := range values {
+ if value == wanted {
+ return true
+ }
+ }
+ return false
+}
+
+// ParseDate is kept here so callers can validate date filters without adding
+// parser logic to the UI. It accepts the common RFC 5322 form and RFC3339.
+func ParseDate(value string) (time.Time, error) {
+ if parsed, err := time.Parse(time.RFC1123Z, value); err == nil {
+ return parsed, nil
+ }
+ return time.Parse(time.RFC3339, value)
+}