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/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/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/core/ical.go b/pkg/core/ical.go new file mode 100644 index 0000000..262140a --- /dev/null +++ b/pkg/core/ical.go @@ -0,0 +1,97 @@ +package core + +import ( + "crypto/sha256" + "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 { + return nil, fmt.Errorf("parse iCalendar: %w", err) + } + + sourceEvents := cal.Events() + events := make([]Event, 0, len(sourceEvents)) + for i, source := range sourceEvents { + 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, source.Id(), 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 parseICalEvent(source *ics.VEvent, calendar string) (Event, error) { + from, err := source.GetStartAt() + if err != nil { + return Event{}, fmt.Errorf("read DTSTART: %w", err) + } + + to, err := source.GetEndAt() + if err != nil { + return Event{}, fmt.Errorf("read DTEND: %w", err) + } + + repeat, err := parseICalRRule(source, from) + if err != nil { + return Event{}, err + } + + 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, + }, nil +} + +func parseICalRRule(event *ics.VEvent, start time.Time) (*rrule.Set, error) { + value := icalText(event, ics.ComponentPropertyRrule) + if value == "" { + return nil, nil + } + + option, err := rrule.StrToROptionInLocation(value, start.Location()) + if err != nil { + return nil, fmt.Errorf("parse RRULE: %w", err) + } + option.Dtstart = start + return newRecurrence(*option, nil) +} + +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 == "" { + // fallback it uid is missing + uid = fmt.Sprintf("%d\x00%s\x00%s", index, event.Title, event.From.Format(time.RFC3339Nano)) + } + 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 new file mode 100644 index 0000000..b6a5c52 --- /dev/null +++ b/pkg/core/ical_test.go @@ -0,0 +1,108 @@ +package core + +import ( + "strings" + "testing" + "time" + + rrule "github.com/teambition/rrule-go" +) + +func TestParseICal(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 := parseICal(strings.NewReader(input), "Work", false) + 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") + } + 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") + } + 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 option.Interval != 2 { + t.Errorf("Interval = %d, want 2", option.Interval) + } + if option.Count != 4 { + t.Errorf("Count = %d, want 4", option.Count) + } +} + +func TestParseICalSupportsRecurrenceModifiers(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` + + 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) + } +} + +func assertICalTime(t *testing.T, got, want time.Time) { + t.Helper() + if !got.Equal(want) { + t.Errorf("time = %v, want %v", got, want) + } +}