Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions chart/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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
github.com/knadh/koanf/providers/confmap v1.0.0
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 (
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
Expand Down
172 changes: 66 additions & 106 deletions internal/calendar/ical.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
}
Loading