formatting.js

/**
 * Provides a number of functions for formatting text, cards and decks on Slack using the Slack API and Markdown.
 * @module  formating
 * @author  Dominic Shelton.
 */
'use strict';

/**
 * @var {object} colours
 * The faction colours are loaded from a JSON file.
 */
var colours = require('./colours.json');
/**
 * @var {string[]|string[][]} packs 
 * The pack and cycle names are loaded from a JSON file.
 */
var packs = require('./datapacks.json');
/**
 * @var {module} alliances
 * The {@link alliances} module is required for calculating the decklist influence total.
 */
var alliances = require('./alliances.js');
/**
 * @var {string} thumbsURL
 * The URL where the card thumbnails are hosted, loaded from the environment variable THUMBS_URL
 */
var thumbsURL = process.env.THUMBS_URL;
/**
 * @var {object}  messages
 * Various text messages conveniently combined into one object.
 */
var messages = {
    noCardHits: [
        "The run was successful but you didn't access _\u200b[cards]\u200b_.",
        "I was unable to find _\u200b[cards]\u200b_ in any of my remotes.",
        "Despite 822 Medium counters, _\u200b[cards]\u200b_ wasn't found.",
        "The Near-Earth Hub couldn't locate _\u200b[cards]\u200b_."
    ],
    noDeckHits: [
        "The archetype of that deck would be _\u200bnon-existant\u200b_."
    ],
    helpBrackets:
        "Search for a card by (partial) name, or acronym, or a decklist by its netrunnerdb link e.g.\`\`\`[sneakdoor] [hiemdal] [etf]\n[netrunnerdb\u200b.com/en/decklist/17055/example]\`\`\`",
    helpDeck:
        "Search for a decklist by its netrunnerdb link or ID number e.g.\`\`\`[command] 12345, [command] netrunnerdb\u200b.com/en/decklist/17055/example\`\`\`",
    helpCard:
        "Search for a card by (partial) name, approximation or acronym e.g.\`\`\`[command]sneakdoor, [command]hiemdal, [command]etf\`\`\`"
};
/**
 * @var {string[][]}    headings
 * Headings used in Decklists, in the order they appear in the decklist output.
 */
var headings = [
    ['Event', 'Hardware', 'Resource', 'Agenda', 'Asset', 'Upgrade', 'Operation'],
    ['Icebreaker', 'Program', 'Barrier', 'Code Gate', 'Sentry', 'Multi', 'Other']
];
/**
 * @var {string[][]}    stats
 * An array of pairs of card stats and corresponding emoji, in the order they should appear in card descriptions.
 */
var stats = [
    ['baselink', ' :_link:'],
    ['cost', ':_credit:'],
    ['memoryunits', ' :_mu:'],
    ['strength', ' str'],
    ['trash', ' :_trash:'],
    ['advancementcost', ' :_advance:'],
    ['minimumdecksize', ' :_deck:'],
    ['influencelimit', '•'],
    ['agendapoints', ' :_agenda:']
];

/**
 * @func cardHelpMessage
 * @param   command {string}    The Slack command that was used to invoke the help request, used in the examples returned. Can be left blank to invoke the fallback brackets help text.
 * @return  {object}    A Slack API message object of a help response message.
 */
module.exports.cardHelpMessage = cardHelpMessage;
function cardHelpMessage(command) {
    if (command) {
        return {
            text: messages.helpCard.replace(/\[command\]/g, command + ' ')
        };
    }
    return {
        text: messages.helpBrackets
    };
};

/**
 * @func deckHelpMessage
 * @param   command {string}    The Slack command that was used to invoke the help request, used in the examples returned.
 * @return  {object}    A Slack API message object of a help response message.
 */
module.exports.deckHelpMessage = deckHelpMessage;
function deckHelpMessage(command) {
    return {
        text: messages.helpDeck.replace(/\[command\]/g, command)
    };
};

/**
 * Generates a message indicating that cards weren't found, by concatenating the names with commas and 'or'
 * @func cardNoHitsMessage
 * @param   cards   {string[]}  An array of card titles that weren't found.
 * @return  {string}    A randomized Slack API message object stating that the given cards weren't found.
 */
module.exports.cardNoHitsMessage = cardNoHitsMessage;
function cardNoHitsMessage(cards) {
    var text;
    if (cards.length >= 2) {
        text = cards.slice(0, cards.length - 1).join(', ');
        text += ' or ' + cards[cards.length - 1];
    } else {
        text = cards[0];
    }
    var message = getCardNoHitsMessage(text);
    return {
        text: message
    };
};

/**
 * Generates a message indicating that a deck wasn't found.
 * @func deckNoHitsMessage
 * @return  {string}    A randomized Slack API message object stating that the deck wasn't found.
 */
module.exports.deckNoHitsMessage = deckNoHitsMessage;
function deckNoHitsMessage() {
    var r = Math.floor(Math.random() * messages.noDeckHits.length);
    return {
        text: messages.noDeckHits[r]
    };
};

/**
 * Applies Slack markdown formatting to the given title to bolden and optionally apply the link.
 * @func    formatTitle
 * @param   title   {string}    The text to display as the title.
 * @param   [url]   {string}    The URL to link the title to.
 * @return  {string}    A string containing the title with Slack markdown formatting applied.
 */
module.exports.formatTitle = formatTitle;
function formatTitle(title, url) {
    title = '*\u200b' + title + '\u200b*';
    if (url && url !== '') {
        return formatLink(title, url);
    }
    return title;
};

/**
 * @func    formatDecklist
 * @param   decklist    {object}    The decklist to be converted into a Slack message.
 * @return  {object}    A Slack API message object containing the decklist or an error message.
 */
module.exports.formatDecklist = (decklist) => {
    // Initialise the return object.
    var o = {text: '', attachments:[{mrkdwn_in: ['pretext', 'fields']}]};
    var faction = decklist.cards.Identity[0].card.faction;
    var usedInfluence = 0;
    var mwlDeduction = 0;
    var decksize = 0;
    var agendapoints = 0;
    var fields = [];
    // Initialise the newestCard var to a card that is guaranteed to be in every deck.
    var newestCard = parseInt(decklist.cards.Identity[0].card.code);
    o.text = formatTitle(decklist.name, decklist.url);
    o.text += ' - _\u200b' + decklist.creator + '\u200b_';
    for (let column in headings) {
        // Create the columns as Slack 'fields'.
        fields[column] = {title: '', value: '', short: true};
        for (let heading in headings[column]) {
            var type = headings[column][heading];
            // Check if the deck actually contains cards of the specified heading
            if (decklist.cards[type]) {
                var typeTotal = 0;
                var text = '';
                // Iterate through all the cards of the type in the decklist.
                for (let i in decklist.cards[type]) {
                    var card = decklist.cards[type][i];
                    var code = parseInt(card.card.code);
                    typeTotal += card.quantity;
                    text += '\n' + card.quantity;
                    text += ' × ' + formatLink(card.card.title, card.card.url);
                    decksize += card.quantity;
                    // Check that the card is not newer than the previous newest card.
                    if (code > newestCard) {
                        newestCard = code;
                    }
                    if (card.card.agendapoints) {
                        agendapoints += card.card.agendapoints * card.quantity;
                    }
                    // Add MWL star if required.
                    if (card.card.mwl)
                    {
                        var mwl = card.quantity * card.card.mwl;
                        text += ' ' + '☆'.repeat(mwl);
                        mwlDeduction += mwl;
                    }
                    // Add influence dots after the card name if required.
                    if (card.card.faction !== faction) {
                        var inf = card.quantity * card.card.factioncost;
                        if (alliances[card.card.code]) {
                            inf *= alliances[card.card.code](decklist);
                        }
                        text += ' ' + influenceDots(inf);
                        usedInfluence += inf;
                    }
                }
                // If this is not the first heading, add padding after the previous.
                if (heading != 0) {
                    fields[column].value += '\n\n';
                }
                fields[column].value += formatTitle(type) + ' (' + typeTotal + ')';
                fields[column].value += text;
            }
        }
    }
    o.attachments[0].color = colours[faction];
    o.attachments[0].fields = fields;
    // The identity of the decklist is displayed before the decklist.
    o.attachments[0].pretext = formatLink(decklist.cards.Identity[0].card.title,
            decklist.cards.Identity[0].card.url);
    o.attachments[0].pretext += '\n' + decksize + ' :_deck: (min ';
    o.attachments[0].pretext +=  decklist.cards.Identity[0].card.minimumdecksize;
    o.attachments[0].pretext += ') - ' + usedInfluence + '/';
    if (decklist.cards.Identity[0].card.influencelimit) {
        o.attachments[0].pretext += decklist.cards.Identity[0].card.influencelimit - mwlDeduction + '•';
        if (mwlDeduction > 0) {
            o.attachments[0].pretext += '(' + decklist.cards.Identity[0].card.influencelimit + '-';
            o.attachments[0].pretext += mwlDeduction + '☆)';
        }
    } else {
        o.attachments[0].pretext += '∞•';
    }
    if (decklist.cards.Identity[0].card.side !== 'Runner') {
        o.attachments[0].pretext += ' - ' + agendapoints + ' :_agenda:';
    }
    o.attachments[0].pretext += '\nCards up to ' + getPack(newestCard);
    return o;
};

/**
 * @func    formatCards
 * @param   [cards] {object[]}  The cards to be converted into a Slack message.
 * @param   [cards] {object[]}  The cards that weren't found and should be mentioned in an error message.
 * @return  {object}    A Slack API Message object either containing the cards as attachments or containing an error message that the cards couldn't be found, or both.
 */
module.exports.formatCards = (cards, missing) => {
    var o;
    // If there are cards that could not be found, tell the user.
    if (missing && missing.length > 0) {
        o = cardNoHitsMessage(missing);
        o.attachments = [];
    } else {
        o = {text:'', attachments:[]};
    }
    // Display the cards that were found either way.
    for (var i = 0; i < cards.length; i++) {
        var a = {pretext: '', mrkdwn_in: ['pretext', 'text']};
        var faction = cards[i].faction;
        var title = cards[i].title;
        if (cards[i].uniqueness){
            title = '◆ ' + title;
        }
        // If the Slack message is blank, put the title of the first card there.
        // The Slack API won't display messages with blank text even when there are attachments.
        if (o.text === '') {
            o.text = formatTitle(title, cards[0].url);
        } else {
            a.pretext = formatTitle(title, cards[i].url) + '\n';
        }
        // Append the rest of the card details to the attachment.
        a.pretext += '*\u200b' + cards[i].type;
        if (cards[i].subtype ) {
            a.pretext += ':\u200b* ' + cards[i].subtype;
        } else {
            a.pretext += '\u200b*';
        }
        a.pretext += ' - ' + getFactionEmoji(faction);
        if (cards[i].factioncost) {
            a.pretext += influenceDots(cards[i].factioncost);
        }
        if (cards[i].mwl) {
            a.pretext += '☆';
        }
        a.pretext += '\n';
        var first = true;
        // For some cards, the cost is actually a rez cost, this should be reflected in the emoji.
        if (cards[i].type === 'Asset' || cards[i].type === 'Upgrade' || cards[i].type === 'ICE') {
            stats[1][1] = ' :_rez:';
        } else {
            stats[1][1] = ':_credit:';
        }
        // Iterate through the possible card stats adding them to the text where present.
        for (var j = 0; j < stats.length; j++) {
            if (cards[i][stats[j][0]] || cards[i][stats[j][0]] === 0) {
                if (!first) {
                    a.pretext += ' - ';
                }
                a.pretext += cards[i][stats[j][0]] + stats[j][1];
                first = false;
            // Special case for draft IDs with infinite influence limit
            } else if (cards[i].type === 'Identity' && stats[j][0] === 'influencelimit' && !cards[i].influencelimit) {
                a.pretext += ' - ∞•';
            }
        }
        // Replace memory units with the slack emoji
        a.pretext = a.pretext.replace(/(\d|X)\s*:_mu:/gi, function (x) {
            return x.replace(/(.).*/, ':_$1mu:').toLowerCase();
        });
        a.pretext += ' - ' + getPack(parseInt(cards[i].code));
        a.color = colours[faction];
        // Add the card text to the attachment if present.
        if (cards[i].text) {
            a.text = formatText(cards[i].text);
        }
        a.thumb_url = thumbsURL + cards[i].code + '.png';
        o.attachments.push(a);
    }
    return o;
};

/**
 * Replace all the html and nrdb text with the Slack equivalent.
 * @func    formatText
 * @param   text    {string}    The body of text to convert to Slack formatting.
 * @return  {string}    The text, formatted in Slack markdown syntax.
 */
module.exports.formatText = formatText;
function formatText(text) {
    if (!text) return text;

    text = text.replace(/\r\n/g, '\n');

    // NRDB symbols to Slack emoji.
    text = text.replace(/\[Credits\]/g, ':_credit:');
    text = text.replace(/\[Recurring\ Credits\]/g, ':_recurringcredit:');
    text = text.replace(/\[Click\]/g, ':_click:');
    text = text.replace(/\ *\[Link\]/g, ' :_link:');
    text = text.replace(/\[Trash\]/g, ':_trash:');
    text = text.replace(/\[Subroutine\]/g, ':_subroutine:');
    // Individual mu emoji for numbers and 'Xmu'
    text = text.replace(/(\d|X)\s*\[Memory\ Unit\]/gi, function (x) {
        return x.replace(/(.).*/, ':_$1mu:').toLowerCase();
    });
    text = text.replace(/\[Memory Unit\]/g, ':_mu:');

    // HTML bold to Slack bold
    text = text.replace(/<strong>/g, '*\u200b');
    text = text.replace(/<\/strong>/g, '\u200b*');
    // HTML superscript to unicode superscript since Slack markdown doesn't support it.
    text = text.replace(/<sup>(?:\d|X)+<\/sup>/gi, function(x){
        x = x.replace(/<sup>|<\/sup>/g, '');
        x = x.replace(/X/i,'ˣ');
        x = x.replace(/\d/g, function(d){
            return ['⁰','¹','²','³','⁴','⁵','⁶','⁷','⁸','⁹'][parseInt(d)];
        });
        return x;
    });

    // Replace nrdb faction symbols with Slack emoji
    text = text.replace(/\[(jinteki|weyland-consortium|nbn|haas-bioroid)\]/, (a, x) => {
        return ":_" + getFactionEmoji(x) + ":";
    });

    text = text.replace(/&/g, '&amp;');
    text = text.replace(/</g, '&lt;');
    text = text.replace(/>/g, '&gt;');

    return text;
};

function influenceDots(influence) {
    return '•'.repeat(influence);
}

function getPack(code) {
    var cycle = packs[Math.floor(code/1000)];
    if (Array.isArray(cycle)) {
        cycle = cycle[Math.floor(((code % 1000) - 1) / 20)];
    }
    return '_\u200b' + cycle + '\u200b_';
}

function formatLink(text, url) {
    return '<' + url + '|' + text + '>';
}

function getFactionEmoji(faction) {
    return ':_' + faction.replace(/\s.*/, '').toLowerCase() + ':';
}

function getCardNoHitsMessage(text) {
    var r = Math.floor(Math.random() * messages.noCardHits.length);
    return messages.noCardHits[r].replace(/\[cards\]/g, text);
}