From 739af255c6aa950e845a6d447b8a6d811d5ef8ac Mon Sep 17 00:00:00 2001 From: Galdin Raphael Date: Thu, 16 Apr 2026 21:29:39 +0100 Subject: [PATCH 1/2] Support for recurring events Closes https://github.com/gldraphael/status/issues/21 --- go.mod | 2 + go.sum | 4 + internal/calendar/ical.go | 172 +++++++++++++-------------------- internal/calendar/ical_test.go | 96 +++++++++--------- 4 files changed, 121 insertions(+), 153 deletions(-) diff --git a/go.mod b/go.mod index a53a20f..8f78ae8 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/gldraphael/status go 1.25.6 require ( + github.com/arran4/golang-ical v0.3.5 github.com/cockroachdb/pebble v1.1.5 github.com/forPelevin/gomoji v1.4.1 github.com/knadh/koanf/parsers/yaml v1.1.0 @@ -10,6 +11,7 @@ require ( github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.4 github.com/rs/zerolog v1.35.0 + github.com/teambition/rrule-go v1.8.2 ) require ( diff --git a/go.sum b/go.sum index 02c6f34..f377d75 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A= +github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -93,6 +95,8 @@ github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= diff --git a/internal/calendar/ical.go b/internal/calendar/ical.go index 4c6261e..f62e50f 100644 --- a/internal/calendar/ical.go +++ b/internal/calendar/ical.go @@ -1,14 +1,15 @@ package calendar import ( - "bufio" - "bytes" "context" "fmt" "io" "net/http" "strings" "time" + + ics "github.com/arran4/golang-ical" + "github.com/teambition/rrule-go" ) // ParsedEvent is an event extracted from an iCal file. @@ -44,135 +45,94 @@ func FetchAndParseICalendar(ctx context.Context, calendarURL string) ([]ParsedEv return nil, fmt.Errorf("read response: %w", err) } - return parseICalendar(body) + return parseICalendar(body, time.Now()) } // parseICalendar parses an iCal stream and extracts VEVENT components. -func parseICalendar(data interface{}) ([]ParsedEvent, error) { - var body []byte +// now is used as the center of the recurrence expansion window. +func parseICalendar(data interface{}, now time.Time) ([]ParsedEvent, error) { + var body string switch v := data.(type) { case []byte: + body = string(v) + case string: body = v - case *bytes.Buffer: - body = v.Bytes() default: return nil, fmt.Errorf("unsupported data type") } - // First, read all lines and unfold continuation lines - scanner := bufio.NewScanner(bytes.NewReader(body)) - var lines []string - var currentLine strings.Builder - - for scanner.Scan() { - line := scanner.Text() - - // If this line starts with space/tab, it's a continuation - if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') { - currentLine.WriteString(strings.TrimSpace(line)) - } else { - // Not a continuation; flush current line if any - if currentLine.Len() > 0 { - lines = append(lines, currentLine.String()) - currentLine.Reset() - } - lines = append(lines, line) - } - } - // Flush any remaining line - if currentLine.Len() > 0 { - lines = append(lines, currentLine.String()) + if now.IsZero() { + now = time.Now() } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read lines: %w", err) + cal, err := ics.ParseCalendar(strings.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("parse calendar: %w", err) } - // Now process the unfolded lines var events []ParsedEvent - var inEvent bool - var event ParsedEvent - var eventStart, eventEnd string - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { + // Expand recurrences for a window around now. + windowStart := now.Add(-24 * time.Hour) + windowEnd := now.Add(24 * time.Hour) + + for _, event := range cal.Events() { + summary := event.GetProperty(ics.ComponentPropertySummary).Value + uid := event.GetProperty(ics.ComponentPropertyUniqueId).Value + status := event.GetProperty(ics.ComponentPropertyStatus) + cancelled := status != nil && status.Value == "CANCELLED" + + startAt, err := event.GetStartAt() + if err != nil { continue } - - if line == "BEGIN:VEVENT" { - inEvent = true - event = ParsedEvent{} - eventStart = "" - eventEnd = "" + endAt, err := event.GetEndAt() + if err != nil { continue } - if line == "END:VEVENT" { - inEvent = false - // Parse timestamps if they were collected. - if eventStart != "" { - if t, err := parseEventTime(eventStart); err == nil { - event.StartTime = t - } - } - if eventEnd != "" { - if t, err := parseEventTime(eventEnd); err == nil { - event.EndTime = t - } - } - if event.ID != "" && event.Summary != "" { - events = append(events, event) - } + duration := endAt.Sub(startAt) + + rruleProp := event.GetProperty(ics.ComponentPropertyRrule) + if rruleProp == nil { + // Single event. + events = append(events, ParsedEvent{ + ID: uid, + Summary: summary, + StartTime: startAt, + EndTime: endAt, + Cancelled: cancelled, + }) continue } - if !inEvent { - continue - } - - // Parse event properties. - if strings.HasPrefix(line, "UID:") { - event.ID = strings.TrimPrefix(line, "UID:") - } else if strings.HasPrefix(line, "SUMMARY:") { - event.Summary = strings.TrimPrefix(line, "SUMMARY:") - } else if strings.HasPrefix(line, "DTSTART") { - // Extract the value part; ignore TZID and other params. - parts := strings.Split(line, ":") - if len(parts) >= 2 { - eventStart = parts[len(parts)-1] - } - } else if strings.HasPrefix(line, "DTEND") { - parts := strings.Split(line, ":") - if len(parts) >= 2 { - eventEnd = parts[len(parts)-1] + // Recurring event. + option, err := rrule.StrToROption(rruleProp.Value) + if err == nil { + option.Dtstart = startAt + rule, err := rrule.NewRRule(*option) + if err == nil { + instances := rule.Between(windowStart, windowEnd, true) + for _, inst := range instances { + events = append(events, ParsedEvent{ + ID: fmt.Sprintf("%s-%s", uid, inst.Format(time.RFC3339)), + Summary: summary, + StartTime: inst, + EndTime: inst.Add(duration), + Cancelled: cancelled, + }) + } + continue } - } else if line == "STATUS:CANCELLED" { - event.Cancelled = true } - } - - return events, nil -} -// parseEventTime parses an iCal DATE-TIME or DATE string. -// Handles formats: YYYYMMDDTHHMMSSZ, YYYYMMDD, YYYYMMDDTHHMMSS -func parseEventTime(s string) (time.Time, error) { - s = strings.TrimSpace(s) - - // DATE-TIME with Z suffix (UTC). - if strings.HasSuffix(s, "Z") { - return time.Parse("20060102T150405Z", s) - } - - // DATE-TIME without timezone (local). - if len(s) == 15 && strings.Contains(s, "T") { - return time.Parse("20060102T150405", s) + // Fallback to base instance. + events = append(events, ParsedEvent{ + ID: uid, + Summary: summary, + StartTime: startAt, + EndTime: endAt, + Cancelled: cancelled, + }) } - // DATE only. - if len(s) == 8 && !strings.Contains(s, "T") { - return time.Parse("20060102", s) - } - - return time.Time{}, fmt.Errorf("unsupported time format: %q", s) + return events, nil } diff --git a/internal/calendar/ical_test.go b/internal/calendar/ical_test.go index 5d1c1c6..ed7c489 100644 --- a/internal/calendar/ical_test.go +++ b/internal/calendar/ical_test.go @@ -21,7 +21,8 @@ SUMMARY:Test Event END:VEVENT END:VCALENDAR` - events, err := parseICalendar([]byte(icalData)) + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) + events, err := parseICalendar([]byte(icalData), now) if err != nil { t.Fatalf("parseICalendar: %v", err) } @@ -33,8 +34,8 @@ END:VCALENDAR` if events[0].Summary != "Test Event" { t.Errorf("Summary: got %q, want %q", events[0].Summary, "Test Event") } - if events[0].ID != "event1@example.com" { - t.Errorf("ID: got %q, want %q", events[0].ID, "event1@example.com") + if !strings.HasPrefix(events[0].ID, "event1@example.com") { + t.Errorf("ID: got %q, want prefix %q", events[0].ID, "event1@example.com") } } @@ -56,7 +57,8 @@ SUMMARY:Second Event END:VEVENT END:VCALENDAR` - events, err := parseICalendar([]byte(icalData)) + now := time.Date(2026, 4, 6, 12, 0, 0, 0, time.UTC) + events, err := parseICalendar([]byte(icalData), now) if err != nil { t.Fatalf("parseICalendar: %v", err) } @@ -85,7 +87,8 @@ STATUS:CANCELLED END:VEVENT END:VCALENDAR` - events, err := parseICalendar([]byte(icalData)) + now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) + events, err := parseICalendar([]byte(icalData), now) if err != nil { t.Fatalf("parseICalendar: %v", err) } @@ -110,7 +113,8 @@ SUMMARY:All Day Event END:VEVENT END:VCALENDAR` - events, err := parseICalendar([]byte(icalData)) + now := time.Date(2026, 4, 6, 12, 0, 0, 0, time.UTC) + events, err := parseICalendar([]byte(icalData), now) if err != nil { t.Fatalf("parseICalendar: %v", err) } @@ -182,45 +186,6 @@ func TestFetchAndParseICalendar_404Error(t *testing.T) { } } -func TestParseEventTime_DateTimeUTC(t *testing.T) { - // Test UTC datetime format (ends with Z) - time1, err := parseEventTime("20260406T140530Z") - if err != nil { - t.Fatalf("parseEventTime: %v", err) - } - - if time1.Year() != 2026 || time1.Month() != 4 || time1.Day() != 6 { - t.Errorf("Date: got %v", time1) - } - if time1.Hour() != 14 || time1.Minute() != 5 || time1.Second() != 30 { - t.Errorf("Time: got %v", time1) - } -} - -func TestParseEventTime_DateTime(t *testing.T) { - // Test datetime format without timezone - time1, err := parseEventTime("20260406T100000") - if err != nil { - t.Fatalf("parseEventTime: %v", err) - } - - if time1.Year() != 2026 || time1.Month() != 4 || time1.Day() != 6 { - t.Errorf("Date: got %v", time1) - } -} - -func TestParseEventTime_DateOnly(t *testing.T) { - // Test date-only format - time1, err := parseEventTime("20260406") - if err != nil { - t.Fatalf("parseEventTime: %v", err) - } - - if time1.Year() != 2026 || time1.Month() != 4 || time1.Day() != 6 { - t.Errorf("Date: got %v", time1) - } -} - func TestFetchAndParseICalendar_GoogleCalendarFormat(t *testing.T) { // This is the exact format from Google Calendar's iCal export icalData := `BEGIN:VCALENDAR @@ -264,8 +229,8 @@ END:VCALENDAR` if events[0].Summary != "Team Meeting" { t.Errorf("Summary: got %q, want %q", events[0].Summary, "Team Meeting") } - if events[0].ID != "test-event@example.com" { - t.Errorf("ID: got %q, want %q", events[0].ID, "test-event@example.com") + if !strings.HasPrefix(events[0].ID, "test-event@example.com") { + t.Errorf("ID: got %q, want prefix %q", events[0].ID, "test-event@example.com") } if events[0].Cancelled { t.Errorf("Cancelled: got true, want false") @@ -273,3 +238,40 @@ END:VCALENDAR` t.Logf("✅ Successfully parsed Google Calendar iCal format") } + +func TestParseICalendar_RecurringEvents(t *testing.T) { + icalData := `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:recurring-daily +DTSTART:20260401T090000Z +DTEND:20260401T100000Z +RRULE:FREQ=DAILY +SUMMARY:Daily Sync +END:VEVENT +END:VCALENDAR` + + // Test a date well after the initial DTSTART to verify RRULE expansion. + now := time.Date(2026, 4, 15, 9, 30, 0, 0, time.UTC) + events, err := parseICalendar([]byte(icalData), now) + if err != nil { + t.Fatalf("parseICalendar: %v", err) + } + + found := false + for _, ev := range events { + // The expanded event should have the same summary but a different start/end time. + if ev.Summary == "Daily Sync" && !ev.StartTime.After(now) && ev.EndTime.After(now) { + found = true + // Verify it's the instance for the 15th + if ev.StartTime.Day() != 15 { + t.Errorf("expected instance for the 15th, got %v", ev.StartTime) + } + break + } + } + + if !found { + t.Errorf("expected to find active recurring instance of 'Daily Sync' for %v", now) + } +} \ No newline at end of file From 8ee323aef706c539acac02c118cc75ffcfe7c27c Mon Sep 17 00:00:00 2001 From: Galdin Raphael Date: Thu, 16 Apr 2026 21:32:49 +0100 Subject: [PATCH 2/2] Bump chart version to 0.1.4 --- chart/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 0bd7ba4..128547a 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.3 +version: 0.1.4 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.1.0" +appVersion: "v0.1.1"