szczanieckiej.git

commit e8a1e1c225adf76adb924cf1f15f1fcaf4ebe87b

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
+}