Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
146 changes: 146 additions & 0 deletions e2e/ical_import_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
5 changes: 4 additions & 1 deletion notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +62,7 @@
│ │ └── <UUID>.json
│ ├── index.jsonl
│ └── index-rich.jsonl
├── imported
├── default.key
├── shared.readonly
└── shared.key
Expand Down
12 changes: 12 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"net/url"
"strings"
"time"

"github.com/git-calendar/core/pkg/core"
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions pkg/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 19 additions & 2 deletions pkg/core/core_calendars.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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("/")
Expand Down Expand Up @@ -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 {
Expand Down
Loading