From dc55f9c0097e1c36b85d7666071b840b902920e9 Mon Sep 17 00:00:00 2001 From: Leonardo Bishop Date: Mon, 20 Jan 2025 02:56:25 +0000 Subject: Add calendar support --- pkg/calendar/service.go | 112 +++++++++++++++++ pkg/database/migrations/0001_initial.sql | 17 ++- pkg/database/query/calendars.sql | 23 ++++ pkg/database/query/favourites.sql | 2 +- pkg/database/sqlc/calendars.sql.go | 97 +++++++++++++++ pkg/database/sqlc/favourites.sql.go | 36 ------ pkg/database/sqlc/models.go | 7 ++ pkg/ical/service.go | 78 ++++++++++++ pkg/schedule/model.go | 72 +++++++++++ pkg/schedule/parse.go | 205 +++++++++++++++++++++++++++++++ pkg/schedule/service.go | 140 +++++++++------------ 11 files changed, 663 insertions(+), 126 deletions(-) create mode 100644 pkg/calendar/service.go create mode 100644 pkg/database/query/calendars.sql create mode 100644 pkg/database/sqlc/calendars.sql.go create mode 100644 pkg/ical/service.go create mode 100644 pkg/schedule/model.go create mode 100644 pkg/schedule/parse.go (limited to 'pkg') diff --git a/pkg/calendar/service.go b/pkg/calendar/service.go new file mode 100644 index 0000000..19b2d9d --- /dev/null +++ b/pkg/calendar/service.go @@ -0,0 +1,112 @@ +package calendar + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Service interface { + GetCalendarForUser(id int32) (*sqlc.Calendar, error) + GetCalendarByName(name string) (*sqlc.Calendar, error) + CreateCalendarForUser(id int32) (*sqlc.Calendar, error) + DeleteCalendarForUser(id int32) error +} + +var ( + ErrCalendarNotFound = errors.New("calendar not found") +) + +type service struct { + pool *pgxpool.Pool +} + +func NewService(pool *pgxpool.Pool) Service { + return &service{ + pool: pool, + } +} + +func (s *service) GetCalendarForUser(id int32) (*sqlc.Calendar, error) { + queries := sqlc.New(s.pool) + + calendar, err := queries.GetCalendarForUser(context.Background(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrCalendarNotFound + } + return nil, err + } + + return &calendar, nil +} + +func (s *service) GetCalendarByName(name string) (*sqlc.Calendar, error) { + queries := sqlc.New(s.pool) + + calendar, err := queries.GetCalendarByName(context.Background(), name) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrCalendarNotFound + } + return nil, err + } + + return &calendar, nil +} + +func (s *service) CreateCalendarForUser(id int32) (*sqlc.Calendar, error) { + queries := sqlc.New(s.pool) + + name, err := randomString(16) + if err != nil { + return nil, fmt.Errorf("could not generate random string: %w", err) + } + + key, err := randomString(32) + if err != nil { + return nil, fmt.Errorf("could not generate random string: %w", err) + } + + calendar, err := queries.CreateCalendar(context.Background(), sqlc.CreateCalendarParams{ + UserID: id, + Name: name, + Key: key, + }) + if err != nil { + return nil, fmt.Errorf("could not create calendar: %w", err) + } + + return &calendar, nil +} + +func (s *service) DeleteCalendarForUser(id int32) error { + queries := sqlc.New(s.pool) + + _, err := queries.DeleteCalendar(context.Background(), id) + if err != nil { + return fmt.Errorf("could not delete calendar: %w", err) + } + + return nil +} + +func randomString(n int) (string, error) { + const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + ret := make([]byte, n) + for i := 0; i < n; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + return "", err + } + ret[i] = letters[num.Int64()] + } + + return string(ret), nil +} diff --git a/pkg/database/migrations/0001_initial.sql b/pkg/database/migrations/0001_initial.sql index eea0a73..a1f5fda 100644 --- a/pkg/database/migrations/0001_initial.sql +++ b/pkg/database/migrations/0001_initial.sql @@ -1,8 +1,8 @@ -- +goose Up CREATE TABLE users ( id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - username varchar(20) UNIQUE NOT NULL, - password char(60) NOT NULL + username varchar(20) UNIQUE NOT NULL CONSTRAINT non_blank_username CHECK(length(username) > 0), + password text NOT NULL CONSTRAINT valid_hash CHECK (length(password) = 60) ); CREATE TABLE favourites ( @@ -11,7 +11,16 @@ CREATE TABLE favourites ( event_guid uuid, event_id int, UNIQUE(user_id, event_guid, event_id), - CONSTRAINT chk_favourites CHECK (event_guid IS NOT NULL OR event_id IS NOT NULL), - FOREIGN KEY (user_id) REFERENCES users(id) + CONSTRAINT require_event_detail CHECK (event_guid IS NOT NULL OR event_id IS NOT NULL), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE calendars ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id int NOT NULL, + name text NOT NULL CONSTRAINT non_blank_name CHECK(length(name) > 0), + key text NOT NULL, + UNIQUE(user_id), + UNIQUE(name), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/pkg/database/query/calendars.sql b/pkg/database/query/calendars.sql new file mode 100644 index 0000000..490475d --- /dev/null +++ b/pkg/database/query/calendars.sql @@ -0,0 +1,23 @@ +-- name: CreateCalendar :one +INSERT INTO calendars ( + user_id, name, key +) VALUES ( + $1, $2, $3 +) +RETURNING *; + +-- name: GetCalendarForUser :one +SELECT * FROM calendars +WHERE user_id = $1 LIMIT 1; + +-- name: GetCalendarByName :one +SELECT * FROM calendars +WHERE name = $1 LIMIT 1; + +-- name: DeleteCalendar :execrows +DELETE FROM calendars +WHERE user_id = $1; + +-- name: DeleteCalendarByName :execrows +DELETE FROM calendars +WHERE name = $1; diff --git a/pkg/database/query/favourites.sql b/pkg/database/query/favourites.sql index 0661daa..94e914c 100644 --- a/pkg/database/query/favourites.sql +++ b/pkg/database/query/favourites.sql @@ -16,4 +16,4 @@ WHERE id = $1; -- name: DeleteFavouriteByEventDetails :execrows DELETE FROM favourites -WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3; \ No newline at end of file +WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3; diff --git a/pkg/database/sqlc/calendars.sql.go b/pkg/database/sqlc/calendars.sql.go new file mode 100644 index 0000000..47ae37f --- /dev/null +++ b/pkg/database/sqlc/calendars.sql.go @@ -0,0 +1,97 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: calendars.sql + +package sqlc + +import ( + "context" +) + +const createCalendar = `-- name: CreateCalendar :one +INSERT INTO calendars ( + user_id, name, key +) VALUES ( + $1, $2, $3 +) +RETURNING id, user_id, name, key +` + +type CreateCalendarParams struct { + UserID int32 `json:"user_id"` + Name string `json:"name"` + Key string `json:"key"` +} + +func (q *Queries) CreateCalendar(ctx context.Context, arg CreateCalendarParams) (Calendar, error) { + row := q.db.QueryRow(ctx, createCalendar, arg.UserID, arg.Name, arg.Key) + var i Calendar + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Key, + ) + return i, err +} + +const deleteCalendar = `-- name: DeleteCalendar :execrows +DELETE FROM calendars +WHERE user_id = $1 +` + +func (q *Queries) DeleteCalendar(ctx context.Context, userID int32) (int64, error) { + result, err := q.db.Exec(ctx, deleteCalendar, userID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const deleteCalendarByName = `-- name: DeleteCalendarByName :execrows +DELETE FROM calendars +WHERE name = $1 +` + +func (q *Queries) DeleteCalendarByName(ctx context.Context, name string) (int64, error) { + result, err := q.db.Exec(ctx, deleteCalendarByName, name) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getCalendarByName = `-- name: GetCalendarByName :one +SELECT id, user_id, name, key FROM calendars +WHERE name = $1 LIMIT 1 +` + +func (q *Queries) GetCalendarByName(ctx context.Context, name string) (Calendar, error) { + row := q.db.QueryRow(ctx, getCalendarByName, name) + var i Calendar + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Key, + ) + return i, err +} + +const getCalendarForUser = `-- name: GetCalendarForUser :one +SELECT id, user_id, name, key FROM calendars +WHERE user_id = $1 LIMIT 1 +` + +func (q *Queries) GetCalendarForUser(ctx context.Context, userID int32) (Calendar, error) { + row := q.db.QueryRow(ctx, getCalendarForUser, userID) + var i Calendar + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Key, + ) + return i, err +} diff --git a/pkg/database/sqlc/favourites.sql.go b/pkg/database/sqlc/favourites.sql.go index 3bf7c06..359ae9d 100644 --- a/pkg/database/sqlc/favourites.sql.go +++ b/pkg/database/sqlc/favourites.sql.go @@ -67,42 +67,6 @@ func (q *Queries) DeleteFavouriteByEventDetails(ctx context.Context, arg DeleteF return result.RowsAffected(), nil } -const deleteFavouriteByEventGuid = `-- name: DeleteFavouriteByEventGuid :execrows -DELETE FROM favourites -WHERE event_guid = $1 AND user_id = $2 -` - -type DeleteFavouriteByEventGuidParams struct { - EventGuid pgtype.UUID `json:"event_guid"` - UserID int32 `json:"user_id"` -} - -func (q *Queries) DeleteFavouriteByEventGuid(ctx context.Context, arg DeleteFavouriteByEventGuidParams) (int64, error) { - result, err := q.db.Exec(ctx, deleteFavouriteByEventGuid, arg.EventGuid, arg.UserID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const deleteFavouriteByEventId = `-- name: DeleteFavouriteByEventId :execrows -DELETE FROM favourites -WHERE event_id = $1 AND user_id = $2 -` - -type DeleteFavouriteByEventIdParams struct { - EventID pgtype.Int4 `json:"event_id"` - UserID int32 `json:"user_id"` -} - -func (q *Queries) DeleteFavouriteByEventId(ctx context.Context, arg DeleteFavouriteByEventIdParams) (int64, error) { - result, err := q.db.Exec(ctx, deleteFavouriteByEventId, arg.EventID, arg.UserID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - const getFavouritesForUser = `-- name: GetFavouritesForUser :many SELECT id, user_id, event_guid, event_id FROM favourites WHERE user_id = $1 diff --git a/pkg/database/sqlc/models.go b/pkg/database/sqlc/models.go index 09208aa..e38851a 100644 --- a/pkg/database/sqlc/models.go +++ b/pkg/database/sqlc/models.go @@ -8,6 +8,13 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Calendar struct { + ID int32 `json:"id"` + UserID int32 `json:"user_id"` + Name string `json:"name"` + Key string `json:"key"` +} + type Favourite struct { ID int32 `json:"id"` UserID int32 `json:"user_id"` diff --git a/pkg/ical/service.go b/pkg/ical/service.go new file mode 100644 index 0000000..2b89fe7 --- /dev/null +++ b/pkg/ical/service.go @@ -0,0 +1,78 @@ +package ical + +import ( + "errors" + "strings" + "time" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/LMBishop/confplanner/pkg/favourites" + "github.com/LMBishop/confplanner/pkg/schedule" + "github.com/microcosm-cc/bluemonday" +) + +type Service interface { + GenerateIcalForCalendar(calendar sqlc.Calendar) (string, error) +} + +var ( + ErrImproperType = errors.New("improper type") + ErrNotFound = errors.New("not found") +) + +type service struct { + favouritesService favourites.Service + scheduleService schedule.Service +} + +func NewService( + favouritesService favourites.Service, + scheduleService schedule.Service, +) Service { + return &service{ + favouritesService: favouritesService, + scheduleService: scheduleService, + } +} + +func (s *service) GenerateIcalForCalendar(calendar sqlc.Calendar) (string, error) { + favourites, err := s.favouritesService.GetFavouritesForUser(calendar.UserID) + if err != nil { + return "", err + } + + sched, _, err := s.scheduleService.GetSchedule() + if err != nil { + return "", err + } + + events := make([]schedule.Event, 0) + for _, favourite := range *favourites { + event := s.scheduleService.GetEventByID(favourite.EventID.Int32) + events = append(events, *event) + } + + now := time.Now() + + // https://www.rfc-editor.org/rfc/rfc5545.html + + ret := "BEGIN:VCALENDAR\n" + ret += "VERSION:2.0\n" + ret += "METHOD:PUBLISH\n" + ret += "X-WR-CALNAME:confplanner calendar\n" + for _, event := range events { + utcStart := event.Start.UTC() + utcEnd := event.End.UTC() + + ret += "BEGIN:VEVENT\n" + ret += "SUMMARY:" + event.Title + "\n" + ret += "DTSTART;TZID=" + sched.Conference.TimeZoneName + ":" + utcStart.Format("20060102T150405Z") + "\n" + ret += "DTEND;TZID=" + sched.Conference.TimeZoneName + ":" + utcEnd.Format("20060102T150405Z") + "\n" + ret += "LOCATION:" + event.Room + "\n" + ret += "DESCRIPTION;ENCODING=QUOTED-PRINTABLE:" + bluemonday.StrictPolicy().Sanitize(strings.Replace(event.Abstract, "\n", "\\n", -1)) + "\\nLast Synchronised: " + now.Format(time.DateTime) + "\n" + ret += "END:VEVENT\n" + } + ret += "END:VCALENDAR\n" + + return ret, nil +} diff --git a/pkg/schedule/model.go b/pkg/schedule/model.go new file mode 100644 index 0000000..fcf39a5 --- /dev/null +++ b/pkg/schedule/model.go @@ -0,0 +1,72 @@ +package schedule + +import "time" + +type Schedule struct { + Conference Conference `json:"conference"` + Tracks []Track `json:"tracks"` + Days []Day `json:"days"` +} + +type Conference struct { + Title string `json:"title"` + Venue string `json:"venue"` + City string `json:"city"` + Start string `json:"start"` + End string `json:"end"` + Days int `json:"days"` + DayChange string `json:"dayChange"` + TimeslotDuration string `json:"timeslotDuration"` + BaseURL string `json:"baseUrl"` + TimeZoneName string `json:"timeZoneName"` +} + +type Track struct { + Name string `json:"name"` +} + +type Day struct { + Date string `json:"date"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Rooms []Room `json:"rooms"` +} + +type Room struct { + Name string `json:"name"` + Events []Event `json:"events"` +} + +type Event struct { + ID int32 `json:"id"` + GUID string `json:"guid"` + Date string `json:"date"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Duration int32 `json:"duration"` + Room string `json:"room"` + URL string `json:"url"` + Track string `json:"track"` + Type string `json:"type"` + Title string `json:"title"` + Abstract string `json:"abstract"` + Persons []Person `json:"persons"` + Attachments []Attachment `json:"attachments"` + Links []Link `json:"links"` +} + +type Person struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type Attachment struct { + Type string `json:"string"` + Href string `json:"href"` + Name string `json:"name"` +} + +type Link struct { + Href string `json:"href"` + Name string `json:"name"` +} diff --git a/pkg/schedule/parse.go b/pkg/schedule/parse.go new file mode 100644 index 0000000..b0ed8df --- /dev/null +++ b/pkg/schedule/parse.go @@ -0,0 +1,205 @@ +package schedule + +import ( + "encoding/xml" + "fmt" + "time" +) + +type schedule struct { + XMLName xml.Name `xml:"schedule"` + Conference conference `xml:"conference"` + Tracks []track `xml:"tracks>track"` + Days []day `xml:"day"` +} + +type conference struct { + Title string `xml:"title"` + Venue string `xml:"venue"` + City string `xml:"city"` + Start string `xml:"start"` + End string `xml:"end"` + Days int `xml:"days"` + DayChange string `xml:"day_change"` + TimeslotDuration string `xml:"timeslot_duration"` + BaseURL string `xml:"base_url"` + TimeZoneName string `xml:"time_zone_name"` +} + +type track struct { + Name string `xml:",chardata"` +} + +type day struct { + Date string `xml:"date,attr"` + Start string `xml:"start,attr"` + End string `xml:"end,attr"` + Rooms []room `xml:"room"` +} + +type room struct { + Name string `xml:"name,attr"` + Events []event `xml:"event"` +} + +type event struct { + ID int32 `xml:"id,attr"` + GUID string `xml:"guid,attr"` + Date string `xml:"date"` + Start string `xml:"start"` + Duration string `xml:"duration"` + Room string `xml:"room"` + URL string `xml:"url"` + Track string `xml:"track"` + Type string `xml:"type"` + Title string `xml:"title"` + Abstract string `xml:"abstract"` + Persons []person `xml:"persons>person"` + Attachments []attachment `xml:"attachments>attachment"` + Links []link `xml:"links>link"` +} + +type person struct { + ID int `xml:"id,attr"` + Name string `xml:",chardata"` +} + +type attachment struct { + Type string `xml:"id,attr"` + Href string `xml:"href,attr"` + Name string `xml:",chardata"` +} + +type link struct { + Href string `xml:"href,attr"` + Name string `xml:",chardata"` +} + +func (dst *Schedule) Scan(src schedule) error { + dst.Conference.Scan(src.Conference) + + dst.Tracks = make([]Track, len(src.Tracks)) + for i := range src.Tracks { + dst.Tracks[i].Scan(src.Tracks[i]) + } + dst.Days = make([]Day, len(src.Days)) + for i := range src.Days { + if err := dst.Days[i].Scan(src.Days[i]); err != nil { + return fmt.Errorf("failed to scan day: %w", err) + } + } + return nil +} + +func (dst *Conference) Scan(src conference) { + dst.Title = src.Title + dst.Venue = src.Venue + dst.City = src.City + dst.Start = src.Start + dst.End = src.End + dst.Days = src.Days + dst.DayChange = src.DayChange + dst.TimeslotDuration = src.TimeslotDuration + dst.BaseURL = src.BaseURL + dst.TimeZoneName = src.TimeZoneName +} + +func (dst *Track) Scan(src track) { + dst.Name = src.Name +} + +func (dst *Day) Scan(src day) error { + dst.Date = src.Date + + start, err := time.Parse(time.RFC3339, src.Start) + if err != nil { + return fmt.Errorf("failed to parse start time: %w", err) + } + end, err := time.Parse(time.RFC3339, src.End) + if err != nil { + return fmt.Errorf("failed to parse end time: %w", err) + } + + dst.Start = start + dst.End = end + + dst.Rooms = make([]Room, len(src.Rooms)) + for i := range src.Rooms { + dst.Rooms[i].Scan(src.Rooms[i]) + } + return nil +} + +func (dst *Room) Scan(src room) { + dst.Name = src.Name + + dst.Events = make([]Event, len(src.Events)) + for i := range src.Events { + dst.Events[i].Scan(src.Events[i]) + } +} + +func (dst *Event) Scan(src event) error { + dst.ID = src.ID + dst.GUID = src.GUID + dst.Date = src.Date + + duration, err := parseDuration(src.Duration) + if err != nil { + return err + } + start, err := time.Parse(time.RFC3339, src.Date) + if err != nil { + start = time.Unix(0, 0) + } + dst.Start = start + dst.End = start.Add(time.Minute * time.Duration(duration)) + + dst.Room = src.Room + dst.URL = src.URL + dst.Track = src.Track + dst.Type = src.Type + dst.Title = src.Title + dst.Abstract = src.Abstract + + dst.Persons = make([]Person, len(src.Persons)) + for i := range src.Persons { + dst.Persons[i].Scan(src.Persons[i]) + } + + dst.Attachments = make([]Attachment, len(src.Attachments)) + for i := range src.Attachments { + dst.Attachments[i].Scan(src.Attachments[i]) + } + + dst.Links = make([]Link, len(src.Links)) + for i := range src.Links { + dst.Links[i].Scan(src.Links[i]) + } + + return nil +} + +func (dst *Person) Scan(src person) { + dst.ID = src.ID + dst.Name = src.Name +} + +func (dst *Attachment) Scan(src attachment) { + dst.Type = src.Type + dst.Href = src.Href + dst.Name = src.Name +} + +func (dst *Link) Scan(src link) { + dst.Href = src.Href + dst.Name = src.Name +} + +func parseDuration(duration string) (int32, error) { + d, err := time.Parse("15:04", duration) + if err != nil { + return 0, err + } + return int32(d.Minute() + d.Hour()*60), nil +} diff --git a/pkg/schedule/service.go b/pkg/schedule/service.go index 70587ef..a73a469 100644 --- a/pkg/schedule/service.go +++ b/pkg/schedule/service.go @@ -10,85 +10,22 @@ import ( ) type Service interface { - GetSchedule() (*Schedule, *time.Time, error) -} - -type Schedule struct { - XMLName xml.Name `json:"-" xml:"schedule"` - Conference Conference `json:"conference" xml:"conference"` - Tracks []Track `json:"tracks" xml:"tracks>track"` - Days []Day `json:"days" xml:"day"` -} - -type Conference struct { - Title string `json:"title" xml:"title"` - Venue string `json:"venue" xml:"venue"` - City string `json:"city" xml:"city"` - Start string `json:"start" xml:"start"` - End string `json:"end" xml:"end"` - Days int `json:"days" xml:"days"` - DayChange string `json:"dayChange" xml:"day_change"` - TimeslotDuration string `json:"timeslotDuration" xml:"timeslot_duration"` - BaseURL string `json:"baseUrl" xml:"base_url"` - TimeZoneName string `json:"timeZoneName" xml:"time_zone_name"` -} - -type Track struct { - Name string `json:"name" xml:",chardata"` -} - -type Day struct { - Date string `json:"date" xml:"date,attr"` - Start string `json:"start" xml:"start"` - End string `json:"end" xml:"end"` - Rooms []Room `json:"rooms" xml:"room"` -} - -type Room struct { - Name string `json:"name" xml:"name,attr"` - Events []Event `json:"events" xml:"event"` -} - -type Event struct { - ID int `json:"id" xml:"id,attr"` - GUID string `json:"guid" xml:"guid,attr"` - Date string `json:"date" xml:"date"` - Start string `json:"start" xml:"start"` - Duration string `json:"duration" xml:"duration"` - Room string `json:"room" xml:"room"` - URL string `json:"url" xml:"url"` - Track string `json:"track" xml:"track"` - Type string `json:"type" xml:"type"` - Title string `json:"title" xml:"title"` - Abstract string `json:"abstract" xml:"abstract"` - Persons []Person `json:"persons" xml:"persons>person"` - Attachments []Attachment `json:"attachments" xml:"attachments>attachment"` - Links []Link `json:"links" xml:"links>link"` -} - -type Person struct { - ID int `json:"id" xml:"id,attr"` - Name string `json:"name" xml:",chardata"` -} - -type Attachment struct { - Type string `json:"string" xml:"id,attr"` - Href string `json:"href" xml:"href,attr"` - Name string `json:"name" xml:",chardata"` -} - -type Link struct { - Href string `json:"href" xml:"href,attr"` - Name string `json:"name" xml:",chardata"` + GetSchedule() (*Schedule, time.Time, error) + GetEventByID(id int32) *Event } type service struct { - schedule *Schedule pentabarfUrl string - lastUpdated time.Time - lock sync.Mutex + + schedule *Schedule + eventsById map[int32]Event + lastUpdated time.Time + accessLock sync.RWMutex + updateLock sync.Mutex } +// TODO: Create a service implementation that persists to DB +// and isn't in memory func NewService(pentabarfUrl string) (Service, error) { service := &service{ pentabarfUrl: pentabarfUrl, @@ -102,15 +39,25 @@ func NewService(pentabarfUrl string) (Service, error) { return service, nil } -func (s *service) GetSchedule() (*Schedule, *time.Time, error) { - if s.hasScheduleExpired() { - err := s.updateSchedule() - if err != nil { - return nil, nil, err - } +func (s *service) GetSchedule() (*Schedule, time.Time, error) { + err := s.updateSchedule() + if err != nil { + return nil, time.Time{}, err } - return s.schedule, &s.lastUpdated, nil + s.accessLock.RLock() + defer s.accessLock.RUnlock() + + return s.schedule, s.lastUpdated, nil +} + +func (s *service) GetEventByID(id int32) *Event { + s.accessLock.RLock() + defer s.accessLock.RUnlock() + + event := s.eventsById[id] + + return &event } func (s *service) hasScheduleExpired() bool { @@ -119,13 +66,16 @@ func (s *service) hasScheduleExpired() bool { } func (s *service) updateSchedule() error { - s.lock.Lock() - defer s.lock.Unlock() - if !s.hasScheduleExpired() { return nil } + if !s.updateLock.TryLock() { + // don't block if another goroutine is already fetching + return nil + } + defer s.updateLock.Unlock() + res, err := http.Get(s.pentabarfUrl) if err != nil { return err @@ -133,14 +83,34 @@ func (s *service) updateSchedule() error { reader := bufio.NewReader(res.Body) - var schedule Schedule + var schedule schedule decoder := xml.NewDecoder(reader) if err := decoder.Decode(&schedule); err != nil { return fmt.Errorf("failed to decode XML: %w", err) } - s.schedule = &schedule + var newSchedule Schedule + err = newSchedule.Scan(schedule) + if err != nil { + return fmt.Errorf("failed to scan schedule: %w", err) + } + + s.accessLock.Lock() + defer s.accessLock.Unlock() + + s.schedule = &newSchedule s.lastUpdated = time.Now() + + s.eventsById = make(map[int32]Event) + + for _, day := range newSchedule.Days { + for _, room := range day.Rooms { + for _, event := range room.Events { + s.eventsById[event.ID] = event + } + } + } + return nil } -- cgit v1.2.3-70-g09d2