Compare commits
No commits in common. "master" and "v0.1.0-dev2" have entirely different histories.
master
...
v0.1.0-dev
|
|
@ -3,20 +3,21 @@ package main
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/geo"
|
"github.com/leaktechnologies/skyfeed/internal/geo"
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/output"
|
"github.com/leaktechnologies/skyfeed/internal/output"
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather"
|
"github.com/leaktechnologies/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 automatic IP-based geolocation. It supports both CLI and API modes.`,
|
using a local IP database for accurate 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()
|
||||||
},
|
},
|
||||||
|
|
@ -29,8 +30,7 @@ func main() {
|
||||||
// Register subcommands
|
// Register subcommands
|
||||||
rootCmd.AddCommand(fetchCmd)
|
rootCmd.AddCommand(fetchCmd)
|
||||||
rootCmd.AddCommand(showCmd)
|
rootCmd.AddCommand(showCmd)
|
||||||
rootCmd.AddCommand(updateTownCmd)
|
rootCmd.AddCommand(updateCmd)
|
||||||
rootCmd.AddCommand(debugLogoCmd)
|
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Println("Error:", err)
|
fmt.Println("Error:", err)
|
||||||
|
|
@ -38,24 +38,21 @@ 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...")
|
||||||
// Ensure town index is present and current
|
if err := geo.EnsureIPDBUpToDate(); err != nil {
|
||||||
output.LogInfo("Skyfeed: Checking town index…")
|
output.LogError(fmt.Sprintf("Failed to update IP DB: %v", err))
|
||||||
if err := geo.EnsureTownIndexUpToDate(); err != nil {
|
|
||||||
output.LogError(fmt.Sprintf("Failed to update town index: %v", err))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect location via new IP method
|
output.LogInfo("Skyfeed: Detecting location...")
|
||||||
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))
|
||||||
|
|
@ -63,8 +60,7 @@ 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))
|
||||||
|
|
||||||
// Find nearest EC station
|
output.LogInfo("Finding nearest Environment Canada 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))
|
||||||
|
|
@ -72,21 +68,15 @@ 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))
|
||||||
|
|
||||||
// Fetch weather
|
output.LogInfo("Fetching latest weather data...")
|
||||||
output.LogInfo("Fetching latest weather data…")
|
// Determine province from the station (if available)
|
||||||
province := station.Province
|
province := strings.Split(station.Code, "_")[0] // fallback heuristic
|
||||||
|
|
||||||
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))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -100,49 +90,19 @@ 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 updateTownCmd = &cobra.Command{
|
var updateCmd = &cobra.Command{
|
||||||
Use: "update-towns",
|
Use: "update-ipdb",
|
||||||
Short: "Manually update the Canadian towns geolocation index",
|
Short: "Manually update the local IP geolocation database",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
output.LogInfo("Forcing town index update…")
|
output.LogInfo("Forcing IP database update...")
|
||||||
if err := geo.ForceUpdateTownIndex(); err != nil {
|
if err := geo.ForceUpdateIPDB(); err != nil {
|
||||||
output.LogError(fmt.Sprintf("Update failed: %v", err))
|
output.LogError(fmt.Sprintf("Update failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
output.LogSuccess("Town index updated successfully.")
|
output.LogSuccess("IP database 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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"language": "en-CA",
|
|
||||||
"location": {
|
|
||||||
"mode": "auto",
|
|
||||||
"manual_city": "",
|
|
||||||
"manual_lat": 0,
|
|
||||||
"manual_lon": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# 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
388
docs/TAXONOMY.md
|
|
@ -1,388 +0,0 @@
|
||||||
[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 git.leaktechnologies.dev/Leak_Technologies/Skyfeed
|
module github.com/leaktechnologies/skyfeed
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,148 +1,113 @@
|
||||||
package geo
|
package geo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"path/filepath"
|
||||||
"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.
|
||||||
// Public IP + Geolocation (ipwho.is)
|
// It will try multiple fallback IP providers if the first one fails.
|
||||||
// -------------------------------
|
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
city, province, lat, lon, err := lookupIP(ip.String())
|
dbPath := filepath.Join(config.DataDir, "GeoLite2-City.mmdb")
|
||||||
if err != nil {
|
db, err := geoip2.Open(dbPath)
|
||||||
return "", 0, 0, fmt.Errorf("IP geolocation failed: %w", err)
|
if err != nil {
|
||||||
}
|
return "", 0, 0, fmt.Errorf("failed to open GeoLite2 database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
// e.g., "Ottawa, Ontario"
|
record, err := db.City(ip)
|
||||||
locationName := fmt.Sprintf("%s, %s", city, province)
|
if err != nil {
|
||||||
|
return "", 0, 0, fmt.Errorf("geoip lookup failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("[geo] Detected: %s (%.4f, %.4f)\n", locationName, lat, lon)
|
city := record.City.Names["en"]
|
||||||
return locationName, lat, lon, nil
|
prov := ""
|
||||||
|
if len(record.Subdivisions) > 0 {
|
||||||
|
prov = record.Subdivisions[0].Names["en"]
|
||||||
|
}
|
||||||
|
|
||||||
|
lat := record.Location.Latitude
|
||||||
|
lon := record.Location.Longitude
|
||||||
|
|
||||||
|
if city == "" && prov == "" {
|
||||||
|
return "", 0, 0, fmt.Errorf("no location info found for IP %s", ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[geo] Detected: %s, %s (%.4f, %.4f)\n", city, prov, lat, lon)
|
||||||
|
return fmt.Sprintf("%s, %s", city, prov), lat, lon, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------
|
// fetchPublicIP tries multiple reliable endpoints for the public IPv4 address.
|
||||||
// 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, err
|
return nil, fmt.Errorf("network error (%s): %w", url, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON-based API fallback
|
// Some APIs return plain text, others JSON
|
||||||
var j struct{ IP string `json:"ip"` }
|
var result struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&j); err == nil && j.IP != "" {
|
// Try decode JSON
|
||||||
ip := net.ParseIP(j.IP)
|
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.IP != "" {
|
||||||
if ip != nil && ip.To4() != nil {
|
ip := net.ParseIP(result.IP)
|
||||||
return ip, nil
|
if ip != nil && ip.To4() != nil {
|
||||||
}
|
return ip, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Plain text fallback → must re-fetch body
|
// Fallback: plain text
|
||||||
resp2, err := client.Get(url)
|
resp2, err := client.Get(url)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return nil, err
|
defer resp2.Body.Close()
|
||||||
}
|
buf := make([]byte, 64)
|
||||||
defer resp2.Body.Close()
|
n, _ := resp2.Body.Read(buf)
|
||||||
|
ip := net.ParseIP(string(buf[:n]))
|
||||||
|
if ip != nil && ip.To4() != nil {
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buf := make([]byte, 64)
|
return nil, fmt.Errorf("no valid IP found from %s", url)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
137
internal/geo/ipdb_updater.go
Normal file
137
internal/geo/ipdb_updater.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package geo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ipdbFileName = "GeoLite2-City.mmdb"
|
||||||
|
keyFileName = "maxmind.key"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureIPDBUpToDate checks the local MaxMind database and refreshes monthly.
|
||||||
|
func EnsureIPDBUpToDate() error {
|
||||||
|
dbPath := filepath.Join(config.DataDir, ipdbFileName)
|
||||||
|
info, err := os.Stat(dbPath)
|
||||||
|
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
fmt.Println("[geo] No IP database found, downloading...")
|
||||||
|
return updateIPDB(dbPath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to check IP DB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modTime := info.ModTime().UTC()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
if modTime.Before(firstOfMonth) {
|
||||||
|
fmt.Println("[geo] IP database is older than this month, refreshing...")
|
||||||
|
return updateIPDB(dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[geo] IP database is current.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceUpdateIPDB forces an immediate refresh.
|
||||||
|
func ForceUpdateIPDB() error {
|
||||||
|
dbPath := filepath.Join(config.DataDir, ipdbFileName)
|
||||||
|
fmt.Println("[geo] Forcing IP database update...")
|
||||||
|
return updateIPDB(dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateIPDB downloads and extracts the official GeoLite2 City database using your MaxMind key.
|
||||||
|
func updateIPDB(dest string) error {
|
||||||
|
keyPath := filepath.Join(config.ConfigDir, keyFileName)
|
||||||
|
keyBytes, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("[geo] Missing MaxMind license key.\nPlease run:\n echo \"YOUR_KEY\" > %s", keyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(string(keyBytes))
|
||||||
|
url := fmt.Sprintf("https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz", key)
|
||||||
|
|
||||||
|
tmpTar := dest + ".tar.gz"
|
||||||
|
if err := downloadFile(url, tmpTar); err != nil {
|
||||||
|
return fmt.Errorf("failed to download GeoLite2 archive: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpTar)
|
||||||
|
|
||||||
|
if err := extractMMDB(tmpTar, dest); err != nil {
|
||||||
|
return fmt.Errorf("failed to extract mmdb: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[geo] IP database updated successfully →", dest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFile streams a file from URL to disk.
|
||||||
|
func downloadFile(url, dest string) error {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMMDB extracts the .mmdb file from a tar.gz archive.
|
||||||
|
func extractMMDB(src, dest string) error {
|
||||||
|
f, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.Ext(h.Name) == ".mmdb" {
|
||||||
|
out, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, tr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no .mmdb found in archive")
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Station represents an Environment Canada citypage station.
|
// Station represents an Environment Canada citypage station.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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,50 +1,56 @@
|
||||||
package geo
|
package geo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Town represents a Canadian town record (loaded from towns.json).
|
||||||
|
type Town struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Province string `json:"province"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
}
|
||||||
|
|
||||||
// FindNearestTown loads the cached towns.json and finds the closest town to given coordinates.
|
// 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)
|
d := Haversine(lat, lon, t.Lat, t.Lon) // ✅ use shared helper from stations.go
|
||||||
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",
|
fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n", nearest.Name, nearest.Province, minDist)
|
||||||
nearest.Name, nearest.Province, minDist)
|
return nearest, nil
|
||||||
|
|
||||||
return nearest, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,123 +1,135 @@
|
||||||
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"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// WORKING GeoGratis endpoint for all populated places (PPL)
|
// Official Geographical Names Board of Canada WFS API
|
||||||
gnbcAPIURL = "https://geogratis.gc.ca/services/geoname/en/geonames.json?feature_code=PPL"
|
// Docs: https://www.nrcan.gc.ca/earth-sciences/geography/geographical-names-board-canada/download-geographical-names-data/10786
|
||||||
townsFile = "towns.json"
|
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"
|
||||||
maxFetchTime = 5 * time.Minute
|
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).
|
// 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 GeoGratis...")
|
fmt.Println("[geo] Fetching town data from GNBC WFS API...")
|
||||||
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 GeoGratis response: %w", err)
|
return fmt.Errorf("failed to read GNBC response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
towns, err := parseGNBCJSON(raw)
|
towns, err := parseGNBCJSON(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse GeoGratis JSON: %w", err)
|
return fmt.Errorf("failed to parse GNBC 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.json: %w", err)
|
return fmt.Errorf("failed to encode towns: %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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uses GeoGratis geonames.json structure
|
// parseGNBCJSON extracts relevant town info from the GNBC GeoJSON.
|
||||||
func parseGNBCJSON(data []byte) ([]Town, error) {
|
func parseGNBCJSON(data []byte) ([]TownRecord, error) {
|
||||||
var response struct {
|
var response struct {
|
||||||
Items []struct {
|
Features []struct {
|
||||||
Name string `json:"name"`
|
Properties struct {
|
||||||
ProvinceCode string `json:"province_code"`
|
Name string `json:"name"`
|
||||||
Latitude float64 `json:"latitude"`
|
Province string `json:"province"`
|
||||||
Longitude float64 `json:"longitude"`
|
Latitude float64 `json:"latitude"`
|
||||||
} `json:"items"`
|
Longitude float64 `json:"longitude"`
|
||||||
}
|
} `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 GeoGratis JSON: %w", err)
|
return nil, fmt.Errorf("invalid GNBC JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var towns []Town
|
var towns []TownRecord
|
||||||
for _, item := range response.Items {
|
for _, f := range response.Features {
|
||||||
if item.Name == "" || item.ProvinceCode == "" {
|
p := f.Properties
|
||||||
continue
|
if p.Name == "" || p.Province == "" {
|
||||||
}
|
continue
|
||||||
|
}
|
||||||
|
towns = append(towns, TownRecord{
|
||||||
|
Name: p.Name,
|
||||||
|
Province: p.Province,
|
||||||
|
Lat: p.Latitude,
|
||||||
|
Lon: p.Longitude,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
towns = append(towns, Town{
|
return towns, nil
|
||||||
Name: item.Name,
|
|
||||||
Province: item.ProvinceCode,
|
|
||||||
Lat: item.Latitude,
|
|
||||||
Lon: item.Longitude,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return towns, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package locale
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Loaded automatically from en_CA.json via LoadAll()
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package locale
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Loaded automatically from fr_CA.json
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
{
|
|
||||||
"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)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package locale
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Loaded automatically from iu_CA.json
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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, " ", "_")
|
|
||||||
}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
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"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather"
|
"github.com/leaktechnologies/skyfeed/internal/weather"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Theme defines simple ANSI colour codes for terminal output.
|
// Theme defines simple ANSI colour codes for terminal output.
|
||||||
|
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
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"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
"github.com/leaktechnologies/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"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
"github.com/leaktechnologies/skyfeed/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WeatherData holds simplified normalized current weather data.
|
// WeatherData holds simplified normalized current weather data.
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user