Files
ical-filter-proxy/calendar.go
2024-08-25 18:29:49 +01:00

272 lines
8.3 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)
if eventDescription == nil {
slog.Debug("Event has no Description so cannot not match filter", "event_summary", eventSummary.Value, "filter", filter.Description)
return false // if VEvent has no description it cannot match filter
}
if !filter.Match.Description.matchesString(eventDescription.Value) {
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)
if eventLocation == nil {
slog.Warn("Event has no Location so cannot match filter", "event_summary", eventSummary.Value, "filter", filter.Description)
return false // if VEvent has no location it cannot match filter
}
if !filter.Match.Location.matchesString(eventLocation.Value) {
slog.Debug("Event Location 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)
}
}
// EventMatchRules contains VEvent properties that user can match against
type EventMatchRules struct {
Summary StringMatchRule `yaml:"summary"`
Description StringMatchRule `yaml:"description"`
Location StringMatchRule `yaml:"location"`
}
// StringMatchRule defines match rules for VEvent properties with string values
type StringMatchRule struct {
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.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 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 != "" {
match, err := regexp.MatchString(data, smr.RegexMatch)
if err != nil {
slog.Warn("error processing regex rule", "value", smr.RegexMatch)
return false // regex error is considered a failure to match
}
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"`
}
// StringTransformRule defines changes for VEvent properties with string values
type StringTransformRule struct {
Replace string `yaml:"replace"`
Remove bool `yaml:"remove"`
}