Grundo's Café stamp album helper

Extend features of the stamp album on Grundo's Café

// ==UserScript==
// @name         Grundo's Café stamp album helper
// @namespace    github.com/windupbird144
// @version      0.9
// @description  Extend features of the stamp album on Grundo's Café
// @author       supercow64, eleven, rowanberryyyy, kateslines
// @match        https://www.grundos.cafe/stamps/album/?page_id=*
// @icon         https://www.grundos.cafe/static/images/favicon.66a6c5f11278.ico
// @grant        none
// @license      MIT
// ==/UserScript==

const prefix = "https://grundoscafe.b-cdn.net/items"

function removePrefix(url) {
    return url.replace(prefix, "")
}

(function () {
    'use strict';

    // Fetch the database of stamps remotely. We expect the host of the external resource to manage
    // the E-Tag header, use no-cache to only reload the resource if it has been update.
    //
    // The entry stamp_database in localStorage overwrites the default resource.
    // This is useful for testing. You can upload an experimental database to e.g. Github Gists,
    // write the link into localStorage and work with your development version
    fetch(localStorage.getItem("stamp_database") ?? "https://raw.githubusercontent.com/windupbird144/gc-stamp-album-helper/main/stamps.json", { cache: "no-cache" })
        .then(res => res.json())
        .then(main)

    function main(database) {

        const table = document.querySelector(`#stamp_tbl`)
        const cells = table.querySelectorAll("td img")

        // Double click for a shop wizard searchg
        table.addEventListener('dblclick', e => {
            // Any element with 'name' in its dataset is considered shop wizard searchable
            const name = e.target?.dataset?.name
            if (name) {
                // A stamp was clicked
                e.stopPropagation()
                e.preventDefault()
                // Open the shop wizard in a new tab
                searchWizard(name)
            }
        })

        table.addEventListener('click', e => {
            const slot = e?.target?.dataset?.position
            if (typeof slot === "string") {
                updateInfo(+slot)
            }
        })


        // The url stores a query parameter page_id=? which indicates the current album
        const page = +new URLSearchParams(window.location.search).get("page_id")

        // Update the album slots
        for (let slot = 0; slot < cells.length; slot++) {
            // This identifies if we have a stamp, wheteher it is collected and a database entry
            const cell = cells[slot]
            const collected = cell.title
            const databaseEntry = database[page] ? database[page][slot] : undefined
            // Update the dataset for the shop wizard functionality
            if (databaseEntry) {
                cell.dataset.position = slot
                cell.dataset.name = databaseEntry[0]
                cell.dataset.rarity = databaseEntry[1]
                cell.dataset.description = databaseEntry[2]
                cell.dataset.collected = !!collected
            }
            // Uncollected stamp fill the slot with database info
            if (databaseEntry && !collected) {
                cell.src = `${prefix}/${databaseEntry[3]}`
                cell.title = `${databaseEntry[0]} - r${databaseEntry[1]} : ${databaseEntry[2]}`
                cell.style.opacity = 0.25
            }
        }


        // Open the url in a new tab and fill the form fields
        function openAndFill(url, formFields) {
            const w = window.open(url)
            w.addEventListener("DOMContentLoaded", () => {
                const document = w.document
                for (let [name, value] of Object.entries(formFields)) {
                    const formField = document.querySelector(`#page_content [name='${name}']`)
                    if (formField) {
                        formField.value = value
                    }
                }
            })
        }

        function encodeQuery(key, value) {
            const tmp = new URLSearchParams()
            tmp.set(key, value)
            return tmp.toString()
        }

        const searchWizard = (query) => window.open(`/market/wizard?${encodeQuery("query",query)}`)
        const searchTradingPost = (query) => openAndFill('/island/tradingpost/browse/', { category : 2, query })
        const searchAuctionHouse = () => window.open("/auctions")
        const searchSDB = (query) => window.open(`/safetydeposit/?page=1&${encodeQuery("query", query)}&category=0`)
        const searchJellyneo = (query) => window.open(`https://items.jellyneo.net/search/?${encodeQuery("name", query)}`)
        const searchVirtupets = (query) => window.open(`https://virtupets.net/search?${encodeQuery("q", query)}`)
        const searchShop = () => window.open(`/viewshop/?shop_id=58`)

        // Show a rich info box at the bottom
        table.insertAdjacentHTML("beforeend", `<tbody>
    <tr>
    <td colspan="5">
      <div id="stampinfo" hidden>
        <div class="name">name</div>
        <div class="rarity"></div>
        <div class="cols">
        <div class="stamp_arrow" data-delta="-1"><</div>
        <div class="image"><img src=""/></div>
        <div class="labels">
           <div><label>Position: </label><span class="position"></span></div>
           <div><label>Status: </label><span class="status"></span></div>
           <div class="links">
             <img data-search="wizard" src="https://neopialive.s3.us-west-1.amazonaws.com/misc/wiz.png" />
             <img data-search="trading" src="https://neopialive.s3.us-west-1.amazonaws.com/misc/tp.png" />
             <img data-search="auction-house" src="https://i.ibb.co/vYzmPxV/auction25.gif" />
             <img data-search="sdb" src="https://neopialive.s3.us-west-1.amazonaws.com/misc/sdb.gif" />
             <img data-search="jn" src="https://i.ibb.co/cvGsCw4/fishnegg25.gif" />
             <img data-search="virtupets" src="https://virtupets.net/assets/images/vp.png" />
             <img data-search="shop" src="https://grundoscafe.b-cdn.net/misc/shopkeeper/58.gif" />
           </div>
        </div>
        <div class="stamp_arrow" data-delta="1">></div>
        </div>
      </div>
    </td>
    </tr>
    </tbody><style>
    #stampinfo {
      margin-top: 1em;
      padding: 1em;
      border: 1px solid #aaa;
    }
    #stampinfo .stamp_arrow {
       font-size: 2em;
       display: flex;
       align-items: center;
       user-select: none;
       cursor: pointer;
    }
    #stampinfo > div {
       text-align: center;
    }
    #stampinfo .labels {
       text-align: left;
       display: grid;
       row-gap: 0.5em;
    }
    #stampinfo .image {
       padding: 0 2em 0 1em;
       user-select: none;
    }
    #stampinfo label,
    #stampinfo .name {
       font-weight: bold;
    }
    .cols {
       display: grid;
       grid-template-columns: min-content auto 1fr min-content;
    }
    img[data-search] { height: 25px; }
    #compare-user {
        margin-top: 1em;
    }
    [data-collected="true"] { color: darkgreen }
    [data-collected="false"] { color: darkred }
    #stamp_tbl td {
        position: relative;
    }
    [data-diff]:after {
        position: absolute;
        content: "";
        border: 1px solid #aaa;
        height: 10px;
        width: 10px;
        left: 5px;
        top: 5px;
    }
    [data-diff=""]::after { display: none; }
    [data-diff="minus"]::after { background: rgba(255,0,0,0.7); }
    [data-diff="plus"]::after { background: rgba(0,255,0,0.7); }

    </style>`)

        const stampinfo = table.querySelector("#stampinfo")

        const infos = {
            img: stampinfo.querySelector("img"),
            name: stampinfo.querySelector(".name"),
            rarity: stampinfo.querySelector(".rarity"),
            position: stampinfo.querySelector(".position"),
            status: stampinfo.querySelector(".status")
        }

        let currentPos = 0

        function updateInfo(pos) {
            const stampImage = cells[pos]
            if (!stampImage) return
            const { src, dataset } = stampImage
            if (!dataset) return
            const { name, rarity, collected } = dataset
            if (!name) return
            infos.img.src = src
            infos.name.textContent = name
            infos.rarity.textContent = "r" + rarity
            infos.position.textContent = pos + 1
            infos.status.textContent = collected === "true" ? "collected" : "not collected"
            infos.status.dataset.collected = collected
            stampinfo.hidden = false
            currentPos = pos
            return true
        }

        stampinfo.addEventListener("click", (e) => {
            // Move left or right to the next stamp, skipping over empty slots
            let delta = parseInt(e?.target?.dataset?.delta, 10)
            if (Math.abs(delta) !== 1) return;
            let target = currentPos + delta
            while (true) {
                if (updateInfo(target)) break; // returns true if the info was updated
                if (target < 0) break;
                if (target > 25) break;
                target = target + delta
            }
        })

        stampinfo.addEventListener("click", (e) => {
            const search = e.target.dataset.search
            const query = cells[currentPos].dataset.name
            const searchFunction = {
                "wizard": searchWizard,
                "trading": searchTradingPost,
                "auction-house": searchAuctionHouse,
                "sdb": searchSDB,
                "virtupets" : searchVirtupets,
                "jn": searchJellyneo,
                "shop" : searchShop
            }[search]
            if (searchFunction) {
                return searchFunction(query)
            }
        })

        const jellyneoLinks = {
            [1]: "/mystery-island-album-avatar-list/",
            [2]: "/virtupets-album-avatar-list/",
            [3]: "/tyrannia-album-avatar-list/",
            [4]: "/haunted-woods-album-avatar-list/",
            [5]: "/neopia-central-album-avatar-list/",
            [6]: "/neoquest-album-avatar-list/",
            [7]: "/snowy-valley-album-avatar-list/",
            [8]: "/meridell-vs-darigan-album-avatar-list/",
            [9]: "/lost-desert-album-avatar-list/",
            [10]: "/battledome-album-avatar-list/",
            [12]: "/battle-for-meridell-album-avatar-list/",
            [13]: "/neoquest-ii-album-avatar-list/"
        }

        const jellyneoLink = jellyneoLinks[page]
        if (jellyneoLink) {
            table.nextElementSibling?.insertAdjacentHTML("afterend", `<a href="https://items.jellyneo.net/search${jellyneoLink}" target="_blank"/><center><img src="https://i.ibb.co/cvGsCw4/fishnegg25.gif" /> Album info <img src="https://i.ibb.co/cvGsCw4/fishnegg25.gif" /></center></a>`)
        }

        // Show diff form
        const compareUser = localStorage.getItem("compare-user") ?? ""

        table.nextElementSibling.insertAdjacentHTML("beforeend", `<form action="#" id="compare-user">
           <label for="compare-user">Compare against another user</label><br>
           <input type="text" name="compare-user" value="${compareUser}" />
           <input type="submit" value="Compare" />
           <button name="clear">Clear</button>
           <div class="error"></div>
        </form>`)

        const diff = table.parentElement.querySelector("#compare-user")
        const error = diff.querySelector(".error")
        const setError = (msg) => error.textContent = msg
        const clearError = () => error.textContent = ""

        // Read the key compare-user from localstorage, make a fetch request to their stamp album and run the diff function
        function applyDiffHTTP(username) {
            return fetch(`/stamps/album/?page_id=${page}&owner=${username}`)
                .then(res => res.text())
                .then((html) => {
                    if (html.includes("That user does not exist!")) {
                        throw new Error("That user user does not exist!")
                    } else {
                        applyDiff(html)
                    }
                })
        }

        // Change the name in the compare-user form field, save it to local storage and run applyDiffFromLocalStorage immediately
        table.parentElement.addEventListener("submit", async (e) => {
            e.preventDefault()
            let username = diff.querySelector(`[name="compare-user"]`).value.trim()
            if (!username?.length) {
                setError("Please enter a valid username")
                return
            }
            setError("Loading...")
            applyDiffHTTP(username)
                .then(() => {
                    clearError()
                    localStorage.setItem("compare-user", username)
                })
                .catch((err) => {
                    setError(err.message)
                })
        })

        // Stop comparing against another user
        table.parentElement.addEventListener("click", (e) => {
            if (e.target.name !== "clear") return
            e.stopPropagation()
            e.preventDefault()
            localStorage.removeItem("compare-user");
            const username = diff.querySelector(`[name="compare-user"]`)
            if (username) {
                username.value = ""
            }
            cells.forEach((cell) => {
                cell.parentElement.dataset.diff = ""
            })
        })

        function applyDiff(html) {
            // regex to get all stamp images on this html page
            // match(/src="\/images.+?"/g).map(e => e.match(/\/images.+\.\w+/)[0])
            for (let cell of cells) {
                cell.parentElement.dataset.diff = ""
                const have = cell.dataset.collected === "true"
                const otherHas = html.includes(removePrefix(cell.src))
                if (have && !otherHas) {
                    cell.parentElement.dataset.diff = "plus"
                } else if (!have && otherHas) {
                    cell.parentElement.dataset.diff = "minus"
                }
            }
        }

        if (compareUser) {
            applyDiffHTTP(compareUser)
        }
    }
})();