Gitlab Mods

Adds colored sections, extra functionality, and avatars to Gitlab. For avatars, detects locally-running image server to use for replacements of avatars. Use http-server (https://www.npmjs.com/package/http-server) for node.js for simple image hosting. Recommend image size of 100x100.

As of 2020-07-29. See the latest version.

// ==UserScript==
// @name			Gitlab Mods
// @namespace		COMDSPDSA
// @version			5.4
// @description		Adds colored sections, extra functionality, and avatars to Gitlab. For avatars, detects locally-running image server to use for replacements of avatars. Use http-server (https://www.npmjs.com/package/http-server) for node.js for simple image hosting. Recommend image size of 100x100.
// @author			Dan Overlander
// @include			*/gitlab.dell.com*
// @require			https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js
// @require         https://greasyfork.org/scripts/23115-tampermonkey-support-library/code/Tampermonkey%20Support%20Library.js?version=831683
// @require		    https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.2/underscore-min.js
// @require         https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment-with-locales.min.js
// @require         https://greasyfork.org/scripts/40055-libraryjquerygrowl/code/libraryJQueryGrowl.js
// @grant           GM_setValue
// @grant           GM_getValue

// ==/UserScript==

// TODO: Make *hourly* reminders show only once no matter how many tabs for Gitlab are open.
// TODO: Convert prefs booleans to real booleans

// Since v05.30: Bug fixes. Mousemove no longer inits page while prefs window is open. Tampericon changed to remove website img. added filter merges button.
// Since v05.20: Unrestricting inline view to 925px; now allowed to show full browser width. dimApprovedRequests.  Added Pivotal Tracker API integration.  User Popup has link to Teams chat. Probably many other small upgrades.  Semi-beta version, here.
// Since v05.10: Added View Site shortcut to top nav bar. update PT title linker
// Since v05.00: Added pipeline error growl notifications, colorized notes on code reviews that are not associated with line numbers.
// Since v04.99: Changed PT linking. TODO: moved base link definitions to user preference. Added User Favorite Links to header.
// Since v04.98: Fixed the alert-wrapper problem.
// Since v04.97: Moving alert removal to alert-wrapper. May need further investigation
// Since v04.96: Added remove-alert button.  Figure out why some notes are not colored
// Since v04.95: Added bisque color for active tabs; partial.
// Since v04.90: Modified the "Act" button to first click the Overview tab before scrolling to the Approve link.
// Since v04.80: Realized Gitlab's new navigation can lead to moments where the Approve button is nowhere near the top, so split main nav new button into two, "Act", and "Top"
// Since v04.70: Altered "Discussions" button to be "Top" button.  Added a reset-timeout for adding the Changes tab's font-size and collapse/expand buttons.
// Since v04.60: Updated unresolved locator to match Gitlab-changed text
// Since v04.50: Fixed TODO default prompt for new users
// Since v04.40: Added visual indicator of TODO count activity (badge count background color change). Updated to latest tamperlibrary script version.  ?? Some setPrefs extra line / bug line removed. Removes dependency on Tamper Global script.
// Since v04.30: Adjusted todo element identifier to match update from Gitlab, fixing TODO monitor.
// Since v04.20: Removed identicon from breadcrumb trail as without an image it looked weird. Important - TODO bugfix for undefined error!
// Since v04.10: Fixed the hourlies reminders.  Added user option to hide "err" code background color.
// Since v04.00: Resovled intermittent (?) avatar photo failures, allowing back in a XX second timer for reinitialization.  Added ability to reset page memory. If the TODOS count changes and you're on the TODO page, auto-reload.
// Since v03.91: User Preferences to control main functions of plugin! Avatar name tweaks.
// Since v03.90: Added comments to AT SOME POINT figure out why the Collapse All buttons are not working all the time. Added other avatar locations. Made a default broken-image "no avatar" avatar. System stops attempting to replace missing avatars if not found on the local server. TODO monitoring no longer requires a separate page to be open, and automatically updates the indicator on every page.
// Since v03.80: Expanded bot detection to include array created via regex
// Since v03.70: Optimized avatar location methods
// Since v03.60: Button bug fixes. Renamed comparisonSize. Moved default- and small- font sizes to userprefs. Hides the "nothing here block" divs. Scrolls to collapsed div when tall divs are collapsed. All states moved to global.states.  Avatar ping functionality disabled if server isn't running.
// Since v03.50: argh. They change the DOM, breaking the expand/collapse functions. Fixed and in a more flexible manner.
// Since v03.30: Gitlab changed a classname, which broke the Discussions button. Fixed in a more flexible manner.
// Since v03.20: Moving .fingery class to global script. Moving out setTamperIcon
// Since v03.10: Made comparisonSize font alteration more inclusive. Filtering out "Americas" from avatar names. Removes vertical margin from fingery class
// Since v03.00: Set title TFS/PT scan to use case-insensivite checks
// Since v02.20: Links TFS and PT values in title to their respective URLs. Adds Font+/- button for comparison text
// Since v02.10: Since Gitlab removed their Expand All button (?!), this adds it back in...
// Since v02.00: Altered the logic of colorizing discussions to search for a more universal pass-fail value. Changes Discussions buttons from icon to text (and is therefore larger)
// Since v01.81: Removed old coffee cup button code. Added Collapse All Button with diff-collapsing function. Bug tweaks.
// Since v01.80: Some conversations were not being colorized.
// Since v01.75: Removes coffee-cup as it is native functionality, now. Yay Gitlab! Fixes a name-finding method that sometimes forgot to add a comma after spaces, for avatar images.
// Since v01.7: Fixes the click functionality of the tamperlabel, also makes it look clickable
// Since v01.6: Adds ability to hide header messages. Adds ability to erase Gitlab Mods memory by clicking on Tampermonkey icon (on this page, a battery in the lower-right corner)
// Since v01.5: Added a conditional close-footer button, close broadcast message button.  Adds memory states for these buttons (only for messages already seen!) as well as the close-tree (coffee) button. Expands breadcrumb dropdown.
// Since v01.4: Linked the notifications to the TODO page. Removed a log statement. Resets TODO page title when timer runs out if reload is cancelled. Slows down reminder by 1 hour (up to 8 times) because coming back to your computer after an extended period and receiving TONS of notifications is annoying
// Since v01.3: Re-enables the Tampericon
// Since v01.2: Adds "hide tree" button (looks like a coffee cup)
// Since v01.1: Updates document title with countdown timer indication.  Adds hourly reminder of existing TODOs. Removes conversation button from non-conversation pages.
// Since v01.0: Added the countdown indicator to the page title for TODO indication; cancelling reverts the title to original
// Since v00.0: init, copying from GIT Avatars script

/*
 * tm is an object included via @require from DorkForce's Tampermonkey Assist script
 */

var cite, pivotalProject, gitlabPtProject;

(function() {
    'use strict';

    var TIMEOUT = 750,
        activityTimestamp = moment(),
        avatarHost = 'localhost:8080/', // for setPhoto
        pingPhoto = '!none', // pinging for setPhoto
        imageExt = '.png',
        global = {
            ids: {
                scriptName: 'Gitlab Mods',
                prefsName: 'gitlabPrefs',
                memsName: 'gitlabMems', // using this as a system-memory kind of thing.  Like the prefs but the user doesn't see them
                triggerElements: ['.content', '#root'],
                gitlabUserId: null,
                listeners: []
            },
            states: {
                appendClickProcessed: false,
                areClassesAdded: false,
                arePrButtonsAdded: false,
                areFavesAdded: false,
                areInterfaceButtonsAdded: false,
                delays: [],
                todoMonitorInitialized: false,
                titlesLinked: false,
                isMouseMoved: false,
                avatarPingFailed: null,
                arePipelinesChecked: false,
                xhrBusy: false,
                mergeRequestScanned: false
            },
            prefs: {},
            mems: undefined,
            reload: {
                lapsed: 0,
                timespan: 10000,
                title: document.title,
                todoTimer: undefined
            },
            templates: {
                adorable: '<img src="https://api.adorable.io/avatars/285/IMGID.png" alt="IMGALT" title="IMGALT" data-src="https://api.adorable.io/avatars/285/IMGID.png" class="avatar s40">',
                dicebear: '<img src="https://avatars.dicebear.com/api/gridy/IMGID.svg" alt="IMGALT" title="IMGALT" data-src="https://avatars.dicebear.com/api/gridy/IMGID.svg" class="avatar s40">'
            }
        },
        properName = function(thisName) {
            if (!thisName) {
                return;
            }
            var firstName = '',
                lastName = '',
                midName = '';

            thisName = thisName
                .replace('https://gitlab.dell.com/', '')
                .replace(' - Dell Team', '')
                .replace('\'s avatar', '')
                .replace('Assigned to ', '')
                .replace('Avatar for ', '')
                .replace('@', '')
                .replace(/@/g, '')
                .replace(/\//g, '')
                .replace(/_/g, '-');
            firstName = thisName.substring(0, thisName.indexOf('-'));
            lastName = thisName.substring(thisName.indexOf('-')+1, thisName.length);
            if ((firstName.length === 0 && lastName.length === 0)) {
                return;
            }
            if (firstName.length > 0 && lastName.length > 0 && thisName.indexOf(',') < 0) {
                thisName = lastName + ', ' + firstName;
            }
            if (thisName.indexOf('-') > 0) {
                midName = thisName.substring(0, thisName.indexOf('-'));
                thisName = thisName.substring(thisName.indexOf('-')+1, thisName.length);
                thisName = thisName + ' ' + midName;
            }
            while (thisName.indexOf('  ') > 0) {
                thisName = thisName.replace(/\s\s/, ''); // no double spaces
            }
            thisName = thisName
                .replace(/(\r\n\t|\n|\r\t)/gm,'') // no line breaks or tabs
                .replace(/ ,/, ',') // no spaces before commas
                .replace(/\%20/, '') // no %20 characters
                .replace(/Americas/g, '')
                .trim(); // seriously, no extra spaces
            thisName = thisName.replace(',', ', ').replace('  ', ' '); // there's probably a less-stupid way of REALLY making sure it's always "COMMA SPACE"
            return thisName;
        },
        updateImg = function(img, thisName) {
            if (thisName != null) {
                if (thisName.length > 0 && thisName !== ', ') {
                    if ($(img).prop('src').indexOf('!none') <= 0) {
                        if (global.states.avatarPingFailed === false && global.prefs.avatarPreference[0] === 'localhost' ) {
                            $(img).prop('src', 'http://' + avatarHost + thisName + imageExt);
                        } else if (global.prefs.avatarPreference[0] === 'adorable') {
                            $(img).prop('src', 'https://api.adorable.io/avatars/285/' + thisName + '.png');
                        } else {
                            $(img).prop('src', 'https://avatars.dicebear.com/api/gridy/' + thisName + '.svg');
                        }
                    }
                } else {
                    if (global.prefs.debugMode === 'true') tm.log('updateImg: invalid user name for ' + img.src + ': ' + thisName + '(' + thisName.length + ' chars)');
                }
            }
        },
        page = {
            initialize: function () {
                setTimeout(function () {
                    page.setPrefs();
                    page.setMems();
                    tm.setTamperIcon(global);
                    tm.initNotes(global);
                    tm.addClasses();
                    page.addClasses();
                    page.setGitlabUserId();
                    page.pivotal.defineProject();
                    page.pivotal.getMembers();
                    page.pivotal.getMe();
                    page.pivotal.getReviewTypes();
                    page.adjustStyles();
                    page.addElements();
                    page.adjustMarkdown();
                    page.appendClickFunctions();
                    page.expandBreadcrumb();
                    page.setAvatars();
                    page.scanMergeRequest();
                    page.monitorTodos();
                    page.linkWorkItems();
                    page.checkForInactivity();
                    page.dimApprovedRequests();
                    page.addFaves();
                    page.monitorPipelines();
                }, TIMEOUT);
            },
            setPrefs: function() {
                var currentPrefs = GM_getValue(global.ids.prefsName);
                if (currentPrefs == null || _.isEmpty(JSON.parse(currentPrefs))) {
                    global.prefs = {
                        debugMode: 'false',
                        autohideNotification: 'true',
                        minimizeMetaInfo: 'true',
                        useLocalAvatars: 'false',
                        avatarPreference: ['dicebear', 'adorable', 'localhost'],
                        colorHeadline: 'cornflowerblue',
                        colorActiveTab: 'bisque',
                        colorResolved: 'palegreen',
                        colorUnresolved: 'orange',
                        colorNavButton: 'burlywood',
                        colorDim1: 'lightgrey',
                        colorDim2: 'aliceblue',
                        colorNavBackground: 'bisque',
                        useThumbnailImages: 'true',
                        hideCodeErrClass: 'true',
                        pivotalToken: '',
                        pivotalMaps: [
                            {"project_id":"2203130","reference":"koa/ui-core/Themes/Documentation"},
                            {"project_id":"2203130","reference":"dao/dell-digital-design/design-language-system/systems/dls-1.0"},
                            {"project_id":"2448496","reference":"dao/dell-digital-design/design-language-system/systems/dls-2.0"},
                            {"project_id":"2451317","reference":"dao/dell-digital-design/design-language-system/experiment/dls-2.0-alpha"}
                        ],
                        userFaves:
                        '{"name": "Docs", "url": "https://gitlab.dell.com/koa/ui-core/Themes/Documentation/-/merge_requests"}; ' +
                        '{"name": "1.0", "url": "https://gitlab.dell.com/dao/dell-digital-design/design-language-system/systems/dls-1.0/-/merge_requests"}; ' +
                        '{"name": "2.0", "url": "https://gitlab.dell.com/dao/dell-digital-design/design-language-system/systems/dls-2.0/-/merge_requests"}; ' +
//                         '{"name": "YMLint", "url": "https://gitlab.dell.com/dao/dell-digital-design/design-language-system/systems/dls-1.0/-/ci/lint"}; ' +
//                         '{"name": "Alpha", "url": "https://gitlab.dell.com/dao/dell-digital-design/design-language-system/experiment/dls-2.0-alpha/-/merge_requests"}; ' +
                        '{"name": "CMS", "url": "https://gitlab.dell.com/dao/dell-digital-design/design-language-system/support-sys"}'
                    };
                    tm.savePreferences(global.ids.prefsName, global.prefs);
                } else {
                    global.prefs = JSON.parse(currentPrefs);
                    if (global.prefs.pivotalMaps) {
                        try {
                            global.prefs.pivotalMaps = JSON.parse(global.prefs.pivotalMaps);
                        } catch (e) {
                            global.prefs.pivotalMaps = global.prefs.pivotalMaps;
                        }
                    }
                    if (global.prefs.avatarPreference) {
                        try {
                            global.prefs.avatarPreference = JSON.parse(global.prefs.avatarPreference);
                        } catch (e) {
                            global.prefs.avatarPreference = global.prefs.avatarPreference;
                        }
                    }
                }
            },
            setMems: function() {
                var currentMems = GM_getValue(global.ids.memsName);
                if (currentMems == null || _.isEmpty(JSON.parse(currentMems))) {
                    global.mems = {};
                    global.mems.todosCount = 0;
                    global.mems.todosTimestamp = moment();
                    global.mems.manyTabTimestamp = moment();
                    global.mems.reloadTimesReminded = '0';
                    global.mems.reloadReminderCount = '0';
                    global.mems.archivedMessages = [];
                    global.mems.archivedBroadcasts = [];
                    global.mems.archivedAlerts = [];
                    global.mems.currentFontSize = global.defaultLineSize;
                    global.mems.defaultLineSize = '12px';
                    global.mems.smallLineSize = '10px';
                    global.mems.avatarPingTimer = global.mems.avatarPingTimer || undefined;
                    global.mems.pivotalData = {
                        projects: [],
                        timers: []
                    };
                    tm.savePreferences(global.ids.memsName, global.mems);
                } else {
                    global.mems = JSON.parse(currentMems);
                }
                $('.line, .line span').css('font-size', global.mems.currentFontSize);
            },
            setGitlabUserId () {
                tm.getContainer({
                    'el': '.header-user-dropdown-toggle',
                    'max': 100,
                    'spd': 1000
                }).then(function($container){
                    var myHref = document.querySelector('.header-user-dropdown-toggle').href;
                    global.ids.gitlabUserId = myHref.substr(myHref.lastIndexOf('/')+1, myHref.length - myHref.lastIndexOf('/')-1);
                });
            },
            addClasses: function () {
                if (!global.states.areClassesAdded) {
                    global.states.areClassesAdded = true;

                    // generic
                    if (global.prefs.hideCodeErrClass === 'true') tm.addGlobalStyle('.err { background-color: inherit !important; }');
                    tm.addGlobalStyle('.tamperlabel { cursor: pointer; }');
                    tm.addGlobalStyle('.btn-headerly { padding:3px 10px; height:30px; margin-top:5px !important; margin-left:0px !important; background:steelblue; border-color:darkslategray; }');
                    tm.addGlobalStyle('.btn-headerly:hover { background-color:aliceblue; border-color:black; }');

                    // colored backgrounds
                    tm.addGlobalStyle('.merge-request-tabs-container {background:' + global.prefs.colorNavBackground + '; }');
                    tm.addGlobalStyle('.merge-request-tabs {background:' + global.prefs.colorNavButton + '; }');
                    tm.addGlobalStyle('.mr-widget-content {background:' + global.prefs.colorHeadline + '; }');
                    // tm.addGlobalStyle('.active {background:' + global.prefs.colorActiveTab + '; }');

                    // UI-sizing
                    tm.addGlobalStyle('.diff-files-holder.container-limited { max-width: inherit !important; }');
                    tm.addGlobalStyle('.approvals-required-text .avatar {width:24px; height:24px; }');
                    if (global.prefs.minimizeMetaInfo === 'true') {
                        tm.addGlobalStyle('.note-text ul {margin:0px !important; }');
                        tm.addGlobalStyle('.system-note {font-size:0.7em; padding:0; margin:0px !important; }');
                        tm.addGlobalStyle('.timeline-entry:hover {background:aliceblue; }');
                        tm.addGlobalStyle('.note-text .gfm-merge_request {background:aliceblue; padding:10px; float:right; position:relative; top:-23px; margin-bottom:-28px; }');
                    }

                    tm.addGlobalStyle('.tamperNewIcon {position:relative; top:-10px; }');

                    tm.addGlobalStyle('.nothing-here-block {display: none;}');

                    tm.addGlobalStyle('.beepboop { background-color:powderblue !important;}');

                    // Pivotal Tracker-related
                    tm.addGlobalStyle('.ptComment { padding: 0.2rem; margin-bottom: 0.625rem; max-width: 17rem; }');
                    tm.addGlobalStyle('.ptTimestamp { font-size: 0.625rem; margin-left: 3rem; }');
                    tm.addGlobalStyle('.reviewsEl { width:50%; }');
                    tm.addGlobalStyle('.reviewEl { float:right; margin-right:0.125rem; border-radius:1rem; border:2px solid white; padding:.25rem; }');
                    tm.addGlobalStyle('.ptIcon { margin-right:8px; color:white; font-weight:bold; }');
                    tm.addGlobalStyle('.userTeamsLink { height:34px; padding:10px; margin:0; background:beige; border-radius:25px; }');
                    if (global.prefs.pivotalToken.length > 0) {
                        tm.addGlobalStyle('[data-qa-selector="approvals_summary_content"] { max-width:22rem; }');
                    }
                }
            },
            addFaves: function() { // adds shorcut buttons to your favorite projects
                // if areFavesAdded is false
                if (!global.states.areFavesAdded) {
                    global.states.areFavesAdded = true;
                    // userFaves froms from prefs as a string, so convert to an array
                    if (global.prefs.userFaves.length === 0) {
                        return;
                    }
                    var leFaves = global.prefs.userFaves.replace(/\s\s+/g, '').split(';');
                    // for each Fave
                    leFaves.forEach((fave) => {
                        // add a button
                        var jsoned = {};
                        try {
                            jsoned = JSON.parse(fave);
                            fave = jsoned.url;
                        }
                        catch (e) {
                            if (global.prefs.debugMode === 'true') tm.log('SAFE FAIL while parsing a user favorite:\n' + e);
                        }
                        var titleContainer = $('header .title-container');
                        var slashPosition = fave.lastIndexOf('/')+1;
                        var faveName = jsoned.name ? jsoned.name : fave.substr(slashPosition, fave.length-slashPosition);
                        titleContainer.append('<a id="fave' + faveName + '" href="' + fave + '" class="btn btn-default btn-headerly fingery">' + faveName + '</a>');
                    });
                }
            },
            adjustStyles: function() { // adjusts colors of discussions based on whether that discussion is resolved or not
                var lePass = global.prefs.colorResolved,
                    leFail = global.prefs.colorUnresolved,
                    botDivs = [],
                    botDivsRegex = new RegExp("Gitbot|ServiceCOMTeamCity");

                botDivs = $(".discussion-notes .timeline-content").filter(function () {
                    return botDivsRegex.test($(this).text());
                });

                botDivs.each(function(discussion) {
                    if ($(this).closest('.card').find('button:contains("Resolve")').length > 0) {
                        $(this).css('background-color', leFail);
                    } else {
                        if ($(this).text().indexOf('Resolved') > 0) {
                            $(this).css('background-color', lePass);
                        }
                    }
                });

                $('.timeline-entry .discussion-header').each(function(discussion) {
                    //
                    $(this).closest('.timeline-entry :contains("Resolve")').find('.discussion-header').css('background-color', leFail);
                    $(this).closest('.timeline-entry :contains("Resolved")').css('background-color', lePass);
                });

                var intX = 0;
                $('.notes').each(function(discussion) {
                    if (intX > 1 && intX < $('.notes').length-1) {
                        if ($(this).find('.discussion-resolved-text').length > 0) {
                            $(this).find('li').eq(0).css('background-color', lePass);
                        } else {
                            $(this).find('li').eq(0).css('background-color', leFail);
                        }
                    }
                    intX++;
                });

//                 // clean-up.  could be done better, I'm sure
//                 document.querySelectorAll(".timeline-entry").forEach(function(te) {
//                     var thisChild = te.querySelector(".timeline-entry");
//                     if (thisChild) {
//                         $(thisChild).css('background-color', 'inherit');
//                     }
//                 });
            },
            addElements: function() {
                $('button:contains("Expand all")').hide();
                var topScrollTarget = $('.group-path');
                var conversationScrollTarget = $('.js-mr-approvals').length > 0 ? $('.js-mr-approvals') :
                $('button:contains("Approve")').parent().length > 0 ? $('button:contains("Approve")').parent() :
                $('strong:contains("Merge request approved")').parent().parent();
                var idTop = 'idTop',
                    idConversations = 'idConversations',
                    idViewSite = 'idViewSite',
                    conversationsClass = '.header-new',
                    addConversationsButton = function () {
                        var buttonAnchor = $(conversationsClass),
                            topAction = function () {
                                $('html, body').animate({ scrollTop: topScrollTarget.offset().top -100 }, 500);
                                return false;
                            },
                            conversationsAction = function () {
                                var event,
                                    eventType = 'click';
                                if (window.CustomEvent && typeof window.CustomEvent === 'function') {
                                    event = new CustomEvent(eventType, {detail: {some: 'data'}});
                                } else {
                                    event = document.createEvent('CustomEvent');
                                    event.initCustomEvent(eventType, true, true, {some: 'data'});
                                }
                                var el = document.getElementsByClassName('notes-tab')[0].querySelector('a');
                                el.dispatchEvent(event);
                                $('html, body').animate({ scrollTop: conversationScrollTarget.offset().top -100 }, 500);
                            },
                            viewSiteAction = function () {
                                document.querySelector('[data-track-event="open_review_app"]').click();
                                return false;
                            };

                        // Conditionally add View Site Button
                        tm.getContainer({
                            'el': '[aria-label="deploy-reviewapp: passed"]',
                            'max': 100,
                            'spd': 250
                        }).then(function($container){
                            buttonAnchor.before('<a id="' + idViewSite + '" class="btn btn-default btn-headerly fingery">View</a>'); // --- VIEW SITE BUTTON
                            $('#' + idViewSite).click(viewSiteAction);
                        });
                        buttonAnchor.before('<a id="' + idConversations + '" class="btn btn-default btn-headerly fingery">Act</a>'); // --- ACT BUTTON
                        $('#' + idConversations).click(conversationsAction);
                        buttonAnchor.before('<a id="' + idTop + '" class="btn btn-default btn-headerly fingery">Top</a>'); // --- TOP BUTTON
                        $('#' + idTop).click(topAction);
                    },
                    addCollapseExpandButtonToDom = function(bId, bAnchor, bAction, bText) {
                        bAnchor.after('<a id="' + bId + '" class="btn btn-default append-right-8 fingery">' + bText + '</a>');
                        $('#' + bId).unbind('click').click(bAction);
                    },

                    idCollapse = 'idCollapse',
                    collapseClass = '.is-compare-versions-header',
                    addCollapseButton = function() {
                        var addCollapseButtonToDom = function () {
                            addCollapseExpandButtonToDom(idCollapse, buttonAnchor, collapseAction, 'Collapse All');
                        },
                            buttonAnchor = $(collapseClass),
                            collapseAction = function () {
                                _.each($('.diff-content'), (diff) => {
                                    if ($(diff).height() > 0) {
                                        $(diff).closest('.diff-file').find('.file-title').click();
                                    }
                                });
                                $(this).remove();
                                addExpandButton();
                            };
                        addCollapseButtonToDom();
                        $('a:contains("Expand all")').hide();
                    },

                    idExpand = 'idExpand',
                    expandClass = '.is-compare-versions-header',
                    addExpandButton = function() {
                        var addExpandButtonToDom = function () {
                            addCollapseExpandButtonToDom(idExpand, buttonAnchor, expandAction, 'Expand All');
                        },
                            buttonAnchor = $(expandClass),
                            expandAction = function () {
                                _.each($('.diff-collapsed'), (diff) => {
                                    $(diff).closest('.diff-file').find('.file-title').click();
                                });
                                $(this).remove();
                                addCollapseButton();
                            };
                        addExpandButtonToDom();
                    },

                    idComparisonFont = 'idComparisonFont',
                    comparisonFontClass = '.is-compare-versions-header',
                    addComparisonFontButton = function() {
                        var buttonAnchor = $(comparisonFontClass),
                            comparisonFontAction = function () {
                                var buttonText = $('#' + idComparisonFont).text();
                                if (global.mems.currentFontSize === global.mems.defaultLineSize) {
                                    global.mems.currentFontSize = global.mems.smallLineSize;
                                    $('#' + idComparisonFont).text(buttonText.replace('-', '+'));
                                } else {
                                    global.mems.currentFontSize = global.mems.defaultLineSize;
                                    $('#' + idComparisonFont).text(buttonText.replace('+', '-'));
                                    // actual setting of font size done during monitoring of page, so mouse movement can trigger it
                                }
                                tm.savePreferences(global.ids.memsName,global.mems);
                            };
                        buttonAnchor.after('<a id="' + idComparisonFont + '" class="btn btn-default append-right-8 fingery">Font -</a>'); //  Font +- button
                        $('#' + idComparisonFont).unbind('click').click( comparisonFontAction);
                        if (global.mems.currentFontSize !== global.mems.defaultLineSize) { // to fix UI if button was previously saved in alternate setting
                            global.mems.currentFontSize = global.mems.defaultLineSize;
                            comparisonFontAction();
                        }
                    },

                    idHeader = 'idHeader',
                    headerClass = '.header-message',
                    addHeaderButton = function() {
                        var buttonAnchor = $(headerClass),
                            headerAction = function () {
                                var msg = $(headerClass + ' p').text();
                                if (!_.contains(global.mems.archivedMessages, msg)) {
                                    global.mems.archivedMessages.push(msg);
                                    tm.savePreferences(global.ids.memsName,global.mems);
                                }
                                buttonAnchor.hide();
                                // special for header
                                $('.navbar').css('top', '0px');
                                $('.nav-sidebar').css('top', '40px');
                                $('.content-wrapper').css('margin-top', '40px');
                                return false;
                            };
                        buttonAnchor.prepend('<i id="' + idHeader + '" class="fa fa-times outlined fingery"></i>'); // "X" button
                        $('#' + idHeader).click(headerAction);
                        // if a previously-hidden header message is showing again
                        if ($(headerClass).css('display') !== 'none') {
                            _.each(global.mems.archivedMessages, function(msg) {
                                if ($(headerClass + ' p').text() == msg) {
                                    headerAction();
                                }
                            });
                        }
                    },

                    idFooter = 'idFooter',
                    footerClass = '.footer-message',
                    addFooterButton = function() {
                        var buttonAnchor = $(footerClass),
                            footerAction = function () {
                                var msg = $(footerClass + ' p').text();
                                if (!_.contains(global.mems.archivedMessages, msg)) {
                                    global.mems.archivedMessages.push(msg);
                                    tm.savePreferences(global.ids.memsName,global.mems);
                                }
                                buttonAnchor.hide();
                                return false;
                            };
                        buttonAnchor.prepend('<i id="' + idFooter + '" class="fa fa-times outlined fingery"></i>'); // "X" button
                        $('#' + idFooter).click(footerAction);
                        // if a previously-hidden footer message is showing again
                        if ($(footerClass).css('display') !== 'none') {
                            _.each(global.mems.archivedMessages, function(msg) {
                                if ($(footerClass + ' p').text() == msg) {
                                    footerAction();
                                }
                            });
                        }
                    },

                    idBroadcast = 'idBroadcast',
                    broadcastClass = '.broadcast-message',
                    addBroadcastButton = function() {
                        var buttonAnchor = $(broadcastClass),
                            broadcastAction = function () {
                                var msg = $(broadcastClass + ' p').text();
                                if (!_.contains(global.mems.archivedBroadcasts, msg)) {
                                    global.mems.archivedBroadcasts.push(msg);
                                    tm.savePreferences(global.ids.memsName,global.mems);
                                }
                                buttonAnchor.hide();
                                return false;
                            };
                        buttonAnchor.prepend('<i id="' + idBroadcast + '" class="fa fa-times outlined fingery"></i>'); // "X" button
                        $('#' + idBroadcast).click( broadcastAction);
                        // if a previously-hidden  broadcast message is showing again
                        if ($( broadcastClass).css('display') !== 'none') {
                            _.each(global.mems.archivedBroadcasts, function(msg) {
                                if ($( broadcastClass + ' p').text() == msg) {
                                    broadcastAction();
                                }
                            });
                        }
                    },

                    idUserTeams = 'idUserTeams',
                    userTeamsClass = '.user-popover',
                    addUserTeamsButton = function() {
                        var buttonAnchor = $(userTeamsClass);
                        buttonAnchor.append('<i id="' + idUserTeams + '" class="userTeamsLink fa fa-bullhorn outlined fingery" style="font-weight:bold;"></i>'); // "X" button
                        var emailName = document.querySelector(userTeamsClass).querySelector('.text-secondary').innerText.replace('@', '');
                        $('#' + idUserTeams).click( function() {
                            utils.userTeamsAction(emailName);
                        });
                    },

                    idFilterMerges = 'idFilterMerges',
                    filterMergesClass = '.top-area .nav-controls',
                    addFilterMergesButton = function() {
                        var buttonAnchor = $(filterMergesClass);
                        buttonAnchor.prepend('<button id="' + idFilterMerges + '" class="btn append-right-10">Filter Merges</button>'); // FILTER MERGES
                        var filterMergeHref = document.URL + '?scope=all&utf8=%E2%9C%93&state=opened&approver_usernames[]=USERME&wip=no&not[author_username]=USERME&only=me';
                        filterMergeHref = filterMergeHref.replace(/USERME/g, global.ids.gitlabUserId);
                        $('#' + idFilterMerges).click( function() {
                            window.location.href = filterMergeHref;
                        });
                    },

                    idAlert = 'idAlert',
                    alertClass = '.broadcast-banner-message',
                    addAlertButton = function() {
                        var buttonAnchor = $(alertClass),
                            alertAction = function () {
                                var msg = $(alertClass + ' p').text();
                                if (!_.contains(global.mems.archivedAlerts, msg)) {
                                    global.mems.archivedAlerts.push(msg);
                                    tm.savePreferences(global.ids.memsName,global.mems);
                                }
                                buttonAnchor.hide().attr("style", "display: none !important");;
                                return false;
                            };
                        buttonAnchor.prepend('<i id="' + idAlert + '" class="fa fa-times outlined fingery"></i>'); // "X" button
                        $('#' + idAlert).click(alertAction);
                        // if a previously-hidden alert message is showing again
                        if ($( alertClass).css('display') !== 'none') {
                            _.each(global.mems.archivedAlerts, function(msg) {
                                if ($( alertClass + ' p').text() == msg) {
                                    alertAction();
                                }
                            });
                        }
                    },

                    idPivotal = 'idPivotalComments',
                    pivotalAnchor = '.issuable-sidebar',
                    addPivotalButton = function () {
                        var buttonAnchor = $(pivotalAnchor),
                            pivotalAction = function () {
                                $('.participants .sidebar-collapsed-icon').click();
                            };

                        buttonAnchor.append('<div id="' + idPivotal + '" class="block pivotal-reference">' +
                                            '  <div class="sidebar-collapsed-icon dont-change-state">' +
                                            '    <button class="btn btn-clipboard btn-transparent">' +
                                            '      <i aria-hidden="true" data-hidden="true" class="fa fa-comments" style="margin-right:0;"></i>' +
                                            '      <span id="pivotalParticipants"></span>' +
                                            '    </button>' +
                                            '  </div>' +
                                            '</div>'); // --- PIVOTAL BUTTON


                        $('#' + idPivotal).click(pivotalAction);
                    };

                if (document.URL.indexOf('merge_requests') < 0) {
                    $('#' + idConversations).remove();
                    $('#' + idCollapse).remove();
                    $('#' + idExpand).remove();
                    $('#' + idPivotal).remove();
                    $('#' + idFilterMerges).remove();
                    global.states.arePrButtonsAdded = false;
                } else {
                    if (!global.states.arePrButtonsAdded) {
                        global.states.arePrButtonsAdded = true;

                        tm.getContainer({
                            'el': filterMergesClass,
                            'max': 100,
                            'spd': 1000
                        }).then(function($container){
                            addFilterMergesButton();
                        });

                        tm.getContainer({
                            'el': conversationsClass,
                            'max': 100,
                            'spd': 1000
                        }).then(function($container){
                            addConversationsButton();
                        });

                        tm.getContainer({
                            'el': collapseClass,
                            'max': 100,
                            'spd': 1000
                        }).then(function($container){
                            addComparisonFontButton();
                            addCollapseButton();
                        });

                        if (utils.isNumeric(document.URL.substr(document.URL.length-1, 1))) { // we are on a specific merge review, not the index of reviews
                            page.pivotal.getReviews();
                            tm.getContainer({
                                'el': pivotalAnchor,
                                'max': 100,
                                'spd': 1000
                            }).then(function($container){
                                addPivotalButton();
                            });
                        }
                    }

                    // outside of arePrButtonsAdded check for xhr allowance
                    if (utils.isNumeric(document.URL.substr(document.URL.length-1, 1))) { // only process when on a specific merge_request
                        if (!global.states.xhrBusy && !$('.ptComment').length > 0) {
                            tm.getContainer({
                                'el': pivotalAnchor,
                                'max': 100,
                                'spd': 1000
                            }).then(function($container){
                                page.pivotal.getComments();
                            });
                        }
                    }
                }


                // Constantly poll for user profile popup
                setTimeout(function() {
                    if (document.querySelector(userTeamsClass) != null && document.querySelector('.userTeamsLink') == null) {
                        addUserTeamsButton();
                    }
                }, TIMEOUT*2);

                if (!global.states.areInterfaceButtonsAdded) {
                    global.states.areInterfaceButtonsAdded = true;
                    tm.getContainer({
                        'el': headerClass,
                        'max': 100,
                        'spd': 1000
                    }).then(function($container){
                        addHeaderButton();
                    });

                    tm.getContainer({
                        'el': footerClass,
                        'max': 100,
                        'spd': 1000
                    }).then(function($container){
                        addFooterButton();
                    });

                    tm.getContainer({
                        'el': broadcastClass,
                        'max': 100,
                        'spd': 1000
                    }).then(function($container){
                        addBroadcastButton();
                    });

                    tm.getContainer({
                        'el': alertClass,
                        'max': 100,
                        'spd': 1000
                    }).then(function($container){
                        addAlertButton();
                    });
                }

            },
            adjustMarkdown: function () {
                if (global.prefs.useThumbnailImages !== 'true') {
                    return;
                }
                if ($('.mdImg').length > 0) {
                    return;
                }
                tm.addGlobalStyle('.md img { max-width:25% !important; }');
                _.each($('.md img'), (mdImg) => {
                    $(mdImg).parent().addClass('mdImg');
                });
                $('.mdImg').click(function(e) {
                    e.preventDefault();
                    var modalBody = '<img src="' + e.target.src + '" style="width:100%" onclick="$(this).parent().remove()" />' +
                        '<style>.popupDetailTitle, .tamperModalClose { display:none; } .popupDetailWindow { width:95%; height:inherit; max-height:90%; }</style>';
                    tm.showModal('imgCloseup', modalBody);
                });
            },
            appendClickFunctions: function () {
                if (document.URL.indexOf('merge_requests') > 0) {
                    var approveSelector = '[data-qa-selector="approve_button"]';
//                     if (!document.querySelector(approveSelector)) {
//                         $('.merge-request-tabs-container').append('<button data-qa-selector="approve_button" class="fakey">FAKEY</button>');
//                     }
                    tm.getContainer({
                        'el': approveSelector,
                        'max': 100,
                        'spd': 1000
                    }).then(function($container){
                        var approveButton = document.querySelector(approveSelector);
                        if (!approveButton.classList.contains('tm-approveButton')) { // only do this once
                            approveButton.classList.add('tm-approveButton');
                            approveButton.addEventListener('click', function approveAppendActions() {
                                if (approveButton && !approveButton.classList.contains('btn-inverted')) { // YET TO APPROVE OR RE-APPROVE *on gitlab* // FAKEY REVERT !approveButton
                                    // approveButton.removeEventListener('click', approveAppendActions);
                                    if (document.querySelector('[data-reviewer_id="' + pivotalProject.me.id + '"]')) { // already exists on PT; don't add another
                                        return;
                                    }
                                    var review_type_id;
                                    if (global.mems.review_type_id) {
                                        page.pivotal.postReview(global.mems.review_type_id);
                                    } else {
                                        var modalBody = '<div style="text-align:center; padding:2rem;"><p>Copying this to Pivotal Tracker.  Is this a CODE or DESIGN review?</p>' +
                                            '<p><button id="confirmCode" class="confirmReviewType" value="Code">CODE</button>&nbsp;&nbsp;&nbsp;' +
                                            '<button id="confirmDesign" class="confirmReviewType" value="Design Review">DESIGN</button></p>' +
                                            '<p><input id="checkRememberReviewType" type="checkbox">Remember my choice</input></p>' +
                                            '</div>' +
                                            '<style>.popupDetailTitle, .tamperModalClose { display:none; } .popupDetailWindow { width:95%; min-height:85% height:inherit; max-height:95%; }</style>';
                                        tm.showModal('confirmReviewType', modalBody);
                                        _.each(document.querySelectorAll('.confirmReviewType'), (confirmTypeButton) => {
                                            utils.listenOnce(confirmTypeButton, 'click', (e) => {
                                                review_type_id = JSON.parse(pivotalProject.review_types).review_types.find((x) => x.name === e.target.value).id;
                                                if ($('#checkRememberReviewType').checked) {
                                                    global.mems.review_type_id = review_type_id;
                                                    tm.savePreferences(global.ids.memsName, global.mems);
                                                }
                                                $('#confirmReviewType').remove();
                                                page.pivotal.postReview(review_type_id);
                                            });
                                        });
                                    }
                                } else { // HAS BEEN APPROVED
                                    // approveButton.removeEventListener('click', unapproveAppendActions);
                                    // approveButton.classList.remove('tm-approveButton');
                                    var review_id = parseInt(document.querySelector('[data-reviewer_id="' + pivotalProject.me.id + '"]').getAttribute('data-review_id'));
                                    $('[data-reviewer_id="' + pivotalProject.me.id + '"]').remove();
                                    page.pivotal.deleteReview(review_id);
                                }
                            });
                        }
                    });
                }

                // APPEND ONLY ONCE:
                if (global.states.appendClickProcessed) {
                    return;
                }
                global.states.appendClickProcessed = true;

                // TODO: THINK ABOUT THIS.
//                 function appendDiffsClick() {
//                     var resetMergeRequestScan = function() {
//                         global.states.mergeRequestScanned = false;
//                     };
//                     $('[data-action="diffs"]').on('click', resetMergeRequestScan);
//                 }
//                 tm.getContainer({
//                     'el': '[data-action="diffs"]',
//                     'max': 100,
//                     'spd': 1000
//                 }).then(function($container){
//                     appendDiffsClick();
//                 });

                function appendFileTitleClicks() {
                    var scrollAfterCollapse = function() {
                        if ($(this).offset().top < $(window).scrollTop() + 125) {
                            $('html, body').animate({ scrollTop: $(this).offset().top-160 }, 1000);
                        }
                    };
                    $('.file-title').on('click', scrollAfterCollapse);
                };
                tm.getContainer({
                    'el': '.file-title',
                    'max': 100,
                    'spd': 1000
                }).then(function($container){
                    appendFileTitleClicks();
                });

            },
            dimApprovedRequests: function () {
                if (!document.URL.indexOf('/merge_requests/') > 0 || utils.isNumeric(document.URL.substr(document.URL.length-1, 1))) {
                    return;
                }
                _.each($('use[*|href*="approval-solid"]'), (svg) => {
                    if (document.URL.indexOf('only=me') > 0) {
                        $(svg).closest('.merge-request').hide();
                    } else {
                        $(svg).closest('.merge-request').css('background-color', global.prefs.colorDim1);
                    }
                });
                _.each($('a[data-qa-selector="assignee_link"]'), (assignee) => {
                    if (assignee.href.indexOf(global.ids.gitlabUserId) > -1) {
                        $(assignee).closest('.merge-request').css('background-color', global.prefs.colorDim2);
                    }
                });
            },
            expandBreadcrumb: function () {
                $('.breadcrumbs .dropdown li').prependTo('.breadcrumbs-list .dropdown');
                $('.breadcrumbs-list .identicon').hide();
                $('.breadcrumbs-list .dropdown button').hide();
            },
            pivotal: {
                defineProject: function () {
                    if (pivotalProject) {
                        return;
                    }
                    if (document.querySelector('cite')) {
                        cite = document.querySelector('cite').innerText.replace(/\n|\r/g, '');
                        cite = cite.substr(cite, cite.indexOf('!'))
                        gitlabPtProject = global.prefs.pivotalMaps.find((x) => x.reference === cite);
                    }
                    if (document.querySelector('cite') && gitlabPtProject != null) {
                        if (global.mems.pivotalData.projects.length > 0) {
                            pivotalProject = global.mems.pivotalData.projects.find((x) => x.project_id === parseInt(gitlabPtProject.project_id));
                            if (global.prefs.debugMode === 'true') {
                                tm.log('SETTING PIVOTALPROJECT OBJECT');
                                console.dir(pivotalProject);
                            }
                        } else {
                            if (global.prefs.debugMode === 'true') tm.log('setPivotalInfo FAILURE: pivotalData.projects is empty!');
                            global.mems.pivotalData.timeUpdated = null;
                            page.pivotal.getMembers();
                        }
                    } else if (global.prefs.debugMode === 'true') {
                        tm.log('setPivotalInfo failed:\n' +
                               '   global.mems.pivotalData.projects.length = ' + global.mems.pivotalData.projects.length + '\n' +
                               '   cite = ' + cite + '\n' +
                               '   gitlabPtProject = ' + gitlabPtProject);
                    }
                },
                deleteReview: function(review_id) {
                    var iAm = 'deleteReview';
                    var whenDataReceived = function (resp) {
                        // does not call on a delete
                    };
                    utils.xhrAction(iAm, 'DELETE', 'stories/PIVOTALSTORYID/reviews/' + review_id, whenDataReceived);
                },
                getMe: function() {
                    var iAm = 'getMe';
                    if (!utils.pivotalHoursElapsed(iAm, 24)) {
                        return;
                    }
                    var whenDataReceived = function (resp) {
                        resp = JSON.parse(resp);
                        pivotalProject.me = global.mems.pivotalData.projects.find(x => x.project_id === pivotalProject.project_id).me = resp;
                    };
                    var finallyDoThis = function() {
                        tm.savePreferences(global.ids.memsName, global.mems);
                    };
                    utils.delayUntil(iAm, () => {return pivotalProject != null;}, () => {
                        utils.xhrAction(iAm, 'GET', 'me', whenDataReceived, finallyDoThis);
                    });
                },
                getMembers: function () {
                    var iAm = 'getMembers';
                    if (!utils.pivotalHoursElapsed(iAm, 2)) {
                        return;
                    }
                    var whenDataReceived = function (resp) {
                        resp = JSON.parse(resp).slice().reverse();

                        if (!global.mems.pivotalData.projects.find(x => x.project_id === resp[0].project_id)) {
                            global.mems.pivotalData.projects.push(
                                {
                                    'project_id': resp[0].project_id,
                                    'members': resp
                                }
                            );
                        } else {
                            global.mems.pivotalData.projects.find(x => x.project_id === resp[0].project_id).members = resp;
                        }
                    };
                    var finallyDoThis = function() {
                        tm.savePreferences(global.ids.memsName, global.mems);
                    };
                    utils.xhrAction(iAm, 'GET', 'memberships', whenDataReceived, finallyDoThis);
                },
                getComments: function () {
                    if (pivotalProject == null || pivotalProject.members == null) {
                        if (global.prefs.debugMode === 'true') tm.log('getComments failed; pivotalProject must be defined.');
                        return;
                    }
                    var whenDataReceived = function (resp) {
                        var comTitle = document.createElement('div');
                        comTitle.innerHTML = '<h5 class="hide-collapsed ptComment">Pivotal Tracker Comments</h5>';
                        document.querySelector('.issuable-sidebar').appendChild(comTitle);

                        resp = JSON.parse(resp).slice().reverse();
                        var commentTotal = 0;
                        _.each(resp, (comment) => {
                            commentTotal++;
                            var person = pivotalProject.members.find((x) => x.person.id === comment.person_id).person;
                            var comDiv = document.createElement('div');
                            comDiv.className = 'ptComment hide-collapsed';
                            comDiv.innerHTML = '<div class="ptTimestamp"><a style="text-decoration:none;" href="javascript:void(0);" title="' + moment(comment.updated_at).format('MMMM Do YYYY, h:mm:ss a') + '">' + moment(comment.updated_at).fromNow() + '</a></div>' +
                                global.prefs.avatarPreference[0] === 'adorable' ?
                                global.templates.adorable.replace(/IMGID/g, comment.person_id).replace(/IMGALT/g, person.name) :
                                global.templates.dicebear.replace(/IMGID/g, comment.person_id).replace(/IMGALT/g, person.name) +
                                '<b>' + person.name + '</b> says:<br />' +
                                comment.text;
                            document.querySelector('.issuable-sidebar').appendChild(comDiv);
                        });
                        if (commentTotal > 0) {
                            document.getElementById('pivotalParticipants').innerText = commentTotal;
                        }
                    };
                    utils.xhrAction('getComments', 'GET', 'stories/PIVOTALSTORYID/comments', whenDataReceived);
                },
                getReviews: function () {
                    var iAm = 'getReviews';
                    if ($('.reviewsEl').length > 0) {
                        return;
                    }
                    var whenDataReceived = function (resp) {
                        var reviewsEl = '<div class="reviewsEl">',
                            reviewEl = '';
                        resp = JSON.parse(resp);
                        _.each(resp, (review) => {
                            reviewEl += '<div class="reviewEl" data-review_id="' + review.id + '">';
                            if (review.reviewer_id) {
                                reviewEl = reviewEl.replace('class="', 'data-reviewer_id="' + review.reviewer_id + '" class="');
                                var person = pivotalProject.members.find((x) => x.person.id === review.reviewer_id).person;
                                var imgAlt = person ? person.name : review.reviewer_id;
                                var thisTemplate = global.prefs.avatarPreference[0] === 'adorable' ? global.templates.adorable : global.templates.dicebear;
                                reviewEl += thisTemplate.replace(/IMGID/g, review.reviewer_id).replace(/IMGALT/g, imgAlt).replace('s40', 's20');
                            }
                            var reviewTypeName = JSON.parse(pivotalProject.review_types).review_types.find((x) => x.id === review.review_type_id).name;
                            switch (reviewTypeName) {
                                case 'Code':
                                    reviewEl += '<i title="' + reviewTypeName + '" class="ptIcon fa fa-code"></i>';
                                    break;
                                case 'Design Review':
                                    reviewEl += '<i title="' + reviewTypeName + '" class="ptIcon fa fa-crop"></i>';
                                    break;
                            }
                            if (review.status === 'pass') {
                                reviewEl += '<i title="PASS" class="ptIcon fa fa-thumbs-up pt' + reviewTypeName.replace(/ /g, '') + '" style="color:' + global.prefs.colorResolved + '" data-review_type_id="' + review.review_type_id + '"></i>';
                            } else if (review.status === 'fail') {
                                reviewEl += '<i title="FAIL" class="ptIcon fa fa-thumbs-down pt' + reviewTypeName.replace(/ /g, '') + '" style="color:' + global.prefs.colorUnresolved + '" data-review_type_id="' + review.review_type_id + '"></i>';
                            } else {
                                reviewEl += '<i title="UNSTARTED" class="ptIcon fa fa-hourglass-start pt' + reviewTypeName.replace(/ /g, '') + ' ptUnstarted"></i>';
                            }
                            reviewEl += '</div>';
                            reviewsEl += reviewEl;
                            reviewEl = '';
                        });
                        reviewsEl += '</div>';
                        //                             $('.detail-page-description .description').prepend(reviewsEl);
                        $('.js-mr-approvals').append(reviewsEl);
                    };
                    utils.delayUntil(iAm, () => {return (pivotalProject != null && pivotalProject.project_id != null && pivotalProject.review_types != null);}, () => {
                        utils.xhrAction(iAm, 'GET', 'stories/PIVOTALSTORYID/reviews', whenDataReceived);
                    });
                },
                getReviewTypes: function () {
                    var iAm = 'getReviewTypes';
                    if (!utils.pivotalHoursElapsed(iAm, 1) || gitlabPtProject == null) {
                        return;
                    }
                    var whenDataReceived = function (resp) {
                        global.mems.pivotalData.projects.find(x => x.project_id === parseInt(gitlabPtProject.project_id)).review_types = resp;
                        pivotalProject.review_types = resp;
                    };
                    var finallyDoThis = function() {
                        tm.savePreferences(global.ids.memsName, global.mems);
                    };
                    utils.delayUntil(iAm, () => {return pivotalProject != null;}, () => {
                        utils.xhrAction(iAm, 'GET', '?fields=review_types', whenDataReceived, finallyDoThis);
                    });
                },
                postReview: function (review_type_id) {
                    var iAm = 'postReview';
                    var whenDataReceived = function (resp) {
                        resp = JSON.parse(resp);
                        // remove an unstarted review of this type, since we just added one.
                        var reviewTypeName = JSON.parse(pivotalProject.review_types).review_types.find((x) => x.id === resp.review_type_id).name;
                        if ($('.pt' + reviewTypeName + '.ptUnstarted').length > 0) {
                            var removeId = $('.pt' + reviewTypeName + '.ptUnstarted').parent().attr('data-review_id');
                            page.pivotal.deleteReview(removeId);
                            setTimeout(() => {
                                $('.reviewsEl').remove();
                                page.pivotal.getReviews();
                            }, TIMEOUT);
//                             var approveButton = document.querySelector(approveSelector);
//                             approveButton.classList.remove('tm-approveButton');
                        } else {
                            $('.reviewsEl').remove();
                            page.pivotal.getReviews();
                        }
                    };
                    utils.xhrAction(iAm, 'POST', 'stories/PIVOTALSTORYID/reviews?review_type_id=' + review_type_id + '&reviewer_id=' + pivotalProject.me.id + '&status=Pass', whenDataReceived);
                }
            },
            scanMergeRequest() {
                if (global.states.mergeRequestScanned) {
                    return;
                }
                global.states.mergeRequestScanned = true;
                if (document.URL.indexOf('merge_requests/') > -1) { // TODO: update other check for if we're viewing a specific MR
                    document.querySelectorAll('.line').forEach((line) => {
                        if (line.innerText.indexOf('console.log') > -1) {
                            $.growl.error({'message': 'console.log found'});
                        };
                    });
                }
            },
            setAvatars: function () {
                if (!(global.prefs.useLocalAvatars === 'true')) {
                    return;
                }

                if (!global.states.avatarPingFailed && pingPhoto != null && global.prefs.avatarPreference[0] === 'localhost') {
                    tm.ping(avatarHost + pingPhoto + imageExt, function callback (response) {
                        if (response === 'responded') {
                            global.states.avatarPingFailed = false;
                        } else {
                            global.states.avatarPingFailed = true;
                        }
                        global.mems.avatarPingTimer = moment();
                        tm.savePreferences(global.ids.memsName, global.mems);
                    });
                }

                var avatarArray = [],
                    thisName = 'none';

                // gitlab
                var avatars = [
                    {
                        element: '.header-user-avatar', // main header (user) image
                        getImgSource: null,
                        getNameSource: function(el) { return $('.user-name').text(); }
                    },{
                        element: '.btn-link .author', // right side-bar (collapsed) author
                        getImgSource: function(el) { return $(el).parent().find('img'); },
                        getNameSource: function(el) { return $(el).next().text(); }
                    },{
                        element: '.participants-author img', // right side-bar, list of participants
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).next().find('div').text(); }
                    },{
                        element: '.issuable-meta .author-link img', // Opened X hours ago by...
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).next().text(); }
                    },{
                        element: '.author-link.inline img', // Merged by...
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).parent().prop('title'); }
                    },{
                        element: '.user-avatar-link img', // Approvers
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).prop('alt'); }
                    },{
                        element: '.header-user-dropdown-global img', // ????????
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).parent().prop('href'); }
                    },{
                        element: '.avatar-cell img', // ????????
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).prop('title'); }
                    },{
                        element: '.avatar-holder img', // ????????
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).parent().parent().next().find('.cover-title').text(); }
                    },{
                        element: '.list-item-name img', // ????????
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).next().find('.member').text(); }
                    },{
                        element: '.user-popover img', // ????????
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).parent().parent().next().find('h5').text(); }
                    },{
                        element: '.system-note-image img', // ????????
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).parent().prop('href'); }
                    },{
                        element: '.assignee .author-link img', // right side-bar, assigned-to
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).next().text(); }
                    },{
                        element: '.js-pipeline-url-user img', // Pipelines tab on merge request
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).next().find('div').text(); }
                    },{
                        element: '.issuable-meta .author-link img', // project's Merge Request page list
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).parent().prop('title'); }
                    },{
                        element: '.todo-avatar img', // TODO page avatars
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).prop('alt').replace('\'s avatar', ''); }
                    },{
                        element: '.search-token-assignee img', // Merge Request list page
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).parent().text(); }
                    },{
                        element: '.ptComment img', // Merge Request page Pivotal Tracker Comments
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).prop('alt'); }
                    },{
                        element: '.sidebar-collapsed-icon .avatar', // Merge Request page sidebar collapsed assigned-to
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).prop('alt').replace("'s avatar", ''); }
                    },{
                        element: '.hide-collapsed .gl-link .avatar-inline:not(.js-lazy-loaded)', // Merge Request page sidebar expanded assigned-to
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).prop('alt').replace("'s avatar", ''); }
                    },{
                        element: '.author-link img.js-lazy-loaded', // Merge Request page sidebar expanded participants
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).parent().parent().prop('href'); }
                    },{
                        element: '.reviewEl img', // Merge Request page sidebar expanded participants
                        getImgSource: null,
                        getNameSource: function(el) { return $(el).prop('alt'); }
                    }
                ];

                avatars.forEach(avatar => {
                    tm.getContainer({
                        'el': avatar.element
                    }).then(function($container){
                        var iter = $(avatar.element).is('img') ? $(avatar.element) : avatar.getImgSource(avatar.element);
                        _.each(iter, function (img) {
                            thisName = properName(avatar.getNameSource(img));
                            updateImg(img, thisName);
                        });
                    });
                });

                $('img').each(function() {
                    var img = new Image(),
                        self = this;

                    img.onerror = function(){
                        $(self).prop('src', 'http://' + avatarHost + '!none' + imageExt);
                    }

                    img.src = this.src;
                });

            },
            monitorPipelines: function () {
                if (document.URL.indexOf('/jobs/') < 0 || $('.js-line span').length === 0 || global.states.arePipelinesChecked) {
                    return;
                }
                global.states.arePipelinesChecked = true;
                _.each($('.js-line span'), (jsLine) => {
                    var lineContent = jsLine.innerHTML,
                        offset = $(jsLine).offset();
                    if (lineContent.toLowerCase().indexOf('error') > -1 ) {
                        jsLine.innerHTML = '<span style="background:gray;">' + jsLine.innerHTML + '</span>'
                        var lineMessage = lineContent.replace(/error/gi, '<b><a style="color:black;" href="javascript:void(0)" onclick="window.scrollTo({top: ' + offset.top + ', behavior: \'smooth\'});">ERROR</a></b>');
                        if (lineContent.indexOf('ERROR') > -1 ) {
                            $.growl.error({
                                message: lineMessage,
                                size: 'medium',
                                delayOnHover: true,
                                duration: 9600 // 3200 is default
                            });
                        } else {
                            $.growl.warning({
                                message: lineMessage,
                                size: 'medium',
                                duration: 5000 // 3200 is default
                            });
                        }
                    }
                });
            },
            monitorTodos: function () {
                if ($('#timerStats').length === 0) {
                    $('body').append('<span id="timerStats" style="display:none;"></span>');
                }
                if ($('#timerHiddenCount').length === 0) {
                    $('body').append('<span id="timerHiddenCount" style="display:none;"></span>');
                }
                var todoElement = '[aria-label="To-Do List"] .badge';
                var indicateSecondsTimer,
                    todosText = $(todoElement).eq(0).text().replace(/(\r\n\t|\n|\r\t)/gm,''),
                    todosCount = todosText == null || todosText.length === 0 ? 0 : Number(todosText),
                    notificationIcon = 'https://gitlab.dell.com/assets/favicon-7901bd695fb93edb07975966062049829afb56cf11511236e61bcf425070e36e.png',
                    indicateSeconds = function() {
                        var secondsRemaining = (Math.floor(global.reload.timespan/1000) - global.reload.lapsed);
                        $('#timerStats').text(todosCount + ' : (' + secondsRemaining + ')');
                        global.reload.lapsed++;
                        if (secondsRemaining > 0) {
                            indicateSecondsTimer = setTimeout(indicateSeconds, 1000);
                        } else {
                            $('#timerStats').text(global.reload.title);
                        }
                    },
                    triggerNotification = function(notifyMessage) {
                        var notification = new Notification(notifyMessage, {
                            icon: notificationIcon,
                            requireInteraction: todosCount === 0 ? false : !(global.prefs.autohideNotification === 'true'),
                            body: todosCount
                        });
                        notification.onclick = function () {
                            window.open('https://gitlab.dell.com/dashboard/todos');
                        };
                        if (document.URL.indexOf('todos') > -1) {
                            window.location.reload(false);
                        }
                    }
                if (Notification.permission === 'default') {
                    Notification.requestPermission();
                } else {
                    if (!global.states.todoMonitorInitialized) {
                        global.states.todoMonitorInitialized = true;
                        global.reload.lapsed = 0;

                        var duration = moment.duration(moment().diff(global.mems.todosTimestamp)),
                            hours = duration.asHours(),
                            diffSeconds = duration.asSeconds();

                        if (Number(todosCount) > 0 && hours > 1) {
                            if (global.prefs.debugMode === 'true') console.dir({
                                '1reason': 'Been a while. Incrementing reloadTimesReminded',
                                '2mems': global.mems,
                                '3savedMems': utils.savedMems()
                            });
                            global.mems.todosTimestamp = moment();
                            global.mems.reloadTimesReminded = Number(global.mems.reloadTimesReminded) + 1;
                            if (global.mems.reloadTimesReminded > Number(utils.savedMems().reloadTimesReminded)) {
                                tm.savePreferences(global.ids.memsName, global.mems);
                                global.mems = utils.savedMems();
                            }
                        }

                        var currentMems = utils.savedMems();
                        if (todosCount !== global.mems.todosCount) {
                            // trigger notification on TODO count change
                            if (currentMems != null && !_.isEmpty(currentMems)) {
                                global.mems.manyTabTimestamp = currentMems.manyTabTimestamp;
                            }
                            duration =  moment.duration(moment().diff(global.mems.manyTabTimestamp));
                            var seconds = Math.round(duration.asSeconds())
                            if (seconds > 30) { // to prevent multiple GitLab tabs all opening a notification each
                                if (global.prefs.debugMode === 'true') tm.log('TODOs / TODOs in memory: ' + todosCount + ':' + global.mems.todosCount);
                                global.mems.manyTabTimestamp = moment();
                                tm.savePreferences(global.ids.memsName,global.mems);
                                triggerNotification('TODOs on GitLab:');
                            }
                        } else if (Number(todosCount) > 0 && hours > 1 && Number(currentMems.reloadTimesReminded) > Number(currentMems.reloadReminderCount)) {
                            if (global.prefs.debugMode === 'true') tm.log('Time to remind user about existing TODOs...');
                            // trigger notification on hourly timeout if there ARE any
                            global.mems.reloadTimesReminded = 0;
                            global.mems.reloadReminderCount = Number(global.mems.reloadReminderCount) + 1;
                            triggerNotification('Reminder- GitLab TODOs:');
                            global.mems.todosTimestamp = moment();
                            if (Number(global.mems.reloadReminderCount) > 8) {
                                global.mems.reloadReminderCount = 0;
                                global.mems.reloadTimesReminded = 0;
                            }
                            tm.savePreferences(global.ids.memsName,global.mems);
                        }

                        // update stored count
                        if(todosCount !== global.mems.todosCount) {
                            global.mems.todosCount = todosCount;
                            global.mems.reloadReminderCount = 0;
                            global.mems.reloadTimesReminded = 0;
                            tm.savePreferences(global.ids.memsName,global.mems);
                        }

                        // Reload in X seconds
                        global.reload.todoTimer = setTimeout(function() {
                            // "reload" the page
                            $('#timerHiddenCount').load( "https://gitlab.dell.com/dashboard/todos .todos-pending .badge", function(responseTxt, statusTxt, xhr){
                                if(statusTxt === 'success') {
                                    // External content loaded successfully
                                    if ($(todoElement).length > 0) {
                                        $(todoElement).text($('#timerHiddenCount').text()).removeClass('hidden');
                                        $(todoElement).addClass('beepboop');
                                        setTimeout(() => {
                                            $(todoElement).removeClass('beepboop');
                                        }, 250);
                                    }
                                    timeoutElement();
                                    global.states.todoMonitorInitialized = false;
                                    setTimeout(page.monitorTodos, TIMEOUT);
                                }
                                if(statusTxt === 'error')
                                    if (global.prefs.debugMode === 'true') tm.log('Error: ' + xhr.status + ': ' + xhr.statusText);
                            });
                        }, global.reload.timespan);

                        var buttonAnchor = $('.page-title');
                        var buttonCancelReload = 'Cancel-Reload';
                        var timeoutElement = function () {
                            clearTimeout(global.reload.todoTimer);
                            clearTimeout(indicateSecondsTimer);
                            $('#' + buttonCancelReload).css('display', 'none');
                            $('#timerStats').text(global.reload.title);
                            return false;
                        };
                        //buttonAnchor.after('<button id="' + buttonCancelReload + '" style="margin-left:50px; border-radius:15px; border:0px; background:lightgoldenrodyellow; padding:5px 15px; ">' + buttonCancelReload + '</button>');
                        //$('#' + buttonCancelReload).click(timeoutElement);

                        var buttonNotifyMe = 'Notify-Me';
                        var notifyMe = function() {
                            if (Notification.permission !== "granted") {
                                Notification.requestPermission();
                            } else {
                                var notification = new Notification('Permission Granted', {
                                    icon: notificationIcon,
                                    body: "Notifications have been allowed.",
                                });
                            }
                            $('#' + buttonNotifyMe).css('display', 'none');
                            return false;
                        }

                        if (Notification.permission !== 'granted') {
                            buttonAnchor.after('<button id="' + buttonNotifyMe + '" style="margin-left:50px; border-radius:15px; border:0px; background:lightgrey; padding:5px 15px; ">' + buttonNotifyMe + '</button>');
                            $('#' + buttonNotifyMe).click(notifyMe);
                        }

                        setTimeout(indicateSeconds, 1000);

                    }
                }
            },
            linkWorkItems: function () {
                if (!global.states.titlesLinked) {
                    global.states.titlesLinked = true;
                    var linkText, linkHref,
                        titleText = $('h2.title').text();
                    if (titleText.toUpperCase().indexOf('PT#') > -1) {
                        linkText = titleText.match(/PT#[0-9]*/gi).toString().split(',');
                        linkText.forEach(thisLink => {
                            if (utils.isNumeric(thisLink.substr(thisLink.length-1, 1))) {
                                linkHref = '<a href="https://www.pivotaltracker.com/story/show/' + thisLink.replace(/PT\#/gi, '') + '" target="blank">' + thisLink + '</a>';
                                titleText = titleText.replace(thisLink, linkHref);
                            }
                        });
                    }
                    if (titleText.toUpperCase().indexOf('TFS#') > -1) {
                        linkText = titleText.match(/TFS#[0-9]*/gi).toString().split(',');
                        linkText.forEach(thisLink => {
                            if (utils.isNumeric(thisLink.substr(thisLink.length-1, 1))) {
                                linkHref = '<a href="http://tfs2.dell.com:8080/tfs/eDell/eDellPrograms/_workitems?id=' + thisLink.replace(/TFS\#/gi, '') + '" target="blank">' + thisLink + '</a>';
                                titleText = titleText.replace(thisLink, linkHref);
                            }
                        });
                    }
                    $('h2.title').html(titleText);
                }
            },
            checkForInactivity: function () {
                let duration = moment.duration(moment().diff(global.activityTimestamp));
                let seconds = Math.round(duration.asSeconds())
                if (seconds > 300) {
                    global.states.arePrButtonsAdded = false;
                }
            }
        },
        utils = {
            delayUntil: function (id, condition, callback) {
                if (!global.states.delays.find((x) => x.delayId === id)) {
                    global.states.delays.push({
                        delayId: id,
                        delayCount: 0
                    });
                }
                global.states.delays.find((x) => x.delayId === id).delayCount++;
                if (global.states.delays.find((x) => x.delayId === id).delayCount > 10) {
                    global.states.delays.find((x) => x.delayId === id).delayCount === 0;
                    return;
                }
                try {
                    if (!condition()) {
                        if (global.prefs.debugMode === 'true') tm.log('delay WAIT called by ' + id);
                        setTimeout(() => {
                            utils.delayUntil(id, condition, callback);
                        }, TIMEOUT);
                    } else {
                        callback();
                    }
                } catch (e) {
                    if (global.prefs.debugMode === 'true') tm.log('delay WAIT called by ' + id + ' TRIED\n   ' + e);
                    setTimeout(() => {
                        utils.delayUntil(id, condition, callback);
                    }, TIMEOUT);
                }
            },
            getPivotalStoryId: function () {
                if (!document.querySelector('a[href^="https://www.pivotal"]')) {
                    return;
                }
                var pivotalStoryId = document.querySelector('a[href^="https://www.pivotal"]').href; // TODO querySelectorAll for multiple-linked PT Stories
                var indexOfId = pivotalStoryId.lastIndexOf('/') + 1;
                pivotalStoryId = pivotalStoryId.substr(indexOfId, pivotalStoryId.length - indexOfId);
                return pivotalStoryId;
            },
            isNumeric: function(n) {
                return !isNaN(parseFloat(n)) && isFinite(n);
            },
            initScript: function () {
                _.each(global.ids.triggerElements, (trigger) => {
                    tm.getContainer({
                        'el': trigger,
                        'max': 100,
                        'spd': 1000
                    }).then(function($container){
                        page.initialize();
                    });
                });
            },
            listenOnce: function(element, event, handler) {
                element.addEventListener(
                    event,
                    function tempHandler(e) {
                        handler(e);
                        element.removeEventListener(event, tempHandler, false);
                    },
                    false
                );
            },
            pivotalHoursElapsed: function(timerId, howMany) {
                if (!global.mems.pivotalData.timers) {
                    tm.log('Reset your app memory. global.mems.pivotalData.timers must be defined.');
                    return false;
                }
                if (!global.mems.pivotalData.timers.find((x) => x.id === timerId)) {
                    global.mems.pivotalData.timers.push({
                        id: timerId,
                        expires: moment().subtract(48, 'hours')
                    });
                }
                var duration = moment.duration(moment().diff(global.mems.pivotalData.timers.find((x) => x.id === timerId).expires)),
                    hours = duration.asHours();
                if (hours >= howMany) {
                    global.mems.pivotalData.timers.find((x) => x.id === timerId).expires = moment();
                    return true;
                } else {
                    return false;
                }
            },
            savedMems: function() {
                return JSON.parse(GM_getValue(global.ids.memsName));
            },
            xhrAction: function(iAm, xhrType, urlPath, callback, alwaysCallback) {
                if (global.prefs.pivotalToken.length === 0) {
                    return;
                }
                if (!iAm || !xhrType || !urlPath || !callback) {
                    if (global.prefs.debugMode === 'true') tm.log('improper xhr setup');
                    return;
                }
                if (global.prefs.debugMode === 'true') tm.log('xhrAction: ' + iAm);
                if (urlPath.indexOf('PIVOTALSTORYID') > 0) {
                    var pivotalStoryId = utils.getPivotalStoryId();
                    if (pivotalStoryId) {
                        urlPath = urlPath.replace('PIVOTALSTORYID', pivotalStoryId);
                    } else if (global.prefs.debugMode === 'true') {
                        if (global.prefs.debugMode === 'true') tm.log(iAm + ' FAILED: Pivotal Story ID could not be determined.');
                        return;
                    }
                }
                // Set up our HTTP request
                var xhr = new XMLHttpRequest();

                // Setup our listener to process completed requests
                xhr.onload = function () {
                    global.states.xhrBusy = false;

                    // Process our return data
                    if (xhr.status >= 200 && xhr.status < 300) {
                        // What do when the request is successful

                        var resp = xhr.response;
                        if (resp) {
                            // resp = JSON.parse(resp).slice().reverse();
                            if (global.prefs.debugMode === 'true') console.dir({iAm, resp});
                            callback(resp);
                        }
                    } else {
                        // What do when the request fails
                        tm.log('XHR Call for ' + iAm + ' failed!');
                    }
                    if (alwaysCallback) {
                        alwaysCallback();
                    }
                };

                if (gitlabPtProject != null) {
                    var xhrUrl = 'https://www.pivotaltracker.com/services/v5/';
                    if (urlPath === 'me') {
                        xhrUrl += urlPath;
                    } else {
                        xhrUrl += 'projects/' + parseInt(gitlabPtProject.project_id) + '/' + urlPath;
                    }
                    global.states.xhrBusy = true;
                    xhr.open(xhrType,  xhrUrl);
                    xhr.setRequestHeader('X-TrackerToken', global.prefs.pivotalToken);
                    xhr.send();
                } else if (global.prefs.debugMode === 'true') {
                    tm.log('Pivotal Settings Not Populated');
                }
            },
            userTeamsAction: function (emailName) {
                // go to teams
                var teamsLink = 'https://teams.microsoft.com/l/chat/0/0?users=';
                emailName = emailName + '@dell.com';
                window.open(teamsLink + emailName);
            }
        };

    (function() { // Global Functions
        if (global.prefs.debugMode === 'true') tm.log('Global initialization of ' + global.ids.scriptName);
        utils.initScript();
        document.addEventListener('mousemove', function handleMouseEvent () {
            if (!global.states.isMouseMoved) {
                global.states.isMouseMoved = true;
                setTimeout(function() {
                    global.states.isMouseMoved = false;
                }, TIMEOUT * 2);
                if (document.getElementById('GitlabModsOptions') == null) { // don't re-init the script when a popup is open
                    utils.initScript();
                }
            }
            //             if (global.mems != null & global.states.avatarPingFailed) { // reset ping timer every 15 seconds, I guess
            //                 var duration = moment.duration(moment().diff(global.mems.avatarPingTimer));
            //                 if (global.mems.avatarPingTimer != null && Math.round(duration.asSeconds()) > 15) {
            //                     global.states.avatarPingFailed = false;
            //                     global.mems.avatarPingTimer = null;
            //                     tm.savePreferences(global.memsName, global.mems);
            //                 }
            //             }
        });
    })(); // Global Functions

})();