Skip to content

Commit ef96be5

Browse files
committed
integrate ical importer with core
1 parent 36a66df commit ef96be5

9 files changed

Lines changed: 411 additions & 49 deletions

File tree

cmd/wasm/main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ func RegisterCallbacks(api *api.Api) {
5050
return nil, api.LoadCalendars()
5151
})
5252
}),
53+
"importICalFile": js.FuncOf(func(this js.Value, args []js.Value) any {
54+
return wrapPromise(func() (any, error) {
55+
return nil, api.ImportICalFile(args[0].String(), args[1].String())
56+
})
57+
}),
58+
"importICalURL": js.FuncOf(func(this js.Value, args []js.Value) any {
59+
return wrapPromise(func() (any, error) {
60+
return nil, api.ImportICalURL(args[0].String(), args[1].String())
61+
})
62+
}),
5363
"updateRemote": js.FuncOf(func(this js.Value, args []js.Value) any {
5464
return wrapPromise(func() (any, error) {
5565
return nil, api.UpdateRemote(args[0].String(), args[1].String(), args[2].Bool())

e2e/ical_import_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package e2e
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"sync/atomic"
12+
"testing"
13+
"time"
14+
15+
"github.com/git-calendar/core/pkg/core"
16+
"github.com/git-calendar/core/pkg/filesystem"
17+
)
18+
19+
func TestImportICalFilePersistsEvents(t *testing.T) {
20+
const calendar = "test-ical-file"
21+
c := core.NewCore()
22+
if err := c.CreateCalendar(calendar, ""); err != nil {
23+
t.Fatal(err)
24+
}
25+
t.Cleanup(func() { _ = c.RemoveCalendar(calendar) })
26+
27+
if err := c.ImportICalFile(calendar, strings.NewReader(icalFeed("Imported event"))); err != nil {
28+
t.Fatal(err)
29+
}
30+
31+
events := importedEvents(c, calendar)
32+
if len(events) != 1 {
33+
t.Fatalf("got %d imported events, want 1", len(events))
34+
}
35+
if events[0].Id.Version() != 4 {
36+
t.Errorf("event ID version = %d, want 4", events[0].Id.Version())
37+
}
38+
id := events[0].Id
39+
40+
home, err := os.UserHomeDir()
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
eventPath := filepath.Join(home, filesystem.DirName, calendar, core.EventsDirName, id.String()+".json")
45+
if _, err := os.Stat(eventPath); err != nil {
46+
t.Fatalf("imported event file was not saved: %v", err)
47+
}
48+
49+
if err := c.LoadCalendars(); err != nil {
50+
t.Fatal(err)
51+
}
52+
events = importedEvents(c, calendar)
53+
if len(events) != 1 || events[0].Id != id {
54+
t.Fatalf("imported event did not survive reload: %+v", events)
55+
}
56+
}
57+
58+
func TestImportICalURLRefetchesOnLoad(t *testing.T) {
59+
const name = "test-ical-url"
60+
61+
var feed atomic.Value
62+
feed.Store(icalFeed("First title"))
63+
var requests atomic.Int32
64+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
65+
requests.Add(1)
66+
_, _ = w.Write([]byte(feed.Load().(string)))
67+
}))
68+
defer server.Close()
69+
70+
sourceURL, err := url.Parse(server.URL)
71+
if err != nil {
72+
t.Fatal(err)
73+
}
74+
75+
c := core.NewCore()
76+
if err := c.ImportICalURL(name, sourceURL); err != nil {
77+
t.Fatal(err)
78+
}
79+
t.Cleanup(func() { _ = c.RemoveCalendar(name) })
80+
81+
events := importedEvents(c, name)
82+
if len(events) != 1 || events[0].Title != "First title" {
83+
t.Fatalf("first URL import = %+v", events)
84+
}
85+
if events[0].Id.Version() != 8 {
86+
t.Errorf("event ID version = %d, want 8", events[0].Id.Version())
87+
}
88+
id := events[0].Id
89+
90+
calendars, err := c.ListCalendars()
91+
if err != nil {
92+
t.Fatal(err)
93+
}
94+
for _, calendar := range calendars {
95+
if calendar.Name == name && !calendar.Readonly {
96+
t.Fatal("URL calendar is not read-only")
97+
}
98+
}
99+
100+
home, err := os.UserHomeDir()
101+
if err != nil {
102+
t.Fatal(err)
103+
}
104+
data, err := os.ReadFile(filepath.Join(home, filesystem.DirName, name))
105+
if err != nil {
106+
t.Fatal(err)
107+
}
108+
if string(data) != server.URL {
109+
t.Errorf("URL file = %q, want %q", data, server.URL)
110+
}
111+
112+
feed.Store(icalFeed("Second title"))
113+
if err := c.LoadCalendars(); err != nil {
114+
t.Fatal(err)
115+
}
116+
117+
events = importedEvents(c, name)
118+
if len(events) != 1 || events[0].Title != "Second title" {
119+
t.Fatalf("refetched URL import = %+v", events)
120+
}
121+
if events[0].Id != id {
122+
t.Errorf("event ID changed after refetch: got %s, want %s", events[0].Id, id)
123+
}
124+
if requests.Load() != 2 {
125+
t.Errorf("URL was fetched %d times, want 2", requests.Load())
126+
}
127+
}
128+
129+
func importedEvents(c *core.Core, calendar string) []core.Event {
130+
from := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
131+
to := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
132+
return c.GetEvents(from, to, core.GetEventsFilter{calendar: nil})
133+
}
134+
135+
func icalFeed(title string) string {
136+
return fmt.Sprintf(`BEGIN:VCALENDAR
137+
VERSION:2.0
138+
PRODID:-//git-calendar//test//EN
139+
BEGIN:VEVENT
140+
UID:stable@example.com
141+
DTSTART:20260714T100000Z
142+
DTEND:20260714T110000Z
143+
SUMMARY:%s
144+
END:VEVENT
145+
END:VCALENDAR`, title)
146+
}

notes.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
- [x] load repositories
2323
- [ ] Undo function (git reset HEAD~1)
2424
- [ ] iCalendar compatibility
25-
- [ ] import (periodical & one-time)
25+
- [x] import
26+
- [x] one-time import into a Git-backed calendar
27+
- [x] URL calendar fetched on every load
2628
- [ ] export
2729
- to a file
2830
- idk about url
@@ -60,6 +62,7 @@
6062
│ │ └── <UUID>.json
6163
│ ├── index.jsonl
6264
│ └── index-rich.jsonl
65+
├── imported
6366
├── default.key
6467
├── shared.readonly
6568
└── shared.key

pkg/api/api.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"errors"
1212
"fmt"
1313
"net/url"
14+
"strings"
1415
"time"
1516

1617
"github.com/git-calendar/core/pkg/core"
@@ -55,9 +56,20 @@ func (a *Api) LoadCalendars() error { return a.inner.LoadCa
5556
func (a *Api) SetCorsProxy(proxyUrl string) error { return a.inner.SetCorsProxy(proxyUrl) }
5657
func (a *Api) SyncAll() error { return a.inner.SyncAll() }
5758
func (a *Api) ExportZip(calendar string) ([]byte, error) { return a.inner.ExportZip(calendar) }
59+
func (a *Api) ImportICalFile(calendar, data string) error {
60+
return a.inner.ImportICalFile(calendar, strings.NewReader(data))
61+
}
5862

5963
// ------------------------------ Wrapper methods encoding and decoding JSONs ------------------------------
6064

65+
func (a *Api) ImportICalURL(name, rawURL string) error {
66+
parsed, err := url.Parse(rawURL)
67+
if err != nil {
68+
return fmt.Errorf("iCalendar URL is invalid: %w", err)
69+
}
70+
return a.inner.ImportICalURL(name, parsed)
71+
}
72+
6173
func (a *Api) UpdateRemote(calendar string, remoteUrl string, readonly bool) error {
6274
parsed, err := url.Parse(remoteUrl)
6375
if err != nil {

pkg/core/core.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ func (c *Core) ExportZip(calendar string) ([]byte, error) {
171171
if !ok {
172172
return nil, fmt.Errorf("calendar not found: %s", calendar)
173173
}
174+
if cal.repository == nil {
175+
return nil, errors.New("URL calendars cannot be exported")
176+
}
174177

175178
wt, err := cal.repository.Worktree()
176179
if err != nil {

pkg/core/core_calendars.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,18 @@ func (c *Core) LoadCalendars() error {
9090
return fmt.Errorf("failed to list all directories in root: %w", err)
9191
}
9292

93+
icalURLs := make(map[string]*url.URL)
9394
for _, entry := range entries {
95+
name := entry.Name()
9496
if !entry.IsDir() {
97+
sourceURL, err := c.readICalURL(name)
98+
if err != nil {
99+
continue
100+
}
101+
c.calendars[name] = &Calendar{Name: name, Readonly: true}
102+
icalURLs[name] = sourceURL
95103
continue
96104
}
97-
name := entry.Name()
98105

99106
repo, err := c.initCalendarRepo(name)
100107
if err != nil {
@@ -130,6 +137,13 @@ func (c *Core) LoadCalendars() error {
130137
// load tree + events
131138
// TODO do not load files, but build tree from index.json
132139
for _, cal := range c.calendars {
140+
if sourceURL, ok := icalURLs[cal.Name]; ok {
141+
if err := c.loadICalURL(cal.Name, sourceURL); err != nil {
142+
fmt.Printf("WARN: failed to load iCalendar URL %q: %v\n", cal.Name, err)
143+
}
144+
continue
145+
}
146+
133147
wt, _ := cal.repository.Worktree()
134148
eventsDir, _ := wt.Filesystem.Chroot(EventsDirName)
135149
eventEntries, _ := eventsDir.ReadDir("/")
@@ -280,7 +294,10 @@ func (c *Core) RenameCalendar(oldName, newName string) error {
280294

281295
calendar := c.calendars[oldName]
282296
if err := c.fs.Rename(oldName, newName); err != nil {
283-
return fmt.Errorf("failed to rename the repository directory: %w", err)
297+
return fmt.Errorf("failed to rename calendar: %w", err)
298+
}
299+
if calendar.repository == nil {
300+
return c.LoadCalendars()
284301
}
285302
if len(calendar.EncryptionKey) != 0 { // TODO: maybe check c.fs.Stat() instead?
286303
if err := c.fs.Rename(fmt.Sprintf("%s.key", oldName), fmt.Sprintf("%s.key", newName)); err != nil {

0 commit comments

Comments
 (0)