Skip to content

feat: add ical parsing support to pixlet #1087

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
8 changes: 8 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions .idea/pixlet.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,43 @@ def main(config):
),
)
```


## Pixlet module: iCalendar

The `iCalendar` module parses .ics based calendars into individual events. This is module can be used to parse published
calendars from major providers such as Apple, Microsoft, and Google instead of using authentication (OAuth) that's likely
to get denied from office IT security departments. The iCalendar specification can be found [here](https://icalendar.org/RFC-Specifications/iCalendar-RFC-5545/).

| Function | Description |
| --- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `parse(rawString)` | Takes a raw iCalendar string and parses into a list of event dictionaries with event data. This function automatically expands recurring events and returns the list sorted by closest start date to furthest start date reaching maximum of 3 months of recurring dates from the current time to avoid overloading memory and infinite loops. |

Example:

```starlark
load("icalendar.star", "icalendar")
load(http.star", "http")

def main(config):
icalendar_url = "http://icalendar.org/your/hosted/ics/file"
timeout = 60
res = http.get(url = url, ttl_seconds = timeout)
if res.status_code != 200:
fail("request to %s failed with status code: %d - %s" % (url, res.status_code, res.body()))

events = icalendar.parse(res.body())

most_recent_event = events[0]
print(most_recent_event['summary'])
print(most_recent_event['description'])
print(most_recent_event['location'])
print(most_recent_event['start']) // RFC3339
print(most_recent_event['end']) // RFC3339
print(most_recent_event['metaData']['minutesUntilStart'])
print(most_recent_event['metaData']['minutesUntilEnd'])

print("Is Cancelled:", most_recent_event['status'] == 'CANCELLED')
print("Is Today:", most_recent_event['metaData']['isToday'])
print("Is Tomorrow: ", most_recent_event['metaData']['isTomorrow'])
```
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ require (
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/atomic v1.9.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
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/tidbyt/gg v0.0.0-20220808163829-95806fa1d427 h1:br5WYVw/jr4G0PZpBBx2fBAANVUrI8KKHMSs3LVqO9A=
github.com/tidbyt/gg v0.0.0-20220808163829-95806fa1d427/go.mod h1:+SCm6iJHe2lfsQzlbLCsd5XsTKYSD0VqtQmWMnNs9OE=
github.com/tidbyt/go-libwebp v0.0.0-20230922075150-fb11063b2a6a h1:zvAhEO3ZB7m1Lc3BwJXLTDrLrHVAbcDByJ7XkL4WR+s=
Expand Down
4 changes: 4 additions & 0 deletions runtime/applet.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"testing"
"testing/fstest"
"tidbyt.dev/pixlet/runtime/modules/icalendar"

starlibbsoup "github.com/qri-io/starlib/bsoup"
starlibgzip "github.com/qri-io/starlib/compress/gzip"
Expand Down Expand Up @@ -575,6 +576,9 @@ func (a *Applet) loadModule(thread *starlark.Thread, module string) (starlark.St
case "humanize.star":
return humanize.LoadModule()

case "icalendar.star":
return icalendar.LoadModule()

case "math.star":
return starlark.StringDict{
starlibmath.Module.Name: starlibmath.Module,
Expand Down
112 changes: 112 additions & 0 deletions runtime/modules/icalendar/icalendar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package icalendar

import (
"fmt"
"strings"
"sync"
"tidbyt.dev/pixlet/runtime/modules/icalendar/parser"
"time"

godfe "github.com/newm4n/go-dfe"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
)

const (
ModuleName = "icalendar"
)

var (
once sync.Once
module starlark.StringDict
empty time.Time
translation *godfe.PatternTranslation
)

func LoadModule() (starlark.StringDict, error) {
translation = godfe.NewPatternTranslation()
once.Do(func() {
module = starlark.StringDict{
ModuleName: &starlarkstruct.Module{
Name: ModuleName,
Members: starlark.StringDict{
"parse": starlark.NewBuiltin("parse", parse),
},
},
}
})

return module, nil
}

/*
* This function returns a list of events with the events metadata
*/
func parse(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var (
rawCalendar starlark.String
)
if err := starlark.UnpackArgs(
"parseCalendar",
args, kwargs,
"str", &rawCalendar,
); err != nil {
return nil, fmt.Errorf("unpacking arguments for bytes: %s", err)
}
calendar := parser.NewParser(strings.NewReader(rawCalendar.GoString()))
if err := calendar.Parse(); err != nil {
return nil, fmt.Errorf("parsing calendar: %s", err)
}

events := make([]starlark.Value, 0, len(calendar.Events))

for _, event := range calendar.Events {
dict := starlark.NewDict(25)

if err := dict.SetKey(starlark.String("uid"), starlark.String(event.Uid)); err != nil {
return nil, fmt.Errorf("setting uid: %s", err)
}
if err := dict.SetKey(starlark.String("summary"), starlark.String(event.Summary)); err != nil {
return nil, fmt.Errorf("setting summary: %s", err)
}
if err := dict.SetKey(starlark.String("description"), starlark.String(event.Description)); err != nil {
return nil, fmt.Errorf("setting description: %s", err)
}
if err := dict.SetKey(starlark.String("status"), starlark.String(event.Status)); err != nil {
return nil, fmt.Errorf("setting status: %s", err)
}
if err := dict.SetKey(starlark.String("comment"), starlark.String(event.Comment)); err != nil {
return nil, fmt.Errorf("setting comment: %s", err)
}
if err := dict.SetKey(starlark.String("start"), starlark.String(event.Start.Format(time.RFC3339))); err != nil {
return nil, fmt.Errorf("setting start: %s", err)
}
if err := dict.SetKey(starlark.String("end"), starlark.String(event.End.Format(time.RFC3339))); err != nil {
return nil, fmt.Errorf("setting end: %s", err)
}
if err := dict.SetKey(starlark.String("is_recurring"), starlark.Bool(event.IsRecurring)); err != nil {
return nil, fmt.Errorf("setting is_recurring: %s", err)
}
if err := dict.SetKey(starlark.String("location"), starlark.String(event.Location)); err != nil {
return nil, fmt.Errorf("setting location: %s", err)
}
if err := dict.SetKey(starlark.String("duration_in_seconds"), starlark.Float(event.Duration.Seconds())); err != nil {
return nil, fmt.Errorf("setting duration: %s", err)
}
if err := dict.SetKey(starlark.String("url"), starlark.String(event.Url)); err != nil {
return nil, fmt.Errorf("setting end: %s", err)
}
if err := dict.SetKey(starlark.String("sequence"), starlark.MakeInt(event.Sequence)); err != nil {
return nil, fmt.Errorf("setting end: %s", err)
}
if err := dict.SetKey(starlark.String("created_at"), starlark.String(event.Created.Format(time.RFC3339))); err != nil {
return nil, fmt.Errorf("setting end: %s", err)
}
if err := dict.SetKey(starlark.String("updated_at"), starlark.String(event.LastModified.Format(time.RFC3339))); err != nil {
return nil, fmt.Errorf("setting end: %s", err)
}

events = append(events, dict)
}
return starlark.NewList(events), nil
}
63 changes: 63 additions & 0 deletions runtime/modules/icalendar/icalendar_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package icalendar_test

import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
"tidbyt.dev/pixlet/runtime"
)

import (
"context"
)

var icalendarSrc = `
load("icalendar.star", "icalendar")
raw_string = """
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:America/Phoenix
LAST-MODIFIED:20231222T233358Z
TZURL:https://www.tzurl.org/zoneinfo-outlook/America/Phoenix
X-LIC-LOCATION:America/Phoenix
BEGIN:STANDARD
TZNAME:MST
TZOFFSETFROM:-0700
TZOFFSETTO:-0700
DTSTART:19700101T000000
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20240817T225510Z
UID:1723935287215-82939@ical.marudot.com
DTSTART;TZID=America/Phoenix:20240801T120000
RRULE:FREQ=DAILY
DTEND;TZID=America/Phoenix:20240801T150000
SUMMARY:Test
DESCRIPTION:Test Description
LOCATION:Phoenix
END:VEVENT
END:VCALENDAR
"""

def test_icalendar():
events = icalendar.parse(raw_string)
return events



def main():
return test_icalendar()

`

func TestICalendar(t *testing.T) {
app, err := runtime.NewApplet("icalendar_test.star", []byte(icalendarSrc))
require.NoError(t, err)

screens, err := app.Run(context.Background())
require.NoError(t, err)
assert.NotNil(t, screens)
}
24 changes: 24 additions & 0 deletions runtime/modules/icalendar/parser/members/latlng.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package members

import (
"fmt"
"strconv"
"strings"
)

func ParseLatLng(l string) (float64, float64, error) {
token := strings.SplitN(l, ";", 2)
if len(token) != 2 {
return 0.0, 0.0, fmt.Errorf("could not parse geo coordinates: %s", l)
}
lat, laterr := strconv.ParseFloat(token[0], 64)
if laterr != nil {
return 0.0, 0.0, fmt.Errorf("could not parse geo latitude: %s", token[0])
}
long, longerr := strconv.ParseFloat(token[1], 64)
if longerr != nil {
return 0.0, 0.0, fmt.Errorf("could not parse geo longitude: %s", token[1])
}

return lat, long, nil
}
43 changes: 43 additions & 0 deletions runtime/modules/icalendar/parser/members/line.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package members

import "strings"

func ParseRecurrenceParams(p string) (string, map[string]string) {
tokens := strings.Split(p, ";")

parameters := make(map[string]string)
for _, p = range tokens {
t := strings.Split(p, "=")
if len(t) != 2 {
continue
}
parameters[t[0]] = t[1]
}

return tokens[0], parameters
}

func ParseParameters(p string) (string, map[string]string) {
tokens := strings.Split(p, ";")

parameters := make(map[string]string)

for _, p = range tokens[1:] {
t := strings.Split(p, "=")
if len(t) != 2 {
continue
}

parameters[t[0]] = t[1]
}

return tokens[0], parameters
}

func UnescapeString(l string) string {
l = strings.Replace(l, `\\`, `\`, -1)
l = strings.Replace(l, `\;`, `;`, -1)
l = strings.Replace(l, `\,`, `,`, -1)

return l
}
Loading