diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 74258e5..5e521b5 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" ) @@ -18,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) { @@ -50,6 +52,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/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/events_basic_test.go b/e2e/events_basic_test.go index 944ea7f..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), }, } @@ -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 { @@ -432,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(), @@ -440,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 { @@ -464,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 { diff --git a/e2e/ical_import_test.go b/e2e/ical_import_test.go new file mode 100644 index 0000000..8bb0313 --- /dev/null +++ b/e2e/ical_import_test.go @@ -0,0 +1,228 @@ +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) { + t.Setenv("HOME", t.TempDir()) + + 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 TestImportICalURLCachesUntilSync(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + const ( + name = "test-ical-url" + renamedName = "test-ical-url-renamed" + ) + + 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() + + sourceURL, err := url.Parse(server.URL + "/calendar.ics") + 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) + _ = c.RemoveCalendar(renamedName) + }) + + 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) + } + found := false + for _, calendar := range calendars { + 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) + } + 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) + } + 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("synced URL import = %+v", events) + } + if 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()) + } + + 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) + } + 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) + } + 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 { + 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) + } + 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 { + 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: {}}) +} + +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..5786259 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 @@ -10,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 7ae3420..609acf4 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= @@ -81,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/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/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..a7f5515 100644 --- a/pkg/core/constants.go +++ b/pkg/core/constants.go @@ -8,6 +8,11 @@ const ( GitRemoteName string = "origin" GitBranchName string = "main" + 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 c7f0adf..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) @@ -171,6 +178,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..af3f38c 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, }) } @@ -90,11 +91,25 @@ 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() { + 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 + } + icalURLs[calendarName] = sourceURL continue } - name := entry.Name() repo, err := c.initCalendarRepo(name) if err != nil { @@ -104,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) @@ -127,9 +142,20 @@ 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 { + 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 + } + wt, _ := cal.repository.Worktree() eventsDir, _ := wt.Filesystem.Chroot(EventsDirName) eventEntries, _ := eventsDir.ReadDir("/") @@ -173,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") @@ -249,13 +278,23 @@ 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) + } + 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 { + 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) @@ -279,28 +318,41 @@ func (c *Core) RenameCalendar(oldName, newName string) error { } calendar := c.calendars[oldName] + 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) + } + 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 { - return fmt.Errorf("failed to rename the repository directory: %w", err) + 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 @@ -333,7 +385,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() @@ -357,7 +409,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) } @@ -371,7 +423,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) @@ -394,7 +446,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 new file mode 100644 index 0000000..a53658e --- /dev/null +++ b/pkg/core/core_ical.go @@ -0,0 +1,205 @@ +package core + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "github.com/go-git/go-billy/v5/util" + "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 and caches its feed. +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 + } + + 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 + } + 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 { + return fmt.Errorf("create iCalendar URL file: %w", err) + } + if _, err := file.Write([]byte(sourceURL.String())); err != nil { + file.Close() + _ = c.fs.Remove(fileName) + return fmt.Errorf("write iCalendar URL file: %w", err) + } + if err := file.Close(); err != nil { + _ = c.fs.Remove(fileName) + return fmt.Errorf("close iCalendar URL file: %w", err) + } + + c.calendars[name] = &Calendar{Name: name, Readonly: true, ICalURL: sourceURL} + 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 +} + +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) fetchICalURL(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") + } + + fmt.Println("fetching ical", name) + + 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) + } + + 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 + } + + seen := make(map[uuid.UUID]struct{}, len(events)) + for _, event := range events { + _, alreadyLoaded := c.events[event.Id] + _, duplicate := seen[event.Id] + if alreadyLoaded || duplicate { + fmt.Printf("WARN: duplicate imported event ID %q\n", event.Id) + continue + } + 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") + } + if !strings.HasSuffix(sourceURL.Path, ".ics") { + return errors.New(`iCalendar URL must end with ".ics"`) + } + 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") + } + return nil +} diff --git a/pkg/core/ical.go b/pkg/core/ical.go new file mode 100644 index 0000000..95bedc7 --- /dev/null +++ b/pkg/core/ical.go @@ -0,0 +1,129 @@ +package core + +import ( + "crypto/sha256" + "fmt" + "io" + "time" + + 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. +// 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 { + normalizeTimeZones(source) + 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 := icalEventEnd(source, from) + if err != nil { + return Event{}, 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 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 == "" { + 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 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 + 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..16df85f --- /dev/null +++ b/pkg/core/ical_test.go @@ -0,0 +1,198 @@ +package core + +import ( + "fmt" + "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 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 +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) + } +} + +// 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) { + t.Errorf("time = %v, want %v", got, want) + } +} 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) }