mirror of
https://github.com/mgrove36/ical-filter-proxy.git
synced 2026-03-03 01:47:07 +00:00
308 lines
9.0 KiB
Go
308 lines
9.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
ics "github.com/arran4/golang-ical"
|
|
)
|
|
|
|
// All structs defined in this file are used to unmarshall yaml configuration and
|
|
// provide helper functions that are used to fetch and filter events
|
|
|
|
// CalendarConfig definition
|
|
type CalendarConfig struct {
|
|
Name string `yaml:"name"`
|
|
PublishName string `yaml:"publish_name"`
|
|
Token string `yaml:"token"`
|
|
FeedURL string `yaml:"feed_url"`
|
|
Filters []Filter `yaml:"filters"`
|
|
}
|
|
|
|
// Downloads iCal feed from the URL and applies filtering rules
|
|
func (calendarConfig CalendarConfig) fetch() ([]byte, error) {
|
|
|
|
// get the iCal feed
|
|
slog.Debug("Fetching iCal feed", "url", calendarConfig.FeedURL)
|
|
resp, err := http.Get(calendarConfig.FeedURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
feedData, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// parse calendar
|
|
cal, err := ics.ParseCalendar(strings.NewReader(string(feedData)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if (calendarConfig.PublishName != "") {
|
|
cal.SetName(calendarConfig.PublishName)
|
|
}
|
|
|
|
// process filters
|
|
if len(calendarConfig.Filters) > 0 {
|
|
slog.Debug("Processing filters", "calendar", calendarConfig.Name)
|
|
for _, event := range cal.Events() {
|
|
if !calendarConfig.ProcessEvent(event) {
|
|
cal.RemoveEvent(event.Id())
|
|
}
|
|
}
|
|
slog.Debug("Filter processing completed", "calendar", calendarConfig.Name)
|
|
} else {
|
|
slog.Debug("No filters to evaluate", "calendar", calendarConfig.Name)
|
|
}
|
|
|
|
// serialize output
|
|
var buf bytes.Buffer
|
|
err = cal.SerializeTo(&buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// return
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// Evaluate the filters for a calendar against a given VEvent and
|
|
// perform any transformations directly to the VEvent (pointer)
|
|
// This function returns false if an event should be deleted
|
|
func (calendarConfig CalendarConfig) ProcessEvent(event *ics.VEvent) bool {
|
|
|
|
// Get the Summary (the "title" of the event)
|
|
// In case we cannot parse the event summary it should get dropped
|
|
summary := event.GetProperty(ics.ComponentPropertySummary) // summary only for logging
|
|
if summary == nil {
|
|
return false
|
|
}
|
|
|
|
// Iterate through the Filter rules
|
|
for id, filter := range calendarConfig.Filters {
|
|
|
|
// Does the filter match the event?
|
|
if filter.matchesEvent(*event) {
|
|
slog.Debug("Filter match found", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value)
|
|
|
|
// The event should get dropped if RemoveEvent is set
|
|
if filter.RemoveEvent {
|
|
slog.Debug("Event to be removed, no more rules will be processed", "action", "DELETE", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value)
|
|
return false
|
|
}
|
|
|
|
// Apply transformation rules to event
|
|
filter.transformEvent(event)
|
|
|
|
// Check if we should stop processing rules
|
|
if filter.Stop {
|
|
slog.Debug("Stop option is set, no more rules will be processed", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep event by default if all Filter rules are processed
|
|
slog.Debug("Rule processing complete, event will be kept", "rule_id", nil, "event_summary", summary.Value)
|
|
return true
|
|
|
|
}
|
|
|
|
// Filter definition
|
|
type Filter struct {
|
|
Description string `yaml:"description"`
|
|
RemoveEvent bool `yaml:"remove"`
|
|
Stop bool `yaml:"stop"`
|
|
Match EventMatchRules `yaml:"match"`
|
|
Transform EventTransformRules `yaml:"transform"`
|
|
}
|
|
|
|
// Returns true if a VEvent matches the Filter conditions
|
|
func (filter Filter) matchesEvent(event ics.VEvent) bool {
|
|
|
|
// If an event property is not defined golang-ical returns a nil pointer
|
|
|
|
// Get event Summary - only used for debug logging
|
|
eventSummary := event.GetProperty(ics.ComponentPropertySummary)
|
|
if eventSummary == nil {
|
|
slog.Warn("Unable to process event summary. Event will be dropped")
|
|
return false // never match if VEvent has no summary
|
|
}
|
|
|
|
// Check Summary filters against VEvent
|
|
if filter.Match.Summary.hasConditions() {
|
|
if !filter.Match.Summary.matchesString(eventSummary.Value) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check Description filters against VEvent
|
|
if filter.Match.Description.hasConditions() {
|
|
eventDescription := event.GetProperty(ics.ComponentPropertyDescription)
|
|
var eventDescriptionValue string
|
|
if eventDescription == nil {
|
|
eventDescriptionValue = ""
|
|
} else {
|
|
eventDescriptionValue = eventDescription.Value
|
|
}
|
|
|
|
if !filter.Match.Description.matchesString(eventDescriptionValue) {
|
|
slog.Debug("Event Description does not match filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description)
|
|
return false // event doesn't match
|
|
}
|
|
}
|
|
|
|
// Check Location filters against VEvent
|
|
if filter.Match.Location.hasConditions() {
|
|
eventLocation := event.GetProperty(ics.ComponentPropertyLocation)
|
|
var eventLocationValue string
|
|
if eventLocation == nil {
|
|
eventLocationValue = ""
|
|
} else {
|
|
eventLocationValue = eventLocation.Value
|
|
}
|
|
if !filter.Match.Location.matchesString(eventLocationValue) {
|
|
slog.Debug("Event Location does not match filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description)
|
|
return false // event doesn't match
|
|
|
|
}
|
|
}
|
|
|
|
// Check Url filters against VEvent
|
|
if filter.Match.Url.hasConditions() {
|
|
eventUrl := event.GetProperty(ics.ComponentPropertyUrl)
|
|
var eventUrlValue string
|
|
if eventUrl == nil {
|
|
eventUrlValue = ""
|
|
} else {
|
|
eventUrlValue = eventUrl.Value
|
|
}
|
|
if !filter.Match.Url.matchesString(eventUrlValue) {
|
|
slog.Debug("Event URL does not match filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description)
|
|
return false // event doesn't match
|
|
}
|
|
}
|
|
|
|
// VEvent must match if we get here
|
|
slog.Debug("Event matches filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description)
|
|
return true
|
|
}
|
|
|
|
// Applies filter transformations to a VEvent pointer
|
|
func (filter Filter) transformEvent(event *ics.VEvent) {
|
|
|
|
// Summary transformations
|
|
if filter.Transform.Summary.Remove {
|
|
event.SetSummary("")
|
|
} else if filter.Transform.Summary.Replace != "" {
|
|
event.SetSummary(filter.Transform.Summary.Replace)
|
|
}
|
|
|
|
// Description transformations
|
|
if filter.Transform.Description.Remove {
|
|
event.SetDescription("")
|
|
} else if filter.Transform.Description.Replace != "" {
|
|
event.SetDescription(filter.Transform.Description.Replace)
|
|
}
|
|
|
|
// Location transformations
|
|
if filter.Transform.Location.Remove {
|
|
event.SetLocation("")
|
|
} else if filter.Transform.Location.Replace != "" {
|
|
event.SetLocation(filter.Transform.Location.Replace)
|
|
}
|
|
|
|
// URL transformations
|
|
if filter.Transform.Url.Remove {
|
|
event.SetURL("")
|
|
} else if filter.Transform.Url.Replace != "" {
|
|
event.SetURL(filter.Transform.Url.Replace)
|
|
}
|
|
}
|
|
|
|
// EventMatchRules contains VEvent properties that user can match against
|
|
type EventMatchRules struct {
|
|
Summary StringMatchRule `yaml:"summary"`
|
|
Description StringMatchRule `yaml:"description"`
|
|
Location StringMatchRule `yaml:"location"`
|
|
Url StringMatchRule `yaml:"url"`
|
|
}
|
|
|
|
// StringMatchRule defines match rules for VEvent properties with string values
|
|
type StringMatchRule struct {
|
|
Null bool `yaml:"empty"`
|
|
Contains string `yaml:"contains"`
|
|
Prefix string `yaml:"prefix"`
|
|
Suffix string `yaml:"suffix"`
|
|
RegexMatch string `yaml:"regex"`
|
|
}
|
|
|
|
// Returns true if StringMatchRule has any conditions
|
|
func (smr StringMatchRule) hasConditions() bool {
|
|
return smr.Null ||
|
|
smr.Contains != "" ||
|
|
smr.Prefix != "" ||
|
|
smr.Suffix != "" ||
|
|
smr.RegexMatch != ""
|
|
}
|
|
|
|
// Returns true if a given string (data) matches ALL StringMatchRule conditions
|
|
func (smr StringMatchRule) matchesString(data string) bool {
|
|
// check null if set and don't process further - this condition can only be met on its own
|
|
if smr.Null {
|
|
return data == ""
|
|
}
|
|
// check contains if set
|
|
if smr.Contains != "" {
|
|
if data == "" || !strings.Contains(data, smr.Contains) {
|
|
return false
|
|
}
|
|
}
|
|
// check prefix if set
|
|
if smr.Prefix != "" {
|
|
if data == "" || !strings.HasPrefix(data, smr.Prefix) {
|
|
return false
|
|
}
|
|
}
|
|
// check suffix if set
|
|
if smr.Suffix != "" {
|
|
if data == "" || !strings.HasSuffix(data, smr.Suffix) {
|
|
return false
|
|
}
|
|
}
|
|
// check regex match if set
|
|
if smr.RegexMatch != "" {
|
|
re, err := regexp.Compile(smr.RegexMatch)
|
|
if err != nil {
|
|
slog.Warn("error processing regex rule", "value", smr.RegexMatch)
|
|
return false // regex error is considered a failure to match
|
|
}
|
|
match := re.MatchString(data)
|
|
if !match {
|
|
return false // regex didn't match
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// EventTransformRules contains VEvent properties that user can modify
|
|
type EventTransformRules struct {
|
|
Summary StringTransformRule `yaml:"summary"`
|
|
Description StringTransformRule `yaml:"description"`
|
|
Location StringTransformRule `yaml:"location"`
|
|
Url StringTransformRule `yaml:"url"`
|
|
}
|
|
|
|
// StringTransformRule defines changes for VEvent properties with string values
|
|
type StringTransformRule struct {
|
|
Replace string `yaml:"replace"`
|
|
Remove bool `yaml:"remove"`
|
|
}
|