OutOfMilk.com Shopping List Enhancements

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

当前为 2018-08-06 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name          OutOfMilk.com Shopping List Enhancements
// @version       0.2.6
// @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)
// @include       http://outofmilk.com/ShoppingList.aspx*
// @include       http://www.outofmilk.com/ShoppingList.aspx*
// @include       https://outofmilk.com/ShoppingList.aspx*
// @include       https://www.outofmilk.com/ShoppingList.aspx*
// @grant         GM_addStyle
// ==/UserScript==

/***
 * History:
 *
 * 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 "Edit Product History" dialog, added a column for links to
 *          the corresponding product on a 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() {
    // 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**/
    };

    // new implementation of saveProductHistory()
    var mySaveProductHistory = function($this) {
        if(validateProductHistoryForm()){
            var ID = $(".editproducthistoryid").html();
            var description = $(".editproducthistorydescription").val();
            var price = $(".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);

            $.ajax({
                type: "POST",
                url: "Services/GenericService.asmx/UpdateProductHistoryItem",
                data: jQueryParams,
                contentType: "application/json; charset=utf-8",
                dataType: "json",
                success: function (msg) {
                    if(msg.d === false){
                            $(".producthistoryitemvalidation").html("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");
                        }

                        $this.dialog("close");
                    }
                },
                failure: function (msg) {
                }
            });
        }
    };
    // 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() {
        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() {
        // patch using .toString() & eval()
        ///$.each(['addShoppingItem', 'updateShoppingItem'], function() {
        // 2018-08-06: Since we're now using our own implementation of ShoppingEngine.addShoppingItem(),
        //             we took care to correct that bug too
        $.each(['updateShoppingItem'], function() {
            eval("ShoppingEngine." + this + " = " + ShoppingEngine[this].toString().replace("src='Images/icon-taxfree.png", "src='\"+ STATIC_URL +\"images/icon-taxfree.png'"));
        });
    };
    // 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() {
        $(".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 */){
        var idUniq = '',
            execDelay = 0,
            tmpScript = document.createElement('script');
        tmpScript.id = '__iC_script-'+Math.random().toString().slice(2);
        // argument #3 (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);
             });
        })();
        document.head.appendChild(tmpScript);
    };
    var injectFuncCode = function(func /* , idUniq = '', execDelay = 0 */){
        if (typeof(func) !== 'function') return;
        var idUniq = (arguments.length > 1 ? arguments[1] : ''),
            execDelay = (arguments.length > 2 ? (arguments[2] === 'domready' ? arguments[2] : (parseInt(arguments[2]) || 0)) : 0),
            wrapper = '('+func.toString()+')();';
        injectCode(wrapper, idUniq, execDelay);
    };

    // code injection, specialized
    var replaceUnsafeFunc = function(targetName, newFuncImpl /* , idUniq = '', thisScope = 'window', execDelay = 0 */){
        // inspiration: https://greasyfork.org/en/scripts/2599-gm-2-port-function-override-helper/code
        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);
             });
        })();
        document.head.appendChild(tmpScript);
    };

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

    // self-explanatory
    document.addStyle = function(css) {
        if (typeof(GM_addStyle) != 'undefined') {
            GM_addStyle(css);
        } else {
            var heads = this.getElementsByTagName('head');
            if (heads.length > 0) {
                var node = this.createElement('style');
                node.type = 'text/css';
                node.appendChild(this.createTextNode(css));
                heads[0].appendChild(node);
            }
        }
    };

/*
 * The Action
 */

    // css injection
    document.addStyle(css_fixes);

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

        // replace saveProductHistory()
        replaceUnsafeFunc('saveProductHistory', mySaveProductHistory, 'mySaveProductHistory');

        // 2018-08-06: replace ShoppingEngine.addShoppingItem()
        replaceUnsafeFunc('addShoppingItem', myAddShoppingItem, 'myAddShoppingItem', 'ShoppingEngine', 'domready');
        // 2018-08-06: add the UPC text field to the "New Item" form
        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
        injectFuncCode(shoppingeditpopup_fix1, 'shoppingeditpopup_fix1');
        injectFuncCode(shoppingeditpopup_fix2, 'shoppingeditpopup_fix2');

        // 2016-01-24: fixes for the "editproducthistoryform" dialog
        injectFuncCode(editproducthistory_fix1, 'editproducthistory_fix1');
        injectFuncCode(editproducthistory_fix2, 'editproducthistory_fix2');

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

/*
 * The End
 */

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