Author: Adam Evyčędo <git@apiote.xyz>
respond with FeedInfo v2
api/feedsResponse.go | 86 +++++++++++++++++++++ api/structs_gen.go | 46 +++++++++++ server/handler_vars.go | 117 +++++++++++++++++++++++++++++ server/parsers.go | 80 ++++++++++++++++++++ server/route_feeds.go | 76 +++++++++++++++++++ server/router.go | 167 ++--------------------------------------- traffic/berlin_vbb.go | 15 +-- traffic/convert.go | 129 ++++++++++++++++++++----------- traffic/date.go | 33 ++++++++ traffic/errors/errors.go | 8 ++ traffic/feed_info.go | 65 ++++++++++++++++ traffic/feeds.go | 24 ----- traffic/gzm_ztm.go | 4 + traffic/krakow_ztp.go | 4 + traffic/poznan_ztm.go | 4 + traffic/structs_gen.go | 36 ++++++++ traffic/timetable.go | 16 ++++
diff --git a/api/feedsResponse.go b/api/feedsResponse.go new file mode 100644 index 0000000000000000000000000000000000000000..39ce5c11ad98025af3afd62003b994069329f88d --- /dev/null +++ b/api/feedsResponse.go @@ -0,0 +1,86 @@ +package api + +import ( + "apiote.xyz/p/szczanieckiej/traffic" + + "time" + + "golang.org/x/text/language" +) + +type AcceptVersionError struct{} + +func (e AcceptVersionError) Error() string { + return "" +} + +var AcceptError AcceptVersionError + +func selectLanguage(descriptions, attributions map[string]string, preferredLanguages []language.Tag) (language.Tag, language.Tag) { + descriptionTags := []language.Tag{} + for t := range descriptions { + descriptionTags = append(descriptionTags, language.MustParse(t)) // NOTE tag is internally stringified, must parse + } + attributionTags := []language.Tag{} + for t := range attributions { + attributionTags = append(attributionTags, language.MustParse(t)) // NOTE tag is internally stringified, must parse + } + + matcher := language.NewMatcher(preferredLanguages) + _, index, _ := matcher.Match(descriptionTags...) + descriptionTag := preferredLanguages[index] + _, index, _ = matcher.Match(attributionTags...) + attributionTag := preferredLanguages[index] + + return descriptionTag, attributionTag +} + +func MakeFeedsResponse(feedInfos map[string]traffic.FeedInfo, lastUpdates map[string]time.Time, accept uint, preferredLanguages []language.Tag) (FeedsResponse, error) { + switch accept { + case 0: + response := FeedsResponseDev{ + Feeds: []FeedInfoV2{}, + } + for id, feedInfo := range feedInfos { + response.Feeds = append(response.Feeds, makeFeedInfoV2(id, feedInfo, lastUpdates[id], preferredLanguages)) + } + return response, nil + case 1: + response := FeedsResponseV1{ + Feeds: []FeedInfoV1{}, + } + for id, feedInfo := range feedInfos { + response.Feeds = append(response.Feeds, makeFeedInfoV1(id, feedInfo, lastUpdates[id], preferredLanguages)) + } + return response, nil + default: + return FeedsResponseDev{}, AcceptError + } +} + +func makeFeedInfoV2(id string, feedInfo traffic.FeedInfo, lastUpdate time.Time, preferredLanguages []language.Tag) FeedInfoV2 { + descriptionTag, attributionTag := selectLanguage(feedInfo.Descriptions, feedInfo.Attributions, preferredLanguages) + return FeedInfoV2{ + Name: feedInfo.Name, + Id: id, + Attribution: feedInfo.Attributions[attributionTag.String()], + Description: feedInfo.Descriptions[descriptionTag.String()], + LastUpdate: lastUpdate.Format(traffic.DateFormat), + QrHost: feedInfo.QrHost, + QrIn: QRLocationV1(feedInfo.QrLocation), + QrSelector: feedInfo.QrSelector, + ValidSince: feedInfo.ValidSince, + ValidTill: feedInfo.ValidTill, + } +} + +func makeFeedInfoV1(id string, feedInfo traffic.FeedInfo, lastUpdate time.Time, preferredLanguages []language.Tag) FeedInfoV1 { + descriptionTag, attributionTag := selectLanguage(feedInfo.Descriptions, feedInfo.Attributions, preferredLanguages) + return FeedInfoV1{ + Name: feedInfo.Name, + Id: id, + Attribution: feedInfo.Attributions[attributionTag.String()], + Description: feedInfo.Descriptions[descriptionTag.String()], + LastUpdate: lastUpdate.Format(traffic.DateFormat), + } +} diff --git a/api/structs_gen.go b/api/structs_gen.go index ed9bb898e15e05874302e98b911d8afea5674d03..783f116ec9e066b41e619c57de0c73a71dabe25d 100644 --- a/api/structs_gen.go +++ b/api/structs_gen.go @@ -32,7 +32,7 @@ return bare.Marshal(t) } type FeedsResponseDev struct { - Feeds []FeedInfoV1 `bare:"feeds"` + Feeds []FeedInfoV2 `bare:"feeds"` } func (t *FeedsResponseDev) Decode(data []byte) error { @@ -68,6 +68,27 @@ return bare.Unmarshal(data, t) } func (t *FeedInfoV1) Encode() ([]byte, error) { + return bare.Marshal(t) +} + +type FeedInfoV2 struct { + Name string `bare:"name"` + Id string `bare:"id"` + Attribution string `bare:"attribution"` + Description string `bare:"description"` + LastUpdate string `bare:"lastUpdate"` + QrHost string `bare:"qrHost"` + QrIn QRLocationV1 `bare:"qrIn"` + QrSelector string `bare:"qrSelector"` + ValidSince string `bare:"validSince"` + ValidTill string `bare:"validTill"` +} + +func (t *FeedInfoV2) Decode(data []byte) error { + return bare.Unmarshal(data, t) +} + +func (t *FeedInfoV2) Encode() ([]byte, error) { return bare.Marshal(t) } @@ -515,6 +536,29 @@ } func (t *ErrorResponse) Encode() ([]byte, error) { return bare.Marshal(t) +} + +type QRLocationV1 uint + +const ( + UNKNOWN QRLocationV1 = 0 + NONE QRLocationV1 = 1 + PATH QRLocationV1 = 2 + QUERY QRLocationV1 = 3 +) + +func (t QRLocationV1) String() string { + switch t { + case UNKNOWN: + return "UNKNOWN" + case NONE: + return "NONE" + case PATH: + return "PATH" + case QUERY: + return "QUERY" + } + panic(errors.New("Invalid QRLocationV1 value")) } type CongestionLevelV1 uint diff --git a/server/handler_vars.go b/server/handler_vars.go new file mode 100644 index 0000000000000000000000000000000000000000..5217d2e23aced3b92b3a81ceec7feb303ce17870 --- /dev/null +++ b/server/handler_vars.go @@ -0,0 +1,117 @@ +package server + +import ( + "apiote.xyz/p/szczanieckiej/config" + "apiote.xyz/p/szczanieckiej/traffic" + + "net/http" + + "git.sr.ht/~sircmpwn/go-bare" + "golang.org/x/text/language" +) + +type AbstractHandlerVars interface { + getWriter() http.ResponseWriter + getRequest() *http.Request + getTraffic() *traffic.Traffic + getConfig() config.Config + getAccept() uint + + getAcceptLanguage() string + setAcceptLanguage(l string) + getPreferredLanguages() []language.Tag + setPreferredLanguages(t []language.Tag) + + getResponse() any + setResponse(r any) + getResponseBytes() []byte + setResponseBytes(b []byte) +} + +type HandlerVars struct { + w http.ResponseWriter + r *http.Request + t *traffic.Traffic + c config.Config + a uint + + acceptLanguage string + preferredLanguages []language.Tag + + response any + responseBytes []byte +} + +func (v HandlerVars) getWriter() http.ResponseWriter { + return v.w +} +func (v HandlerVars) getRequest() *http.Request { + return v.r +} +func (v HandlerVars) getTraffic() *traffic.Traffic { + return v.t +} +func (v HandlerVars) getConfig() config.Config { + return v.c +} +func (v HandlerVars) getAccept() uint { + return v.a +} +func (v HandlerVars) getAcceptLanguage() string { + return v.acceptLanguage +} +func (v *HandlerVars) setAcceptLanguage(l string) { + v.acceptLanguage = l +} +func (v HandlerVars) getPreferredLanguages() []language.Tag { + return v.preferredLanguages +} +func (v *HandlerVars) setPreferredLanguages(t []language.Tag) { + v.preferredLanguages = t +} +func (v HandlerVars) getResponse() any { + return v.response +} +func (v *HandlerVars) setResponse(r any) { + v.response = r +} +func (v HandlerVars) getResponseBytes() []byte { + return v.responseBytes +} +func (v *HandlerVars) setResponseBytes(b []byte) { + v.responseBytes = b +} + +func getAcceptLanguage(v AbstractHandlerVars) AbstractHandlerVars { + v.setAcceptLanguage(v.getRequest().Header.Get("Accept-Language")) + if v.getAcceptLanguage() == "" { + v.setAcceptLanguage("und") + } + return v +} + +func parseAcceptLanguage(v AbstractHandlerVars) (AbstractHandlerVars, error) { + preferredLanguages, _, err := language.ParseAcceptLanguage(v.getAcceptLanguage()) + if err != nil { + return v, ServerError{ + code: http.StatusBadRequest, + field: "Accept-Language", + value: v.getAcceptLanguage(), + err: err, + } + } + v.setPreferredLanguages(preferredLanguages) + return v, nil +} + +func marshalResponse(v AbstractHandlerVars) (AbstractHandlerVars, error) { + r := v.getResponse() + bytes, err := bare.Marshal(&r) + v.setResponseBytes(bytes) + return v, err +} + +func writeResponse(v AbstractHandlerVars) error { + _, err := v.getWriter().Write(v.getResponseBytes()) + return err +} diff --git a/server/parsers.go b/server/parsers.go new file mode 100644 index 0000000000000000000000000000000000000000..6e69d233af51b79db7dcbd0f10ce560ef641f0e7 --- /dev/null +++ b/server/parsers.go @@ -0,0 +1,80 @@ +package server + +import ( + "apiote.xyz/p/szczanieckiej/traffic" + traffic_errors "apiote.xyz/p/szczanieckiej/traffic/errors" + + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +func parseAccept(headers []string) (uint, error) { + accept := -1 + for _, header := range headers { + if header == "" { + header = "EMPTY" + } + header, _ := strings.CutPrefix(header, "application/") + header, _ = strings.CutSuffix(header, "+bare") + a, err := strconv.ParseUint(header, 10, 0) + if err != nil { + continue + } + + if a == 0 { + return 0, nil + } + if int(a) > accept { + accept = int(a) + } + } + if accept == -1 { + return 0, ServerError{ + code: http.StatusBadRequest, + field: "Accept", + value: "", + } + } + return uint(accept), nil +} + +func parseDate(dateString, feedName string, t *traffic.Traffic) (traffic.Validity, time.Time, error) { + versionCode, date, err := traffic.ParseDate(dateString, feedName, t) + if err != nil { + if _, ok := err.(traffic_errors.NoVersionError); ok { + return versionCode, date, ServerError{ + code: http.StatusNotFound, + field: "date", + value: dateString, + err: err, + } + } else { + return versionCode, date, ServerError{ + code: http.StatusBadRequest, + field: "date", + value: dateString, + err: err, + } + } + } + return versionCode, date, nil +} + +func parsePosition(location string) (traffic.Position, error) { + locationString := strings.Split(location, ",") + if len(locationString) != 2 { + return traffic.Position{}, fmt.Errorf("location is not two numbers") + } + lat, err := strconv.ParseFloat(locationString[0], 64) + if err != nil { + return traffic.Position{}, fmt.Errorf("latitude is not a float") + } + lon, err := strconv.ParseFloat(locationString[1], 64) + if err != nil { + return traffic.Position{}, fmt.Errorf("longitude is not a float") + } + return traffic.Position{Lat: lat, Lon: lon}, nil +} diff --git a/server/route_feeds.go b/server/route_feeds.go new file mode 100644 index 0000000000000000000000000000000000000000..0ee314d2f7d79762e79edfd2aa379ed1609239a9 --- /dev/null +++ b/server/route_feeds.go @@ -0,0 +1,76 @@ +package server + +import ( + "apiote.xyz/p/szczanieckiej/api" + "apiote.xyz/p/szczanieckiej/config" + "apiote.xyz/p/szczanieckiej/traffic" + + "errors" + "net/http" + "time" + + "apiote.xyz/p/gott/v2" + "git.sr.ht/~sircmpwn/go-bare" +) + +type FeedsHandlerVars struct { + HandlerVars + feedInfos map[string]traffic.FeedInfo + lastUpdates map[string]time.Time +} + +func getFeedInfos(v AbstractHandlerVars) (AbstractHandlerVars, error) { + vv := v.(*FeedsHandlerVars) + + feedInfos, lastUpdates, err := traffic.GetFeedInfos(v.getTraffic(), v.getConfig()) + + if err != nil && errors.Is(err, api.AcceptError) { + err = ServerError{ + code: http.StatusNotAcceptable, + } + } + + vv.feedInfos = feedInfos + vv.lastUpdates = lastUpdates + return vv, err +} + +func makeFeedsResponse(v AbstractHandlerVars) (AbstractHandlerVars, error) { + vv := v.(*FeedsHandlerVars) + + response, err := api.MakeFeedsResponse(vv.feedInfos, vv.lastUpdates, v.getAccept(), v.getPreferredLanguages()) + v.setResponse(response) + + return vv, err +} + +func marshalFeedsResponse(v AbstractHandlerVars) (AbstractHandlerVars, error) { + r := v.getResponse().(api.FeedsResponse) + bytes, err := bare.Marshal(&r) + v.setResponseBytes(bytes) + return v, err +} + +func handleFeeds(w http.ResponseWriter, r *http.Request, cfg config.Config, t *traffic.Traffic, accept uint) error { + handlerVars := &FeedsHandlerVars{ + HandlerVars: HandlerVars{ + w: w, + r: r, + t: t, + c: cfg, + a: accept, + }, + } + result := gott.R[AbstractHandlerVars]{ + S: handlerVars, + } + result = result. + Map(getAcceptLanguage). + Bind(parseAcceptLanguage). + Bind(getFeedInfos). + Bind(makeFeedsResponse). + Bind(marshalFeedsResponse). + Tee(writeResponse) + + return result.E +} diff --git a/server/router.go b/server/router.go index 69fb052922a1d6c99b0d2f7d8b38d413774f9379..3ed7415b0f6e113dcac42db9e008c5f8c1ddf69d 100644 --- a/server/router.go +++ b/server/router.go @@ -8,12 +8,12 @@ traffic_errors "apiote.xyz/p/szczanieckiej/traffic/errors" "errors" "fmt" + "io" "log" "net/http" "os" "strconv" "strings" - "time" "golang.org/x/text/language" @@ -49,142 +49,23 @@ } return message } -func parseDate(dateString string, feedName string, t *traffic.Traffic) (traffic.Validity, time.Time, error) { - versionCode := traffic.Validity("") - if dateString == "" { - feedNow := time.Now().In(t.Feeds[feedName].GetLocation()) - dateString = feedNow.Format(traffic.DateFormat) - } - date, err := time.ParseInLocation(traffic.DateFormat, dateString, t.Feeds[feedName].GetLocation()) - if err != nil { - return versionCode, date, ServerError{ - code: http.StatusBadRequest, - field: "date", - value: dateString, - } - } - for _, v := range t.Versions[feedName] { - if !v.ValidFrom.After(date) && !date.After(v.ValidTill) { - versionCode = traffic.Validity(v.String()) - } - } - - if versionCode == "" { - return versionCode, date, ServerError{ - code: http.StatusNotFound, - field: "date", - value: dateString, - } - } - return versionCode, date, nil -} - -func parsePosition(location string) (traffic.Position, error) { - locationString := strings.Split(location, ",") - if len(locationString) != 2 { - return traffic.Position{}, fmt.Errorf("location is not two numbers") - } - lat, err := strconv.ParseFloat(locationString[0], 64) - if err != nil { - return traffic.Position{}, fmt.Errorf("latitude is not a float") - } - lon, err := strconv.ParseFloat(locationString[1], 64) - if err != nil { - return traffic.Position{}, fmt.Errorf("longitude is not a float") - } - return traffic.Position{Lat: lat, Lon: lon}, nil -} - -func handleRoot(w http.ResponseWriter, r *http.Request, t *traffic.Traffic, cfg config.Config, accept uint) error { - if accept > 2 { +func handleFeed(w http.ResponseWriter, r *http.Request, feedID string, cfg config.Config, t *traffic.Traffic, accept uint) error { + if accept != 3 { return ServerError{ code: http.StatusNotAcceptable, } } - var success api.FeedsResponse - switch accept { - case 0: - success = api.FeedsResponseDev{} - case 1: - success = api.FeedsResponseV1{} - default: - return ServerError{ - code: http.StatusNotAcceptable, - } - } - acceptLanguage := r.Header.Get("Accept-Language") - if acceptLanguage == "" { - acceptLanguage = "und" - } - preferredLanguages, _, err := language.ParseAcceptLanguage(acceptLanguage) - if err != nil { - return ServerError{ - code: http.StatusBadRequest, - field: "Accept-Language", - value: acceptLanguage, - err: err, - } - } - for id, feed := range t.Feeds { - lastUpdate, err := traffic.LastUpdate(cfg.FeedsPath, feed.String()) - if err != nil { - return fmt.Errorf("while getting last update for %s: %w", id, err) - } - trafficDescription := feed.Description() - descriptionTags := []language.Tag{} - for t := range trafficDescription { - descriptionTags = append(descriptionTags, t) - } - trafficAttribution := feed.Attribution() - attributionTags := []language.Tag{} - for t := range trafficAttribution { - attributionTags = append(attributionTags, t) - } - matcher := language.NewMatcher(preferredLanguages) - _, index, _ := matcher.Match(descriptionTags...) - descriptionTag := preferredLanguages[index] - _, index, _ = matcher.Match(attributionTags...) - attributionTag := preferredLanguages[index] - - f := api.FeedInfoV1{ - Name: feed.Name(), - Id: id, - Attribution: trafficAttribution[attributionTag], - Description: trafficDescription[descriptionTag], - LastUpdate: lastUpdate.Format(time.RFC3339), - } - - switch accept { - case 0: - s := success.(api.FeedsResponseDev) - s.Feeds = append(s.Feeds, f) - success = s - case 1: - s := success.(api.FeedsResponseV1) - s.Feeds = append(s.Feeds, f) - success = s - } - } - var response api.FeedsResponse = success - bytes, err := bare.Marshal(&response) + timetable, err := traffic.GetTimetable(feedID, t, cfg) + defer timetable.Close() if err != nil { - return fmt.Errorf("while marshaling: %w", err) + return fmt.Errorf("while getting timetable: %w", err) } - _, err = w.Write(bytes) + _, err = io.Copy(w, timetable) if err != nil { return fmt.Errorf("while writing: %w", err) } return nil -} - -func handleFeed(w http.ResponseWriter, r *http.Request, feedName string, accept uint) error { - return ServerError{ - code: http.StatusNotFound, - field: "resource", - value: "feed", - } - // todo(BAF17) send feed } func handleLocatables(w http.ResponseWriter, r *http.Request, feedNames []string, cfg config.Config, t *traffic.Traffic, accept uint) error { @@ -703,36 +584,6 @@ w.WriteHeader(se.code) w.Write(b) } -func parseAccept(headers []string) (uint, error) { - accept := -1 - for _, header := range headers { - if header == "" { - header = "EMPTY" - } - header, _ := strings.CutPrefix(header, "application/") - header, _ = strings.CutSuffix(header, "+bare") - a, err := strconv.ParseUint(header, 10, 0) - if err != nil { - continue - } - - if a == 0 { - return 0, nil - } - if int(a) > accept { - accept = int(a) - } - } - if accept == -1 { - return 0, ServerError{ - code: http.StatusBadRequest, - field: "Accept", - value: "", - } - } - return uint(accept), nil -} - func Route(cfg config.Config, traffic *traffic.Traffic) *http.Server { srv := &http.Server{Addr: cfg.ListenAddress} @@ -743,7 +594,7 @@ sendError(w, r, fmt.Errorf("while parsing accept: %w", err)) return } if r.URL.Path[1:] == "" { - err = handleRoot(w, r, traffic, cfg, accept) + err = handleFeeds(w, r, cfg, traffic, accept) } else { path := strings.Split(r.URL.Path[1:], "/") feedNames := strings.Split(path[0], ",") @@ -766,7 +617,7 @@ field: "feed", value: path[0], } } else { - err = handleFeed(w, r, feedNames[0], accept) + err = handleFeed(w, r, feedNames[0], cfg, traffic, accept) } } else { resource := path[1] diff --git a/traffic/berlin_vbb.go b/traffic/berlin_vbb.go index 24090b69fc818cf5aac3a6eef86392bff72fd2df..5729110453f2ccb39f57b4bc6166d00701a21a13 100644 --- a/traffic/berlin_vbb.go +++ b/traffic/berlin_vbb.go @@ -115,7 +115,6 @@ err = w.Write(header) if err != nil { return fmt.Errorf("while writing stops header: %w", err) } - fmt.Println("header", fields) for { record, err := r.Read() if err == io.EOF { @@ -125,21 +124,13 @@ if err != nil { return fmt.Errorf("while reading a stop record: %w", err) } f, ok := fields["location_type"] - fmt.Println("FIX stop:", f, ok, "Platform:", record[fields["platform_code"]]) - if ok { - fmt.Println(record[f]) - } if (!ok || record[f] == "" || record[f] == "0") && record[fields["platform_code"]] == "" { - fmt.Println(fields["stop_id"], record[fields["stop_id"]]) stopID := strings.Split(record[fields["stop_id"]], ":") - fmt.Println("So fixing", len(stopID), stopID) if len(stopID) >= 5 { record[fields["platform_code"]] = stopID[4] } } - fmt.Println("write:", record) err = w.Write(record) - fmt.Println("NEXT") if err != nil { return fmt.Errorf("while writing a stop record: %w", err) } @@ -192,7 +183,7 @@ if record[fields["route_type"]] == "100" { record[fields["route_type"]] = "2" } if record[fields["route_type"]] == "109" { - record[fields["route_type"]] = "0" + record[fields["route_type"]] = "1" } if record[fields["route_type"]] == "400" { record[fields["route_type"]] = "1" @@ -222,3 +213,7 @@ return fmt.Errorf("while renaming routes: %w", err) } return nil } + +func (z VbbBerlin) QRInfo() (string, QRLocation, string) { + return "qr.bvg.de", QRLocationPath, "/(?<SEL>.*)" +} diff --git a/traffic/convert.go b/traffic/convert.go index 9d037ca66f577b5d0875fc32ea8f5fedaed521ec..a3c9d3c4fb3e220496bfc8a7ac2314321bfcd226 100644 --- a/traffic/convert.go +++ b/traffic/convert.go @@ -1,11 +1,11 @@ package traffic -// todo(BAF10) direction (0|1) to const (TO|BACK) -// fixme converting lines takes much time (graphs) -// todo Agency.language, FeedInfo.language -> IETF language tag -// todo Agency.phoneNumber -> E.123 format -// todo remove StopOrder.tripID -// todo validity to FeedInfo, not filename +// TODO(BAF10) direction (0|1) to const (TO|BACK) +// FIXME converting lines takes much time (graphs); each trip opens trips.bare +// TODO Agency.language, FeedInfo.language -> IETF language tag +// TODO Agency.phoneNumber -> E.123 format +// TODO remove StopOrder.tripID +// TODO validity to FeedInfo, not filename // FIXME LineName is not unique @@ -82,6 +82,7 @@ ValidTill time.Time ValidTillError []error tripHeadsigns map[string]string stopNames map[string]string + feedInfo FeedInfo } // helper functions @@ -283,7 +284,7 @@ } return c, e } -func convertCalendar(c feedConverter) (feedConverter, error) { +func convertCalendar(c feedConverter) (feedConverter, error) { // ( feedInfo -- >> calendar) path := c.TmpFeedPath resultFile := c.TrafficCalendarFile calendarFile, err := os.Open(filepath.Join(path, "calendar.txt")) @@ -353,6 +354,7 @@ c.ValidFromError = append(c.ValidFromError, err) } if err == nil && (c.ValidFrom.IsZero() || scheduleStart.Before(c.ValidFrom)) { c.ValidFrom = scheduleStart + c.feedInfo.ValidSince = scheduleStart.Format("20060102") } scheduleEnd, err := time.ParseInLocation("2006-01-02", schedule.EndDate, c.Feed.GetLocation()) @@ -361,6 +363,7 @@ c.ValidTillError = append(c.ValidTillError, err) } if err == nil && (c.ValidTill.IsZero() || scheduleEnd.After(c.ValidTill)) { c.ValidTill = scheduleEnd + c.feedInfo.ValidTill = scheduleEnd.Format("20060102") } } return c, nil @@ -418,6 +421,7 @@ c.ValidFromError = append(c.ValidFromError, err) } if err == nil && (c.ValidFrom.IsZero() || scheduleStart.Before(c.ValidFrom)) { c.ValidFrom = scheduleStart + c.feedInfo.ValidSince = scheduleStart.Format("20060102") } scheduleEnd, err := time.ParseInLocation("2006-01-02", schedule.EndDate, c.Feed.GetLocation()) @@ -426,9 +430,30 @@ c.ValidTillError = append(c.ValidTillError, err) } if err == nil && (c.ValidTill.IsZero() || scheduleEnd.After(c.ValidTill)) { c.ValidTill = scheduleEnd + c.feedInfo.ValidTill = scheduleEnd.Format("20060102") } } return c, nil +} + +func saveFeedInfo(c feedConverter) error { + path := c.TmpFeedPath + result, err := os.Create(filepath.Join(path, "feed_info.bare")) + if err != nil { + return fmt.Errorf("while creating file: %w", err) + } + defer result.Close() + + bytes, err := bare.Marshal(&c.feedInfo) + if err != nil { + return fmt.Errorf("while marshalling: %w", err) + } + _, err = result.Write(bytes) + if err != nil { + return fmt.Errorf("while writing: %w", err) + } + + return nil } func checkAnyCalendarConverted(c feedConverter) error { @@ -1065,65 +1090,74 @@ c.LineIndex = index return c, nil } -func convertFeedInfo(c feedConverter) (feedConverter, error) { // O(1:feed_info) ; ( -- >> feed_info) +func convertFeedInfo(c feedConverter) (feedConverter, error) { // O(1:feed_info) ; ( -- feed_info >> ) path := c.TmpFeedPath - result, err := os.Create(filepath.Join(path, "feed_info.bare")) - if err != nil { - return c, fmt.Errorf("while creating file: %w", err) - } - defer result.Close() + feedInfo := FeedInfo{} file, err := os.Open(filepath.Join(path, "feed_info.txt")) if err != nil { if errors.Is(err, fs.ErrNotExist) { log.Println("[WARN] no feed_info.txt") - return c, nil + file = nil + } else { + return c, fmt.Errorf("while opening file: %w", err) } - return c, fmt.Errorf("while opening file: %w", err) } - defer file.Close() - r := csv.NewReader(bufio.NewReader(file)) - header, err := r.Read() - if err != nil { - return c, fmt.Errorf("while reading header: %w", err) - } - fields := map[string]int{} - for i, headerField := range header { - fields[headerField] = i - } + if file != nil { + defer file.Close() - feedInfo := FeedInfo{} - record, err := r.Read() - if err != nil { - return c, fmt.Errorf("while reading a record: %w", err) - } + r := csv.NewReader(bufio.NewReader(file)) + header, err := r.Read() + if err != nil { + return c, fmt.Errorf("while reading header: %w", err) + } + fields := map[string]int{} + for i, headerField := range header { + fields[headerField] = i + } - feedInfo.Name = record[fields["feed_publisher_name"]] - feedInfo.Website = record[fields["feed_publisher_url"]] - feedInfo.Language = record[fields["feed_lang"]] - if ix, ok := fields["feed_start_date"]; ok { - c.ValidFrom, err = time.ParseInLocation("20060102", record[ix], c.Feed.GetLocation()) + record, err := r.Read() if err != nil { - c.ValidFromError = append(c.ValidFromError, err) + return c, fmt.Errorf("while reading a record: %w", err) + } + feedInfo.Name = record[fields["feed_publisher_name"]] + feedInfo.Website = record[fields["feed_publisher_url"]] + feedInfo.Language = record[fields["feed_lang"]] + + if ix, ok := fields["feed_start_date"]; ok { + c.ValidFrom, err = time.ParseInLocation("20060102", record[ix], c.Feed.GetLocation()) + if err != nil { + c.ValidFromError = append(c.ValidFromError, err) + } + feedInfo.ValidSince = record[ix] } - } - if ix, ok := fields["feed_end_date"]; ok { - c.ValidTill, err = time.ParseInLocation("20060102", record[ix], c.Feed.GetLocation()) - if err != nil { - c.ValidTillError = append(c.ValidTillError, err) + if ix, ok := fields["feed_end_date"]; ok { + c.ValidTill, err = time.ParseInLocation("20060102", record[ix], c.Feed.GetLocation()) + if err != nil { + c.ValidTillError = append(c.ValidTillError, err) + } + feedInfo.ValidTill = record[ix] } } - bytes, err := bare.Marshal(&feedInfo) - if err != nil { - return c, fmt.Errorf("while marshalling: %w", err) + feedInfo.QrHost, feedInfo.QrLocation, feedInfo.QrSelector = c.Feed.QRInfo() + + feedInfo.Attributions = map[string]string{} + for lng, attr := range c.Feed.Attribution() { + feedInfo.Attributions[lng.String()] = attr } - _, err = result.Write(bytes) - if err != nil { - return c, fmt.Errorf("while writing: %w", err) + feedInfo.Descriptions = map[string]string{} + for lng, attr := range c.Feed.Description() { + feedInfo.Descriptions[lng.String()] = attr + } + + if feedInfo.Name == "" { + feedInfo.Name = c.Feed.Name() } + + c.feedInfo = feedInfo return c, nil } @@ -1306,6 +1340,7 @@ Recover(recoverCalendar). Bind(convertCalendarDates). Recover(recoverCalendar). Tee(checkAnyCalendarConverted). + Tee(saveFeedInfo). Recover(closeTrafficCalendarFile). Bind(convertDepartures). Bind(getLineNames). diff --git a/traffic/date.go b/traffic/date.go new file mode 100644 index 0000000000000000000000000000000000000000..c01af8437fd40d1453c4d26492aeb077be595095 --- /dev/null +++ b/traffic/date.go @@ -0,0 +1,33 @@ +package traffic + +import ( + "fmt" + + traffic_errors "apiote.xyz/p/szczanieckiej/traffic/errors" + + "time" +) + +func ParseDate(dateString string, feedName string, t *Traffic) (Validity, time.Time, error) { + versionCode := Validity("") + if dateString == "" { + feedNow := time.Now().In(t.Feeds[feedName].GetLocation()) + dateString = feedNow.Format(DateFormat) + } + date, err := time.ParseInLocation(DateFormat, dateString, t.Feeds[feedName].GetLocation()) + if err != nil { + return versionCode, date, fmt.Errorf("while parsing date: %w", err) + } + for _, v := range t.Versions[feedName] { + if !v.ValidFrom.After(date) && !date.After(v.ValidTill) { + versionCode = Validity(v.String()) + } + } + + if versionCode == "" { + return versionCode, date, traffic_errors.NoVersionError{ + Date: dateString, + } + } + return versionCode, date, nil +} diff --git a/traffic/errors/errors.go b/traffic/errors/errors.go index df7d2bf988cbec74308b260133a3c9d706bc9b4f..1cda2ca506e764794a69c0f6b6a37fafdf222581 100644 --- a/traffic/errors/errors.go +++ b/traffic/errors/errors.go @@ -28,3 +28,11 @@ func (v VersionError) Error() string { return v.Msg } + +type NoVersionError struct { + Date string +} + +func (e NoVersionError) Error() string { + return "No version for " + e.Date +} diff --git a/traffic/feed_info.go b/traffic/feed_info.go new file mode 100644 index 0000000000000000000000000000000000000000..b457feeb854f6b22e6d8c810b91561b504886912 --- /dev/null +++ b/traffic/feed_info.go @@ -0,0 +1,65 @@ +package traffic + +import ( + "log" + + "apiote.xyz/p/szczanieckiej/config" + + "fmt" + "os" + "path/filepath" + "time" + + "git.sr.ht/~sircmpwn/go-bare" +) + +func getLastFeedsUpdated(feedsPath string) (map[string]time.Time, error) { + lastUpdates := map[string]time.Time{} + + updatesFilename := filepath.Join(feedsPath, "updated.bare") + updatesFile, err := os.Open(updatesFilename) + if err != nil { + return lastUpdates, fmt.Errorf("while opening updates file: %w", err) + } + defer updatesFile.Close() + + var lastUpdated map[string]string + err = bare.UnmarshalReader(updatesFile, &lastUpdated) + if err != nil { + return lastUpdates, fmt.Errorf("while unmarshaling updates file: %w", err) + } + + for feedID, st := range lastUpdated { + t, err := time.Parse(time.RFC3339, st) + if err != nil { + return lastUpdates, fmt.Errorf("while parsing update time for %s: %w", feedID, err) + } + lastUpdates[feedID] = t + } + return lastUpdates, nil +} + +func GetFeedInfos(t *Traffic, cfg config.Config) (map[string]FeedInfo, map[string]time.Time, error) { + feedInfos := map[string]FeedInfo{} + lastUpdates, err := getLastFeedsUpdated(cfg.FeedsPath) + if err != nil { + return feedInfos, lastUpdates, fmt.Errorf("while getting last update dates: %w", err) + } + + for id := range t.Feeds { + versionCode, _, err := ParseDate("", id, t) + if err != nil { + log.Printf("while parsing date for %s: %v", id, err) + continue + } + + feedInfo, err := getFeedInfo(cfg.FeedsPath, id, versionCode, t) + if err != nil { + log.Printf("while getting feed info for %s: %v", id, err) + continue + } + + feedInfos[id] = feedInfo + } + return feedInfos, lastUpdates, nil +} diff --git a/traffic/feeds.go b/traffic/feeds.go index 0c2ba66c8c8ba7616414af236adf2b317cc44556..2a7381f6e91aa3557cb1156d8828262b3de9f34d 100644 --- a/traffic/feeds.go +++ b/traffic/feeds.go @@ -6,37 +6,14 @@ "apiote.xyz/p/szczanieckiej/file" "fmt" "net/http" - "os" "path/filepath" "strings" "time" "golang.org/x/text/language" "golang.org/x/text/transform" - - "git.sr.ht/~sircmpwn/go-bare" ) -func LastUpdate(feedsPath, feedID string) (time.Time, error) { - updatesFilename := filepath.Join(feedsPath, "updated.bare") - updatesFile, err := os.Open(updatesFilename) - if err != nil { - return time.Time{}, fmt.Errorf("while opening updates file: %w", err) - } - defer updatesFile.Close() - - var lastUpdated map[string]string - err = bare.UnmarshalReader(updatesFile, &lastUpdated) - if err != nil { - return time.Time{}, fmt.Errorf("while unmarshaling updates file: %w", err) - } - t, err := time.Parse(time.RFC3339, lastUpdated[feedID]) - if err != nil { - return time.Time{}, fmt.Errorf("while parsing update time: %w", err) - } - return t, nil -} - type Feed interface { fmt.Stringer ConvertVehicles(string) error @@ -49,6 +26,7 @@ Attribution() map[language.Tag]string // SHOULD have und tag Description() map[language.Tag]string // SHOULD have und tag Flags() FeedFlags FeedPrepareZip(string) error + QRInfo() (string, QRLocation, string) } type HeadsignSource uint diff --git a/traffic/gzm_ztm.go b/traffic/gzm_ztm.go index a29b5962030e8bcd5019d4183373d4f2bd5934ae..b95bd0c2433006b64a8125771bc430f1a3ddb70a 100644 --- a/traffic/gzm_ztm.go +++ b/traffic/gzm_ztm.go @@ -344,3 +344,7 @@ return fmt.Errorf("while renaming routes: %w", err) } return nil } + +func (z GzmZtm) QRInfo() (string, QRLocation, string) { + return "rj.metropoliaztm.pl", QRLocationPath, "/redir/stop/(?<stop>[^/]+)" +} diff --git a/traffic/krakow_ztp.go b/traffic/krakow_ztp.go index 72a570db4697f54ccd0b6c1ceffbacd38758b458..64f1833e2b6cf09b9603220d700e8031162aea09 100644 --- a/traffic/krakow_ztp.go +++ b/traffic/krakow_ztp.go @@ -93,3 +93,7 @@ func (z ZtpKrakow) FeedPrepareZip(path string) error { return nil } + +func (z ZtpKrakow) QRInfo() (string, QRLocation, string) { + return "", QRLocationNone, "" +} diff --git a/traffic/poznan_ztm.go b/traffic/poznan_ztm.go index fab3fade1b39c58903819def516c639cf9408275..3ddd8cb1787ea0a61cc5827f4825bd87e891ce40 100644 --- a/traffic/poznan_ztm.go +++ b/traffic/poznan_ztm.go @@ -192,3 +192,7 @@ func (z ZtmPoznan) FeedPrepareZip(path string) error { return nil } + +func (z ZtmPoznan) QRInfo() (string, QRLocation, string) { + return "www.peka.poznan.pl", QRLocationQuery, "przystanek" +} diff --git a/traffic/structs_gen.go b/traffic/structs_gen.go index 0f5522ba86e072f5146e242ea352b3faee51bf43..b58b794530595797adaac0a8aa7339e799add583 100644 --- a/traffic/structs_gen.go +++ b/traffic/structs_gen.go @@ -163,9 +163,16 @@ return bare.Marshal(t) } type FeedInfo struct { - Name string `bare:"name"` - Website string `bare:"website"` - Language string `bare:"language"` + Name string `bare:"name"` + Website string `bare:"website"` + Language string `bare:"language"` + ValidSince string `bare:"validSince"` + ValidTill string `bare:"validTill"` + QrHost string `bare:"qrHost"` + QrLocation QRLocation `bare:"qrLocation"` + QrSelector string `bare:"qrSelector"` + Attributions map[string]string `bare:"attributions"` + Descriptions map[string]string `bare:"descriptions"` } func (t *FeedInfo) Decode(data []byte) error { @@ -351,3 +358,26 @@ return "MONORAIL" } panic(errors.New("Invalid LineType value")) } + +type QRLocation uint + +const ( + QRLocationUnknown QRLocation = 0 + QRLocationNone QRLocation = 1 + QRLocationPath QRLocation = 2 + QRLocationQuery QRLocation = 3 +) + +func (t QRLocation) String() string { + switch t { + case QRLocationUnknown: + return "QRLocationUnknown" + case QRLocationNone: + return "QRLocationNone" + case QRLocationPath: + return "QRLocationPath" + case QRLocationQuery: + return "QRLocationQuery" + } + panic(errors.New("Invalid QRLocation value")) +} diff --git a/traffic/timetable.go b/traffic/timetable.go new file mode 100644 index 0000000000000000000000000000000000000000..26966e9fd548f84c2935728fd846a958ebe39f29 --- /dev/null +++ b/traffic/timetable.go @@ -0,0 +1,16 @@ +package traffic + +import ( + "apiote.xyz/p/szczanieckiej/config" + + "io" + "os" + "path/filepath" +) + +func GetTimetable(feedID string, t *Traffic, c config.Config) (io.ReadCloser, error) { + validity, _, err := ParseDate("", feedID, t) + path := filepath.Join(c.FeedsPath, feedID, string(validity)+".txz") + f, err := os.Open(path) + return f, err +}