136 lines
3.6 KiB
Go
136 lines
3.6 KiB
Go
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
|
|
}
|