Lex's SG Chart Maker

Create bundle charts for Steam Gifts.

// ==UserScript==
// @name         Lex's SG Chart Maker
// @namespace    https://www.steamgifts.com/user/lext
// @version      0.2.12
// @description  Create bundle charts for Steam Gifts.
// @author       Lex
// @match        *://store.steampowered.com/app/*
// @match        *://store.steampowered.com/sub/*
// @match        *://store.steampowered.com/bundle/*
// @require      http://code.jquery.com/jquery-3.2.1.min.js
// @require      http://code.jquery.com/ui/1.12.1/jquery-ui.min.js
// @require      https://cdn.jsdelivr.net/npm/markdown-it@11.0.0/dist/markdown-it.min.js
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      api.isthereanydeal.com
// @connect      cdn.steam.tools
// @connect      api.steamcardexchange.net
// @connect      rafaelgssa.com
// ==/UserScript==

/* eslint curly: "off", no-prototype-builtins: 1 */
/* eslint-env jquery */

// TODO:

(function() {
    'use strict';

    //GM_deleteValue("gameOrder");
    //GM_deleteValue("games");
    if ("sets" in JSON.parse(GM_getValue("cardData", "{}"))) {
        console.log("Deleting old card data");
        GM_deleteValue("cardData");
    }
    var ITAD_API_KEY = GM_getValue("ITAD_API_KEY");
    const API_KEY_REGEXP = /[0-9A-Za-z]{40}/;
    const INVALIDATION_TIME = 60*60*1000; // 60 minute cache time
    const GameID = window.location.pathname.match(/(app|sub|bundle)\/\d+/)[0];
    const NOCV_ICON = "☠";
    const CARD_ICON = "❤";
    const ADULT_ICON = "🔞";
    const LEARNING_ICON = "⚙️";
    const LIMITED_ICON = "⛔";
    const FOOTER = "Chart created with [Lex's SG Chart Maker](https://www.steamgifts.com/discussion/ed1gC/userscript-lexs-sg-chart-maker)\n";
    const ACHIEVEMENTS_URL = "https://astats.astats.nl/astats/Steam_Game_Info.php?AppID={0}";
    // other possiblities: "DailyIndieGame" "GreenMan Gaming"
    const BUNDLE_BLACKLIST = ["Chrono.GG", "Chrono.gg", "Ikoid", "Humble Mobile Bundle", "PlayInjector", "Vodo",
                              "Get Loaded", "Indie Ammo Box", "MacGameStore", "PeonBundle", "Select n'Play", "StackSocial",
                              "StoryBundle", "Bundle Central", "Cult of Mac", "GOG", "Gram.pl", "Indie Fort", "IUP Bundle", "Paddle",
                              "SavyGamer", "Shinyloot", "Sophie Houlden", "Unversala", "Indie Game Stand", "Fourth Wall Games"];

    $("head").append ('<link ' +
        'href="//ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/base/jquery-ui.min.css" ' +
        'rel="stylesheet" type="text/css">'
    );

    // From https://stackoverflow.com/questions/610406/javascript-equivalent-to-printf-string-format
    if (!String.format) {
      String.format = function(format) {
        var args = Array.prototype.slice.call(arguments, 1);
        return format.replace(/{(\d+)}/g, function(match, number) { 
          return typeof args[number] != 'undefined'
            ? args[number] 
            : match
          ;
        });
      };
    }

    // Promise wrapper for GM_xmlhttpRequest
    const Request = details => new Promise((resolve, reject) => {
        details.onerror = details.ontimeout = reject;
        details.onload = resolve;
        GM_xmlhttpRequest(details);
    });

    // Adapted from https://stackoverflow.com/questions/6108819/javascript-timestamp-to-relative-time
    function timeDifference(current, previous) {
        const msPerMinute = 60 * 1000;
        const msPerHour = msPerMinute * 60;
        const msPerDay = msPerHour * 24;
        const msPerMonth = msPerDay * 30;
        const msPerYear = msPerDay * 365;
        const elapsed = current - previous;
        if (elapsed < msPerMinute*2)
            return Math.floor(elapsed/1000) + ' seconds ago';
        else if (elapsed < msPerHour*2)
            return Math.floor(elapsed/msPerMinute) + ' minutes ago';
        else if (elapsed < msPerDay*2)
            return Math.floor(elapsed/msPerHour ) + ' hours ago';
        else if (elapsed < msPerMonth*2)
            return Math.floor(elapsed/msPerDay) + ' days ago';
        else if (elapsed < msPerYear*2)
            return 12*(current.getFullYear() - previous.getFullYear()) + (current.getMonth() - previous.getMonth()) + ' months ago';
        else
            return Math.floor(elapsed/msPerYear) + ' years ago';
    }

    function getGames() { return JSON.parse(GM_getValue("games", '{}')); }
    function getGameOrder() { return JSON.parse(GM_getValue("gameOrder", '[]')); }

    function generateQuickGuid() { return Math.random().toString(36).substring(2, 15) +
        Math.random().toString(36).substring(2, 15); }

    function getCachedJSONValue(key, default_value, invalidation_time) {
        try {
            let result = JSON.parse(GM_getValue(key));
            if ((new Date()).getTime() - result.UPDATE_TIME < (invalidation_time || INVALIDATION_TIME))
                return result;
        } catch (err) { }
        return default_value;
    }

    function setCachedJSONValue(key, value) {
        value.UPDATE_TIME = (new Date()).getTime();
        GM_setValue(key, JSON.stringify(value));
    }

    async function loadNoCV() {
        const noCVData = getCachedJSONValue("noCVData", undefined, 48*60*60*1000); // 48 hour cache time for nocv data
        if (noCVData !== undefined) {
            return handleNoCVData(noCVData);
        }
        console.log("Download new No CV data");
        const response = await Request({
            "method": "GET",
            "url": "https://rafaelgssa.com/esgst/games/ncv",
            "timeout": 30000
        });
        const jresp = JSON.parse(response.responseText);
        if (jresp && jresp.error == null) {
            setCachedJSONValue("noCVData", jresp.result.found);
            handleNoCVData(jresp.result.found);
        }
    }

    // nocv is an object { apps: { }, subs: {} }
    function handleNoCVData(nocv) {
        var games = getGames();
        for (let g of Object.values(games)) {
            if (g.subid) {
                if (g.subid in nocv.subs)
                    g.noCV = true;
            } else if (g.appid in nocv.apps)
                g.noCV = true;
        }
        GM_setValue("games", JSON.stringify(games));
        dumpListing();
    }

    async function fetchCardData() {
        const cachedCardData = getCachedJSONValue("cardData", undefined, 24*60*60*1000); // 24 hour cache time for card data
        if (cachedCardData !== undefined)
            return handleCardData(cachedCardData);
        const response = await Request({
            "method": "GET",
            //"url": "http://cdn.steam.tools/data/set_data.json",
            "url": "http://api.steamcardexchange.net/GetBadgePrices.json",
        });
        const jresp = JSON.parse(response.responseText);
        setCachedJSONValue("cardData", jresp);
        handleCardData(jresp);
    }

    async function itad_getplains(appids) {
        const response = await Request({ "method": "GET",
            "url": "https://api.isthereanydeal.com/v01/game/plain/id/?key=" + ITAD_API_KEY + "&shop=steam&ids=" + appids.join(",")
        });
        return JSON.parse(response.responseText).data;
    }

    async function itad_getbundles(plains) {
        const response = await Request({ "method": "GET",
            "url": "https://api.isthereanydeal.com/v01/game/bundles/us/?key=" + ITAD_API_KEY + "&limit=-1&expired=1&plains=" + plains.join(",")
        });
        return JSON.parse(response.responseText).data;
    }

    async function itad_getusprices(plains) {
        /*const response = await Request({ "method": "GET",
            "url": "https://api.isthereanydeal.com/v01/game/prices/us/?key=" + ITAD_API_KEY + "&country=US&plains=" + plains.join(",")
        });
        return JSON.parse(response.responseText).data;*/
        return await itad_getprices(plains, "us");
    }

    async function itad_getprices(plains, region) {
        const response = await Request({ "method": "GET",
            "url": `https://api.isthereanydeal.com/v01/game/prices/us/?key=${ITAD_API_KEY}&region=${region}&plains=`+plains.join(",")
        });
        return JSON.parse(response.responseText).data;
    }
    
    async function esapi_getplains(gameids) {
        const appids = gameids.filter(e => e.startsWith("app")).map(e => e.substring(4));
        const subids = gameids.filter(e => e.startsWith("sub")).map(e => e.substring(4));
        const response = await Request({ method: "GET",
            url:`https://esapi.isthereanydeal.com/v01/prices/?cc=US&appids=${appids.join(",")}&subids=${subids.join(",")}&bundleids=&coupon=true`
        });
        let plains = {};
        for (const [gameid, game] of Object.entries(JSON.parse(response.responseText).data.data)) {
            plains[gameid] = game.urls.info.match(/game\/(\w+)\/info/)[1];
        }
        return plains;
    }

    // Functions for scraping data from an app page
    const appPage = {
        rating(context = document) {
            const rating = $("div[itemprop=aggregateRating]", context).attr('data-tooltip-html').replace(/(\d+)%[^\d]*([\d,]*).*/, "$1% of $2 reviews");
            if (rating.startsWith("Need more")) {
                let total = parseInt($("label[for=review_type_all]", context).text().match(/[\d,]+/)[0].replace(/,/g,''));
                let pos = parseInt($("label[for=review_type_positive]", context).text().match(/[\d,]+/)[0].replace(/,/g,''));
                return Math.round(100*pos/total) + `% of ${total} reviews`;
            } else
                return rating;
        },
        appid: () => window.location.pathname.split('/')[2],
        name: (context=document) => context.querySelector(".apphub_AppName").childNodes[0].textContent,
        price: (context=document) => $.trim($(".game_area_purchase_game:first .price,.game_area_purchase_game:first .discount_original_price", context).text()),
        windows: (context=document) => $(".platform_img.win", context).length > 0,
        mac: (context=document) => $(".platform_img.mac", context).length > 0,
        linux: (context=document) => $(".platform_img.linux", context).length > 0,
        achievements: (context=document) => $("#achievement_block", context).length > 0,
        achievementCount(context=document) {
            const b = $("#achievement_block .block_title", context);
            if (b) {
                const m = b.text().match(/(\d+) Steam/);
                if (m) return m[1];
            }
        },
        cards: (context=document) => context.querySelector("img.category_icon[src$='ico_cards.png']") !== null,
        learningAbout: (context=document) => context.querySelector("img.category_icon[src$='ico_learning_about_game.png']") !== null,
        profileLimited: (context=document) => context.querySelector("img.category_icon[src$='ico_info.png']") !== null,
        adultOnly: (context=document) => context.querySelector("div.mature_content_notice") !== null,
        dlc: (context=document) => $(".game_area_dlc_bubble").length > 0,
    };

    function handleCardData(jresp) {
        var games = getGames();
        for (let g of Object.values(games)) {
            if (!(g.appid in jresp))
                continue;
            const set = jresp[g.appid];
            g.card_count = set.Count;
            g.card_set_price = set.Normal;
            g.cards = true;
        }
        GM_setValue("games", JSON.stringify(games));
        dumpListing();
    }

    function addToGameOrder(gameid) {
        let gameOrder = getGameOrder();
        gameOrder.push(gameid);
        GM_setValue("gameOrder", JSON.stringify(gameOrder));

        loadNoCV();
    }

    // Add the current page's App to the chart
    // Does not work for package Subs
    function addAppToChart() {
        if (getGameOrder().includes(GameID)) // Game already in chart
            return;
        var game = {
            gameid: GameID,
            appid: appPage.appid(),
            name: appPage.name(),
            rating: appPage.rating(),
            windows: appPage.windows(),
            mac: appPage.mac(),
            linux: appPage.linux(),
            achievements: appPage.achievements(),
            achievementCount: appPage.achievementCount(),
            learningAbout: appPage.learningAbout(),
            profileLimited: appPage.profileLimited(),
            adultOnly : appPage.adultOnly(),
            cards: appPage.cards(),
            price: appPage.price(),
            url: window.location.href,
            dlc: appPage.dlc(),
            bundles: undefined,
        };
        var games = getGames();
        games[GameID] = game;
        GM_setValue("games", JSON.stringify(games));

        addToGameOrder(GameID);
    }

    // From the main app page, adds a package listed like a deluxe edition
    // elem: the div for the package listing on the main app's page
    function addPackageToChart(elem) {
        const subid = elem.find("input[name=subid]").attr("value");
        const gameid = "sub/" + subid;
        if (getGameOrder().includes(gameid))
            return;
        var game = {
            gameid: gameid,
            appid: appPage.appid(),
            subid: subid,
            name: elem.find("h1")[0].childNodes[0].nodeValue.substring(4).trim(),
            windows: appPage.windows(elem),
            mac: appPage.mac(elem),
            linux: appPage.linux(elem),
            achievements: appPage.achievements(),
            achievementCount: appPage.achievementCount(),
            learningAbout: appPage.learningAbout(),
            profileLimited: appPage.profileLimited(),
            adultOnly : appPage.adultOnly(),
            rating: appPage.rating(),
            cards: appPage.cards(),
            price: $.trim(elem.find(".price,.discount_original_price").text()),
            url: window.location.protocol+"//store.steampowered.com/sub/" + subid,
            dlc: appPage.dlc(),
            bundles: undefined,
        };
        var games = getGames();
        games[gameid] = game;
        GM_setValue("games", JSON.stringify(games));

        addToGameOrder(gameid);
    }

    // Loads the rating for a gameid because sub pages do not have reviews
    // gameid is the gameid on the chart, appid is the Steam App ID to get the rating for
    async function getGameRating(gameid, appid) {
        const response = await Request({
            "method": "GET",
            "url": window.location.protocol+"//store.steampowered.com/app/" + appid
        });
        const dom = $(response.responseText);
        var games = getGames();
        let game = games[gameid];
        game.rating = appPage.rating(dom);
        game.achievements = appPage.achievements(dom);
        game.achievementCount = appPage.achievementCount(dom);
        game.appid = appid;
        GM_setValue("games", JSON.stringify(games));
        dumpListing();
    }

    // elem: the div for the package listing on the sub's page
    function addSubToChart(elem) {
        const subid = elem.find("input[name=subid]").attr("value");
        const gameid = "sub/" + subid;
        if (getGameOrder().includes(gameid)) // sub id already in the chart
            return;
        var game = {
            gameid: gameid,
            appid: subid,
            subid: subid,
            name: elem.find("h1")[0].childNodes[0].nodeValue.substring(4).trim(),
            rating: "?",
            windows: appPage.windows(),
            mac: appPage.mac(),
            linux: appPage.linux(),
            achievements: appPage.achievements(),
            achievementCount: appPage.achievementCount(),
            learningAbout: appPage.learningAbout(),
            profileLimited: appPage.profileLimited(),
            adultOnly : appPage.adultOnly(),
            cards: appPage.cards(),
            price: $.trim(elem.find(".price,.discount_original_price").text()),
            url: window.location.protocol+"//store.steampowered.com/sub/" + subid,
            dlc: appPage.dlc(),
            bundles: undefined,
        };
        var games = getGames();
        games[gameid] = game;
        GM_setValue("games", JSON.stringify(games));

        // Submit an AJAX request to get the game's rating
        const appid = $(".tab_item:first").attr("data-ds-appid");
        getGameRating(gameid, appid);

        addToGameOrder(gameid);
    }

    function addBundleToChart(elem) {
        const bundleid = elem.attr('data-ds-bundleid');
        const gameid = "bundle/" + bundleid;
        if (getGameOrder().includes(gameid)) // game id already in the chart
            return;
        var game = {
            gameid: gameid,
            appid: bundleid,
            bundleid: bundleid,
            name: $.trim(elem.find("h1")[0].childNodes[0].nodeValue.substring(4)),
            rating: "?",
            achievements: appPage.achievements(),
            achievementCount: appPage.achievementCount(),
            windows: appPage.windows(),
            mac: appPage.mac(),
            linux: appPage.linux(),
            learningAbout: appPage.learningAbout(),
            profileLimited: appPage.profileLimited(),
            adultOnly : appPage.adultOnly(),
            cards: appPage.cards(),
            price: '?',
            url: window.location.protocol+"//store.steampowered.com/" + gameid,
            dlc: appPage.dlc(),
            bundles: undefined,
        };
        var games = getGames();
        games[gameid] = game;
        GM_setValue("games", JSON.stringify(games));

        // Submit an AJAX request to get the game's rating
        let appid = $(".tab_item:first").attr("data-ds-appid");
        getGameRating(gameid, appid);

        addToGameOrder(gameid);
    }

    // Uses an ITAD call, then calls update_func(game, plain, data) on every response
    async function itad_games_obj(itad_func, plainArr, update_func) {
        var plains = Object.values(plainArr).filter(v => v !== null);
        const list = await itad_func(plains);
        let games = getGames();
        for (const plain in list) {
            const gid = Object.keys(plainArr).find(key => plainArr[key] === plain); // reverse the dictionary to find the key from value
            const game = games[gid];
            update_func(game, plain, list[plain]);
        }
        GM_setValue("games", JSON.stringify(games));
        dumpListing();
        updateListing();
    }

    // Load prices from ITAD into the games object
    function loadPrices(plainArr) {
        const updateFunc = function(game, plain, data) {
            // Don't replace the price of bundles
            /*if (game.gameid.startsWith("bundle"))
                return;*/
            const steamShop = data.list.find(p => p.shop.id == "steam");
            if (steamShop !== undefined) {
                game.price = "$" + steamShop.price_old;
                game.price_old = steamShop.price_old;
                game.price_new = steamShop.price_new;
                game.price_cut = steamShop.price_cut;
            } else
                console.log("Lex's SG Chart Maker Error: ITAD unable to find price for " + plain);
        }
        itad_games_obj(itad_getusprices, plainArr, updateFunc);

        const updateFuncEU = function(game, plain, data) {
            // Don't replace the price of bundles
            /*if (game.gameid.startsWith("bundle"))
                return;*/
            const steamShop = data.list.find(p => p.shop.id == "steam");
            if (steamShop !== undefined) {
                game.euPrice = steamShop.price_old;
                game.eu_price_old = steamShop.price_old;
                game.eu_price_new = steamShop.price_new;
                game.eu_price_cut = steamShop.price_cut;
            } else
                console.log("Lex's SG Chart Maker Error: ITAD unable to find price for " + plain);
        }
        itad_games_obj(p => itad_getprices(p, "eu1"), plainArr, updateFuncEU);
    }

    // Called from the Load Bundle Info button
    async function loadBundleInfo() {
        const gameids = getGameOrder().filter(g => !g.startsWith("tier"));
        // First fetch the 'plains' (ITAD's name for game identifiers)
        let plains = await itad_getplains(gameids);
        console.log(plains);
        const errors = Object.keys(plains).filter(g => plains[g] === null);
        if (errors.length > 0) {
            console.log("Lex's SG Chart Maker Error: ITAD unable to find ids: " + errors);
            console.log("Trying to obtain plains using ESAPI.");
            const p2 = await esapi_getplains(gameids);
            Object.assign(plains, p2)
        }
        const updateFunc = function(game, plain, data) {
            game.bundlesUrl = data.urls.bundles; // ITAD page for all the bundles the game has been in
            game.itadUrl = data.urls.game; // ITAD info page for the game
            game.bundles = data.list; // Dump all the bundles into game.bundles and filter later
            game.plain = plain; // ITAD identifier
        }
        let promise = itad_games_obj(itad_getbundles, plains, updateFunc);
        promise.then(loadPrices.bind(null, plains));
    }

    function showChartMaker() {
        if (!$("#lcm_dialog").length) {
            // Create the dialog
            GM_addStyle(".lcm_dialog { display: flex; flex-direction: column; } " +
                        "#lcm_dialog a { color: blue; text-decoration: underline; } " +
                        "#lcm_list { list-style-type: none; margin: 0 auto; padding: 0; width: 75%; }" +
                        "#lcm_dump { margin: 25px auto 0 auto; display: block; flex-grow: 1; resize: none; width: 95%; }" +
                        "#lcm_bundle_info { margin-bottom: 5px; }" +
                        "#lcm_itad { float: left; margin-bottom: 5px; }" +
                        "#lcm_center_btns { float:none; text-align: center; }");
            var d = $(`<div id="lcm_dialog" class="lcm_dialog"><div name="top-container">
<div id="lcm_itad">
  <div>
    <a href="https://isthereanydeal.com/dev/app/" target=_blank>IsThereAnyDeal API Key</a>: <input type="text"></input><button>Submit</button>
  </div>
  <a style="display:none" href="javascript:">Delete ITAD<br/>API Key?</a>
</div>
<div style="float: right"><button id="lcm_bundle_info" class="ui-button ui-widget ui-corner-all">Load Bundle Info</button></div>
<div id="lcm_center_btns">
    <div style="margin-bottom: 2px">
        <button id="lcm_add_tier" class="ui-button ui-widget ui-corner-all">🛆 Add Tier</button>
        <label for="lcm_totals">🧮 Totals</label>
        <input type="checkbox" id="lcm_totals"/>
        <button id="lcm_clear_chart" class="ui-button ui-widget ui-corner-all">🗑️ Empty</button>
        <button id="lcm_show_preview" class="ui-button ui-widget ui-corner-all">🖼️ Preview</button>
    </div>
    <div id="lcm_columns" style="margin-bottom: 2px">
      <label for="lcm_rating" title="Show or hide the Rating column">⭐</label>
      <input type="checkbox" id="lcm_rating"/>
      <label for="lcm_cards" title="Show or hide the Cards column">❤</label>
      <input type="checkbox" id="lcm_cards"/>
      <label for="lcm_achievements" title="Show or hide the Achievements column">🏆</label>
      <input type="checkbox" id="lcm_achievements"/>
      <label for="lcm_details" title="Show or hide the Details column">📃</label>
      <input type="checkbox" id="lcm_details"/>
      <label for="lcm_platforms" title="Show or hide the Platforms column">🖥️</label>
      <input type="checkbox" id="lcm_platforms"/>
      <label for="lcm_bundles" title="Show or hide the Bundled column">📦</label>
      <input type="checkbox" id="lcm_bundles"/>
      <label for="lcm_discount" title="Show or hide the Discount column">💸</label>
      <input type="checkbox" id="lcm_discount"/>
      <label for="lcm_currentprice" title="Show or hide the Current Price column">🛒</label>
      <input type="checkbox" id="lcm_currentprice"/>
      <label for="lcm_card_prices">🃏 Card Prices</label>
      <input type="checkbox" id="lcm_card_prices"/>
  </div>
</div>
</div>
<ul id="lcm_list"></ul>
<textarea id="lcm_dump"></textarea></div>`);
            $("body").append(d);
            if (GM_getValue("ITAD_API_KEY") !== undefined)
                $("#lcm_itad div,#lcm_itad a").toggle();
            const ColumnToggles = [
                // [ HTML ID, GM value key, default value ]
                ["#lcm_rating", "addRating", true],
                ["#lcm_achievements", "addAchievements", true],
                ["#lcm_details", "addDetails", true],
                ["#lcm_platforms", "addPlatforms", true],
                ["#lcm_cards", "addCards", true],
                ["#lcm_bundles", "addBundles", true],
                ["#lcm_discount", "addDiscount", false],
                ["#lcm_currentprice", "addCurrentPrice", false]
            ]
            ColumnToggles.forEach(tgl => {
                $(tgl[0])
                    .prop('checked', GM_getValue(tgl[1], tgl[2]))
                    .button()
                    .click(function(){
                        GM_setValue(tgl[1], $(this).prop('checked'));
                        dumpListing();
                    });
            });
            /*$("#lcm_columns").sortable({
                deactivate: function (event, ui) {
                    dumpListing();
                }
            });*/
            // Add Totals button
            $("#lcm_totals").prop('checked', GM_getValue("addTotals", false))
            .button()
            .click(function(){
                GM_setValue("addTotals", $(this).prop('checked'));
                dumpListing();
            });
            // Load card prices button
            $("#lcm_card_prices").button().click(fetchCardData);
            // Load ITAD API key
            $("#lcm_itad button").click(function(){
                try{
                    ITAD_API_KEY = $("#lcm_itad input").val().match(API_KEY_REGEXP)[0];
                    GM_setValue("ITAD_API_KEY", ITAD_API_KEY);
                    $("#lcm_itad div,#lcm_itad a").toggle();
                }catch(err){
                    alert("Error setting API key");
                }
            });
            // Add tier button
            $("#lcm_add_tier").click(function(){
                addToGameOrder("tier-" + generateQuickGuid());
                updateListing();
                dumpListing();
            });
            // Delete API key button
            $("#lcm_itad a").click(function(){
                GM_deleteValue("ITAD_API_KEY");
                ITAD_API_KEY = undefined;
                $("#lcm_itad div,#lcm_itad a").toggle();
            });

            $("#lcm_dialog").dialog({
                modal: false,
                title: "Lex's SG Chart Maker v" + GM_info.script.version,
                position: {
                    my: "center",
                    at: "center",
                    of: window,
                    collusion: "none"
                },
                width: 800,
                height: 400,
                minWidth: 300,
                minHeight: 200,
                zIndex: 3666,
            })
            .dialog("widget").draggable("option", "containment", "none");
            $("#lcm_list").sortable({
                deactivate: function (event, ui) {
                    saveGameOrder();
                    dumpListing();
                }
            });
            $("#lcm_bundle_info").click(function(){
                loadBundleInfo();
                fetchCardData();
            });
            $("#lcm_show_preview").click(showPreviewWindow);
            $("#lcm_clear_chart").click(function(){
                GM_deleteValue("gameOrder");
                GM_deleteValue("games");
                updateListing();
                dumpListing();
            });
            $("#lcm_dump").bind("input propertychange", function(){
                updatePreview($("#lcm_dump").val());
            });
        } else {
            $("#lcm_dialog").dialog();
        }
        updateListing();
        dumpListing();
    }

    function showPreviewWindow() {
        if ($("#lcm_preview").length) {
            $("#lcm_preview").dialog();
        } else {
            GM_addStyle(`.markdown h1,.markdown h2,.markdown h3,.markdown h4,.markdown h5,.markdown h6{color:#324862;padding-top:5px;margin-bottom:8px!important;line-height:1em!important}.markdown h1{font:300 28px "Open Sans",sans-serif}.markdown h2{font:700 18px "Open Sans",sans-serif}.markdown h3{font:700 14px "Open Sans",sans-serif}.markdown{word-wrap:break-word}.markdown--resize-body{font-size:13px;line-height:1.55em}.markdown table{border-collapse:collapse;border:1px solid #d2d6e0;table-layout:fixed;width:100%}.markdown thead{background-color:#e8eaef;font-weight:700;border-bottom:1px solid #d2d6e0}.markdown td,.markdown th{padding:3px 10px}.markdown td:not(:last-child),.markdown th:not(:last-child){border-right:1px solid #d2d6e0}.markdown tr:not(:last-child){border-bottom:1px solid #d2d6e0}.markdown pre{white-space:pre-wrap;background-color:#e8eef6;border:1px solid #d0dced;padding:5px 15px;border-radius:4px;text-shadow:1px 1px rgba(255,255,255,.2);color:#5c7397}.markdown code{font-family:"Droid Sans Mono",sans-serif;font-size:11px}.markdown hr{border-top:1px solid #d2d6e0;border-bottom:1px solid rgba(255,255,255,.3);border-left:none;border-right:none}.markdown .have>:not(:last-child):not(div),.markdown .want>:not(:last-child):not(div),.markdown>:not(:last-child):not(div){margin-bottom:15px}.markdown .spoiler:not(:hover){background-color:#d9dee6;color:transparent;text-shadow:none}.markdown .spoiler:not(:hover) a{color:transparent;text-decoration:none}.markdown ol,.markdown ul{margin-right:25px;margin-left:25px}.markdown ol>li{counter-increment:list}.markdown li{padding:2px 5px}.markdown ol>li:before{content:counter(list) "."}.markdown ul>li:before{content:"•"}.markdown li p:not(:last-child){margin-bottom:5px}.markdown li:before{color:#da5d88;margin-left:-60px;font-weight:700;font-size:11px;position:absolute;width:50px;text-align:right}.markdown .search_highlight{background-color:#ff0;text-shadow:none}.markdown img{max-width:500px;max-height:500px;margin-top:5px;display:inline-block}.markdown .comment__toggle-attached{font-size:11px;font-style:italic;text-decoration:underline;color:#c86848;cursor:pointer}.markdown a{color:#4b72d4;text-decoration:underline}.markdown blockquote{border-left:5px solid #d2d6e0;padding:3px 15px;font-style:italic;opacity:.8}.markdown .have,.markdown .want{border-left:5px solid;padding:10px 20px}.markdown .have:not(:last-child),.markdown .want:not(:last-child){margin-bottom:15px}.markdown .have{border-left-color:#e1868c;background-color:#efedf0}.markdown .want{border-left-color:#6bbfdb;background-color:#e8eff3}.markdown blockquote blockquote{border-left:none;padding:0;opacity:1}`);
            var d = $(`<div id="lcm_preview" class="lcm_dialog markdown" style="font-size:13px"></div>`);
            $("body").append(d);
            $("#lcm_preview").dialog({
                modal: false,
                title: "Lex's SG Chart Maker Preview",
                position: {
                    my: "right",
                    at: "right",
                    of: window,
                    collusion: "none"
                },
                width: 820, // results in a table 796px wide which is the same as SG
                height: 400,
                minWidth: 300,
                minHeight: 200,
                zIndex: 3666,
            })
            .dialog("widget").draggable("option", "containment", "none");

            updatePreview($("#lcm_dump").val());
        }
    }

    function updatePreview(dump) {
        if ($("#lcm_preview")) {
            var md = window.markdownit();
            $("#lcm_preview").html(md.render(dump));
        }
    }

    function updateListing() {
        $("#lcm_list").empty();
        var games = getGames();
        for (let id of getGameOrder()) {
            const p = (!id.startsWith("tier") && games[id].price) ? games[id].price : "?";
            const text = id.startsWith("tier") ? "Tier" : `<a href="${games[id].url}">${games[id].name}</a> - ${id} - ${p}`;
            $(`<li class="ui-state-default" data-appid="${id}">${text}<a href="javascript:" style="float:right; color:red; margin-top:-3px">✖</a></li>`)
            .appendTo("#lcm_list")
            .find("a:last").click(function(){ // Delete button
                deleteGame($(this).parent().attr("data-appid"));
                updateListing();
                dumpListing();
            });
        }
    }

    // Read order from the sortable and saves it
    function saveGameOrder() {
        const gameOrder = $("#lcm_list li").map((i,e) => e.getAttribute("data-appid")).get();
        if (gameOrder.concat().sort().join(",") !== getGameOrder().sort().join(",")) {
            alert("Chart data is out of date! Were you editing in a different tab? Reloading data from cache...");
            updateListing();
        } else
            GM_setValue("gameOrder", JSON.stringify(gameOrder));
    }

    function getProfit(cost) {
        const cf = 100;
        cost = cost * cf;
        if (cost < 22)
            return (cost - 2) / cf;
        if (cost < 33)
            return (cost - 3) / cf;
        return (cost * 0.85) / 100;
    }

    var dumpFormatters = {
        name: [ "Game", ":-", function(g) { // Dumps the name entry for a game
            return `**[${g.name}](${g.url})**` + (g.dlc ? " (DLC)" : "");
        }],
        rating: ["Ratings", ":-:", function(g) {
            return g.rating;
        }],
        cards: ["Cards", ":-:", function(g) {
            if (!g.cards) return "-";
            let tooltip = "";
            if (g.card_count) tooltip = g.card_count + " cards";
            if (g.dlc)
                return "(Base game has cards)";
            else
                return `[**${CARD_ICON}**](http://www.steamcardexchange.net/index.php?gamepage-appid-${g.appid} "${tooltip}")`;
        }],
        achievements: ["Cheevos", ":-:", function(g) {
            if (!g.achievements)
                return "-";
            if (!g.achievementCount) {
                return `[🏆](${String.format(ACHIEVEMENTS_URL, g.appid)})`;
            } else {
                return `[🏆](${String.format(ACHIEVEMENTS_URL, g.appid)} "${g.achievementCount} achievements")`;
            }
        }],
        details: ["Details", ":-:", function(g) {
            let url = "https://www.steamgifts.com/giveaways/search?app=" + g.appid;
            if (g.subid || g.bundleid)
                url = "https://www.steamgifts.com/giveaways/search?q=" + encodeURIComponent(g.name).replace(/%20/g,"+");
            let cv = 0.0;
            if (g.price)
                cv = parseFloat(g.price.replace(/\$/g,''))*0.15;
            const isUSD = g.price.startsWith("$");
            if (!isUSD) {
                cv = 0.0;
            }

            let icons = [];
            if (g.noCV || g.price == "Free" || g.price == "Free To Play") {
                icons.push(NOCV_ICON);
                cv = 0.0;
            }
            if (g.learningAbout || g.profileLimited) icons.push(LEARNING_ICON);
            if (g.adultOnly) icons.push(ADULT_ICON);
            if (icons.length) icons = " " + icons.join(""); // prepend a space

            let id = "app/" + g.appid;
            if (g.subid) id = "sub/" + g.subid;
            if (g.bundleid) id = "bundle/" + g.bundleid;
            return `[${cv.toFixed(2)} CV](${url})${icons} ${id}`;
        }],
        platforms: ["Platforms", ":-:", function(g) {
            let ps = [];
            if (g.windows) ps.push("W");
            if (g.mac) ps.push("M");
            if (g.linux) ps.push("L");
            return ps.join(" ");
        }],
        cardPrices: ["Set Price (Profit)", ":-:", function(g) {
            if (g.card_count)
                try {
                    const profit = Math.round(g.card_count / 2) * getProfit(g.card_set_price / g.card_count);
                    const market = window.location.protocol+"//steamcommunity.com/market/search?category_753_Game%5B%5D=tag_app_"+g.appid+"&category_753_cardborder%5B%5D=tag_cardborder_0&category_753_item_class%5B%5D=tag_item_class_2&appid=753";
                    return `[x${g.card_count} = $${g.card_set_price} ($${profit.toFixed(2)})](${market})`;
                } catch(err) {}
            return "-";
        }],
        bundles: ["Bundled", ":-:", function(g) {
            let bundleCount = "?";
            let tooltip = "";
            if (g.bundles !== undefined) {
                // Bundles not on blacklist and at least 48 hours old
                const notBlacklisted = b => !BUNDLE_BLACKLIST.includes(b.bundle) && (Date.now()/1000 - b.start) > 48*60*60;
                bundleCount = g.bundles.filter(notBlacklisted).length;
                //💵📉📦🛒💸💰
                const formatBundle = function(b) {
                    let delta = timeDifference(new Date(), new Date(b.expiry*1000));
                    if (b.start && (b.expiry === null || b.expiry*1000 > new Date()))
                        delta = "ongoing";
                    return "📦 " + b.title.trim() + " (" + delta + ")";
                }
                tooltip = g.bundles.length + " bundles " + g.bundles.map(formatBundle).join(" ");
            }
            return `[${bundleCount}](${g.bundlesUrl||""} "${tooltip.trim()}")`;
        }],
        price: ["Retail Price", ":-:", function(g) {
            let price = g.price || "?";
            if (price == "Free")
                price = "🆓 Free";
            if (price == "Free To Play")
                price = "💩 Free To Play";
            let tooltip = "";
            if (g.euPrice)
                tooltip = ' "' + g.euPrice + '€"';
            if (g.plain)
                price = `[${price}](https://isthereanydeal.com/#/page:game/info?plain=${g.plain}${tooltip})`;
            return price;
        }],
        discount: ["Discount", ":-:", function(g) {
            if (g.eu_price_cut !== undefined && g.eu_price_cut !== g.price_cut)
                return `[-${g.price_cut}%](# "-${g.eu_price_cut}%")`;
            return "-" + g.price_cut + "%";
        }],
        currentPrice: ["Current Price", ":-:", function(g) {
            if (g.eu_price_new !== undefined)
                return `[$${g.price_new}](# "${g.eu_price_new}€")`;
            return "$" + g.price_new;
        }],
    };
    
    // Post chart code to the textarea
    function dumpListing() {
        // Enable or disable columns by setting them to true or false. Defaults to true
        let colToggles = {
            "rating": $("#lcm_rating").prop('checked'),
            "achievements": $("#lcm_achievements").prop('checked'),
            "details": $("#lcm_details").prop('checked'),
            "cardPrices": $("#lcm_card_prices").prop('checked'),
            "platforms": $("#lcm_platforms").prop('checked'),
            "cards": $("#lcm_cards").prop('checked'),
            "bundles": $("#lcm_bundles").prop('checked'),
            "discount": $("#lcm_discount").prop('checked'),
            "currentPrice": $("#lcm_currentprice").prop('checked'),
        }
        // columns is a list of dumpFormatter keys to dump
        const has = Object.prototype.hasOwnProperty;
        let columns = Object.keys(dumpFormatters).filter(k => !has.call(colToggles, k) || colToggles[k]);

        // First two rows of the table
        let header = columns.map(e => dumpFormatters[e][0]).join(" | ") + "\n";
        header += columns.map(e => dumpFormatters[e][1]).join(" | ") + "\n";

        let dump = header;
        // If at least one Tier is added, display Tier 1 at the top
        if (getGameOrder().filter(g => g.startsWith("tier")).length)
            dump = `### **Tier 1**\n` + dump;
        let totals = [0]; // total prices
        let cardProfits = [0];
        let gameOrder = getGameOrder();
        const games = getGames();
        for (let idx = 0; idx < gameOrder.length; idx++) {
            let gid = gameOrder[idx];
            if (gid.startsWith("tier")) {
                if (idx !== 0) {
                    cardProfits.push(0);
                    totals.push(0);
                }
                dump = (idx===0 ? "":dump+"\n") + `### **Tier ${totals.length}**\n${header}`;
                continue;
            }
            const g = games[gid];
            if (g === undefined)
                continue;

            totals[totals.length-1] += parseFloat(g.price ? g.price.replace(/\$/g,'') : "0.0");
            cardProfits[cardProfits.length-1] += Math.round(g.card_count / 2) * getProfit(g.card_set_price / g.card_count);

            dump += columns.map(e => dumpFormatters[e][2](g)).join(" | ");
            dump += "\n";
        }
        // If any games have no CV
        if (Object.values(games).reduce((a,c) => a || c.noCV, false))
            dump += NOCV_ICON + " - Game was free at some time and does not grant any CV if given away.\n";
        // If any games are being learned about or profile limited
        if (Object.values(games).reduce((a,c) => a || c.learningAbout || c.profileLimited, false))
            dump += LEARNING_ICON + " - Not currently eligible to appear in certain showcases on your Steam Profile, and does not contribute to global Achievement or game collector counts.\n";
        // If any games are adult only
        if (Object.values(games).reduce((a,c) => a || c.adultOnly, false))
            dump += ADULT_ICON + " - Adult only\n";
        if (GM_getValue("addTotals")) {
            if (totals.length > 1 && totals[0] === 0)
                totals.splice(0, 1); // Cut off empty first tier
            dump += "\n**Retail:**\n";
            let cv = "\n**CV:**\n";
            let cp = colToggles.cardPrices ? "\n**Card Farming Profit:**\n" : "";
            for (let i = 0; i < totals.length; i++) {
                let t = totals[i];
                const cumCost = totals.slice(0, i+1).reduce((p,c) => p + c, 0);
                const cumCardProfits = cardProfits.slice(0, i+1).reduce((p,c) => p + c, 0);
                const prep = totals.length === 1 ? `* ` : `* Tier ${[...Array(i+2).keys()].slice(1).join(" + ")} = `;
                cv += prep + `${(cumCost*0.15).toFixed(4)}\n`;
                dump += prep + `$${cumCost.toFixed(2)}\n`;
                if (cp)
                    cp += prep + `$${cumCardProfits.toFixed(2)}\n`;
            }
            dump += cv + cp + "\n";
        }
        dump += FOOTER;
        $("#lcm_dump").val(dump);

        updatePreview(dump);
    }

    function deleteGame(aid) {
        if (aid == GameID) // Unmark the + Chart button
            $("#lcm_add_btn").removeClass("queue_btn_active");

        let gameOrder = getGameOrder();
        try {
            gameOrder.splice(gameOrder.indexOf(aid), 1);
            GM_setValue("gameOrder", JSON.stringify(gameOrder));
        }catch(err) {}

        let games = getGames();
        try {
            delete games[aid];
            GM_setValue("games", JSON.stringify(games));
        }catch(err) {}
    }

    function createSubButton(callback) {
        let btn = document.createElement("button");
        btn.type = "button";
        btn.innerText = " +⊞ Chart";
        btn.addEventListener('click', function(){
            callback.call(this);
            showChartMaker();
        });
        btn.style.cssFloat = 'right';
        btn.style.fontSize = "110%";
        btn.style.marginTop = "-1px";
        return btn;
    }

    function handleAppPage() {
        // Add button to app page
        $(`<a id="lcm_add_btn" class="btnv6_blue_hoverfade btn_medium btn_steamdb"><span>+ <span style="position:relative;top:-1px">&#x229e;</span> Chart</span></a>`)
        .appendTo(`.apphub_OtherSiteInfo:first`)
        .click(function(){
            $(this).addClass("queue_btn_active");
            addAppToChart();
            showChartMaker();
        })
        .toggleClass("queue_btn_active", GameID in getGames());

        $(".game_area_purchase_game:first").prepend(createSubButton(addAppToChart));

        // Find other purchase options on the page
        let subs = $(".game_area_purchase_game:not(:first)");
        // But ignore bundles
        subs = subs.filter((i,e) => !e.parentNode.matches("[data-ds-bundleid]"));
        // add chart buttons to each of them
        const callback = function(){ addPackageToChart($(this).closest(".game_area_purchase_game")); };
        subs.each((i,e) => e.prepend(createSubButton(callback)));
    }

    function handleSubPage() {
        // Add buttons to the package listing
        const callback = function(){ addSubToChart($(this).closest(".game_area_purchase_game")); };
        document.querySelector(".game_area_purchase_game").prepend(createSubButton(callback));
    }

    function handleBundlePage() {
        // Add buttons to the bundle listing
        const callback = function(){ addBundleToChart($(this).closest(".game_area_purchase_game")); };
        $(".game_area_purchase_game").each((i,e) => e.prepend(createSubButton(callback)));
    }

    if (window.location.pathname.match(/app\/\d+/))
        handleAppPage();
    if (window.location.pathname.match(/sub\/\d+/))
        handleSubPage();
    if (window.location.pathname.match(/bundle\/\d+/))
        handleBundlePage();
})();