From 36a66dfa91fc67c7d2a7cc8b696dbe233d2a0816 Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 14 Jul 2026 21:50:12 +0200 Subject: [PATCH 1/3] add ical importer --- go.mod | 1 + go.sum | 2 + pkg/ical/import.go | 129 ++++++++++++++++++++++++++++++++++++++++ pkg/ical/import_test.go | 99 ++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 pkg/ical/import.go create mode 100644 pkg/ical/import_test.go diff --git a/go.mod b/go.mod index 22f0c86..2cbe9cb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/git-calendar/core go 1.26.4 require ( + github.com/arran4/golang-ical v0.3.5 github.com/go-git/go-billy/v5 v5.9.0 github.com/go-git/go-git/v5 v5.19.1 github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 7ae3420..8bfb036 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +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/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= diff --git a/pkg/ical/import.go b/pkg/ical/import.go new file mode 100644 index 0000000..08b479f --- /dev/null +++ b/pkg/ical/import.go @@ -0,0 +1,129 @@ +package ical + +import ( + "errors" + "fmt" + "io" + + ics "github.com/arran4/golang-ical" + "github.com/git-calendar/core/pkg/core" +) + +// Import parses iCalendar events and assigns them to calendar. +func Import(r io.Reader, calendar string) ([]core.Event, error) { + cal, err := ics.ParseCalendar(r) + if err != nil { + return nil, fmt.Errorf("parse iCalendar: %w", err) + } + + sourceEvents := cal.Events() + events := make([]core.Event, 0, len(sourceEvents)) + for i, source := range sourceEvents { + event, err := importEvent(source, calendar) + if err != nil { + return nil, fmt.Errorf("import event %d: %w", i+1, err) + } + events = append(events, event) + } + + return events, nil +} + +func importEvent(source *ics.VEvent, calendar string) (core.Event, error) { + from, err := source.GetStartAt() + if err != nil { + return core.Event{}, fmt.Errorf("read DTSTART: %w", err) + } + + to, err := source.GetEndAt() + if err != nil { + return core.Event{}, fmt.Errorf("read DTEND: %w", err) + } + + repeat, err := importRepetition(source) + if err != nil { + return core.Event{}, err + } + + event := core.Event{ + Title: text(source, ics.ComponentPropertySummary), + Location: text(source, ics.ComponentPropertyLocation), + Description: text(source, ics.ComponentPropertyDescription), + From: from, + To: to, + Calendar: calendar, + Repeat: repeat, + } + if err := event.Validate(); err != nil { + return core.Event{}, err + } + + return event, nil +} + +func importRepetition(event *ics.VEvent) (*core.Repetition, error) { + if event.HasProperty(ics.ComponentPropertyRdate) || + event.HasProperty(ics.ComponentPropertyExdate) || + event.HasProperty(ics.ComponentPropertyExrule) || + event.HasProperty(ics.ComponentPropertyRecurrenceId) { + return nil, errors.New("recurrence dates and exceptions are not supported") + } + + rules, err := event.GetRRules() + if err != nil { + return nil, err + } + if len(rules) == 0 { + return nil, nil + } + if len(rules) != 1 { + return nil, errors.New("multiple recurrence rules are not supported") + } + + rule := rules[0] + if hasModifiers(rule) { + return nil, errors.New("recurrence modifiers are not supported") + } + + frequency, err := importFrequency(rule.Freq) + if err != nil { + return nil, err + } + + return &core.Repetition{ + Frequency: frequency, + Interval: rule.Interval, + Until: rule.Until, + Count: rule.Count, + }, nil +} + +func importFrequency(frequency ics.Frequency) (core.Freq, error) { + switch frequency { + case ics.FrequencyDaily: + return core.Day, nil + case ics.FrequencyWeekly: + return core.Week, nil + case ics.FrequencyMonthly: + return core.Month, nil + case ics.FrequencyYearly: + return core.Year, nil + default: + return core.Invalid, fmt.Errorf("unsupported recurrence frequency %q", frequency) + } +} + +func hasModifiers(rule *ics.RecurrenceRule) bool { + return len(rule.BySecond)+len(rule.ByMinute)+len(rule.ByHour)+ + len(rule.ByDay)+len(rule.ByMonthDay)+len(rule.ByYearDay)+ + len(rule.ByWeekNo)+len(rule.ByMonth)+len(rule.BySetPos) > 0 || + rule.Wkst != "" +} + +func text(event *ics.VEvent, property ics.ComponentProperty) string { + value := event.GetProperty(property) + if value == nil { + return "" + } + return value.Value +} diff --git a/pkg/ical/import_test.go b/pkg/ical/import_test.go new file mode 100644 index 0000000..5607883 --- /dev/null +++ b/pkg/ical/import_test.go @@ -0,0 +1,99 @@ +package ical_test + +import ( + "strings" + "testing" + "time" + + "github.com/git-calendar/core/pkg/core" + "github.com/git-calendar/core/pkg/ical" +) + +func TestImport(t *testing.T) { + input := `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//git-calendar//test//EN +BEGIN:VEVENT +UID:one@example.com +DTSTART:20260714T100000Z +DTEND:20260714T113000Z +SUMMARY:Planning\, review +LOCATION:https://meeting.abc/123 +DESCRIPTION:Line one\nLine two +END:VEVENT +BEGIN:VEVENT +UID:two@example.com +DTSTART:20260715T090000Z +DTEND:20260715T100000Z +SUMMARY:Fortnightly sync +RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4 +END:VEVENT +END:VCALENDAR` + + events, err := ical.Import(strings.NewReader(input), "Work") + if err != nil { + t.Fatal(err) + } + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + + first := events[0] + if first.Id.Version() != 4 { + t.Errorf("event ID version = %d, want 4", first.Id.Version()) + } + if first.Title != "Planning, review" { + t.Errorf("Title = %q, want %q", first.Title, "Planning, review") + } + if first.Location != "https://meeting.abc/123" { + t.Errorf("Location = %q, want %q", first.Location, "https://meeting.abc/123") + } + if first.Description != "Line one\nLine two" { + t.Errorf("Description = %q, want %q", first.Description, "Line one\nLine two") + } + if first.Calendar != "Work" { + t.Errorf("Calendar = %q, want %q", first.Calendar, "Work") + } + assertTime(t, first.From, time.Date(2026, 7, 14, 10, 0, 0, 0, time.UTC)) + assertTime(t, first.To, time.Date(2026, 7, 14, 11, 30, 0, 0, time.UTC)) + + second := events[1] + if second.Repeat == nil { + t.Fatal("Repeat is nil") + } + if second.Repeat.Frequency != core.Week { + t.Errorf("Frequency = %v, want %v", second.Repeat.Frequency, core.Week) + } + if second.Repeat.Interval != 2 { + t.Errorf("Interval = %d, want 2", second.Repeat.Interval) + } + if second.Repeat.Count != 4 { + t.Errorf("Count = %d, want 4", second.Repeat.Count) + } +} + +func TestImportRejectsUnsupportedRecurrence(t *testing.T) { + input := `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//git-calendar//test//EN +BEGIN:VEVENT +UID:one@example.com +DTSTART:20260714T100000Z +DTEND:20260714T110000Z +SUMMARY:Several days +RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE +END:VEVENT +END:VCALENDAR` + + _, err := ical.Import(strings.NewReader(input), "Work") + if err == nil || !strings.Contains(err.Error(), "recurrence modifiers are not supported") { + t.Fatalf("error = %v, want unsupported recurrence modifier error", err) + } +} + +func assertTime(t *testing.T, got, want time.Time) { + t.Helper() + if !got.Equal(want) { + t.Errorf("time = %v, want %v", got, want) + } +} From ef96be584ffb51f34d4f22391404d2fe96c7280a Mon Sep 17 00:00:00 2001 From: firu11 Date: Wed, 22 Jul 2026 13:10:57 +0200 Subject: [PATCH 2/3] integrate ical importer with core --- cmd/wasm/main.go | 10 ++ e2e/ical_import_test.go | 146 ++++++++++++++++ notes.md | 5 +- pkg/api/api.go | 12 ++ pkg/core/core.go | 3 + pkg/core/core_calendars.go | 21 ++- pkg/core/core_ical.go | 159 ++++++++++++++++++ pkg/{ical/import.go => core/ical.go} | 81 +++++---- .../import_test.go => core/ical_test.go} | 23 ++- 9 files changed, 411 insertions(+), 49 deletions(-) create mode 100644 e2e/ical_import_test.go create mode 100644 pkg/core/core_ical.go rename pkg/{ical/import.go => core/ical.go} (50%) rename pkg/{ical/import_test.go => core/ical_test.go} (75%) diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 74258e5..dd0d95d 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -50,6 +50,16 @@ func RegisterCallbacks(api *api.Api) { return nil, api.LoadCalendars() }) }), + "importICalFile": js.FuncOf(func(this js.Value, args []js.Value) any { + return wrapPromise(func() (any, error) { + return nil, api.ImportICalFile(args[0].String(), args[1].String()) + }) + }), + "importICalURL": js.FuncOf(func(this js.Value, args []js.Value) any { + return wrapPromise(func() (any, error) { + return nil, api.ImportICalURL(args[0].String(), args[1].String()) + }) + }), "updateRemote": js.FuncOf(func(this js.Value, args []js.Value) any { return wrapPromise(func() (any, error) { return nil, api.UpdateRemote(args[0].String(), args[1].String(), args[2].Bool()) diff --git a/e2e/ical_import_test.go b/e2e/ical_import_test.go new file mode 100644 index 0000000..97204aa --- /dev/null +++ b/e2e/ical_import_test.go @@ -0,0 +1,146 @@ +package e2e + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/git-calendar/core/pkg/core" + "github.com/git-calendar/core/pkg/filesystem" +) + +func TestImportICalFilePersistsEvents(t *testing.T) { + const calendar = "test-ical-file" + c := core.NewCore() + if err := c.CreateCalendar(calendar, ""); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.RemoveCalendar(calendar) }) + + if err := c.ImportICalFile(calendar, strings.NewReader(icalFeed("Imported event"))); err != nil { + t.Fatal(err) + } + + events := importedEvents(c, calendar) + if len(events) != 1 { + t.Fatalf("got %d imported events, want 1", len(events)) + } + if events[0].Id.Version() != 4 { + t.Errorf("event ID version = %d, want 4", events[0].Id.Version()) + } + id := events[0].Id + + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + eventPath := filepath.Join(home, filesystem.DirName, calendar, core.EventsDirName, id.String()+".json") + if _, err := os.Stat(eventPath); err != nil { + t.Fatalf("imported event file was not saved: %v", err) + } + + if err := c.LoadCalendars(); err != nil { + t.Fatal(err) + } + events = importedEvents(c, calendar) + if len(events) != 1 || events[0].Id != id { + t.Fatalf("imported event did not survive reload: %+v", events) + } +} + +func TestImportICalURLRefetchesOnLoad(t *testing.T) { + const name = "test-ical-url" + + var feed atomic.Value + feed.Store(icalFeed("First title")) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + _, _ = w.Write([]byte(feed.Load().(string))) + })) + defer server.Close() + + sourceURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + c := core.NewCore() + if err := c.ImportICalURL(name, sourceURL); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.RemoveCalendar(name) }) + + events := importedEvents(c, name) + if len(events) != 1 || events[0].Title != "First title" { + t.Fatalf("first URL import = %+v", events) + } + if events[0].Id.Version() != 8 { + t.Errorf("event ID version = %d, want 8", events[0].Id.Version()) + } + id := events[0].Id + + calendars, err := c.ListCalendars() + if err != nil { + t.Fatal(err) + } + for _, calendar := range calendars { + if calendar.Name == name && !calendar.Readonly { + t.Fatal("URL calendar is not read-only") + } + } + + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(home, filesystem.DirName, name)) + if err != nil { + t.Fatal(err) + } + if string(data) != server.URL { + t.Errorf("URL file = %q, want %q", data, server.URL) + } + + feed.Store(icalFeed("Second title")) + if err := c.LoadCalendars(); err != nil { + t.Fatal(err) + } + + events = importedEvents(c, name) + if len(events) != 1 || events[0].Title != "Second title" { + t.Fatalf("refetched URL import = %+v", events) + } + if events[0].Id != id { + t.Errorf("event ID changed after refetch: got %s, want %s", events[0].Id, id) + } + if requests.Load() != 2 { + t.Errorf("URL was fetched %d times, want 2", requests.Load()) + } +} + +func importedEvents(c *core.Core, calendar string) []core.Event { + from := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + return c.GetEvents(from, to, core.GetEventsFilter{calendar: nil}) +} + +func icalFeed(title string) string { + return fmt.Sprintf(`BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//git-calendar//test//EN +BEGIN:VEVENT +UID:stable@example.com +DTSTART:20260714T100000Z +DTEND:20260714T110000Z +SUMMARY:%s +END:VEVENT +END:VCALENDAR`, title) +} diff --git a/notes.md b/notes.md index 331fcd6..2c3b353 100644 --- a/notes.md +++ b/notes.md @@ -22,7 +22,9 @@ - [x] load repositories - [ ] Undo function (git reset HEAD~1) - [ ] iCalendar compatibility - - [ ] import (periodical & one-time) + - [x] import + - [x] one-time import into a Git-backed calendar + - [x] URL calendar fetched on every load - [ ] export - to a file - idk about url @@ -60,6 +62,7 @@ │ │ └── .json │ ├── index.jsonl │ └── index-rich.jsonl +├── imported ├── default.key ├── shared.readonly └── shared.key diff --git a/pkg/api/api.go b/pkg/api/api.go index e0eaf75..165d6d8 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "net/url" + "strings" "time" "github.com/git-calendar/core/pkg/core" @@ -55,9 +56,20 @@ func (a *Api) LoadCalendars() error { return a.inner.LoadCa func (a *Api) SetCorsProxy(proxyUrl string) error { return a.inner.SetCorsProxy(proxyUrl) } func (a *Api) SyncAll() error { return a.inner.SyncAll() } func (a *Api) ExportZip(calendar string) ([]byte, error) { return a.inner.ExportZip(calendar) } +func (a *Api) ImportICalFile(calendar, data string) error { + return a.inner.ImportICalFile(calendar, strings.NewReader(data)) +} // ------------------------------ Wrapper methods encoding and decoding JSONs ------------------------------ +func (a *Api) ImportICalURL(name, rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("iCalendar URL is invalid: %w", err) + } + return a.inner.ImportICalURL(name, parsed) +} + func (a *Api) UpdateRemote(calendar string, remoteUrl string, readonly bool) error { parsed, err := url.Parse(remoteUrl) if err != nil { diff --git a/pkg/core/core.go b/pkg/core/core.go index c7f0adf..ad461a8 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -171,6 +171,9 @@ func (c *Core) ExportZip(calendar string) ([]byte, error) { if !ok { return nil, fmt.Errorf("calendar not found: %s", calendar) } + if cal.repository == nil { + return nil, errors.New("URL calendars cannot be exported") + } wt, err := cal.repository.Worktree() if err != nil { diff --git a/pkg/core/core_calendars.go b/pkg/core/core_calendars.go index 789af58..1b40039 100644 --- a/pkg/core/core_calendars.go +++ b/pkg/core/core_calendars.go @@ -90,11 +90,18 @@ func (c *Core) LoadCalendars() error { return fmt.Errorf("failed to list all directories in root: %w", err) } + icalURLs := make(map[string]*url.URL) for _, entry := range entries { + name := entry.Name() if !entry.IsDir() { + sourceURL, err := c.readICalURL(name) + if err != nil { + continue + } + c.calendars[name] = &Calendar{Name: name, Readonly: true} + icalURLs[name] = sourceURL continue } - name := entry.Name() repo, err := c.initCalendarRepo(name) if err != nil { @@ -130,6 +137,13 @@ func (c *Core) LoadCalendars() error { // load tree + events // TODO do not load files, but build tree from index.json for _, cal := range c.calendars { + if sourceURL, ok := icalURLs[cal.Name]; ok { + if err := c.loadICalURL(cal.Name, sourceURL); err != nil { + fmt.Printf("WARN: failed to load iCalendar URL %q: %v\n", cal.Name, err) + } + continue + } + wt, _ := cal.repository.Worktree() eventsDir, _ := wt.Filesystem.Chroot(EventsDirName) eventEntries, _ := eventsDir.ReadDir("/") @@ -280,7 +294,10 @@ func (c *Core) RenameCalendar(oldName, newName string) error { calendar := c.calendars[oldName] if err := c.fs.Rename(oldName, newName); err != nil { - return fmt.Errorf("failed to rename the repository directory: %w", err) + return fmt.Errorf("failed to rename calendar: %w", err) + } + if calendar.repository == nil { + return c.LoadCalendars() } if len(calendar.EncryptionKey) != 0 { // TODO: maybe check c.fs.Stat() instead? if err := c.fs.Rename(fmt.Sprintf("%s.key", oldName), fmt.Sprintf("%s.key", newName)); err != nil { diff --git a/pkg/core/core_ical.go b/pkg/core/core_ical.go new file mode 100644 index 0000000..7ff1d45 --- /dev/null +++ b/pkg/core/core_ical.go @@ -0,0 +1,159 @@ +package core + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "github.com/google/uuid" +) + +// ImportICalFile imports events once and saves them like normal events. +func (c *Core) ImportICalFile(calendar string, r io.Reader) error { + cal, ok := c.calendars[calendar] + if !ok { + return fmt.Errorf("calendar not found: %s", calendar) + } + if cal.Readonly { + return errors.New("the specified calendar is read-only") + } + + events, err := parseICal(r, calendar, false) + if err != nil { + return err + } + + for i, event := range events { + if _, err := c.CreateEvent(event); err != nil { + return fmt.Errorf("save imported event %d: %w", i+1, err) + } + } + + return nil +} + +// ImportICalURL creates a read-only calendar that is fetched on every load. +func (c *Core) ImportICalURL(name string, sourceURL *url.URL) error { + if err := validateICalURL(sourceURL); err != nil { + return err + } + if err := validateICalName(name); err != nil { + return err + } + if _, exists := c.calendars[name]; exists { + return fmt.Errorf("calendar named %s already exists", name) + } + if _, err := c.fs.Stat(name); err == nil { + return fmt.Errorf("file named %s already exists", name) + } else if !os.IsNotExist(err) { + return err + } + + file, err := c.fs.Create(name) + if err != nil { + return fmt.Errorf("create iCalendar URL file: %w", err) + } + if _, err := file.Write([]byte(sourceURL.String())); err != nil { + file.Close() + _ = c.fs.Remove(name) + return fmt.Errorf("write iCalendar URL file: %w", err) + } + if err := file.Close(); err != nil { + _ = c.fs.Remove(name) + return fmt.Errorf("close iCalendar URL file: %w", err) + } + + return c.LoadCalendars() +} + +func (c *Core) readICalURL(name string) (*url.URL, error) { + file, err := c.fs.Open(name) + if err != nil { + return nil, err + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + sourceURL, err := url.ParseRequestURI(strings.TrimSpace(string(data))) + if err != nil { + return nil, err + } + if err := validateICalURL(sourceURL); err != nil { + return nil, err + } + return sourceURL, nil +} + +func (c *Core) loadICalURL(name string, sourceURL *url.URL) error { + requestURL := sourceURL + if c.proxyUrl != nil { + requestURL = useCorsProxy(sourceURL, c.proxyUrl) + } + if requestURL == nil { + return errors.New("invalid proxied iCalendar URL") + } + + client := http.Client{Timeout: 30 * time.Second} + response, err := client.Get(requestURL.String()) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("fetch iCalendar: %s", response.Status) + } + + events, err := parseICal(response.Body, name, true) + if err != nil { + return err + } + + seen := make(map[uuid.UUID]struct{}, len(events)) + for _, event := range events { + _, alreadyLoaded := c.events[event.Id] + _, duplicate := seen[event.Id] + if alreadyLoaded || duplicate { + return fmt.Errorf("duplicate imported event ID %q", event.Id) + } + seen[event.Id] = struct{}{} + } + + for i := range events { + event := &events[i] + if err := c.intervalTree.InsertEvent(*event); err != nil { + return err + } + c.events[event.Id] = event + } + + return nil +} + +func validateICalURL(sourceURL *url.URL) error { + if sourceURL == nil || sourceURL.Host == "" || + (sourceURL.Scheme != "http" && sourceURL.Scheme != "https") { + return errors.New("iCalendar URL must be an absolute HTTP or HTTPS URL") + } + return nil +} + +func validateICalName(name string) error { + if name == "" || name == "." || path.Base(name) != name { + return errors.New("iCalendar name must be a single file name") + } + if strings.HasSuffix(name, ".key") || strings.HasSuffix(name, ".readonly") { + return errors.New("iCalendar name uses a reserved suffix") + } + return nil +} diff --git a/pkg/ical/import.go b/pkg/core/ical.go similarity index 50% rename from pkg/ical/import.go rename to pkg/core/ical.go index 08b479f..cb2c730 100644 --- a/pkg/ical/import.go +++ b/pkg/core/ical.go @@ -1,67 +1,69 @@ -package ical +package core import ( + "crypto/sha256" "errors" "fmt" "io" + "time" ics "github.com/arran4/golang-ical" - "github.com/git-calendar/core/pkg/core" + "github.com/google/uuid" ) -// Import parses iCalendar events and assigns them to calendar. -func Import(r io.Reader, calendar string) ([]core.Event, error) { +func parseICal(r io.Reader, calendar string, stableIDs bool) ([]Event, error) { cal, err := ics.ParseCalendar(r) if err != nil { return nil, fmt.Errorf("parse iCalendar: %w", err) } sourceEvents := cal.Events() - events := make([]core.Event, 0, len(sourceEvents)) + events := make([]Event, 0, len(sourceEvents)) for i, source := range sourceEvents { - event, err := importEvent(source, calendar) + event, err := parseICalEvent(source, calendar) if err != nil { return nil, fmt.Errorf("import event %d: %w", i+1, err) } + if stableIDs { + event.Id = icalEventID(calendar, icalText(source, ics.ComponentPropertyUniqueId), i, event) + } + if err := event.Validate(); err != nil { + return nil, fmt.Errorf("import event %d: %w", i+1, err) + } events = append(events, event) } return events, nil } -func importEvent(source *ics.VEvent, calendar string) (core.Event, error) { +func parseICalEvent(source *ics.VEvent, calendar string) (Event, error) { from, err := source.GetStartAt() if err != nil { - return core.Event{}, fmt.Errorf("read DTSTART: %w", err) + return Event{}, fmt.Errorf("read DTSTART: %w", err) } to, err := source.GetEndAt() if err != nil { - return core.Event{}, fmt.Errorf("read DTEND: %w", err) + return Event{}, fmt.Errorf("read DTEND: %w", err) } - repeat, err := importRepetition(source) + repeat, err := parseICalRepetition(source) if err != nil { - return core.Event{}, err + return Event{}, err } - event := core.Event{ - Title: text(source, ics.ComponentPropertySummary), - Location: text(source, ics.ComponentPropertyLocation), - Description: text(source, ics.ComponentPropertyDescription), + return Event{ + Title: icalText(source, ics.ComponentPropertySummary), + Location: icalText(source, ics.ComponentPropertyLocation), + Description: icalText(source, ics.ComponentPropertyDescription), From: from, To: to, Calendar: calendar, Repeat: repeat, - } - if err := event.Validate(); err != nil { - return core.Event{}, err - } - - return event, nil + }, nil } -func importRepetition(event *ics.VEvent) (*core.Repetition, error) { +func parseICalRepetition(event *ics.VEvent) (*Repetition, error) { if event.HasProperty(ics.ComponentPropertyRdate) || event.HasProperty(ics.ComponentPropertyExdate) || event.HasProperty(ics.ComponentPropertyExrule) || @@ -81,16 +83,16 @@ func importRepetition(event *ics.VEvent) (*core.Repetition, error) { } rule := rules[0] - if hasModifiers(rule) { + if hasICalModifiers(rule) { return nil, errors.New("recurrence modifiers are not supported") } - frequency, err := importFrequency(rule.Freq) + frequency, err := parseICalFrequency(rule.Freq) if err != nil { return nil, err } - return &core.Repetition{ + return &Repetition{ Frequency: frequency, Interval: rule.Interval, Until: rule.Until, @@ -98,32 +100,45 @@ func importRepetition(event *ics.VEvent) (*core.Repetition, error) { }, nil } -func importFrequency(frequency ics.Frequency) (core.Freq, error) { +func parseICalFrequency(frequency ics.Frequency) (Freq, error) { switch frequency { case ics.FrequencyDaily: - return core.Day, nil + return Day, nil case ics.FrequencyWeekly: - return core.Week, nil + return Week, nil case ics.FrequencyMonthly: - return core.Month, nil + return Month, nil case ics.FrequencyYearly: - return core.Year, nil + return Year, nil default: - return core.Invalid, fmt.Errorf("unsupported recurrence frequency %q", frequency) + return Invalid, fmt.Errorf("unsupported recurrence frequency %q", frequency) } } -func hasModifiers(rule *ics.RecurrenceRule) bool { +func hasICalModifiers(rule *ics.RecurrenceRule) bool { return len(rule.BySecond)+len(rule.ByMinute)+len(rule.ByHour)+ len(rule.ByDay)+len(rule.ByMonthDay)+len(rule.ByYearDay)+ len(rule.ByWeekNo)+len(rule.ByMonth)+len(rule.BySetPos) > 0 || rule.Wkst != "" } -func text(event *ics.VEvent, property ics.ComponentProperty) string { +func icalText(event *ics.VEvent, property ics.ComponentProperty) string { value := event.GetProperty(property) if value == nil { return "" } return value.Value } + +func icalEventID(calendar, uid string, index int, event Event) uuid.UUID { + if uid == "" { + uid = fmt.Sprintf("%d\x00%s\x00%s", index, event.Title, event.From.Format(time.RFC3339Nano)) + } + sum := sha256.Sum256([]byte(calendar + "\x00" + uid)) + + var id uuid.UUID + copy(id[:], sum[:16]) + id[6] = id[6]&0x0f | 0x80 + id[8] = id[8]&0x3f | 0x80 + return id +} diff --git a/pkg/ical/import_test.go b/pkg/core/ical_test.go similarity index 75% rename from pkg/ical/import_test.go rename to pkg/core/ical_test.go index 5607883..5a821ee 100644 --- a/pkg/ical/import_test.go +++ b/pkg/core/ical_test.go @@ -1,15 +1,12 @@ -package ical_test +package core import ( "strings" "testing" "time" - - "github.com/git-calendar/core/pkg/core" - "github.com/git-calendar/core/pkg/ical" ) -func TestImport(t *testing.T) { +func TestParseICal(t *testing.T) { input := `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//git-calendar//test//EN @@ -30,7 +27,7 @@ RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4 END:VEVENT END:VCALENDAR` - events, err := ical.Import(strings.NewReader(input), "Work") + events, err := parseICal(strings.NewReader(input), "Work", false) if err != nil { t.Fatal(err) } @@ -54,15 +51,15 @@ END:VCALENDAR` if first.Calendar != "Work" { t.Errorf("Calendar = %q, want %q", first.Calendar, "Work") } - assertTime(t, first.From, time.Date(2026, 7, 14, 10, 0, 0, 0, time.UTC)) - assertTime(t, first.To, time.Date(2026, 7, 14, 11, 30, 0, 0, time.UTC)) + assertICalTime(t, first.From, time.Date(2026, 7, 14, 10, 0, 0, 0, time.UTC)) + assertICalTime(t, first.To, time.Date(2026, 7, 14, 11, 30, 0, 0, time.UTC)) second := events[1] if second.Repeat == nil { t.Fatal("Repeat is nil") } - if second.Repeat.Frequency != core.Week { - t.Errorf("Frequency = %v, want %v", second.Repeat.Frequency, core.Week) + if second.Repeat.Frequency != Week { + t.Errorf("Frequency = %v, want %v", second.Repeat.Frequency, Week) } if second.Repeat.Interval != 2 { t.Errorf("Interval = %d, want 2", second.Repeat.Interval) @@ -72,7 +69,7 @@ END:VCALENDAR` } } -func TestImportRejectsUnsupportedRecurrence(t *testing.T) { +func TestParseICalRejectsUnsupportedRecurrence(t *testing.T) { input := `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//git-calendar//test//EN @@ -85,13 +82,13 @@ RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE END:VEVENT END:VCALENDAR` - _, err := ical.Import(strings.NewReader(input), "Work") + _, err := parseICal(strings.NewReader(input), "Work", false) if err == nil || !strings.Contains(err.Error(), "recurrence modifiers are not supported") { t.Fatalf("error = %v, want unsupported recurrence modifier error", err) } } -func assertTime(t *testing.T, got, want time.Time) { +func assertICalTime(t *testing.T, got, want time.Time) { t.Helper() if !got.Equal(want) { t.Errorf("time = %v, want %v", got, want) From 5ac7514eb32ce0de791a8abd531dde9d712ddcfa Mon Sep 17 00:00:00 2001 From: firu11 Date: Wed, 22 Jul 2026 13:11:15 +0200 Subject: [PATCH 3/3] refactor ical importer for rrule repetition --- pkg/core/ical.go | 77 +++++++++---------------------------------- pkg/core/ical_test.go | 32 ++++++++++++------ 2 files changed, 37 insertions(+), 72 deletions(-) diff --git a/pkg/core/ical.go b/pkg/core/ical.go index cb2c730..262140a 100644 --- a/pkg/core/ical.go +++ b/pkg/core/ical.go @@ -2,15 +2,18 @@ package core import ( "crypto/sha256" - "errors" "fmt" "io" "time" ics "github.com/arran4/golang-ical" "github.com/google/uuid" + rrule "github.com/teambition/rrule-go" ) +// parseICal parses ical events from r to []Event. +// calendar is copied to each event and used to scope deterministic IDs. +// When stableIDs is false, each event gets a random ID. func parseICal(r io.Reader, calendar string, stableIDs bool) ([]Event, error) { cal, err := ics.ParseCalendar(r) if err != nil { @@ -25,7 +28,7 @@ func parseICal(r io.Reader, calendar string, stableIDs bool) ([]Event, error) { return nil, fmt.Errorf("import event %d: %w", i+1, err) } if stableIDs { - event.Id = icalEventID(calendar, icalText(source, ics.ComponentPropertyUniqueId), i, event) + event.Id = icalEventID(calendar, source.Id(), i, event) } if err := event.Validate(); err != nil { return nil, fmt.Errorf("import event %d: %w", i+1, err) @@ -47,7 +50,7 @@ func parseICalEvent(source *ics.VEvent, calendar string) (Event, error) { return Event{}, fmt.Errorf("read DTEND: %w", err) } - repeat, err := parseICalRepetition(source) + repeat, err := parseICalRRule(source, from) if err != nil { return Event{}, err } @@ -63,63 +66,18 @@ func parseICalEvent(source *ics.VEvent, calendar string) (Event, error) { }, nil } -func parseICalRepetition(event *ics.VEvent) (*Repetition, error) { - if event.HasProperty(ics.ComponentPropertyRdate) || - event.HasProperty(ics.ComponentPropertyExdate) || - event.HasProperty(ics.ComponentPropertyExrule) || - event.HasProperty(ics.ComponentPropertyRecurrenceId) { - return nil, errors.New("recurrence dates and exceptions are not supported") - } - - rules, err := event.GetRRules() - if err != nil { - return nil, err - } - if len(rules) == 0 { +func parseICalRRule(event *ics.VEvent, start time.Time) (*rrule.Set, error) { + value := icalText(event, ics.ComponentPropertyRrule) + if value == "" { return nil, nil } - if len(rules) != 1 { - return nil, errors.New("multiple recurrence rules are not supported") - } - - rule := rules[0] - if hasICalModifiers(rule) { - return nil, errors.New("recurrence modifiers are not supported") - } - frequency, err := parseICalFrequency(rule.Freq) + option, err := rrule.StrToROptionInLocation(value, start.Location()) if err != nil { - return nil, err - } - - return &Repetition{ - Frequency: frequency, - Interval: rule.Interval, - Until: rule.Until, - Count: rule.Count, - }, nil -} - -func parseICalFrequency(frequency ics.Frequency) (Freq, error) { - switch frequency { - case ics.FrequencyDaily: - return Day, nil - case ics.FrequencyWeekly: - return Week, nil - case ics.FrequencyMonthly: - return Month, nil - case ics.FrequencyYearly: - return Year, nil - default: - return Invalid, fmt.Errorf("unsupported recurrence frequency %q", frequency) + return nil, fmt.Errorf("parse RRULE: %w", err) } -} - -func hasICalModifiers(rule *ics.RecurrenceRule) bool { - return len(rule.BySecond)+len(rule.ByMinute)+len(rule.ByHour)+ - len(rule.ByDay)+len(rule.ByMonthDay)+len(rule.ByYearDay)+ - len(rule.ByWeekNo)+len(rule.ByMonth)+len(rule.BySetPos) > 0 || - rule.Wkst != "" + option.Dtstart = start + return newRecurrence(*option, nil) } func icalText(event *ics.VEvent, property ics.ComponentProperty) string { @@ -132,13 +90,8 @@ func icalText(event *ics.VEvent, property ics.ComponentProperty) string { func icalEventID(calendar, uid string, index int, event Event) uuid.UUID { if uid == "" { + // fallback it uid is missing uid = fmt.Sprintf("%d\x00%s\x00%s", index, event.Title, event.From.Format(time.RFC3339Nano)) } - sum := sha256.Sum256([]byte(calendar + "\x00" + uid)) - - var id uuid.UUID - copy(id[:], sum[:16]) - id[6] = id[6]&0x0f | 0x80 - id[8] = id[8]&0x3f | 0x80 - return id + return uuid.NewHash(sha256.New(), uuid.Nil, []byte(calendar+"\x00"+uid), 8) } diff --git a/pkg/core/ical_test.go b/pkg/core/ical_test.go index 5a821ee..b6a5c52 100644 --- a/pkg/core/ical_test.go +++ b/pkg/core/ical_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" "time" + + rrule "github.com/teambition/rrule-go" ) func TestParseICal(t *testing.T) { @@ -58,18 +60,23 @@ END:VCALENDAR` if second.Repeat == nil { t.Fatal("Repeat is nil") } - if second.Repeat.Frequency != Week { - t.Errorf("Frequency = %v, want %v", second.Repeat.Frequency, Week) + rule := second.Repeat.GetRRule() + if rule == nil { + t.Fatal("RRULE is nil") + } + option := rule.OrigOptions + if option.Freq != rrule.WEEKLY { + t.Errorf("Frequency = %v, want %v", option.Freq, rrule.WEEKLY) } - if second.Repeat.Interval != 2 { - t.Errorf("Interval = %d, want 2", second.Repeat.Interval) + if option.Interval != 2 { + t.Errorf("Interval = %d, want 2", option.Interval) } - if second.Repeat.Count != 4 { - t.Errorf("Count = %d, want 4", second.Repeat.Count) + if option.Count != 4 { + t.Errorf("Count = %d, want 4", option.Count) } } -func TestParseICalRejectsUnsupportedRecurrence(t *testing.T) { +func TestParseICalSupportsRecurrenceModifiers(t *testing.T) { input := `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//git-calendar//test//EN @@ -82,9 +89,14 @@ RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE END:VEVENT END:VCALENDAR` - _, err := parseICal(strings.NewReader(input), "Work", false) - if err == nil || !strings.Contains(err.Error(), "recurrence modifiers are not supported") { - t.Fatalf("error = %v, want unsupported recurrence modifier error", err) + events, err := parseICal(strings.NewReader(input), "Work", false) + if err != nil { + t.Fatal(err) + } + + weekdays := events[0].Repeat.GetRRule().OrigOptions.Byweekday + if len(weekdays) != 2 || weekdays[0].String() != "MO" || weekdays[1].String() != "WE" { + t.Fatalf("Byweekday = %v, want [MO WE]", weekdays) } }