v0.2.0 #11

Merged
partisan merged 11 commits from work into main 2024-09-09 15:32:06 +00:00
4 changed files with 482 additions and 0 deletions
Showing only changes of commit 980de2b59b - Show all commits

View file

@ -166,6 +166,7 @@ func runServer() {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
http.HandleFunc("/", handleSearch) http.HandleFunc("/", handleSearch)
http.HandleFunc("/search", handleSearch) http.HandleFunc("/search", handleSearch)
http.HandleFunc("/suggestions", handleSuggestions)
http.HandleFunc("/img_proxy", handleImageProxy) http.HandleFunc("/img_proxy", handleImageProxy)
http.HandleFunc("/node", handleNodeRequest) http.HandleFunc("/node", handleNodeRequest)
http.HandleFunc("/settings", handleSettings) http.HandleFunc("/settings", handleSettings)

315
static/js/autocomplete.js Normal file
View file

@ -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 += `<li>${item}</li>`;
});
// Only show the autocomplete suggestions if the search input has a non-empty value
if (searchInput.value) {
searchWrapper.classList.add('show');
}
resultsWrapper.innerHTML = `<ul>${content}</ul>`;
}
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;
});
}
}

161
suggestions.go Normal file
View file

@ -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 + "]"
}

View file

@ -9,6 +9,7 @@
<link rel="search" type="application/opensearchdescription+xml" title="Ocásek" href="/opensearch.xml"> <link rel="search" type="application/opensearchdescription+xml" title="Ocásek" href="/opensearch.xml">
</head> </head>
<body> <body>
<script defer src="/static/js/autocomplete.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// Define the updateSettings function first // Define the updateSettings function first
@ -83,6 +84,10 @@
<div class="wrapper"> <div class="wrapper">
<input type="text" name="q" autofocus id="search-input" placeholder="Type to search..." /> <input type="text" name="q" autofocus id="search-input" placeholder="Type to search..." />
<button id="search-wrapper-ico" class="material-icons-round" name="t" value="text" type="submit">search</button> <button id="search-wrapper-ico" class="material-icons-round" name="t" value="text" type="submit">search</button>
<div class="autocomplete">
<ul>
</ul>
</div>
<!-- <a id="clearSearch" class="material-icons-round">close</a> --> <!-- <a id="clearSearch" class="material-icons-round">close</a> -->
</div> </div>
<div class="search-button-wrapper"> <div class="search-button-wrapper">