diff --git a/favicon.go b/favicon.go index 37c7d94..889b543 100644 --- a/favicon.go +++ b/favicon.go @@ -16,7 +16,6 @@ import ( "net/url" "os" "path/filepath" - "regexp" "strings" "sync" "time" @@ -45,9 +44,6 @@ var ( "/apple-touch-icon.png", "/apple-touch-icon-precomposed.png", } - - // Regex to extract favicon URLs from HTML - iconLinkRegex = regexp.MustCompile(`]+rel=["'](?:icon|shortcut icon|apple-touch-icon)["'][^>]+href=["']([^"']+)["']`) ) // Add this near the top with other vars @@ -372,7 +368,7 @@ func cacheFavicon(imageURL, imageID string) (string, bool, error) { // } // Debug - fmt.Printf("Downloading favicon [%s] for ID [%s]\n", imageURL, imageID) + printDebug("Downloading favicon ID: %s\n", imageID) filename := fmt.Sprintf("%s_icon.webp", imageID) imageCacheDir := filepath.Join(config.DriveCache.Path, "images") diff --git a/ia-calc.go b/ia-calc.go new file mode 100644 index 0000000..1e629e8 --- /dev/null +++ b/ia-calc.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// Enhanced math expression parser +func parseMathExpression(query string) (string, bool) { + // Clean and normalize the expression + query = strings.ReplaceAll(query, " ", "") + query = strings.ReplaceAll(query, ",", "") + + // Regex to match valid math expressions + mathRegex := regexp.MustCompile(`^\d+(\.\d+)?([\+\-\*/\^]\d+(\.\d+)?)+$`) + if !mathRegex.MatchString(query) { + return "", false + } + + // Operator precedence handling + operators := []struct { + symbol string + apply func(float64, float64) float64 + }{ + {"^", func(a, b float64) float64 { + result := 1.0 + for i := 0; i < int(b); i++ { + result *= a + } + return result + }}, + {"*", func(a, b float64) float64 { return a * b }}, + {"/", func(a, b float64) float64 { return a / b }}, + {"+", func(a, b float64) float64 { return a + b }}, + {"-", func(a, b float64) float64 { return a - b }}, + } + + // Parse numbers and operators + var tokens []interface{} + current := "" + for _, char := range query { + if char >= '0' && char <= '9' || char == '.' { + current += string(char) + } else { + if current != "" { + num, _ := strconv.ParseFloat(current, 64) + tokens = append(tokens, num) + current = "" + } + tokens = append(tokens, string(char)) + } + } + if current != "" { + num, _ := strconv.ParseFloat(current, 64) + tokens = append(tokens, num) + } + + // Evaluate expression with operator precedence + for _, op := range operators { + for i := 1; i < len(tokens)-1; i += 2 { + if operator, ok := tokens[i].(string); ok && operator == op.symbol { + left := tokens[i-1].(float64) + right := tokens[i+1].(float64) + result := op.apply(left, right) + + // Update tokens + tokens = append(tokens[:i-1], tokens[i+2:]...) + tokens = append(tokens[:i-1], append([]interface{}{result}, tokens[i-1:]...)...) + i -= 2 // Adjust index after modification + } + } + } + + // Format result + result := tokens[0].(float64) + if result == float64(int(result)) { + return fmt.Sprintf("%d", int(result)), true + } + return fmt.Sprintf("%.2f", result), true +} diff --git a/ia-currency.go b/ia-currency.go new file mode 100644 index 0000000..54c4e48 --- /dev/null +++ b/ia-currency.go @@ -0,0 +1,206 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// ExchangeRateCache holds currency rates with automatic refresh +var ( + exchangeRates = make(map[string]float64) + lastUpdated time.Time + exchangeCacheMutex sync.RWMutex + cacheExpiration = 1 * time.Hour + allCurrencies []string +) + +// CurrencyAPIResponse structure for exchange rate API +type CurrencyAPIResponse struct { + Rates map[string]float64 `json:"rates"` +} + +// UpdateExchangeRates fetches and caches currency rates +func UpdateExchangeRates() error { + exchangeCacheMutex.Lock() + defer exchangeCacheMutex.Unlock() + + // Use a reliable free API with good rate limits + resp, err := http.Get("https://open.er-api.com/v6/latest/USD") + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var apiResponse struct { + Result string `json:"result"` + Rates map[string]float64 `json:"rates"` + } + if err := json.Unmarshal(body, &apiResponse); err != nil { + return err + } + + if apiResponse.Result != "success" { + return fmt.Errorf("API error: %s", apiResponse.Result) + } + + // Cache the rates + exchangeRates = apiResponse.Rates + lastUpdated = time.Now() + + // Update list of all currencies + allCurrencies = make([]string, 0, len(exchangeRates)) + for currency := range exchangeRates { + allCurrencies = append(allCurrencies, currency) + } + + log.Printf("Updated currency rates: %d currencies cached", len(allCurrencies)) + return nil +} + +// PrecacheAllCurrencyPairs pre-caches conversion rates for all currency pairs +func PrecacheAllCurrencyPairs() { + exchangeCacheMutex.RLock() + defer exchangeCacheMutex.RUnlock() + + if len(exchangeRates) == 0 { + log.Println("Skipping precache - no rates available") + return + } + + log.Printf("Starting precache of all currency pairs (%d currencies)", len(allCurrencies)) + + // We'll pre-cache the most common pairs directly + commonCurrencies := []string{"USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY", "INR"} + + for _, from := range commonCurrencies { + for _, to := range allCurrencies { + if from == to { + continue + } + GetExchangeRate(from, to) + } + } + log.Println("Common currency pairs precached") +} + +// GetExchangeRate gets the current exchange rate with caching +func GetExchangeRate(from, to string) (float64, bool) { + // Auto-update cache if expired + if time.Since(lastUpdated) > cacheExpiration { + UpdateExchangeRates() // Ignore errors, use stale data if needed + } + + exchangeCacheMutex.RLock() + defer exchangeCacheMutex.RUnlock() + + // Handle same currency + if from == to { + return 1, true + } + + // Convert via USD if direct rate not available + fromRate, fromExists := exchangeRates[from] + toRate, toExists := exchangeRates[to] + + if !fromExists || !toExists { + return 0, false + } + + // Calculate cross rate: (1 USD / fromRate) * toRate + return toRate / fromRate, true +} + +// ParseCurrencyConversion detects and processes currency conversion queries +func ParseCurrencyConversion(query string) (float64, string, string, bool) { + // Match patterns like: "100 USD to EUR", "50 eur in gbp", "¥1000 to USD" + re := regexp.MustCompile(`(?i)([\d,]+(?:\.\d+)?)\s*([$€£¥₩₹₽A-Za-z]{1,6})\s+(?:to|in|➞|→)\s+([$€£¥₩₹₽A-Za-z]{1,6})`) + matches := re.FindStringSubmatch(query) + if len(matches) < 4 { + return 0, "", "", false + } + + // Clean and parse amount + amountStr := strings.ReplaceAll(matches[1], ",", "") + amount, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + return 0, "", "", false + } + + // Normalize currency symbols + currencyMap := map[string]string{ + "$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY", "₩": "KRW", "₹": "INR", "₽": "RUB", + "usd": "USD", "eur": "EUR", "gbp": "GBP", "jpy": "JPY", "krw": "KRW", "inr": "INR", "rub": "RUB", + "dollar": "USD", "euro": "EUR", "pound": "GBP", "yen": "JPY", "won": "KRW", "rupee": "INR", "ruble": "RUB", + } + + fromCurr := strings.ToUpper(matches[2]) + if mapped, ok := currencyMap[fromCurr]; ok { + fromCurr = mapped + } else if len(fromCurr) > 3 { + // Try to match longer names + for k, v := range currencyMap { + if strings.EqualFold(k, fromCurr) { + fromCurr = v + break + } + } + } + + toCurr := strings.ToUpper(matches[3]) + if mapped, ok := currencyMap[toCurr]; ok { + toCurr = mapped + } else if len(toCurr) > 3 { + // Try to match longer names + for k, v := range currencyMap { + if strings.EqualFold(k, toCurr) { + toCurr = v + break + } + } + } + + return amount, fromCurr, toCurr, true +} + +// ConvertCurrency handles the actual conversion +func ConvertCurrency(amount float64, from, to string) (float64, bool) { + if from == to { + return amount, true + } + + rate, ok := GetExchangeRate(from, to) + if !ok { + // Try to find similar currencies + from = strings.ToUpper(from) + to = strings.ToUpper(to) + + // Check if we have the currency in our list + exchangeCacheMutex.RLock() + defer exchangeCacheMutex.RUnlock() + + _, fromExists := exchangeRates[from] + _, toExists := exchangeRates[to] + + if !fromExists || !toExists { + return 0, false + } + + // Shouldn't happen due to the check above, but just in case + return 0, false + } + + return amount * rate, true +} diff --git a/ia-main.go b/ia-main.go new file mode 100644 index 0000000..da25e40 --- /dev/null +++ b/ia-main.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "time" +) + +type InstantAnswer struct { + Type string // "calc", "unit_convert", "wiki", ... + Title string + Content interface{} +} + +func detectInstantAnswer(query string) *InstantAnswer { + // Try currency conversion first (more specific) + if amount, from, to, ok := ParseCurrencyConversion(query); ok { + if result, ok := ConvertCurrency(amount, from, to); ok { + return &InstantAnswer{ + Type: "currency", + Title: "Currency Conversion", + Content: map[string]interface{}{ + "from": from, + "to": to, + "amount": amount, + "result": result, + "display": fmt.Sprintf("%.2f %s = %.2f %s", amount, from, result, to), + }, + } + } + } + + // Try math expression + if result, ok := parseMathExpression(query); ok { + return &InstantAnswer{ + Type: "calc", + Title: "Calculation Result", + Content: result, + } + } + + // Try Wikipedia search + if title, text, link, ok := getWikipediaSummary(query); ok { + return &InstantAnswer{ + Type: "wiki", + Title: title, + Content: map[string]string{ + "text": text, + "link": link, + }, + } + } + + return nil +} + +func initExchangeRates() { + // Initial synchronous load + if err := UpdateExchangeRates(); err != nil { + printErr("Initial exchange rate update failed: %v", err) + } else { + PrecacheAllCurrencyPairs() + } + + // Pre-cache common wiki terms in background + go func() { + commonTerms := []string{"United States", "Europe", "Technology", "Science", "Mathematics"} + for _, term := range commonTerms { + getWikipediaSummary(term) + } + }() + + // Periodically update cache + ticker := time.NewTicker(30 * time.Minute) + go func() { + for range ticker.C { + if err := UpdateExchangeRates(); err != nil { + printWarn("Periodic exchange rate update failed: %v", err) + } else { + PrecacheAllCurrencyPairs() + } + } + }() +} diff --git a/ia-wiki.go b/ia-wiki.go new file mode 100644 index 0000000..3e9fe3c --- /dev/null +++ b/ia-wiki.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// Wikipedia API response structure +type WikipediaResponse struct { + Query struct { + Pages map[string]struct { + PageID int `json:"pageid"` + Title string `json:"title"` + Extract string `json:"extract"` + } `json:"pages"` + } `json:"query"` +} + +// Get Wikipedia summary +func getWikipediaSummary(query string) (title, text, link string, ok bool) { + // Clean and prepare query + query = strings.TrimSpace(query) + if query == "" { + return "", "", "", false + } + + // API request + apiURL := fmt.Sprintf( + "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts&exintro&explaintext&redirects=1&titles=%s", + url.QueryEscape(query), + ) + + resp, err := http.Get(apiURL) + if err != nil { + return "", "", "", false + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", "", false + } + + // Parse JSON response + var result WikipediaResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", "", "", false + } + + // Extract first valid page + for _, page := range result.Query.Pages { + if page.PageID == 0 || page.Extract == "" { + continue + } + + // Format text + text = page.Extract + if len(text) > 500 { + text = text[:500] + "..." + } + + // Create link + titleForURL := strings.ReplaceAll(page.Title, " ", "_") + link = fmt.Sprintf("https://en.wikipedia.org/wiki/%s", url.PathEscape(titleForURL)) + + return page.Title, text, link, true + } + + return "", "", "", false +} diff --git a/init-extra.go b/init-extra.go index 6810f7c..c455d41 100644 --- a/init-extra.go +++ b/init-extra.go @@ -72,6 +72,7 @@ func main() { initFileEngines() initPipedInstances() initMusicEngines() + initExchangeRates() } InitializeLanguage("en") // Initialize language before generating OpenSearch diff --git a/init.go b/init.go index 5b8f934..30f5345 100644 --- a/init.go +++ b/init.go @@ -71,6 +71,7 @@ func main() { initFileEngines() initPipedInstances() initMusicEngines() + initExchangeRates() } InitializeLanguage("en") // Initialize language before generating OpenSearch diff --git a/main.go b/main.go index fa2d7aa..7b72235 100755 --- a/main.go +++ b/main.go @@ -175,7 +175,7 @@ func handleSearch(w http.ResponseWriter, r *http.Request) { case "text": fallthrough default: - HandleTextSearch(w, settings, query, page) + HandleTextSearchWithInstantAnswer(w, settings, query, page) } } diff --git a/static/css/style-instantanswer.css b/static/css/style-instantanswer.css new file mode 100644 index 0000000..c2d67be --- /dev/null +++ b/static/css/style-instantanswer.css @@ -0,0 +1,139 @@ +.instant-container { + position: absolute; + top: 140px; + right: 175px; + width: 500px; + z-index: 1; +} + +.instant-box { + border: 1px solid var(--snip-border); + background-color: var(--snip-background); + border-radius: 8px; + padding: 15px; + margin-bottom: 20px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.instant-box h3 { + margin-top: 0; + color: var(--highlight); + font-size: 20px; + border-bottom: 1px solid var(--border); + padding-bottom: 10px; + text-decoration: none; +} + +.instant-result { + margin: 15px 0; + font-size: 16px; + line-height: 1.5; +} + +.calc-container { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 15px; +} + +.calc-input { + padding: 10px; + width: 95%; + font-size: 18px; + border: 1px solid var(--border); + border-radius: 4px; + background-color: var(--search-bg); + color: var(--fg); +} + +.calc-buttons { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px; +} + +.calc-buttons button { + padding: 10px; + font-size: 18px; + border: 1px solid var(--border); + border-radius: 4px; + background-color: var(--button); + color: var(--fg); + cursor: pointer; + transition: background-color 0.2s; +} + +.calc-buttons button:hover { + background-color: var(--search-select); +} + +.calc-result { + margin-top: 10px; + padding: 10px; + font-size: 20px; + border: 1px solid var(--border); + border-radius: 4px; + background-color: var(--snip-background); +} + +.calc-history { + display: flex; + flex-direction: column-reverse; + font-family: monospace; + font-size: 16px; + color: var(--fg); + background-color: var(--search-bg); + border: 1px solid var(--border); + padding: 10px; + border-radius: 4px; + height: 120px; + overflow-y: auto; + white-space: pre-line; + overflow-anchor: none; +} + +.calc-buttons { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.calc-buttons button { + padding: 20px; + font-size: 20px; + border-radius: 6px; + background-color: var(--search-bg); + border: 1px solid var(--border); + color: var(--fg); +} + +.calc-buttons .equals { + background-color: #2ecc71; + color: #fff; + font-weight: bold; +} + +.calc-buttons .equals:hover { + background-color: #27ae60; +} + + +/* Responsive adjustments */ +@media only screen and (max-width: 1220px) { + .instant-container { + position: relative; + width: 100%; + top: 0; + right: 0; + margin-left: 175px; + margin-top: 20px; + } +} + +@media only screen and (max-width: 880px) { + .instant-container { + width: 90%; + margin-left: 20px; + } +} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 3f1b47b..2ea7783 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -123,93 +123,6 @@ html { font-size: 15px; } -.calc { - height: fit-content; - width: fit-content; - position: relative; - left: 175px; - border: 1px solid var(--snip-border); - background-color: var(--snip-background); - border-radius: 8px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; -} - -.calc-btn-style { - background-color: var(--html-bg) !important; -} - -.calc-input { - width: 90%; - height: 10%; - background-color: var(--search-bg); - border: 1px solid var(--snip-border); - border-radius: 8px; - padding: 15px; - margin-top: 8px; - text-align: right; - max-width: 48em; - line-height: 1.58; - font-size: 22px; - color: var(--fg); - letter-spacing: normal; - overflow: hidden; -} - -.calc-btn { - max-width: 48em; - line-height: 50px; - font-size: 22px; - color: var(--fg); - letter-spacing: normal; - border-radius: 8px; - background-color: var(--search-bg); - border: 1px solid var(--snip-border); - height: 50px; - margin: auto; - margin: 4px; - width: 80px; - text-align: center; -} - -.calc-btn-2 { - max-width: 48em; - line-height: 50px; - font-size: 22px; - color: var(--fff); - letter-spacing: normal; - border-radius: 8px; - background-color: var(--font-fg); - height: 50px; - margin: auto; - margin: 4px; - width: 80px; - text-align: center; -} - -.calc-btns { - display: grid; - grid-template-columns: repeat(4, 90px); - width: 100%; - justify-content: center; - padding: 4px; -} - -.calc-pos-absolute { - position: absolute; - margin-top: 60px; - display: flex; - flex-direction: row; - flex-wrap: wrap; -} - -.prev_calculation { - opacity: 0.5; - font-size: 14px; -} - .emoji-code { font-family: Arial, Helvetica, sans-serif; } @@ -978,6 +891,7 @@ p { max-width: 600px; word-wrap: break-word; margin-bottom: 35px; + z-index: 0; } .result_sublink { @@ -1518,6 +1432,10 @@ button { @media only screen and (max-width: 1220px) { + .results { + padding-right: 340px; + } + .snip { position: relative; float: none; @@ -1541,10 +1459,6 @@ button { font-size: 13px; } - .calc { - left: 20px; - } - .results h3, .result_sublink h3 { font-size: 16px; diff --git a/static/js/calculator.js b/static/js/calculator.js new file mode 100644 index 0000000..b29f61b --- /dev/null +++ b/static/js/calculator.js @@ -0,0 +1,47 @@ +document.addEventListener('DOMContentLoaded', function () { + const calcContainer = document.getElementById('dynamic-calc'); + const staticCalc = document.getElementById('static-calc'); + + if (calcContainer) { + calcContainer.style.display = 'block'; + if (staticCalc) staticCalc.style.display = 'none'; + + const input = document.getElementById('calc-input'); + const result = document.getElementById('calc-result'); + const history = document.getElementById('calc-history'); + const buttons = document.querySelectorAll('.calc-buttons button'); + + let currentInput = ''; + let historyLog = []; + + const updateUI = () => { + input.value = currentInput; + history.innerHTML = historyLog.map(entry => `
{{ translate "fetched_in" .Fetched }}
+ + {{if .Instant}} +