Compare commits
2 Commits
v0.1.0-dev
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a3e92212d1 | |||
| 7f00667213 |
|
|
@ -3,21 +3,20 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||
"github.com/leaktechnologies/skyfeed/internal/geo"
|
||||
"github.com/leaktechnologies/skyfeed/internal/output"
|
||||
"github.com/leaktechnologies/skyfeed/internal/weather"
|
||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/geo"
|
||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/output"
|
||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "skyfeed",
|
||||
Short: "Skyfeed - Open Weather Engine for Telefact and Terminal",
|
||||
Long: `Skyfeed fetches and normalizes weather data from Environment Canada,
|
||||
using a local IP database for accurate geolocation. It supports both CLI and API modes.`,
|
||||
using automatic IP-based geolocation. It supports both CLI and API modes.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.Help()
|
||||
},
|
||||
|
|
@ -30,7 +29,8 @@ func main() {
|
|||
// Register subcommands
|
||||
rootCmd.AddCommand(fetchCmd)
|
||||
rootCmd.AddCommand(showCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
rootCmd.AddCommand(updateTownCmd)
|
||||
rootCmd.AddCommand(debugLogoCmd)
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Println("Error:", err)
|
||||
|
|
@ -38,21 +38,24 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// ----------------------------------------------------
|
||||
// Subcommands
|
||||
// ------------------------------
|
||||
// ----------------------------------------------------
|
||||
|
||||
var fetchCmd = &cobra.Command{
|
||||
Use: "fetch",
|
||||
Short: "Fetch the latest weather data for your current location",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output.LogInfo("Skyfeed: Checking IP database...")
|
||||
if err := geo.EnsureIPDBUpToDate(); err != nil {
|
||||
output.LogError(fmt.Sprintf("Failed to update IP DB: %v", err))
|
||||
|
||||
// Ensure town index is present and current
|
||||
output.LogInfo("Skyfeed: Checking town index…")
|
||||
if err := geo.EnsureTownIndexUpToDate(); err != nil {
|
||||
output.LogError(fmt.Sprintf("Failed to update town index: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
output.LogInfo("Skyfeed: Detecting location...")
|
||||
// Detect location via new IP method
|
||||
output.LogInfo("Skyfeed: Detecting location…")
|
||||
city, lat, lon, err := geo.GetUserLocation()
|
||||
if err != nil {
|
||||
output.LogError(fmt.Sprintf("Could not determine location: %v", err))
|
||||
|
|
@ -60,7 +63,8 @@ var fetchCmd = &cobra.Command{
|
|||
}
|
||||
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)
|
||||
if err != nil {
|
||||
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("Fetching latest weather data...")
|
||||
// Determine province from the station (if available)
|
||||
province := strings.Split(station.Code, "_")[0] // fallback heuristic
|
||||
// Fetch weather
|
||||
output.LogInfo("Fetching latest weather data…")
|
||||
province := station.Province
|
||||
|
||||
data, err := weather.FetchCurrent(station.Code, province)
|
||||
if err != nil {
|
||||
output.LogError(fmt.Sprintf("Weather fetch failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Render dynamic header/logo
|
||||
output.PrintLogo(data.Condition)
|
||||
fmt.Println()
|
||||
|
||||
// Main output
|
||||
fmt.Println(output.FormatWeatherCLI(data, true))
|
||||
},
|
||||
}
|
||||
|
|
@ -90,19 +100,49 @@ var showCmd = &cobra.Command{
|
|||
output.LogError(fmt.Sprintf("Failed to load cache: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
output.PrintLogo(data.Condition)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println(output.FormatWeatherCLI(data, true))
|
||||
},
|
||||
}
|
||||
|
||||
var updateCmd = &cobra.Command{
|
||||
Use: "update-ipdb",
|
||||
Short: "Manually update the local IP geolocation database",
|
||||
var updateTownCmd = &cobra.Command{
|
||||
Use: "update-towns",
|
||||
Short: "Manually update the Canadian towns geolocation index",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output.LogInfo("Forcing IP database update...")
|
||||
if err := geo.ForceUpdateIPDB(); err != nil {
|
||||
output.LogInfo("Forcing town index update…")
|
||||
if err := geo.ForceUpdateTownIndex(); err != nil {
|
||||
output.LogError(fmt.Sprintf("Update failed: %v", err))
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,113 +1,148 @@
|
|||
package geo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetUserLocation resolves the user's public IP into a city and coordinates.
|
||||
// It will try multiple fallback IP providers if the first one fails.
|
||||
// -------------------------------
|
||||
// Public IP + Geolocation (ipwho.is)
|
||||
// -------------------------------
|
||||
|
||||
// GetUserLocation resolves the user's IP into:
|
||||
// city, latitude, longitude
|
||||
func GetUserLocation() (string, float64, float64, error) {
|
||||
fmt.Println("[geo] Detecting location...")
|
||||
fmt.Println("[geo] Detecting location...")
|
||||
|
||||
ip, err := fetchPublicIP()
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err)
|
||||
}
|
||||
ip, err := fetchPublicIP()
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(config.DataDir, "GeoLite2-City.mmdb")
|
||||
db, err := geoip2.Open(dbPath)
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("failed to open GeoLite2 database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
city, province, lat, lon, err := lookupIP(ip.String())
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("IP geolocation failed: %w", err)
|
||||
}
|
||||
|
||||
record, err := db.City(ip)
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("geoip lookup failed: %w", err)
|
||||
}
|
||||
// e.g., "Ottawa, Ontario"
|
||||
locationName := fmt.Sprintf("%s, %s", city, province)
|
||||
|
||||
city := record.City.Names["en"]
|
||||
prov := ""
|
||||
if len(record.Subdivisions) > 0 {
|
||||
prov = record.Subdivisions[0].Names["en"]
|
||||
}
|
||||
|
||||
lat := record.Location.Latitude
|
||||
lon := record.Location.Longitude
|
||||
|
||||
if city == "" && prov == "" {
|
||||
return "", 0, 0, fmt.Errorf("no location info found for IP %s", ip.String())
|
||||
}
|
||||
|
||||
fmt.Printf("[geo] Detected: %s, %s (%.4f, %.4f)\n", city, prov, lat, lon)
|
||||
return fmt.Sprintf("%s, %s", city, prov), lat, lon, nil
|
||||
fmt.Printf("[geo] Detected: %s (%.4f, %.4f)\n", locationName, lat, lon)
|
||||
return locationName, lat, lon, nil
|
||||
}
|
||||
|
||||
// fetchPublicIP tries multiple reliable endpoints for the public IPv4 address.
|
||||
// -------------------------------
|
||||
// PUBLIC IP DETECTION
|
||||
// -------------------------------
|
||||
|
||||
func fetchPublicIP() (net.IP, error) {
|
||||
providers := []string{
|
||||
"https://ipv4.icanhazip.com",
|
||||
"https://api.ipify.org?format=json",
|
||||
"https://ifconfig.co/json",
|
||||
}
|
||||
providers := []string{
|
||||
"https://ipv4.icanhazip.com",
|
||||
"https://api.ipify.org?format=json",
|
||||
"https://ifconfig.co/json",
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
for _, url := range providers {
|
||||
ip, err := tryProvider(url, client)
|
||||
if err == nil && ip != nil {
|
||||
return ip, nil
|
||||
}
|
||||
fmt.Println("[geo] Fallback:", err)
|
||||
}
|
||||
for _, url := range providers {
|
||||
ip, err := tryProvider(url, client)
|
||||
if err == nil && ip != nil {
|
||||
return ip, nil
|
||||
}
|
||||
fmt.Println("[geo] Fallback:", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all IP detection methods failed")
|
||||
return nil, fmt.Errorf("all IP detection methods failed")
|
||||
}
|
||||
|
||||
// tryProvider queries a single IP API endpoint and parses IPv4 results.
|
||||
func tryProvider(url string, client *http.Client) (net.IP, error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("network error (%s): %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
// Some APIs return plain text, others JSON
|
||||
var result struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
// JSON-based API fallback
|
||||
var j struct{ IP string `json:"ip"` }
|
||||
|
||||
// Try decode JSON
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.IP != "" {
|
||||
ip := net.ParseIP(result.IP)
|
||||
if ip != nil && ip.To4() != nil {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&j); err == nil && j.IP != "" {
|
||||
ip := net.ParseIP(j.IP)
|
||||
if ip != nil && ip.To4() != nil {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: plain text
|
||||
resp2, err := client.Get(url)
|
||||
if err == nil {
|
||||
defer resp2.Body.Close()
|
||||
buf := make([]byte, 64)
|
||||
n, _ := resp2.Body.Read(buf)
|
||||
ip := net.ParseIP(string(buf[:n]))
|
||||
if ip != nil && ip.To4() != nil {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
// Plain text fallback → must re-fetch body
|
||||
resp2, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
return nil, fmt.Errorf("no valid IP found from %s", url)
|
||||
buf := make([]byte, 64)
|
||||
n, _ := resp2.Body.Read(buf)
|
||||
ip := net.ParseIP(strings.TrimSpace(string(buf[:n])))
|
||||
|
||||
if ip != nil && ip.To4() != nil {
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid response from %s", url)
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// GEO LOOKUP USING ipwho.is
|
||||
// -------------------------------
|
||||
|
||||
func lookupIP(ip string) (city string, province string, lat float64, lon float64, err error) {
|
||||
url := fmt.Sprintf("https://ipwho.is/%s", ip)
|
||||
|
||||
client := &http.Client{Timeout: 6 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", "", 0, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", 0, 0, fmt.Errorf("ipwho.is returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Success bool `json:"success"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Lat float64 `json:"latitude"`
|
||||
Lon float64 `json:"longitude"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", "", 0, 0, fmt.Errorf("decode error: %w", err)
|
||||
}
|
||||
|
||||
if !data.Success {
|
||||
return "", "", 0, 0, fmt.Errorf("ipwho.is error: %s", data.Message)
|
||||
}
|
||||
|
||||
// Clean fields
|
||||
city = strings.TrimSpace(data.City)
|
||||
province = strings.TrimSpace(data.Region)
|
||||
|
||||
if city == "" {
|
||||
city = "Unknown"
|
||||
}
|
||||
if province == "" {
|
||||
province = "Unknown"
|
||||
}
|
||||
|
||||
return city, province, data.Lat, data.Lon, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
"time"
|
||||
|
||||
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"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.
|
||||
func FindNearestTown(lat, lon float64) (Town, error) {
|
||||
townsPath := filepath.Join(config.DataDir, "towns.json")
|
||||
townsPath := filepath.Join(config.DataDir, "towns.json")
|
||||
|
||||
data, err := os.ReadFile(townsPath)
|
||||
if err != nil {
|
||||
return Town{}, fmt.Errorf("failed to read town index: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(townsPath)
|
||||
if err != nil {
|
||||
return Town{}, fmt.Errorf("failed to read town index: %w", err)
|
||||
}
|
||||
|
||||
var towns []Town
|
||||
if err := json.Unmarshal(data, &towns); err != nil {
|
||||
return Town{}, fmt.Errorf("failed to parse towns.json: %w", err)
|
||||
}
|
||||
var towns []Town
|
||||
if err := json.Unmarshal(data, &towns); err != nil {
|
||||
return Town{}, fmt.Errorf("failed to parse towns.json: %w", err)
|
||||
}
|
||||
|
||||
if len(towns) == 0 {
|
||||
return Town{}, fmt.Errorf("no towns found in index")
|
||||
}
|
||||
if len(towns) == 0 {
|
||||
return Town{}, fmt.Errorf("no towns found in index")
|
||||
}
|
||||
|
||||
minDist := math.MaxFloat64
|
||||
var nearest Town
|
||||
minDist := math.MaxFloat64
|
||||
var nearest Town
|
||||
|
||||
for _, t := range towns {
|
||||
d := Haversine(lat, lon, t.Lat, t.Lon) // ✅ use shared helper from stations.go
|
||||
if d < minDist {
|
||||
minDist = d
|
||||
nearest = t
|
||||
}
|
||||
}
|
||||
for _, t := range towns {
|
||||
d := Haversine(lat, lon, t.Lat, t.Lon)
|
||||
if d < minDist {
|
||||
minDist = d
|
||||
nearest = t
|
||||
}
|
||||
}
|
||||
|
||||
if nearest.Name == "" {
|
||||
return Town{}, fmt.Errorf("no nearby town found")
|
||||
}
|
||||
if nearest.Name == "" {
|
||||
return Town{}, fmt.Errorf("no nearby town found")
|
||||
}
|
||||
|
||||
fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n", nearest.Name, nearest.Province, minDist)
|
||||
return nearest, nil
|
||||
fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n",
|
||||
nearest.Name, nearest.Province, minDist)
|
||||
|
||||
return nearest, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,135 +1,123 @@
|
|||
package geo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// Official Geographical Names Board of Canada WFS API
|
||||
// Docs: https://www.nrcan.gc.ca/earth-sciences/geography/geographical-names-board-canada/download-geographical-names-data/10786
|
||||
gnbcAPIURL = "https://geogratis.gc.ca/geonames/servlet/com.gc.ccra.geonames.webservices.GeographicalNamesService?service=WFS&request=GetFeature&version=2.0.0&typeNames=geonames:geoname_eng&outputFormat=json&featureCode=PPL"
|
||||
townsFile = "towns.json"
|
||||
maxFetchTime = 5 * time.Minute
|
||||
// WORKING GeoGratis endpoint for all populated places (PPL)
|
||||
gnbcAPIURL = "https://geogratis.gc.ca/services/geoname/en/geonames.json?feature_code=PPL"
|
||||
townsFile = "towns.json"
|
||||
maxFetchTime = 5 * time.Minute
|
||||
)
|
||||
|
||||
// TownRecord represents a single Canadian town.
|
||||
type TownRecord struct {
|
||||
Name string `json:"name"`
|
||||
Province string `json:"province"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
// EnsureTownIndexUpToDate checks if the towns index needs updating (monthly).
|
||||
func EnsureTownIndexUpToDate() error {
|
||||
dest := filepath.Join(config.DataDir, townsFile)
|
||||
dest := filepath.Join(config.DataDir, townsFile)
|
||||
|
||||
info, err := os.Stat(dest)
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Println("[geo] No town index found, downloading...")
|
||||
return downloadTownIndex(dest)
|
||||
}
|
||||
info, err := os.Stat(dest)
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Println("[geo] No town index found, downloading...")
|
||||
return downloadTownIndex(dest)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to check town index: %w", err)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to check town index: %w", err)
|
||||
}
|
||||
|
||||
modTime := info.ModTime().UTC()
|
||||
now := time.Now().UTC()
|
||||
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
modTime := info.ModTime().UTC()
|
||||
now := time.Now().UTC()
|
||||
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if modTime.Before(firstOfMonth) {
|
||||
fmt.Println("[geo] Town index is older than this month, refreshing...")
|
||||
return downloadTownIndex(dest)
|
||||
}
|
||||
if modTime.Before(firstOfMonth) {
|
||||
fmt.Println("[geo] Town index is older than this month, refreshing...")
|
||||
return downloadTownIndex(dest)
|
||||
}
|
||||
|
||||
fmt.Println("[geo] Town index is current.")
|
||||
return nil
|
||||
fmt.Println("[geo] Town index is current.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceUpdateTownIndex forces an immediate rebuild.
|
||||
func ForceUpdateTownIndex() error {
|
||||
dest := filepath.Join(config.DataDir, townsFile)
|
||||
fmt.Println("[geo] Forcing town index update...")
|
||||
return downloadTownIndex(dest)
|
||||
dest := filepath.Join(config.DataDir, townsFile)
|
||||
fmt.Println("[geo] Forcing town index update...")
|
||||
return downloadTownIndex(dest)
|
||||
}
|
||||
|
||||
// downloadTownIndex fetches and stores the Canadian town dataset.
|
||||
func downloadTownIndex(dest string) error {
|
||||
client := &http.Client{Timeout: maxFetchTime}
|
||||
client := &http.Client{Timeout: maxFetchTime}
|
||||
|
||||
fmt.Println("[geo] Fetching town data from GNBC WFS API...")
|
||||
resp, err := client.Get(gnbcAPIURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch town dataset: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
fmt.Println("[geo] Fetching town data from GeoGratis...")
|
||||
resp, err := client.Get(gnbcAPIURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch town dataset: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read GNBC response: %w", err)
|
||||
}
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read GeoGratis response: %w", err)
|
||||
}
|
||||
|
||||
towns, err := parseGNBCJSON(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse GNBC JSON: %w", err)
|
||||
}
|
||||
towns, err := parseGNBCJSON(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse GeoGratis JSON: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(towns, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode towns: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(towns, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode towns.json: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dest, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", dest, err)
|
||||
}
|
||||
if err := os.WriteFile(dest, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", dest, err)
|
||||
}
|
||||
|
||||
fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns))
|
||||
return nil
|
||||
fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns))
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseGNBCJSON extracts relevant town info from the GNBC GeoJSON.
|
||||
func parseGNBCJSON(data []byte) ([]TownRecord, error) {
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Properties struct {
|
||||
Name string `json:"name"`
|
||||
Province string `json:"province"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
// Uses GeoGratis geonames.json structure
|
||||
func parseGNBCJSON(data []byte) ([]Town, error) {
|
||||
var response struct {
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
ProvinceCode string `json:"province_code"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("invalid GNBC JSON: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("invalid GeoGratis JSON: %w", err)
|
||||
}
|
||||
|
||||
var towns []TownRecord
|
||||
for _, f := range response.Features {
|
||||
p := f.Properties
|
||||
if p.Name == "" || p.Province == "" {
|
||||
continue
|
||||
}
|
||||
towns = append(towns, TownRecord{
|
||||
Name: p.Name,
|
||||
Province: p.Province,
|
||||
Lat: p.Latitude,
|
||||
Lon: p.Longitude,
|
||||
})
|
||||
}
|
||||
var towns []Town
|
||||
for _, item := range response.Items {
|
||||
if item.Name == "" || item.ProvinceCode == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
return towns, nil
|
||||
towns = append(towns, Town{
|
||||
Name: item.Name,
|
||||
Province: item.ProvinceCode,
|
||||
Lat: item.Latitude,
|
||||
Lon: item.Longitude,
|
||||
})
|
||||
}
|
||||
|
||||
return towns, nil
|
||||
}
|
||||
|
|
|
|||
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"
|
||||
"time"
|
||||
|
||||
"github.com/leaktechnologies/skyfeed/internal/weather"
|
||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather"
|
||||
)
|
||||
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
||||
)
|
||||
|
||||
// Cache file path
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"strings" // ✅ Required for province normalization
|
||||
"time"
|
||||
|
||||
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
||||
)
|
||||
|
||||
// WeatherData holds simplified normalized current weather data.
|
||||
|
|
|
|||
Reference in New Issue
Block a user