package service import ( "encoding/json" "fmt" "io/ioutil" "os" "path/filepath" ) // PublicTransportService provides read-only access to precomputed public transport data type PublicTransportService struct { metadata map[string]interface{} stops map[string]interface{} raw map[string]interface{} baseDir string } // NewPublicTransportService attempts to load enriched public transport JSON from data directory func NewPublicTransportService(baseDir string) (*PublicTransportService, error) { svc := &PublicTransportService{baseDir: baseDir} // Try to read enriched JSON first candidates := []string{ filepath.Join(baseDir, "bugulma_public_transport_enriched.json"), filepath.Join(baseDir, "bugulma_public_transport.json"), } var found bool for _, p := range candidates { if _, err := os.Stat(p); err == nil { b, err := ioutil.ReadFile(p) if err != nil { return nil, fmt.Errorf("failed to read public transport file %s: %w", p, err) } var raw map[string]interface{} if err := json.Unmarshal(b, &raw); err != nil { return nil, fmt.Errorf("failed to unmarshal public transport json %s: %w", p, err) } svc.raw = raw // Extract metadata and common stops if present if m, ok := raw["metadata"].(map[string]interface{}); ok { svc.metadata = m } if cs, ok := raw["common_stops_directory"].(map[string]interface{}); ok { svc.stops = cs } else { svc.stops = map[string]interface{}{} } found = true break } } if !found { // No JSON file found, return empty service with baseDir set so GTFS files can still be served svc.raw = map[string]interface{}{} svc.metadata = map[string]interface{}{} svc.stops = map[string]interface{}{} } return svc, nil } // GetMetadata returns metadata extracted from the data file func (s *PublicTransportService) GetMetadata() map[string]interface{} { return s.metadata } // ListStops returns a map of stop key -> stop object func (s *PublicTransportService) ListStops() map[string]interface{} { return s.stops } // GetStop returns a stop by id (key in the common_stops_directory) func (s *PublicTransportService) GetStop(id string) (interface{}, bool) { st, ok := s.stops[id] return st, ok } // Search stops by substring match in names (case-insensitive) func (s *PublicTransportService) SearchStops(query string) map[string]interface{} { out := map[string]interface{}{} if query == "" { return out } for k, v := range s.stops { if k == query { out[k] = v continue } // try looking into name fields if present if m, ok := v.(map[string]interface{}); ok { if name, ok := m["name"].(string); ok { if containsIgnoreCase(name, query) { out[k] = v continue } } if nameEn, ok := m["name_en"].(string); ok { if containsIgnoreCase(nameEn, query) { out[k] = v continue } } } } return out } // ReadGTFSFile returns file contents from the GTFS export folder if present func (s *PublicTransportService) ReadGTFSFile(filename string) (string, error) { p := filepath.Join(s.baseDir, "bugulma_gtfs_export", filename) b, err := ioutil.ReadFile(p) if err != nil { return "", err } return string(b), nil } // Helper: case-insensitive substring func containsIgnoreCase(src, sub string) bool { return len(src) >= len(sub) && (stringIndexIgnoreCase(src, sub) >= 0) } func stringIndexIgnoreCase(s, substr string) int { // naive implementation that converts to lower-case sLower := []rune{} subLower := []rune{} for _, r := range s { if 'A' <= r && r <= 'Z' { r = r - 'A' + 'a' } sLower = append(sLower, r) } for _, r := range substr { if 'A' <= r && r <= 'Z' { r = r - 'A' + 'a' } subLower = append(subLower, r) } sStr := string(sLower) subStr := string(subLower) return indexOf(sStr, subStr) } func indexOf(s, substr string) int { if substr == "" { return 0 } for i := 0; i+len(substr) <= len(s); i++ { if s[i:i+len(substr)] == substr { return i } } return -1 }