From edc42ea35d05b536c2bebfcb78d1bf6007445e85 Mon Sep 17 00:00:00 2001
From: lolcat <will@lolcat.ca>
Date: Wed, 13 Sep 2023 09:01:23 -0400
Subject: [PATCH] added autocomplete

---
 api/v1/ac.php        | 225 ++++++++++++++++++++++++++++++++++++
 scraper/brave.php    |   2 -
 scraper/google.php   |  32 +++---
 scraper/sc.php       |   9 +-
 settings.php         |  91 ++++++++++++---
 static/client.js     | 266 ++++++++++++++++++++++++++++++++++++++++++-
 static/style.css     |  16 +--
 template/header.html |   2 +-
 template/home.html   |   4 +-
 9 files changed, 594 insertions(+), 53 deletions(-)
 create mode 100644 api/v1/ac.php

diff --git a/api/v1/ac.php b/api/v1/ac.php
new file mode 100644
index 0000000..0964fd9
--- /dev/null
+++ b/api/v1/ac.php
@@ -0,0 +1,225 @@
+<?php
+
+new autocomplete();
+
+class autocomplete{
+	
+	public function __construct(){
+		
+		header("Content-Type: application/json");
+		
+		$this->scrapers = [
+			"brave" => "https://search.brave.com/api/suggest?q={searchTerms}",
+			"ddg" => "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
+			"yandex" => "https://suggest.yandex.com/suggest-ff.cgi?part={searchTerms}&uil=en&v=3&sn=5&lr=21276&yu=4861394161661655015",
+			"google" => "https://www.google.com/complete/search?client=mobile-gws-lite&q={searchTerms}",
+			"qwant" => "https://api.qwant.com/v3/suggest/?q={searchTerms}&client=opensearch",
+			"yep" => "https://api.yep.com/ac/?query={searchTerms}",
+			"marginalia" => "https://search.marginalia.nu/suggest/?partial={searchTerms}",
+			"yt" => "https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&q={searchTerms}",
+			"sc" => "https://api-v2.soundcloud.com/search/queries?q={searchTerms}&client_id=iMxZgT5mfGstBj8GWJbYMvpzelS8ne0E&limit=10&offset=0&linked_partitioning=1&app_version=1693487844&app_locale=en"
+		];
+		
+		/*
+			Sanitize input
+		*/
+		if(!isset($_GET["s"])){
+			
+			$this->do404("Missing search(s) parameter");
+		}
+		
+		if(is_string($_GET["s"]) === false){
+			
+			$this->do404("Invalid search(s) parameter");
+		}
+		
+		if(strlen($_GET["s"]) > 500){
+			
+			$this->do404("Search(s) exceeds the 500 char length");
+		}
+		
+		if(
+			isset($_GET["scraper"]) &&
+			is_string($_GET["scraper"]) === false
+		){
+			
+			$_GET["scraper"] = "brave"; // default option
+		}
+		
+		/*
+			Get $scraper
+		*/
+		if(!isset($_GET["scraper"])){
+			
+			if(isset($_COOKIE["scraper_ac"])){
+				
+				$scraper = $_COOKIE["scraper_ac"];
+			}else{
+				
+				$scraper = "brave"; // default option
+			}
+		}else{
+			
+			$scraper = $_GET["scraper"];
+		}
+		
+		if($scraper == "disabled"){
+			
+			// this shouldnt happen, but let's handle it anyways
+			$this->doempty();
+		}
+		
+		// make sure it exists
+		if(!isset($this->scrapers[$scraper])){
+			
+			$scraper = "brave"; // default option
+		}
+		
+		// return results
+		
+		switch($scraper){
+			
+			case "google":
+			case "yt":
+				// handle google cause they want to be a special snowflake :(
+				$js = $this->get($this->scrapers[$scraper], $_GET["s"]);
+				
+				preg_match(
+					'/\((\[.*\])\)/',
+					$js,
+					$js
+				);
+				
+				if(!isset($js[1])){
+					
+					$this->doempty();
+				}
+				
+				$js = json_decode($js[1]);
+				$json = [];
+				
+				foreach($js[1] as $item){
+					
+					$json[] = strip_tags($item[0]);
+				}
+				
+				echo json_encode(
+					[
+						$_GET["s"],
+						$json
+					]
+				);
+				break;
+			
+			case "sc":
+				// soundcloud
+				$js = $this->get($this->scrapers[$scraper], $_GET["s"]);
+				
+				$js = json_decode($js, true);
+				
+				if(!isset($js["collection"])){
+					
+					$this->doempty();
+				}
+				
+				$json = [];
+				foreach($js["collection"] as $item){
+					
+					$json[] = $item["query"];
+				}
+				
+				echo json_encode(
+					[
+						$_GET["s"],
+						$json
+					]
+				);
+				break;
+			
+			case "marginalia":
+				$json = $this->get($this->scrapers[$scraper], $_GET["s"]);
+				
+				$json = json_decode($json, true);
+				if($json === null){
+					
+					
+					$this->doempty();
+				}
+				
+				echo json_encode(
+					[
+						$_GET["s"],
+						$json
+					]
+				);
+				break;
+			
+			default:
+				// if it respects the openSearch protocol
+				$json = json_decode($this->get($this->scrapers[$scraper], $_GET["s"]), true);
+				
+				echo json_encode(
+					[
+						$_GET["s"],
+						$json[1] // ensure it contains valid key 0
+					]
+				);
+				break;
+		}
+	}
+	
+	private function get($url, $query){
+		
+		$curlproc = curl_init();
+		
+		$url = str_replace("{searchTerms}", urlencode($query), $url);
+		
+		curl_setopt($curlproc, CURLOPT_URL, $url);
+		
+		curl_setopt($curlproc, CURLOPT_ENCODING, ""); // default encoding
+		curl_setopt($curlproc, CURLOPT_HTTPHEADER,
+			["User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.0",
+			"Accept: application/json, text/javascript, */*; q=0.01",
+			"Accept-Language: en-US,en;q=0.5",
+			"Accept-Encoding: gzip",
+			"DNT: 1",
+			"Connection: keep-alive",
+			"Sec-Fetch-Dest: empty",
+			"Sec-Fetch-Mode: cors",
+			"Sec-Fetch-Site: same-site"]
+		);
+		
+		curl_setopt($curlproc, CURLOPT_RETURNTRANSFER, true);
+		curl_setopt($curlproc, CURLOPT_SSL_VERIFYHOST, 2);
+		curl_setopt($curlproc, CURLOPT_SSL_VERIFYPEER, true);
+		curl_setopt($curlproc, CURLOPT_CONNECTTIMEOUT, 30);
+		curl_setopt($curlproc, CURLOPT_TIMEOUT, 30);
+		
+		$data = curl_exec($curlproc);
+		
+		if(curl_errno($curlproc)){
+			
+			throw new Exception(curl_error($curlproc));
+		}
+		
+		curl_close($curlproc);
+		return $data;
+	}
+	
+	private function do404($error){
+		
+		echo json_encode(["error" => $error]);
+		die();
+	}
+	
+	private function doempty(){
+		
+		echo json_encode(
+			[
+				$_GET["s"],
+				[]
+			]
+		);
+		die();
+	}
+}
diff --git a/scraper/brave.php b/scraper/brave.php
index bf11865..93256a8 100644
--- a/scraper/brave.php
+++ b/scraper/brave.php
@@ -574,8 +574,6 @@ class brave{
 						}
 					}
 					
-					echo "test";
-					
 					if($rating !== null){
 						
 						$table["Rating"] = $rating;
diff --git a/scraper/google.php b/scraper/google.php
index d0e90ca..ca77231 100644
--- a/scraper/google.php
+++ b/scraper/google.php
@@ -1616,21 +1616,23 @@ class google{
 					$imgvl
 				);
 				
-				$imgvl = $imgvl[1];
-				
-				$params["async"] = "_id:islrg_c,_fmt:html";
-				$params["asearch"] = "ichunklite";
-				$params["ved"] = $ved;
-				$params["vet"] = "1" . $ved . "..i";
-				$params["start"] = 100;
-				$params["ijn"] = 1;
-				$params["imgvl"] = $imgvl;
-				
-				$out["npt"] =
-					$this->nextpage->store(
-						json_encode($params),
-						"images"
-					);
+				if(isset($imgvl[1])){
+					$imgvl = $imgvl[1];
+					
+					$params["async"] = "_id:islrg_c,_fmt:html";
+					$params["asearch"] = "ichunklite";
+					$params["ved"] = $ved;
+					$params["vet"] = "1" . $ved . "..i";
+					$params["start"] = 100;
+					$params["ijn"] = 1;
+					$params["imgvl"] = $imgvl;
+					
+					$out["npt"] =
+						$this->nextpage->store(
+							json_encode($params),
+							"images"
+						);
+				}
 			}
 		}
 		
diff --git a/scraper/sc.php b/scraper/sc.php
index f297723..1774c20 100644
--- a/scraper/sc.php
+++ b/scraper/sc.php
@@ -288,7 +288,7 @@ class sc{
 					
 					if(count($description) != 0){
 						
-						$description = $count . " songs. " . implode(", ", $description);
+						$description = trim($count . " songs. " . implode(", ", $description));
 					}
 					
 					if(
@@ -320,7 +320,7 @@ class sc{
 					
 					$out["playlist"][] = [
 						"title" => $item["title"],
-						"description" => $description,
+						"description" => $this->limitstrlen($description),
 						"author" => [
 							"name" => $item["user"]["username"],
 							"url" => $item["user"]["permalink_url"],
@@ -385,13 +385,14 @@ class sc{
 				"\n",
 				wordwrap(
 					str_replace(
-						"\n",
+						["\n\r", "\r\n", "\n", "\r"],
 						" ",
 						$text
 					),
 					300,
 					"\n"
-				)
+				),
+				2
 			)[0];
 	}
 }
diff --git a/settings.php b/settings.php
index f6abb12..bce3af0 100644
--- a/settings.php
+++ b/settings.php
@@ -58,6 +58,56 @@ $settings = [
 	[
 		"name" => "Scrapers to use",
 		"settings" => [
+			[
+				"description" => "Autocomplete<br><i>Picking <div class=\"code-inline\">Auto</div> changes the source dynamically depending of the page's scraper<br>Picking <div class=\"code-inline\">Disabled</div> disables this feature</i>",
+				"parameter" => "scraper_ac",
+				"options" => [
+					[
+						"value" => "disabled",
+						"text" => "Disabled"
+					],
+					[
+						"value" => "auto",
+						"text" => "Auto"
+					],
+					[
+						"value" => "brave",
+						"text" => "Brave"
+					],
+					[
+						"value" => "ddg",
+						"text" => "DuckDuckGo"
+					],
+					[
+						"value" => "yandex",
+						"text" => "Yandex"
+					],
+					[
+						"value" => "google",
+						"text" => "Google"
+					],
+					[
+						"value" => "qwant",
+						"text" => "Qwant"
+					],
+					[
+						"value" => "yep",
+						"text" => "Yep"
+					],
+					[
+						"value" => "marginalia",
+						"text" => "Marginalia"
+					],
+					[
+						"value" => "yt",
+						"text" => "YouTube"
+					],
+					[
+						"value" => "sc",
+						"text" => "SoundCloud"
+					]
+				]
+			],
 			[
 				"description" => "Web",
 				"parameter" => "scraper_web",
@@ -183,8 +233,13 @@ $settings = [
 if($_POST){
 
 	$loop = &$_POST;
-}else{
+}elseif(count($_GET) !== 0){
 	
+	// redirect user to front page
+	$loop = &$_GET;
+	header("Location: /");
+	
+}else{
 	// refresh cookie dates
 	$loop = &$_COOKIE;
 }
@@ -245,7 +300,7 @@ echo
 		'<head>' .
 			'<meta http-equiv="Content-Type" content="text/html;charset=utf-8">' .
 			'<title>Settings</title>' .
-			'<link rel="stylesheet" href="/static/style.css?v3">' .
+			'<link rel="stylesheet" href="/static/style.css?v4">' .
 			'<meta name="viewport" content="width=device-width,initial-scale=1">' .
 			'<meta name="robots" content="index,follow">' .
 			'<link rel="icon" type="image/x-icon" href="/favicon.ico">' .
@@ -260,14 +315,14 @@ $left =
 		'By clicking <div class="code-inline">Update settings!</div>, a plaintext <div class="code-inline">key=value</div> cookie will be stored on your browser. When selecting a default setting, the parameter is removed from your cookies.';
 
 $c = count($_COOKIE);
+$code = "";
+
 if($c !== 0){
 	
 	$left .=
 		'<br><br>Your current cookie looks like this:' .
 		'<div class="code">';
 	
-	$code = "";
-	
 	$ca = 0;
 	foreach($_COOKIE as $key => $value){
 		
@@ -326,17 +381,23 @@ $left .=
 	'</div>' .
 	'<div class="settings-submit">' .
 		'<input type="submit" value="Update settings!">' .
-		'<a href="../">&lt; Return to main page</a>' .
+		'<a href="../">&lt; Return to front page</a>' .
 	'</div>' .
 	'</form>';
 
-echo
-	$frontend->load(
-		"search.html",
-		[
-			"class" => "",
-			"right-left" => "",
-			"right-right" => "",
-			"left" => $left
-		]
-	);
+if(count($_GET) === 0){
+
+	echo
+		$frontend->load(
+			"search.html",
+			[
+				"class" => "",
+				"right-left" =>			
+					'<div class="infobox"><h2>Preference link</h2>Follow this link to auto-apply all cookies. Useful if your browser clears out cookies after a browsing session. Following this link will redirect you to the front page, unless no settings are set.<br><br>' .
+					'<a href="settings' . rtrim("?" . str_replace("; ", "&", $code), "?") . '">Bookmark me!</a>' .
+					'</div>',
+				"right-right" => "",
+				"left" => $left
+			]
+		);
+}
diff --git a/static/client.js b/static/client.js
index 89e9a5e..a53cdb6 100644
--- a/static/client.js
+++ b/static/client.js
@@ -660,15 +660,16 @@ function changeimage(event){
 	centerpopup();
 }
 
-/*
-	Shortcuts
-*/
 var searchbox_wrapper = document.getElementsByClassName("searchbox");
 
 if(searchbox_wrapper.length !== 0){
+		
 	searchbox_wrapper = searchbox_wrapper[0];
 	var searchbox = searchbox_wrapper.getElementsByTagName("input")[1];
-
+	
+	/*
+		Textarea shortcuts
+	*/
 	document.addEventListener("keydown", function(key){
 		
 		switch(key.keyCode){
@@ -695,4 +696,261 @@ if(searchbox_wrapper.length !== 0){
 				break;
 		}
 	});
+	
+	/*
+		Autocompleter
+	*/
+	if( // make sure the user wants it
+		document.cookie.includes("scraper_ac=") &&
+		document.cookie.includes("scraper_ac=disabled") === false
+	){
+		
+		var autocomplete_cache = [];
+		var focuspos = -1;
+		var list = [];
+		var autocomplete_div = document.getElementsByClassName("autocomplete")[0];
+		
+		if(
+			document.cookie.includes("scraper_ac=auto") &&
+			typeof scraper_dropdown != "undefined"
+		){
+			
+			var ac_req_appendix = "&scraper=" + scraper_dropdown.value;
+		}else{
+			
+			var ac_req_appendix = "";
+		}
+		
+		function getsearchboxtext(){
+			
+			var value =
+				searchbox.value
+				.trim()
+				.replace(
+					/ +/g,
+					" "
+				)
+				.toLowerCase();
+			
+			return value;
+		}
+		
+		searchbox.addEventListener("input", async function(){
+			
+			// ratelimit on input only
+			// dont ratelimit if we already have res
+			if(typeof autocomplete_cache[getsearchboxtext()] != "undefined"){
+				
+				await getac();
+			}else{
+				
+				await getac_ratelimit();
+			}
+		});
+		
+		async function getac(){
+			
+			var curvalue = getsearchboxtext();
+			
+			if(curvalue == ""){
+				
+				// hide autocompleter
+				autocomplete_div.style.display = "none";
+				return;
+			}
+			
+			if(typeof autocomplete_cache[curvalue] == "undefined"){
+				
+				/*
+					Fetch autocomplete
+				*/
+				// make sure we dont fetch same thing twice
+				autocomplete_cache[curvalue] = [];
+				
+				var res = await fetch("/api/v1/ac?s=" + encodeURIComponent(curvalue) + ac_req_appendix);
+				var json = await res.json();
+				
+				autocomplete_cache[curvalue] = json[1];
+				
+				if(curvalue == getsearchboxtext()){
+					
+					render_ac(curvalue, autocomplete_cache[curvalue]);
+				}
+				return;
+			}
+			
+			render_ac(curvalue, autocomplete_cache[curvalue]);	
+		}
+		
+		var ac_func = null;
+		function getac_ratelimit(){
+			
+			return new Promise(async function(resolve, reject){
+				
+				if(ac_func !== null){
+					
+					clearTimeout(ac_func);
+				}//else{
+					
+					// no ratelimits
+					//getac();
+				//}
+				
+				ac_func =
+					setTimeout(function(){
+						
+						ac_func = null;
+						getac(); // get results after 100ms of no keystroke
+						resolve();
+					}, 300);
+			});
+		}
+		
+		function render_ac(query, list){
+			
+			if(list.length === 0){
+				
+				autocomplete_div.style.display = "none";
+				return;
+			}
+			
+			html = "";
+			
+			// prepare regex
+			var highlight = query.split(" ");
+			var regex = [];
+			
+			for(var k=0; k<highlight.length; k++){
+				
+				// espace regex
+				regex.push(
+					highlight[k].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+				);
+			}
+			
+			regex = new RegExp(highlight.join("|"), "gi");
+			
+			for(var i=0; i<list.length; i++){
+				
+				html +=
+					'<div tabindex="0" class="entry" onclick="handle_entry_click(this);">' +
+						htmlspecialchars(
+							list[i]
+						).replace(
+							regex,
+							'<u>$&</u>'
+						) +
+					'</div>';
+			}
+			
+			autocomplete_div.innerHTML = html;
+			autocomplete_div.style.display = "block";
+		}
+		
+		var should_focus = false;
+		document.addEventListener("keydown", function(event){
+			
+			if(event.key == "Escape"){
+				
+				document.activeElement.blur();
+				focuspos = -1;
+				autocomplete_div.style.display = "none";
+				return;
+			}
+			
+			if(
+				is_click_within(event.target, "searchbox") === false ||
+				typeof autocomplete_cache[getsearchboxtext()] == "undefined"
+			){
+				
+				return;
+			}
+			
+			switch(event.key){
+				
+				case "ArrowUp":
+					event.preventDefault();
+					focuspos--;
+					if(focuspos === -2){
+						
+						focuspos = autocomplete_cache[getsearchboxtext()].length - 1;
+					}
+					break;
+				
+				case "ArrowDown":
+				case "Tab":
+					event.preventDefault();
+					
+					focuspos++;
+					if(focuspos >= autocomplete_cache[getsearchboxtext()].length){
+						
+						focuspos = -1;
+					}
+					break;
+				
+				case "Enter":
+					should_focus = true;
+					
+					if(focuspos !== -1){
+						
+						// replace input content
+						event.preventDefault();
+						searchbox.value =
+							autocomplete_div.getElementsByClassName("entry")[focuspos].innerText;
+						break;
+					}
+					break;
+				
+				default:
+					focuspos = -1;
+					break;
+			}
+			
+			if(focuspos === -1){
+				
+				searchbox.focus();
+				return;
+			}
+			
+			autocomplete_div.getElementsByClassName("entry")[focuspos].focus();
+		});
+		
+		window.addEventListener("blur", function(){
+			
+			autocomplete_div.style.display = "none";
+		});
+		
+		document.addEventListener("keyup", function(event){
+			
+			// handle ENTER key on entry
+			if(should_focus){
+				
+				should_focus = false;
+				searchbox.focus();
+			}
+		});
+		
+		document.addEventListener("mousedown", function(event){
+			
+			// hide input if click is outside
+			if(is_click_within(event.target, "searchbox") === false){
+				
+				autocomplete_div.style.display = "none";
+				return;
+			}
+		});
+		
+		function handle_entry_click(event){
+			
+			searchbox.value = event.innerText;
+			focuspos = -1;
+			searchbox.focus();
+		}
+		
+		searchbox.addEventListener("focus", function(){
+			
+			focuspos = -1;
+			getac();
+		});
+	}
 }
diff --git a/static/style.css b/static/style.css
index ee320a7..ec55624 100644
--- a/static/style.css
+++ b/static/style.css
@@ -149,31 +149,27 @@ h3,h4,h5,h6{
 	left:-1px;
 	right:-1px;
 	background:var(--282828);
-	border:1px solid var(--504945);
+	border:1px solid var(--928374);
 	border-top:none;
 	border-radius:0 0 2px 2px;
 	z-index:10;
+	overflow:hidden;
 }
 
 .autocomplete .entry{
 	overflow:hidden;
 	padding:4px 10px;
 	cursor:pointer;
+	outline:none;
+	user-select:none;
 }
 
 .autocomplete .entry:hover{
 	background:var(--3c3836);
 }
 
-.autocomplete .title{
-	float:left;
-}
-
-.autocomplete .subtext{
-	float:right;
-	font-size:14px;
-	color:var(--928374);
-	margin-left:7px;
+.autocomplete .entry:focus{
+	background:var(--3c3836);
 }
 
 /* Tabs */
diff --git a/template/header.html b/template/header.html
index b687d27..9e519fc 100644
--- a/template/header.html
+++ b/template/header.html
@@ -3,7 +3,7 @@
 	<head>
 		<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
 		<title>{%title%}</title>
-		<link rel="stylesheet" href="/static/style.css?v3">
+		<link rel="stylesheet" href="/static/style.css?v4">
 		<meta name="viewport" content="width=device-width,initial-scale=1">
 		<meta name="robots" content="{%index%}index,{%index%}follow">
 		<link rel="icon" type="image/x-icon" href="/favicon.ico">
diff --git a/template/home.html b/template/home.html
index 25267c8..9818677 100644
--- a/template/home.html
+++ b/template/home.html
@@ -4,7 +4,7 @@
 		<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
 		<title>4get</title>
 		<meta name="viewport" content="width=device-width,initial-scale=1">
-		<link rel="stylesheet" href="/static/style.css?v3">
+		<link rel="stylesheet" href="/static/style.css?v4">
 		<meta name="robots" content="index,follow">
 		<link rel="icon" type="image/x-icon" href="/favicon.ico">
 		<meta name="description" content="4get.ca: They live in our walls!">
@@ -33,6 +33,6 @@
 				Report a problem: <a href="https://lolcat.ca">lolcat.ca</a>
 			</div>
 		</div>
-		<script src="/static/client.js?v3"></script>
+		<script src="/static/client.js?v4"></script>
 	</body>
 </html>