diff --git a/files.go b/files.go index 68f8742..931daaa 100755 --- a/files.go +++ b/files.go @@ -71,6 +71,7 @@ func handleFileSearch(w http.ResponseWriter, settings UserSettings, query string CurrentLang string Theme string Safe string + IsThemeDark bool }{ Results: combinedResults, Query: query, @@ -84,6 +85,7 @@ func handleFileSearch(w http.ResponseWriter, settings UserSettings, query string CurrentLang: settings.Language, Theme: settings.Theme, Safe: settings.SafeSearch, + IsThemeDark: settings.IsThemeDark, } // // Debugging: Print results before rendering template diff --git a/forums.go b/forums.go index d0be4f6..4c15490 100755 --- a/forums.go +++ b/forums.go @@ -115,6 +115,7 @@ func handleForumsSearch(w http.ResponseWriter, settings UserSettings, query stri CurrentLang string Theme string Safe string + IsThemeDark bool }{ Query: query, Results: results, @@ -125,6 +126,7 @@ func handleForumsSearch(w http.ResponseWriter, settings UserSettings, query stri CurrentLang: settings.Language, Theme: settings.Theme, Safe: settings.SafeSearch, + IsThemeDark: settings.IsThemeDark, } funcMap := template.FuncMap{ diff --git a/images-deviantart.go b/images-deviantart.go new file mode 100644 index 0000000..cbeeea4 --- /dev/null +++ b/images-deviantart.go @@ -0,0 +1,237 @@ +package main + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/PuerkitoBio/goquery" +) + +// NextPageCache is a specialized cache for storing next page links +type NextPageCache struct { + mu sync.Mutex + links map[string]string + expiration time.Duration +} + +// NewNextPageCache creates a new NextPageCache with a specified expiration duration +func NewNextPageCache(expiration time.Duration) *NextPageCache { + return &NextPageCache{ + links: make(map[string]string), + expiration: expiration, + } +} + +// Get retrieves the next page link for a given key from the cache +func (npc *NextPageCache) Get(key CacheKey) (string, bool) { + npc.mu.Lock() + defer npc.mu.Unlock() + + link, exists := npc.links[npc.keyToString(key)] + if !exists { + return "", false + } + + return link, true +} + +// Set stores the next page link for a given key in the cache +// Idk it maybye worth it to use "cache.go" for this +func (npc *NextPageCache) Set(key CacheKey, link string) { + npc.mu.Lock() + defer npc.mu.Unlock() + + npc.links[npc.keyToString(key)] = link +} + +// keyToString converts a CacheKey to a string representation +func (npc *NextPageCache) keyToString(key CacheKey) string { + return fmt.Sprintf("%s|%d|%t|%s|%s", key.Query, key.Page, key.Safe, key.Lang, key.Type) +} + +var ( + nextPageCache = NewNextPageCache(6 * time.Hour) // Cache with 6-hour expiration +) + +// PerformDeviantArtImageSearch performs a search on DeviantArt and returns a list of image results +func PerformDeviantArtImageSearch(query, safe, lang string, page int) ([]ImageSearchResult, time.Duration, error) { + startTime := time.Now() + + cacheKey := CacheKey{ + Query: query, + Page: page, + Safe: safe == "active", + Lang: lang, + Type: "deviantart", + } + + // Check if the next page link is cached + var searchURL string + if page > 1 { + if nextPageLink, found := nextPageCache.Get(cacheKey); found { + searchURL = nextPageLink + } else { + return nil, 0, fmt.Errorf("next page link not found in cache") + } + } else { + searchURL = buildDeviantArtSearchURL(query, page) + } + + // Get the User-Agent string + DeviantArtImageUserAgent, err := GetUserAgent("Image-Search-DeviantArt") + if err != nil { + return nil, 0, err + } + + // Make the HTTP request with User-Agent header + client := &http.Client{} + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, 0, fmt.Errorf("creating request: %v", err) + } + req.Header.Set("User-Agent", DeviantArtImageUserAgent) + + resp, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("making request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Parse the HTML document + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, 0, fmt.Errorf("loading HTML document: %v", err) + } + + // Channel to receive valid image results + resultsChan := make(chan ImageSearchResult) + var wg sync.WaitGroup + + // Extract data using goquery + doc.Find("div._2pZkk div div a").Each(func(i int, s *goquery.Selection) { + // Skip images that are blurred (premium content) + premiumText := s.Find("../div/div/div").Text() + if strings.Contains(premiumText, "Watch the artist to view this deviation") { + return + } + + // Extract image source, fallback on data-src if necessary + imgSrc, exists := s.Find("div img").Attr("srcset") + if !exists { + imgSrc, exists = s.Find("div img").Attr("data-src") + } + if !exists || imgSrc == "" { + return + } + imgSrc = strings.Split(imgSrc, " ")[0] + parsedURL, err := url.Parse(imgSrc) + if err == nil { + parts := strings.Split(parsedURL.Path, "/v1") + parsedURL.Path = parts[0] + imgSrc = parsedURL.String() + } + + // Extract URL and title + resultURL := s.AttrOr("href", "") + title := s.AttrOr("aria-label", "") + + // Only proceed if title, URL, and img_src are not empty + if title != "" && resultURL != "" && imgSrc != "" { + wg.Add(1) + go func(imgSrc, resultURL, title string) { + defer wg.Done() + // Verify if the image URL is accessible + if isValidImageURL(imgSrc, DeviantArtImageUserAgent, resultURL) { + resultsChan <- ImageSearchResult{ + Title: strings.TrimSpace(title), + Media: imgSrc, + Width: 0, + Height: 0, + Source: resultURL, + ThumbProxy: imgSrc, + } + } + }(imgSrc, resultURL, title) + } + }) + + // Close the results channel when all goroutines are done + go func() { + wg.Wait() + close(resultsChan) + }() + + // Collect results from the channel + var results []ImageSearchResult + for result := range resultsChan { + results = append(results, result) + } + + // Cache the next page link, if any + nextPageLink := doc.Find("a._1OGeq").Last().AttrOr("href", "") + if nextPageLink != "" { + nextPageCache.Set(cacheKey, nextPageLink) + } + + duration := time.Since(startTime) + + // Check if the number of results is one or less + if len(results) == 0 { + return nil, duration, fmt.Errorf("no images found") + } + + return results, duration, nil +} + +// buildDeviantArtSearchURL builds the search URL for DeviantArt +func buildDeviantArtSearchURL(query string, page int) string { + baseURL := "https://www.deviantart.com/search" + params := url.Values{} + params.Add("q", query) + return baseURL + "?" + params.Encode() +} + +// isValidImageURL checks if the image URL is accessible with the provided User-Agent +func isValidImageURL(imgSrc, userAgent, referer string) bool { + client := &http.Client{} + req, err := http.NewRequest("HEAD", imgSrc, nil) + if err != nil { + return false + } + + // Set headers to mimic a regular browser request + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Referer", referer) + + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK +} + +// // Example usage: +// func main() { +// results, duration, err := PerformDeviantArtImageSearch("kittens", "false", "en", 1) +// if err != nil { +// fmt.Println("Error:", err) +// return +// } + +// fmt.Printf("Search took: %v\n", duration) +// fmt.Printf("Total results: %d\n", len(results)) +// for _, result := range results { +// fmt.Printf("Title: %s\nThumbnail: %s\nMedia: %s\nSource (Original Image URL): %s\n\n", +// result.Title, result.Thumbnail, result.Media, result.Source) +// } +// } diff --git a/images-quant.go b/images-quant.go index d9a9770..0dccdec 100644 --- a/images-quant.go +++ b/images-quant.go @@ -24,6 +24,64 @@ type QwantAPIResponse struct { } `json:"data"` } +func ConvertToQwantLocale(langCode string) string { + langMap := map[string]string{ + "en": "en_us", // English + "af": "en_us", // Afrikaans (no direct match, default to English) + "ar": "ar", // Arabic + "hy": "en_us", // Armenian (no direct match, default to English) + "be": "en_us", // Belarusian (no direct match, default to English) + "bg": "bg_bg", // Bulgarian + "ca": "ca_es", // Catalan + "zh-CN": "zh_cn", // Chinese Simplified + "zh-TW": "zh_hk", // Chinese Traditional + "hr": "en_us", // Croatian (no direct match, default to English) + "cs": "cs_cz", // Czech + "da": "da_dk", // Danish + "nl": "nl_nl", // Dutch + "eo": "en_us", // Esperanto (no direct match, default to English) + "et": "et_ee", // Estonian + "tl": "en_us", // Tagalog (no direct match, default to English) + "fi": "fi_fi", // Finnish + "fr": "fr_fr", // French + "de": "de_de", // German + "el": "el_gr", // Greek + "iw": "he_il", // Hebrew + "hi": "en_us", // Hindi (no direct match, default to English) + "hu": "hu_hu", // Hungarian + "is": "en_us", // Icelandic (no direct match, default to English) + "id": "en_us", // Indonesian (no direct match, default to English) + "it": "it_it", // Italian + "ja": "ja_jp", // Japanese + "ko": "ko_kr", // Korean + "lv": "en_us", // Latvian (no direct match, default to English) + "lt": "en_us", // Lithuanian (no direct match, default to English) + "no": "nb_no", // Norwegian + "fa": "en_us", // Persian (no direct match, default to English) + "pl": "pl_pl", // Polish + "pt": "pt_pt", // Portuguese + "ro": "ro_ro", // Romanian + "ru": "ru_ru", // Russian + "sr": "en_us", // Serbian (no direct match, default to English) + "sk": "en_us", // Slovak (no direct match, default to English) + "sl": "en_us", // Slovenian (no direct match, default to English) + "es": "es_es", // Spanish + "sw": "en_us", // Swahili (no direct match, default to English) + "sv": "sv_se", // Swedish + "th": "th_th", // Thai + "tr": "tr_tr", // Turkish + "uk": "en_us", // Ukrainian (no direct match, default to English) + "vi": "en_us", // Vietnamese (no direct match, default to English) + "": "en_us", // Default to English if no language is provided + } + + if qwantLocale, exists := langMap[langCode]; exists { + return qwantLocale + } + printWarn("Qwant locale code missing: %v, defaulting to: en_us", langCode) + return "en_us" // Default fallback +} + // PerformQwantImageSearch performs an image search on Qwant and returns the results. func PerformQwantImageSearch(query, safe, lang string, page int) ([]ImageSearchResult, time.Duration, error) { startTime := time.Now() // Start the timer @@ -36,13 +94,16 @@ func PerformQwantImageSearch(query, safe, lang string, page int) ([]ImageSearchR offset = (page - 1) * resultsPerPage } + // Ensure count + offset is within acceptable limits + if offset+resultsPerPage > 250 { + return nil, 0, fmt.Errorf("count + offset must be lower than 250 for quant") + } + if safe == "" { safe = "0" } - if lang == "" { - lang = "en_CA" - } + lang = ConvertToQwantLocale(lang) apiURL := fmt.Sprintf("https://api.qwant.com/v3/search/images?t=images&q=%s&count=%d&locale=%s&offset=%d&device=desktop&tgp=2&safesearch=%s", url.QueryEscape(query), @@ -58,7 +119,7 @@ func PerformQwantImageSearch(query, safe, lang string, page int) ([]ImageSearchR return nil, 0, fmt.Errorf("creating request: %v", err) } - ImageUserAgent, err := GetUserAgent("Image-Search") + ImageUserAgent, err := GetUserAgent("Image-Search-Quant") if err != nil { return nil, 0, err } @@ -87,7 +148,7 @@ func PerformQwantImageSearch(query, safe, lang string, page int) ([]ImageSearchR Title: item.Title, Media: item.Media, Source: item.Url, - ThumbProxy: "/img_proxy?url=" + url.QueryEscape(item.Media), + ThumbProxy: "/img_proxy?url=" + url.QueryEscape(item.Media), // New proxy not exactly working as intended Width: item.Width, Height: item.Height, }) diff --git a/images.go b/images.go index 86920e2..4c6b957 100755 --- a/images.go +++ b/images.go @@ -13,6 +13,7 @@ var imageSearchEngines []SearchEngine func init() { imageSearchEngines = []SearchEngine{ {Name: "Qwant", Func: wrapImageSearchFunc(PerformQwantImageSearch), Weight: 1}, + {Name: "DeviantArt", Func: wrapImageSearchFunc(PerformDeviantArtImageSearch), Weight: 2}, {Name: "Bing", Func: wrapImageSearchFunc(PerformBingImageSearch), Weight: 2}, // Bing sometimes returns with low amount of images, this leads to danamica page loading not working {Name: "Imgur", Func: wrapImageSearchFunc(PerformImgurImageSearch), Weight: 3}, } @@ -44,6 +45,7 @@ func handleImageSearch(w http.ResponseWriter, settings UserSettings, query strin CurrentLang string Theme string Safe string + IsThemeDark bool }{ Results: combinedResults, Query: query, @@ -56,6 +58,7 @@ func handleImageSearch(w http.ResponseWriter, settings UserSettings, query strin CurrentLang: settings.Language, Theme: settings.Theme, Safe: settings.SafeSearch, + IsThemeDark: settings.IsThemeDark, } err = tmpl.Execute(w, data) diff --git a/main.go b/main.go index d16485f..63b60ca 100755 --- a/main.go +++ b/main.go @@ -17,78 +17,92 @@ type LanguageOption struct { var settings UserSettings var languageOptions = []LanguageOption{ - {Code: "", Name: "Any Language"}, - {Code: "lang_en", Name: "English"}, - {Code: "lang_af", Name: "Afrikaans"}, - {Code: "lang_ar", Name: "العربية (Arabic)"}, - {Code: "lang_hy", Name: "Հայերեն (Armenian)"}, - {Code: "lang_be", Name: "Беларуская (Belarusian)"}, - {Code: "lang_bg", Name: "български (Bulgarian)"}, - {Code: "lang_ca", Name: "Català (Catalan)"}, - {Code: "lang_zh-CN", Name: "中文 (简体) (Chinese Simplified)"}, - {Code: "lang_zh-TW", Name: "中文 (繁體) (Chinese Traditional)"}, - {Code: "lang_hr", Name: "Hrvatski (Croatian)"}, - {Code: "lang_cs", Name: "Čeština (Czech)"}, - {Code: "lang_da", Name: "Dansk (Danish)"}, - {Code: "lang_nl", Name: "Nederlands (Dutch)"}, - {Code: "lang_eo", Name: "Esperanto"}, - {Code: "lang_et", Name: "Eesti (Estonian)"}, - {Code: "lang_tl", Name: "Filipino (Tagalog)"}, - {Code: "lang_fi", Name: "Suomi (Finnish)"}, - {Code: "lang_fr", Name: "Français (French)"}, - {Code: "lang_de", Name: "Deutsch (German)"}, - {Code: "lang_el", Name: "Ελληνικά (Greek)"}, - {Code: "lang_iw", Name: "עברית (Hebrew)"}, - {Code: "lang_hi", Name: "हिन्दी (Hindi)"}, - {Code: "lang_hu", Name: "magyar (Hungarian)"}, - {Code: "lang_is", Name: "íslenska (Icelandic)"}, - {Code: "lang_id", Name: "Bahasa Indonesia (Indonesian)"}, - {Code: "lang_it", Name: "italiano (Italian)"}, - {Code: "lang_ja", Name: "日本語 (Japanese)"}, - {Code: "lang_ko", Name: "한국어 (Korean)"}, - {Code: "lang_lv", Name: "latviešu (Latvian)"}, - {Code: "lang_lt", Name: "lietuvių (Lithuanian)"}, - {Code: "lang_no", Name: "norsk (Norwegian)"}, - {Code: "lang_fa", Name: "فارسی (Persian)"}, - {Code: "lang_pl", Name: "polski (Polish)"}, - {Code: "lang_pt", Name: "português (Portuguese)"}, - {Code: "lang_ro", Name: "română (Romanian)"}, - {Code: "lang_ru", Name: "русский (Russian)"}, - {Code: "lang_sr", Name: "српски (Serbian)"}, - {Code: "lang_sk", Name: "slovenčina (Slovak)"}, - {Code: "lang_sl", Name: "slovenščina (Slovenian)"}, - {Code: "lang_es", Name: "español (Spanish)"}, - {Code: "lang_sw", Name: "Kiswahili (Swahili)"}, - {Code: "lang_sv", Name: "svenska (Swedish)"}, - {Code: "lang_th", Name: "ไทย (Thai)"}, - {Code: "lang_tr", Name: "Türkçe (Turkish)"}, - {Code: "lang_uk", Name: "українська (Ukrainian)"}, - {Code: "lang_vi", Name: "Tiếng Việt (Vietnamese)"}, + {Code: "", Name: "Auto-detect"}, + {Code: "en", Name: "English"}, + {Code: "af", Name: "Afrikaans"}, + {Code: "ar", Name: "العربية (Arabic)"}, + {Code: "hy", Name: "Հայերեն (Armenian)"}, + {Code: "be", Name: "Беларуская (Belarusian)"}, + {Code: "bg", Name: "български (Bulgarian)"}, + {Code: "ca", Name: "Català (Catalan)"}, + {Code: "zh-CN", Name: "中文 (简体) (Chinese Simplified)"}, + {Code: "zh-TW", Name: "中文 (繁體) (Chinese Traditional)"}, + {Code: "hr", Name: "Hrvatski (Croatian)"}, + {Code: "cs", Name: "Čeština (Czech)"}, + {Code: "da", Name: "Dansk (Danish)"}, + {Code: "nl", Name: "Nederlands (Dutch)"}, + {Code: "eo", Name: "Esperanto"}, + {Code: "et", Name: "Eesti (Estonian)"}, + {Code: "tl", Name: "Filipino (Tagalog)"}, + {Code: "fi", Name: "Suomi (Finnish)"}, + {Code: "fr", Name: "Français (French)"}, + {Code: "de", Name: "Deutsch (German)"}, + {Code: "el", Name: "Ελληνικά (Greek)"}, + {Code: "iw", Name: "עברית (Hebrew)"}, + {Code: "hi", Name: "हिन्दी (Hindi)"}, + {Code: "hu", Name: "magyar (Hungarian)"}, + {Code: "is", Name: "íslenska (Icelandic)"}, + {Code: "id", Name: "Bahasa Indonesia (Indonesian)"}, + {Code: "it", Name: "italiano (Italian)"}, + {Code: "ja", Name: "日本語 (Japanese)"}, + {Code: "ko", Name: "한국어 (Korean)"}, + {Code: "lv", Name: "latviešu (Latvian)"}, + {Code: "lt", Name: "lietuvių (Lithuanian)"}, + {Code: "no", Name: "norsk (Norwegian)"}, + {Code: "fa", Name: "فارسی (Persian)"}, + {Code: "pl", Name: "polski (Polish)"}, + {Code: "pt", Name: "português (Portuguese)"}, + {Code: "ro", Name: "română (Romanian)"}, + {Code: "ru", Name: "русский (Russian)"}, + {Code: "sr", Name: "српски (Serbian)"}, + {Code: "sk", Name: "slovenčina (Slovak)"}, + {Code: "sl", Name: "slovenščina (Slovenian)"}, + {Code: "es", Name: "español (Spanish)"}, + {Code: "sw", Name: "Kiswahili (Swahili)"}, + {Code: "sv", Name: "svenska (Swedish)"}, + {Code: "th", Name: "ไทย (Thai)"}, + {Code: "tr", Name: "Türkçe (Turkish)"}, + {Code: "uk", Name: "українська (Ukrainian)"}, + {Code: "vi", Name: "Tiếng Việt (Vietnamese)"}, } func handleSearch(w http.ResponseWriter, r *http.Request) { query, safe, lang, searchType, page := parseSearchParams(r) // Load user settings - settings = loadUserSettings(r) + settings = loadUserSettings(w, r) - // Update the theme, safe search, and language based on query parameters or use existing settings + // Update theme if provided, or use existing settings theme := r.URL.Query().Get("theme") if theme != "" { settings.Theme = theme - saveUserSettings(w, settings) + saveUserSettings(w, settings) // Save if theme is updated } else if settings.Theme == "" { settings.Theme = "dark" // Default theme } + // Update safe search if provided, or use existing settings if safe != "" && safe != settings.SafeSearch { settings.SafeSearch = safe - saveUserSettings(w, settings) + saveUserSettings(w, settings) // Save if safe search is updated } + // Update language if provided, or use existing settings if lang != "" && lang != settings.Language { settings.Language = lang - saveUserSettings(w, settings) + saveUserSettings(w, settings) // Save if language is updated + } else if settings.Language == "" { + // If no language set, auto-detect from browser or default to "en" + settings.Language = normalizeLangCode(r.Header.Get("Accept-Language")) + saveUserSettings(w, settings) // Save if language is auto-detected + } + + // This will do for now (to handle Dark Reader addon) + switch settings.Theme { + case "dark", "black", "night", "latte": + settings.IsThemeDark = true + default: + settings.IsThemeDark = false } // Check if there is a search query @@ -99,11 +113,13 @@ func handleSearch(w http.ResponseWriter, r *http.Request) { CurrentLang string Theme string Safe string + IsThemeDark bool }{ LanguageOptions: languageOptions, CurrentLang: settings.Language, Theme: settings.Theme, Safe: settings.SafeSearch, + IsThemeDark: settings.IsThemeDark, } tmpl := template.Must(template.ParseFiles("templates/search.html")) @@ -166,6 +182,7 @@ func runServer() { http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) http.HandleFunc("/", handleSearch) http.HandleFunc("/search", handleSearch) + http.HandleFunc("/suggestions", handleSuggestions) http.HandleFunc("/img_proxy", handleImageProxy) http.HandleFunc("/node", handleNodeRequest) http.HandleFunc("/settings", handleSettings) diff --git a/open-search.go b/open-search.go index 280fe3d..8ae97e1 100644 --- a/open-search.go +++ b/open-search.go @@ -12,7 +12,7 @@ type OpenSearchDescription struct { ShortName string `xml:"ShortName"` Description string `xml:"Description"` Tags string `xml:"Tags"` - URL URL `xml:"Url"` + URLs []URL `xml:"Url"` } type URL struct { @@ -25,12 +25,18 @@ func generateOpenSearchXML(config Config) { opensearch := OpenSearchDescription{ Xmlns: "http://a9.com/-/spec/opensearch/1.1/", - ShortName: "Search Engine", - Description: "Search engine", - Tags: "search, engine", - URL: URL{ - Type: "text/html", - Template: fmt.Sprintf("%s/search?q={searchTerms}", baseURL), + ShortName: "Warp", + Description: "Warp search engine", + Tags: "search, engine, warp", + URLs: []URL{ + { + Type: "text/html", + Template: fmt.Sprintf("%s/search?q={searchTerms}", baseURL), + }, + { + Type: "application/x-suggestions+json", + Template: fmt.Sprintf("%s/suggestions?q={searchTerms}", baseURL), + }, }, } diff --git a/static/css/style.css b/static/css/style.css index a132aaa..782163e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1732,9 +1732,9 @@ body, h1, p, a, input, button { display: none; } - .search-menu { + /* .search-menu { display: none; - } + } */ .sub-search-button-wrapper { margin: 0; @@ -1838,9 +1838,9 @@ body, h1, p, a, input, button { top: 5px; } - .settings-search-div { + /* .settings-search-div { display: none; - } + } */ .search-container h1 { font-size: 55px; @@ -1861,9 +1861,7 @@ body, h1, p, a, input, button { } .search-button-wrapper button { - display: table-row; - margin: 30px 0px 0px 0px; - width: 80%; + display: none; } #clearSearch { diff --git a/static/js/autocomplete.js b/static/js/autocomplete.js new file mode 100644 index 0000000..3e1cb58 --- /dev/null +++ b/static/js/autocomplete.js @@ -0,0 +1,188 @@ +/** + * @source: ./script.js (originally from araa-search on Github) + * + * @licstart The following is the entire license notice for the + * JavaScript code in this page. + * + * Copyright (C) 2023 Extravi + * + * The JavaScript code in this page is free software: you can + * redistribute it and/or modify it under the terms of the GNU Affero + * General Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + * + * The code is distributed WITHOUT ANY WARRANTY; without even the + * implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * As additional permission under GNU Affero General Public License + * section 7, you may distribute non-source (e.g., minimized or compacted) + * forms of that code without the copy of the GNU Affero General Public + * License normally required by section 4, provided you include this + * license notice and a URL through which recipients can access the + * Corresponding Source. + * + * @licend The above is the entire license notice + * for the JavaScript code in this page. + */ + +// Removes the 'Apply Settings' button for Javascript users, +// since changing any of the elements causes the settings to apply +// automatically. +let resultsSave = document.querySelector(".results-save"); +if (resultsSave != null) { + resultsSave.style.display = "none"; +} + +const searchInput = document.getElementById('search-input'); +const searchWrapper = document.querySelectorAll('.wrapper, .wrapper-results')[0]; +const resultsWrapper = document.querySelector('.autocomplete'); +// const clearSearch = document.querySelector("#clearSearch"); + +async function getSuggestions(query) { + try { + const params = new URLSearchParams({ "q": query }).toString(); + const response = await fetch(`/suggestions?${params}`); + const data = await response.json(); + return data[1]; // Return only the array of suggestion strings + } catch (error) { + console.error(error); + } + } + +let currentIndex = -1; // Keep track of the currently selected suggestion + +let results = []; +searchInput.addEventListener('input', async () => { + let input = searchInput.value; + if (input.length) { + results = await getSuggestions(input); + } + renderResults(results); + currentIndex = -1; // Reset index when we return new results +}); + +searchInput.addEventListener("focus", async () => { + let input = searchInput.value; + if (results.length === 0 && input.length != 0) { + results = await getSuggestions(input); + } + renderResults(results); +}) + +// clearSearch.style.visibility = "visible"; // Only show the clear search button for JS users. +// clearSearch.addEventListener("click", () => { +// searchInput.value = ""; +// searchInput.focus(); +// }) + +searchInput.addEventListener('keydown', (event) => { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Tab') { + event.preventDefault(); // Prevent the default behavior, such as moving the cursor + + // Find the currently selected suggestion element + const selectedSuggestion = resultsWrapper.querySelector('.selected'); + if (selectedSuggestion) { + selectedSuggestion.classList.remove('selected'); // Deselect the currently selected suggestion + } + + // Increment current index when ArrowUp is pressed otherwise hen Tab OR ArrowDown decrement + if (event.key === 'ArrowUp') { + currentIndex--; + } else { + currentIndex++; + } + + // Wrap around the index if it goes out of bounds + if (currentIndex < 0) { + currentIndex = resultsWrapper.querySelectorAll('li').length - 1; + } else if (currentIndex >= resultsWrapper.querySelectorAll('li').length) { + currentIndex = 0; + } + + // Select the new suggestion + resultsWrapper.querySelectorAll('li')[currentIndex].classList.add('selected'); + // Update the value of the search input + searchInput.value = resultsWrapper.querySelectorAll('li')[currentIndex].textContent; + } +}); + +// Default to the currently selected type or fallback to 'text' +let selectedType = document.querySelector('.search-active')?.value || 'text'; + +// Function to render results +function renderResults(results) { + if (!results || !results.length || !searchInput.value) { + searchWrapper.classList.remove('show'); + return; + } + + let content = ''; + results.forEach((item) => { + content += `
  • ${item}
  • `; + }); + + if (searchInput.value) { + searchWrapper.classList.add('show'); + } + resultsWrapper.innerHTML = ``; +} + +// Function to handle search input +searchInput.addEventListener('input', async () => { + let input = searchInput.value; + if (input.length) { + const results = await getSuggestions(input); + renderResults(results); + } +}); + +// Handle click events on the type buttons +const typeButtons = document.querySelectorAll('[name="t"]'); +typeButtons.forEach(button => { + button.addEventListener('click', function() { + selectedType = this.value; + typeButtons.forEach(btn => btn.classList.remove('search-active')); + this.classList.add('search-active'); + }); +}); + +// Handle clicks on search results +resultsWrapper.addEventListener('click', (event) => { + if (event.target.tagName === 'LI') { + const query = event.target.textContent; + window.location.href = `/search?q=${encodeURIComponent(query)}&t=${encodeURIComponent(selectedType)}`; + } +}); + +document.addEventListener("keypress", (event) => { + if (document.activeElement == searchInput) { + // Allow the '/' character to be pressed when searchInput is active + } else if (document.querySelector(".calc") != null) { + // Do nothing if the calculator is available, so the division keybinding + // will still work + } + else if (event.key == "/") { + event.preventDefault(); + searchInput.focus(); + searchInput.selectionStart = searchInput.selectionEnd = searchInput.value.length; + } +}) + +// Add event listener to hide autocomplete suggestions when clicking outside of search-input or wrapper +document.addEventListener('click', (event) => { + // Check if the target of the event is the search-input or any of its ancestors + if (!searchInput.contains(event.target) && !searchWrapper.contains(event.target)) { + // Remove the show class from the search wrapper + searchWrapper.classList.remove('show'); + } +}); + +// Update visual feedback for selected type on page load +document.addEventListener("DOMContentLoaded", () => { + const activeButton = document.querySelector(`[name="t"][value="${selectedType}"]`); + if (activeButton) { + typeButtons.forEach(btn => btn.classList.remove('search-active')); + activeButton.classList.add('search-active'); + } +}); diff --git a/static/js/dynamicscrolling.js b/static/js/dynamicscrolling.js new file mode 100644 index 0000000..a3975eb --- /dev/null +++ b/static/js/dynamicscrolling.js @@ -0,0 +1,96 @@ +document.addEventListener("DOMContentLoaded", function() { + const templateData = document.getElementById('template-data'); + let page = parseInt(templateData.getAttribute('data-page')) || 1; + const query = templateData.getAttribute('data-query') || ''; + let searchType = templateData.getAttribute('data-type') || 'text'; // Default to 'text' if not provided + let loading = false; + let hasMoreResults = true; + const loadingIndicator = document.getElementById('message-bottom-left'); + let loadingTimeout; + + function loadResults(newPage) { + if (loading || !hasMoreResults) return; + loading = true; + + // Show loading indicator if taking more than 100ms + loadingTimeout = setTimeout(() => { + loadingIndicator.style.display = 'flex'; + }, 100); + + fetch(`/search?q=${encodeURIComponent(query)}&t=${encodeURIComponent(searchType)}&p=${newPage}`) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.text(); + }) + .then(data => { + clearTimeout(loadingTimeout); + loadingIndicator.style.display = 'none'; + const parser = new DOMParser(); + const doc = parser.parseFromString(data, 'text/html'); + const newResults = doc.getElementById('results').innerHTML; + const noResultsMessage = `No results found for '${query}'. Try different keywords.`; + const endOfResultsMessage = "Looks like this is the end of results."; + const serverError = "Internal Server Error"; + + if (newResults.includes(noResultsMessage) || newResults.includes(endOfResultsMessage) || newResults.includes(serverError)) { + document.getElementById('results').innerHTML += newResults; + hasMoreResults = false; + } else { + document.getElementById('results').innerHTML += newResults; + page = newPage; + // Automatically load more results if content height is less than window height + checkIfMoreResultsNeeded(); + } + loading = false; + }) + .catch(error => { + clearTimeout(loadingTimeout); + loadingIndicator.style.display = 'none'; + console.error('Error loading results:', error); + hasMoreResults = false; + loading = false; + }); + } + + function checkIfMoreResultsNeeded() { + if (document.body.scrollHeight <= window.innerHeight && hasMoreResults) { + loadResults(page + 1); + } + } + + window.addEventListener('scroll', () => { + if (window.innerHeight + window.scrollY >= document.body.offsetHeight) { + loadResults(page + 1); + } + }); + + // Ensure the correct search button has the active class at load + const buttons = document.querySelectorAll('.search-container-results-btn button'); + if (buttons.length === 0) { + console.error("No search buttons found"); + } else { + buttons.forEach(btn => { + btn.addEventListener('click', function() { + const activeElement = document.querySelector('.search-container-results-btn .search-active'); + if (activeElement) { + activeElement.classList.remove('search-active'); + } + this.classList.add('search-active'); + // Update search type when button is clicked + searchType = this.getAttribute('value'); + }); + }); + + // Ensure one button is active on page load + const initialActiveElement = document.querySelector('.search-container-results-btn .search-active'); + if (!initialActiveElement) { + buttons[0].classList.add('search-active'); + searchType = buttons[0].getAttribute('value'); // Set default search type + } + } + + // Check if more results are needed right after the initial content is loaded + checkIfMoreResultsNeeded(); +}); diff --git a/suggestions.go b/suggestions.go new file mode 100644 index 0000000..73e9b02 --- /dev/null +++ b/suggestions.go @@ -0,0 +1,160 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +func handleSuggestions(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `["",[]]`) + return + } + + // Define the fallback sequence with Google lower in the hierarchy + suggestionSources := []func(string) []string{ + fetchDuckDuckGoSuggestions, + fetchEdgeSuggestions, + fetchBraveSuggestions, + fetchEcosiaSuggestions, + fetchQwantSuggestions, + fetchStartpageSuggestions, + // fetchGoogleSuggestions, // I advise against it, but you can use it if you want to + } + + var suggestions []string + for _, fetchFunc := range suggestionSources { + suggestions = fetchFunc(query) + if len(suggestions) > 0 { + printDebug("Suggestions found using %T", fetchFunc) + break + } else { + printWarn("%T did not return any suggestions or failed.", fetchFunc) + } + } + + if len(suggestions) == 0 { + printErr("All suggestion services failed. Returning empty response.") + } + + // Return the final suggestions as JSON + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `["",%s]`, toJSONStringArray(suggestions)) +} + +func fetchGoogleSuggestions(query string) []string { + encodedQuery := url.QueryEscape(query) + url := fmt.Sprintf("http://suggestqueries.google.com/complete/search?client=firefox&q=%s", encodedQuery) + printDebug("Fetching suggestions from Google: %s", url) + return fetchSuggestionsFromURL(url) +} + +func fetchDuckDuckGoSuggestions(query string) []string { + encodedQuery := url.QueryEscape(query) + url := fmt.Sprintf("https://duckduckgo.com/ac/?q=%s&type=list", encodedQuery) + printDebug("Fetching suggestions from DuckDuckGo: %s", url) + return fetchSuggestionsFromURL(url) +} + +func fetchEdgeSuggestions(query string) []string { + encodedQuery := url.QueryEscape(query) + url := fmt.Sprintf("https://api.bing.com/osjson.aspx?query=%s", encodedQuery) + printDebug("Fetching suggestions from Edge (Bing): %s", url) + return fetchSuggestionsFromURL(url) +} + +func fetchBraveSuggestions(query string) []string { + encodedQuery := url.QueryEscape(query) + url := fmt.Sprintf("https://search.brave.com/api/suggest?q=%s", encodedQuery) + printDebug("Fetching suggestions from Brave: %s", url) + return fetchSuggestionsFromURL(url) +} + +func fetchEcosiaSuggestions(query string) []string { + encodedQuery := url.QueryEscape(query) + url := fmt.Sprintf("https://ac.ecosia.org/?q=%s&type=list", encodedQuery) + printDebug("Fetching suggestions from Ecosia: %s", url) + return fetchSuggestionsFromURL(url) +} + +func fetchQwantSuggestions(query string) []string { + encodedQuery := url.QueryEscape(query) + url := fmt.Sprintf("https://api.qwant.com/v3/suggest?q=%s", encodedQuery) + printDebug("Fetching suggestions from Qwant: %s", url) + return fetchSuggestionsFromURL(url) +} + +func fetchStartpageSuggestions(query string) []string { + encodedQuery := url.QueryEscape(query) + url := fmt.Sprintf("https://startpage.com/suggestions?q=%s", encodedQuery) + printDebug("Fetching suggestions from Startpage: %s", url) + return fetchSuggestionsFromURL(url) +} + +func fetchSuggestionsFromURL(url string) []string { + resp, err := http.Get(url) + if err != nil { + printWarn("Error fetching suggestions from %s: %v", url, err) + return []string{} + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + printWarn("Error reading response body from %s: %v", url, err) + return []string{} + } + + // Log the Content-Type for debugging + contentType := resp.Header.Get("Content-Type") + printDebug("Response Content-Type from %s: %s", url, contentType) + + // Check if the body is non-empty + if len(body) == 0 { + printWarn("Received empty response body from %s", url) + return []string{} + } + + // Attempt to parse the response as JSON regardless of Content-Type + var parsedResponse []interface{} + if err := json.Unmarshal(body, &parsedResponse); err != nil { + printErr("Error parsing JSON from %s: %v", url, err) + printDebug("Response body: %s", string(body)) + return []string{} + } + + // Ensure the response structure is as expected + if len(parsedResponse) < 2 { + printWarn("Unexpected response format from %v: %v", url, string(body)) + return []string{} + } + + suggestions := []string{} + if items, ok := parsedResponse[1].([]interface{}); ok { + for _, item := range items { + if suggestion, ok := item.(string); ok { + suggestions = append(suggestions, suggestion) + } + } + } else { + printErr("Unexpected suggestions format in response from: %v", url) + } + + return suggestions +} + +func toJSONStringArray(strings []string) string { + result := "" + for i, str := range strings { + result += fmt.Sprintf(`"%s"`, str) + if i < len(strings)-1 { + result += "," + } + } + return "[" + result + "]" +} diff --git a/templates/files.html b/templates/files.html index af39437..e119424 100755 --- a/templates/files.html +++ b/templates/files.html @@ -3,6 +3,9 @@ + {{ if .IsThemeDark }} + + {{ end }} {{.Query}} - Ocásek @@ -14,6 +17,10 @@
    +
    + +
    @@ -105,6 +112,7 @@ Try rephrasing your search term and/or recorrect any spelling mistakes.
    {{ end }} + + + - diff --git a/templates/map.html b/templates/map.html index dedcb37..5ce8c62 100644 --- a/templates/map.html +++ b/templates/map.html @@ -3,6 +3,9 @@ + {{ if .IsThemeDark }} + + {{ end }} {{ .Query }} - Ocásek @@ -29,6 +32,10 @@
    +
    + +
    diff --git a/templates/search.html b/templates/search.html index 1300d4e..61fbb0d 100755 --- a/templates/search.html +++ b/templates/search.html @@ -3,12 +3,16 @@ + {{ if .IsThemeDark }} + + {{ end }} Search with Ocásek + + - diff --git a/templates/videos.html b/templates/videos.html index d03b984..3be0bc0 100644 --- a/templates/videos.html +++ b/templates/videos.html @@ -3,6 +3,9 @@ + {{ if .IsThemeDark }} + + {{ end }} {{.Query}} - Ocásek @@ -14,6 +17,10 @@
    +
    +
      +
    +
    @@ -82,6 +89,7 @@ {{ end }}
    +