diff --git a/cmd/skyfeed/main.go b/cmd/skyfeed/main.go index 7185727..32f6a3d 100644 --- a/cmd/skyfeed/main.go +++ b/cmd/skyfeed/main.go @@ -1,41 +1,41 @@ package main import ( - "fmt" - "os" + "fmt" + "os" - "github.com/spf13/cobra" + "github.com/spf13/cobra" - "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" - "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/geo" - "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/output" - "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather" + "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" + "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/geo" + "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/output" + "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather" ) var rootCmd = &cobra.Command{ - Use: "skyfeed", - Short: "Skyfeed - Open Weather Engine for Telefact and Terminal", - Long: `Skyfeed fetches and normalizes weather data from Environment Canada, -using a local IP database for accurate geolocation. It supports both CLI and API modes.`, - Run: func(cmd *cobra.Command, args []string) { - cmd.Help() - }, + Use: "skyfeed", + Short: "Skyfeed - Open Weather Engine for Telefact and Terminal", + Long: `Skyfeed fetches and normalizes weather data from Environment Canada, +using automatic IP-based geolocation. It supports both CLI and API modes.`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, } func main() { - // Initialize configuration and ensure data directories exist - config.Init() + // Initialize configuration and ensure data directories exist + config.Init() - // Register subcommands - rootCmd.AddCommand(fetchCmd) - rootCmd.AddCommand(showCmd) - rootCmd.AddCommand(updateCmd) - rootCmd.AddCommand(debugLogoCmd) + // Register subcommands + rootCmd.AddCommand(fetchCmd) + rootCmd.AddCommand(showCmd) + rootCmd.AddCommand(updateTownCmd) + rootCmd.AddCommand(debugLogoCmd) - if err := rootCmd.Execute(); err != nil { - fmt.Println("Error:", err) - os.Exit(1) - } + if err := rootCmd.Execute(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } } // ---------------------------------------------------- @@ -43,101 +43,106 @@ func main() { // ---------------------------------------------------- var fetchCmd = &cobra.Command{ - Use: "fetch", - Short: "Fetch the latest weather data for your current location", - Run: func(cmd *cobra.Command, args []string) { - output.LogInfo("Skyfeed: Checking IP database...") - if err := geo.EnsureIPDBUpToDate(); err != nil { - output.LogError(fmt.Sprintf("Failed to update IP DB: %v", err)) - return - } + Use: "fetch", + Short: "Fetch the latest weather data for your current location", + Run: func(cmd *cobra.Command, args []string) { - output.LogInfo("Skyfeed: Detecting location...") - city, lat, lon, err := geo.GetUserLocation() - if err != nil { - output.LogError(fmt.Sprintf("Could not determine location: %v", err)) - return - } - output.LogInfo(fmt.Sprintf("Detected: %s (%.4f, %.4f)", city, lat, lon)) + // Ensure town index is present and current + output.LogInfo("Skyfeed: Checking town index…") + if err := geo.EnsureTownIndexUpToDate(); err != nil { + output.LogError(fmt.Sprintf("Failed to update town index: %v", err)) + return + } - output.LogInfo("Finding nearest Environment Canada station...") - station, err := geo.FindNearestStation(lat, lon) - if err != nil { - output.LogError(fmt.Sprintf("Station lookup failed: %v", err)) - return - } - output.LogInfo(fmt.Sprintf("Nearest station: %s [%s]", station.Name, station.Code)) + // Detect location via new IP method + output.LogInfo("Skyfeed: Detecting location…") + city, lat, lon, err := geo.GetUserLocation() + if err != nil { + output.LogError(fmt.Sprintf("Could not determine location: %v", err)) + return + } + output.LogInfo(fmt.Sprintf("Detected: %s (%.4f, %.4f)", city, lat, lon)) - output.LogInfo("Fetching latest weather data...") - province := station.Province + // Find nearest EC station + output.LogInfo("Finding nearest Environment Canada station…") + station, err := geo.FindNearestStation(lat, lon) + if err != nil { + output.LogError(fmt.Sprintf("Station lookup failed: %v", err)) + return + } + output.LogInfo(fmt.Sprintf("Nearest station: %s [%s]", station.Name, station.Code)) - data, err := weather.FetchCurrent(station.Code, province) - if err != nil { - output.LogError(fmt.Sprintf("Weather fetch failed: %v", err)) - return - } + // Fetch weather + output.LogInfo("Fetching latest weather data…") + province := station.Province - // Render dynamic header - output.RenderLogo(data.Condition) - fmt.Println() + data, err := weather.FetchCurrent(station.Code, province) + if err != nil { + output.LogError(fmt.Sprintf("Weather fetch failed: %v", err)) + return + } - // Main output - fmt.Println(output.FormatWeatherCLI(data, true)) - }, + // Render dynamic header/logo + output.PrintLogo(data.Condition) + fmt.Println() + + // Main output + fmt.Println(output.FormatWeatherCLI(data, true)) + }, } var showCmd = &cobra.Command{ - Use: "show", - Short: "Show cached weather data from disk", - Run: func(cmd *cobra.Command, args []string) { - data, err := weather.LoadCached() - if err != nil { - output.LogError(fmt.Sprintf("Failed to load cache: %v", err)) - return - } + Use: "show", + Short: "Show cached weather data from disk", + Run: func(cmd *cobra.Command, args []string) { + data, err := weather.LoadCached() + if err != nil { + output.LogError(fmt.Sprintf("Failed to load cache: %v", err)) + return + } - output.RenderLogo(data.Condition) - fmt.Println() + output.PrintLogo(data.Condition) + fmt.Println() - fmt.Println(output.FormatWeatherCLI(data, true)) - }, + fmt.Println(output.FormatWeatherCLI(data, true)) + }, } -var updateCmd = &cobra.Command{ - Use: "update-ipdb", - Short: "Manually update the local IP geolocation database", - Run: func(cmd *cobra.Command, args []string) { - output.LogInfo("Forcing IP database update...") - if err := geo.ForceUpdateIPDB(); err != nil { - output.LogError(fmt.Sprintf("Update failed: %v", err)) - return - } - output.LogSuccess("IP database updated successfully.") - }, +var updateTownCmd = &cobra.Command{ + Use: "update-towns", + Short: "Manually update the Canadian towns geolocation index", + Run: func(cmd *cobra.Command, args []string) { + output.LogInfo("Forcing town index update…") + if err := geo.ForceUpdateTownIndex(); err != nil { + output.LogError(fmt.Sprintf("Update failed: %v", err)) + return + } + output.LogSuccess("Town index updated successfully.") + }, } // ---------------------------------------------------- -// Debug-only utility command +// Debug-only ASCII logo renderer // ---------------------------------------------------- var debugLogoCmd = &cobra.Command{ - Use: "debug-logo [condition]", - Hidden: true, - Short: "Render the dynamic ASCII Skyfeed logo for a given condition (debug use only)", - Long: `This command renders the Skyfeed ASCII logo using a simulated + Use: "debug-logo [condition]", + Hidden: true, + Short: "Render the dynamic ASCII Skyfeed logo for a given condition", + Long: `This command renders the Skyfeed ASCII logo using a simulated weather condition. It is intended strictly for development and testing. Example: skyfeed debug-logo "Light Snow"`, - Run: func(cmd *cobra.Command, args []string) { - if len(args) == 0 { - fmt.Println("Usage: skyfeed debug-logo \"Weather Condition\"") - fmt.Println("Example: skyfeed debug-logo \"Sunny\"") - return - } + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + fmt.Println("Usage: skyfeed debug-logo \"Weather Condition\"") + fmt.Println("Example: skyfeed debug-logo \"Sunny\"") + return + } - condition := args[0] - fmt.Printf("Rendering logo for condition: %s\n\n", condition) + condition := args[0] + fmt.Printf("Rendering logo for condition: %s\n\n", condition) - output.RenderLogo(condition) - }, + output.PrintLogo(condition) + }, } diff --git a/internal/geo/geolocate.go b/internal/geo/geolocate.go index 6a1f3e0..21a08e8 100644 --- a/internal/geo/geolocate.go +++ b/internal/geo/geolocate.go @@ -1,113 +1,148 @@ package geo import ( - "encoding/json" - "fmt" - "net" - "net/http" - "path/filepath" - "time" - - "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" - "github.com/oschwald/geoip2-golang" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "time" ) -// GetUserLocation resolves the user's public IP into a city and coordinates. -// It will try multiple fallback IP providers if the first one fails. +// ------------------------------- +// Public IP + Geolocation (ipwho.is) +// ------------------------------- + +// GetUserLocation resolves the user's IP into: +// city, latitude, longitude func GetUserLocation() (string, float64, float64, error) { - fmt.Println("[geo] Detecting location...") + fmt.Println("[geo] Detecting location...") - ip, err := fetchPublicIP() - if err != nil { - return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err) - } + ip, err := fetchPublicIP() + if err != nil { + return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err) + } - dbPath := filepath.Join(config.DataDir, "GeoLite2-City.mmdb") - db, err := geoip2.Open(dbPath) - if err != nil { - return "", 0, 0, fmt.Errorf("failed to open GeoLite2 database: %w", err) - } - defer db.Close() + city, province, lat, lon, err := lookupIP(ip.String()) + if err != nil { + return "", 0, 0, fmt.Errorf("IP geolocation failed: %w", err) + } - record, err := db.City(ip) - if err != nil { - return "", 0, 0, fmt.Errorf("geoip lookup failed: %w", err) - } + // e.g., "Ottawa, Ontario" + locationName := fmt.Sprintf("%s, %s", city, province) - city := record.City.Names["en"] - prov := "" - if len(record.Subdivisions) > 0 { - prov = record.Subdivisions[0].Names["en"] - } - - lat := record.Location.Latitude - lon := record.Location.Longitude - - if city == "" && prov == "" { - return "", 0, 0, fmt.Errorf("no location info found for IP %s", ip.String()) - } - - fmt.Printf("[geo] Detected: %s, %s (%.4f, %.4f)\n", city, prov, lat, lon) - return fmt.Sprintf("%s, %s", city, prov), lat, lon, nil + fmt.Printf("[geo] Detected: %s (%.4f, %.4f)\n", locationName, lat, lon) + return locationName, lat, lon, nil } -// fetchPublicIP tries multiple reliable endpoints for the public IPv4 address. +// ------------------------------- +// PUBLIC IP DETECTION +// ------------------------------- + func fetchPublicIP() (net.IP, error) { - providers := []string{ - "https://ipv4.icanhazip.com", - "https://api.ipify.org?format=json", - "https://ifconfig.co/json", - } + providers := []string{ + "https://ipv4.icanhazip.com", + "https://api.ipify.org?format=json", + "https://ifconfig.co/json", + } - client := &http.Client{Timeout: 5 * time.Second} + client := &http.Client{Timeout: 5 * time.Second} - for _, url := range providers { - ip, err := tryProvider(url, client) - if err == nil && ip != nil { - return ip, nil - } - fmt.Println("[geo] Fallback:", err) - } + for _, url := range providers { + ip, err := tryProvider(url, client) + if err == nil && ip != nil { + return ip, nil + } + fmt.Println("[geo] Fallback:", err) + } - return nil, fmt.Errorf("all IP detection methods failed") + return nil, fmt.Errorf("all IP detection methods failed") } -// tryProvider queries a single IP API endpoint and parses IPv4 results. func tryProvider(url string, client *http.Client) (net.IP, error) { - resp, err := client.Get(url) - if err != nil { - return nil, fmt.Errorf("network error (%s): %w", url, err) - } - defer resp.Body.Close() + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url) - } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url) + } - // Some APIs return plain text, others JSON - var result struct { - IP string `json:"ip"` - } + // JSON-based API fallback + var j struct{ IP string `json:"ip"` } - // Try decode JSON - if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.IP != "" { - ip := net.ParseIP(result.IP) - if ip != nil && ip.To4() != nil { - return ip, nil - } - } + if err := json.NewDecoder(resp.Body).Decode(&j); err == nil && j.IP != "" { + ip := net.ParseIP(j.IP) + if ip != nil && ip.To4() != nil { + return ip, nil + } + } - // Fallback: plain text - resp2, err := client.Get(url) - if err == nil { - defer resp2.Body.Close() - buf := make([]byte, 64) - n, _ := resp2.Body.Read(buf) - ip := net.ParseIP(string(buf[:n])) - if ip != nil && ip.To4() != nil { - return ip, nil - } - } + // Plain text fallback → must re-fetch body + resp2, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp2.Body.Close() - return nil, fmt.Errorf("no valid IP found from %s", url) + buf := make([]byte, 64) + n, _ := resp2.Body.Read(buf) + ip := net.ParseIP(strings.TrimSpace(string(buf[:n]))) + + if ip != nil && ip.To4() != nil { + return ip, nil + } + + return nil, fmt.Errorf("invalid response from %s", url) +} + +// ------------------------------- +// GEO LOOKUP USING ipwho.is +// ------------------------------- + +func lookupIP(ip string) (city string, province string, lat float64, lon float64, err error) { + url := fmt.Sprintf("https://ipwho.is/%s", ip) + + client := &http.Client{Timeout: 6 * time.Second} + resp, err := client.Get(url) + if err != nil { + return "", "", 0, 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", 0, 0, fmt.Errorf("ipwho.is returned HTTP %d", resp.StatusCode) + } + + var data struct { + Success bool `json:"success"` + City string `json:"city"` + Region string `json:"region"` + Lat float64 `json:"latitude"` + Lon float64 `json:"longitude"` + Message string `json:"message"` + } + + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", "", 0, 0, fmt.Errorf("decode error: %w", err) + } + + if !data.Success { + return "", "", 0, 0, fmt.Errorf("ipwho.is error: %s", data.Message) + } + + // Clean fields + city = strings.TrimSpace(data.City) + province = strings.TrimSpace(data.Region) + + if city == "" { + city = "Unknown" + } + if province == "" { + province = "Unknown" + } + + return city, province, data.Lat, data.Lon, nil } diff --git a/internal/geo/ipdb_updater.go b/internal/geo/ipdb_updater.go deleted file mode 100644 index da67cb9..0000000 --- a/internal/geo/ipdb_updater.go +++ /dev/null @@ -1,137 +0,0 @@ -package geo - -import ( - "archive/tar" - "compress/gzip" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" -) - -const ( - ipdbFileName = "GeoLite2-City.mmdb" - keyFileName = "maxmind.key" -) - -// EnsureIPDBUpToDate checks the local MaxMind database and refreshes monthly. -func EnsureIPDBUpToDate() error { - dbPath := filepath.Join(config.DataDir, ipdbFileName) - info, err := os.Stat(dbPath) - - if os.IsNotExist(err) { - fmt.Println("[geo] No IP database found, downloading...") - return updateIPDB(dbPath) - } - if err != nil { - return fmt.Errorf("unable to check IP DB: %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] IP database is older than this month, refreshing...") - return updateIPDB(dbPath) - } - - fmt.Println("[geo] IP database is current.") - return nil -} - -// ForceUpdateIPDB forces an immediate refresh. -func ForceUpdateIPDB() error { - dbPath := filepath.Join(config.DataDir, ipdbFileName) - fmt.Println("[geo] Forcing IP database update...") - return updateIPDB(dbPath) -} - -// updateIPDB downloads and extracts the official GeoLite2 City database using your MaxMind key. -func updateIPDB(dest string) error { - keyPath := filepath.Join(config.ConfigDir, keyFileName) - keyBytes, err := os.ReadFile(keyPath) - if err != nil { - return fmt.Errorf("[geo] Missing MaxMind license key.\nPlease run:\n echo \"YOUR_KEY\" > %s", keyPath) - } - - key := strings.TrimSpace(string(keyBytes)) - url := fmt.Sprintf("https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz", key) - - tmpTar := dest + ".tar.gz" - if err := downloadFile(url, tmpTar); err != nil { - return fmt.Errorf("failed to download GeoLite2 archive: %w", err) - } - defer os.Remove(tmpTar) - - if err := extractMMDB(tmpTar, dest); err != nil { - return fmt.Errorf("failed to extract mmdb: %w", err) - } - - fmt.Println("[geo] IP database updated successfully →", dest) - return nil -} - -// downloadFile streams a file from URL to disk. -func downloadFile(url, dest string) error { - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected HTTP status: %s", resp.Status) - } - - out, err := os.Create(dest) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, resp.Body) - return err -} - -// extractMMDB extracts the .mmdb file from a tar.gz archive. -func extractMMDB(src, dest string) error { - f, err := os.Open(src) - if err != nil { - return err - } - defer f.Close() - - gz, err := gzip.NewReader(f) - if err != nil { - return err - } - defer gz.Close() - - tr := tar.NewReader(gz) - for { - h, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return err - } - - if filepath.Ext(h.Name) == ".mmdb" { - out, err := os.Create(dest) - if err != nil { - return err - } - defer out.Close() - _, err = io.Copy(out, tr) - return err - } - } - return fmt.Errorf("no .mmdb found in archive") -} diff --git a/internal/geo/towns.go b/internal/geo/towns.go new file mode 100644 index 0000000..6c02184 --- /dev/null +++ b/internal/geo/towns.go @@ -0,0 +1,10 @@ +package geo + +// Town represents a Canadian populated place. +// Shared by both the updater and lookup system. +type Town struct { + Name string `json:"name"` + Province string `json:"province"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} diff --git a/internal/geo/towns_lookup.go b/internal/geo/towns_lookup.go index f4c342b..d7f0b1e 100644 --- a/internal/geo/towns_lookup.go +++ b/internal/geo/towns_lookup.go @@ -1,56 +1,50 @@ package geo import ( - "encoding/json" - "fmt" - "math" - "os" - "path/filepath" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" - "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" + "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" ) -// Town represents a Canadian town record (loaded from towns.json). -type Town struct { - Name string `json:"name"` - Province string `json:"province"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` -} - // FindNearestTown loads the cached towns.json and finds the closest town to given coordinates. func FindNearestTown(lat, lon float64) (Town, error) { - townsPath := filepath.Join(config.DataDir, "towns.json") + townsPath := filepath.Join(config.DataDir, "towns.json") - data, err := os.ReadFile(townsPath) - if err != nil { - return Town{}, fmt.Errorf("failed to read town index: %w", err) - } + data, err := os.ReadFile(townsPath) + if err != nil { + return Town{}, fmt.Errorf("failed to read town index: %w", err) + } - var towns []Town - if err := json.Unmarshal(data, &towns); err != nil { - return Town{}, fmt.Errorf("failed to parse towns.json: %w", err) - } + var towns []Town + if err := json.Unmarshal(data, &towns); err != nil { + return Town{}, fmt.Errorf("failed to parse towns.json: %w", err) + } - if len(towns) == 0 { - return Town{}, fmt.Errorf("no towns found in index") - } + if len(towns) == 0 { + return Town{}, fmt.Errorf("no towns found in index") + } - minDist := math.MaxFloat64 - var nearest Town + minDist := math.MaxFloat64 + var nearest Town - for _, t := range towns { - d := Haversine(lat, lon, t.Lat, t.Lon) // ✅ use shared helper from stations.go - if d < minDist { - minDist = d - nearest = t - } - } + for _, t := range towns { + d := Haversine(lat, lon, t.Lat, t.Lon) + if d < minDist { + minDist = d + nearest = t + } + } - if nearest.Name == "" { - return Town{}, fmt.Errorf("no nearby town found") - } + if nearest.Name == "" { + return Town{}, fmt.Errorf("no nearby town found") + } - fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n", nearest.Name, nearest.Province, minDist) - return nearest, nil + fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n", + nearest.Name, nearest.Province, minDist) + + return nearest, nil } diff --git a/internal/geo/towns_updater.go b/internal/geo/towns_updater.go index 4d603e0..7d054bb 100644 --- a/internal/geo/towns_updater.go +++ b/internal/geo/towns_updater.go @@ -1,135 +1,123 @@ package geo import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "time" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" - "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" + "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 + // WORKING GeoGratis endpoint for all populated places (PPL) + gnbcAPIURL = "https://geogratis.gc.ca/services/geoname/en/geonames.json?feature_code=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) + 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) - } + 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) - } + 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) + 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) - } + 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 + 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) + 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} + 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() + fmt.Println("[geo] Fetching town data from GeoGratis...") + 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) - } + 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) - } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read GeoGratis response: %w", err) + } - towns, err := parseGNBCJSON(raw) - if err != nil { - return fmt.Errorf("failed to parse GNBC JSON: %w", err) - } + towns, err := parseGNBCJSON(raw) + if err != nil { + return fmt.Errorf("failed to parse GeoGratis JSON: %w", err) + } - data, err := json.MarshalIndent(towns, "", " ") - if err != nil { - return fmt.Errorf("failed to encode towns: %w", err) - } + data, err := json.MarshalIndent(towns, "", " ") + if err != nil { + return fmt.Errorf("failed to encode towns.json: %w", err) + } - if err := os.WriteFile(dest, data, 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", dest, 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 + 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"` - } +// Uses GeoGratis geonames.json structure +func parseGNBCJSON(data []byte) ([]Town, error) { + var response struct { + Items []struct { + Name string `json:"name"` + ProvinceCode string `json:"province_code"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + } `json:"items"` + } - if err := json.Unmarshal(data, &response); err != nil { - return nil, fmt.Errorf("invalid GNBC JSON: %w", err) - } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("invalid GeoGratis 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, - }) - } + var towns []Town + for _, item := range response.Items { + if item.Name == "" || item.ProvinceCode == "" { + continue + } - return towns, nil + towns = append(towns, Town{ + Name: item.Name, + Province: item.ProvinceCode, + Lat: item.Latitude, + Lon: item.Longitude, + }) + } + + return towns, nil } diff --git a/skyfeed b/skyfeed index 94c81b0..231fde9 100755 Binary files a/skyfeed and b/skyfeed differ