Compare commits

...

2 Commits

27 changed files with 1905 additions and 392 deletions

View File

@ -3,21 +3,20 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/leaktechnologies/skyfeed/internal/config" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
"github.com/leaktechnologies/skyfeed/internal/geo" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/geo"
"github.com/leaktechnologies/skyfeed/internal/output" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/output"
"github.com/leaktechnologies/skyfeed/internal/weather" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather"
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "skyfeed", Use: "skyfeed",
Short: "Skyfeed - Open Weather Engine for Telefact and Terminal", Short: "Skyfeed - Open Weather Engine for Telefact and Terminal",
Long: `Skyfeed fetches and normalizes weather data from Environment Canada, 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.`, using automatic IP-based geolocation. It supports both CLI and API modes.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
cmd.Help() cmd.Help()
}, },
@ -30,7 +29,8 @@ func main() {
// Register subcommands // Register subcommands
rootCmd.AddCommand(fetchCmd) rootCmd.AddCommand(fetchCmd)
rootCmd.AddCommand(showCmd) rootCmd.AddCommand(showCmd)
rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(updateTownCmd)
rootCmd.AddCommand(debugLogoCmd)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Println("Error:", err) fmt.Println("Error:", err)
@ -38,21 +38,24 @@ func main() {
} }
} }
// ------------------------------ // ----------------------------------------------------
// Subcommands // Subcommands
// ------------------------------ // ----------------------------------------------------
var fetchCmd = &cobra.Command{ var fetchCmd = &cobra.Command{
Use: "fetch", Use: "fetch",
Short: "Fetch the latest weather data for your current location", Short: "Fetch the latest weather data for your current location",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
output.LogInfo("Skyfeed: Checking IP database...")
if err := geo.EnsureIPDBUpToDate(); err != nil { // Ensure town index is present and current
output.LogError(fmt.Sprintf("Failed to update IP DB: %v", err)) output.LogInfo("Skyfeed: Checking town index…")
if err := geo.EnsureTownIndexUpToDate(); err != nil {
output.LogError(fmt.Sprintf("Failed to update town index: %v", err))
return return
} }
output.LogInfo("Skyfeed: Detecting location...") // Detect location via new IP method
output.LogInfo("Skyfeed: Detecting location…")
city, lat, lon, err := geo.GetUserLocation() city, lat, lon, err := geo.GetUserLocation()
if err != nil { if err != nil {
output.LogError(fmt.Sprintf("Could not determine location: %v", err)) output.LogError(fmt.Sprintf("Could not determine location: %v", err))
@ -60,7 +63,8 @@ var fetchCmd = &cobra.Command{
} }
output.LogInfo(fmt.Sprintf("Detected: %s (%.4f, %.4f)", city, lat, lon)) output.LogInfo(fmt.Sprintf("Detected: %s (%.4f, %.4f)", city, lat, lon))
output.LogInfo("Finding nearest Environment Canada station...") // Find nearest EC station
output.LogInfo("Finding nearest Environment Canada station…")
station, err := geo.FindNearestStation(lat, lon) station, err := geo.FindNearestStation(lat, lon)
if err != nil { if err != nil {
output.LogError(fmt.Sprintf("Station lookup failed: %v", err)) output.LogError(fmt.Sprintf("Station lookup failed: %v", err))
@ -68,15 +72,21 @@ var fetchCmd = &cobra.Command{
} }
output.LogInfo(fmt.Sprintf("Nearest station: %s [%s]", station.Name, station.Code)) output.LogInfo(fmt.Sprintf("Nearest station: %s [%s]", station.Name, station.Code))
output.LogInfo("Fetching latest weather data...") // Fetch weather
// Determine province from the station (if available) output.LogInfo("Fetching latest weather data…")
province := strings.Split(station.Code, "_")[0] // fallback heuristic province := station.Province
data, err := weather.FetchCurrent(station.Code, province) data, err := weather.FetchCurrent(station.Code, province)
if err != nil { if err != nil {
output.LogError(fmt.Sprintf("Weather fetch failed: %v", err)) output.LogError(fmt.Sprintf("Weather fetch failed: %v", err))
return return
} }
// Render dynamic header/logo
output.PrintLogo(data.Condition)
fmt.Println()
// Main output
fmt.Println(output.FormatWeatherCLI(data, true)) fmt.Println(output.FormatWeatherCLI(data, true))
}, },
} }
@ -90,19 +100,49 @@ var showCmd = &cobra.Command{
output.LogError(fmt.Sprintf("Failed to load cache: %v", err)) output.LogError(fmt.Sprintf("Failed to load cache: %v", err))
return return
} }
output.PrintLogo(data.Condition)
fmt.Println()
fmt.Println(output.FormatWeatherCLI(data, true)) fmt.Println(output.FormatWeatherCLI(data, true))
}, },
} }
var updateCmd = &cobra.Command{ var updateTownCmd = &cobra.Command{
Use: "update-ipdb", Use: "update-towns",
Short: "Manually update the local IP geolocation database", Short: "Manually update the Canadian towns geolocation index",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
output.LogInfo("Forcing IP database update...") output.LogInfo("Forcing town index update…")
if err := geo.ForceUpdateIPDB(); err != nil { if err := geo.ForceUpdateTownIndex(); err != nil {
output.LogError(fmt.Sprintf("Update failed: %v", err)) output.LogError(fmt.Sprintf("Update failed: %v", err))
return return
} }
output.LogSuccess("IP database updated successfully.") output.LogSuccess("Town index updated successfully.")
},
}
// ----------------------------------------------------
// 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",
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
}
condition := args[0]
fmt.Printf("Rendering logo for condition: %s\n\n", condition)
output.PrintLogo(condition)
}, },
} }

View File

@ -0,0 +1,9 @@
{
"language": "en-CA",
"location": {
"mode": "auto",
"manual_city": "",
"manual_lat": 0,
"manual_lon": 0
}
}

42
docs/CHANGELOG.md Normal file
View File

@ -0,0 +1,42 @@
# Skyfeed Changelog
Format: vMAJOR.MINOR.PATCH[-devN|-betaN]
Branch: master
## Unreleased
- Add upcoming changes here.
## v0.1.0-dev1 — Initial Development Build
Tag: v0.1.0-dev1
Date: 2025-11-12
Added:
- Initial project structure and base modules.
- MaxMind GeoLite2 IP geolocation integration.
- IP → coordinates geolocation pipeline.
- Town index and town lookup system.
- Weather fetching via Environment Canada citypageWeather XML.
- Weather normalization system.
- Terminal output formatter.
- Config loader and defaults.
- Scheduler stubs for future background updates.
- UI stubs (terminal and GUI, currently inactive).
- Station index placeholders (in-progress).
Notes:
- First internal prototype.
- Many systems are incomplete or placeholders.
- Unstable behavior expected.
# Versioning Rules
Development builds:
v0.1.0-dev1
v0.1.0-dev2
v0.1.0-dev3
Beta builds:
v0.1.0-beta1
v0.1.0-beta2
Stable releases:
v0.1.0
v0.2.0

285
docs/LANGUAGE_SUPPORT.md Normal file
View File

@ -0,0 +1,285 @@
# Skyfeed Language and Localisation Support
Version v0.1.0-dev1
Skyfeed is a Canadian-focused weather engine and must provide native support for Canadian languages. This includes fully localised weather conditions, warnings, UI labels, and community/province names. The localisation system is shared with Telefact, so both systems display information consistently.
This document describes the localisation model, supported language bundles, file structure, selection rules, and long-term objectives.
------------------------------------------------------------
1. Supported Locales
------------------------------------------------------------
Skyfeed currently supports the following Canadian languages:
en-CA
Canadian English
Default system language
fr-CA
Canadian French
Required for federal bilingual standards
Matches terminology used by Environment Canada and Radio-Canada
iu-CA
Inuktitut
Written in syllabics
Primary language for Nunavut and northern communities
Provides localised place names and weather vocabulary
Additional languages may be added in future releases.
------------------------------------------------------------
2. Locale Selection Rules
------------------------------------------------------------
Skyfeed selects a language using the following priority:
1. If the user has configured a language in config/default.json, use it.
2. If the user specifies a language using a CLI flag, use it for that run.
3. On the first run, Skyfeed will prompt the user to choose a language.
4. If a translation key is missing for the chosen language, fall back to en-CA.
Telefact inherits the same localisation engine and will display pages using the configured language.
------------------------------------------------------------
3. Localisation File Structure
------------------------------------------------------------
All localisation bundles and place-name dictionaries are stored in:
internal/i18n/
Skyfeed uses the following files:
internal/i18n/en_CA.json
internal/i18n/fr_CA.json
internal/i18n/iu_CA.json
These contain:
locale code
language name
weather condition translations
weather alert translations
UI text
formatting rules
Place name dictionaries are separate:
internal/i18n/places_provinces.json
internal/i18n/places_nunavut.json
internal/i18n/places_cities.json (future)
Example of a translation structure:
{
"locale": "en-CA",
"language_name": "English",
"conditions": {
"clear": "Clear",
"snow": "Snow",
"freezing_rain": "Freezing Rain"
},
"alerts": {
"tornado_warning": "Tornado Warning",
"fog_advisory": "Fog Advisory"
},
"ui": {
"loading": "Loading...",
"fetching_weather": "Fetching latest weather..."
}
}
------------------------------------------------------------
4. Inuktitut (iu-CA) Support
------------------------------------------------------------
Inuktitut support includes:
1. Full syllabic script (ᐃᓄᒃᑎᑐᑦ)
2. Basic Roman orthography (future toggle)
3. Translation of all Nunavut community names
4. Translation of major northern place names in NWT (future)
5. Weather condition vocabulary
6. Weather alert vocabulary
The goal is to provide a natural-feeling experience for northern users based on public Inuktitut terminology resources.
Inuktitut keys that do not have translations will fall back to en-CA.
------------------------------------------------------------
5. Region-Based Default Language Logic
------------------------------------------------------------
Skyfeed may automatically choose a default language based on detected location:
Nunavut
Default: iu-CA
Secondary: en-CA
Quebec
Default: fr-CA
Secondary: en-CA
Rest of Canada
Default: en-CA
Users may override these defaults.
------------------------------------------------------------
6. First-Run Language Prompt
------------------------------------------------------------
On first run, the CLI or TUI will display:
Please select your language
1. English (en-CA)
2. Français (fr-CA)
3. ᐃᓄᒃᑎᑐᑦ (iu-CA)
The selected language will be saved in:
~/.local/share/skyfeed/config.json
or
config/default.json
------------------------------------------------------------
7. Localised Weather Alerts
------------------------------------------------------------
All alert types defined in TAXONOMY.md must provide translations for:
en-CA
fr-CA
iu-CA
Example:
tornado_warning
en-CA: Tornado Warning
fr-CA: Alerte Tornade
iu-CA: ᐊᓂᖅᑕᖅᑐᖅ ᐅᓪᓗᖓ
If a translation is unavailable in iu-CA or fr-CA, Skyfeed will use the English version automatically.
------------------------------------------------------------
8. Localised Weather Conditions
------------------------------------------------------------
Normalised condition keys (see WEATHER_TAXONOMY.md) must be translated.
Examples:
clear
en-CA: Clear
fr-CA: Dégagé
iu-CA: ᐅᖃᓕᒫᓂᖅ
snow
en-CA: Snow
fr-CA: Neige
iu-CA: ᐊᐳᑦ
freezing_rain
en-CA: Freezing Rain
fr-CA: Pluie Verglaçante
iu-CA: ᓯᕗᓂᖅ ᐱᒋᐊᓂ
------------------------------------------------------------
9. Localised UI Terminology
------------------------------------------------------------
Examples:
Fetching latest weather...
en-CA: Fetching weather data...
fr-CA: Récupération des données météo...
iu-CA: ᐊᐅᓪᓚᓂᖅ ᐅᑎᖃᑦᑕᖅ
Current conditions
en-CA: Current Conditions
fr-CA: Conditions Actuelles
iu-CA: ᐅᓂᒃᑳᖅᑐᖅ
Feels like
en-CA: Feels Like
fr-CA: Ressenti
iu-CA: ᐋᖅᑭᖅ ᐊᐅᓪᓚᓂᖅ
------------------------------------------------------------
10. Localised CLI Output
------------------------------------------------------------
Skyfeed will translate all textual output, including:
City names
Province names
Weather descriptions
Wind and visibility data
Warnings and colour-coded alerts
Error and status messages
Example French output:
Cornwall, Ontario
14:05 EST
Ciel dégagé
Ressenti 22°C
Vent O 10 km/h
Avertissement de pluie
------------------------------------------------------------
11. UI and Telefact Integration
------------------------------------------------------------
Skyfeed GUI
Uses the same translation bundles
Respects user language preference
Telefact
All pages, headings, alerts, and conditions will use the correct language bundle
Inuktitut news feeds will be supported as they become available
------------------------------------------------------------
12. Testing Localisation
------------------------------------------------------------
Tests must ensure:
All missing keys fall back to en-CA
Invalid locale codes default to en-CA
iu-CA renders properly even with partial translations
Weather numerical formatting follows Canadian standards
Wind directions convert correctly per language
Additionally, a debug command will allow testing all languages:
skyfeed debug-languages --location "Iqaluit, NU"
------------------------------------------------------------
13. Future Language Expansion
------------------------------------------------------------
Future Canadian languages may include:
Cree (cr-CA)
Ojibwe (oj-CA)
Innu (innu-CA)
Dene (den-CA)
Dakelh
Mohawk
These will be added gradually as vocabulary sets become available.
------------------------------------------------------------
14. Limitations
------------------------------------------------------------
Some weather concepts may lack simple Inuktitut or Cree translations
Non-standard alerts may require English fallback
Some community names outside Nunavut may not have official Indigenous forms
Translation refinement from native speakers may be required
Skyfeed must always remain functional even if translations are incomplete.
------------------------------------------------------------
End of LANGUAGE_SUPPORT.md

26
docs/TAGGING.md Normal file
View File

@ -0,0 +1,26 @@
# Skyfeed Tagging & Versioning Policy
**Branch:** `master` (primary branch — no `main`)
## Version Format
vMAJOR.MINOR.PATCH[-devN|-betaN]
### Examples
- `v0.1.0-dev1` → first development build for v0.1.0
- `v0.1.0-dev2` → second dev iteration
- `v0.1.0-beta1` → pre-release for testing
- `v0.1.0` → stable tagged release
### Rules
- Increment **PATCH** for small fixes or cleanup.
- Increment **MINOR** for added features.
- Increment **MAJOR** for breaking changes or major re-architecture.
- Every push that meaningfully changes behavior should get a new `-devN` tag.
- Tags are always annotated (with `-a`), containing a descriptive changelog message.
### Commands
```bash
git add .
git commit -m "Describe change"
git tag -a v0.1.0-dev2 -m "Second dev build: IP resolver refactor"
git push && git push origin v0.1.0-dev2

388
docs/TAXONOMY.md Normal file
View File

@ -0,0 +1,388 @@
[FILE: docs/TAXONOMY.md]
# Skyfeed Weather Alert Taxonomy Canada
Version v0.1.0-dev1
Scope Environment Canada MSC Alerts for Skyfeed and Telefact
Skyfeed is a Canada-focused weather engine.
This taxonomy defines all alert types Skyfeed aims to support, based on
Environment Canada Meteorological Service of Canada
Canadian severe weather and marine alerts
Air quality and wildfire smoke products
The goal is to mirror every alert type that can realistically be issued for Canada, so that
Nothing important is missed
Alerts can be formatted and colour-coded cleanly
The taxonomy can be localised to en-CA, fr-CA, and iu-CA Inuktitut
Note This file is taxonomy only.
Implementation details will live in internalweatheralerts.go and related files.
------------------------------------------------------------
1. Alert Levels and Concepts
------------------------------------------------------------
Skyfeed recognises these conceptual alert levels
Warning
Watch
Advisory
Statement
Information or Special Weather
Severity handling
1 Warnings
2 Watches
3 Advisories
4 Statements and informational products
------------------------------------------------------------
2. Core Severe Weather Warnings
------------------------------------------------------------
Tornado Warning
Severe Thunderstorm Warning
Snow Squall Warning
Squall Warning
Winter Storm Warning
Blizzard Warning
Snowfall Warning
Rainfall Warning
Freezing Rain Warning
Flash Freeze Warning
Wind Warning
Extreme Cold Warning
Heat Warning
Arctic Outflow Warning
Storm Surge Warning
Tropical Storm Warning
Hurricane Warning
Storm Warning Marine
Gale Warning Marine
Freezing Spray Warning Marine
High Water Level Warning
------------------------------------------------------------
3. Watches
------------------------------------------------------------
Tornado Watch
Severe Thunderstorm Watch
Snow Squall Watch
Special Weather Watch
Heat Watch
Cold Watch
Wind Watch
Hurricane Watch
Tropical Storm Watch
Storm Surge Watch
------------------------------------------------------------
4. Advisories
------------------------------------------------------------
Fog Advisory
Freezing Drizzle Advisory
Freezing Fog Advisory
Special Weather Advisory
Rainfall Advisory
Snowfall Advisory
Wind Advisory
Blowing Snow Advisory
Marine Fog Advisory
Freezing Spray Advisory
------------------------------------------------------------
5. Statements and Information Products
------------------------------------------------------------
Weather Statement
Special Weather Statement
Air Quality Statement
Smoke Statement
Dust Statement
Temperature Statement
Tropical Cyclone Information Statement
Outlook or Long Range Statement
------------------------------------------------------------
6. Air Quality and Wildfire Related Alerts
------------------------------------------------------------
Air Quality Advisory
Air Quality Statement
Wildfire Smoke Advisory
Wildfire Smoke Statement
Smoke Advisory
Dust and Smoke Statement
------------------------------------------------------------
7. Marine and Coastal Alerts
------------------------------------------------------------
Gale Warning
Storm Warning
Hurricane Force Wind Warning
Strong Wind Warning
Freezing Spray Warning
Squall Warning
Blizzard Warning Marine
High Water Level Warning
Storm Surge Warning
Wave or Surf Warning
------------------------------------------------------------
8. Arctic and Northern-Specific Alerts
------------------------------------------------------------
Arctic Outflow Warning
Extreme Cold Warning
Wind Chill Warning
Blowing Snow Advisory
Blizzard Warning
Freezing Spray Warning
------------------------------------------------------------
9. Rare but Possible Phenomena in Canada
------------------------------------------------------------
Funnel Cloud Advisory
Waterspout Warning
Tropical Cyclone Information Statement
Thunderstorm Outlook or Convective Outlook
------------------------------------------------------------
10. Out of Scope For Now
------------------------------------------------------------
Volcano Warning
Ashfall Warning
Lahar Warning
Lava Flow Warning
Tsunami Warning or Advisory
Radiation or Nuclear Incident Warning
Sandstorm or Haboob Alert
If such an event appears in CAP data, Skyfeed will classify it under Other Alert.
------------------------------------------------------------
11. Internal Skyfeed Alert Codes Planned
------------------------------------------------------------
Skyfeed will define internal alert keys, for example
tornado_warning
severe_thunderstorm_warning
snow_squall_warning
heat_warning
fog_advisory
air_quality_statement
arctic_outflow_warning
hurricane_watch
wildfire_smoke_advisory
Each code will map to
English label
French label
Inuktitut label
Severity
Colour theme
Optional long description
------------------------------------------------------------
12. Language and Localisation Notes
------------------------------------------------------------
Supported languages
en-CA / Canadian English
fr-CA / Canadian French
iu-CA / Inuktitut
------------------------------------------------------------
13. Future Work
------------------------------------------------------------
CAP to Skyfeed mapping layer
Colour and icon system
Alert grouping rules
Multi-language rendering
Per-page prioritisation for Telefact
END OF FILE
[FILE: docs/TAXONOMY_WEATHER.md]
# Skyfeed Weather Condition Taxonomy
Version v0.1.0-dev1
Scope Normalised condition codes for Canada for Skyfeed and Telefact
This document defines all base weather conditions, grouped logically, and provides stable internal keys for Skyfeed.
Translations will be handled separately.
------------------------------------------------------------
1. Skyfeed Condition Structure
------------------------------------------------------------
Each condition has
A unique internal key
A short description
A category
A suggested icon
A colour theme mapping
Categories are
Sky conditions
Precipitation
Visibility
Wind
Arctic or cold-specific
Thunderstorm and convection
Smoke and air quality
Marine
------------------------------------------------------------
2. Sky Conditions
------------------------------------------------------------
clear
mainly_clear
partly_cloudy
mostly_cloudy
cloudy
overcast
sunny_breaks
cloudy_with_sunny_breaks
------------------------------------------------------------
3. Precipitation Types
------------------------------------------------------------
drizzle
light_drizzle
freezing_drizzle
rain
light_rain
heavy_rain
freezing_rain
rain_showers
snow
light_snow
heavy_snow
ice_pellets
sleet
mixed_precipitation
snow_showers
rain_and_snow
hail
graupel
------------------------------------------------------------
4. Visibility and Obscuration
------------------------------------------------------------
fog
freezing_fog
mist
haze
smoke
blowing_snow
drifting_snow
dust
sand
ice_crystals
------------------------------------------------------------
5. Wind and Motion
------------------------------------------------------------
wind_light
wind_moderate
wind_strong
wind_gale
wind_storm
wind_hurricane_force
gusty_winds
------------------------------------------------------------
6. Thunderstorm and Convective Weather
------------------------------------------------------------
thunderstorm
strong_thunderstorm
severe_thunderstorm
thundershowers
lightning_only
------------------------------------------------------------
7. Arctic and Northern Conditions
------------------------------------------------------------
extreme_cold
wind_chill
arctic_outflow
ice_fog
polar_low
freezing_spray
sea_ice
blizzard_conditions
------------------------------------------------------------
8. Marine Weather Conditions
------------------------------------------------------------
marine_fog
marine_freezing_spray
rough_seas
high_waves
storm_surge_conditions
water_spout
------------------------------------------------------------
9. Special and Rare Conditions
------------------------------------------------------------
funnel_cloud
volcanic_ash
ash_in_air
dust_storm
smoke_storm
------------------------------------------------------------
10. Normalisation Table Notes
------------------------------------------------------------
Environment Canada condition strings must be mapped into the above keys.
Examples
Mainly Sunny maps to mainly_clear
A Few Clouds maps to partly_cloudy
Periods of Rain maps to rain
Risk of Freezing Rain maps to freezing_rain
Snow at times heavy maps to heavy_snow
Ice Crystals maps to ice_crystals
Local Smoke maps to smoke
Haze maps to haze
Mist maps to mist
Thunderstorms maps to thunderstorm unless severity tag present
A complete mapping list will be implemented in internalweathernormalize.go.
------------------------------------------------------------
11. Colour Theme Notes
------------------------------------------------------------
clear primary sky blue and secondary gold
cloudy light grey to blue-grey
rain slate blue
snow white to light grey
fog mid grey
smoke brown-grey
thunderstorm violet purple accents
arctic cold pale blue and white
marine deep blue
These themes apply to the Skyfeed logo.
END OF FILE

2
go.mod
View File

@ -1,4 +1,4 @@
module github.com/leaktechnologies/skyfeed module git.leaktechnologies.dev/Leak_Technologies/Skyfeed
go 1.25.4 go 1.25.4

View File

@ -5,15 +5,16 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"path/filepath" "strings"
"time" "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. // Public IP + Geolocation (ipwho.is)
// -------------------------------
// GetUserLocation resolves the user's IP into:
// city, latitude, longitude
func GetUserLocation() (string, float64, float64, error) { func GetUserLocation() (string, float64, float64, error) {
fmt.Println("[geo] Detecting location...") fmt.Println("[geo] Detecting location...")
@ -22,36 +23,22 @@ func GetUserLocation() (string, float64, float64, error) {
return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err) return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err)
} }
dbPath := filepath.Join(config.DataDir, "GeoLite2-City.mmdb") city, province, lat, lon, err := lookupIP(ip.String())
db, err := geoip2.Open(dbPath)
if err != nil { if err != nil {
return "", 0, 0, fmt.Errorf("failed to open GeoLite2 database: %w", err) return "", 0, 0, fmt.Errorf("IP geolocation failed: %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"] // e.g., "Ottawa, Ontario"
prov := "" locationName := fmt.Sprintf("%s, %s", city, province)
if len(record.Subdivisions) > 0 {
prov = record.Subdivisions[0].Names["en"]
}
lat := record.Location.Latitude fmt.Printf("[geo] Detected: %s (%.4f, %.4f)\n", locationName, lat, lon)
lon := record.Location.Longitude return locationName, lat, lon, nil
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. // -------------------------------
// PUBLIC IP DETECTION
// -------------------------------
func fetchPublicIP() (net.IP, error) { func fetchPublicIP() (net.IP, error) {
providers := []string{ providers := []string{
"https://ipv4.icanhazip.com", "https://ipv4.icanhazip.com",
@ -72,11 +59,10 @@ func fetchPublicIP() (net.IP, error) {
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) { func tryProvider(url string, client *http.Client) (net.IP, error) {
resp, err := client.Get(url) resp, err := client.Get(url)
if err != nil { if err != nil {
return nil, fmt.Errorf("network error (%s): %w", url, err) return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -84,30 +70,79 @@ func tryProvider(url string, client *http.Client) (net.IP, error) {
return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url) return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url)
} }
// Some APIs return plain text, others JSON // JSON-based API fallback
var result struct { var j struct{ IP string `json:"ip"` }
IP string `json:"ip"`
}
// Try decode JSON if err := json.NewDecoder(resp.Body).Decode(&j); err == nil && j.IP != "" {
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.IP != "" { ip := net.ParseIP(j.IP)
ip := net.ParseIP(result.IP)
if ip != nil && ip.To4() != nil { if ip != nil && ip.To4() != nil {
return ip, nil return ip, nil
} }
} }
// Fallback: plain text // Plain text fallback → must re-fetch body
resp2, err := client.Get(url) resp2, err := client.Get(url)
if err == nil { if err != nil {
return nil, err
}
defer resp2.Body.Close() defer resp2.Body.Close()
buf := make([]byte, 64) buf := make([]byte, 64)
n, _ := resp2.Body.Read(buf) n, _ := resp2.Body.Read(buf)
ip := net.ParseIP(string(buf[:n])) ip := net.ParseIP(strings.TrimSpace(string(buf[:n])))
if ip != nil && ip.To4() != nil { if ip != nil && ip.To4() != nil {
return ip, 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)
} }
return nil, fmt.Errorf("no valid IP found from %s", url) 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
} }

View File

@ -1,137 +0,0 @@
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")
}

View File

@ -12,7 +12,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/leaktechnologies/skyfeed/internal/config" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
) )
// Station represents an Environment Canada citypage station. // Station represents an Environment Canada citypage station.

10
internal/geo/towns.go Normal file
View File

@ -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"`
}

View File

@ -7,17 +7,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/leaktechnologies/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. // FindNearestTown loads the cached towns.json and finds the closest town to given coordinates.
func FindNearestTown(lat, lon float64) (Town, error) { func FindNearestTown(lat, lon float64) (Town, error) {
townsPath := filepath.Join(config.DataDir, "towns.json") townsPath := filepath.Join(config.DataDir, "towns.json")
@ -40,7 +32,7 @@ func FindNearestTown(lat, lon float64) (Town, error) {
var nearest Town var nearest Town
for _, t := range towns { for _, t := range towns {
d := Haversine(lat, lon, t.Lat, t.Lon) // ✅ use shared helper from stations.go d := Haversine(lat, lon, t.Lat, t.Lon)
if d < minDist { if d < minDist {
minDist = d minDist = d
nearest = t nearest = t
@ -51,6 +43,8 @@ func FindNearestTown(lat, lon float64) (Town, error) {
return Town{}, fmt.Errorf("no nearby town found") return Town{}, fmt.Errorf("no nearby town found")
} }
fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n", nearest.Name, nearest.Province, minDist) fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n",
nearest.Name, nearest.Province, minDist)
return nearest, nil return nearest, nil
} }

View File

@ -9,25 +9,16 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/leaktechnologies/skyfeed/internal/config" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
) )
const ( const (
// Official Geographical Names Board of Canada WFS API // WORKING GeoGratis endpoint for all populated places (PPL)
// Docs: https://www.nrcan.gc.ca/earth-sciences/geography/geographical-names-board-canada/download-geographical-names-data/10786 gnbcAPIURL = "https://geogratis.gc.ca/services/geoname/en/geonames.json?feature_code=PPL"
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" townsFile = "towns.json"
maxFetchTime = 5 * time.Minute 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). // EnsureTownIndexUpToDate checks if the towns index needs updating (monthly).
func EnsureTownIndexUpToDate() error { func EnsureTownIndexUpToDate() error {
dest := filepath.Join(config.DataDir, townsFile) dest := filepath.Join(config.DataDir, townsFile)
@ -62,11 +53,10 @@ func ForceUpdateTownIndex() error {
return downloadTownIndex(dest) return downloadTownIndex(dest)
} }
// downloadTownIndex fetches and stores the Canadian town dataset.
func downloadTownIndex(dest string) error { 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...") fmt.Println("[geo] Fetching town data from GeoGratis...")
resp, err := client.Get(gnbcAPIURL) resp, err := client.Get(gnbcAPIURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch town dataset: %w", err) return fmt.Errorf("failed to fetch town dataset: %w", err)
@ -79,17 +69,17 @@ func downloadTownIndex(dest string) error {
raw, err := io.ReadAll(resp.Body) raw, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("failed to read GNBC response: %w", err) return fmt.Errorf("failed to read GeoGratis response: %w", err)
} }
towns, err := parseGNBCJSON(raw) towns, err := parseGNBCJSON(raw)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse GNBC JSON: %w", err) return fmt.Errorf("failed to parse GeoGratis JSON: %w", err)
} }
data, err := json.MarshalIndent(towns, "", " ") data, err := json.MarshalIndent(towns, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("failed to encode towns: %w", err) return fmt.Errorf("failed to encode towns.json: %w", err)
} }
if err := os.WriteFile(dest, data, 0644); err != nil { if err := os.WriteFile(dest, data, 0644); err != nil {
@ -100,34 +90,32 @@ func downloadTownIndex(dest string) error {
return nil return nil
} }
// parseGNBCJSON extracts relevant town info from the GNBC GeoJSON. // Uses GeoGratis geonames.json structure
func parseGNBCJSON(data []byte) ([]TownRecord, error) { func parseGNBCJSON(data []byte) ([]Town, error) {
var response struct { var response struct {
Features []struct { Items []struct {
Properties struct {
Name string `json:"name"` Name string `json:"name"`
Province string `json:"province"` ProvinceCode string `json:"province_code"`
Latitude float64 `json:"latitude"` Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"` Longitude float64 `json:"longitude"`
} `json:"properties"` } `json:"items"`
} `json:"features"`
} }
if err := json.Unmarshal(data, &response); err != nil { if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("invalid GNBC JSON: %w", err) return nil, fmt.Errorf("invalid GeoGratis JSON: %w", err)
} }
var towns []TownRecord var towns []Town
for _, f := range response.Features { for _, item := range response.Items {
p := f.Properties if item.Name == "" || item.ProvinceCode == "" {
if p.Name == "" || p.Province == "" {
continue continue
} }
towns = append(towns, TownRecord{
Name: p.Name, towns = append(towns, Town{
Province: p.Province, Name: item.Name,
Lat: p.Latitude, Province: item.ProvinceCode,
Lon: p.Longitude, Lat: item.Latitude,
Lon: item.Longitude,
}) })
} }

5
internal/locale/en_CA.go Normal file
View File

@ -0,0 +1,5 @@
package locale
func init() {
// Loaded automatically from en_CA.json via LoadAll()
}

5
internal/locale/fr_CA.go Normal file
View File

@ -0,0 +1,5 @@
package locale
func init() {
// Loaded automatically from fr_CA.json
}

View File

@ -0,0 +1,131 @@
{
"locale": "en-CA",
"language_name": "English",
"script": "latin",
"ui": {
"loading": "Loading...",
"fetching_weather": "Fetching weather data...",
"current_conditions": "Current Conditions",
"feels_like": "Feels Like",
"humidity": "Humidity",
"wind": "Wind",
"pressure": "Pressure",
"visibility": "Visibility",
"sunrise": "Sunrise",
"sunset": "Sunset",
"alerts": "Alerts",
"no_alerts": "No active alerts",
"updated": "Updated",
"location": "Location",
"language": "Language",
"select_language": "Select your language",
"select_location": "Select your location",
"manual_entry": "Enter Manually"
},
"conditions": {
"clear": "Clear",
"mainly_clear": "Mainly Clear",
"partly_cloudy": "Partly Cloudy",
"mostly_cloudy": "Mostly Cloudy",
"cloudy": "Cloudy",
"overcast": "Overcast",
"rain": "Rain",
"light_rain": "Light Rain",
"moderate_rain": "Moderate Rain",
"heavy_rain": "Heavy Rain",
"freezing_rain": "Freezing Rain",
"drizzle": "Drizzle",
"freezing_drizzle": "Freezing Drizzle",
"snow": "Snow",
"light_snow": "Light Snow",
"moderate_snow": "Moderate Snow",
"heavy_snow": "Heavy Snow",
"snow_grains": "Snow Grains",
"blowing_snow": "Blowing Snow",
"ice_pellets": "Ice Pellets",
"hail": "Hail",
"fog": "Fog",
"dense_fog": "Dense Fog",
"freezing_fog": "Freezing Fog",
"mist": "Mist",
"thunderstorm": "Thunderstorm",
"thunderstorm_rain": "Thunderstorm with Rain",
"thunderstorm_snow": "Thunderstorm with Snow",
"smoke": "Smoke",
"haze": "Haze",
"dust": "Dust",
"blowing_dust": "Blowing Dust",
"ash": "Ash",
"funnel_cloud": "Funnel Cloud",
"waterspout": "Waterspout"
},
"alerts": {
"tornado_warning": "Tornado Warning",
"tornado_watch": "Tornado Watch",
"severe_thunderstorm_warning": "Severe Thunderstorm Warning",
"severe_thunderstorm_watch": "Severe Thunderstorm Watch",
"snow_squall_warning": "Snow Squall Warning",
"snow_squall_watch": "Snow Squall Watch",
"winter_storm_warning": "Winter Storm Warning",
"blizzard_warning": "Blizzard Warning",
"snowfall_warning": "Snowfall Warning",
"rainfall_warning": "Rainfall Warning",
"flash_freeze_warning": "Flash Freeze Warning",
"freezing_rain_warning": "Freezing Rain Warning",
"wind_warning": "Wind Warning",
"wind_watch": "Wind Watch",
"heat_warning": "Heat Warning",
"cold_warning": "Cold Warning",
"extreme_cold_warning": "Extreme Cold Warning",
"arctic_outflow_warning": "Arctic Outflow Warning",
"wind_chill_warning": "Wind Chill Warning",
"fog_advisory": "Fog Advisory",
"freezing_fog_advisory": "Freezing Fog Advisory",
"freezing_drizzle_advisory": "Freezing Drizzle Advisory",
"blowing_snow_advisory": "Blowing Snow Advisory",
"air_quality_statement": "Air Quality Statement",
"air_quality_advisory": "Air Quality Advisory",
"smoke_statement": "Smoke Statement",
"smoke_advisory": "Smoke Advisory",
"wildfire_smoke_advisory": "Wildfire Smoke Advisory",
"dust_statement": "Dust Statement",
"blowing_dust_advisory": "Blowing Dust Advisory",
"special_weather_statement": "Special Weather Statement",
"weather_statement": "Weather Statement",
"marine_freezing_spray_warning": "Freezing Spray Warning",
"marine_gale_warning": "Gale Warning",
"marine_storm_warning": "Storm Warning",
"marine_hurricane_force_warning": "Hurricane Force Wind Warning",
"marine_strong_wind_warning": "Strong Wind Warning",
"tropical_storm_warning": "Tropical Storm Warning",
"tropical_storm_watch": "Tropical Storm Watch",
"hurricane_warning": "Hurricane Warning",
"hurricane_watch": "Hurricane Watch",
"storm_surge_warning": "Storm Surge Warning",
"storm_surge_watch": "Storm Surge Watch",
"funnel_cloud_advisory": "Funnel Cloud Advisory",
"waterspout_warning": "Waterspout Warning"
}
}

View File

@ -0,0 +1,131 @@
{
"locale": "fr-CA",
"language_name": "Français",
"script": "latin",
"ui": {
"loading": "Chargement...",
"fetching_weather": "Récupération des données météo...",
"current_conditions": "Conditions actuelles",
"feels_like": "Ressenti",
"humidity": "Humidité",
"wind": "Vent",
"pressure": "Pression",
"visibility": "Visibilité",
"sunrise": "Lever du soleil",
"sunset": "Coucher du soleil",
"alerts": "Alertes",
"no_alerts": "Aucune alerte active",
"updated": "Mis à jour",
"location": "Emplacement",
"language": "Langue",
"select_language": "Choisissez votre langue",
"select_location": "Choisissez votre emplacement",
"manual_entry": "Entrée manuelle"
},
"conditions": {
"clear": "Dégagé",
"mainly_clear": "Généralement dégagé",
"partly_cloudy": "Partiellement nuageux",
"mostly_cloudy": "Généralement nuageux",
"cloudy": "Nuageux",
"overcast": "Couvert",
"rain": "Pluie",
"light_rain": "Pluie faible",
"moderate_rain": "Pluie modérée",
"heavy_rain": "Pluie forte",
"freezing_rain": "Pluie verglaçante",
"drizzle": "Bruine",
"freezing_drizzle": "Bruine verglaçante",
"snow": "Neige",
"light_snow": "Neige faible",
"moderate_snow": "Neige modérée",
"heavy_snow": "Neige forte",
"snow_grains": "Grains de neige",
"blowing_snow": "Poudrerie",
"ice_pellets": "Granules de glace",
"hail": "Grêle",
"fog": "Brouillard",
"dense_fog": "Brouillard dense",
"freezing_fog": "Brouillard givrant",
"mist": "Brume",
"thunderstorm": "Orage",
"thunderstorm_rain": "Orage avec pluie",
"thunderstorm_snow": "Orage avec neige",
"smoke": "Fumée",
"haze": "Brume sèche",
"dust": "Poussière",
"blowing_dust": "Poussière soulevée",
"ash": "Cendre",
"funnel_cloud": "Nuage en entonnoir",
"waterspout": "Trombe marine"
},
"alerts": {
"tornado_warning": "Alerte de tornade",
"tornado_watch": "Veille de tornade",
"severe_thunderstorm_warning": "Alerte dorage violent",
"severe_thunderstorm_watch": "Veille dorage violent",
"snow_squall_warning": "Alerte de rafales de neige",
"snow_squall_watch": "Veille de rafales de neige",
"winter_storm_warning": "Alerte de tempête hivernale",
"blizzard_warning": "Alerte de blizzard",
"snowfall_warning": "Alerte de neige",
"rainfall_warning": "Alerte de pluie",
"flash_freeze_warning": "Alerte de gel éclair",
"freezing_rain_warning": "Alerte de pluie verglaçante",
"wind_warning": "Alerte de vents forts",
"wind_watch": "Veille de vents forts",
"heat_warning": "Alerte de chaleur",
"cold_warning": "Alerte de froid",
"extreme_cold_warning": "Alerte de froid extrême",
"arctic_outflow_warning": "Alerte découlement arctique",
"wind_chill_warning": "Alerte de refroidissement éolien",
"fog_advisory": "Avis de brouillard",
"freezing_fog_advisory": "Avis de brouillard givrant",
"freezing_drizzle_advisory": "Avis de bruine verglaçante",
"blowing_snow_advisory": "Avis de poudrerie",
"air_quality_statement": "Déclaration sur la qualité de lair",
"air_quality_advisory": "Avis sur la qualité de lair",
"smoke_statement": "Déclaration de fumée",
"smoke_advisory": "Avis de fumée",
"wildfire_smoke_advisory": "Avis de fumée de feux de forêt",
"dust_statement": "Déclaration de poussière",
"blowing_dust_advisory": "Avis de poussière soulevée",
"special_weather_statement": "Déclaration météorologique spéciale",
"weather_statement": "Déclaration météo",
"marine_freezing_spray_warning": "Avertissement de givre marin",
"marine_gale_warning": "Avertissement de coup de vent",
"marine_storm_warning": "Avertissement de tempête",
"marine_hurricane_force_warning": "Avertissement de vents de force ouragan",
"marine_strong_wind_warning": "Avertissement de vents forts",
"tropical_storm_warning": "Avertissement de tempête tropicale",
"tropical_storm_watch": "Veille de tempête tropicale",
"hurricane_warning": "Avertissement douragan",
"hurricane_watch": "Veille douragan",
"storm_surge_warning": "Avertissement de marée de tempête",
"storm_surge_watch": "Veille de marée de tempête",
"funnel_cloud_advisory": "Avis de nuage en entonnoir",
"waterspout_warning": "Avertissement de trombe marine"
}
}

View File

@ -0,0 +1,125 @@
{
"locale": "iu-CA",
"language_name": "ᐃᓄᒃᑎᑐᑦ (Inuktitut)",
"script": "syllabics_with_roman",
"ui": {
"loading": "ᐊᐅᓚᑦᑕᖅ... (aulattaq)",
"fetching_weather": "ᐅᑎᖃᑦᑕᖅ ᐊᐃᑉᐳᖅ... (utiqattaq aippuq)",
"current_conditions": "ᐅᓂᒃᑳᖅᑐᖅ (unikkaaqtuk)",
"feels_like": "ᐋᖅᑭᖅ (aaqiq)",
"humidity": "ᐃᒡᓗᒃᑕᐅᓂᖅ (igluktauq)",
"wind": "ᐊᑕᐅᓯᕐᓂᖅ (atausirniq)",
"pressure": "ᐅᒥᐊᕆᓂᖅ (umiarniq)",
"visibility": "ᐅᓪᓗᖅᑐᖅ (ullurqtuk)",
"sunrise": "ᐅᐳᓗᐃᑦ (upuluit)",
"sunset": "ᐊᖅᑕᐅᓯᖅ (aqtausiq)",
"alerts": "ᐅᖃᐅᓯᓕᖅᑯᑦ (uqausiliqkut)",
"no_alerts": "ᐅᖃᐅᓯᓕᖅᑯᑦ ᐱᑕᖃᙱᑦᑐᑦ (uqausiliqkut pitagaqngittut)",
"updated": "ᓄᓇᕗᑦ (nunavut)",
"location": "ᐃᓕᓴᐃᔭᖅ (ilisajaaq)",
"select_language": "ᐅᖃᐅᓯᖅ ᐅᖃᐅᓯᕐᓂᖅ (uqausiq uqaunirq)",
"select_location": "ᐃᓕᓴᐃᔭᖅ ᐅᖃᐅᓯᖅ (ilisajaaq uqausiq)",
"manual_entry": "ᐅᖃᐅᓯᓂᖅ ᐊᑐᖅᑳᖅ (uqausiniq atukkaa)"
},
"conditions": {
"clear": "ᐅᖃᓕᒫᓂᖅ (uqarliiq)",
"mainly_clear": "ᐅᖃᓕᒫᓂᖅ ᐊᒻᒪ ᑕᒻᒪᖁᑎᖅ (uqarliiq amma tammaqutiq)",
"partly_cloudy": "ᐋᖅᑐᖅ ᐃᕐᖃᑕᐅᓯᖅ (aaktuq irqatutsi)",
"mostly_cloudy": "ᐃᕐᖃᑕᐅᓯᖅ ᐊᒻᒪ ᐅᖃᓕᒫᓂᖅ (irqatutsi amma uqarliiq)",
"cloudy": "ᐃᕐᖃᑕᐅᓯᖅ (irqatutsi)",
"overcast": "ᐃᕐᖃᑕᐅᓯᖅ ᐅᓐᓂᐊᕐᓗᒋᑦ (irqatutsi unniarlugit)",
"rain": "ᒪᓚᐅᑦ (malaut)",
"light_rain": "ᒪᓚᐅᑦ ᓇᐅᔭᖅ (malaut nauyaq)",
"moderate_rain": "ᒪᓚᐅᑦ ᐅᓐᓂᐊᕆᐊᖅ (malautunniariak)",
"heavy_rain": "ᒪᓚᐅᑦ ᐱᔪᓐᓇᕐᓗᒍ (malaut pijunnarluq)",
"freezing_rain": "ᓯᕗᓂᖅ ᒪᓚᐅᑦ (sivuniq malaut)",
"drizzle": "ᐃᓄᐃᑕᐅᖅ ᒪᓚᐅᑦ (inuittauq malaut)",
"freezing_drizzle": "ᓯᕗᓂᖅ ᐃᓄᐃᑕᐅᖅ (sivuniq inuittauq)",
"snow": "ᐊᐳᑦ (aput)",
"light_snow": "ᐊᐳᑦ ᓇᐅᔭᖅ (aput nauyaq)",
"moderate_snow": "ᐊᐳᑦ ᐅᓐᓂᐊᕆᐊᖅ (aputunniariak)",
"heavy_snow": "ᐊᐳᑦ ᐱᔪᓐᓇᕐᓗᒍ (aput pijunnarluq)",
"snow_grains": "ᐊᐳᑦ ᓄᓇᓱᒃ (aput nunasuk)",
"blowing_snow": "ᐊᐳᑦ ᐊᑕᐅᓯᕐᓗᒍ (aput ataussirluk)",
"ice_pellets": "ᓯᕗᓂᖅ ᓄᓇᓱᒃ (sivuniq nunasuk)",
"hail": "ᐱᒋᐊᓯᐅᖅ (pijasiuq)",
"fog": "ᐃᒡᓗᒃᑕᐅᓯᖅ (igluktauqsik)",
"dense_fog": "ᐃᒡᓗᒃᑕᐅᓯᖅ ᐱᔪᓐᓇᖅ (igluktauqsik pijunnaq)",
"freezing_fog": "ᓯᕗᓂᖅ ᐃᒡᓗᒃᑕᐅᓯᖅ (sivuniq igluktauqsik)",
"mist": "ᐃᒡᓗᒃᑕᐅᐳᖅ (igluktaupuk)",
"thunderstorm": "ᒪᒃᑯᖅ (makkuq)",
"thunderstorm_rain": "ᒪᒃᑯᖅ ᒪᓚᐅᑦ (makkuq malaut)",
"thunderstorm_snow": "ᒪᒃᑯᖅ ᐊᐳᑦ (makkuq aput)",
"smoke": "ᐊᐅᓐᓇᓯᖅ (aunnasiq)",
"haze": "ᐅᐳᓐᓂᐊᕆᐊᖅ ᐊᐅᓐᓇᓯᖅ (upunniariaq aunnasiq)",
"dust": "ᐊᓕᐅᑲᖅ (aliuqaq)",
"blowing_dust": "ᐊᓕᐅᑲᖅ ᐊᑕᐅᓯᕐᓗᒍ (aliuqaq ataussirluk)",
"ash": "ᐋᔾᔪ (aajju)",
"funnel_cloud": "ᐅᖃᕐᖅᑐᓗᒋᑦ ᐃᒡᓗᒃ (uqarrtulugiqluk)",
"waterspout": "ᐅᖃᕐᖅᑐᓗᒋᑦ ᐊᕐᓇᑐᖅ (uqarrtulugiqarnaqtuk)"
},
"alerts": {
"tornado_warning": "ᐊᓂᖅᑕᖅᑐᖅ ᐅᓪᓗᖓ (aniqtaktuk ulluq)",
"tornado_watch": "ᑎᑎᖅᑲᓐᓂᖅ ᐅᓪᓗᖓ (titiraqanngittuq ulluq)",
"severe_thunderstorm_warning": "ᐱᔪᓐᓇᕐᓗᒍ ᒪᒃᑯᖅ ᐊᓂᖅᑕᖅᑐᖅ (pijunnarluq makkuq)",
"severe_thunderstorm_watch": "ᑎᑎᖅᑲᓐᓂᖅ ᐱᔪᓐᓇᕐᓗᒍ ᒪᒃᑯᖅ (titiraqanngittuq makkuq)",
"snow_squall_warning": "ᐊᐳᑦ ᐊᑕᐅᓯᕐᓗᒍ ᐊᓂᖅᑕᖅᑐᖅ (aput ataussirluk)",
"snow_squall_watch": "ᑎᑎᖅᑲᓐᓂᖅ ᐊᐳᑦ ᐊᑕᐅᓯᕐᓗᒍ (titiraqanngittuq aput)",
"winter_storm_warning": "ᐊᐳᑦ ᐱᔪᓐᓇᕐᓗᒍ ᐊᓂᖅᑕᖅᑐᖅ (aput pijunnarluq)",
"blizzard_warning": "ᐱᔪᓐᓇᕐᓗᒍ ᐊᓂᖅᑕᖅᑐᖅ (pijunnarluq aput)",
"rainfall_warning": "ᒪᓚᐅᑦ ᐊᓂᖅᑕᖅᑐᖅ (malaut aniqtaktuk)",
"snowfall_warning": "ᐊᐳᑦ ᐊᓂᖅᑕᖅᑐᖅ (aput aniqtaktuk)",
"wind_warning": "ᐊᑕᐅᓯᕐᓂᖅ ᐊᓂᖅᑕᖅᑐᖅ (atausirniq aniqtaktuk)",
"wind_watch": "ᑎᑎᖅᑲᓐᓂᖅ ᐊᑕᐅᓯᕐᓂᖅ (titiraqanngittuq atausirniq)",
"heat_warning": "ᐅᒥᐊᕆᓂᖅ ᐊᓂᖅᑕᖅᑐᖅ (umiarniq aniqtaktuk)",
"cold_warning": "ᐊᖅᑕᐅᔾᔨᖅᑕᖅᑐᖅ (aqtaujjirqtuk)",
"extreme_cold_warning": "ᐱᔪᓐᓇᕐᓗᒍ ᐊᖅᑕᐅᔾᔨᖅᑕᖅᑐᖅ (pijunnarluq aqtaujjirqtuk)",
"arctic_outflow_warning": "ᐊᓗᒃᑕᖅ ᐊᑕᐅᓯᕐᓗᒍ (aluktaq ataussirluk)",
"wind_chill_warning": "ᐊᑕᐅᓯᕐᓗᒍ ᐊᖅᑕᐅᔾᔨᖅᑕᖅᑐᖅ (atausirniq aqtaujjirqtuk)",
"flash_freeze_warning": "ᓯᕗᓂᖅ ᐱᔪᓐᓇᕐᓗᒍ (sivuniq pijunnarluq)",
"freezing_rain_warning": "ᓯᕗᓂᖅ ᒪᓚᐅᑦ (sivuniq malaut)",
"fog_advisory": "ᐃᒡᓗᒃᑕᐅᓯᖅ ᑎᑎᖅᑲᓐᓂᖅ (igluktauqsik titiraqanngittuq)",
"freezing_fog_advisory": "ᓯᕗᓂᖅ ᐃᒡᓗᒃᑕᐅᓯᖅ ᑎᑎᖅᑲᓐᓂᖅ (sivuniq igluktauqsik titiraqanngittuq)",
"blowing_snow_advisory": "ᐊᐳᑦ ᐊᑕᐅᓯᕐᓂᖅ ᑎᑎᖅᑲᓐᓂᖅ (aput ataussirluk titiraqanngittuq)",
"air_quality_statement": "ᐃᒡᓗᒃᑕᐅᓯᖅ ᐱᖁᓂᖅ (igluktauq pinniq)",
"air_quality_advisory": "ᐃᒡᓗᒃᑕᐅᓯᖅ ᑎᑎᖅᑲᓐᓂᖅ (igluktauq titiraqanngittuq)",
"smoke_statement": "ᐊᐅᓐᓇᓯᖅ ᐱᖁᓂᖅ (aunnasiq pinniq)",
"smoke_advisory": "ᐊᐅᓐᓇᓯᖅ ᑎᑎᖅᑲᓐᓂᖅ (aunnasiq titiraqanngittuq)",
"special_weather_statement": "ᐊᐃᑉᐳᖅ ᐱᖁᓂᖅ (aippuq pinniq)",
"marine_freezing_spray_warning": "ᓯᕗᓂᖅ ᐊᕐᓇᑐᖅ ᐊᓂᖅᑕᖅᑐᖅ (sivuniq arnatuk)",
"marine_gale_warning": "ᐊᑕᐅᓯᕐᓗᒍ ᐊᓂᖅᑕᖅᑐᖅ (atausirniq aniqtaktuk)",
"marine_storm_warning": "ᐊᐃᑉᐳᖅ ᐊᓂᖅᑕᖅᑐᖅ (aippuq aniqtaktuk)",
"marine_hurricane_force_warning": "ᐅᐳᓐᓂᐊᕆᐊᖅ ᐊᓂᖅᑕᖅᑐᖅ (upunniariaq aniqtaktuk)",
"tropical_storm_warning": "ᐊᐃᑉᐳᖅ ᐊᓂᖅᑕᖅᑐᖅ (aippuq aniqtaktuk)",
"tropical_storm_watch": "ᑎᑎᖅᑲᓐᓂᖅ ᐊᐃᑉᐳᖅ (titiraqanngittuq aippuq)",
"hurricane_warning": "ᐅᐳᓐᓂᐊᕆᐊᖅ ᐊᓂᖅᑕᖅᑐᖅ (upunniariaq aniqtaktuk)",
"hurricane_watch": "ᑎᑎᖅᑲᓐᓂᖅ ᐅᐳᓐᓂᐊᕆᐊᖅ (titiraqanngittuq upunniariaq)",
"storm_surge_warning": "ᐊᐃᑉᐳᖅ ᐋᕐᓇᑐᖅ ᐊᓂᖅᑕᖅᑐᖅ (aippuq aarnatuk)",
"storm_surge_watch": "ᑎᑎᖅᑲᓐᓂᖅ ᐊᐃᑉᐳᖅ ᐋᕐᓇᑐᖅ (titiraqanngittuq aarnatuk)",
"funnel_cloud_advisory": "ᐅᖃᕐᖅᑐᓗᒋᑦ ᐃᒡᓗᒃ ᑎᑎᖅᑲᓐᓂᖅ (uqarrtulugiqluk titiraqanngittuq)",
"waterspout_warning": "ᐅᖃᕐᖅᑐᓗᒋᑦ ᐊᕐᓇᑐᖅ ᐊᓂᖅᑕᖅᑐᖅ (uqarrtulugiqarnaqtuk)"
}
}

5
internal/locale/iu_CA.go Normal file
View File

@ -0,0 +1,5 @@
package locale
func init() {
// Loaded automatically from iu_CA.json
}

41
internal/locale/loader.go Normal file
View File

@ -0,0 +1,41 @@
package locale
import (
"embed"
"encoding/json"
"fmt"
"path/filepath"
)
// Embed all locale JSON files inside binary
//go:embed i18n/*.json
var localeFS embed.FS
// Load all languages on startup
func LoadAll() error {
files, err := localeFS.ReadDir("i18n")
if err != nil {
return fmt.Errorf("locale: cannot read embedded FS: %w", err)
}
for _, f := range files {
name := f.Name()
path := filepath.Join("i18n", name)
data, err := localeFS.ReadFile(path)
if err != nil {
return fmt.Errorf("locale: failed to read %s: %w", name, err)
}
var b Bundle
if err := json.Unmarshal(data, &b); err != nil {
return fmt.Errorf("locale: failed to parse %s: %w", name, err)
}
lang := Lang(b.Locale)
register(lang, b)
}
return nil
}

67
internal/locale/locale.go Normal file
View File

@ -0,0 +1,67 @@
package locale
import (
"strings"
)
// Language code type
type Lang string
const (
EN_CA Lang = "en-CA"
FR_CA Lang = "fr-CA"
IU_CA Lang = "iu-CA" // Inuktitut (syllabics + roman in parentheses)
)
// Bundle mirrors the JSON structure
type Bundle struct {
UI struct {
Loading string `json:"loading"`
FetchingWeather string `json:"fetching_weather"`
CurrentCond string `json:"current_conditions"`
FeelsLike string `json:"feels_like"`
Humidity string `json:"humidity"`
Wind string `json:"wind"`
Pressure string `json:"pressure"`
Visibility string `json:"visibility"`
Sunrise string `json:"sunrise"`
Sunset string `json:"sunset"`
Alerts string `json:"alerts"`
NoAlerts string `json:"no_alerts"`
Updated string `json:"updated"`
Location string `json:"location"`
Language string `json:"language"`
SelectLang string `json:"select_language"`
SelectLocation string `json:"select_location"`
ManualEntry string `json:"manual_entry"`
} `json:"ui"`
Conditions map[string]string `json:"conditions"`
Alerts map[string]string `json:"alerts"`
Locale string `json:"locale"`
Language string `json:"language_name"`
Script string `json:"script"`
}
// Runtime loaded language bundles (populated by loader.go)
var bundles = map[Lang]Bundle{}
// Register language bundle
func register(lang Lang, b Bundle) {
bundles[lang] = b
}
// Get returns a language bundle, defaulting to EN_CA
func Get(lang Lang) Bundle {
if b, ok := bundles[lang]; ok {
return b
}
return bundles[EN_CA]
}
// Normalize condition keys internally
func Normalize(cond string) string {
c := strings.ToLower(cond)
return strings.ReplaceAll(c, " ", "_")
}

144
internal/output/colour.go Normal file
View File

@ -0,0 +1,144 @@
package output
import (
"fmt"
"strconv"
"strings"
"time"
)
// ---------------------------------------------------------------------
// RGB STRUCTS & HELPERS
// ---------------------------------------------------------------------
type RGB struct {
R int
G int
B int
}
func (c RGB) ANSI() string {
return fmt.Sprintf("\033[38;2;%d;%d;%dm", c.R, c.G, c.B)
}
func RGBFromInts(r, g, b int) RGB {
return RGB{R: r, G: g, B: b}
}
func RGBFromHex(hex string) RGB {
hex = strings.TrimPrefix(hex, "#")
if len(hex) != 6 {
return RGB{255, 255, 255} // fail-safe
}
r, _ := strconv.ParseInt(hex[0:2], 16, 0)
g, _ := strconv.ParseInt(hex[2:4], 16, 0)
b, _ := strconv.ParseInt(hex[4:6], 16, 0)
return RGB{int(r), int(g), int(b)}
}
func Blend(a, b RGB, factor float64) RGB {
return RGB{
R: int(float64(a.R)*(1-factor) + float64(b.R)*factor),
G: int(float64(a.G)*(1-factor) + float64(b.G)*factor),
B: int(float64(a.B)*(1-factor) + float64(b.B)*factor),
}
}
// ---------------------------------------------------------------------
// TIME-OF-DAY COLOUR PHASES (CANADIAN ENGLISH)
// ---------------------------------------------------------------------
// These were recreated from the original Python _get_time_phase logic.
type Phase struct {
Name string
Primary RGB // top gradient
Secondary RGB // bottom gradient
}
func getTimePhase() Phase {
h := time.Now().Hour()
switch {
case h >= 5 && h < 8:
return Phase{
Name: "dawn",
Primary: RGBFromHex("#FFB478"), // orange-peach morning sky
Secondary: RGBFromHex("#8C64A0"), // purple base
}
case h >= 8 && h < 17:
return Phase{
Name: "day",
Primary: RGBFromHex("#4696FF"), // bright sky blue
Secondary: RGBFromHex("#78BEFF"), // lighter blue
}
case h >= 17 && h < 20:
return Phase{
Name: "sunset",
Primary: RGBFromHex("#FF6E3C"), // deep orange
Secondary: RGBFromHex("#643C78"), // muted purple
}
default:
return Phase{
Name: "night",
Primary: RGBFromHex("#323C64"), // deep navy
Secondary: RGBFromHex("#141E3C"), // darker base
}
}
}
// Public so other modules (logo, TUI, Telefact) can use it.
func GetTimePhaseColours() (RGB, RGB, string) {
phase := getTimePhase()
return phase.Primary, phase.Secondary, phase.Name
}
// ---------------------------------------------------------------------
// WEATHER TONES — CANADIAN ENGLISH (GREY, COLOUR, METRE, etc.)
// ---------------------------------------------------------------------
// This is used as a tint on top of the base time-of-day palette.
var WeatherTones = map[string]RGB{
"sunny": RGBFromHex("#FFD700"), // gold
"clear": RGBFromHex("#FFD700"),
"cloudy": RGBFromHex("#B4B4B4"),
"mostly_cloudy": RGBFromHex("#B4B4B4"),
"rain": RGBFromHex("#2870C8"),
"drizzle": RGBFromHex("#2870C8"),
"snow": RGBFromHex("#E6E6F0"),
"fog": RGBFromHex("#82828C"),
"mist": RGBFromHex("#82828C"),
"thunderstorm": RGBFromHex("#C878FF"),
"smoke": RGBFromHex("#8C7864"),
}
// Normalises Environment Canada text into our tone keys.
func NormaliseCondition(cond string) string {
c := strings.ToLower(cond)
switch {
case strings.Contains(c, "sun"):
return "sunny"
case strings.Contains(c, "clear"):
return "clear"
case strings.Contains(c, "snow"):
return "snow"
case strings.Contains(c, "storm"):
return "thunderstorm"
case strings.Contains(c, "rain"):
return "rain"
case strings.Contains(c, "drizzle"):
return "drizzle"
case strings.Contains(c, "fog"):
return "fog"
case strings.Contains(c, "mist"):
return "mist"
case strings.Contains(c, "cloud"):
return "cloudy"
case strings.Contains(c, "smoke"):
return "smoke"
}
return "clear"
}

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/leaktechnologies/skyfeed/internal/weather" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather"
) )
// Theme defines simple ANSI colour codes for terminal output. // Theme defines simple ANSI colour codes for terminal output.

179
internal/output/logo.go Normal file
View File

@ -0,0 +1,179 @@
package output
import (
"fmt"
"strings"
"time"
)
// ---------------------------------------------------------------------
// SKYFEED ASCII LOGO
// ---------------------------------------------------------------------
var SkyfeedLogoLines = []string{
" ______ ___ __",
" / __/ /____ __/ _/__ ___ ___/ /",
" _\\ \\/ '_/ // / _/ -_) -_) _ / ",
"/___/_/\\_\\\\_, /_/ \\__/\\__/\\_,_/ ",
" /___/ ",
}
// ---------------------------------------------------------------------
// ANSI HELPERS
// ---------------------------------------------------------------------
func ansi(r, g, b int) string {
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
}
func reset() string {
return "\033[0m"
}
// ---------------------------------------------------------------------
// TIME-OF-DAY PALETTES (MATCHING PYTHON LOGIC)
// ---------------------------------------------------------------------
//
// Python version used a function _get_time_phase() which returned
// two hex colours: primary (top) and secondary (bottom).
// We reproduce that behaviour exactly here.
//
type PhaseColors struct {
Primary [3]int // top colour
Secondary [3]int // bottom colour
}
func getTimePhaseColors() PhaseColors {
h := time.Now().Hour()
switch {
case h >= 5 && h < 8:
// Dawn
return PhaseColors{
Primary: [3]int{255, 180, 120},
Secondary: [3]int{140, 100, 160},
}
case h >= 8 && h < 17:
// Noon
return PhaseColors{
Primary: [3]int{70, 150, 255},
Secondary: [3]int{120, 190, 255},
}
case h >= 17 && h < 20:
// Sunset
return PhaseColors{
Primary: [3]int{255, 110, 60},
Secondary: [3]int{100, 60, 120},
}
default:
// Night
return PhaseColors{
Primary: [3]int{50, 60, 100},
Secondary: [3]int{20, 30, 60},
}
}
}
// ---------------------------------------------------------------------
// WEATHER TONE MAPPING (ANSI-256 FOREGROUND)
//
// These match the Python "tone_color_map" used for verbose mode,
// but here they are used as coloration tint.
// ---------------------------------------------------------------------
var weatherToneMap = map[string][3]int{
"sunny": {255, 215, 0}, // yellow(ish)
"clear": {255, 215, 0},
"cloudy": {180, 180, 180},
"mostly_cloudy": {180, 180, 180},
"rain": {40, 110, 200},
"drizzle": {40, 110, 200},
"snow": {230, 230, 240},
"fog": {130, 130, 140},
"mist": {130, 130, 140},
"thunderstorm": {200, 120, 255},
"smoke": {140, 120, 100},
}
// Normalize a condition to a known tone key.
func normalizeCondition(cond string) string {
c := strings.ToLower(cond)
switch {
case strings.Contains(c, "sun"):
return "sunny"
case strings.Contains(c, "clear"):
return "clear"
case strings.Contains(c, "snow"):
return "snow"
case strings.Contains(c, "storm"):
return "thunderstorm"
case strings.Contains(c, "rain"):
return "rain"
case strings.Contains(c, "drizzle"):
return "drizzle"
case strings.Contains(c, "fog"):
return "fog"
case strings.Contains(c, "mist"):
return "fog"
case strings.Contains(c, "cloud"):
return "cloudy"
case strings.Contains(c, "smoke"):
return "smoke"
}
return "clear"
}
// ---------------------------------------------------------------------
// BLENDING FUNCTIONS
// ---------------------------------------------------------------------
func blend(a, b [3]int, factor float64) [3]int {
return [3]int{
int(float64(a[0])*(1-factor) + float64(b[0])*factor),
int(float64(a[1])*(1-factor) + float64(b[1])*factor),
int(float64(a[2])*(1-factor) + float64(b[2])*factor),
}
}
// ---------------------------------------------------------------------
// RENDER LOGO: RETURNS A STRING
// ---------------------------------------------------------------------
func RenderLogoString(weatherCond string) string {
phase := getTimePhaseColors()
toneKey := normalizeCondition(weatherCond)
tone := weatherToneMap[toneKey]
var b strings.Builder
total := len(SkyfeedLogoLines)
for i, line := range SkyfeedLogoLines {
// Vertical gradient: bottom → top
factor := float64(i) / float64(total-1)
// Blend primary→secondary vertically
lineCol := blend(phase.Secondary, phase.Primary, factor)
// Weather tint applied lightly (0.0 → 0.25)
tintFactor := 0.25
tinted := blend(lineCol, tone, tintFactor)
b.WriteString(ansi(tinted[0], tinted[1], tinted[2]))
b.WriteString(line)
b.WriteString(reset())
b.WriteString("\n")
}
return b.String()
}
// ---------------------------------------------------------------------
// PRINT LOGO — For CLI / debug-logo command
// ---------------------------------------------------------------------
func PrintLogo(weatherCond string) {
fmt.Print(RenderLogoString(weatherCond))
}

View File

@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/leaktechnologies/skyfeed/internal/config" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
) )
// Cache file path // Cache file path

View File

@ -12,7 +12,7 @@ import (
"strings" // ✅ Required for province normalization "strings" // ✅ Required for province normalization
"time" "time"
"github.com/leaktechnologies/skyfeed/internal/config" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
) )
// WeatherData holds simplified normalized current weather data. // WeatherData holds simplified normalized current weather data.

BIN
skyfeed Executable file

Binary file not shown.