MediaWiki:ChatLinkSearch.js

From Guild Wars 2 Wiki
Jump to navigationJump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/* <nowiki> */
/**
 * GW2W Chat link search
 *
 * Decodes Guild Wars 2 chat links in the search panel, and tries to find the
 * corresponding article using the SMW property "Has game id".
 *
 * Original by Patrick Westerhoff [User:Poke]. 2022 modifications by Chieftain Alex.
 */
 
 
/**
 * Since square brackets are considered illegal characters for native interwiki search redirects,
 * this JS is required such that interwiki's can be respected when searching from the ingame
 * /wiki command with a chatlink.
 *
 * "de:" redirects to the german wiki,
 * "fr:" redirects to the french wiki,
 * "es:" redirects to the spanish wiki.
 * 
 * Based on a suggestion at [[MediaWiki talk:ChatLinkSearch.js]] by [[de:Benutzer:Olertu]]
 *
 * To disable this functionality by setting a cookie, visit [[Widget:No interwiki search]].
 */
 (function checkForInterWiki() {
    var searchBar = document.querySelector('#searchText input');
    if (!searchBar) {
        return;
    }

    // Check for a cookie which, if set, prevents the wiki redirecting on chat links
    var stopCookie = getCookie('ignoreInterwikiSearchRedirect');

    // Check for an interwiki prefix
    var match = searchBar.value.match(/^(de|fr|es):(.*?)$/i);
    if (match && stopCookie === null) {
        console.log('Redirecting from ',window.location.href,'  to another language wiki.');
        window.location.href = 'https://wiki-' + match[1] + '.guildwars2.com/index.php?title=Special:Search&search=' + encodeURIComponent(match[2]);
    } else {
        chatLinkSearch(searchBar)
    }
})();

function getCookie(k) {
    var v = document.cookie.match('(^|;) ?' + k + '=([^;]*)(;|$)');
    return v ? v[2] : null
}

function chatLinkSearch(searchBar) {
    var mwApi;

    // Helper function: Convert item mask into options (upgrades, sigils/runes, skins)
    function itemChoices(mask){
        var option = {};
        // Bitmask meanings: 0 = no upgrades, 64 (or 32) = 1 sigil, 96 = 2 sigils, 128 = skinned, 192 (or 160) = skinned + 1 sigil, 224 = skinned + 2 sigils
        switch (mask) {
            case 0:   option.name = 'no upgrades';                         option.arr = ['','',''];             break;
            case 32:
            case 64:  option.name = 'one sigil/rune';                      option.arr = ['item','',''];         break;
            case 96:  option.name = 'two sigils/runes';                    option.arr = ['item','item',''];     break;
            case 128: option.name = 'skin applied';                        option.arr = ['skin','',''];         break;
            case 160:
            case 192: option.name = 'one sigil/rune and a skin applied';   option.arr = ['skin','item',''];     break;
            case 224: option.name = 'two sigils/runes and a skin applied'; option.arr = ['skin','item','item']; break;
            default:  option.name = 'unknown';                             option.arr = ['','',''];             break;
        }
        return option;
    }

    // Helper function: Convert specialization mask into options (top/middle/bottom)
    function specializationChoices(mask){
        // Convert to binary
        var binary = mask.toString(2).padStart(8,'0');

        // Split into pairs
        var binary_pairs = binary.match(/../g);

        // Remove the useless 1st pair
        binary_pairs.shift();

        // Reverse the order and convert back into decimals
        var positions = $.map(binary_pairs.reverse(), function(v) {
            return parseInt(v,2) - 1;
        });

        var traits = $.map(positions, function(v,k) {
            var pos = ['Top','Middle','Bottom'];
            return pos[v];
        });
        return traits;
    }

    function decodeChatLink2(input) {
        /** Example usage: decodeChatLink('[&AdsnAAA=]')
         *
         * Some examples that can be decoded:
         * '[&AdsnAAA=]'; // Coin - 1g 02s 03c
         * '[&AgGqtgAA]'; // Item - Zojja's Claymore
         * '[&AgGqtgDgfQ4AAP9fAAAnYAAA]'; // Item - Zojja's Claymore (item 46762), bitmask 224 (skin + two upgrades) skinned as Dreamthistle Greatsword (skin 3709), with Superior Bloodlust (item 24575), Superior Force (24615)
         * '[&AxcnAAA=]'; // Text: "Fight what cannot be fought" - id 10007
         * '[&DGYAAABOBAAA]'; // WvW objective: [[Y'lan Academy]] --> map id 1102, objective 102
         * '[&DAYAAAAmAAAA]'; // WvW objective: [[Speldan Clearcut]] --> map id 38, objective 6
         * '[&DQQIByEANzZ5AHgAqwEAALUApQEAALwA7QDtABg9AAEAAAAAAAAAAAAAAAA=]'; // Ranger build
         * '[&DQEqHhAaPj1LFwAAFRcAAEgBAAAxAQAANwEAAAAAAAAAAAAAAAAAAAAAAAA=]'; // Burn firebrand
         */

        // HELPER FUNCTION #1
        // Reads a string like AAB60000, break into pairs AA-B6-00-00, reverses pairs 00-00-B6-AA, joins, and converts HEX (radix 16) to DECIMAL (radix 10).
        // Note: prefixing a number with 0x would allow you to skip specifying the 16 bit.
        // https://www.binaryhexconverter.com/hex-to-decimal-converter
        function parseHexLittleEndian(text){
            if (text == undefined || !(text.match(/../g)) ){
                return '';
            }
            return parseInt(text.match(/../g).reverse().join(''),16);
        }

        // Input cleanup - remove "[&" and rear "]"
        var code = input.replace(/^\[\&+|\]+$/g, '');

        // Split characters into array
        var textArray = code.split('');

        // Convert from Text to an Array of decimal numbers (0, 1, 2, 3, 4, 5, 6, 7, 8, 9).
        var AtoB_lookup = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
        var decimals = new Array(textArray.length);
        for (var i = 0; i < code.length; i++) {
            decimals[i] = AtoB_lookup.indexOf(textArray[i]);
        }

        // Convert from numbers to blocks of 6 binary bits [aka digits]
        //  Needs 6 digits 32-16-8-4-2-1 to represent 0-63 for a base 64 number
        var binaries = new Array(code.length);
        for (var i = 0; i < code.length; i++) {
            binaries[i] = decimals[i].toString(2).padStart(6,'0');
        }

        // Join
        var binary_stream = binaries.join('');

        // Split into blocks, but this time in groups of 4 binary bits
        var binary_quads = binary_stream.match(/..../g);

        // Interpret as HEX - one hex character = 4 bits. Therefore two hex characters = 8 bits == 1 byte.
        var hex_quads = new Array(binary_quads.length);
        for (var i = 0; i < binary_quads.length; i++) {
            hex_quads[i] = parseInt(binary_quads[i], 2).toString(16).toUpperCase();
        }

        // Join
        var hex_stream = hex_quads.join('');

        // Layout specifications change depending on the header number
        var specification = {
            1: {
                name: 'coin',
                searchflag: 'n',
                format: [
                    { name: 'header',     bytes: 2 },
                    { name: 'copper_qty', bytes: 8 }
                ]
            },
            2: {
                name: 'item',
                searchflag: 'y',
                format: [
                    { name: 'header',    bytes: 2 },
                    { name: 'quantity',  bytes: 2 },
                    { name: 'id',        bytes: 6 },
                    { name: 'bitmask',   bytes: 2 }, // Bitmask meanings: 0 = no upgrades, 64 (or 32) = 1 sigil, 96 = 2 sigils, 128 = skinned, 192 (or 160) = skinned + 1 sigil, 224 = skinned + 2 sigils
                    { name: 'upgrade1',  bytes: 6 },
                    { name: 'padding1',  bytes: 2 },
                    { name: 'upgrade2',  bytes: 6 },
                    { name: 'padding2',  bytes: 2 },
                    { name: 'upgrade3',  bytes: 6 }
                ]
            },
            3: {
                name: 'text',
                searchflag: 'n',
                format: [
                    { name: 'header',    bytes: 2 },
                    { name: 'id',        bytes: 8 }
                ]
            },
            4: {
                name: 'location',
                searchflag: 'y',
                format: [
                    { name: 'header',    bytes: 2 },
                    { name: 'id',        bytes: 8 }
                ]
            },
            6: {
                name: 'skill',
                searchflag: 'y',
                format: [
                    { name: 'header',    bytes: 2 },
                    { name: 'id',        bytes: 8 }
                ]
            },
            7: {
                name: 'trait',
                searchflag: 'y',
                format: [
                    { name: 'header',    bytes: 2 },
                    { name: 'id',        bytes: 8 }
                ]
            },
            9: {
                name: 'recipe',
                searchflag: 'y',
                format: [
                    { name: 'header',    bytes: 2 },
                    { name: 'id',        bytes: 8 }
                ]
            },
            10: {
                name: 'skin',
                searchflag: 'y',
                format: [
                    { name: 'header',    bytes: 2 },
                    { name: 'id',        bytes: 8 }
                ]
            },
            11: {
                name: 'outfit',
                searchflag: 'y',
                format: [
                    { name: 'header',    bytes: 2 },
                    { name: 'id',        bytes: 8 }
                ]
            },
            12: {
                name: 'wvw objective',
                searchflag: 'n',
                format: [
                    { name: 'header',    bytes: 2 },
                    { name: 'id',        bytes: 8 },
                    { name: 'map_id',    bytes: 8 }
                ]
            },
            13: {
                name: 'build template',
                searchflag: 'n',
                format: [
                    { name: 'header',                    bytes: 2 }, // 2 hex digits (each capable of 0-15 permutations) = 2*4 binary digits = 1 byte
                    { name: 'prof',                      bytes: 2 }, // 1 byte
                    { name: 'spec1',                     bytes: 2 }, // 1oo6 bytes
                    { name: 'spec1_choices',             bytes: 2 }, // 2oo6 bytes
                    { name: 'spec2',                     bytes: 2 }, // 3oo6 bytes
                    { name: 'spec2_choices',             bytes: 2 }, // 4oo6 bytes
                    { name: 'spec3',                     bytes: 2 }, // 5oo6 bytes
                    { name: 'spec3_choices',             bytes: 2 }, // 6oo6 bytes
                    { name: 'heal',                      bytes: 4 }, // 2oo20 bytes
                    { name: 'aquatic_heal',              bytes: 4 }, // 4oo20
                    { name: 'utility1',                  bytes: 4 }, // 6oo20
                    { name: 'aquatic_utility1',          bytes: 4 }, // 8oo20
                    { name: 'utility2',                  bytes: 4 }, // 10oo20
                    { name: 'aquatic_utility2',          bytes: 4 }, // 12oo20
                    { name: 'utility3',                  bytes: 4 }, // 14oo20
                    { name: 'aquatic_utility3',          bytes: 4 }, // 16oo20
                    { name: 'elite',                     bytes: 4 }, // 18oo20
                    { name: 'aquatic_elite',             bytes: 4 }, // 20oo20
                    { name: 'pet1ORrevlegend1',          bytes: 2 }, // 1oo4 bytes
                    { name: 'pet2ORrevlegend2',          bytes: 2 }, // 2oo4
                    { name: 'aquatic_pet1ORarevlegend1', bytes: 2 }, // 3oo4
                    { name: 'aquatic_pet2ORarevlegend2', bytes: 2 }  // 4oo4
                ]
            }
        };

        // Examine the header - this informs the structure of the rest of the chatlink
        var headerTypeNum = parseHexLittleEndian( hex_stream.slice(0,2) );
        if (!(headerTypeNum in specification)){
            // Chatlink header type not supported
            return {
                headername: 'unsupported',
                header: headerTypeNum,
                searchflag: 'n'
            };
        }

        // Convert hex stream into blocks of decimals
        var hex_spec = {}, dec_spec = { 'headername': '' }, offset = 0;
        $.each( specification[headerTypeNum].format, function(i,v){
            hex_spec[v.name] = hex_stream.slice(offset, offset + v.bytes);
            dec_spec[v.name] = parseHexLittleEndian( hex_spec[v.name] );
            offset += v.bytes;
        });


        // Push the header name and wiki seach true/false flag
        dec_spec.headername = specification[headerTypeNum].name;
        dec_spec.searchflag = specification[headerTypeNum].searchflag;

        // Extra sanitization due to printing the json blob
        if (dec_spec.headername == 'item') {
            // Upgrades
            var i_temp = itemChoices(dec_spec.bitmask);
            
            // Name
            dec_spec.enhancements = i_temp.name;
            
            // Rename remaining variables
            var i_temp_upgrades_array = [dec_spec.upgrade1,dec_spec.upgrade2,dec_spec.upgrade3];
            var i_count = 0;
            $.each(i_temp.arr, function(i,v) {
                if (v !== '') {
                    if (v == 'skin') {
                        dec_spec.skin = i_temp_upgrades_array[i];
                    } else {
                        i_count += 1;
                        dec_spec['item_upgrade_id' + i_count] = i_temp_upgrades_array[i];
                    }
                }
            });
            
            delete dec_spec.bitmask;
            delete dec_spec.upgrade1;
            delete dec_spec.upgrade2;
            delete dec_spec.upgrade3;
            delete dec_spec.padding1;
            delete dec_spec.padding2;
            delete dec_spec.padding3;
        }
        if (dec_spec.headername == 'build template') {
            // Specializations
            dec_spec.spec1_choices = specializationChoices(dec_spec.spec1_choices).join('-');
            dec_spec.spec2_choices = specializationChoices(dec_spec.spec2_choices).join('-');
            dec_spec.spec3_choices = specializationChoices(dec_spec.spec3_choices).join('-');

            // Ranger and Revenant specific
            // Ranger
            if (dec_spec.prof == 4) {
                dec_spec.pet1 = dec_spec.pet1ORrevlegend1;
                dec_spec.pet2 = dec_spec.pet2ORrevlegend2;
                dec_spec.aquatic_pet1 = dec_spec.aquatic_pet1ORarevlegend1;
                dec_spec.aquatic_pet2 = dec_spec.aquatic_pet2ORarevlegend2;
            }

            // Revenant
            if (dec_spec.prof == 9) {
                dec_spec.revlegend1 = dec_spec.pet1ORrevlegend1;
                dec_spec.revlegend2 = dec_spec.pet2ORrevlegend2;
                dec_spec.aquatic_revlegend1 = dec_spec.aquatic_pet1ORarevlegend1;
                dec_spec.aquatic_revlegend2 = dec_spec.aquatic_pet2ORarevlegend2;
            }

            delete dec_spec.pet1ORrevlegend1;
            delete dec_spec.pet2ORrevlegend2;
            delete dec_spec.aquatic_pet1ORarevlegend1;
            delete dec_spec.aquatic_pet2ORarevlegend2;
        }

        return dec_spec;
    }

    function smwAskArticle (type, id, callback) {
        var apiData = { action: 'ask', query: '?Has canonical name|?Has context|limit=1|' };
        var query = '[[:+]] [[Has game id::' + id + ']]';
        if (type == 'item') {
            query += '[[Has context::Item]]';
        }
        else if (type == 'location') {
            query += '[[Has context::Location]]';
        }
        else if (type == 'skill') {
            query = query + '[[Has context::Skill]] OR ' + query + '[[Has context::Effect]]';
        }
        else if (type == 'trait') {
            query += '[[Has context::Trait]]';
        }
        else if (type == 'skin') {
            query += '[[Has context::Skin]]';
        }
        else if (type == 'recipe') {
            query = '[[:+]] [[Has recipe id::' + id + ']]';
        }
        else if (type == 'outfit') {
            query = '[[:+]] [[Has outfit id::' + id + ']]';
        }
        apiData.query += query;
        mwApi.get(apiData)
        .done(function (data) {
            if (data.query.results.length === 0) {
                callback(null);
            }
            else {
                for (var title in data.query.results) {
                    var canonicalName = data.query.results[title].printouts['Has canonical name'][0];
                    var gameContexts = data.query.results[title].printouts['Has context']
                    callback(title, canonicalName, gameContexts.length ? gameContexts[0] : null);
                    return;
                }
            }
        })
        .fail(function (data) {
            callback(null);
        });
    }

    function capitalizeFirstLetter(string) {
        return string.charAt(0).toUpperCase() + string.slice(1);
    }

    function sanitizeTitle(obj) {
        delete obj.searchflag;

        // Remove emtpy key-value pairs
        $.each(obj, function(i,v){
            if (v == "") {
                delete obj[i];
            }
        });

        // Replace quote marks, newlines and double spaces
        return JSON.stringify(obj, null, 2)
            .replace(/"/g,"")
            .replace(/\n/g,"")
            .replace(/  /g," ");
    }

    function display (code, listItem) {
        var data = decodeChatLink2(code);
        var type = data.headername;
        var id;
        var searchflag = data.searchflag;

        if (searchflag == 'n') {
            if (type == 'unsupported') {
                var span = document.createElement('span');
                span.innerHTML = 'This type of chat link is not recognized and has not been decoded. (Chat link header #' + data.header + ')';
                span.title = sanitizeTitle(data);
                $(span).fadeIn(1000).appendTo(listItem);
                return;
            } else {
                var span = document.createElement('span');
                span.innerHTML = capitalizeFirstLetter(type) + ' chat link. Searching for this type of chat link is not currently supported, but it has been decoded, hover over this line for details.'
                if ('id' in data ) {
                    span.innerHTML += ' (' + type +  ' #' + data.id + ')';
                }
                span.title = sanitizeTitle(data);
                $(span).fadeIn(1000).appendTo(listItem);
                return;
            }

        } else {
            id = data.id;

            smwAskArticle(type, id, function (title, canonicalName, gameContext) {
                var span = document.createElement('span');
                span.title = sanitizeTitle(data);
                if (title) {
                    // If a single chatlink returns a single result (single li element), redirect to that page
                    //  but don't redirect if it contains anything except a chatlink, e.g. interwiki prefix or text following
                    if (searchBar.value.match(/^\[&[A-Za-z0-9+/=]+\]$/)) {
                        // Redirect only once for the current browsing session for that precise result
                        var key = 'searchredirected-' + searchBar.value;
                        try {
                            if (!sessionStorage.getItem(key)) {
                                sessionStorage.setItem(key, 'true');
                                document.location = '/index.php?title=' + encodeURIComponent(title.replace(/ /g, '_'));
                            }
                        } catch(e) {
                            // This might throw if session storage is disabled or unsupported. Just don't redirect if so.
                        }
                    }

                    var link = document.createElement('a');
                    link.href = '/wiki/' + $.map(title.split('/'), function(v){
                        return encodeURIComponent(v.replace(/ /g, '_'));
                    }).join('/');
                    link.title = title;
                    link.innerHTML = canonicalName || title;
                    span.appendChild(link);
                    if (type == 'skill' && gameContext == 'Effect') {
                        type = 'effect';
                    }
                    span.appendChild(document.createTextNode(' (' + type + ' #' + id + ')'));
                }
                else {
                    var msg = 'There is no article linked with this ID (' + id + ') yet.';
                    msg += ' If you know what <i>' + (type == 'skill' ? 'skill or effect' : type) + '</i> this chat link links to, please add the ID to the article or create it if it does not exist yet.';
                    span.innerHTML = msg;
                }
                $(span).fadeIn(1000).appendTo(listItem);
                $(listItem).attr('data-gameid', id)
            });
        }

    }

    window.mw.loader.using('mediawiki.api', function() {
        mwApi = new window.mw.Api();

        // Find chat links
        var ul = document.createElement('ul');
        var expr = /\[&([A-Za-z0-9+/]+=*)\]/g;
        var match;
        while ((match = expr.exec(searchBar.value))) {
            var li = document.createElement('li');
            li.innerHTML = '<tt>' + match[0] + '</tt>';
            ul.appendChild(li);
            display(match[1], li);
        }

        // Display results
        if (ul.children.length) {
            var div = document.createElement('div');
            div.className = 'gw2w-chat-link-search';
            div.innerHTML = 'The following <a href="/wiki/Chat_link_format" title="Chat link format">chat links</a> were included in your search query:';
            div.appendChild(ul);

            var topTable = document.getElementById('mw-search-top-table');
            $(div).hide().insertAfter(topTable).show('fast');
        }
    });
}
/* </nowiki> */