OutOfMilk.com Shopping List Enhancements

Collection of HTML/CSS enhancements for various bugs and/or shortcomings of the Shopping Lists page (/ShoppingList.aspx)

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name          OutOfMilk.com Shopping List Enhancements
// @version       0.2.9
// @description   Collection of HTML/CSS enhancements for various bugs and/or shortcomings of the Shopping Lists page (/ShoppingList.aspx)
// @namespace     https://greasyfork.org/en/users/15562
// @author        Jonathan Brochu (https://greasyfork.org/en/users/15562)
// @license       GPLv3 or later (http://www.gnu.org/licenses/gpl-3.0.en.html)
// @match         https://outofmilk.com/shoppinglist.aspx*
// @grant         GM_addStyle
// ==/UserScript==

/***
 * History:
 *
 * 0.2.9  Change made:
 *        - Removed legacy URLs, including unsecured ones.
 *        - Updated matched URL to "https://outofmilk.com/shoppinglist.aspx"
 *        - Within the mySaveProductHistory() fix, defining $.growlUIStatic()
 *          in case it isn't found when scoped back into unsecure content
 *          (but mostly, so JSLint can shut up about it not being defined).
 *        - Also in mySaveProductHistory(), just as with myAddShoppingItem(),
 *          now using $.growlUIStatic() for showing dialogs just as by the
 *          original code being replaced.
 *        (2022-04-21)
 * unrel  Changes made:
 *        - Commented out fix implemented through shoppingeditpopup_fix1(), as
 *          the related issue seems to have been fixed on the target page.
 *        - Fixed proper linking to "icon-taxfree.png" images used by templates
 *          by retrieving the relative path directly from them.
 *        - Before injecting re-implemented handlers & fixing functions, now
 *          making sure they exist.
 *        (2020-07-23)
 * 0.2.8  Change made:
 *        - For method mySaveProductHistory(), fixed how an item's properties
 *          are parsed, namely where the wrong jQuery function was being used
 *          to evaluate its value (e.g. {}.html() where it should have been
 *          {}.text()) and for integer/float values left as strings (where
 *          parseInt()/parseFloat() should have been used).
 *        (2020-04-27)
 * 0.2.7  Changes made:
 *        - Modified part where we reference the outside window.console object
 *          so we don't redefine the log() function of window.console with an
 *          empty function but instead define a local-scope console object.
 *        - Now using @match instead of @include in the metadata block.
 *        - For iconTaxFreeURLs_fix(), not using eval() anymore.
 *        - Made code cleanups here and there.
 *        (2019-10-09)
 * 0.2.6  Changes made:
 *        - Improved declarations of tool methods injectCode(), injectFuncCode()
 *          and replaceUnsafeFunc() to denote optional arguments supported for
 *          each. Also, reworked their code a bit.
 *        - For method replaceUnsafeFunc(), added support for two new optional
 *          arguments: the first one to specify the parent object whose function
 *          must be replaced, when it's not in the global scope (i.e. window);
 *          and the second to specify a delay before which the function
 *          replacement should be done, i.e. for objects or functions created
 *          (or exposed) through deferred scripts.
 *        - For methods injectCode() and injectFuncCode(), added as new optional
 *          argument the possibility to specify an execution delay, just like
 *          for replaceUnsafeFunc().
 *        - Added the possibility to specify a UPC when adding a new item to the
 *          shopping list. To do so, we add a new UPC field to the "New Item"
 *          form and then replace that form's handling method with a version
 *          supporting the new field.
 *          Unfortunately, for the time being it seems like the web service
 *          ignores any value used as the UPC (though to begin with the spec was
 *          already there to show how it should be passed when making the call).
 *        (2018-08-06)
 * 0.2.5  Changes made:
 *        - Tweaked the UPC parsing code a bit.
 *        - In the future, this script should be modified to allow users to
 *          provide their own link templates for looking up products by their
 *          stored UPC codes.
 *        (2016-08-09) *not publicly released
 * 0.2.4  Change made:
 *        - Completed the UPC parsing code, including expanding UPC-E barcodes,
 *          as part of the product links building added in version 0.2.3.
 *        (2016-06-21) *not publicly released
 * 0.2.3  Change made:
 *        - On the "Product History Management" dialog, added a column for
 *          links to the corresponding product on a (personally-used) grocery
 *          store website (IGA.net).
 *        (2016-06-02) *not publicly released
 * 0.2.2  Change made:
 *        - For the fix around the "Tax Free" checkbox of the "Edit Product
 *          History" dialog, a jQuery function call was failing so we're now
 *          using its DOM native equivalent.
 *        (2016-05-06)
 * 0.2.1  Changes made:
 *        - Fixed the always-unchecked "Tax Free" checkbox for the dialog
 *          "Edit Product History".
 *        - Updated element id for the "Description" field of the dialog
 *          "Edit Product History".
 *        (2016-01-24)
 * 0.2.0  Changes made:
 *        - Updated the "producthistory-template" template to match column
 *          names recently added by outofmilk.com, while at the same time
 *          disabling the CSS code previously used to display custom column
 *          headers.
 *        - Updated the "producthistory-template" template so that, like the
 *          original one, "Yes" & "No" are used as values for the "Tax Free?"
 *          column instead of "true" & "false".
 *        - Fixed the non-working "Tax Free" checkbox and empty "How Much?"
 *          dropdown list for dialog "shoppingeditpopup".
 *        - Fixed the URL used for the "icon-taxfree.png" image whenever
 *          adding or editing an item that is tax free.
 *        (2015-09-17)
 * 0.1.7  Changes made:
 *        - Updated script for use with the repository [greasyfork.org].
 *        - No change made to the code.
 *        (2015-09-14)
 * 0.1.6  Changes made:
 *        - Kept being annoyed that everytime I added/changed a UPC from the
 *          product history it wouldn't update, so now I re-implement method
 *          saveProductHistory() (from "ProductManagement.js?v=...") with the
 *          added tweaks (after all, I'm the one adding the column). Also,
 *          updated the producthistory template with a new class for that
 *          column.
 *          NOTE: I'll have to watch out for any updates/changes to the site
 *                as I replace the whole function, since obviously I cannot
 *                just patch the existing code. Oh, wait...
 *          NODO: I know they say "eval() is evil()", but what if I'd say the
 *                words "toString()", "String.replace()" and "eval()" in that
 *                particular order... OK, I'll leave that hanging in the air.
 *        - Took the opportunity to fix the call that sets the initial width
 *          of the "Product History Management" dialog, which was failing with
 *          errors of the sort "Permission denied to access property".
 *        (2015-06-30)
 * 0.1.5  Change made:
 *        - Added outofmilk.com as a possible domain for include URLs.
 *        (2015-04-02)
 * 0.1.4  Changes made:
 *        - Changed how the initial width of the "Product History Management"
 *          dialog is set.
 *        - Implemented changes to add a "UPC" column to the product history
 *          table (by changing its jQuery UI dialog template; this is possible
 *          since the web service's "GetAllProductHistoryItems" method already
 *          returns the stored UPC value for each history item).
 *        - Removed keep-alive code since no longer necessary.
 *        (2013-08-19)
 * 0.1.3  Changes made:
 *        - Removed "!important" when setting the (initial) width property of
 *          the "Product History Management" dialog (since the specified width
 *          isn't meant to be permanent).
 *        (2013-04-05)
 * 0.1.2  Changes made:
 *        - Added javascript code to keep the session alive (without the
 *          need to refresh the page).
 *        (2013-04-04)
 * 0.1.1  Changes made:
 *        - Added column names for dialog "Product History Management".
 *        - Changed text alignment for (newly-named) column "Tax Exempt".
 *        (2013-04-04)
 * 0.1.0  First implementation. (2013-04-02)
 *
 */

(function() {
    'use strict';

    // constants
    var USERSCRIPT_NAME = 'OutOfMilk.com Shopping List Enhancements';

/*
 * The Payload
 */

    // css definitions
    var css_fixes =
            '@namespace url(http://www.w3.org/1999/xhtml);\n' +
        // Changes & Overrides
            // background overlays for modal dialog with fixed postion
            '.ui-widget-overlay { position: fixed /* original: absolute */ !important ; }\n' +
            // "Product History Management" dialog - increase initial width
            //  '-> now done through javascript
            // //'div[aria-describedby="manageproducthistoryform"] { width: 80% /* original: 600px */ ; }\n' +
            // "Product History Management" dialog - take full parent's width for table within dialog
            'table.producthistory-table { width: 100% /* original: 550px */ !important ; }\n' +
            // "Product History Management" dialog - column headers
            // 2015-09-17: Column headers not needed anymore (done through the template itself)
            ///'table#producthistorytable.table-default > tr:nth-child(1) > th:nth-child(1) > strong:before { ' +
            ///        'content: "Item" /* original: (none specified) */ !important ; }\n' +
            ///'table#producthistorytable.table-default > tr:nth-child(1) > th:nth-child(3) > strong:before { ' +
            ///        'content: "Tax Exempt" /* original: (none specified) */ !important ; }\n' +
            ///'table#producthistorytable.table-default > tr:nth-child(1) > th:nth-child(4) > strong:before { ' +
            ///        'content: "Category" /* original: (none specified) */ !important ; }\n' +
            ///'table#producthistorytable.table-default > tr:nth-child(1) > th:nth-child(5) > strong:before { ' +
            ///        'content: "UPC" /* original: (none specified) */ !important ; }\n' +
            'table#producthistorytable.table-default > tr:nth-child(1) > th:nth-child(6):before { ' +
            ///        'content: "Actions" /* original: (none specified) */ !important ; ' +
                    'text-align: center /* original: left (through inheritance) */ !important ; ' +
                '}\n' +
            'table#producthistorytable.table-default > tr:nth-child(1) > th:nth-child(5) { ' +
                    'text-align: center /* original: left (through inheritance) */ !important ; ' +
                '}\n' +
            // "Product History Management" dialog - values for column "Tax Exempt" centered horizontally
            'td.producthistorytaxfree { text-align: center /* original: left (through inheritance) */ !important ; }\n' +
            // "Edit Product History" dialog - wider "Description" field
            '#txtEditProductHistoryDescription { ' +
                    'width: 380px /* original: (none specified) */ !important ; }\n' +
        // <END>
            '';

    // new "producthistory-template" template
    var templateProductHistory = function() {
/**HEREDOC
    <script type="producthistory-template">
        <##
        if (!String.prototype.reverse) {
            String.prototype.reverse = function() {
                return this.split('').reverse().join('');
            };
        }
        var eanCheckDigit = function(s) {
            var result = 0;
            var rs = s.reverse();
            for (counter = 0; counter < rs.length; counter++) {
                result = result + parseInt(rs.charAt(counter)) * Math.pow(3, ((counter+1) % 2));
            }
            return (10 - (result % 10)) % 10;
        };
        ##>
        <# if(this.dataobjects.length > 0) { #>
            <tr>
                <th><strong>Description</strong></th>
                <th><strong>Price</strong></th>
                <th><strong>Tax Free?</strong></th>
                <th><strong>Category</strong></th>
                <th><strong>UPC</strong></th>
                <th><strong>Links</strong></th>
                <th colspan="3">Actions</th>
            </tr>
            <# $.each(this.dataobjects, function(i, object) { #>
            <tr>
                <td class="producthistoryid hidden"><#= object.ID #></td>
                <td>
                    <span class="producthistorydescription"><#= trimDescription(object.Description,60,"<acronym title=\"" + object.Description + "\">...</acronym>") #></span>
                </td>
                <td class="producthistoryprice">
                    <span><#= FormatNumberCurrency(object.Price) #></span>
                </td>
                <td class="producthistorytaxfree">
                    <span>
                        <# if (object.TaxFree) { #>
                        Yes
                        <# } else { #>
                        No
                        <# } #>
                    </span>
                </td>
                <td class="producthistorycategory">
                    <span><#= object.CategoryName #></span>
                </td>
                <td class="producthistoryupc">
                    <span><#= object.UPC #></span>
                    <##
                    object.UPC = String(object.UPC);
                    if (object.UPC.length >= 12) {
                        object.UPC12 = object.UPC.substr(-12,12);
                        if (object.UPC12.substr(0,2) === '00') {
                            object.UPC12 = object.UPC12.substr(1,11);
                            object.UPC12 += eanCheckDigit(object.UPC12);
                        }
                    } else if (object.UPC.length === 6 && object.UPC.substr(0,1) === "F") {
                        object.UPC12 = '000000' + object.UPC.substr(1,5);
                        object.UPC12 += eanCheckDigit(object.UPC12);
                    } else if (object.UPC.length === 8) {
                        object.UPC12 = object.UPC.charAt(0);
                        switch (object.UPC.charAt(6)) {
                            case '0':
                            case '1':
                            case '2':
                                object.UPC12 += object.UPC.substr(1, 2) + object.UPC.substr(6, 1) + '0000' + object.UPC.substr(3, 3) + object.UPC.substr(7, 1);
                                break;
                            case '3':
                                object.UPC12 += object.UPC.substr(1, 3) + '00000' + object.UPC.substr(4, 2) + object.UPC.substr(7, 1);
                                break;
                            case '4':
                                object.UPC12 += object.UPC.substr(1, 4) + '00000' + object.UPC.substr(5, 1) + object.UPC.substr(7, 1);
                                break;
                            case '5':
                            case '6':
                            case '7':
                            case '8':
                            case '9':
                                object.UPC12 += object.UPC.substr(1, 5) + '0000' + object.UPC.substr(6, 1) + object.UPC.substr(7, 1);
                                break;
                            default: object.UPC12 = '';
                        }
                    } else {
                        object.UPC12 = '';
                    }
                    ##>
                </td>
                <td class="producthistorylink">
                    <span><# if (object.UPC12.length > 0) { #>
                        <a target="_blank" href="https://www.iga.net/en/search?k=00<#= object.UPC12.substr(0, 11) #>"><strong>IGA</strong></a>
                    <# } else { #>
                        &nbsp;
                    <# } #></span>
                </td>
                <td>
                    <a href="javascript:void(0);" class="btn-default addproducthistory"><span>Add To List</span></a>
                </td>
                <td>
                    <a href="javascript:void(0);" class="btn-default editproducthistory"><span>Edit</span></a>
                </td>
                <td class="last-column">
                    <a href="javascript:void(0);" class="btn-default deleteproducthistory"><span>Delete</span></a>
                </td>
            </tr>
            <# }); #>
        <# } else { #>
            <tr>
                <td colspan="5">There are no items to display</td>
            </tr>
        <# } #>
    </script>
HEREDOC**/
    };

    var validateProductHistoryForm = validateProductHistoryForm || window.validateProductHistoryForm || function() {},
        $ = $ || window.$ || function() {},
        FormatNumberCurrency = FormatNumberCurrency || window.FormatNumberCurrency || function() {},
        ShoppingEngine = ShoppingEngine || window.ShoppingEngine || { validateShoppingItemForm: function() {} },
        usedAutoComplete = usedAutoComplete || window.usedAutoComplete || false,
        ListID = ListID || window.ListID || {},
        gettext = gettext || window.gettext || {},
        globalRes = globalRes || window.globalRes || { TaxFree: false },
        STATIC_URL = STATIC_URL || window.STATIC_URL || "",
        FormatNumber = FormatNumber || window.FormatNumber || function() {},
        ListEngine = ListEngine || window.ListEngine || { bindListItemsEdit: function() {}, bindContextMenus: function() {} },
        sortLists = sortLists || window.sortLists || function() {},
        Apprise = Apprise || window.Apprise || function() {},
        jQuery = jQuery || window.jQuery || function() {};

    // new implementation of saveProductHistory()
    var mySaveProductHistory = function($this) {
        // new implementation:
        if(validateProductHistoryForm()){
            var ID = parseInt($(".editproducthistoryid").text(), 10);
            var description = $(".editproducthistorydescription").val();
            var price = parseFloat($(".editproducthistoryprice").val());
            var taxfree = $(".editproducthistorytaxfree input").is(":checked");
            var upc = $(".editproducthistoryupc").val();

            var Params = { "ID": ID, "description": description, "price": price, "upc": upc, "taxfree": taxfree };
            var jQueryParams = JSON.stringify(Params);

            $.growlUIStatic = (typeof $.growlUIStatic === 'function')
                ? $.growlUIStatic
                : function(title, message, onClose) {
                    var $m = $('<div class="growlUI"></div>');
                    if (title) $m.append('<h1>'+title+'</h1>');
                    if (message) $m.append('<h2>'+message+'</h2>');
                    $.blockUI({
                        message: $m, fadeIn: 700, fadeOut: 1000, centerY: false,
                        timeout: 0, showOverlay: false,
                        onUnblock: onClose,
                        css: $.blockUI.defaults.growlCSS
                    });
                };
            $.growlUIStatic(gettext('Please wait...'), gettext('Saving changes to product history item...'));

            $.ajax({
                type: "POST",
                url: "Services/GenericService.asmx/UpdateProductHistoryItem",
                data: jQueryParams,
                contentType: "application/json; charset=utf-8",
                dataType: "json",
                success: function (msg) {
                    $.unblockUI();
                    if(msg.d === false){
                        $(".producthistoryitemvalidation").html(gettext("An item already exists with this description and price!"));
                    } else {
                        var $element = $(".producthistoryid:contains("+ID+")").parents("tr");
                        $element.find(".producthistorydescription").html(description);
                        $element.find(".producthistoryprice").html(FormatNumberCurrency(price));
        /* added --> */ $element.find(".producthistoryupc").html(upc);
                        if(taxfree) {
                            $element.find(".producthistorytaxfree").html("Yes");
                        } else {
                            $element.find(".producthistorytaxfree").html("No");
                        }
                        $.growlUI(gettext('Success!'), gettext('Changes to product history item saved'));
                        $this.dialog("close");
                    }
                },
                failure: function (data) {
                    $.unblockUI();
                    $.growlUI(gettext('Warning'), gettext('Deleting product history item failed!'));
                }
            });
        }
    };
    // of course, I could also do something like:
    /*
        eval('var mySaveProductHistory = ' +
            unsafeWindow.saveProductHistory.toString().replace(/(html\(FormatNumberCurrency\(price\)\);)/, '$1\n$$element.find(".producthistoryupc").html(upc);')
        );
    */
    // but, would blindly patching code be actually better than
    //   replacing a whole function? Yeah, I thought so.

    // 2018-08-06: new implementation of ShoppingEngine.addShoppingItem() to add a UPC field
    // NOTE: Not my code; I only added not even half a line
    var myAddShoppingItem = function() {
        // new implementation:
        if (ShoppingEngine.validateShoppingItemForm($(".shoppinglistitem"), false) != false) {
            var $element = $(".shoppinglistitem");

            var description = $element.find(".itemdescription").val();
            var quantity = $element.find(".itemquantity").val();
            var unit = $element.find(".itemunit").val();
            var unittext = $element.find(".itemunit option:selected").text();
            var price = $element.find(".itemprice").val();
            var note = $element.find(".itemnote").val();
            var taxfree = $element.find(".taxfree input").is(":checked");
            var category = $element.find(".itemcategory").val();
            // 2018-08-06: Added a UPC field
            var UPC = $element.find(".itemupc").val() || '';

            if (usedAutoComplete == undefined) { usedAutoComplete = false; }

            var Params = { "ID": ListID, "usedAutoComplete": usedAutoComplete, "description": description, "quantity": quantity, "unit": unit, "price": price, "note": note, "taxfree": taxfree, "upc": UPC, "category": category, "promoproviderpromotionid": "", "promoproviderstaticid": 0, "useMinOrdinal": false, "isEmailPromo": false };
            var jQueryParams = JSON.stringify(Params);

            $.growlUI(gettext('Please Wait...'), gettext('Adding your item to the list!'));

            $.ajax({
                type: "POST",
                url: "Services/GenericService.asmx/AddShoppingItem",
                data: jQueryParams,
                contentType: "application/json; charset=utf-8",
                dataType: "json",
                success: function (msg) {
                    var Data = JSON.parse(msg.d);

                    var $template = $(".itemtemplate").clone();

                    var flagsText = "";
                    if (taxfree || note.length > 0) {
                        flagsText = "<div class='additionalitems clearfix'>";
                        if (taxfree) {
                            flagsText = flagsText + "&nbsp; <acronym title='" + globalRes.TaxFree + "'><img src='"+ STATIC_URL +"images/icon-taxfree.png' /></acronym>";
                        }
                        if (note.length > 0) {
                            flagsText = flagsText + "&nbsp; <acronym title=\"" + note.replace(/\"/g, '\'') + "\"><img src='"+ STATIC_URL +"images/icon-note.png' /></acronym>";
                        }
                        flagsText = flagsText + "</div>";
                    }

                    $template.find(".cell-title").html(flagsText).text(description);
                    $template.find(".cell-qty").html(FormatNumber(quantity) + " " + unittext);
                    $template.find(".cell-price").html(FormatNumberCurrency(Data.Price));
                    $template.find(".productid").html(Data.ID);
                    $template.find(".itemguid").html(Data.GUID);
                    $template.find(".createddate").html(Data.Created);
                    $template.removeClass("hidden");
                    $template.removeClass("itemtemplate");
                    $template.attr("id", "item_" + Data.ID);

                    //Find all categories on the list and then insert before if we have a category, and insert after the beginning if we don't.

                    //if we have a category but it's not actually on the list, that means it was a newly created category list from the add item
                    //so we will check for that and do a full list refresh if that's the case
                    var foundCategory = false;
                    if (Data.Category) {
                        $(".category").each(function () {
                            var catID = $(this).attr("id").split("cat_")[1];
                            if (Data.Category == catID) {
                                $template.insertAfter($(this));
                                foundCategory = true;
                            }
                        })
                    } else {
                        //if there was no category set this to true so that we dont have a full refresh
                        foundCategory = true;
                        $template.insertAfter("#table_totals");
                    }

                    $("#sortable tbody").sortable('refresh', { items: 'tr:not(.tabletop):not(.itemtemplate)' });
                    ListEngine.bindListItemsEdit();
                    ShoppingEngine.updateShoppingListPrice();
                    ListEngine.bindContextMenus();

                    sortLists(true);

                    usedAutoComplete = false;

                    if (!foundCategory) {
                        $("#btn-refresh-list").click();
                    }

                    //Bind the click event for toggling an items status
                    ListEngine.toggleItemStatus();
                },
                error: function (msg) {
                    Apprise(gettext("Failed to insert item. Please try again.") + "<br /><br />" + gettext("If this problem persists, please contact us at") + " <a href='https://outofmilk.zendesk.com/hc/en-us/requests/new'>Support Request</a>", { 'confirm': true });
                }
            });

            return true;
        } else {
            return false;
        }
    };

    // 2015-09-17: fix for the "Tax Free" checkbox not working in the "shoppingeditpopup" dialog
/*
    var shoppingeditpopup_fix1 = function() {
        // find the "Tax Free" checkbox and its <div> container
        var $element = $(".shoppingeditpopup"),
            taxfree = $element.find("input#checkbox1"),
            taxfreeParentDiv = taxfree.parent();
        // add the missing "taxfree" class, such that in method
        // ShoppingEngine.updateShoppingItem() the line:
        // ---
        // var taxfree = $element.find(".taxfree input").is(":checked");
        // ---
        // works properly
        taxfreeParentDiv.addClass('taxfree');
    };
*/
    // 2015-09-17: fix for the empty <select> element in the "shoppingeditpopup" dialog
    var shoppingeditpopup_fix2 = function() {
        // find the "How Much?" <select> element for units (removing any children in the process)
        var $element = $(".shoppingeditpopup"),
            editUnits = $element.find("select#drpUnitsEdit").find("option").remove().end(),
            itemUnitOptions = $(".shoppinglistitem").find("select.itemunit").find("option");
        // copy the options for the "shoppinglistitem" dialog
        $.each(itemUnitOptions, function() {
            editUnits.append($("<option />").val($(this).val()).text($(this).text()));
        });
    };

    // 2015-09-17: methods ShoppingEngine.addShoppingItem() & ShoppingEngine.updateShoppingItem()
    //             don't use proper links for "icon-taxfree.png"; fix that
    var iconTaxFreeURLs_fix = function() {
        // 2020-07-23: now retrieving link to "icon-taxfree.png" directly from template
        var templateSrc = $("script[type='product-template']"),
            toReplaceSrc = "src='../static/images/icon-taxfree.png'",
            taxfreeIconRE = /<# *if\(object\.TaxFree\) *\{ *#>\s+((?:.(?!<#))+)\s*<# *\} *#>/m,
            taxFreeIconResult = taxfreeIconRE.exec(templateSrc.html()),
            taxFreeIconHTML = (typeof taxFreeIconResult[1] !== 'undefined' ? taxFreeIconResult[1] : ''),
            taxFreeIconSrc = '';
        if (taxFreeIconHTML) {
            try {
                taxFreeIconSrc = /src="([^"]+\/icon-taxfree.png)"/.exec(taxFreeIconHTML)[1];
                console.log(taxFreeIconSrc);
            } catch(e) { /* ignore */ }
        }
        $.each(['updateShoppingItem'], function() {
            (new Function("this.ShoppingEngine." + this + " = " +
                          ShoppingEngine[this].toString().replace(
                              toReplaceSrc,
                              (taxFreeIconSrc ? "src='" + taxFreeIconSrc + "'" : "src='\"+ STATIC_URL +\"images/icon-taxfree.png'")
                          ))
            ).call(window);
        });
    };

    // 2016-01-24: fix for the missing "producthistorytaxfree" class in the "editproducthistoryform" dialog
    var editproducthistory_fix1 = function() {
        // find the "Tax Free" <input type="checkbox"> element and its <td> parent
        var taxfree = $("#txtEditProductHistorytaxfree"),
            taxfreeParentTD = taxfree.parent();
        // add the missing "producthistorytaxfree" class, so that the lines
        //   $(".editproducthistorytaxfree input").attr("checked", true);
        //   $(".editproducthistorytaxfree input").attr("checked", false);
        // work as intended
        taxfreeParentTD.addClass('editproducthistorytaxfree');
    };

    // 2016-01-24: fix for the "Tax Free" checkbox being always unchecked in the "editproducthistoryform" dialog
    var editproducthistory_fix2 = function() {
        // hook the "Manage Product History" button/link at right
        $(".manageproducts").on("click", function() {
            $(".editproducthistory").off();
            $(".editproducthistory").on("click", function() {
                var $parent = $(this).parents("tr");
                var ID = $parent.find(".producthistoryid").html();
                var description = $parent.find(".producthistorydescription").html();
                var price = $parent.find(".producthistoryprice").html();
                var Params = { "ID": ID };
                var jQueryParams = JSON.stringify(Params);
                $.growlUI('<img src="' + STATIC_URL + 'images/monster-help.png" />Please Wait...', 'Loading Product History item...');
                $.ajax({
                    type: "POST",
                    url: "Services/GenericService.asmx/GetProductHistoryItem",
                    data: jQueryParams,
                    contentType: "application/json; charset=utf-8",
                    dataType: "json",
                    success: function (msg) {
                        $(".editproducthistorydescription").val(msg.d.Description);
                        $(".editproducthistoryprice").val(FormatNumber(msg.d.Price));
                        $(".editproducthistoryid").html(msg.d.ID);
                        $(".editproducthistoryupc").val(msg.d.UPC);
                        $(".editproducthistorytaxfree input").removeAttribute("checked");
                        if (msg.d.TaxFree) {
                            $(".editproducthistorytaxfree input").checked = true;
                        } else {
                            $(".editproducthistorytaxfree input").checked = false;
                        }
                        $("#editproducthistoryform").find('input').keypress(function (e) {
                            if ((e.which && e.which == 13) || (e.keyCode && e.keyCode == 13)) {
                                $(".ui-dialog[aria-labelledby='ui-dialog-title-editproducthistoryform']").find('.ui-dialog-buttonpane').find('button:first').click();
                                return false;
                            }
                        });
                        $("#editproducthistoryform").dialog("open");
                    },
                    failure: function (msg) {
                    }
                });
            });
        });
    };

    // 2018-08-06: fix for adding a UPC field to the "New Item" form
    var listadditemform_fix = function() {
        // get form
        var $element = jQuery('.shoppinglistitem').first();
        // make sure we don't already have the UPC field present
        if ($element.length > 0 && $element.find('.itemupc').length == 0) {
            var itemPriceElem = $element.find('.itemprice').first();
            if (!itemPriceElem) { return; }
            itemPriceElem.parent().after('<strong><span>UPC (if any)</span><input type="text" value="" class="textbox itemupc"></strong>');
        }
    };

/*
 * The Tools
 */

    // heredoc parser
    var getHeredoc = function(container, identifier) {
        // **WARNING**: Inputs not filtered (e.g. types, illegal chars within regex, etc.)
        var re = new RegExp("/\\*\\*" + identifier + "[\\n\\r]+[\\s\\S]*?[\\n\\r]+" + identifier + "\\*\\*/", "m");
        var str = container.toString();
        str = re.exec(str).toString();
        str = str.replace(new RegExp("/\\*\\*" + identifier + "[\\n\\r]+",'m'),'').toString();
        return str.replace(new RegExp("[\\n\\r]+" +identifier + "\\*\\*/",'m'),'').toString();
    };

    // template substitution
    var replaceDialogTemplate = function(templateName, newContent) {
        var scripts = document.getElementsByTagName('script');
        if (scripts.length > 0) {
            for (var i = 0; i < scripts.length; i++) {
                if (scripts[i].getAttribute('type') == templateName) {
                    var newText = newContent.toString();
                    // remove comments
                    newText = newText.replace(/\/\*(?:\r\n|\r|\n|.)+?\*\//gm, '').replace(/\/\/.+$/g, '');
                    // remove empty lines
                    newText = newText.replace(/$\s*\r\n/g, '');
                    // make sure switch() blocks have their first case statement on the same line
                    newText = newText.replace(/switch\s*\([^\{]+\)(\s*)\{(\s*)case/gm, function(match, p1, p2) {
                        return match.replace(p1, ' ').replace(p2, ' ');
                    });
                    // process custom <## [..] ##> blocks
                    newText = newText.replace(/\<##\s*((?:\r\n|\r|\n|.)+?)\s*##\>/gm, function(match, p) {
                            return p.split(/\r\n|\r|\n/g).map(function(item, idx) {
                                return item.replace(/^(\s*)(.*)/g, function(match, p1, p2) {
                                    return p1 + '<# ' + p2.trim() + ' #>';
                                });
                            }).join("\r\n");
                        });
                    // replace template content
                    newText = newText.replace(/^[\r\n\s]*<script[^>]*>|<\/script>[\r\n\s]*$/g, '');
                    scripts[i].innerHTML = newText;
                    return;
                }
            }
        }
    };

    // code injection
    var injectCode = function(code /* , idUniq = '', execDelay = 0 */){
        // get call arguments
        var idUniq = '',
            execDelay = 0,
            tmpScript = document.createElement('script');
        tmpScript.id = '__iC_script-'+Math.random().toString().slice(2);
        // argument #2 (optional): <script> element ID suffix
        if (arguments.length > 1) {
            idUniq = '_' + /[$_a-zA-Z][$_a-zA-Z0-9]*/.exec(arguments[1])[0] || '';
        }
        tmpScript.id = tmpScript.id + idUniq;
        // argument #3 (optional): execution delay
        if (arguments.length > 2) {
            execDelay = (arguments[2] === 'domready' ? arguments[2] : (parseInt(arguments[2]) || 0));
        }
        tmpScript.type = 'text/javascript';
        tmpScript.textContent = (function() {
            return [
                ';'+(
                    execDelay === 'domready' ? '$(document).ready(' :
                    (0+execDelay > 0 ? 'window.setTimeout(' : '(')
                )+(function () {
                    /*code*/
                    var thisScript = document.getElementById('/*scriptId*/');
                    if (thisScript) { thisScript.parentNode.removeChild(thisScript); } // <-- oh no, you didn't!!
                }).toString()+(
                    execDelay === 'domready' ? ')' :
                    (0+execDelay > 0 ? ', '+execDelay+')' : ')()')
                )+';',
                { k: 'code', v: (typeof(code) == 'string' && code.trim().length > 0 ? code : '/*failed*/') },
                { k: 'scriptId', v: tmpScript.id }
            ].reduce(function(base, mapping){
                return base.replace('/*'+mapping.k+'*/', mapping.v);
             });
        })();
        // inject temporary script
        document.head.appendChild(tmpScript);
    };
    var injectFuncCode = function(func /* , idUniq = '', execDelay = 0 */){
        // make sure we got passed a function
        if (typeof(func) !== 'function') return;
        // get call arguments
        var idUniq = (arguments.length > 1 ? arguments[1] : ''),
            execDelay = (arguments.length > 2 ? (arguments[2] === 'domready' ? arguments[2] : (parseInt(arguments[2]) || 0)) : 0),
            wrapper = '('+func.toString()+')();';
        // inject temporary script
        injectCode(wrapper, idUniq, execDelay);
    };

    // code injection, specialized
    // inspiration: https://greasyfork.org/en/scripts/2599-gm-2-port-function-override-helper/code
    var replaceUnsafeFunc = function(targetName, newFuncImpl /* , idUniq = '', thisScope = 'window', execDelay = 0 */){
        // get call arguments
        var idUniq = '',
            thisScope = 'window',
            execDelay = 0,
            tmpScript = document.createElement('script');
        tmpScript.id = '__rUF_script-'+Math.random().toString().slice(2);
        // argument #3 (optional): <script> element ID suffix
        if (arguments.length > 2) {
            idUniq = '_' + /[$_a-zA-Z][$_a-zA-Z0-9]*/.exec(arguments[2])[0] || '';
        }
        tmpScript.id = tmpScript.id + idUniq;
        // argument #4 (optional): scope of function to replace; by default, window (global)
        if (arguments.length > 3) {
            thisScope = ''+arguments[3].replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, '') || 'window';
        }
        // argument #5 (optional): execution delay
        if (arguments.length > 4) {
            execDelay = (arguments[4] === 'domready' ? arguments[4] : (parseInt(arguments[4]) || 0));
        }
        tmpScript.type = 'text/javascript';
        tmpScript.textContent = (function() {
            return [
                ';'+(
                    execDelay === 'domready' ? '$(document).ready(' :
                    (0+execDelay > 0 ? 'window.setTimeout(' : '(')
                )+(function () {
                    try {
                        window/*scope*/ /*target*/ = /*newFunc*/window;
                    } catch(_err) {
                        console.log('Error in replaceUnsafeFunc() payload with id "/*scriptId*/": ' + _err.message);
                    }
                    var thisScript = document.getElementById('/*scriptId*/');
                    if (thisScript) { thisScript.parentNode.removeChild(thisScript); } // <-- oh no, you didn't!!
                }).toString()+(
                    execDelay === 'domready' ? ')' :
                    (0+execDelay > 0 ? ', '+execDelay+')' : ')()')
                )+';',
                { k: 'scope', v: '.'+thisScope },
                { k: 'target', v: '.'+(typeof(targetName) == 'string' && targetName.trim().length > 0 ? targetName : '_void') },
                { k: 'newFunc', v: (typeof(newFuncImpl) == 'function' ? newFuncImpl : function(){}).toString()+';//' },
                { k: 'scriptId', v: tmpScript.id }
            ].reduce(function(base, mapping){
                // 2018-08-06: replaced [String].replace() with [String].split().join()
                ///return base.replace('/*'+mapping.k+'*/', mapping.v);
                return base.split('/*'+mapping.k+'*/').join(''+mapping.v);
             });
        })();
        // inject temporary script
        document.head.appendChild(tmpScript);
    };

    // reference some outside objects
    var console = window.console || (function() {
        if (typeof(unsafeWindow) == 'undefined') return { 'log': function() {} };
        return unsafeWindow.console;
    })();

    // self-explanatory
    document.addStyle = function(css /*, media */) {
        var media = (arguments.length > 1 ? arguments[1] : false);
        if (typeof(GM_addStyle) != 'undefined' && !media) {
            GM_addStyle(css);
            return true;
        } else {
            if (!media) { media = 'all'; }
            var heads = this.getElementsByTagName('head');
            if (heads.length > 0) {
                var node = this.createElement('style');
                node.type = 'text/css';
                if (media) node.media = media;
                if (node.appendChild(this.createTextNode(css))) {
                    return (typeof heads[0].appendChild(node) != 'undefined');
                }
            }
            return false;
        }
    };

/*
 * The Action
 */

    // css injection
    document.addStyle(css_fixes);

    // javascript patching
    try {
        // replace template "producthistory-template"
        if (templateProductHistory) {
            replaceDialogTemplate('producthistory-template', getHeredoc(templateProductHistory, 'HEREDOC'));
        }

        // replace saveProductHistory()
        if (typeof mySaveProductHistory === 'function') {
            replaceUnsafeFunc('saveProductHistory', mySaveProductHistory, 'mySaveProductHistory');
        }

        // 2018-08-06: replace ShoppingEngine.addShoppingItem()
        if (typeof myAddShoppingItem === 'function') {
            replaceUnsafeFunc('addShoppingItem', myAddShoppingItem, 'myAddShoppingItem', 'ShoppingEngine', 'domready');
        }

        // 2018-08-06: add the UPC text field to the "New Item" form
        if (typeof listadditemform_fix === 'function') {
            injectFuncCode(listadditemform_fix, 'listadditemform_fix');
        }

        // set initial width of "Product History Management" dialog
        ///replaceUnsafeFunc('onload', function(){ $("#manageproducthistoryform").dialog("option", "width", "80%"); }, 'onload');
        // 2018-08-06: now making use of new features added to injectCode() and injectFuncCode()
        injectFuncCode(function(){ $("#manageproducthistoryform").dialog("option", "width", "80%"); }, 'manageproducthistoryform_fix', 'domready');

        // 2015-09-17: fixes for the "shoppingeditpopup" dialog
        if (typeof shoppingeditpopup_fix1 === 'function') {
            injectFuncCode(shoppingeditpopup_fix1, 'shoppingeditpopup_fix1');
        }
        if (typeof shoppingeditpopup_fix2 === 'function') {
            injectFuncCode(shoppingeditpopup_fix2, 'shoppingeditpopup_fix2');
        }

        // 2016-01-24: fixes for the "editproducthistoryform" dialog
        if (typeof editproducthistory_fix1 === 'function') {
            injectFuncCode(editproducthistory_fix1, 'editproducthistory_fix1');
        }
        if (typeof editproducthistory_fix2 === 'function') {
            injectFuncCode(editproducthistory_fix2, 'editproducthistory_fix2');
        }

        // 2015-09-17: fixes for "icon-taxfree.png" URLs
        if (typeof iconTaxFreeURLs_fix === 'function') {
            injectFuncCode(iconTaxFreeURLs_fix, 'iconTaxFreeURLs_fix');
        }
    } catch(err) {
        console.log(err);
    }

/*
 * The End
 */

    console.log('User script "' + USERSCRIPT_NAME + '" has completed.');
})();