From ecc6a55aba7bb35fc778e7a53848396b88214151 Mon Sep 17 00:00:00 2001 From: Leonardo Bishop Date: Sat, 23 Aug 2025 22:29:28 +0100 Subject: Add multiple conferences feature --- pkg/conference/model.go | 72 +++++++++++++++ pkg/conference/parse.go | 205 +++++++++++++++++++++++++++++++++++++++++++ pkg/conference/service.go | 218 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 495 insertions(+) create mode 100644 pkg/conference/model.go create mode 100644 pkg/conference/parse.go create mode 100644 pkg/conference/service.go (limited to 'pkg/conference') diff --git a/pkg/conference/model.go b/pkg/conference/model.go new file mode 100644 index 0000000..343271f --- /dev/null +++ b/pkg/conference/model.go @@ -0,0 +1,72 @@ +package conference + +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/conference/parse.go b/pkg/conference/parse.go new file mode 100644 index 0000000..b7f9dba --- /dev/null +++ b/pkg/conference/parse.go @@ -0,0 +1,205 @@ +package conference + +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/conference/service.go b/pkg/conference/service.go new file mode 100644 index 0000000..59c2c8c --- /dev/null +++ b/pkg/conference/service.go @@ -0,0 +1,218 @@ +package conference + +import ( + "bufio" + "context" + "encoding/xml" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Service interface { + CreateConference(url string) (*sqlc.Conference, error) + DeleteConference(id int32) error + GetConferences() ([]sqlc.Conference, error) + GetSchedule(id int32) (*Schedule, time.Time, error) + GetEventByID(conferenceID, eventID int32) (*Event, error) +} + +type loadedConference struct { + pentabarfUrl string + schedule *Schedule + eventsById map[int32]Event + lastUpdated time.Time + lock sync.RWMutex +} + +var ( + ErrConferenceNotFound = errors.New("conference not found") + ErrScheduleFetch = errors.New("could not fetch schedule") +) + +type service struct { + conferences map[int32]*loadedConference + lock sync.RWMutex + pool *pgxpool.Pool +} + +// TODO: Create a service implementation that persists to DB +// and isn't in memory +func NewService(pool *pgxpool.Pool) (Service, error) { + service := &service{ + pool: pool, + conferences: make(map[int32]*loadedConference), + } + + queries := sqlc.New(pool) + conferences, err := queries.GetConferences(context.Background()) + if err != nil { + return nil, err + } + + for _, conference := range conferences { + c := &loadedConference{ + pentabarfUrl: conference.Url, + lastUpdated: time.Unix(0, 0), + } + service.conferences[conference.ID] = c + } + + return service, nil +} + +func (s *service) CreateConference(url string) (*sqlc.Conference, error) { + s.lock.Lock() + defer s.lock.Unlock() + + c := &loadedConference{ + pentabarfUrl: url, + lastUpdated: time.Unix(0, 0), + } + err := c.updateSchedule() + if err != nil { + return nil, errors.Join(ErrScheduleFetch, err) + } + + queries := sqlc.New(s.pool) + + conference, err := queries.CreateConference(context.Background(), sqlc.CreateConferenceParams{ + Url: url, + Title: pgtype.Text{String: c.schedule.Conference.Title, Valid: true}, + Venue: pgtype.Text{String: c.schedule.Conference.Venue, Valid: true}, + City: pgtype.Text{String: c.schedule.Conference.City, Valid: true}, + }) + if err != nil { + return nil, fmt.Errorf("could not create conference: %w", err) + } + + s.conferences[conference.ID] = c + + return &conference, nil +} + +func (s *service) DeleteConference(id int32) error { + s.lock.Lock() + defer s.lock.Unlock() + + queries := sqlc.New(s.pool) + err := queries.DeleteConference(context.Background(), id) + if err != nil { + return fmt.Errorf("could not delete conference: %w", err) + } + + delete(s.conferences, id) + return nil +} + +func (s *service) GetConferences() ([]sqlc.Conference, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + queries := sqlc.New(s.pool) + return queries.GetConferences(context.Background()) +} + +func (s *service) GetSchedule(id int32) (*Schedule, time.Time, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + c, ok := s.conferences[id] + if !ok { + return nil, time.Time{}, ErrConferenceNotFound + } + + if err := c.updateSchedule(); err != nil { + return nil, time.Time{}, err + } + + c.lock.RLock() + defer c.lock.RUnlock() + + queries := sqlc.New(s.pool) + if _, err := queries.UpdateConferenceDetails(context.Background(), sqlc.UpdateConferenceDetailsParams{ + ID: id, + Title: pgtype.Text{String: c.schedule.Conference.Title, Valid: true}, + Venue: pgtype.Text{String: c.schedule.Conference.Venue, Valid: true}, + City: pgtype.Text{String: c.schedule.Conference.City, Valid: true}, + }); err != nil { + return nil, time.Time{}, fmt.Errorf("failed to update cached conference details: %w", err) + } + + return c.schedule, c.lastUpdated, nil +} + +func (s *service) GetEventByID(conferenceID, eventID int32) (*Event, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + c, ok := s.conferences[conferenceID] + if !ok { + return nil, ErrConferenceNotFound + } + + c.lock.RLock() + defer c.lock.RUnlock() + + event := c.eventsById[eventID] + + return &event, nil +} + +func (c *loadedConference) hasScheduleExpired() bool { + expire := c.lastUpdated.Add(15 * time.Minute) + return time.Now().After(expire) +} + +func (c *loadedConference) updateSchedule() error { + if !c.hasScheduleExpired() { + return nil + } + + if !c.lock.TryLock() { + // don't block if another goroutine is already fetching + return nil + } + defer c.lock.Unlock() + + res, err := http.Get(c.pentabarfUrl) + if err != nil { + return err + } + + reader := bufio.NewReader(res.Body) + + var schedule schedule + + decoder := xml.NewDecoder(reader) + if err := decoder.Decode(&schedule); err != nil { + return fmt.Errorf("failed to decode XML: %w", err) + } + + var newSchedule Schedule + err = newSchedule.Scan(schedule) + if err != nil { + return fmt.Errorf("failed to scan schedule: %w", err) + } + + c.schedule = &newSchedule + c.lastUpdated = time.Now() + + c.eventsById = make(map[int32]Event) + + for _, day := range newSchedule.Days { + for _, room := range day.Rooms { + for _, event := range room.Events { + c.eventsById[event.ID] = event + } + } + } + + return nil +} -- cgit v1.2.3-70-g09d2