From 36a66dfa91fc67c7d2a7cc8b696dbe233d2a0816 Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 14 Jul 2026 21:50:12 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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) } } From cd9be3d1d229add1715c8fe1bd8dfcfc2674393f Mon Sep 17 00:00:00 2001 From: firu11 Date: Sat, 25 Jul 2026 18:18:37 +0200 Subject: [PATCH 04/10] fix ical allday event parsing --- pkg/core/ical.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/pkg/core/ical.go b/pkg/core/ical.go index 262140a..20366b0 100644 --- a/pkg/core/ical.go +++ b/pkg/core/ical.go @@ -45,9 +45,9 @@ func parseICalEvent(source *ics.VEvent, calendar string) (Event, error) { return Event{}, fmt.Errorf("read DTSTART: %w", err) } - to, err := source.GetEndAt() + to, err := icalEventEnd(source, from) if err != nil { - return Event{}, fmt.Errorf("read DTEND: %w", err) + return Event{}, err } repeat, err := parseICalRRule(source, from) @@ -66,6 +66,24 @@ func parseICalEvent(source *ics.VEvent, calendar string) (Event, error) { }, nil } +func icalEventEnd(event *ics.VEvent, start time.Time) (time.Time, error) { + if event.HasProperty(ics.ComponentPropertyDtEnd) { + end, err := event.GetEndAt() + if err != nil { + return time.Time{}, fmt.Errorf("read DTEND: %w", err) + } + return end, nil + } + + startProperty := event.GetProperty(ics.ComponentPropertyDtStart) + if startProperty != nil && startProperty.GetValueType() == ics.ValueDataTypeDate { + // RFC 5545 defines a date-only VEVENT without DTEND as lasting one day. + return start.AddDate(0, 0, 1), nil + } + + return time.Time{}, fmt.Errorf("read DTEND: %w: %s", ics.ErrorPropertyNotFound, ics.ComponentPropertyDtEnd) +} + func parseICalRRule(event *ics.VEvent, start time.Time) (*rrule.Set, error) { value := icalText(event, ics.ComponentPropertyRrule) if value == "" { From 44ff71fe4bd27e30af81681fc89dc5128b27fbad Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 28 Jul 2026 14:51:06 +0200 Subject: [PATCH 05/10] store ical feeds as .url files --- e2e/encryption_test.go | 2 +- e2e/ical_import_test.go | 50 +++++++++++++++++++++++-- pkg/core/calendar.go | 9 +++++ pkg/core/constants.go | 4 ++ pkg/core/core_calendars.go | 75 ++++++++++++++++++++++++++------------ pkg/core/core_ical.go | 30 +++++++++++---- pkg/core/ical_test.go | 27 ++++++++++++++ 7 files changed, 160 insertions(+), 37 deletions(-) diff --git a/e2e/encryption_test.go b/e2e/encryption_test.go index 19aa849..5be2c1c 100644 --- a/e2e/encryption_test.go +++ b/e2e/encryption_test.go @@ -31,7 +31,7 @@ func TestCreateCalendarWithPassword_CreatesKeyFile(t *testing.T) { t.Errorf("failed to get home dir: %v", err) } - b, err := os.ReadFile(filepath.Join(home, filesystem.DirName, fmt.Sprintf("%s.key", TestCalendarName))) + b, err := os.ReadFile(filepath.Join(home, filesystem.DirName, TestCalendarName+core.KeyFileSuffix)) if err != nil { t.Errorf("failed to read key file: %v", err) } diff --git a/e2e/ical_import_test.go b/e2e/ical_import_test.go index 97204aa..f88eeb3 100644 --- a/e2e/ical_import_test.go +++ b/e2e/ical_import_test.go @@ -56,7 +56,10 @@ func TestImportICalFilePersistsEvents(t *testing.T) { } func TestImportICalURLRefetchesOnLoad(t *testing.T) { - const name = "test-ical-url" + const ( + name = "test-ical-url" + renamedName = "test-ical-url-renamed" + ) var feed atomic.Value feed.Store(icalFeed("First title")) @@ -76,7 +79,10 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { if err := c.ImportICalURL(name, sourceURL); err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = c.RemoveCalendar(name) }) + t.Cleanup(func() { + _ = c.RemoveCalendar(name) + _ = c.RemoveCalendar(renamedName) + }) events := importedEvents(c, name) if len(events) != 1 || events[0].Title != "First title" { @@ -91,17 +97,30 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { if err != nil { t.Fatal(err) } + found := false for _, calendar := range calendars { - if calendar.Name == name && !calendar.Readonly { + if calendar.Name != name { + continue + } + found = true + if !calendar.Readonly { t.Fatal("URL calendar is not read-only") } } + if !found { + t.Fatal("URL calendar was not registered") + } home, err := os.UserHomeDir() if err != nil { t.Fatal(err) } - data, err := os.ReadFile(filepath.Join(home, filesystem.DirName, name)) + calendarRoot := filepath.Join(home, filesystem.DirName) + if _, err := os.Stat(filepath.Join(calendarRoot, name)); !os.IsNotExist(err) { + t.Fatalf("unsuffixed URL file exists or could not be checked: %v", err) + } + urlFilePath := filepath.Join(calendarRoot, name+".url") + data, err := os.ReadFile(urlFilePath) if err != nil { t.Fatal(err) } @@ -124,6 +143,29 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { if requests.Load() != 2 { t.Errorf("URL was fetched %d times, want 2", requests.Load()) } + + if err := c.RenameCalendar(name, renamedName); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(urlFilePath); !os.IsNotExist(err) { + t.Fatalf("old URL file still exists or could not be checked after rename: %v", err) + } + renamedURLFilePath := filepath.Join(calendarRoot, renamedName+".url") + if data, err := os.ReadFile(renamedURLFilePath); err != nil { + t.Fatal(err) + } else if string(data) != server.URL { + t.Errorf("renamed URL file = %q, want %q", data, server.URL) + } + if requests.Load() != 3 { + t.Errorf("URL was fetched %d times after rename, want 3", requests.Load()) + } + + if err := c.RemoveCalendar(renamedName); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(renamedURLFilePath); !os.IsNotExist(err) { + t.Fatalf("URL file still exists or could not be checked after removal: %v", err) + } } func importedEvents(c *core.Core, calendar string) []core.Event { diff --git a/pkg/core/calendar.go b/pkg/core/calendar.go index 04beedb..2b522e3 100644 --- a/pkg/core/calendar.go +++ b/pkg/core/calendar.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "path" "strings" @@ -16,6 +17,7 @@ type Calendar struct { Tags []Tag EncryptionKey []byte Readonly bool + ICalURL *url.URL repository *gogit.Repository } @@ -66,6 +68,7 @@ func (cal *Calendar) MarshalJSON() ([]byte, error) { Name string `json:"name"` Tags []Tag `json:"tags"` RemoteURL string `json:"remote_url"` + ICalUrl string `json:"ical_url"` Encrypted bool `json:"encrypted"` Readonly bool `json:"readonly"` } @@ -75,10 +78,16 @@ func (cal *Calendar) MarshalJSON() ([]byte, error) { return nil, err } + var icalURL string + if cal.ICalURL != nil { + icalURL = cal.ICalURL.String() + } + return json.Marshal(calendarJSON{ Name: cal.Name, Tags: cal.Tags, RemoteURL: remoteURL, + ICalUrl: icalURL, Encrypted: cal.IsEncrypted(), Readonly: cal.Readonly, }) diff --git a/pkg/core/constants.go b/pkg/core/constants.go index 063d00b..7edc8d9 100644 --- a/pkg/core/constants.go +++ b/pkg/core/constants.go @@ -8,6 +8,10 @@ const ( GitRemoteName string = "origin" GitBranchName string = "main" + KeyFileSuffix = ".key" + ReadonlyFileSuffix = ".readonly" + ICalURLFileSuffix = ".url" + // IndexFileName string = "index.json" // RichIndexFileName string = "index-rich.json" ) diff --git a/pkg/core/core_calendars.go b/pkg/core/core_calendars.go index 1b40039..9c28552 100644 --- a/pkg/core/core_calendars.go +++ b/pkg/core/core_calendars.go @@ -74,6 +74,7 @@ func (c *Core) ListCalendars() ([]Calendar, error) { EncryptionKey: slices.Clone(cal.EncryptionKey), repository: cal.repository, Readonly: cal.Readonly, + ICalURL: cal.ICalURL, }) } @@ -94,12 +95,19 @@ func (c *Core) LoadCalendars() error { for _, entry := range entries { name := entry.Name() if !entry.IsDir() { + if !strings.HasSuffix(name, ICalURLFileSuffix) { + continue + } + + calendarName := strings.TrimSuffix(name, ICalURLFileSuffix) + if err := validateICalName(calendarName); err != nil { + continue + } sourceURL, err := c.readICalURL(name) if err != nil { continue } - c.calendars[name] = &Calendar{Name: name, Readonly: true} - icalURLs[name] = sourceURL + icalURLs[calendarName] = sourceURL continue } @@ -111,7 +119,7 @@ func (c *Core) LoadCalendars() error { // load key file var key []byte = nil - keyFile, err := c.fs.Open(fmt.Sprintf("%s.key", name)) + keyFile, err := c.fs.Open(name + KeyFileSuffix) if err == nil { if key, err = io.ReadAll(keyFile); err != nil { fmt.Printf("failed to read encryption key for %q calendar: %v\n", name, err) @@ -134,6 +142,10 @@ func (c *Core) LoadCalendars() error { c.calendars[name] = cal } + for name, icalURL := range icalURLs { + c.calendars[name] = &Calendar{Name: name, Readonly: true, ICalURL: icalURL} + } + // load tree + events // TODO do not load files, but build tree from index.json for _, cal := range c.calendars { @@ -187,6 +199,9 @@ func (c *Core) LoadCalendars() error { // Clones a repository/calendar from url, using CORS proxy, if specified. func (c *Core) CloneCalendar(repoUrl *url.URL, password string, readonly bool) error { + if !strings.HasSuffix(repoUrl.Path, ".git") { + return errors.New(`remote URL must end with ".git"`) + } calendarName := calendarNameFromUrl(repoUrl) if cal, ok := c.calendars[calendarName]; ok || cal != nil { return errors.New("calendar with this name already exists") @@ -263,13 +278,20 @@ func (c *Core) CloneCalendar(repoUrl *url.URL, password string, readonly bool) e // Removes and deletes the whole calendar. func (c *Core) RemoveCalendar(name string) error { - // remove dir from filesystem - if err := gogitutil.RemoveAll(c.fs, name); err != nil { - return fmt.Errorf("failed to remove repo directory: %w", err) - } + calendar := c.calendars[name] + if calendar != nil && calendar.repository == nil { + if err := c.fs.Remove(name + ICalURLFileSuffix); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to remove iCalendar URL file: %w", err) + } + } else { + // remove dir from filesystem + if err := gogitutil.RemoveAll(c.fs, name); err != nil { + return fmt.Errorf("failed to remove repo directory: %w", err) + } - // try to remove encryption key - _ = c.fs.Remove(fmt.Sprintf("%s.key", name)) + // try to remove encryption key + _ = c.fs.Remove(name + KeyFileSuffix) + } // remove from map delete(c.calendars, name) @@ -293,31 +315,37 @@ 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 calendar: %w", err) - } if calendar.repository == nil { + if err := validateICalName(newName); err != nil { + return err + } + if err := c.fs.Rename(oldName+ICalURLFileSuffix, newName+ICalURLFileSuffix); err != nil { + return fmt.Errorf("failed to rename iCalendar URL file: %w", err) + } return c.LoadCalendars() } + if err := c.fs.Rename(oldName, newName); err != nil { + return fmt.Errorf("failed to rename calendar: %w", err) + } 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 { + if err := c.fs.Rename(oldName+KeyFileSuffix, newName+KeyFileSuffix); err != nil { _ = c.fs.Rename(newName, oldName) // try to rename repo back return fmt.Errorf("failed to rename the encryption key file: %w", err) } } if c.isCalendarReadonly(oldName) { - if err := c.fs.Rename(fmt.Sprintf("%s.readonly", oldName), fmt.Sprintf("%s.readonly", newName)); err != nil { - _ = c.fs.Rename(newName, oldName) // try to rename repo back - _ = c.fs.Rename(fmt.Sprintf("%s.key", newName), fmt.Sprintf("%s.key", oldName)) // try to rename key back (maybe it didn't exist in the first place, mehh) + if err := c.fs.Rename(oldName+ReadonlyFileSuffix, newName+ReadonlyFileSuffix); err != nil { + _ = c.fs.Rename(newName, oldName) // try to rename repo back + _ = c.fs.Rename(newName+KeyFileSuffix, oldName+KeyFileSuffix) // try to rename key back (maybe it didn't exist in the first place, mehh) return fmt.Errorf("failed to rename the encryption key file: %w", err) } } newRepo, err := c.initCalendarRepo(newName) if err != nil { - _ = c.fs.Rename(newName, oldName) // try to rename repo back - _ = c.fs.Rename(fmt.Sprintf("%s.key", newName), fmt.Sprintf("%s.key", oldName)) // try to rename key back (maybe it didn't exist in the first place, mehh) - _ = c.fs.Rename(fmt.Sprintf("%s.readonly", newName), fmt.Sprintf("%s.readonly", oldName)) // try to rename readonly sign back (maybe it didn't exist in the first place, mehh) + _ = c.fs.Rename(newName, oldName) // try to rename repo back + _ = c.fs.Rename(newName+KeyFileSuffix, oldName+KeyFileSuffix) // try to rename key back (maybe it didn't exist in the first place, mehh) + _ = c.fs.Rename(newName+ReadonlyFileSuffix, oldName+ReadonlyFileSuffix) // try to rename readonly sign back (maybe it didn't exist in the first place, mehh) return fmt.Errorf("failed to load new repo dir: %w", err) } calendar.Name = newName @@ -350,7 +378,7 @@ func (c *Core) UpdateRemote(calendar string, remoteURL *url.URL, readonly bool) } if !strings.HasSuffix(remoteURL.Path, ".git") { - return errors.New(`remote URL has to end with ".git"`) + return errors.New(`remote URL must end with ".git"`) } cfg, _ := cal.repository.Config() @@ -374,7 +402,7 @@ func (c *Core) UpdateRemote(calendar string, remoteURL *url.URL, readonly bool) func (c *Core) createKeyFile(calendarName, password string) ([]byte, error) { key := encryption.DeriveKey(password, []byte(calendarName)) - keyFile, err := c.fs.Create(fmt.Sprintf("%s.key", calendarName)) + keyFile, err := c.fs.Create(calendarName + KeyFileSuffix) if err != nil { return nil, fmt.Errorf("failed to create key file: %w", err) } @@ -388,7 +416,7 @@ func (c *Core) createKeyFile(calendarName, password string) ([]byte, error) { } func (c *Core) updateReadonlyFile(calendarName string, readonly bool) error { - path := fmt.Sprintf("%s.readonly", calendarName) + path := calendarName + ReadonlyFileSuffix if readonly { file, err := c.fs.Create(path) @@ -411,7 +439,6 @@ func (c *Core) updateReadonlyFile(calendarName string, readonly bool) error { } func (c *Core) isCalendarReadonly(calendarName string) bool { - path := fmt.Sprintf("%s.readonly", calendarName) - _, err := c.fs.Stat(path) + _, err := c.fs.Stat(calendarName + ReadonlyFileSuffix) return err == nil } diff --git a/pkg/core/core_ical.go b/pkg/core/core_ical.go index 7ff1d45..88309a5 100644 --- a/pkg/core/core_ical.go +++ b/pkg/core/core_ical.go @@ -55,21 +55,32 @@ func (c *Core) ImportICalURL(name string, sourceURL *url.URL) error { return err } - file, err := c.fs.Create(name) + fileName := name + ICalURLFileSuffix + if _, err := c.fs.Stat(fileName); err == nil { + return fmt.Errorf("file named %s already exists", fileName) + } else if !os.IsNotExist(err) { + return err + } + + file, err := c.fs.Create(fileName) 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) + _ = c.fs.Remove(fileName) return fmt.Errorf("write iCalendar URL file: %w", err) } if err := file.Close(); err != nil { - _ = c.fs.Remove(name) + _ = c.fs.Remove(fileName) return fmt.Errorf("close iCalendar URL file: %w", err) } - return c.LoadCalendars() + c.calendars[name] = &Calendar{Name: name, Readonly: true, ICalURL: sourceURL} + if err := c.loadICalURL(name, sourceURL); err != nil { + fmt.Printf("WARN: failed to load iCalendar %q: %v\n", name, err) + } + return nil } func (c *Core) readICalURL(name string) (*url.URL, error) { @@ -103,6 +114,8 @@ func (c *Core) loadICalURL(name string, sourceURL *url.URL) error { return errors.New("invalid proxied iCalendar URL") } + fmt.Println("fetching ical", name) + client := http.Client{Timeout: 30 * time.Second} response, err := client.Get(requestURL.String()) if err != nil { @@ -124,7 +137,8 @@ func (c *Core) loadICalURL(name string, sourceURL *url.URL) error { _, alreadyLoaded := c.events[event.Id] _, duplicate := seen[event.Id] if alreadyLoaded || duplicate { - return fmt.Errorf("duplicate imported event ID %q", event.Id) + fmt.Printf("WARN: duplicate imported event ID %q\n", event.Id) + continue } seen[event.Id] = struct{}{} } @@ -145,6 +159,9 @@ func validateICalURL(sourceURL *url.URL) error { (sourceURL.Scheme != "http" && sourceURL.Scheme != "https") { return errors.New("iCalendar URL must be an absolute HTTP or HTTPS URL") } + if !strings.HasSuffix(sourceURL.Path, ".ics") { + return errors.New(`iCalendar URL must end with ".ics"`) + } return nil } @@ -152,8 +169,5 @@ 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/core/ical_test.go b/pkg/core/ical_test.go index b6a5c52..e215d69 100644 --- a/pkg/core/ical_test.go +++ b/pkg/core/ical_test.go @@ -76,6 +76,33 @@ END:VCALENDAR` } } +func TestParseICalDefaultsAllDayEventWithoutEndToOneDay(t *testing.T) { + input := `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:icalendar-ruby +BEGIN:VEVENT +UID:holiday@example.com +DTSTART;VALUE=DATE:20240101 +SUMMARY:Nový rok +RRULE:FREQ=YEARLY;COUNT=6 +END:VEVENT +END:VCALENDAR` + + events, err := parseICal(strings.NewReader(input), "Holidays", false) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + + assertICalTime(t, events[0].From, time.Date(2024, 1, 1, 0, 0, 0, 0, time.Local)) + assertICalTime(t, events[0].To, time.Date(2024, 1, 2, 0, 0, 0, 0, time.Local)) + if events[0].Repeat == nil { + t.Fatal("Repeat is nil") + } +} + func TestParseICalSupportsRecurrenceModifiers(t *testing.T) { input := `BEGIN:VCALENDAR VERSION:2.0 From 089494c1be4b9b20ef6f8c934c37d18ca7728a5f Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 28 Jul 2026 20:27:10 +0200 Subject: [PATCH 06/10] fix microslop dates --- cmd/wasm/main.go | 1 + e2e/ical_import_test.go | 10 +++---- go.mod | 1 + go.sum | 2 ++ pkg/core/ical.go | 14 +++++++++ pkg/core/ical_test.go | 63 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 5 deletions(-) diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index dd0d95d..9995fcb 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -4,6 +4,7 @@ package main import ( "syscall/js" + _ "time/tzdata" // microslop dates need this "github.com/git-calendar/core/pkg/api" ) diff --git a/e2e/ical_import_test.go b/e2e/ical_import_test.go index f88eeb3..9c1d741 100644 --- a/e2e/ical_import_test.go +++ b/e2e/ical_import_test.go @@ -70,7 +70,7 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { })) defer server.Close() - sourceURL, err := url.Parse(server.URL) + sourceURL, err := url.Parse(server.URL + "/calendar.ics") if err != nil { t.Fatal(err) } @@ -124,8 +124,8 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { if err != nil { t.Fatal(err) } - if string(data) != server.URL { - t.Errorf("URL file = %q, want %q", data, server.URL) + if string(data) != sourceURL.String() { + t.Errorf("URL file = %q, want %q", data, sourceURL) } feed.Store(icalFeed("Second title")) @@ -153,8 +153,8 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { renamedURLFilePath := filepath.Join(calendarRoot, renamedName+".url") if data, err := os.ReadFile(renamedURLFilePath); err != nil { t.Fatal(err) - } else if string(data) != server.URL { - t.Errorf("renamed URL file = %q, want %q", data, server.URL) + } else if string(data) != sourceURL.String() { + t.Errorf("renamed URL file = %q, want %q", data, sourceURL) } if requests.Load() != 3 { t.Errorf("URL was fetched %d times after rename, want 3", requests.Load()) diff --git a/go.mod b/go.mod index 2cbe9cb..5786259 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/jedisct1/go-aes-siv v1.0.0 github.com/rdleal/intervalst v1.5.0 github.com/teambition/rrule-go v1.8.2 + github.com/thommeo/winianatz v0.0.2 golang.org/x/crypto v0.53.0 ) diff --git a/go.sum b/go.sum index 8bfb036..609acf4 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu 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/thommeo/winianatz v0.0.2 h1:x9VywTyCQL8+aN1FK46fE9+8QGsR2RKyUwilLMwFuxA= +github.com/thommeo/winianatz v0.0.2/go.mod h1:fzwROCdZKIS6n/rXKgDwpSTNCXOT7iJXwClrC9I4/sw= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= diff --git a/pkg/core/ical.go b/pkg/core/ical.go index 20366b0..95bedc7 100644 --- a/pkg/core/ical.go +++ b/pkg/core/ical.go @@ -9,6 +9,7 @@ import ( ics "github.com/arran4/golang-ical" "github.com/google/uuid" rrule "github.com/teambition/rrule-go" + "github.com/thommeo/winianatz" ) // parseICal parses ical events from r to []Event. @@ -23,6 +24,7 @@ func parseICal(r io.Reader, calendar string, stableIDs bool) ([]Event, error) { sourceEvents := cal.Events() events := make([]Event, 0, len(sourceEvents)) for i, source := range sourceEvents { + normalizeTimeZones(source) event, err := parseICalEvent(source, calendar) if err != nil { return nil, fmt.Errorf("import event %d: %w", i+1, err) @@ -106,6 +108,18 @@ func icalText(event *ics.VEvent, property ics.ComponentProperty) string { return value.Value } +func normalizeTimeZones(event *ics.VEvent) { + for i := range event.Properties { + tzid := event.Properties[i].ICalParameters[string(ics.ParameterTzid)] + if len(tzid) != 1 { + continue + } + if zone, err := winianatz.FromMicrosoftAlias(tzid[0]); err == nil { + tzid[0] = zone.IANA + } + } +} + func icalEventID(calendar, uid string, index int, event Event) uuid.UUID { if uid == "" { // fallback it uid is missing diff --git a/pkg/core/ical_test.go b/pkg/core/ical_test.go index e215d69..16df85f 100644 --- a/pkg/core/ical_test.go +++ b/pkg/core/ical_test.go @@ -1,6 +1,7 @@ package core import ( + "fmt" "strings" "testing" "time" @@ -127,6 +128,68 @@ END:VCALENDAR` } } +// fuck you microslop +func TestParseICalSupportsWindowsTimeZones(t *testing.T) { + tests := []struct { + name string + tzid string + start string + end string + wantStart time.Time + wantEnd time.Time + }{ + { + name: "Central Europe daylight time", + tzid: "Central Europe Standard Time", + start: "20260729T160000", + end: "20260729T183000", + wantStart: time.Date(2026, 7, 29, 14, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 7, 29, 16, 30, 0, 0, time.UTC), + }, + { + name: "Pacific standard time", + tzid: "Pacific Standard Time", + start: "20260115T100000", + end: "20260115T110000", + wantStart: time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 1, 15, 19, 0, 0, 0, time.UTC), + }, + { + name: "India half-hour offset", + tzid: "India Standard Time", + start: "20260115T100000", + end: "20260115T110000", + wantStart: time.Date(2026, 1, 15, 4, 30, 0, 0, time.UTC), + wantEnd: time.Date(2026, 1, 15, 5, 30, 0, 0, time.UTC), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := fmt.Sprintf(`BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:microsoft@example.com +SUMMARY:MS event +DTSTART;TZID=%s:%s +DTEND;TZID=%s:%s +END:VEVENT +END:VCALENDAR`, test.tzid, test.start, test.tzid, test.end) + + events, err := parseICal(strings.NewReader(input), "Microsoft", true) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + + assertICalTime(t, events[0].From, test.wantStart) + assertICalTime(t, events[0].To, test.wantEnd) + }) + } +} + func assertICalTime(t *testing.T, got, want time.Time) { t.Helper() if !got.Equal(want) { From 28f7ffa5fc1678cd29bcfc307041bb232a8ace00 Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 28 Jul 2026 20:27:15 +0200 Subject: [PATCH 07/10] Update main.go --- cmd/wasm/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 9995fcb..5e521b5 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -19,7 +19,8 @@ func main() { } func RegisterCallbacks(api *api.Api) { - js.Global().Set("CalendarCore", + js.Global().Set( + "CalendarCore", js.ValueOf(map[string]any{ // we wrap each method "createCalendar": js.FuncOf(func(this js.Value, args []js.Value) any { return wrapPromise(func() (any, error) { From 4d21d647b3a07685ae74366eaa739758f5a16044 Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 28 Jul 2026 21:05:21 +0200 Subject: [PATCH 08/10] reverse filters --- e2e/events_basic_test.go | 3 ++- e2e/ical_import_test.go | 2 +- pkg/core/utils.go | 21 +++++++++++++-------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/e2e/events_basic_test.go b/e2e/events_basic_test.go index 944ea7f..2f6a69d 100644 --- a/e2e/events_basic_test.go +++ b/e2e/events_basic_test.go @@ -396,7 +396,8 @@ func TestGetEvents_FilterByCalendarAndTag(t *testing.T) { } got := c.GetEvents(from.Add(-time.Hour), to.Add(time.Hour), core.GetEventsFilter{ - calendarA: {tagA}, + calendarA: {HiddenTagIds: []uuid.UUID{tagB}}, + calendarB: {HiddenTagIds: []uuid.UUID{tagA}}, }) if len(got) != 1 { diff --git a/e2e/ical_import_test.go b/e2e/ical_import_test.go index 9c1d741..743f988 100644 --- a/e2e/ical_import_test.go +++ b/e2e/ical_import_test.go @@ -171,7 +171,7 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { 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}) + return c.GetEvents(from, to, core.GetEventsFilter{calendar: {}}) } func icalFeed(title string) string { diff --git a/pkg/core/utils.go b/pkg/core/utils.go index 59bb129..030c42e 100644 --- a/pkg/core/utils.go +++ b/pkg/core/utils.go @@ -142,17 +142,22 @@ func getTimeFromUUID(id uuid.UUID) time.Time { return time.Unix(int64(unix32), 0) } -type GetEventsFilter map[string][]uuid.UUID // map[Calendar.Name]Tag.Id +type CalendarEventsFilter struct { + HiddenTagIds []uuid.UUID `json:"hidden_tag_ids"` + HideUntagged bool `json:"hide_untagged"` +} + +type GetEventsFilter map[string]CalendarEventsFilter -func checkFilter(e *Event, f GetEventsFilter) bool { - tags, ok := f[e.Calendar] +func checkFilter(e *Event, filters GetEventsFilter) bool { + filter, ok := filters[e.Calendar] if !ok { - return false // doesn't satisfy filtered calendars + return true } + if e.TagId == nil { - return true + return !filter.HideUntagged } - return slices.ContainsFunc(tags, func(u uuid.UUID) bool { - return u == *e.TagId - }) + + return !slices.Contains(filter.HiddenTagIds, *e.TagId) } From 6cd387b9740d896487587d775a61f42b51940d20 Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 28 Jul 2026 21:05:30 +0200 Subject: [PATCH 09/10] cleanup --- e2e/events_basic_test.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/e2e/events_basic_test.go b/e2e/events_basic_test.go index 2f6a69d..9a38190 100644 --- a/e2e/events_basic_test.go +++ b/e2e/events_basic_test.go @@ -369,7 +369,7 @@ func TestGetEvents_FilterByCalendarAndTag(t *testing.T) { Title: "matching event", From: from, To: to, - TagId: uuidPtr(tagA), + TagId: new(tagA), }, { Id: wrongTagID, @@ -377,7 +377,7 @@ func TestGetEvents_FilterByCalendarAndTag(t *testing.T) { Title: "wrong tag", From: from, To: to, - TagId: uuidPtr(tagB), + TagId: new(tagB), }, { Id: wrongCalendarID, @@ -385,7 +385,7 @@ func TestGetEvents_FilterByCalendarAndTag(t *testing.T) { Title: "wrong calendar", From: from, To: to, - TagId: uuidPtr(tagA), + TagId: new(tagA), }, } @@ -433,7 +433,7 @@ func TestGetEvents_NilFilterReturnsAllEvents(t *testing.T) { Title: "event A", From: from, To: to, - TagId: uuidPtr(tagA), + TagId: new(tagA), } eventB := core.Event{ Id: uuid.New(), @@ -441,7 +441,7 @@ func TestGetEvents_NilFilterReturnsAllEvents(t *testing.T) { Title: "event B", From: from.Add(30 * time.Minute), To: to.Add(30 * time.Minute), - TagId: uuidPtr(tagB), + TagId: new(tagB), } if _, err := c.CreateEvent(eventA); err != nil { @@ -465,10 +465,6 @@ func TestGetEvents_NilFilterReturnsAllEvents(t *testing.T) { } } -func uuidPtr(id uuid.UUID) *uuid.UUID { - return &id -} - func hasEvent(events []core.Event, id uuid.UUID) bool { for _, e := range events { if e.Id == id { From d71a1568ca5d4466b0c9e3aa13017bc5d123f2f8 Mon Sep 17 00:00:00 2001 From: firu11 Date: Tue, 28 Jul 2026 21:05:49 +0200 Subject: [PATCH 10/10] cache ical feed --- e2e/ical_import_test.go | 50 ++++++++++++++++++++++++++++++++++---- pkg/core/constants.go | 1 + pkg/core/core.go | 11 +++++++-- pkg/core/core_calendars.go | 13 +++++++--- pkg/core/core_ical.go | 42 ++++++++++++++++++++++++++++---- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/e2e/ical_import_test.go b/e2e/ical_import_test.go index 743f988..8bb0313 100644 --- a/e2e/ical_import_test.go +++ b/e2e/ical_import_test.go @@ -17,6 +17,8 @@ import ( ) func TestImportICalFilePersistsEvents(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const calendar = "test-ical-file" c := core.NewCore() if err := c.CreateCalendar(calendar, ""); err != nil { @@ -55,7 +57,9 @@ func TestImportICalFilePersistsEvents(t *testing.T) { } } -func TestImportICalURLRefetchesOnLoad(t *testing.T) { +func TestImportICalURLCachesUntilSync(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const ( name = "test-ical-url" renamedName = "test-ical-url-renamed" @@ -64,8 +68,13 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { var feed atomic.Value feed.Store(icalFeed("First title")) var requests atomic.Int32 + var offline atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1) + if offline.Load() { + http.Error(w, "offline", http.StatusServiceUnavailable) + return + } _, _ = w.Write([]byte(feed.Load().(string))) })) defer server.Close() @@ -127,18 +136,37 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { if string(data) != sourceURL.String() { t.Errorf("URL file = %q, want %q", data, sourceURL) } + icalFilePath := filepath.Join(calendarRoot, name+core.ICalFileSuffix) + if data, err := os.ReadFile(icalFilePath); err != nil { + t.Fatal(err) + } else if string(data) != icalFeed("First title") { + t.Errorf("cached iCalendar = %q", data) + } feed.Store(icalFeed("Second title")) + offline.Store(true) if err := c.LoadCalendars(); err != nil { t.Fatal(err) } + events = importedEvents(c, name) + if len(events) != 1 || events[0].Title != "First title" { + t.Fatalf("cached URL import = %+v", events) + } + if requests.Load() != 1 { + t.Errorf("URL was fetched %d times during load, want 1", requests.Load()) + } + + offline.Store(false) + if err := c.SyncAll(); 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) + t.Fatalf("synced URL import = %+v", events) } if events[0].Id != id { - t.Errorf("event ID changed after refetch: got %s, want %s", events[0].Id, id) + t.Errorf("event ID changed after sync: got %s, want %s", events[0].Id, id) } if requests.Load() != 2 { t.Errorf("URL was fetched %d times, want 2", requests.Load()) @@ -150,14 +178,23 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { if _, err := os.Stat(urlFilePath); !os.IsNotExist(err) { t.Fatalf("old URL file still exists or could not be checked after rename: %v", err) } + if _, err := os.Stat(icalFilePath); !os.IsNotExist(err) { + t.Fatalf("old cached iCalendar still exists or could not be checked after rename: %v", err) + } renamedURLFilePath := filepath.Join(calendarRoot, renamedName+".url") if data, err := os.ReadFile(renamedURLFilePath); err != nil { t.Fatal(err) } else if string(data) != sourceURL.String() { t.Errorf("renamed URL file = %q, want %q", data, sourceURL) } - if requests.Load() != 3 { - t.Errorf("URL was fetched %d times after rename, want 3", requests.Load()) + renamedICalFilePath := filepath.Join(calendarRoot, renamedName+core.ICalFileSuffix) + if data, err := os.ReadFile(renamedICalFilePath); err != nil { + t.Fatal(err) + } else if string(data) != icalFeed("Second title") { + t.Errorf("renamed cached iCalendar = %q", data) + } + if requests.Load() != 2 { + t.Errorf("URL was fetched %d times after rename, want 2", requests.Load()) } if err := c.RemoveCalendar(renamedName); err != nil { @@ -166,6 +203,9 @@ func TestImportICalURLRefetchesOnLoad(t *testing.T) { if _, err := os.Stat(renamedURLFilePath); !os.IsNotExist(err) { t.Fatalf("URL file still exists or could not be checked after removal: %v", err) } + if _, err := os.Stat(renamedICalFilePath); !os.IsNotExist(err) { + t.Fatalf("cached iCalendar still exists or could not be checked after removal: %v", err) + } } func importedEvents(c *core.Core, calendar string) []core.Event { diff --git a/pkg/core/constants.go b/pkg/core/constants.go index 7edc8d9..a7f5515 100644 --- a/pkg/core/constants.go +++ b/pkg/core/constants.go @@ -11,6 +11,7 @@ const ( KeyFileSuffix = ".key" ReadonlyFileSuffix = ".readonly" ICalURLFileSuffix = ".url" + ICalFileSuffix = ".ics" // IndexFileName string = "index.json" // RichIndexFileName string = "index-rich.json" diff --git a/pkg/core/core.go b/pkg/core/core.go index ad461a8..4fd693e 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -67,14 +67,21 @@ func (c *Core) SyncAll() error { errs := make(chan error, len(c.calendars)) for _, cal := range c.calendars { - if cal == nil || cal.repository == nil { + if cal == nil { continue // important to check here; syncCalendar and other do assume this } wg.Add(1) go func(cal *Calendar) { defer wg.Done() - if err := c.syncCalendar(cal); err != nil { + + var err error + if cal.ICalURL != nil { + err = c.fetchICalURL(cal.Name, cal.ICalURL) + } else if cal.repository != nil { + err = c.syncCalendar(cal) + } + if err != nil { errs <- fmt.Errorf("%q: sync failed: %w", cal.Name, err) } }(cal) diff --git a/pkg/core/core_calendars.go b/pkg/core/core_calendars.go index 9c28552..af3f38c 100644 --- a/pkg/core/core_calendars.go +++ b/pkg/core/core_calendars.go @@ -149,9 +149,9 @@ 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) + if _, ok := icalURLs[cal.Name]; ok { + if err := c.loadICalFile(cal.Name); err != nil { + fmt.Printf("WARN: failed to load cached iCalendar %q: %v\n", cal.Name, err) } continue } @@ -283,6 +283,9 @@ func (c *Core) RemoveCalendar(name string) error { if err := c.fs.Remove(name + ICalURLFileSuffix); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to remove iCalendar URL file: %w", err) } + if err := c.fs.Remove(name + ICalFileSuffix); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to remove cached iCalendar file: %w", err) + } } else { // remove dir from filesystem if err := gogitutil.RemoveAll(c.fs, name); err != nil { @@ -322,6 +325,10 @@ func (c *Core) RenameCalendar(oldName, newName string) error { if err := c.fs.Rename(oldName+ICalURLFileSuffix, newName+ICalURLFileSuffix); err != nil { return fmt.Errorf("failed to rename iCalendar URL file: %w", err) } + if err := c.fs.Rename(oldName+ICalFileSuffix, newName+ICalFileSuffix); err != nil && !errors.Is(err, os.ErrNotExist) { + _ = c.fs.Rename(newName+ICalURLFileSuffix, oldName+ICalURLFileSuffix) + return fmt.Errorf("failed to rename cached iCalendar file: %w", err) + } return c.LoadCalendars() } if err := c.fs.Rename(oldName, newName); err != nil { diff --git a/pkg/core/core_ical.go b/pkg/core/core_ical.go index 88309a5..a53658e 100644 --- a/pkg/core/core_ical.go +++ b/pkg/core/core_ical.go @@ -1,6 +1,7 @@ package core import ( + "bytes" "errors" "fmt" "io" @@ -11,6 +12,7 @@ import ( "strings" "time" + "github.com/go-git/go-billy/v5/util" "github.com/google/uuid" ) @@ -38,7 +40,7 @@ func (c *Core) ImportICalFile(calendar string, r io.Reader) error { return nil } -// ImportICalURL creates a read-only calendar that is fetched on every load. +// ImportICalURL creates a read-only calendar and caches its feed. func (c *Core) ImportICalURL(name string, sourceURL *url.URL) error { if err := validateICalURL(sourceURL); err != nil { return err @@ -61,6 +63,12 @@ func (c *Core) ImportICalURL(name string, sourceURL *url.URL) error { } else if !os.IsNotExist(err) { return err } + cacheName := name + ICalFileSuffix + if _, err := c.fs.Stat(cacheName); err == nil { + return fmt.Errorf("file named %s already exists", cacheName) + } else if !os.IsNotExist(err) { + return err + } file, err := c.fs.Create(fileName) if err != nil { @@ -77,8 +85,12 @@ func (c *Core) ImportICalURL(name string, sourceURL *url.URL) error { } c.calendars[name] = &Calendar{Name: name, Readonly: true, ICalURL: sourceURL} - if err := c.loadICalURL(name, sourceURL); err != nil { - fmt.Printf("WARN: failed to load iCalendar %q: %v\n", name, err) + if err := c.fetchICalURL(name, sourceURL); err != nil { + fmt.Printf("WARN: failed to fetch iCalendar %q: %v\n", name, err) + return nil + } + if err := c.loadICalFile(name); err != nil { + fmt.Printf("WARN: failed to load cached iCalendar %q: %v\n", name, err) } return nil } @@ -105,7 +117,7 @@ func (c *Core) readICalURL(name string) (*url.URL, error) { return sourceURL, nil } -func (c *Core) loadICalURL(name string, sourceURL *url.URL) error { +func (c *Core) fetchICalURL(name string, sourceURL *url.URL) error { requestURL := sourceURL if c.proxyUrl != nil { requestURL = useCorsProxy(sourceURL, c.proxyUrl) @@ -127,7 +139,27 @@ func (c *Core) loadICalURL(name string, sourceURL *url.URL) error { return fmt.Errorf("fetch iCalendar: %s", response.Status) } - events, err := parseICal(response.Body, name, true) + data, err := io.ReadAll(response.Body) + if err != nil { + return err + } + if _, err := parseICal(bytes.NewReader(data), name, true); err != nil { + return err + } + if err := util.WriteFile(c.fs, name+ICalFileSuffix, data, 0o644); err != nil { + return fmt.Errorf("cache iCalendar: %w", err) + } + return nil +} + +func (c *Core) loadICalFile(name string) error { + file, err := c.fs.Open(name + ICalFileSuffix) + if err != nil { + return err + } + defer file.Close() + + events, err := parseICal(file, name, true) if err != nil { return err }