package geo import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "time" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" ) const ( // Official Geographical Names Board of Canada WFS API // Docs: https://www.nrcan.gc.ca/earth-sciences/geography/geographical-names-board-canada/download-geographical-names-data/10786 gnbcAPIURL = "https://geogratis.gc.ca/geonames/servlet/com.gc.ccra.geonames.webservices.GeographicalNamesService?service=WFS&request=GetFeature&version=2.0.0&typeNames=geonames:geoname_eng&outputFormat=json&featureCode=PPL" townsFile = "towns.json" maxFetchTime = 5 * time.Minute ) // TownRecord represents a single Canadian town. type TownRecord struct { Name string `json:"name"` Province string `json:"province"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } // EnsureTownIndexUpToDate checks if the towns index needs updating (monthly). func EnsureTownIndexUpToDate() error { dest := filepath.Join(config.DataDir, townsFile) info, err := os.Stat(dest) if os.IsNotExist(err) { fmt.Println("[geo] No town index found, downloading...") return downloadTownIndex(dest) } if err != nil { return fmt.Errorf("unable to check town index: %w", err) } modTime := info.ModTime().UTC() now := time.Now().UTC() firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) if modTime.Before(firstOfMonth) { fmt.Println("[geo] Town index is older than this month, refreshing...") return downloadTownIndex(dest) } fmt.Println("[geo] Town index is current.") return nil } // ForceUpdateTownIndex forces an immediate rebuild. func ForceUpdateTownIndex() error { dest := filepath.Join(config.DataDir, townsFile) fmt.Println("[geo] Forcing town index update...") return downloadTownIndex(dest) } // downloadTownIndex fetches and stores the Canadian town dataset. func downloadTownIndex(dest string) error { client := &http.Client{Timeout: maxFetchTime} fmt.Println("[geo] Fetching town data from GNBC WFS API...") resp, err := client.Get(gnbcAPIURL) if err != nil { return fmt.Errorf("failed to fetch town dataset: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected HTTP status: %s", resp.Status) } raw, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read GNBC response: %w", err) } towns, err := parseGNBCJSON(raw) if err != nil { return fmt.Errorf("failed to parse GNBC JSON: %w", err) } data, err := json.MarshalIndent(towns, "", " ") if err != nil { return fmt.Errorf("failed to encode towns: %w", err) } if err := os.WriteFile(dest, data, 0644); err != nil { return fmt.Errorf("failed to write %s: %w", dest, err) } fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns)) return nil } // parseGNBCJSON extracts relevant town info from the GNBC GeoJSON. func parseGNBCJSON(data []byte) ([]TownRecord, error) { var response struct { Features []struct { Properties struct { Name string `json:"name"` Province string `json:"province"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` } `json:"properties"` } `json:"features"` } if err := json.Unmarshal(data, &response); err != nil { return nil, fmt.Errorf("invalid GNBC JSON: %w", err) } var towns []TownRecord for _, f := range response.Features { p := f.Properties if p.Name == "" || p.Province == "" { continue } towns = append(towns, TownRecord{ Name: p.Name, Province: p.Province, Lat: p.Latitude, Lon: p.Longitude, }) } return towns, nil }