Compare commits
2 Commits
v0.1.0-dev
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a3e92212d1 | |||
| 7f00667213 |
|
|
@ -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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"language": "en-CA",
|
||||||
|
"location": {
|
||||||
|
"mode": "auto",
|
||||||
|
"manual_city": "",
|
||||||
|
"manual_lat": 0,
|
||||||
|
"manual_lon": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
42
docs/CHANGELOG.md
Normal file
42
docs/CHANGELOG.md
Normal 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
285
docs/LANGUAGE_SUPPORT.md
Normal 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
26
docs/TAGGING.md
Normal 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
388
docs/TAXONOMY.md
Normal 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
2
go.mod
|
|
@ -1,4 +1,4 @@
|
||||||
module github.com/leaktechnologies/skyfeed
|
module git.leaktechnologies.dev/Leak_Technologies/Skyfeed
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,148 @@
|
||||||
package geo
|
package geo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"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...")
|
||||||
|
|
||||||
ip, err := fetchPublicIP()
|
ip, err := fetchPublicIP()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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("IP geolocation failed: %w", err)
|
||||||
return "", 0, 0, fmt.Errorf("failed to open GeoLite2 database: %w", err)
|
}
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
record, err := db.City(ip)
|
// e.g., "Ottawa, Ontario"
|
||||||
if err != nil {
|
locationName := fmt.Sprintf("%s, %s", city, province)
|
||||||
return "", 0, 0, fmt.Errorf("geoip lookup failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
city := record.City.Names["en"]
|
fmt.Printf("[geo] Detected: %s (%.4f, %.4f)\n", locationName, lat, lon)
|
||||||
prov := ""
|
return locationName, lat, lon, nil
|
||||||
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.
|
// -------------------------------
|
||||||
|
// 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",
|
||||||
"https://api.ipify.org?format=json",
|
"https://api.ipify.org?format=json",
|
||||||
"https://ifconfig.co/json",
|
"https://ifconfig.co/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
|
||||||
for _, url := range providers {
|
for _, url := range providers {
|
||||||
ip, err := tryProvider(url, client)
|
ip, err := tryProvider(url, client)
|
||||||
if err == nil && ip != nil {
|
if err == nil && ip != nil {
|
||||||
return ip, nil
|
return ip, nil
|
||||||
}
|
}
|
||||||
fmt.Println("[geo] Fallback:", err)
|
fmt.Println("[geo] Fallback:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("all IP detection methods failed")
|
return nil, fmt.Errorf("all IP detection methods failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryProvider queries a single IP API endpoint and parses IPv4 results.
|
|
||||||
func tryProvider(url string, client *http.Client) (net.IP, error) {
|
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()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
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 {
|
||||||
defer resp2.Body.Close()
|
return nil, err
|
||||||
buf := make([]byte, 64)
|
}
|
||||||
n, _ := resp2.Body.Read(buf)
|
defer resp2.Body.Close()
|
||||||
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)
|
buf := make([]byte, 64)
|
||||||
|
n, _ := resp2.Body.Read(buf)
|
||||||
|
ip := net.ParseIP(strings.TrimSpace(string(buf[:n])))
|
||||||
|
|
||||||
|
if ip != nil && ip.To4() != nil {
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("invalid response from %s", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// GEO LOOKUP USING ipwho.is
|
||||||
|
// -------------------------------
|
||||||
|
|
||||||
|
func lookupIP(ip string) (city string, province string, lat float64, lon float64, err error) {
|
||||||
|
url := fmt.Sprintf("https://ipwho.is/%s", ip)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 6 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", 0, 0, fmt.Errorf("ipwho.is returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Lat float64 `json:"latitude"`
|
||||||
|
Lon float64 `json:"longitude"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return "", "", 0, 0, fmt.Errorf("decode error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.Success {
|
||||||
|
return "", "", 0, 0, fmt.Errorf("ipwho.is error: %s", data.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean fields
|
||||||
|
city = strings.TrimSpace(data.City)
|
||||||
|
province = strings.TrimSpace(data.Region)
|
||||||
|
|
||||||
|
if city == "" {
|
||||||
|
city = "Unknown"
|
||||||
|
}
|
||||||
|
if province == "" {
|
||||||
|
province = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return city, province, data.Lat, data.Lon, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
|
|
@ -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
10
internal/geo/towns.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
@ -1,56 +1,50 @@
|
||||||
package geo
|
package geo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"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")
|
||||||
|
|
||||||
data, err := os.ReadFile(townsPath)
|
data, err := os.ReadFile(townsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Town{}, fmt.Errorf("failed to read town index: %w", err)
|
return Town{}, fmt.Errorf("failed to read town index: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var towns []Town
|
var towns []Town
|
||||||
if err := json.Unmarshal(data, &towns); err != nil {
|
if err := json.Unmarshal(data, &towns); err != nil {
|
||||||
return Town{}, fmt.Errorf("failed to parse towns.json: %w", err)
|
return Town{}, fmt.Errorf("failed to parse towns.json: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(towns) == 0 {
|
if len(towns) == 0 {
|
||||||
return Town{}, fmt.Errorf("no towns found in index")
|
return Town{}, fmt.Errorf("no towns found in index")
|
||||||
}
|
}
|
||||||
|
|
||||||
minDist := math.MaxFloat64
|
minDist := math.MaxFloat64
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if nearest.Name == "" {
|
if nearest.Name == "" {
|
||||||
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",
|
||||||
return nearest, nil
|
nearest.Name, nearest.Province, minDist)
|
||||||
|
|
||||||
|
return nearest, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,123 @@
|
||||||
package geo
|
package geo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"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)
|
||||||
|
|
||||||
info, err := os.Stat(dest)
|
info, err := os.Stat(dest)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
fmt.Println("[geo] No town index found, downloading...")
|
fmt.Println("[geo] No town index found, downloading...")
|
||||||
return downloadTownIndex(dest)
|
return downloadTownIndex(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to check town index: %w", err)
|
return fmt.Errorf("unable to check town index: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
modTime := info.ModTime().UTC()
|
modTime := info.ModTime().UTC()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
if modTime.Before(firstOfMonth) {
|
if modTime.Before(firstOfMonth) {
|
||||||
fmt.Println("[geo] Town index is older than this month, refreshing...")
|
fmt.Println("[geo] Town index is older than this month, refreshing...")
|
||||||
return downloadTownIndex(dest)
|
return downloadTownIndex(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[geo] Town index is current.")
|
fmt.Println("[geo] Town index is current.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForceUpdateTownIndex forces an immediate rebuild.
|
// ForceUpdateTownIndex forces an immediate rebuild.
|
||||||
func ForceUpdateTownIndex() error {
|
func ForceUpdateTownIndex() error {
|
||||||
dest := filepath.Join(config.DataDir, townsFile)
|
dest := filepath.Join(config.DataDir, townsFile)
|
||||||
fmt.Println("[geo] Forcing town index update...")
|
fmt.Println("[geo] Forcing town index update...")
|
||||||
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)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
return fmt.Errorf("failed to write %s: %w", dest, err)
|
return fmt.Errorf("failed to write %s: %w", dest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns))
|
fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns))
|
||||||
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"`
|
ProvinceCode string `json:"province_code"`
|
||||||
Province string `json:"province"`
|
Latitude float64 `json:"latitude"`
|
||||||
Latitude float64 `json:"latitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
Longitude float64 `json:"longitude"`
|
} `json:"items"`
|
||||||
} `json:"properties"`
|
}
|
||||||
} `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,
|
|
||||||
Province: p.Province,
|
|
||||||
Lat: p.Latitude,
|
|
||||||
Lon: p.Longitude,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return towns, nil
|
towns = append(towns, Town{
|
||||||
|
Name: item.Name,
|
||||||
|
Province: item.ProvinceCode,
|
||||||
|
Lat: item.Latitude,
|
||||||
|
Lon: item.Longitude,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return towns, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
internal/locale/en_CA.go
Normal file
5
internal/locale/en_CA.go
Normal 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
5
internal/locale/fr_CA.go
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package locale
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Loaded automatically from fr_CA.json
|
||||||
|
}
|
||||||
131
internal/locale/i18n/en_CA.json
Normal file
131
internal/locale/i18n/en_CA.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
131
internal/locale/i18n/fr_CA.json
Normal file
131
internal/locale/i18n/fr_CA.json
Normal 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 d’orage violent",
|
||||||
|
"severe_thunderstorm_watch": "Veille d’orage 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 l’air",
|
||||||
|
"air_quality_advisory": "Avis sur la qualité de l’air",
|
||||||
|
|
||||||
|
"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 d’ouragan",
|
||||||
|
"hurricane_watch": "Veille d’ouragan",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
125
internal/locale/i18n/iu_CA.json
Normal file
125
internal/locale/i18n/iu_CA.json
Normal 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
5
internal/locale/iu_CA.go
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package locale
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Loaded automatically from iu_CA.json
|
||||||
|
}
|
||||||
41
internal/locale/loader.go
Normal file
41
internal/locale/loader.go
Normal 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
67
internal/locale/locale.go
Normal 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
144
internal/output/colour.go
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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
179
internal/output/logo.go
Normal 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))
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user