Skip to content
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

feat: add ical parsing support to pixlet #1087

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
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:[email protected]
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