Skip to content
Merged
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
14 changes: 13 additions & 1 deletion cmd/wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main

import (
"syscall/js"
_ "time/tzdata" // microslop dates need this

"github.com/git-calendar/core/pkg/api"
)
Expand All @@ -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) {
Expand Down Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion e2e/encryption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
17 changes: 7 additions & 10 deletions e2e/events_basic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,23 +369,23 @@ func TestGetEvents_FilterByCalendarAndTag(t *testing.T) {
Title: "matching event",
From: from,
To: to,
TagId: uuidPtr(tagA),
TagId: new(tagA),
},
{
Id: wrongTagID,
Calendar: calendarA,
Title: "wrong tag",
From: from,
To: to,
TagId: uuidPtr(tagB),
TagId: new(tagB),
},
{
Id: wrongCalendarID,
Calendar: calendarB,
Title: "wrong calendar",
From: from,
To: to,
TagId: uuidPtr(tagA),
TagId: new(tagA),
},
}

Expand All @@ -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 {
Expand Down Expand Up @@ -432,15 +433,15 @@ 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(),
Calendar: calendar,
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 {
Expand All @@ -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 {
Expand Down
228 changes: 228 additions & 0 deletions e2e/ical_import_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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
github.com/google/uuid v1.6.0
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
)

Expand Down
4 changes: 4 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 Expand Up @@ -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=
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
Loading