package main import ( "context" "encoding/csv" "encoding/json" "fmt" "log" "net/http" "os" "path" "regexp" "strconv" "time" "gopkg.in/yaml.v3" ) type Show struct { Name string `json:"name"` Start time.Time `json:"start"` End *time.Time `json:"end,omitempty"` } type TimeTable struct { Title string `json:"title"` Location string `json:"location"` Shows []Show `json:"shows"` } func (tt TimeTable) findShowAt(when time.Time) (int) { for i, s := range(tt.Shows) { if when.After(s.Start) && (s.End == nil || when.Before(*s.End)) { return i } } return -1 } func (tt TimeTable) ShowAt(when time.Time) (Show, bool) { if idx := tt.findShowAt(when); idx >= 0 { return tt.Shows[idx], true } else { return Show{}, false } } func (tt TimeTable) CurrentShow() (Show, bool) { return tt.ShowAt(time.Now()) } func (tt TimeTable) NextShowAt(when time.Time) (Show, bool) { if idx := tt.findShowAt(when); idx >= 0 && (idx+1) < len(tt.Shows) { return tt.Shows[idx], true } else { return Show{}, false } } func (tt TimeTable) NextShow() (Show, bool) { return tt.NextShowAt(time.Now()) } type TimeTableSource struct { ID string `yaml:"id"` URL string `yaml:"url"` Title string `yaml:"title,omitempty"` Location string `yaml:"location,omitempty"` StartDate string `yaml:"startDate,omitempty"` TimeColumn int `yaml:"timeColumn"` NameColumn int `yaml:"nameColumn"` CountdownPath string `yaml:"countDownPath"` } type Config struct { Listen string `yaml:"listen"` StaticDir string `yaml:"staticdir"` Sources []TimeTableSource `yaml:"sources,omitempty"` } func getCSV(src string, ctx context.Context) ([][]string, error) { req, err := http.NewRequestWithContext(ctx, "GET", src, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if resp.StatusCode > 299 { return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, resp.Status) } ct := resp.Header.Get("Content-Type") if ct != "text/csv" && ct != "" { return nil, fmt.Errorf("Content is not CSV: %s", ct) } return csv.NewReader(resp.Body).ReadAll() } func timeAtHour(t time.Time, h int) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), h, 0, 0, 0, t.Location()) } func (src *TimeTableSource) GetTimetable(ctx context.Context) (TimeTable, error) { records, err := getCSV(src.URL, ctx) if err != nil { return TimeTable{}, err } start, err := time.ParseInLocation(time.DateOnly, src.StartDate, time.Local) if err != nil { return TimeTable{}, err } tt := TimeTable{ Title: src.Title, Location: src.Location, } numColumns := src.TimeColumn + 1 if src.NameColumn > src.TimeColumn { numColumns = src.NameColumn + 1 } timeSlotRe := regexp.MustCompile(`([0-9]+)-([0-9]+)?`) for _, row := range(records) { // Check that we have enought columns if (len(row) < numColumns) { continue } // Parse the time slot, skip if invalid timeSlot := timeSlotRe.FindStringSubmatch(row[src.TimeColumn]) if len(timeSlot) < 2 { continue } startHour, err := strconv.Atoi(timeSlot[1]) if err != nil { continue } start = timeAtHour(start, startHour) end := start if len(timeSlot) > 2 { endHour, err := strconv.Atoi(timeSlot[2]) if err != nil { continue } if endHour < startHour { end = end.Add(time.Hour * 24) } end = timeAtHour(end, endHour) } show := Show{ Name: row[src.NameColumn], Start: start, } if end != start { show.End = &end } if show.Name != "" { tt.Shows = append(tt.Shows, show) } start = end } return tt, nil } func (src TimeTableSource) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Early validity check switch when := r.PathValue("when"); when { case "": case "now": case "next": case "first": default: http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) } ctx, cancel := context.WithTimeout(r.Context(), time.Second*10) defer cancel() tt, err := src.GetTimetable(ctx) if err != nil { log.Printf("Failed to get timetable: %s", err) http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) return } var data any found := true switch when := r.PathValue("when"); when { case "": data = tt case "now": data, found = tt.CurrentShow() case "next": data, found = tt.NextShow() case "first": if len(tt.Shows) > 0 { data = tt.Shows[0] } else { found = false } default: http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") // Send an empty object if nothing was found if !found { data = make(map[string]string) } json.NewEncoder(w).Encode(data) } func LoadConfig(path string) (Config, error) { rd, err := os.Open(path) if err != nil { return Config{}, fmt.Errorf("Failed to open config: %w", err) } defer rd.Close() var cfg Config err = yaml.NewDecoder(rd).Decode(&cfg) if err != nil { return Config{}, fmt.Errorf("Failed to parse config: %w", err) } return cfg, nil } func main() { cfgFile := "timetables.yaml" cfg, err := LoadConfig(cfgFile) if err != nil { log.Fatal(err) } staticDir := cfg.StaticDir if staticDir == "" { staticDir = "static" } http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))) for _, src := range(cfg.Sources) { if src.ID == "" || src.URL == "" { log.Printf("Skipping invalid source: %s\n", src) continue } log.Printf("Registering timetable %s\n", src.ID) if src.CountdownPath == "" { src.CountdownPath = "countdown.html" } http.Handle(fmt.Sprintf("/timetable/%s", src.ID), src) http.Handle(fmt.Sprintf("/timetable/%s/{when}", src.ID), src) http.HandleFunc(fmt.Sprintf("/countdown/%s", src.ID), func (w http.ResponseWriter, req *http.Request) { http.ServeFile(w, req, path.Join(staticDir, src.CountdownPath)) }) } listen := cfg.Listen if listen == "" { listen = ":8080" } log.Fatal(http.ListenAndServe(listen, nil)) }