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 = `
${content}
`;
+}
+
+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 @@
+