Greasy Fork is available in English.

wikiroute

Finds the shortest routes between two wikipedia pages and marks the corresponding links

// ==UserScript==
// @name         wikiroute
// @namespace    https://wikiroute.revig.nl/
// @version      0.5.1
// @description  Finds the shortest routes between two wikipedia pages and marks the corresponding links
// @author       Revi
// @license      MIT
// @match        https://*.wikipedia.org/wiki/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=tampermonkey.net
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';
    let defaultServ = "https://wikiroute.revig.nl/wikiroute?"
    let servOverwritesByLang = new Map()
    let lang = location.hostname.indexOf("wikipedia")!==-1 ? location.hostname.split(".")[0] : "en-text"
    let wikirouteServ = servOverwritesByLang.get(lang) ?? defaultServ

    GM_registerMenuCommand("route", getRoute, "r");
    async function getRoute() {
        let dest = prompt("Destination","");
        if (dest==null) {
            sessionStorage.route=null;
            sessionStorage.articlesVisited=null;
            return;
        }
        let source = me;
        try {
            let res = await fetch(wikirouteServ+"source="+encodeURIComponent(source)+"&dest="+encodeURIComponent(dest)+"&lang="+encodeURIComponent(lang));
            sessionStorage.route = await res.text();
           sessionStorage.articlesVisited=JSON.stringify([]);
        } catch(err) {
            alert(err);
            sessionStorage.route = null;
            sessionStorage.articlesVisited=null;
        }
        start();
    }
    function inMainNamespace(link) {
        let namespaces = ["media","special","user","wikipedia","file","mediawiki","template","help","category","portal","draft","timedtext","module","gadget","gadget definition"];
        let talks = namespaces.map(a=>a+" talk");
        talks.push("talk");
        namespaces = namespaces.concat(talks);
        link = link.toLowerCase();
        for (let i=0;i<namespaces.length;i++) {
            if (link.startsWith(namespaces[i]+":")) return false;
        }
        return true;
    }
    async function updateRoute() {
        function getLinks() {return Array.from((document.getElementById("bodyContent")||document.getElementsByClassName("mw-parser-output")[0]).getElementsByTagName("a")).map(a=>a.dataset?.url?.split("_").join(" ")||a.href.split("#")[0].split("/wiki/")[1]&&decodeURIComponent(a.href.split("#")[0].split("/wiki/")[1])).filter(a=>a!=undefined)}
        let route = JSON.parse(sessionStorage.route);
        let dests = route.destinations;
        let sources = Array.from(new Set(getLinks().map(a=>a.split("_").join(" ")).filter(a=>a!==me&&inMainNamespace(a))));
        if (sources.some(source=>dests.includes(source))) {
            route.route[me] = sources.filter(source=>dests.includes(source));
            console.log("Direct link");
        } else {
            let exclude;
            try { exclude = JSON.parse(sessionStorage.articlesVisited) } catch(e) { exclude=[];console.log("failed to parse articlesVisited") }
            let res = await fetch(wikirouteServ+"lang="+encodeURIComponent(lang), {method:"POST",body:JSON.stringify({sources,dests,exclude})});
            route = await res.json();
            if (route.route==null) {
                let res = await fetch(wikirouteServ+"lang="+encodeURIComponent(lang), {method:"POST",body:JSON.stringify({sources,dests})});
                route = await res.json();
            }
            if (route.route==null) {
                alert("No route found when trying to update route");
                // maybe return?
            }
            route.route[me] = route.sources.filter(a=>route.route[a]&&route.route[a].length);
            if (marked.length&&!route.route[(marked[0].dataset.url||getPlainLink(marked[0])).split("_").join(" ")]) {
                console.log("Found faster route");
                marked.forEach(a=>{a.style=""}) // TODO: make better, such that old styles are kept
                marked=[];
                markedI=0;
            }
        }
        sessionStorage.route = JSON.stringify(route);
        console.log("Updated route", route.route[me]);
        routeUpdated = true;
        let urls = route.route[me].map(a=>a.split(" ").join("_"));
        urls.forEach(url=>Array.from(document.getElementsByTagName("a")).forEach(a=>{if((getPlainLink(a)===url||a.dataset.url===url)&&!marked.includes(a)) { a.style.color = "white";a.style.backgroundColor="green";marked.push(a);console.log(a) }}));
    }
    function delay(ms) {
        return new Promise(res=>setTimeout(res, ms));
    }
    var marked = [];
    var markedI = 0;
    let redirectsResolved = false;
    let routeUpdated = false;
    let active = false;
    let me = decodeURIComponent(document.querySelector('link[rel="canonical"]').href.split("#")[0].split("_").join(" ").split("/wiki/")[1]);
    async function saveURLS(callback) {
        let as = document.getElementsByTagName("a");
        console.time("Redirect resolution");
        let toResolve = [];
        let redirectas = [];
        for (let i=0;i<as.length;i++) {
            let a = as[i];
            if (!a.classList.contains("mw-redirect")) continue;
            let link = getPlainLink(a);
            if (!link) continue;
            link = link.split("_").join(" ");
            redirectas.push({a,link});
            if (!toResolve.includes(link)) toResolve.push(link);
        }
        let redirectMap = await getRedirectMap(toResolve,redirectMap=>{
            for (let i=0;i<redirectas.length;i++) {
                let link = redirectas[i].link;
                if (!redirectMap.has(link)) continue
                let a = redirectas[i].a;
                a.dataset.url = redirectMap.get(link).split(" ").join("_");
                callback(a);
                redirectas.splice(i,1);i--;
            }
        });
        if (redirectas.length) {
            console.log("Unable to resolve the following redirects:",redirectas,redirectMap);
        }
        console.timeEnd("Redirect resolution");
        updateRoute();
        redirectsResolved = true;
    }
    async function getRedirectMap(toResolve, callback) {
        let redirectMap = new Map();
        for (let i=0;i<toResolve.length;i+=50) {
            let requestURL = "/w/api.php?action=query&titles="+toResolve.slice(i,Math.min(i+50,toResolve.length)).map(encodeURIComponent).join("|")+"&formatversion=2&redirects=1&format=json";
            console.time("fetch");
            let res = await fetch(requestURL, {headers:{"api-user-agent":"wikiroute.revig.nl/wikiroute.user.js"}});
            console.timeEnd("fetch");
            let redirects = (await res.json()).query.redirects;
            for (let j=0;j<redirects.length;j++) {
                redirectMap.set(redirects[j].from, redirects[j].to);
            }
            callback(redirectMap);
        }
        return redirectMap;
    }
    function getPlainLink(a) {
        let link = a.href.split("#")[0].split("/wiki/")[1];
        if (!link) return undefined;
        return decodeURIComponent(link);
    }
    function markURLS(urls) {
      if (!urls) {
        console.log("Page not on route");
        sessionStorage.articlesVisited="[]"; // maybe store two seperate articlesVisited arrays?
        urls=[];
      }
      urls = urls.map(a=>decodeURIComponent(a).split(" ").join("_"));
      Array.from(document.getElementsByTagName("a")).forEach(a=>{if(!a.classList.contains("mw-redirect")&&urls.includes(getPlainLink(a))) { a.style.color = "white";a.style.backgroundColor="black";marked.push(a);console.log(a) }});
      saveURLS(a=>{
        urls.forEach(url=>{
          if (a.dataset.url===url) {a.style.color = "white";a.style.backgroundColor="gray";marked.push(a);console.log(a)}
        })
      })
    }

    function focusMarkedLink() {
        if (!marked.length) {
            if (!redirectsResolved) {
                alert("Please wait for redirect resolution");
            } else if (!routeUpdated) {
                alert("Please wait for route update");
            } else {
                alert("Fail");
            }
            return;
        }
        if (!marked[markedI].checkVisibility()) uncollapseAll()
        marked[markedI].focus();
        if (marked[markedI].offsetParent==null) {
            console.log(marked,markedI);
            Array.from(document.getElementsByClassName("mw-collapsible-text")).forEach(a=>a.click());
            marked[markedI].focus()
        }
        markedI = (markedI+1)%marked.length;
    }
    function uncollapseAll() {
                Array.from(document.getElementsByClassName("mw-collapsible-toggle-collapsed")).forEach(e=>e.click())
                Array.from(document.getElementsByClassName("mf-icon-expand")).forEach(e=>e.click())
    }
    start();
    document.body.addEventListener("keydown", e=>{if(e.key=="r"&&(e.altKey||e.target===document.body)) getRoute()});
    function start() {
        let route ;
        try { route=JSON.parse(window.sessionStorage.route); } catch {console.log("No valid current route");return;}
        if (!route) return;
        if (route.sources.length===0) {
            console.log("Source not found");
            route.route={};
            route.route[me] = [];
            sessionStorage.route=JSON.stringify(route);
            //no return
        } else if (route.route==null) {
            if (route.destinations.length===0) {
                alert("Destination not found");
            } else {
                alert("No route found");
            }
            window.sessionStorage.route=null;
            window.sessionStorage.articlesVisited=null;
            return;
        }
        if (route.destinations.includes(me)) {
            sessionStorage.route=null;
            sessionStorage.articlesVisited=null;
            return;
        }
        console.log(me, route.route[me]);
        markURLS(route.route[me]);
        active = true
        redirectsResolved = false;
        routeUpdated = false;
        GM_registerMenuCommand("link", focusMarkedLink);
        document.body.addEventListener("keydown", function(e) {
            if (e.target!==document.body&&e.target.tagName!=="A") return;
            if (e.key==="k") {
                focusMarkedLink()
            } else if (e.key==="c") {
                uncollapseAll()
            } else if (e.key==="d") {debugger;}
        });
        try { let visited=JSON.parse(window.sessionStorage.articlesVisited);visited.push(me);sessionStorage.articlesVisited=JSON.stringify(visited) } catch(e) { console.log("error updating articlesvisited",e) }
    }
})();