aboutsummaryrefslogtreecommitdiffstats
path: root/pkg/conference
diff options
context:
space:
mode:
authorLeonardo Bishop <me@leonardobishop.com>2025-08-23 22:29:28 +0100
committerLeonardo Bishop <me@leonardobishop.com>2025-08-23 22:29:28 +0100
commitecc6a55aba7bb35fc778e7a53848396b88214151 (patch)
tree1b37a2dc5f4594155114da1ae0c4529d20a4c548 /pkg/conference
parent8f7dec8ba6b2f9bde01afd0a110596ebbd43e0ed (diff)
Add multiple conferences feature
Diffstat (limited to 'pkg/conference')
-rw-r--r--pkg/conference/model.go72
-rw-r--r--pkg/conference/parse.go205
-rw-r--r--pkg/conference/service.go218
3 files changed, 495 insertions, 0 deletions
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
+}