diff --git a/main.go b/main.go index 6661b19..9729f00 100644 --- a/main.go +++ b/main.go @@ -120,6 +120,8 @@ func main() { renderTemplate(w, "download-linux.html", nil) }) + http.HandleFunc("/suggestions", handleSuggestions) + // Route for generating the RSS feed for all blogs http.HandleFunc("/rss", func(w http.ResponseWriter, r *http.Request) { siteURL := fmt.Sprintf("http://%s", r.Host) // or you can use a fixed base URL diff --git a/printing.go b/printing.go new file mode 100644 index 0000000..bed7aed --- /dev/null +++ b/printing.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "time" +) + +var LogLevel = 1 + +// printDebug logs debug-level messages when LogLevel is set to 4. +func printDebug(format string, args ...interface{}) { + if LogLevel >= 4 { + logMessage("DEBUG", format, args...) + } +} + +// printInfo logs info-level messages when LogLevel is set to 3 or higher. +func printInfo(format string, args ...interface{}) { + if LogLevel >= 3 { + logMessage("INFO", format, args...) + } +} + +// printWarn logs warning-level messages when LogLevel is set to 2 or higher. +func printWarn(format string, args ...interface{}) { + if LogLevel >= 2 { + logMessage("WARN", format, args...) + } +} + +// printErr logs error-level messages regardless of LogLevel. +func printErr(format string, args ...interface{}) { + if LogLevel >= 1 { + logMessage("ERROR", format, args...) + } +} + +// printMessage logs messages without a specific log level (e.g., general output). +func printMessage(format string, args ...interface{}) { + logMessage("", format, args...) +} + +// logMessage handles the actual logging logic without using the default logger's timestamp. +func logMessage(level string, format string, args ...interface{}) { + timestamp := time.Now().Format("2006-01-02 15:04:05") + message := fmt.Sprintf(format, args...) + if level != "" { + fmt.Printf("[%s %s] %s\n", timestamp, level, message) + } else { + fmt.Printf("[%s] %s\n", timestamp, message) + } +} diff --git a/run.sh b/run.sh index 42630ba..e510451 100755 --- a/run.sh +++ b/run.sh @@ -13,4 +13,4 @@ while [[ "$#" -gt 0 ]]; do done # Run the Go application with the parsed flags -go run discord.go rss.go telegram.go save.go main.go -p=$PORT +go run printing.go suggestions.go discord.go rss.go telegram.go save.go main.go -p=$PORT diff --git a/static/css/extras.css b/static/css/extras.css index 1c5b732..87254b3 100644 --- a/static/css/extras.css +++ b/static/css/extras.css @@ -23,4 +23,10 @@ width: 48px; height: 48px; overflow: visible; -} \ No newline at end of file +} + +.reset-styles * { + all: unset; + display: revert; + } + \ No newline at end of file diff --git a/static/css/search.css b/static/css/search.css new file mode 100644 index 0000000..77ad65f --- /dev/null +++ b/static/css/search.css @@ -0,0 +1,237 @@ +/* inter-300 - latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + src: local(''), + url('/static/fonts/inter-v12-latin-300.woff2') format('woff2'), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('/static/fonts/inter-v12-latin-300.woff') format('woff'); + /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + +/* inter-regular - latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + src: local(''), + url('/static/fonts/inter-v12-latin-regular.woff2') format('woff2'), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('/static/fonts/inter-v12-latin-regular.woff') format('woff'); + /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + +/* inter-700 - latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + src: local(''), + url('/static/fonts/inter-v12-latin-700.woff2') format('woff2'), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('/static/fonts/inter-v12-latin-700.woff') format('woff'); + /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + +/* material-icons-round-regular - latin */ +@font-face { + font-display: swap; + /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Material Icons Round'; + font-style: normal; + font-weight: 400; + src: url('/static/webfonts/material-icons-round-v108-latin-regular.woff2') format('woff2'); + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +#search-wrapper-ico, +#clearSearch { + background: none; + border: none; + color: var(--fg); + position: absolute; + top: 7px; + right: 10px; + cursor: pointer; +} + +#search-wrapper-ico:hover, +#clearSearch:hover { + transition: all .3s ease; + color: var(--blue); +} + +#sub-search-wrapper-ico { + background: none; + border: none; + cursor: pointer; + font-size: 17px; + /* will be set to 17px if icon pack can be loaded. */ + padding-right: 0px; + margin-right: 0px; +} + +.search-container { + text-align: center; + margin-top: 10%; +} + +.search-container h1 { + font-size: 70px; + color: var(--font-fg); + font-family: 'Inter'; +} + +.search-container input { + width: 90%; + color: var(--font-fg); + background-color: var(--search-bg-input); + font-size: inherit; + font-family: sans-serif; + border: none; + margin-right: 100%; + margin-left: 3px; +} + +.search-container-results-btn { + display: flex; + color: var(--blue); +} + +.search-container-results-btn:hover button { + transition: all 0.3s ease; + color: var(--blue); +} + +#search-wrapper-ico, +#clearSearch { + background: none; + border: none; + color: var(--fg); + position: absolute; + top: 7px; + right: 10px; + cursor: pointer; +} + +.search-button-wrapper button:hover { + border: 1px solid #5f6368; + cursor: pointer; +} + + +.wrapper { + margin: 0 auto; + background: var(--search-bg-input); + border-radius: 22px; + position: absolute; + width: 520px; + overflow: hidden; + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + z-index: 2; + border: 1px solid var(--search-bg-input-border); +} + +.wrapper input { + padding: 10px; +} + +.wrapper-results { + margin: 0 auto; + background: var(--search-bg-input); + border-radius: 22px; + position: absolute; + width: 628px; + overflow: hidden; + margin-top: 0px; + top: 18px; + left: 170px; + z-index: 2; + border: 1px solid var(--search-bg-input-border); +} + +.wrapper-results:hover, +.wrapper-results:focus-within, +.wrapper:hover, +.wrapper:focus-within { + box-shadow: 0px 6px 12px rgba(0, 0, 0, 0.24); + transition: all 0.3s cubic-bezier(.25, .8, .25, 1); +} + + +.autocomplete { + padding: 0px; +} + +.autocomplete ul { + margin: 0; + padding: 0; +} + +.autocomplete ul li { + list-style: none; + opacity: 0; + display: none; + padding: 8px 12px; +} + +.show .autocomplete ul li { + opacity: 1; + display: block; + text-align: left; +} + +.show .autocomplete { + padding-top: 10px; + padding-bottom: 10px; + color: var(--font-fg); +} + +.autocomplete ul li:hover { + cursor: pointer; + background: var(--search-select); +} + + +@media only screen and (max-width: 750px) { + + .wrapper { + width: 86%; + position: absolute; + float: none; + margin-top: 0px; + margin-bottom: 0px; + margin-left: auto; + margin-right: auto; + display: block; + margin-top: 0px; + top: 110px; + left: 4px; + } + + .wrapper input { + padding: 10px; + max-width: 92%; + } + + .results-search-container { + margin-left: auto; + margin-right: auto; + text-align: center; + } + + .results-search-container input { + width: 84%; + margin-right: 100%; + margin-left: 3px; + } + + #search-wrapper-ico { + top: 5px; + } + +} \ No newline at end of file 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/static/webfonts/material-icons-round-v108-latin-regular.woff2 b/static/webfonts/material-icons-round-v108-latin-regular.woff2 new file mode 100644 index 0000000..e9e305f Binary files /dev/null and b/static/webfonts/material-icons-round-v108-latin-regular.woff2 differ 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/index.html b/templates/index.html index 80fe4b1..3071985 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,6 +21,7 @@ + @@ -164,6 +165,28 @@ + + +
    @@ -220,6 +243,7 @@ +