chore: use floatpane/go-icalendar (#1395)

Drew Smirnoff created

## What?

Replaces `calendar.go` with a new library
[`go-icalendar`](https://github.com/floatpane/go-icalendar)

## Why?

Makes the code easier to expand and improve apart. Also, could be
re-used by other people

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

calendar/calendar.go        | 210 ----------------------------------
calendar/calendar_test.go   | 237 ---------------------------------------
go.mod                      |   3 
go.sum                      |   2 
main.go                     |   2 
testdata/invites/simple.ics |  18 --
tui/email_view.go           |   2 
tui/messages.go             |   2 
8 files changed, 7 insertions(+), 469 deletions(-)

Detailed changes

calendar/calendar.go 🔗

@@ -1,210 +0,0 @@
-package calendar
-
-import (
-	"bytes"
-	"fmt"
-	"strings"
-	"time"
-
-	ics "github.com/arran4/golang-ical"
-)
-
-// Event represents a parsed calendar event from an .ics attachment
-type Event struct {
-	UID         string
-	Summary     string // Event title
-	Description string
-	Location    string
-	Start       time.Time
-	End         time.Time
-	Organizer   string // Organizer email
-	Status      string // CONFIRMED, TENTATIVE, CANCELLED
-	Method      string // REQUEST, REPLY, CANCEL
-}
-
-// ParseICS extracts the first VEVENT from .ics data
-func ParseICS(data []byte) (*Event, error) {
-	cal, err := ics.ParseCalendar(bytes.NewReader(data))
-	if err != nil {
-		return nil, fmt.Errorf("parse calendar: %w", err)
-	}
-
-	events := cal.Events()
-	if len(events) == 0 {
-		return nil, fmt.Errorf("no VEVENT found")
-	}
-
-	vevent := events[0]
-
-	// Extract properties
-	uid := getEventProperty(vevent, ics.ComponentPropertyUniqueId)
-	summary := getEventProperty(vevent, ics.ComponentPropertySummary)
-	description := getEventProperty(vevent, ics.ComponentPropertyDescription)
-	location := getEventProperty(vevent, ics.ComponentPropertyLocation)
-	organizer := extractEmail(getEventProperty(vevent, ics.ComponentPropertyOrganizer))
-	status := getEventProperty(vevent, ics.ComponentPropertyStatus)
-
-	// Get METHOD from calendar level
-	method := ""
-	for _, prop := range cal.CalendarProperties {
-		if prop.IANAToken == string(ics.PropertyMethod) {
-			method = prop.Value
-			break
-		}
-	}
-
-	// Parse timestamps
-	start, _ := parseEventTimestamp(vevent, ics.ComponentPropertyDtStart)
-	end, _ := parseEventTimestamp(vevent, ics.ComponentPropertyDtEnd)
-
-	return &Event{
-		UID:         uid,
-		Summary:     summary,
-		Description: description,
-		Location:    location,
-		Start:       start,
-		End:         end,
-		Organizer:   organizer,
-		Status:      status,
-		Method:      method,
-	}, nil
-}
-
-// GenerateRSVP creates a RFC 6047 (iMIP) compliant reply .ics.
-// Google Calendar requires:
-// - METHOD:REPLY at calendar level
-// - Only the responding attendee in VEVENT (others removed)
-// - Updated PARTSTAT on the attendee
-// - Current DTSTAMP
-func GenerateRSVP(originalData []byte, userEmail, response string) ([]byte, error) {
-	// response: "ACCEPTED", "DECLINED", "TENTATIVE"
-
-	cal, err := ics.ParseCalendar(bytes.NewReader(originalData))
-	if err != nil {
-		return nil, fmt.Errorf("parse calendar: %w", err)
-	}
-
-	// Set METHOD:REPLY
-	cal.SetMethod(ics.MethodReply)
-
-	userEmail = strings.ToLower(strings.TrimSpace(userEmail))
-
-	for _, vevent := range cal.Events() {
-		// Update DTSTAMP to current time
-		vevent.SetDtStampTime(time.Now().UTC())
-
-		// Find the responding attendee and remove all others
-		var matchedAttendee *ics.Attendee
-		attendees := vevent.Attendees()
-		for _, attendee := range attendees {
-			attendeeEmail := strings.ToLower(extractEmail(attendee.Email()))
-			if strings.Contains(attendeeEmail, userEmail) || strings.Contains(userEmail, attendeeEmail) {
-				matchedAttendee = attendee
-				break
-			}
-		}
-
-		// Remove all ATTENDEE properties
-		vevent.RemoveProperty(ics.ComponentPropertyAttendee)
-
-		// Re-add only the responding attendee with updated PARTSTAT and RSVP=TRUE
-		if matchedAttendee != nil {
-			matchedAttendee.ICalParameters[string(ics.ParameterParticipationStatus)] = []string{response}
-			matchedAttendee.ICalParameters["RSVP"] = []string{"TRUE"}
-			vevent.Properties = append(vevent.Properties, matchedAttendee.IANAProperty)
-		} else {
-			// Attendee not found in original - add ourselves with full parameters
-			vevent.AddAttendee("mailto:"+userEmail,
-				ics.WithRSVP(true),
-				ics.ParticipationStatusNeedsAction,
-				ics.CalendarUserTypeIndividual,
-				ics.ParticipationRoleReqParticipant,
-			)
-			for _, att := range vevent.Attendees() {
-				att.ICalParameters[string(ics.ParameterParticipationStatus)] = []string{response}
-			}
-		}
-	}
-
-	return []byte(cal.Serialize()), nil
-}
-
-// getEventProperty extracts a property value from a VEVENT
-func getEventProperty(vevent *ics.VEvent, prop ics.ComponentProperty) string {
-	p := vevent.GetProperty(prop)
-	if p == nil {
-		return ""
-	}
-	return p.Value
-}
-
-// parseEventTimestamp parses DTSTART or DTEND with timezone handling
-func parseEventTimestamp(vevent *ics.VEvent, prop ics.ComponentProperty) (time.Time, error) {
-	p := vevent.GetProperty(prop)
-	if p == nil {
-		return time.Time{}, fmt.Errorf("property not found")
-	}
-
-	value := p.Value
-	var tzid string
-	var isDateOnly bool
-	if params := p.ICalParameters; params != nil {
-		if tzids := params["TZID"]; len(tzids) > 0 {
-			tzid = tzids[0]
-		}
-		if vals := params["VALUE"]; len(vals) > 0 && strings.EqualFold(vals[0], "DATE") {
-			isDateOnly = true
-		}
-	}
-	// RFC 5545 DATE form is YYYYMMDD (8 chars, no time component).
-	if !isDateOnly && len(value) == 8 {
-		isDateOnly = true
-	}
-
-	// Try parsing with timezone
-	var t time.Time
-	var err error
-
-	// RFC 5545 formats
-	formats := []string{
-		"20060102T150405Z", // UTC
-		"20060102T150405",  // Local/TZID
-		"20060102",         // Date only (all-day)
-		time.RFC3339,       // Fallback
-	}
-
-	for _, format := range formats {
-		t, err = time.Parse(format, value)
-		if err == nil {
-			break
-		}
-	}
-
-	if err != nil {
-		return time.Time{}, fmt.Errorf("parse timestamp: %w", err)
-	}
-
-	// Apply timezone if specified. RFC 5545: VALUE=DATE has no timezone, so
-	// TZID must be ignored for date-only values even when present.
-	if tzid != "" && !strings.HasSuffix(value, "Z") && !isDateOnly {
-		if loc, locErr := time.LoadLocation(tzid); locErr == nil {
-			t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
-		}
-	}
-
-	return t, nil
-}
-
-// extractEmail strips "mailto:" prefix and CN parameter from organizer/attendee fields
-func extractEmail(mailto string) string {
-	// Strip mailto: prefix
-	email := strings.TrimPrefix(mailto, "mailto:")
-	email = strings.TrimPrefix(email, "MAILTO:")
-
-	// Strip CN and other parameters (format: CN=Name:email@example.com)
-	if idx := strings.Index(email, ":"); idx != -1 {
-		email = email[idx+1:]
-	}
-
-	return strings.TrimSpace(email)
-}

calendar/calendar_test.go 🔗

@@ -1,237 +0,0 @@
-package calendar
-
-import (
-	"os"
-	"strings"
-	"testing"
-	"time"
-)
-
-func TestParseICS_Simple(t *testing.T) {
-	data, err := os.ReadFile("../testdata/invites/simple.ics")
-	if err != nil {
-		t.Fatalf("Failed to read test fixture: %v", err)
-	}
-
-	event, err := ParseICS(data)
-	if err != nil {
-		t.Fatalf("ParseICS failed: %v", err)
-	}
-
-	if event.UID != "test-event-123@example.com" {
-		t.Errorf("Expected UID test-event-123@example.com, got %s", event.UID)
-	}
-
-	if event.Summary != "Q2 Planning Meeting" {
-		t.Errorf("Expected summary 'Q2 Planning Meeting', got %s", event.Summary)
-	}
-
-	if event.Location != "Conference Room A" {
-		t.Errorf("Expected location 'Conference Room A', got %s", event.Location)
-	}
-
-	if event.Organizer != "alice@company.com" {
-		t.Errorf("Expected organizer alice@company.com, got %s", event.Organizer)
-	}
-
-	if event.Status != "CONFIRMED" {
-		t.Errorf("Expected status CONFIRMED, got %s", event.Status)
-	}
-
-	if event.Method != "REQUEST" {
-		t.Errorf("Expected method REQUEST, got %s", event.Method)
-	}
-
-	expectedStart := time.Date(2026, 4, 21, 14, 0, 0, 0, time.UTC)
-	if !event.Start.Equal(expectedStart) {
-		t.Errorf("Expected start %v, got %v", expectedStart, event.Start)
-	}
-
-	expectedEnd := time.Date(2026, 4, 21, 15, 30, 0, 0, time.UTC)
-	if !event.End.Equal(expectedEnd) {
-		t.Errorf("Expected end %v, got %v", expectedEnd, event.End)
-	}
-}
-
-func TestParseICS_NoEvent(t *testing.T) {
-	data := []byte(`BEGIN:VCALENDAR
-VERSION:2.0
-PRODID:-//Test//Test//EN
-END:VCALENDAR`)
-
-	_, err := ParseICS(data)
-	if err == nil {
-		t.Error("Expected error for calendar with no VEVENT")
-	}
-	if !strings.Contains(err.Error(), "no VEVENT") {
-		t.Errorf("Expected 'no VEVENT' error, got: %v", err)
-	}
-}
-
-func TestParseICS_Malformed(t *testing.T) {
-	data := []byte(`INVALID ICAL DATA`)
-
-	_, err := ParseICS(data)
-	if err == nil {
-		t.Error("Expected error for malformed iCalendar data")
-	}
-}
-
-func TestGenerateRSVP(t *testing.T) {
-	data, err := os.ReadFile("../testdata/invites/simple.ics")
-	if err != nil {
-		t.Fatalf("Failed to read test fixture: %v", err)
-	}
-
-	responses := []string{"ACCEPTED", "DECLINED", "TENTATIVE"}
-
-	for _, response := range responses {
-		t.Run(response, func(t *testing.T) {
-			rsvpData, err := GenerateRSVP(data, "bob@company.com", response)
-			if err != nil {
-				t.Fatalf("GenerateRSVP failed for %s: %v", response, err)
-			}
-
-			rsvpStr := string(rsvpData)
-
-			// Check METHOD:REPLY is set
-			if !strings.Contains(rsvpStr, "METHOD:REPLY") {
-				t.Error("Expected METHOD:REPLY in RSVP")
-			}
-
-			// Check PARTSTAT is updated
-			if !strings.Contains(rsvpStr, "PARTSTAT="+response) {
-				t.Errorf("Expected PARTSTAT=%s in RSVP", response)
-			}
-
-			// RFC 6047: only the responding attendee should remain
-			attendeeCount := strings.Count(rsvpStr, "ATTENDEE")
-			if attendeeCount != 1 {
-				t.Errorf("Expected exactly 1 ATTENDEE in RSVP, got %d", attendeeCount)
-			}
-
-			// Should contain responding user's email
-			if !strings.Contains(rsvpStr, "bob@company.com") {
-				t.Error("Expected bob@company.com in RSVP attendee")
-			}
-
-			// Should NOT contain other attendees
-			if strings.Contains(rsvpStr, "carol@company.com") {
-				t.Error("RSVP should not contain other attendees")
-			}
-
-			// Verify it's still valid iCalendar
-			_, err = ParseICS(rsvpData)
-			if err != nil {
-				t.Errorf("Generated RSVP is not valid iCalendar: %v", err)
-			}
-		})
-	}
-}
-
-// buildICS wraps DTSTART/DTEND lines into a minimal VCALENDAR for ParseICS.
-func buildICS(dtstart, dtend string) []byte {
-	return []byte("BEGIN:VCALENDAR\r\n" +
-		"VERSION:2.0\r\n" +
-		"PRODID:-//Test//Test//EN\r\n" +
-		"BEGIN:VEVENT\r\n" +
-		"UID:date-only@example.com\r\n" +
-		"DTSTAMP:20260415T120000Z\r\n" +
-		dtstart + "\r\n" +
-		dtend + "\r\n" +
-		"SUMMARY:Test\r\n" +
-		"END:VEVENT\r\n" +
-		"END:VCALENDAR\r\n")
-}
-
-func TestParseICS_DateOnly(t *testing.T) {
-	wantStart := time.Date(2026, 4, 21, 0, 0, 0, 0, time.UTC)
-	wantEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC)
-
-	tests := []struct {
-		name    string
-		dtstart string
-		dtend   string
-	}{
-		{
-			name:    "VALUE=DATE without TZID",
-			dtstart: "DTSTART;VALUE=DATE:20260421",
-			dtend:   "DTEND;VALUE=DATE:20260422",
-		},
-		{
-			// Regression: TZID present on a date-only value must be ignored
-			// (RFC 5545 forbids TZID with VALUE=DATE; some producers emit it anyway).
-			name:    "VALUE=DATE with TZID is ignored",
-			dtstart: "DTSTART;TZID=America/New_York;VALUE=DATE:20260421",
-			dtend:   "DTEND;TZID=America/New_York;VALUE=DATE:20260422",
-		},
-		{
-			// Shape-only detection: no VALUE param, but YYYYMMDD value with TZID.
-			name:    "YYYYMMDD shape with TZID is treated as date-only",
-			dtstart: "DTSTART;TZID=America/Los_Angeles:20260421",
-			dtend:   "DTEND;TZID=America/Los_Angeles:20260422",
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			event, err := ParseICS(buildICS(tt.dtstart, tt.dtend))
-			if err != nil {
-				t.Fatalf("ParseICS failed: %v", err)
-			}
-			if !event.Start.Equal(wantStart) {
-				t.Errorf("Start = %v, want %v", event.Start.UTC(), wantStart)
-			}
-			if !event.End.Equal(wantEnd) {
-				t.Errorf("End = %v, want %v", event.End.UTC(), wantEnd)
-			}
-		})
-	}
-}
-
-func TestParseICS_TimedWithTZID(t *testing.T) {
-	// Existing behavior: timed values with TZID keep their zone semantics.
-	event, err := ParseICS(buildICS(
-		"DTSTART;TZID=America/New_York:20260421T140000",
-		"DTEND;TZID=America/New_York:20260421T153000",
-	))
-	if err != nil {
-		t.Fatalf("ParseICS failed: %v", err)
-	}
-
-	loc, err := time.LoadLocation("America/New_York")
-	if err != nil {
-		t.Skipf("America/New_York unavailable on this system: %v", err)
-	}
-	wantStart := time.Date(2026, 4, 21, 14, 0, 0, 0, loc)
-	wantEnd := time.Date(2026, 4, 21, 15, 30, 0, 0, loc)
-
-	if !event.Start.Equal(wantStart) {
-		t.Errorf("Start = %v, want %v", event.Start, wantStart)
-	}
-	if !event.End.Equal(wantEnd) {
-		t.Errorf("End = %v, want %v", event.End, wantEnd)
-	}
-}
-
-func TestExtractEmail(t *testing.T) {
-	tests := []struct {
-		input    string
-		expected string
-	}{
-		{"mailto:user@example.com", "user@example.com"},
-		{"MAILTO:user@example.com", "user@example.com"},
-		{"CN=John Doe:user@example.com", "user@example.com"},
-		{"user@example.com", "user@example.com"},
-		{"  user@example.com  ", "user@example.com"},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.input, func(t *testing.T) {
-			result := extractEmail(tt.input)
-			if result != tt.expected {
-				t.Errorf("extractEmail(%q) = %q, want %q", tt.input, result, tt.expected)
-			}
-		})
-	}
-}

go.mod 🔗

@@ -9,7 +9,6 @@ require (
 	git.sr.ht/~rockorager/go-jmap v0.5.3
 	github.com/ProtonMail/go-crypto v1.4.1
 	github.com/PuerkitoBio/goquery v1.12.0
-	github.com/arran4/golang-ical v0.3.5
 	github.com/charmbracelet/x/ansi v0.11.7
 	github.com/emersion/go-imap/v2 v2.0.0-beta.8
 	github.com/emersion/go-maildir v0.6.0
@@ -17,6 +16,7 @@ require (
 	github.com/emersion/go-pgpmail v0.2.2
 	github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
 	github.com/floatpane/bubble-overlay v0.0.1
+	github.com/floatpane/go-icalendar v0.0.1
 	github.com/floatpane/go-openpgp-card-hl v0.0.1
 	github.com/floatpane/go-secretbox v0.1.0
 	github.com/floatpane/go-uds-jsonrpc v0.0.1
@@ -39,6 +39,7 @@ require (
 	cunicu.li/go-iso7816 v0.8.8 // indirect
 	cunicu.li/go-openpgp-card v0.3.11 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
+	github.com/arran4/golang-ical v0.3.5 // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/charmbracelet/colorprofile v0.4.3 // indirect

go.sum 🔗

@@ -70,6 +70,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTe
 github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
 github.com/floatpane/bubble-overlay v0.0.1 h1:5xU8cNigDPYegvgGMfOG23fIDXhrqXPvLTaEB7uHGK4=
 github.com/floatpane/bubble-overlay v0.0.1/go.mod h1:Csi1byxb9L8EAb8X13XdWF5aX5YiBD5C9WEWACyGa8A=
+github.com/floatpane/go-icalendar v0.0.1 h1:lF9NhEI4TobX8valDakAFfCnBhM2GDITWMVymhXzD8c=
+github.com/floatpane/go-icalendar v0.0.1/go.mod h1:LSy9G+LwUZtfNIAjLlEVRXkuc2A+hq6+pVCIFOiEAyE=
 github.com/floatpane/go-openpgp-card-hl v0.0.1 h1:1DYmzwGDb8eneZxbc/xtwjXeFY8DFL3eYnUooMT0L0w=
 github.com/floatpane/go-openpgp-card-hl v0.0.1/go.mod h1:Mrx+ukCnpEpMAxyB0p8Ch2gu78Q3Ir40BxBybb2jirw=
 github.com/floatpane/go-secretbox v0.1.0 h1:xNryazmCP0oR/yVxIkHRc5bcV56YrbisY+bMl8BBfwU=

main.go 🔗

@@ -28,12 +28,12 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	calendar "github.com/floatpane/go-icalendar"
 	"github.com/floatpane/matcha/backend"
 	_ "github.com/floatpane/matcha/backend/imap"
 	_ "github.com/floatpane/matcha/backend/jmap"
 	_ "github.com/floatpane/matcha/backend/maildir"
 	_ "github.com/floatpane/matcha/backend/pop3"
-	"github.com/floatpane/matcha/calendar"
 	matchaCli "github.com/floatpane/matcha/cli"
 	"github.com/floatpane/matcha/clib"
 	"github.com/floatpane/matcha/clib/macos"

testdata/invites/simple.ics 🔗

@@ -1,18 +0,0 @@
-BEGIN:VCALENDAR
-VERSION:2.0
-PRODID:-//Test//Test//EN
-METHOD:REQUEST
-BEGIN:VEVENT
-UID:test-event-123@example.com
-DTSTAMP:20260415T120000Z
-DTSTART:20260421T140000Z
-DTEND:20260421T153000Z
-SUMMARY:Q2 Planning Meeting
-DESCRIPTION:Discuss roadmap priorities and resource allocation for Q2.
-LOCATION:Conference Room A
-ORGANIZER:mailto:alice@company.com
-ATTENDEE;CN=Bob Smith;PARTSTAT=NEEDS-ACTION:mailto:bob@company.com
-ATTENDEE;CN=Carol Jones;PARTSTAT=NEEDS-ACTION:mailto:carol@company.com
-STATUS:CONFIRMED
-END:VEVENT
-END:VCALENDAR

tui/email_view.go 🔗

@@ -10,7 +10,7 @@ import (
 	"charm.land/bubbles/v2/viewport"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
-	"github.com/floatpane/matcha/calendar"
+	calendar "github.com/floatpane/go-icalendar"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
 	"github.com/floatpane/matcha/theme"

tui/messages.go 🔗

@@ -1,8 +1,8 @@
 package tui
 
 import (
+	calendar "github.com/floatpane/go-icalendar"
 	"github.com/floatpane/matcha/backend"
-	"github.com/floatpane/matcha/calendar"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/daemonrpc"
 	"github.com/floatpane/matcha/fetcher"