diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
| commit | c1decadb590c4d79d92bcc4df7772119a5c91244 (patch) | |
| tree | 468f9fa37535dbb90cc5d4061eb20de83912131e /internal/filter/filter.go | |
| download | aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip | |
Initial Aegis Usenet client release
Diffstat (limited to 'internal/filter/filter.go')
| -rw-r--r-- | internal/filter/filter.go | 245 |
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) +} |
