RR search

Handy search tool for royal road fictions.

// ==UserScript==
// @name         RR search
// @namespace    http://tampermonkey.net/
// @version      0.3.0
// @description  Handy search tool for royal road fictions.
// @author       Primordial Shadow
// @match        *://*.www.royalroad.com/fiction/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=royalroad.com
// @grant        none
// ==/UserScript==

/*
INSTRUCTIONS:
go to a fiction page in royalroad (chapter pages work too)
press "ù" to show/hide the interface
type whatever you want to search and press enter
*/
(function() {
    'use strict';
// for access without hotkey
    let cache = [];
    let wait = false;
    let ver = "v0.3.0";
    let sideButton = document.createElement("img");
    sideButton.setAttribute("src", "https://github.com/shadowghost69/RoyalRoad-Fiction-Analysis/blob/main/img/icon.png?raw=true");
    sideButton.setAttribute("style", `
    display: none;
	position: fixed;
	right: 0;
	top: 10vh;
	width: 7vw;
	background-color: black;
	border: solid 1px;
	border-bottom-left-radius: 10% !important;
	border-top-left-radius: 10% !important;
	border-right-style: none !important;
	padding: 2px;
    cursor: pointer
 `);
    sideButton.setAttribute("title", "[Shortcut] -> Shift + U  / ù");
    sideButton.addEventListener("click", () => { root.style.left = "0px";});
    sideButton.addEventListener("load", () => { sideButton.style.display = "block";});
    document.body.appendChild(sideButton);

// anchor
    let root = document.createElement("div");
root.setAttribute("style", "width: 100vw ;height: 100vh; display: flex;justify-content: center; align-items: center; position: fixed; top: 0; left: 100vw;z-index: 100; transition: ease-in-out 1s;");
document.body.appendChild(root);

//main container
let main = document.createElement("div");
main.setAttribute("style", `background-color: black;
                            width: 100%;
                            height: 100%;
                            border: 5px solid whitesmoke;
                            border-radius: 2rem;
                            display: flex;
                            flex-direction: column;
                            color:whitesmoke;
                            font-size: 24px;
                            `);
root.appendChild(main);

// interface title
let title = document.createElement("h1");
title.appendChild(document.createTextNode("RR Search"));
title.setAttribute("style", `
	width: 100%;
	text-align: center;
	font-size: 3rem;
	border-bottom: 4px solid whitesmoke;
	position: relative;
	height: 15%;
	margin: 0;
	display: flex;
	flex-direction: column;
	justify-content: center;
    flex-shrink: 0;
`);

main.appendChild(title);

let version = document.createElement("span");
version.setAttribute("style", `
                             font-size:1rem;
                             float:right;
                             position: absolute;
                             right: 2px;
                             bottom: 1px;`);
version.appendChild(document.createTextNode(ver));
title.appendChild(version);
// for closing without hotkey
let close = document.createElement("span");
close.setAttribute("style", `cursor: pointer;
                             font-size:3rem;
                             float:right;
                             position: absolute;
                             right: 2px;
                             top: 1px;`);
close.appendChild(document.createTextNode("×"));
close.addEventListener("click", ()=>{root.style.left="100vw";});
title.appendChild(close);


// sub-container
let flex = document.createElement("div");
flex.setAttribute("style", `display:flex;
                            justify-content: flex-start;
                            align-items: center;
                            flex-direction: column;
                            flex-grow: 10;
                            overflow: auto;`);
main.appendChild(flex);
// search bar (duh)
    let searchBar = document.createElement("input");
    searchBar.setAttribute("type", "text");
searchBar.setAttribute("placeholder", "Search Term");
searchBar.setAttribute("style", "max-width: 200px;color: black; text-align:center");
    flex.appendChild(searchBar);
let options = document.createElement("div");
options.setAttribute("style", `width: 100%;
                            display:flex;
                            justify-content: space-around;
                            align-items: center;`);
let caseSense = document.createElement("div");
    caseSense.innerHTML = `<input type="checkbox" id="caseSense"/> <label>Case Sensitive</label>`
    options.appendChild(caseSense);
let independancy = document.createElement("div");
    independancy.innerHTML = `<input type="checkbox" id="independancy" checked /> <label>Independancy</label>`
    options.appendChild(independancy);
    flex.appendChild(options);
// result container so i can easily clear it
        let container = document.createElement("div");
    container.setAttribute("style", `display:flex;
                            justify-content: flex-start;
                            align-items: center;
                            flex-direction: column;`);
    flex.appendChild(container);

// search trigger on pressing enter
    searchBar.addEventListener("keypress", function(event) {
  if (event.key === "Enter") {
    event.preventDefault();
      if (!wait) {
    container.innerHTML= "";
    Search(location.href.match(/.+fiction\/\d+\/[^\/]+/), searchBar.value);
      }
  }
});
// hotkeys
function handleShortcut(event) {
    if (event.key === "ù" || (event.keyCode === 85 && event.shiftKey)) {
        event.preventDefault();
        if (root.style.left == "0px") root.style.left = "100vw";
        else root.style.left = "0px";
    }
}
document.addEventListener("keydown", handleShortcut);

async function fetchHTML(link) {
  const response = await fetch(link);
  const html = await response.text();
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, "text/html");
  return doc;
}

function getLinks(doc) {
  let map = new Map();
  let i = 1;
  let a = new Set(doc.getElementsByTagName("td"));
  let b = new Set(doc.getElementsByClassName("text-right"));
  let c = a.difference(b);
  for (const elem of Array.from(c)) {
    let name = `(${i}) ` + elem.childNodes[1].textContent.replace(/\n | \s{2,}/g, "");
    let link = elem.childNodes[1].href
    map.set(name, link);
    i++
  }
  return map;
}
/*
function getMatches(doc, word, CaseSensitive, independancy) {
  let regex = new RegExp(independancy ? `.*\\b${word}\\b.*` : `.*${word}.*` ,CaseSensitive ? "g" : "gi");
  let countRegex = new RegExp(independancy ? `\\b${word}\\b` : `${word}` ,CaseSensitive ? "g" : "gi");
    console.debug(regex);
    console.debug(countRegex);
    cache.push({title: doc.getElementsByTagName("h1")[0].textContent, content: doc.getElementsByClassName("chapter-inner")[0].textContent});
  try {
    let title = doc.getElementsByTagName("h1")[0].textContent;
    let count = doc.getElementsByClassName("chapter-inner")[0].textContent.match(countRegex).length;
    let matches = doc.getElementsByClassName("chapter-inner")[0].textContent.match(regex);
    return {
      title: title,
      count: count,
      matches: matches
    }
  } catch (error) {
    console.debug(`found no matches`)
  }
}*/
function getMatchesFromCache(cacheArray, word, CaseSensitive, independancy) {
  let result = [];
  let regex = new RegExp(independancy ? `.*\\b${word}\\b.*` : `.*${word}.*` ,CaseSensitive ? "g" : "gi");
  let countRegex = new RegExp(independancy ? `\\b${word}\\b` : `${word}` ,CaseSensitive ? "g" : "gi");
//  let progress = document.createElement("h2");
//  progress.appendChild(document.createTextNode(`Searching... (0/${cacheArray.length})`));
//  container.appendChild(progress);
    for (const obj of cacheArray) {
try {
    let title = obj.title;
    let count = obj.content.match(countRegex).length;
    let matches = obj.content.match(regex);
    result.push({
      title: title,
      count: count,
      matches: matches
    });
  } catch (error) {
    console.debug(`found no matches in ${obj.title}`)
  }
   // progress.innerText = progress.innerText.replace(progress.innerText.match(/\d+/)[0],`${+progress.innerText.match(/\d+/)[0] + 1}`);
    }
  //  progress.remove();
return result;
}
    async function getChapterData(map) {
    wait = true;
    let progress = document.createElement("h2");
    progress.appendChild(document.createTextNode(`Fetching Chapters... (0/${Array.from(map.values()).length})`));
   container.appendChild(progress);
  for (const [name, link] of map) {
    if (link != undefined) {
      let doc = await fetchHTML(link);
      cache.push({title: doc.getElementsByTagName("h1")[0].textContent, content: doc.getElementsByClassName("chapter-inner")[0].textContent});
      console.debug(`${name} Fetched`)
      progress.innerText = progress.innerText.replace(progress.innerText.match(/\d+/)[0],`${+progress.innerText.match(/\d+/)[0] + 1}`);
    }

  }
    progress.remove();
    wait = false;
    }
async function Search(url, word) {
  let result = [];
  let totalCount = 0;
    if (cache.length === 0 ) {
  let linkMap = getLinks(await fetchHTML(url));
  console.debug("links: Get");
  await getChapterData(linkMap);
    }
    result = getMatchesFromCache(cache, word,document.getElementById("caseSense").checked, document.getElementById("independancy").checked);
for (const find of result) totalCount+= find.count;
    let finalResult = {result:result, totalCount: totalCount};
      console.debug(finalResult);

    displayResult(finalResult, container, word);
}

function displayResult(result, parent, word) {
 parent.appendChild( document.createElement("h2").appendChild(document.createTextNode(`Found ${result.totalCount} matches in total`)));
  for (const match of result.result) {
    let innerHTML = `<details>
  <summary style="text-align: center;background: grey;
  cursor: pointer;">${match.title} - ${match.count} ${match.count == 1 ? "match" : "matches"}</summary>`;
    for (const para of match.matches) {
      innerHTML += `<p style="font-size: 0.6em;">${para.replace(new RegExp(document.getElementById("independancy").checked ? `\\b(${word})\\b`:`(${word})` , document.getElementById("caseSense").checked ? "g" : "gi"), `<strong>$1</strong>`)}</p>`;
    }
    innerHTML += `</details>`;
    let elem = document.createElement("div");
    elem.className= "find";
    elem.innerHTML = innerHTML;
    parent.appendChild(elem);
  }
}
})();