Skip to content

Commit 36a66df

Browse files
committed
add ical importer
1 parent 919c255 commit 36a66df

4 files changed

Lines changed: 231 additions & 0 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/git-calendar/core
33
go 1.26.4
44

55
require (
6+
github.com/arran4/golang-ical v0.3.5
67
github.com/go-git/go-billy/v5 v5.9.0
78
github.com/go-git/go-git/v5 v5.19.1
89
github.com/google/go-cmp v0.7.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
99
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
1010
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
1111
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
12+
github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A=
13+
github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8=
1214
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
1315
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
1416
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=

pkg/ical/import.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package ical
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
8+
ics "github.com/arran4/golang-ical"
9+
"github.com/git-calendar/core/pkg/core"
10+
)
11+
12+
// Import parses iCalendar events and assigns them to calendar.
13+
func Import(r io.Reader, calendar string) ([]core.Event, error) {
14+
cal, err := ics.ParseCalendar(r)
15+
if err != nil {
16+
return nil, fmt.Errorf("parse iCalendar: %w", err)
17+
}
18+
19+
sourceEvents := cal.Events()
20+
events := make([]core.Event, 0, len(sourceEvents))
21+
for i, source := range sourceEvents {
22+
event, err := importEvent(source, calendar)
23+
if err != nil {
24+
return nil, fmt.Errorf("import event %d: %w", i+1, err)
25+
}
26+
events = append(events, event)
27+
}
28+
29+
return events, nil
30+
}
31+
32+
func importEvent(source *ics.VEvent, calendar string) (core.Event, error) {
33+
from, err := source.GetStartAt()
34+
if err != nil {
35+
return core.Event{}, fmt.Errorf("read DTSTART: %w", err)
36+
}
37+
38+
to, err := source.GetEndAt()
39+
if err != nil {
40+
return core.Event{}, fmt.Errorf("read DTEND: %w", err)
41+
}
42+
43+
repeat, err := importRepetition(source)
44+
if err != nil {
45+
return core.Event{}, err
46+
}
47+
48+
event := core.Event{
49+
Title: text(source, ics.ComponentPropertySummary),
50+
Location: text(source, ics.ComponentPropertyLocation),
51+
Description: text(source, ics.ComponentPropertyDescription),
52+
From: from,
53+
To: to,
54+
Calendar: calendar,
55+
Repeat: repeat,
56+
}
57+
if err := event.Validate(); err != nil {
58+
return core.Event{}, err
59+
}
60+
61+
return event, nil
62+
}
63+
64+
func importRepetition(event *ics.VEvent) (*core.Repetition, error) {
65+
if event.HasProperty(ics.ComponentPropertyRdate) ||
66+
event.HasProperty(ics.ComponentPropertyExdate) ||
67+
event.HasProperty(ics.ComponentPropertyExrule) ||
68+
event.HasProperty(ics.ComponentPropertyRecurrenceId) {
69+
return nil, errors.New("recurrence dates and exceptions are not supported")
70+
}
71+
72+
rules, err := event.GetRRules()
73+
if err != nil {
74+
return nil, err
75+
}
76+
if len(rules) == 0 {
77+
return nil, nil
78+
}
79+
if len(rules) != 1 {
80+
return nil, errors.New("multiple recurrence rules are not supported")
81+
}
82+
83+
rule := rules[0]
84+
if hasModifiers(rule) {
85+
return nil, errors.New("recurrence modifiers are not supported")
86+
}
87+
88+
frequency, err := importFrequency(rule.Freq)
89+
if err != nil {
90+
return nil, err
91+
}
92+
93+
return &core.Repetition{
94+
Frequency: frequency,
95+
Interval: rule.Interval,
96+
Until: rule.Until,
97+
Count: rule.Count,
98+
}, nil
99+
}
100+
101+
func importFrequency(frequency ics.Frequency) (core.Freq, error) {
102+
switch frequency {
103+
case ics.FrequencyDaily:
104+
return core.Day, nil
105+
case ics.FrequencyWeekly:
106+
return core.Week, nil
107+
case ics.FrequencyMonthly:
108+
return core.Month, nil
109+
case ics.FrequencyYearly:
110+
return core.Year, nil
111+
default:
112+
return core.Invalid, fmt.Errorf("unsupported recurrence frequency %q", frequency)
113+
}
114+
}
115+
116+
func hasModifiers(rule *ics.RecurrenceRule) bool {
117+
return len(rule.BySecond)+len(rule.ByMinute)+len(rule.ByHour)+
118+
len(rule.ByDay)+len(rule.ByMonthDay)+len(rule.ByYearDay)+
119+
len(rule.ByWeekNo)+len(rule.ByMonth)+len(rule.BySetPos) > 0 ||
120+
rule.Wkst != ""
121+
}
122+
123+
func text(event *ics.VEvent, property ics.ComponentProperty) string {
124+
value := event.GetProperty(property)
125+
if value == nil {
126+
return ""
127+
}
128+
return value.Value
129+
}

pkg/ical/import_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package ical_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/git-calendar/core/pkg/core"
9+
"github.com/git-calendar/core/pkg/ical"
10+
)
11+
12+
func TestImport(t *testing.T) {
13+
input := `BEGIN:VCALENDAR
14+
VERSION:2.0
15+
PRODID:-//git-calendar//test//EN
16+
BEGIN:VEVENT
17+
UID:one@example.com
18+
DTSTART:20260714T100000Z
19+
DTEND:20260714T113000Z
20+
SUMMARY:Planning\, review
21+
LOCATION:https://meeting.abc/123
22+
DESCRIPTION:Line one\nLine two
23+
END:VEVENT
24+
BEGIN:VEVENT
25+
UID:two@example.com
26+
DTSTART:20260715T090000Z
27+
DTEND:20260715T100000Z
28+
SUMMARY:Fortnightly sync
29+
RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4
30+
END:VEVENT
31+
END:VCALENDAR`
32+
33+
events, err := ical.Import(strings.NewReader(input), "Work")
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
if len(events) != 2 {
38+
t.Fatalf("got %d events, want 2", len(events))
39+
}
40+
41+
first := events[0]
42+
if first.Id.Version() != 4 {
43+
t.Errorf("event ID version = %d, want 4", first.Id.Version())
44+
}
45+
if first.Title != "Planning, review" {
46+
t.Errorf("Title = %q, want %q", first.Title, "Planning, review")
47+
}
48+
if first.Location != "https://meeting.abc/123" {
49+
t.Errorf("Location = %q, want %q", first.Location, "https://meeting.abc/123")
50+
}
51+
if first.Description != "Line one\nLine two" {
52+
t.Errorf("Description = %q, want %q", first.Description, "Line one\nLine two")
53+
}
54+
if first.Calendar != "Work" {
55+
t.Errorf("Calendar = %q, want %q", first.Calendar, "Work")
56+
}
57+
assertTime(t, first.From, time.Date(2026, 7, 14, 10, 0, 0, 0, time.UTC))
58+
assertTime(t, first.To, time.Date(2026, 7, 14, 11, 30, 0, 0, time.UTC))
59+
60+
second := events[1]
61+
if second.Repeat == nil {
62+
t.Fatal("Repeat is nil")
63+
}
64+
if second.Repeat.Frequency != core.Week {
65+
t.Errorf("Frequency = %v, want %v", second.Repeat.Frequency, core.Week)
66+
}
67+
if second.Repeat.Interval != 2 {
68+
t.Errorf("Interval = %d, want 2", second.Repeat.Interval)
69+
}
70+
if second.Repeat.Count != 4 {
71+
t.Errorf("Count = %d, want 4", second.Repeat.Count)
72+
}
73+
}
74+
75+
func TestImportRejectsUnsupportedRecurrence(t *testing.T) {
76+
input := `BEGIN:VCALENDAR
77+
VERSION:2.0
78+
PRODID:-//git-calendar//test//EN
79+
BEGIN:VEVENT
80+
UID:one@example.com
81+
DTSTART:20260714T100000Z
82+
DTEND:20260714T110000Z
83+
SUMMARY:Several days
84+
RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE
85+
END:VEVENT
86+
END:VCALENDAR`
87+
88+
_, err := ical.Import(strings.NewReader(input), "Work")
89+
if err == nil || !strings.Contains(err.Error(), "recurrence modifiers are not supported") {
90+
t.Fatalf("error = %v, want unsupported recurrence modifier error", err)
91+
}
92+
}
93+
94+
func assertTime(t *testing.T, got, want time.Time) {
95+
t.Helper()
96+
if !got.Equal(want) {
97+
t.Errorf("time = %v, want %v", got, want)
98+
}
99+
}

0 commit comments

Comments
 (0)