From 980de2b59b4163fb65ddb38d0194b24dc9d7345b Mon Sep 17 00:00:00 2001 From: partisan Date: Wed, 21 Aug 2024 22:36:45 +0200 Subject: [PATCH 01/11] added search suggestions --- main.go | 1 + static/js/autocomplete.js | 315 ++++++++++++++++++++++++++++++++++++++ suggestions.go | 161 +++++++++++++++++++ templates/search.html | 5 + 4 files changed, 482 insertions(+) create mode 100644 static/js/autocomplete.js create mode 100644 suggestions.go diff --git a/main.go b/main.go index d16485f..85c10c8 100755 --- a/main.go +++ b/main.go @@ -166,6 +166,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/static/js/autocomplete.js b/static/js/autocomplete.js new file mode 100644 index 0000000..c9049c5 --- /dev/null +++ b/static/js/autocomplete.js @@ -0,0 +1,315 @@ +/** + * @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.preventDefault(); // Prevent the cursor from moving in the search input + + // Find the currently selected suggestion element + const selectedSuggestion = resultsWrapper.querySelector('.selected'); + if (selectedSuggestion) { + selectedSuggestion.classList.remove('selected'); // Deselect the currently selected suggestion + } + + // Increment or decrement the current index based on the arrow key pressed + 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; + } +}); + +function renderResults(results) { + if (!results || !results.length || !searchInput.value) { + return searchWrapper.classList.remove('show'); + } + + let content = ''; + results.forEach((item) => { + content += `
  • ${item}
  • `; + }); + + // Only show the autocomplete suggestions if the search input has a non-empty value + if (searchInput.value) { + searchWrapper.classList.add('show'); + } + resultsWrapper.innerHTML = ``; +} + +resultsWrapper.addEventListener('click', (event) => { + if (event.target.tagName === 'LI') { + // Set the value of the search input to the clicked suggestion + searchInput.value = event.target.textContent; + // Reset the current index + currentIndex = -1; + // Submit the form + searchWrapper.querySelector('input[type="submit"]').click(); + // Remove the show class from the search wrapper + searchWrapper.classList.remove('show'); + } +}); + + +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'); + } +}); + +// Load material icons. If the file cannot be loaded, +// skip them and put a warning in the console. +const font = new FontFace('Material Icons Round', 'url("/fonts/material-icons-round-v108-latin-regular.woff2") format("woff2")'); +font.load().then(() => { + const icons = document.getElementsByClassName('material-icons-round'); + + // Display all icons. + for (let icon of icons) { + icon.style.visibility = 'visible'; + } + + // Ensure icons for the different types of searches are sized correctly. + document.querySelectorAll('#sub-search-wrapper-ico').forEach((el) => { + el.style.fontSize = '17px'; + }); +}).catch(() => { + console.warn('Failed to load Material Icons Round. Hiding any icons using said pack.'); +}); + +// load image after server side processing +window.addEventListener('DOMContentLoaded', function () { + var knoTitleElement = document.getElementById('kno_title'); + var kno_title = knoTitleElement.dataset.knoTitle; + fetch(kno_title) + .then(response => response.json()) + .then(data => { + const pageId = Object.keys(data.query.pages)[0]; + const thumbnailSource = data.query.pages[pageId].thumbnail.source; + const url = "/img_proxy?url=" + thumbnailSource; + + // update the img tag with url and add kno_wiki_show + var imgElement = document.querySelector('.kno_wiki'); + imgElement.src = url; + imgElement.classList.add('kno_wiki_show'); + + console.log(url); + }) + .catch(error => { + console.log('Error fetching data:', error); + }); +}); + +const urlParams = new URLSearchParams(window.location.search); + +if (document.querySelectorAll(".search-active")[1].getAttribute("value") === "image") { + + // image viewer for image search + const closeButton = document.querySelector('.image-close'); + const imageView = document.querySelector('.image_view'); + const images = document.querySelector('.images'); + const viewImageImg = document.querySelector('.view-image-img'); + const imageSource = document.querySelector('.image-source'); + const imageFull = document.querySelector(".full-size"); + const imageProxy = document.querySelector('.proxy-size'); + const imageViewerLink = document.querySelector('.image-viewer-link'); + const imageSize = document.querySelector('.image-size'); + const fullImageSize = document.querySelector(".full-image-size"); + const imageAlt = document.querySelector('.image-alt'); + const openImageViewer = document.querySelectorAll('.open-image-viewer'); + const imageBefore = document.querySelector('.image-before'); + const imageNext = document.querySelector('.image-next'); + let currentImageIndex = 0; + + closeButton.addEventListener('click', function () { + imageView.classList.remove('image_show'); + imageView.classList.add('image_hide'); + for (const image of document.querySelectorAll(".image_selected")) { + image.classList = ['image']; + } + images.classList.add('images_viewer_hidden'); + }); + + openImageViewer.forEach((image, index) => { + image.addEventListener('click', function (event) { + event.preventDefault(); + currentImageIndex = index; + showImage(); + }); + }); + + document.addEventListener('keydown', function (event) { + if (searchInput == document.activeElement) + return; + if (event.key === 'ArrowLeft') { + currentImageIndex = (currentImageIndex - 1 + openImageViewer.length) % openImageViewer.length; + showImage(); + } + else if (event.key === 'ArrowRight') { + currentImageIndex = (currentImageIndex + 1) % openImageViewer.length; + showImage(); + } + }); + + imageBefore.addEventListener('click', function () { + currentImageIndex = (currentImageIndex - 1 + openImageViewer.length) % openImageViewer.length; + showImage(); + }); + + imageNext.addEventListener('click', function () { + currentImageIndex = (currentImageIndex + 1) % openImageViewer.length; + showImage(); + }); + + function showImage() { + for (const image of document.querySelectorAll(".image_selected")) { + image.classList = ['image']; + } + const current_image = document.querySelectorAll(".image")[currentImageIndex]; + current_image.classList.add("image_selected"); + var rect = current_image.getBoundingClientRect(); + if (!(rect.top >= 0 && rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth))) { + current_image.scrollIntoView(false); + } + + const src = openImageViewer[currentImageIndex].getAttribute('src'); + const alt = openImageViewer[currentImageIndex].getAttribute('alt'); + const data = openImageViewer[currentImageIndex].getAttribute('data'); + const clickableLink = openImageViewer[currentImageIndex].closest('.clickable'); + const href = clickableLink.getAttribute('href'); + viewImageImg.src = src; + imageProxy.href = src; + imageFull.href = data; + imageSource.href = href; + imageSource.textContent = href; + imageViewerLink.href = href; + images.classList.remove('images_viewer_hidden'); + imageView.classList.remove('image_hide'); + imageView.classList.add('image_show'); + imageAlt.textContent = alt; + fullImageSize.textContent = document.querySelector(".image_selected .resolution").textContent; + + getImageSize(src).then(size => { + imageSize.textContent = size; + }); + } + + function getImageSize(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = function () { + const size = `${this.width} x ${this.height}`; + resolve(size); + }; + img.onerror = function () { + reject('Error loading image'); + }; + img.src = url; + }); + } +} \ No newline at end of file diff --git a/suggestions.go b/suggestions.go new file mode 100644 index 0000000..3990e11 --- /dev/null +++ b/suggestions.go @@ -0,0 +1,161 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "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 { + log.Printf("Suggestions found using %T\n", fetchFunc) + break + } else { + log.Printf("%T did not return any suggestions or failed.\n", fetchFunc) + } + } + + if len(suggestions) == 0 { + log.Println("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) + log.Println("Fetching suggestions from Google:", 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) + log.Println("Fetching suggestions from DuckDuckGo:", 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) + log.Println("Fetching suggestions from Edge (Bing):", 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) + log.Println("Fetching suggestions from Brave:", 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) + log.Println("Fetching suggestions from Ecosia:", 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) + log.Println("Fetching suggestions from Qwant:", url) + return fetchSuggestionsFromURL(url) +} + +func fetchStartpageSuggestions(query string) []string { + encodedQuery := url.QueryEscape(query) + url := fmt.Sprintf("https://startpage.com/suggestions?q=%s", encodedQuery) + log.Println("Fetching suggestions from Startpage:", url) + return fetchSuggestionsFromURL(url) +} + +func fetchSuggestionsFromURL(url string) []string { + resp, err := http.Get(url) + if err != nil { + log.Println("Error fetching suggestions from", url, ":", err) + return []string{} + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Println("Error reading response body from", url, ":", err) + return []string{} + } + + // Log the Content-Type for debugging + contentType := resp.Header.Get("Content-Type") + log.Println("Response Content-Type from", url, ":", contentType) + + // Check if the body is non-empty + if len(body) == 0 { + log.Println("Received empty response body from", 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 { + log.Println("Error parsing JSON from", url, ":", err) + log.Println("Response body:", string(body)) // Log the body for debugging + return []string{} + } + + // Ensure the response structure is as expected + if len(parsedResponse) < 2 { + log.Println("Unexpected response format from", 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 { + log.Println("Unexpected suggestions format in response from", 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/search.html b/templates/search.html index 1300d4e..8dd0cf9 100755 --- a/templates/search.html +++ b/templates/search.html @@ -9,6 +9,7 @@ + - diff --git a/templates/text.html b/templates/text.html index 809afeb..21fdcd8 100755 --- a/templates/text.html +++ b/templates/text.html @@ -96,66 +96,12 @@ {{ end }} +
    + - -- 2.40.1 From 4d6b99c67e8a92d04b8d0e0946523bcc94bb18b3 Mon Sep 17 00:00:00 2001 From: partisan Date: Sat, 31 Aug 2024 19:04:41 +0200 Subject: [PATCH 10/11] added dynamic loading for "forums" --- templates/forums.html | 43 ++++++++++++++++++++++++++----------------- templates/text.html | 14 ++++++++------ 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/templates/forums.html b/templates/forums.html index 04a3c2b..e833d4f 100755 --- a/templates/forums.html +++ b/templates/forums.html @@ -18,8 +18,7 @@
    -
      -
    +
      @@ -41,10 +40,10 @@
      -
      - - -
      +
      + + +
      @@ -63,9 +62,9 @@ {{end}} - + -
      +
      {{if .Results}} {{range .Results}}
      @@ -75,22 +74,32 @@

      {{end}} - {{else}} + {{else if .NoResults}}
      No results found for '{{ .Query }}'. Try different keywords.
      + {{else}} +
      Looks like this is the end of results.
      {{end}} +
      +
      + Searching for new results...
      -
      +
      - - {{ if .HasPrevPage }} - - {{ end }} - {{ if .HasNextPage }} - - {{ end }} + +
      + {{ if .HasPrevPage }} + + {{ end }} + {{ if .HasNextPage }} + + {{ end }} +
      + +
      +