diff --git a/README.md b/cd similarity index 100% rename from README.md rename to cd diff --git a/cmd/skyfeed/main.go b/cmd/skyfeed/main.go new file mode 100644 index 0000000..d7072d3 --- /dev/null +++ b/cmd/skyfeed/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/leaktechnologies/skyfeed/internal/config" + "github.com/leaktechnologies/skyfeed/internal/geo" + "github.com/leaktechnologies/skyfeed/internal/output" + "github.com/leaktechnologies/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() + }, +} + +func main() { + // Initialize configuration and ensure data directories exist + config.Init() + + // Register subcommands + rootCmd.AddCommand(fetchCmd) + rootCmd.AddCommand(showCmd) + rootCmd.AddCommand(updateCmd) + + if err := rootCmd.Execute(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} + +// ------------------------------ +// Subcommands +// ------------------------------ + +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 + } + + 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("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)) + + output.LogInfo("Fetching latest weather data...") + // Determine province from the station (if available) + province := strings.Split(station.Code, "_")[0] // fallback heuristic + data, err := weather.FetchCurrent(station.Code, province) + if err != nil { + output.LogError(fmt.Sprintf("Weather fetch failed: %v", err)) + return + } + + 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 + } + 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.") + }, +} diff --git a/config/default.json b/config/default.json new file mode 100644 index 0000000..e69de29 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/INTEGRATION_TELEFACT.md b/docs/INTEGRATION_TELEFACT.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/WEATHER_CODES.md b/docs/WEATHER_CODES.md new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..46009be --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/leaktechnologies/skyfeed + +go 1.25.4 + +require ( + github.com/oschwald/geoip2-golang v1.13.0 + github.com/spf13/cobra v1.10.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/oschwald/maxminddb-golang v1.13.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2faa1d9 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= +github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= +github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..a57cc3f --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1 @@ +package api \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8cfc0d1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,38 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" +) + +// Global app paths (used by all other packages) +var ( + ConfigDir string + CacheDir string + DataDir string +) + +// Init creates the required directories and prints their paths if missing. +func Init() { + home, err := os.UserHomeDir() + if err != nil { + fmt.Println("Error: cannot resolve home directory:", err) + os.Exit(1) + } + + ConfigDir = filepath.Join(home, ".config", "skyfeed") + CacheDir = filepath.Join(home, ".cache", "skyfeed") + DataDir = filepath.Join(home, ".local", "share", "skyfeed") + + dirs := []string{ConfigDir, CacheDir, DataDir} + for _, dir := range dirs { + if _, err := os.Stat(dir); os.IsNotExist(err) { + err := os.MkdirAll(dir, 0755) + if err != nil { + fmt.Printf("Error creating %s: %v\n", dir, err) + os.Exit(1) + } + } + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 0000000..63acfdd --- /dev/null +++ b/internal/config/defaults.go @@ -0,0 +1 @@ +package config \ No newline at end of file diff --git a/internal/geo/distance.go b/internal/geo/distance.go new file mode 100644 index 0000000..a7b3818 --- /dev/null +++ b/internal/geo/distance.go @@ -0,0 +1 @@ +package geo \ No newline at end of file diff --git a/internal/geo/geolocate.go b/internal/geo/geolocate.go new file mode 100644 index 0000000..d65f603 --- /dev/null +++ b/internal/geo/geolocate.go @@ -0,0 +1,113 @@ +package geo + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "path/filepath" + "time" + + "github.com/leaktechnologies/skyfeed/internal/config" + "github.com/oschwald/geoip2-golang" +) + +// GetUserLocation resolves the user's public IP into a city and coordinates. +// It will try multiple fallback IP providers if the first one fails. +func GetUserLocation() (string, float64, float64, error) { + fmt.Println("[geo] Detecting location...") + + 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() + + record, err := db.City(ip) + if err != nil { + return "", 0, 0, fmt.Errorf("geoip lookup failed: %w", err) + } + + 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 +} + +// fetchPublicIP tries multiple reliable endpoints for the public IPv4 address. +func fetchPublicIP() (net.IP, error) { + providers := []string{ + "https://ipv4.icanhazip.com", + "https://api.ipify.org?format=json", + "https://ifconfig.co/json", + } + + 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) + } + + 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() + + 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"` + } + + // 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 + } + } + + // 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 + } + } + + return nil, fmt.Errorf("no valid IP found from %s", url) +} diff --git a/internal/geo/ipdb_updater.go b/internal/geo/ipdb_updater.go new file mode 100644 index 0000000..953df74 --- /dev/null +++ b/internal/geo/ipdb_updater.go @@ -0,0 +1,137 @@ +package geo + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/leaktechnologies/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/stations.go b/internal/geo/stations.go new file mode 100644 index 0000000..64a6324 --- /dev/null +++ b/internal/geo/stations.go @@ -0,0 +1,183 @@ +package geo + +import ( + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "math" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/leaktechnologies/skyfeed/internal/config" +) + +// Station represents an Environment Canada citypage station. +type Station struct { + Name string `json:"name"` + Code string `json:"code"` + Province string `json:"province"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +// stationCacheFile defines where we persist the station index locally. +const stationCacheFile = "stations.json" + +// FindNearestStation locates the closest Environment Canada weather station to given coordinates. +func FindNearestStation(lat, lon float64) (Station, error) { + if lat == 0 && lon == 0 { + return Station{}, errors.New("invalid coordinates: cannot locate nearest station") + } + + stations, err := ensureStationCache() + if err != nil { + return Station{}, fmt.Errorf("failed to load station list: %w", err) + } + + var nearest Station + minDist := math.MaxFloat64 + + for _, s := range stations { + d := Haversine(lat, lon, s.Lat, s.Lon) + if d < minDist { + minDist = d + nearest = s + } + } + + if nearest.Code == "" { + return Station{}, errors.New("no station found in index") + } + + fmt.Printf("[geo] Nearest station: %s (%s, %.2f km)\n", nearest.Name, nearest.Province, minDist) + return nearest, nil +} + +// ensureStationCache loads the cached station list or updates it if missing/outdated. +func ensureStationCache() ([]Station, error) { + cachePath := filepath.Join(config.DataDir, stationCacheFile) + + info, err := os.Stat(cachePath) + if os.IsNotExist(err) { + fmt.Println("[geo] No station cache found, fetching from Environment Canada...") + return updateStationCache(cachePath) + } + if err != nil { + return nil, err + } + + // Refresh monthly + 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] Station cache is older than this month, refreshing...") + return updateStationCache(cachePath) + } + + // Load existing JSON cache + f, err := os.Open(cachePath) + if err != nil { + return nil, err + } + defer f.Close() + + var stations []Station + if err := json.NewDecoder(f).Decode(&stations); err != nil { + return nil, err + } + + return stations, nil +} + +// updateStationCache fetches and parses Environment Canada's current site list (XML). +func updateStationCache(dest string) ([]Station, error) { + const ecURL = "https://geo.weather.gc.ca/geomet/features/collections/citypage_weather:siteList/items?f=xml" + + fmt.Println("[geo] Downloading station list from:", ecURL) + + client := &http.Client{Timeout: 25 * time.Second} + resp, err := client.Get(ecURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch EC station list: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected HTTP status: %s — %s", resp.Status, string(body)) + } + + type Site struct { + Code string `xml:"properties>code"` + NameEn string `xml:"properties>nameEn"` + NameFr string `xml:"properties>nameFr"` + Province string `xml:"properties>provinceCode"` + Lat float64 `xml:"geometry>coordinates>1"` // Note: GeoJSON order is [lon, lat] + Lon float64 `xml:"geometry>coordinates>0"` + } + + var parsed struct { + Sites []Site `xml:"member"` + } + + if err := xml.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, fmt.Errorf("failed to parse site list XML: %w", err) + } + + stations := make([]Station, 0, len(parsed.Sites)) + for _, s := range parsed.Sites { + if s.Code == "" || s.Lat == 0 || s.Lon == 0 { + continue + } + name := s.NameEn + if name == "" { + name = s.NameFr + } + stations = append(stations, Station{ + Name: name, + Code: s.Code, + Province: s.Province, + Lat: s.Lat, + Lon: s.Lon, + }) + } + + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return nil, err + } + + f, err := os.Create(dest) + if err != nil { + return nil, err + } + defer f.Close() + + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(stations); err != nil { + return nil, err + } + + fmt.Printf("[geo] Saved %d Environment Canada stations → %s\n", len(stations), dest) + return stations, nil +} + +// Haversine computes the great-circle distance (in km) between two coordinates. +func Haversine(lat1, lon1, lat2, lon2 float64) float64 { + const R = 6371 + dLat := toRadians(lat2 - lat1) + dLon := toRadians(lon2 - lon1) + lat1R := toRadians(lat1) + lat2R := toRadians(lat2) + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(lat1R)*math.Cos(lat2R)*math.Sin(dLon/2)*math.Sin(dLon/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return R * c +} + +func toRadians(deg float64) float64 { return deg * math.Pi / 180 } diff --git a/internal/geo/towns_build.go b/internal/geo/towns_build.go new file mode 100644 index 0000000..a7b3818 --- /dev/null +++ b/internal/geo/towns_build.go @@ -0,0 +1 @@ +package geo \ No newline at end of file diff --git a/internal/geo/towns_lookup.go b/internal/geo/towns_lookup.go new file mode 100644 index 0000000..533534e --- /dev/null +++ b/internal/geo/towns_lookup.go @@ -0,0 +1,56 @@ +package geo + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + + "github.com/leaktechnologies/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") + + 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) + } + + if len(towns) == 0 { + return Town{}, fmt.Errorf("no towns found in index") + } + + 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 + } + } + + 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 +} diff --git a/internal/geo/towns_updater.go b/internal/geo/towns_updater.go new file mode 100644 index 0000000..036f739 --- /dev/null +++ b/internal/geo/towns_updater.go @@ -0,0 +1,135 @@ +package geo + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/leaktechnologies/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 +} diff --git a/internal/output/formatter.go b/internal/output/formatter.go new file mode 100644 index 0000000..850ac2b --- /dev/null +++ b/internal/output/formatter.go @@ -0,0 +1,101 @@ +package output + +import ( + "fmt" + "strings" + "time" + + "github.com/leaktechnologies/skyfeed/internal/weather" +) + +// Theme defines simple ANSI colour codes for terminal output. +var Theme = struct { + Reset string + White string + Cyan string + Yellow string + Blue string + Green string + Red string + Magenta string +}{ + Reset: "\033[0m", + White: "\033[97m", + Cyan: "\033[96m", + Yellow: "\033[93m", + Blue: "\033[94m", + Green: "\033[92m", + Red: "\033[91m", + Magenta: "\033[95m", +} + +// WeatherIcon maps normalized condition keywords to simple glyphs. +func WeatherIcon(condition string) string { + condition = strings.ToLower(condition) + switch { + case strings.Contains(condition, "sunny"): + return "☀" + case strings.Contains(condition, "clear"): + return "🌙" + case strings.Contains(condition, "partly"): + return "⛅" + case strings.Contains(condition, "cloud"): + return "☁" + case strings.Contains(condition, "rain"): + return "🌧" + case strings.Contains(condition, "snow"): + return "❄" + case strings.Contains(condition, "mixed"): + return "🌨" + case strings.Contains(condition, "fog"): + return "🌫" + case strings.Contains(condition, "thunder"): + return "⛈" + default: + return "❔" + } +} + +// FormatWeatherCLI returns a full formatted string for terminal output. +func FormatWeatherCLI(data weather.WeatherData, colored bool) string { + icon := WeatherIcon(data.Condition) + temp := fmt.Sprintf("%.1f°C", data.Temperature) + ts := parseTimestamp(data.Timestamp) + + if colored { + return fmt.Sprintf("%s%s %s%s %s%s%s\n%sCondition:%s %s\n%sHumidity:%s %s%% %sWind:%s %s %s\n%sPressure:%s %s kPa %sUpdated:%s %s%s", + Theme.Cyan, icon, Theme.White, data.Station, + Theme.Yellow, temp, Theme.Reset, + Theme.Blue, Theme.Reset, data.Condition, + Theme.Blue, Theme.Reset, data.Humidity, + Theme.Blue, Theme.Reset, data.WindDir, data.WindSpeed, + Theme.Blue, Theme.Reset, data.Pressure, + Theme.Blue, Theme.Reset, ts.Format("15:04 MST"), Theme.Reset, + ) + } + + return fmt.Sprintf("%s %s %s\nCondition: %s\nHumidity: %s%% Wind: %s %s\nPressure: %s kPa Updated: %s", + icon, data.Station, temp, + data.Condition, + data.Humidity, data.WindDir, data.WindSpeed, + data.Pressure, ts.Format("15:04 MST"), + ) +} + +// parseTimestamp safely parses an RFC3339 or other timestamp format. +func parseTimestamp(ts string) time.Time { + if ts == "" { + return time.Now() + } + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return time.Now() + } + return t +} + +// FormatForUI produces a compact version for GUI or embedded display. +func FormatForUI(data weather.WeatherData) string { + icon := WeatherIcon(data.Condition) + return fmt.Sprintf("%s %s %.1f°C — %s", icon, data.Station, data.Temperature, data.Condition) +} diff --git a/internal/output/logger.go b/internal/output/logger.go new file mode 100644 index 0000000..ea615d4 --- /dev/null +++ b/internal/output/logger.go @@ -0,0 +1,16 @@ +package output + +import "fmt" + +// Simple color-coded log helpers +func LogInfo(msg string) { + fmt.Printf("\033[36m[INFO]\033[0m %s\n", msg) // Cyan +} + +func LogSuccess(msg string) { + fmt.Printf("\033[32m[SUCCESS]\033[0m %s\n", msg) // Green +} + +func LogError(msg string) { + fmt.Printf("\033[31m[ERROR]\033[0m %s\n", msg) // Red +} diff --git a/internal/scheduler/updater.go b/internal/scheduler/updater.go new file mode 100644 index 0000000..232537b --- /dev/null +++ b/internal/scheduler/updater.go @@ -0,0 +1 @@ +package scheduler \ No newline at end of file diff --git a/internal/ui/gui.go b/internal/ui/gui.go new file mode 100644 index 0000000..5492fd6 --- /dev/null +++ b/internal/ui/gui.go @@ -0,0 +1 @@ +package ui \ No newline at end of file diff --git a/internal/ui/tui.go b/internal/ui/tui.go new file mode 100644 index 0000000..5492fd6 --- /dev/null +++ b/internal/ui/tui.go @@ -0,0 +1 @@ +package ui \ No newline at end of file diff --git a/internal/weather/cache.go b/internal/weather/cache.go new file mode 100644 index 0000000..062e590 --- /dev/null +++ b/internal/weather/cache.go @@ -0,0 +1,84 @@ +package weather + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/leaktechnologies/skyfeed/internal/config" +) + +// Cache file path +var cacheFile = filepath.Join(config.DataDir, "last_weather.json") + +// CacheTTL defines how long cached weather data is considered "fresh". +// Default: 30 minutes. +const CacheTTL = 30 * time.Minute + +// LoadFromCache loads cached weather data if available and still fresh. +// Returns (WeatherData, isFresh, error) +func LoadFromCache() (WeatherData, bool, error) { + file, err := os.Open(cacheFile) + if err != nil { + return WeatherData{}, false, fmt.Errorf("no cache found") + } + defer file.Close() + + var data WeatherData + if err := json.NewDecoder(file).Decode(&data); err != nil { + return WeatherData{}, false, fmt.Errorf("failed to decode cache: %w", err) + } + + ts, err := time.Parse(time.RFC3339, data.Timestamp) + if err != nil { + // handle legacy or non-RFC timestamps + return data, false, nil + } + + age := time.Since(ts) + if age > CacheTTL { + fmt.Printf("[weather] Cache is stale (%.0f min old)\n", age.Minutes()) + return data, false, nil + } + + fmt.Println("[weather] Loaded fresh cache (", int(age.Minutes()), "min old )") + return data, true, nil +} + +// SaveToCache writes a WeatherData object to disk. +func SaveToCache(data WeatherData) error { + if err := os.MkdirAll(filepath.Dir(cacheFile), 0755); err != nil { + return fmt.Errorf("failed to create cache dir: %w", err) + } + + file, err := os.Create(cacheFile) + if err != nil { + return fmt.Errorf("failed to write cache: %w", err) + } + defer file.Close() + + data.Timestamp = time.Now().UTC().Format(time.RFC3339) + + enc := json.NewEncoder(file) + enc.SetIndent("", " ") + if err := enc.Encode(data); err != nil { + return fmt.Errorf("failed to encode cache: %w", err) + } + + fmt.Println("[weather] Cache saved →", cacheFile) + return nil +} + +// ClearCache removes the current cached file if present. +func ClearCache() error { + if _, err := os.Stat(cacheFile); os.IsNotExist(err) { + return nil + } + if err := os.Remove(cacheFile); err != nil { + return fmt.Errorf("failed to clear cache: %w", err) + } + fmt.Println("[weather] Cache cleared.") + return nil +} diff --git a/internal/weather/fetch.go b/internal/weather/fetch.go new file mode 100644 index 0000000..1d3eb28 --- /dev/null +++ b/internal/weather/fetch.go @@ -0,0 +1,139 @@ +package weather + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" // ✅ Required for province normalization + "time" + + "github.com/leaktechnologies/skyfeed/internal/config" +) + +// WeatherData holds simplified normalized current weather data. +type WeatherData struct { + Station string `json:"station"` + Temperature float64 `json:"temperature"` + Condition string `json:"condition"` + Humidity string `json:"humidity"` + Pressure string `json:"pressure"` + WindSpeed string `json:"wind_speed"` + WindDir string `json:"wind_dir"` + Timestamp string `json:"timestamp"` +} + +// Supported province folders in Environment Canada citypage_weather XML structure. +var provinceCodes = []string{ + "AB", "BC", "MB", "NB", "NL", "NS", "NT", "NU", + "ON", "PE", "QC", "SK", "YT", +} + +// FetchCurrent retrieves current weather from Environment Canada for any province. +func FetchCurrent(stationCode, province string) (WeatherData, error) { + if stationCode == "" { + return WeatherData{}, fmt.Errorf("no station code provided") + } + + // If province unknown, we’ll probe each possible province directory until one succeeds. + targetProvinces := provinceCodes + if province != "" { + targetProvinces = []string{strings.ToUpper(province)} + } + + var lastErr error + for _, prov := range targetProvinces { + url := fmt.Sprintf("https://dd.weather.gc.ca/citypage_weather/xml/%s/%s_e.xml", prov, stationCode) + fmt.Printf("[weather] Fetching current weather for %s in %s...\n", stationCode, prov) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(url) + if err != nil { + lastErr = err + continue + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("HTTP %s", resp.Status) + continue + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return WeatherData{}, fmt.Errorf("failed to read EC XML: %w", err) + } + + var parsed struct { + XMLName xml.Name `xml:"siteData"` + Location string `xml:"location>name"` + CurrentConditions struct { + Temperature string `xml:"temperature"` + Condition string `xml:"condition"` + RelativeHumidity string `xml:"relativeHumidity"` + Pressure string `xml:"pressure"` + Wind struct { + Speed string `xml:"speed"` + Direction string `xml:"direction"` + } `xml:"wind"` + } `xml:"currentConditions"` + } + + if err := xml.Unmarshal(body, &parsed); err != nil { + lastErr = fmt.Errorf("failed to parse EC XML: %w", err) + continue + } + + if parsed.CurrentConditions.Temperature == "" && parsed.CurrentConditions.Condition == "" { + lastErr = fmt.Errorf("no data for %s in %s", stationCode, prov) + continue + } + + temp, _ := strconv.ParseFloat(parsed.CurrentConditions.Temperature, 64) + + data := WeatherData{ + Station: parsed.Location, + Temperature: temp, + Condition: NormalizeCondition(parsed.CurrentConditions.Condition), + Humidity: parsed.CurrentConditions.RelativeHumidity, + Pressure: parsed.CurrentConditions.Pressure, + WindSpeed: parsed.CurrentConditions.Wind.Speed, + WindDir: parsed.CurrentConditions.Wind.Direction, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + + if err := SaveToCache(data); err != nil { + fmt.Println("[weather] Warning: failed to save cache:", err) + } + + fmt.Printf("[SUCCESS] Current: %.1f°C, %s (%s)\n", data.Temperature, data.Condition, prov) + return data, nil + } + + if lastErr != nil { + return WeatherData{}, fmt.Errorf("no valid feed found for %s: %v", stationCode, lastErr) + } + return WeatherData{}, fmt.Errorf("failed to fetch weather for %s", stationCode) +} + +// LoadCached loads the last weather data from disk for offline fallback. +func LoadCached() (WeatherData, error) { + cachePath := filepath.Join(config.DataDir, "last_weather.json") + file, err := os.Open(cachePath) + if err != nil { + return WeatherData{}, fmt.Errorf("no cached weather found") + } + defer file.Close() + + var data WeatherData + if err := json.NewDecoder(file).Decode(&data); err != nil { + return WeatherData{}, fmt.Errorf("failed to decode cache: %w", err) + } + + fmt.Println("[weather] Loaded cached weather →", data.Timestamp) + return data, nil +} diff --git a/internal/weather/normalize.go b/internal/weather/normalize.go new file mode 100644 index 0000000..6ef464d --- /dev/null +++ b/internal/weather/normalize.go @@ -0,0 +1,67 @@ +package weather + +import ( + "regexp" + "strings" +) + +// NormalizeCondition converts Environment Canada’s verbose condition text +// into a consistent, title-cased string suitable for display or icon mapping. +func NormalizeCondition(raw string) string { + raw = strings.TrimSpace(strings.ToLower(raw)) + if raw == "" { + return "Unknown" + } + + // Common regex cleanup patterns + replacements := map[*regexp.Regexp]string{ + regexp.MustCompile(`(?i)snowshower|snow showers?`): "Snow", + regexp.MustCompile(`(?i)rainshower|rain showers?`): "Rain", + regexp.MustCompile(`(?i)freezing rain|ice pellets|sleet`): "Freezing Rain", + regexp.MustCompile(`(?i)flurries?`): "Light Snow", + regexp.MustCompile(`(?i)thunderstorms?|tstorms?`): "Thunderstorm", + regexp.MustCompile(`(?i)drizzle`): "Drizzle", + regexp.MustCompile(`(?i)fog patches?|mist|haze|smoke|ash`): "Fog", + regexp.MustCompile(`(?i)blowing snow|drifting snow`): "Blowing Snow", + regexp.MustCompile(`(?i)freezing drizzle`): "Freezing Drizzle", + regexp.MustCompile(`(?i)showers? mixed with flurries?`): "Mixed Rain/Snow", + regexp.MustCompile(`(?i)snow mixed with rain`): "Mixed Rain/Snow", + regexp.MustCompile(`(?i)mainly cloudy|mostly cloudy`): "Cloudy", + regexp.MustCompile(`(?i)mainly sunny|mostly sunny|sunny`): "Sunny", + regexp.MustCompile(`(?i)partly cloudy|a few clouds`): "Partly Cloudy", + regexp.MustCompile(`(?i)clear|fair`): "Clear", + regexp.MustCompile(`(?i)rain and snow|rain/snow`): "Mixed Rain/Snow", + regexp.MustCompile(`(?i)light rain`): "Light Rain", + regexp.MustCompile(`(?i)heavy rain`): "Heavy Rain", + regexp.MustCompile(`(?i)light snow`): "Light Snow", + regexp.MustCompile(`(?i)heavy snow`): "Heavy Snow", + } + + for pattern, replacement := range replacements { + if pattern.MatchString(raw) { + raw = pattern.ReplaceAllString(raw, replacement) + } + } + + // Collapse multiple spaces and ensure proper capitalization + raw = strings.Join(strings.Fields(raw), " ") + raw = strings.Title(raw) + + // Final normalization + switch raw { + case "Cloudy Periods": + raw = "Cloudy" + case "Mostly Cloudy": + raw = "Cloudy" + case "Mostly Sunny": + raw = "Sunny" + } + + return raw +} + +// NormalizeWeatherData standardizes the Condition field in WeatherData. +func NormalizeWeatherData(data WeatherData) WeatherData { + data.Condition = NormalizeCondition(data.Condition) + return data +} diff --git a/scripts/update_geoip.sh b/scripts/update_geoip.sh new file mode 100755 index 0000000..e2aadaa --- /dev/null +++ b/scripts/update_geoip.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${MAXMIND_LICENSE:?Set MAXMIND_LICENSE env var before running}" + +DATA_DIR="${HOME}/.local/share/skyfeed" +TMP_DIR="$(mktemp -d)" +OUT_TAR="$TMP_DIR/geolite.tar.gz" +DEST_MMDB="$DATA_DIR/GeoLite2-City.mmdb" + +mkdir -p "$DATA_DIR" + +# MaxMind download URL pattern (uses license_key and suffix) +URL="https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=${MAXMIND_LICENSE}&suffix=tar.gz" + +echo "[update_geoip] Downloading GeoLite2-City..." +curl -sSL -A "Skyfeed/1.0 (+https://leaktechnologies.dev)" -o "$OUT_TAR" "$URL" + +echo "[update_geoip] Extracting .mmdb..." +# Tar contains folder like GeoLite2-City_YYYYMMDD/GeoLite2-City.mmdb +tar -xzf "$OUT_TAR" -C "$TMP_DIR" + +MMDB_PATH=$(find "$TMP_DIR" -type f -name "GeoLite2-City.mmdb" | head -n1) +if [ -z "$MMDB_PATH" ]; then + echo "[update_geoip] ERROR: .mmdb not found in archive" >&2 + exit 2 +fi + +# atomic replace +tmp_dest="${DEST_MMDB}.tmp" +cp "$MMDB_PATH" "$tmp_dest" +sync +mv -f "$tmp_dest" "$DEST_MMDB" + +echo "[update_geoip] Installed $DEST_MMDB" +rm -rf "$TMP_DIR"