Skip to content

Instantly share code, notes, and snippets.

@cpearce
Created April 7, 2023 22:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cpearce/3933b609824c03f3c555b8f2dc1ac2a1 to your computer and use it in GitHub Desktop.
Save cpearce/3933b609824c03f3c555b8f2dc1ac2a1 to your computer and use it in GitHub Desktop.
// Copyright 2023 Chris Pearce
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// The "amr ical events lists" Wordpress plugin can struggle with large ICS
// files. There's evidence online that this is caused by calendars
// with lots of recurrent events; the recurrent events are expanded by AMR
// into memory before filtering, with too many concurrent requests this can
// cause you to hit your memory limit on shared hosting servers.
//
// So this CGI program takes an already downloaded calendar ICS file, and filters
// it to a desired date range. Recurrent events are filtered in if they overlap
// with the specified date. If no date is specified, the current date minus 7
// plus 30 days is used. You can then take the URL of this CGI program, and
// pass it to arm ical events lists instead of the base calendar URL. This will
// expose a much smaller calendar to AMR, and thus use less memory.
//
// Note: This does minimal parsing; just enough to drop the lines between
// BEGIN:VEVENT...END:VEVENT for events with start/end times which don't
// ovelap with the target range. We rely on AMR's existing parsing to select
// the events it cares abouts; here we just filter out the events we know
// we aren't interested in.
//
// Note: This parses the calendar at calendar.ics from the current directory;
// you need to setup a cron job to periodically update your calendar.ics file
// on disk.
//
// Be aware this only handles the datetime formats as seen in Google Calendar's
// ICS files, namely:
//
// DTSTART:20120618T010000Z
// DTSTART;VALUE=DATE:20120626
//
// To deploy, install Go, and compile with:
// $ GOARCH="amd64" GOOS="linux" go build -o calendar.cgi main.go
//
// Then copy calendar.cgi to your server, chmod +x, and copy your calendar.ics
// file to the same directory as calendar.cgi, and setup a cron job to periodically
// update your calendar.
//
// You can then test with, e.g.:
// $ curl "https://your-domain.com/calendar.cgi?from=20230115&to=20230315"
package main
import (
"bufio"
"fmt"
"net/http"
"net/http/cgi"
"os"
"regexp"
"strconv"
"strings"
"time"
)
// Golang time format strings are this date, laid out in desired format.
// Mon Jan 2 15:04:05 -0700 MST 2006
const startDatetimeLayout = "DTSTART:20060102T150405Z"
const startDateLayout = "DTSTART;VALUE=DATE:20060102"
const endDatetimeLayout = "DTEND:20060102T150405Z"
const endDateLayout = "DTEND;VALUE=DATE:20060102"
func ParseEventDates(lines []string) (time.Time, time.Time) {
// Two formats observed in Google calendar's ICS output:
// DTSTART:20120618T010000Z
// DTSTART;VALUE=DATE:20120626
start := time.Now()
end := time.Now()
for _, line := range lines {
if strings.HasPrefix(line, "DTSTART;VALUE=DATE:") {
d, err := time.Parse(startDateLayout, line)
if err == nil {
start = d
}
} else if strings.HasPrefix(line, "DTSTART:") {
d, err := time.Parse(startDatetimeLayout, line)
if err == nil {
start = d
}
} else if strings.HasPrefix(line, "DTEND;VALUE=DATE:") {
d, err := time.Parse(endDateLayout, line)
if err == nil {
end = d
}
} else if strings.HasPrefix(line, "DTEND:") {
d, err := time.Parse(endDatetimeLayout, line)
if err == nil {
end = d
}
}
}
return start, end
}
func overlap(
aStart time.Time,
aEnd time.Time,
bStart time.Time,
bEnd time.Time,
) bool {
return aStart.Before(bEnd) && aEnd.After(bStart)
}
var countRegex = regexp.MustCompile(`COUNT=(\d+);`)
func extractCount(line string) (int, error) {
m := countRegex.FindStringSubmatch(line)
if len(m) < 2 {
return 0, fmt.Errorf("can't find count")
}
return strconv.Atoi(m[1])
}
var intervalRegex = regexp.MustCompile(`INTERVAL=(\d+);`)
func extractInterval(line string) (int, error) {
m := intervalRegex.FindStringSubmatch(line)
if len(m) < 2 {
return 0, fmt.Errorf("can't find interval")
}
return strconv.Atoi(m[1])
}
func RRuleIncludes(
line string,
eventTime time.Time,
start time.Time,
end time.Time,
) bool {
if !strings.Contains(line, "FREQ=WEEKLY") {
// Don't handle others...
return false
}
idx := strings.Index(line, "UNTIL=")
if idx != -1 {
dateStr := line[idx+6 : idx+14]
until, err := time.Parse(YYYYMMDD, dateStr)
if err == nil {
return overlap(start, end, eventTime, until)
} else {
return false
}
}
count, err := extractCount(line)
if err != nil {
return false
}
interval, err := extractInterval(line)
if err != nil {
return false
}
week := 7 * 24 * time.Hour
eventEnd := eventTime.Add(time.Duration(interval*count) * week)
return overlap(eventTime, eventEnd, start, end)
}
func FilterCalendarByDate(
w http.ResponseWriter,
input *os.File,
start time.Time,
end time.Time,
) {
scanner := bufio.NewScanner(input)
parsingEvent := false
eventLines := []string{}
lineNum := 0
rruleLine := ""
for scanner.Scan() {
lineNum += 1
line := scanner.Text()
if line == "BEGIN:VEVENT" {
if parsingEvent {
panic(fmt.Sprintf("Nested VEVENT line %d", lineNum))
} else {
parsingEvent = true
}
}
if !parsingEvent {
fmt.Fprintln(w, line)
continue
}
// We're parsing an event, need to read until we've hit the end.
eventLines = append(eventLines, line)
if strings.HasPrefix(line, "RRULE") {
rruleLine = line
}
if line == "END:VEVENT" {
eventStart, eventEnd := ParseEventDates(eventLines)
if overlap(eventStart, eventEnd, start, end) || RRuleIncludes(rruleLine, eventStart, start, end) {
for _, l := range eventLines {
fmt.Fprintln(w, l)
}
}
parsingEvent = false
// Reset slice to 0 length keeping memory.
eventLines = eventLines[:0]
rruleLine = ""
}
}
}
const YYYYMMDD = "20060102"
func handler(w http.ResponseWriter, r *http.Request) {
header := w.Header()
header.Set("Content-Type", "text/calendar; charset=utf-8")
query := r.URL.Query()
from, err := time.Parse(YYYYMMDD, query.Get("from"))
if err != nil {
from = time.Now()
}
to, err := time.Parse(YYYYMMDD, query.Get("to"))
if err != nil {
to = from.Add(30 * 24 * time.Hour)
}
if from.After(to) {
from = time.Now()
to = time.Now().Add(30 * 24 * time.Hour)
}
f, _ := os.Open("calendar.ics")
defer f.Close()
FilterCalendarByDate(w, f, from, to)
}
// Uncomment to run parser locally...
// func main() {
// f, _ := os.Open("calendar.ics")
// defer f.Close()
// start := time.Date(2023, time.March, 13, 0, 0, 0, 0, time.Local)
// end := time.Date(2023, time.April, 12, 0, 0, 0, 0, time.Local)
// lines, err := Parse(f, start, end)
// if err != nil {
// panic(err)
// }
// for _, line := range lines {
// fmt.Println(line)
// }
// }
func main() {
err := cgi.Serve(http.HandlerFunc(handler))
if err != nil {
fmt.Println(err)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment