From 51b7e0569c5f783e835f2d6b1f608d8fd4c8719d Mon Sep 17 00:00:00 2001 From: Nicholas Novak <34256932+NickyBoy89@users.noreply.github.com> Date: Fri, 17 Mar 2023 18:23:40 -0700 Subject: [PATCH] Initial commit --- .gitignore | 1 + README.md | 15 +++++ calendar/calendarevent.go | 111 ++++++++++++++++++++++++++++++++++++ go.mod | 7 +++ go.sum | 22 +++++++ main.go | 98 +++++++++++++++++++++++++++++++ selection/eventselection.go | 7 +++ timeline/timelines.go | 7 +++ 8 files changed, 268 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 calendar/calendarevent.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 selection/eventselection.go create mode 100644 timeline/timelines.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..405ec2c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..39e3882 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# assign-notify + +A small script that displays the upcoming assignments on my school's Moodle site + +## Configuration + +Everything in `config.toml` + +An example looks like: +```toml +moodle_root = "https://moodle.example.com" +user_id = "12345" +auth_token = "aaaabbbbccccddddeeeeffff" +``` +These values can be obtained by exporting a calendar in Moodle, and looking at the resulting link that was generated diff --git a/calendar/calendarevent.go b/calendar/calendarevent.go new file mode 100644 index 0000000..a77c756 --- /dev/null +++ b/calendar/calendarevent.go @@ -0,0 +1,111 @@ +package calendar + +import ( + "strings" + "time" + + ics "github.com/arran4/golang-ical" +) + +// CalendarClass is a class that represents what "class" the specified event is +// +// Currently, I have only observed this value as being `PUBLIC`, but other +// values are handled as panics +type CalendarClass byte + +const ( + ClassPublic CalendarClass = iota +) + +func ParseClass(raw string) CalendarClass { + switch raw { + case "PUBLIC": + return ClassPublic + } + panic("Unknown class: " + raw) +} + +func (cc CalendarClass) String() string { + switch cc { + case ClassPublic: + return "PUBLIC" + default: + panic("Unknown class") + } +} + +// CalendarEvent represents a single event in a calendar that is tied to a certain date +type CalendarEvent struct { + UID string + // A summary of the event, including the name of the event + // This value is set every time as far as I can tell + Summary string + // A fescription of the event, including more specific information. Not always + // set, so don't rely on it + Description string + // Access class? of the event + Class CalendarClass + LastModified time.Time + DateStamp time.Time + StartTime time.Time + EndTime time.Time + // Every event comes with a category. This usually contains the name of the + // class, along with some other information (Semester code, CRN, sections) + Category struct { + ClassName string + OtherData string + } +} + +const calendarTimeFormat = "20060102T150405Z" + +// FromVEvent parses an ICS VEvent's format and outputs a specific calendar event, or an error +func FromVEvent(external *ics.VEvent) (event CalendarEvent, err error) { + if len(external.Components) > 0 { + panic("Event has unhandled components") + } + + for _, prop := range external.Properties { + if len(prop.ICalParameters) > 0 { + panic("Property has unhandled ICalParameters") + } + + switch prop.IANAToken { + case "UID": + event.UID = prop.Value + case "SUMMARY": + event.Summary = prop.Value + case "DESCRIPTION": + event.Description = prop.Value + case "CLASS": + event.Class = ParseClass(prop.Value) + case "LAST-MODIFIED": + event.LastModified, err = time.Parse(calendarTimeFormat, prop.Value) + if err != nil { + return event, err + } + case "DTSTAMP": + event.DateStamp, err = time.Parse(calendarTimeFormat, prop.Value) + if err != nil { + return event, err + } + case "DTSTART": + event.StartTime, err = time.Parse(calendarTimeFormat, prop.Value) + if err != nil { + return event, err + } + case "DTEND": + event.EndTime, err = time.Parse(calendarTimeFormat, prop.Value) + if err != nil { + return event, err + } + case "CATEGORIES": + dashIndex := strings.Index(prop.Value, "-") + event.Category.ClassName = prop.Value[:dashIndex] + event.Category.OtherData = prop.Value[dashIndex:] + default: + panic("Unknown token when parsing event: " + prop.IANAToken) + } + } + return event, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6c156b6 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/NickyBoy89/assign-notify + +go 1.20 + +require github.com/arran4/golang-ical v0.0.0-20230213232137-07c6aad5e4f0 + +require github.com/BurntSushi/toml v1.2.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..434fc1e --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/arran4/golang-ical v0.0.0-20230213232137-07c6aad5e4f0 h1:VVPogIxPiZ6WK5G4Pve5VSQ4HEFiJ8GChpqRjo1gN2c= +github.com/arran4/golang-ical v0.0.0-20230213232137-07c6aad5e4f0/go.mod h1:BSTTrYHuM12oAL8jDdcmPdw02SBThKYWNFHQlvEG6b0= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3faf182 --- /dev/null +++ b/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/BurntSushi/toml" + "github.com/NickyBoy89/assign-notify/calendar" + "github.com/NickyBoy89/assign-notify/selection" + "github.com/NickyBoy89/assign-notify/timeline" + ics "github.com/arran4/golang-ical" +) + +func ConstructCalendarUrl( + moodleBase, userId, authToken string, + eventTypes selection.CalendarEventType, + when timeline.CalendarEventHorizon, +) string { + return fmt.Sprintf( + "%s/calendar/export_execute.php?userid=%s&authtoken=%s&preset_what=%s&preset_time=%s", + moodleBase, + userId, + authToken, + eventTypes, + when, + ) +} + +const ConfigFileName = "config.toml" + +type Config struct { + MoodleRoot string `toml:"moodle_root"` + UserId string `toml:"user_id"` + AuthToken string `toml:"auth_token"` +} + +func main() { + // Load the config + var conf Config + confFile, err := os.Open(ConfigFileName) + if err != nil { + log.Fatalf("Unable to open config %s: %s", ConfigFileName, err) + } + + if _, err := toml.NewDecoder(confFile).Decode(&conf); err != nil { + log.Fatalf("Error decoding %s: %s", ConfigFileName, err) + } + confFile.Close() + + resp, err := http.Get(ConstructCalendarUrl( + conf.MoodleRoot, + conf.UserId, + conf.AuthToken, + selection.AllEvents, + timeline.WithinMonth, + )) + if err != nil { + log.Fatalf("Error requesting calendar: %s", err) + } + defer resp.Body.Close() + + cal, err := ics.ParseCalendar(resp.Body) + if err != nil { + log.Fatalf("Error parsing ICAL: %s", err) + } + + // Sort the events into when their due dates are + var past, upcoming []calendar.CalendarEvent + + for _, event := range cal.Events() { + calEvent, err := calendar.FromVEvent(event) + if err != nil { + panic(err) + } + if calEvent.EndTime.Before(time.Now()) { + past = append(past, calEvent) + } else { + upcoming = append(upcoming, calEvent) + } + } + + for _, event := range upcoming { + due := time.Until(event.EndTime) + days := int(due.Hours()) / 24 + hours := int(due.Hours()) + fmt.Printf( + "%s: %s: due in %v days %v hours %.0f minutes\n", + event.Category.ClassName, + event.Summary, + days, + hours-(days*24), + due.Minutes()-float64(hours*60), + ) + } +} diff --git a/selection/eventselection.go b/selection/eventselection.go new file mode 100644 index 0000000..ca9fdf1 --- /dev/null +++ b/selection/eventselection.go @@ -0,0 +1,7 @@ +package selection + +type CalendarEventType string + +const ( + AllEvents CalendarEventType = "all" +) diff --git a/timeline/timelines.go b/timeline/timelines.go new file mode 100644 index 0000000..aedc1e4 --- /dev/null +++ b/timeline/timelines.go @@ -0,0 +1,7 @@ +package timeline + +type CalendarEventHorizon string + +const ( + WithinMonth CalendarEventHorizon = "monthnow" +)