diff --git a/common.go b/common.go index 75baf40..06c47bf 100755 --- a/common.go +++ b/common.go @@ -29,9 +29,48 @@ var ( } return string(jsonBytes), nil }, + "formatShortDate": func(date string) string { + t, _ := time.Parse("2006-01-02", date) + // return t.Format("Mon") // e.g. "Sat" + return t.Format("2.1.") // e.g. "29.6." + }, + "weatherIcon": func(cur interface{}) string { + switch c := cur.(type) { + case map[string]interface{}: + if cond, ok := c["Condition"].(string); ok { + return iconForCond(cond) + } + case WeatherCurrent: + return iconForCond(c.Condition) + case *WeatherCurrent: + return iconForCond(c.Condition) + } + return "🌈" + }, } ) +func iconForCond(cond string) string { + switch cond { + case "Clear": + return "☀️" + case "Partly cloudy": + return "⛅" + case "Cloudy": + return "☁️" + case "Rain": + return "🌧️" + case "Snow": + return "❄️" + case "Thunderstorm": + return "⛈️" + case "Fog": + return "🌫️" + default: + return "🌈" + } +} + type SearchEngine struct { Name string Func func(string, string, string, int) ([]SearchResult, time.Duration, error) diff --git a/ia-main.go b/ia-main.go index da25e40..0e90477 100644 --- a/ia-main.go +++ b/ia-main.go @@ -38,6 +38,23 @@ func detectInstantAnswer(query string) *InstantAnswer { } } + // Try weather instant answer + if city, forecast, ok := getWeatherForQuery(query); ok { + return &InstantAnswer{ + Type: "weather", + Title: fmt.Sprintf("Weather in %s", city.Name), + Content: map[string]interface{}{ + "city": city.Name, + "country": city.Country, + "lat": city.Lat, + "lon": city.Lon, + "current": forecast.Current, + "forecast": forecast.Forecast, + "display": fmt.Sprintf("%.1f°C, %s", forecast.Current.Temperature, forecast.Current.Condition), + }, + } + } + // Try Wikipedia search if title, text, link, ok := getWikipediaSummary(query); ok { return &InstantAnswer{ diff --git a/ia-weather.go b/ia-weather.go new file mode 100644 index 0000000..5c9f532 --- /dev/null +++ b/ia-weather.go @@ -0,0 +1,367 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +type WeatherCity struct { + Name string + Country string + Lat float64 + Lon float64 +} +type WeatherCurrent struct { + Temperature float64 + Wind float64 + Humidity int + Condition string +} +type WeatherDay struct { + Date string + MinTemp float64 + MaxTemp float64 + Condition string +} +type WeatherForecast struct { + Current WeatherCurrent + Forecast []WeatherDay +} + +func getWeatherForQuery(query string) (city WeatherCity, forecast WeatherForecast, ok bool) { + // Expanded multi-language weather keywords (40+ languages/dialects) + weatherWords := []string{ + // English + "weather", "forecast", "temperature", "conditions", "meteorology", "outlook", + // Czech/Slovak + "počasí", "předpověď", "teplota", "vlhkost", "srážky", "vítr", "meteo", + // German + "wetter", "vorhersage", "temperatur", "wettervorhersage", "wetterbericht", + // French + "météo", "prévisions", "température", "conditions météo", "prévision météo", + // Spanish + "tiempo", "clima", "pronóstico", "temperatura", "meteorología", "previsión", + // Italian + "tempo", "meteo", "previsioni", "temperatura", "condizioni atmosferiche", + // Portuguese + "tempo", "clima", "previsão", "temperatura", "meteorologia", + // Polish + "pogoda", "prognoza", "temperatura", "warunki atmosferyczne", + // Russian + "погода", "прогноз", "температура", "метео", "метеопрогноз", + // Ukrainian + "погода", "прогноз", "температура", "метео", + // Dutch + "weer", "voorspelling", "temperatuur", "weersverwachting", + // Scandinavian + "väder", "prognos", "temperatur", // Swedish + "vær", "prognose", "temperatur", // Norwegian/Danish + "veður", "spá", "hitastig", // Icelandic + // East Asian + "天気", "予報", "気温", // Japanese (tenki, yohō, kion) + "날씨", "예보", "기온", // Korean (nalssi, yebo, gion) + "天气", "预报", "气温", // Chinese (tiānqì, yùbào, qìwēn) + // South Asian + "मौसम", "पूर्वानुमान", "तापमान", // Hindi (mausam, purvanumaan, taapmaan) + "আবহাওয়া", "পূর্বাভাস", "তাপমাত্রা", // Bengali (ābhawāẏā, pūrbābhāsa, tāpamātrā) + // Middle Eastern + "طقس", "توقعات", "درجة الحرارة", // Arabic (ṭaqs, tawaqquʿāt, darajat al-ḥarāra) + "آب و ہوا", "پیش گوئی", "درجہ حرارت", // Urdu (āb-o-hawā, peshgoī, daraja ḥarārat) + // Turkish + "hava", "tahmin", "sıcaklık", "hava durumu", + // Greek + "καιρός", "πρόβλεψη", "θερμοκρασία", + // Hebrew + "מזג אוויר", "תחזית", "טמפרטורה", + // Other European + "időkép", "előrejelzés", "hőmérséklet", // Hungarian + "vreme", "prognoză", "temperatură", // Romanian + "vrijeme", "prognoza", "temperatura", // Croatian/Serbian + // Global/Internet slang + "temp", "wx", "meteo", "wea", "forec", + } + + // Enhanced multi-language prepositions + prepositions := []string{ + // English + "in", "at", "for", "around", "near", + // Czech/Slovak + "v", "ve", "na", "do", "u", "při", "blízko", "okolí", + // German + "in", "bei", "an", "für", "um", "nahe", + // Romance + "en", "a", "au", "aux", "dans", // French + "en", "a", "de", // Spanish + "a", "in", "da", // Italian + "em", "no", "na", // Portuguese + // Slavic + "w", "we", "na", "dla", "pod", // Polish + "в", "на", "у", "к", "под", // Russian/Ukrainian + // Nordic + "i", "på", "hos", // Swedish/Danish/Norwegian + // Others + "في", "عند", "قرب", // Arabic (fī, ʿind, qurb) + "में", "पर", "के पास", // Hindi (mẽ, par, ke pās) + "で", "に", "の近く", // Japanese (de, ni, no chikaku) + "에서", "에", "근처", // Korean (eseo, e, geuncheo) + "在", "于", "附近", // Chinese (zài, yú, fùjìn) + } + + // Always normalize query (lowercase + remove diacritics) + normalized := removeDiacritics(strings.ToLower(query)) + hasWeather := false + for _, word := range weatherWords { + if strings.Contains(normalized, removeDiacritics(word)) { + hasWeather = true + break + } + } + if !hasWeather { + return city, forecast, false + } + + // Improved location extraction with diacritic handling + loc := extractWeatherLocation(normalized, weatherWords, prepositions) + if loc == "" { + return city, forecast, false + } + + // Geocode and get weather + return geocodeAndGetWeather(loc) +} + +func extractWeatherLocation(query string, weatherWords, prepositions []string) string { + // Create normalized versions for matching + normWeatherWords := make([]string, len(weatherWords)) + for i, w := range weatherWords { + normWeatherWords[i] = removeDiacritics(w) + } + + normPrepositions := make([]string, len(prepositions)) + for i, p := range prepositions { + normPrepositions[i] = removeDiacritics(p) + } + + // Pattern 1: [weather_word] [preposition]? [location] + pattern1 := `(?:` + strings.Join(normWeatherWords, "|") + `)\s*(?:` + strings.Join(normPrepositions, "|") + `)?\s*(.+)` + re1 := regexp.MustCompile(pattern1) + if matches := re1.FindStringSubmatch(query); len(matches) > 1 { + loc := cleanLocation(matches[1], normPrepositions) + if loc != "" { + return loc + } + } + + // Pattern 2: [location] [weather_word] + pattern2 := `(.+?)\s+(?:` + strings.Join(normWeatherWords, "|") + `)` + re2 := regexp.MustCompile(pattern2) + if matches := re2.FindStringSubmatch(query); len(matches) > 1 { + loc := cleanLocation(matches[1], normPrepositions) + if loc != "" { + return loc + } + } + + // Pattern 3: Question format + questionPattern := `(?:how is|what is|what's|jak[ée]\s+je|wie ist|quel est|qu[eé]\s+tal|com'[èe])\s+(?:the )?(?:` + + strings.Join(normWeatherWords, "|") + `)\s*(?:` + strings.Join(normPrepositions, "|") + `)?\s*(.+)` + re3 := regexp.MustCompile(questionPattern) + if matches := re3.FindStringSubmatch(query); len(matches) > 1 { + loc := cleanLocation(matches[1], normPrepositions) + if loc != "" { + return loc + } + } + + // Fallback with smarter exclusion + return extractByExclusion(query, normWeatherWords, normPrepositions) +} + +func cleanLocation(loc string, prepositions []string) string { + // Create preposition set + prepSet := make(map[string]bool) + for _, p := range prepositions { + prepSet[p] = true + } + + words := strings.Fields(loc) + + // Remove leading prepositions + for len(words) > 0 && prepSet[words[0]] { + words = words[1:] + } + + // Remove trailing prepositions + for len(words) > 0 && prepSet[words[len(words)-1]] { + words = words[:len(words)-1] + } + + // Rejoin and clean + cleaned := strings.Join(words, " ") + return strings.Trim(cleaned, ",.?!:;()[]{}'\"") +} + +// Remove diacritics implementation +func removeDiacritics(s string) string { + var result []rune + for _, r := range norm.NFD.String(s) { + if unicode.Is(unicode.Mn, r) { // Mn: nonspacing marks + continue + } + result = append(result, r) + } + return string(result) +} + +// Extract location by removing weather-related words +func extractByExclusion(query string, weatherWords, prepositions []string) string { + // Create removal set + removeSet := make(map[string]bool) + for _, w := range weatherWords { + removeSet[w] = true + } + for _, p := range prepositions { + removeSet[p] = true + } + + // Process query words + words := strings.Fields(query) + var locWords []string + for _, word := range words { + if !removeSet[word] { + locWords = append(locWords, word) + } + } + + loc := strings.Join(locWords, " ") + return cleanLocation(loc, prepositions) +} + +// // Improved location cleaning +// func cleanLocation(loc string) string { +// loc = strings.Trim(loc, ",.?!:;()[]{}'\"") + +// // Remove trailing verbs +// verbs := []string{"is", "at", "for", "in", "v", "ve", "na", "do", "w", "en", "a"} +// for _, v := range verbs { +// loc = strings.TrimSuffix(loc, " "+v) +// } + +// return loc +// } + +// // Remove diacritics implementation +// func removeDiacritics(s string) string { +// var result []rune +// for _, r := range norm.NFD.String(s) { +// if unicode.Is(unicode.Mn, r) { // Mn: nonspacing marks +// continue +// } +// result = append(result, r) +// } +// return string(result) +// } + +func geocodeAndGetWeather(loc string) (WeatherCity, WeatherForecast, bool) { + var city WeatherCity + var forecast WeatherForecast + // 1. Geocode + geoURL := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1", urlQueryEscape(loc)) + resp, err := http.Get(geoURL) + if err != nil { + return city, forecast, false + } + defer resp.Body.Close() + var geo struct { + Results []struct { + Name string `json:"name"` + Country string `json:"country"` + Lat float64 `json:"latitude"` + Lon float64 `json:"longitude"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&geo); err != nil || len(geo.Results) == 0 { + return city, forecast, false + } + g := geo.Results[0] + city = WeatherCity{ + Name: g.Name, + Country: g.Country, + Lat: g.Lat, + Lon: g.Lon, + } + // 2. Weather (current + forecast) + weatherURL := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%f&longitude=%f¤t=temperature_2m,weather_code,wind_speed_10m,relative_humidity_2m&daily=temperature_2m_min,temperature_2m_max,weather_code&forecast_days=3&timezone=auto", g.Lat, g.Lon) + resp2, err := http.Get(weatherURL) + if err != nil { + return city, forecast, false + } + defer resp2.Body.Close() + var data struct { + Current struct { + Temp float64 `json:"temperature_2m"` + Wind float64 `json:"wind_speed_10m"` + Hum int `json:"relative_humidity_2m"` + Code int `json:"weather_code"` + } `json:"current"` + Daily struct { + Dates []string `json:"time"` + MinTemp []float64 `json:"temperature_2m_min"` + MaxTemp []float64 `json:"temperature_2m_max"` + Weather []int `json:"weather_code"` + } `json:"daily"` + } + body, _ := io.ReadAll(resp2.Body) + if err := json.Unmarshal(body, &data); err != nil { + return city, forecast, false + } + forecast.Current = WeatherCurrent{ + Temperature: data.Current.Temp, + Wind: data.Current.Wind, + Humidity: data.Current.Hum, + Condition: weatherDescription(data.Current.Code), + } + for i := range data.Daily.Dates { + forecast.Forecast = append(forecast.Forecast, WeatherDay{ + Date: data.Daily.Dates[i], + MinTemp: data.Daily.MinTemp[i], + MaxTemp: data.Daily.MaxTemp[i], + Condition: weatherDescription(data.Daily.Weather[i]), + }) + } + return city, forecast, true +} + +func weatherDescription(code int) string { + // Minimal mapping, can be expanded + switch code { + case 0: + return "Clear" + case 1, 2, 3: + return "Partly cloudy" + case 45, 48: + return "Fog" + case 51, 53, 55, 56, 57: + return "Drizzle" + case 61, 63, 65, 66, 67, 80, 81, 82: + return "Rain" + case 71, 73, 75, 77, 85, 86: + return "Snow" + case 95, 96, 99: + return "Thunderstorm" + default: + return "Unknown" + } +} + +// Helper for safe query escaping +func urlQueryEscape(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, " ", "+"), "%", "") +} diff --git a/static/css/style-instantanswer.css b/static/css/style-instantanswer.css index c2d67be..b31e1b9 100644 --- a/static/css/style-instantanswer.css +++ b/static/css/style-instantanswer.css @@ -118,6 +118,151 @@ background-color: #27ae60; } +.weather { + padding: 0; + background: none; + border-radius: 0; + box-shadow: none; + color: var(--snip-text); + min-width: 0; + max-width: none; + margin: 0; +} + +.weather-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 1.13em; + font-weight: 500; + margin-bottom: 6px; +} + +.weather-location { + opacity: 0.82; +} + +.weather-temp { + font-size: 2.3em; + font-weight: 700; + color: var(--blue); /* not sure if using "var(--blue);" is the right idea */ + letter-spacing: -1px; +} + +.weather-deg { + font-size: 0.5em; + font-weight: 500; + vertical-align: super; + opacity: 0.5; +} + +.weather-current { + display: flex; + align-items: center; + margin-bottom: 12px; + gap: 10px; + padding: 12px 14px 10px 14px; +} + +.weather-icon { + font-size: 1.7em; + margin-right: 7px; +} + +.weather-cond { + font-size: 1.09em; + font-weight: 500; + color: var(--highlight); + margin-right: 12px; + display: flex; + align-items: center; + gap: 5px; +} + +.weather-details { + font-size: 0.97em; + opacity: 0.82; + margin-left: auto; +} + +.weather-details span { + color: var(--fg); +} + +.weather-forecast { + display: flex; + gap: 7px; + margin-top: 9px; +} + +.weather-forecast-day { + flex: 1 1 0; + min-width: 0; + background: var(--search-bg); + border-radius: 8px; + padding: 8px 6px; + text-align: center; + color: var(--font-fg); + box-shadow: 0 1px 3px color-mix(in srgb, var(--search-bg), black 15%); +} + +.weather-forecast-date { + font-size: 0.97em; + font-weight: 500; + opacity: 0.82; + margin-bottom: 2px; + color: var(--highlight); +} + +.weather-forecast-temps { + font-size: 1.13em; + font-weight: 600; + color: var(--blue); +} + +.weather-forecast-cond { + font-size: 0.98em; + opacity: 0.83; + color: var(--fg); +} + + +.weather-current { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + gap: 8px; +} + +.weather-current-left { + display: flex; + align-items: center; + gap: 7px; +} + +.weather-icon { + font-size: 1.7em; +} + +.weather-cond { + font-size: 1.08em; + font-weight: 500; + color: var(--highlight); +} + +.weather-current-right { + display: flex; + align-items: center; + gap: 12px; +} + +.weather-detail { + font-size: 0.98em; + color: var(--blue); + opacity: 0.85; + font-weight: 500; +} /* Responsive adjustments */ @media only screen and (max-width: 1220px) { @@ -136,4 +281,14 @@ width: 90%; margin-left: 20px; } -} \ No newline at end of file +} + +@media (max-width: 540px) { + .weather-forecast { + gap: 4px; + } + .weather-forecast-day { + padding: 6px 2px; + font-size: 0.95em; + } +} diff --git a/templates/text.html b/templates/text.html index e2c5823..795a00e 100755 --- a/templates/text.html +++ b/templates/text.html @@ -168,8 +168,8 @@ {{if .Instant}}