MediaWiki:Gadget-checkboxList-core.js

From RuneRealm Wiki

This is an old revision of this page, as edited by Alex (talk | contribs) at 00:10, 17 October 2024. The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

Jump to navigation Jump to search

After saving, you may need to bypass your browser's cache to see the changes. For further information, see Wikipedia:Bypass your cache.

  • In most Windows and Linux browsers: Hold down Ctrl and press F5.
  • In Safari: Hold down ⇧ Shift and click the Reload button.
  • In Chrome and Firefox for Mac: Hold down both ⌘ Cmd+⇧ Shift and press R.
/**
 * Adds support for checkbox lists ([[Template:Checklist]])
 *
 * Examples/Tests: <https://rs.wiki/User:Cqm/Scrapbook_4>
 *
 * History:
 * - 1.0: Original implementation - Cqm
 */

/*
 * DATA STORAGE STRUCTURE
 * ----------------------
 *
 * In its raw, uncompressed format, the stored data is as follows:
 * {
 *     hashedPageName1: [
 *         [0, 1, 0, 1, 0, 1],
 *         [1, 0, 1, 0, 1, 0],
 *         [0, 0, 0, 0, 0, 0]
 *     ],
 *     hashedPageName2: [
 *         [0, 1, 0, 1, 0, 1],
 *         [1, 0, 1, 0, 1, 0],
 *         [0, 0, 0, 0, 0, 0]
 *     ]
 * }
 *
 * Where `hashedPageNameX` is the value of wgPageName passed through our `hashString` function,
 * the arrays of numbers representing tables on a page (from top to bottom) and the numbers
 * representing whether a row is highlighted or not, depending on if it is 1 or 0 respectively.
 *
 * During compression, these numbers are collected into groups of 6 and converted to base64.
 * For example:
 *
 *   1. [0, 1, 0, 1, 0, 1]
 *   2. 0x010101             (1 + 4 + 16 = 21)
 *   3. BASE_64_URL[21]      (U)
 *
 * Once each table's rows have been compressed into strings, they are concatenated using `.` as a
 * delimiter. The hashed page name (which is guaranteed to be 8 characters long) is then prepended
 * to this string to look something like the following:
 *
 *   XXXXXXXXab.dc.ef
 *
 *
 * The first character of a hashed page name is then used to form the object that is actually
 * stored. As the hashing function uses hexadecimal, this gives us 16 possible characters (0-9A-Z).
 *
 * {
 *     A: ...
 *     B: ...
 *     C: ...
 *     // etc.
 * }
 *
 * The final step of compression is to merge each page's data together under it's respective top
 * level key. this is done by concatenation again, separated by a `!`.
 *
 * The resulting object is then converted to a string and persisted in local storage. When
 * uncompressing data, simply perform the following steps in reverse.
 *
 * For the implementation of this algorithm, see:
 * - `compress`
 * - `parse`
 * - `hashString`
 *
 * Note that while rows could theoretically be compressed further by using all ASCII characters,
 * eventually we'd start using characters outside printable ASCII which makes debugging painful.
 */

/*jshint bitwise:false, camelcase:true, curly:true, eqeqeq:true, es3:false,
    forin:true, immed:true, indent:4, latedef:true, newcap:true,
    noarg:true, noempty:true, nonew:true, plusplus:true, quotmark:single,
    undef:true, unused:true, strict:true, trailing:true,
    browser:true, devel:false, jquery:true,
    onevar:true
*/

'use strict';

    // constants
var STORAGE_KEY = 'rs:checkList',
    LIST_CLASS = 'checklist',
    CHECKED_CLASS = 'checked',
    NO_TOGGLE_PARENT_CLASS = 'no-toggle-parent',
    INDEX_ATTRIBUTE = 'data-checklist-index',
    BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
    PAGE_SEPARATOR = '!',
    LIST_SEPARATOR = '.',
    CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
    UINT32_MAX = 0xffffffff,

    conf = mw.config.get([
        'debug',
        'wgPageName'
    ]),


    self = {
        /*
         * Stores the current uncompressed data for the current page.
         */
        data: null,

        /*
         * Perform initial checks on the page and browser.
         */
        init: function () {
            var $lists = $(['ul.' + LIST_CLASS,
                            'div.' + LIST_CLASS + ' > ul'].join(', ')),
                hashedPageName = self.hashString(mw.config.get('wgPageName'));

            // check we have some tables to interact with
            if (!$lists.length) {
                return;
            }

            // check the browser supports local storage
            if (!rs.hasLocalStorage()) {
                return;
            }

            self.data = self.load(hashedPageName, $lists.length);
            self.initLists(hashedPageName, $lists);
        },

        /*
         * Initialise table highlighting.
         *
         * @param hashedPageName The current page name as a hash.
         * @param $lists A list of checkbox lists on the current page.
         */
        initLists: function (hashedPageName, $lists) {
            $lists.each(function (listIndex) {
                var $this = $(this),
                    toggleParent = !(
                        $this.hasClass(NO_TOGGLE_PARENT_CLASS) ||
                        $this.parent('div.' + LIST_CLASS).hasClass(NO_TOGGLE_PARENT_CLASS)
                    ),
                    // list items
                    $items = $this.find('li'),
                    listData = self.data[listIndex];

                // initialise list items if necessary
                while ($items.length > listData.length) {
                    listData.push(0);
                }

                $items.each(function (itemIndex) {
                    var $this = $(this),
                        itemData = listData[itemIndex];

                    // initialize checking based on the cookie
                    self.setChecked($this, itemData);

                    // give the item a unique index in the list
                    $this.attr(INDEX_ATTRIBUTE, itemIndex);

                    // set mouse events
                    $this
                        .click(function (e) {
                            var $this = $(this),
                                $parent = $this.parent('ul').parent('li'),
                                $childItems = $this.children('ul').children('li'),
                                isChecked;

                            // don't bubble up to parent lists
                            e.stopPropagation();

                            function checkChildItems() {
                                var $this = $(this),
                                    index = $this.attr(INDEX_ATTRIBUTE),
                                    $childItems = $this.children('ul').children('li'),
                                    childIsChecked = $this.hasClass(CHECKED_CLASS);

                                if (
                                    (isChecked && !childIsChecked) ||
                                    (!isChecked && childIsChecked)
                                ) {
                                    listData[index] = 1 - listData[index];
                                    self.setChecked($this, listData[index]);
                                }

                                if ($childItems.length) {
                                    $childItems.each(checkChildItems);
                                }
                            }

                            function checkParent($parent) {
                                var parentIndex = $parent.attr(INDEX_ATTRIBUTE),
                                    parentIsChecked = $parent.hasClass(CHECKED_CLASS),
                                    parentShouldBeChecked = true,
                                    $myParent = $parent.parent('ul').parent('li');

                                $parent.children('ul').children('li').each(function () {
                                    var $child = $(this),
                                        childIsChecked = $child.hasClass(CHECKED_CLASS);

                                    if (!childIsChecked) {
                                        parentShouldBeChecked = false;
                                    }
                                });

                                if (
                                    (parentShouldBeChecked && !parentIsChecked && toggleParent) ||
                                    (!parentShouldBeChecked && parentIsChecked)
                                ) {
                                    listData[parentIndex] = 1 - listData[parentIndex];
                                    self.setChecked($parent, listData[parentIndex]);
                                }

                                if ($myParent.length) {
                                    checkParent($myParent);
                                }
                            }

                            // don't toggle highlight when clicking links
                            if ((e.target.tagName !== 'A') && (e.target.tagName !== 'IMG')) {
                                // 1 -> 0
                                // 0 -> 1
                                listData[itemIndex] = 1 - listData[itemIndex];

                                self.setChecked($this, listData[itemIndex]);
                                isChecked = $this.hasClass(CHECKED_CLASS);

                                if ($childItems.length) {
                                    $childItems.each(checkChildItems);
                                }

                                // if the list has a parent
                                // check if all the children are checked and uncheck the parent if not
                                if ($parent.length) {
                                    checkParent($parent);
                                }

                                self.save(hashedPageName);
                            }
                        });
                });
                
                // add a button for reset
                var reset = $('<div>').append(
                	$('<sup>').append('[').append(
                		$('<a>').append('uncheck all')
            		).append(']')
                ).addClass('sl-reset');

                reset.first('sup').click(function () {
                    $items.each(function (itemIndex) {
                        listData[itemIndex] = 0;
                        self.setChecked($(this), 0);
                    });

                    self.save(hashedPageName, $lists.length);
                });
                
                $this.append(reset);
            });
        },

        /*
         * Change the list item checkbox based on mouse events.
         *
         * @param $item The list item element.
         * @param val The value to control what class to add (if any).
         *            0 -> unchecked (no class)
         *            1 -> light on
         *            2 -> mouse over
         */
        setChecked: function ($item, val) {
            $item.removeClass(CHECKED_CLASS);

            switch (val) {
                // checked
                case 1:
                    $item.addClass(CHECKED_CLASS);
                    break;
            }
        },

        /*
         * Merge the updated data for the current page into the data for other mw.pages into local storage.
         *
         * @param hashedPageName A hash of the current page name.
         */
        save: function (hashedPageName) {
                // load the existing data so we know where to save it
            var curData = localStorage.getItem(STORAGE_KEY),
                compressedData;

            if (curData === null) {
                curData = {};
            } else {
                curData = JSON.parse(curData);
                curData = self.parse(curData);
            }

            // merge in our updated data and compress it
            curData[hashedPageName] = self.data;
            compressedData = self.compress(curData);

            // convert to a string and save to localStorage
            compressedData = JSON.stringify(compressedData);
            localStorage.setItem(STORAGE_KEY, compressedData);
        },

        /*
         * Compress the entire data set using tha algoritm documented at the top of the page.
         *
         * @param data The data to compress.
         *
         * @return the compressed data.
         */
        compress: function (data) {
            var ret = {};
            
            Object.keys(data).forEach(function (hashedPageName) {
                var pageData = data[hashedPageName],
                    pageKey = hashedPageName.charAt(0);

                if (!ret.hasOwnProperty(pageKey)) {
                    ret[pageKey] = {};
                }

                ret[pageKey][hashedPageName] = [];

                pageData.forEach(function (tableData) {
                    var compressedListData = '',
                        i, j, k;

                    for (i = 0; i < Math.ceil(tableData.length / 6); i += 1) {
                        k = tableData[6 * i];

                        for (j = 1; j < 6; j += 1) {
                            k = 2 * k + ((6 * i + j < tableData.length) ? tableData[6 * i + j] : 0);
                        }

                        compressedListData += BASE_64_URL.charAt(k);
                    }

                    ret[pageKey][hashedPageName].push(compressedListData);
                });

                ret[pageKey][hashedPageName] = ret[pageKey][hashedPageName].join(LIST_SEPARATOR);
            });

            Object.keys(ret).forEach(function (pageKey) {
                var hashKeys = Object.keys(ret[pageKey]),
                    hashedData = [];

                hashKeys.forEach(function (key) {
                    var pageData = ret[pageKey][key];
                    hashedData.push(key + pageData);
                });

                hashedData = hashedData.join(PAGE_SEPARATOR);
                ret[pageKey] = hashedData;
            });

            return ret;
        },

        /*
         * Get the existing data for the current page.
         *
         * @param hashedPageName A hash of the current page name.
         * @param numLists The number of lists on the current page. Used to ensure the loaded
         *                 data matches the number of lists on the page thus handling cases
         *                 where lists have been added or removed. This does not check the
         *                 amount of items in the given lists.
         *
         * @return The data for the current page.
         */
        load: function (hashedPageName, numLists) {
            var data = localStorage.getItem(STORAGE_KEY),
                pageData;

            if (data === null) {
                pageData = [];
            } else {
                data = JSON.parse(data);
                data = self.parse(data);

                if (data.hasOwnProperty(hashedPageName)) {
                    pageData = data[hashedPageName];
                } else {
                    pageData = [];
                }
            }

            // if more lists were added
            // add extra arrays to store the data in
            // also populates if no existing data was found
            while (numLists > pageData.length) {
                pageData.push([]);
            }

            // if lists were removed, remove data from the end of the list
            // as there's no way to tell which was removed
            while (numLists < pageData.length) {
                pageData.pop();
            }

            return pageData;
        },

        /*
         * Parse the compressed data as loaded from local storage using the algorithm desribed
         * at the top of the page.
         *
         * @param data The data to parse.
         *
         * @return the parsed data.
         */
        parse: function (data) {
            var ret = {};

            Object.keys(data).forEach(function (pageKey) {
                var pageData = data[pageKey].split(PAGE_SEPARATOR);

                pageData.forEach(function (listData) {
                    var hashedPageName = listData.substr(0, 8);

                    listData = listData.substr(8).split(LIST_SEPARATOR);
                    ret[hashedPageName] = [];

                    listData.forEach(function (itemData, index) {
                        var i, j, k;

                        ret[hashedPageName].push([]);

                        for (i = 0; i < itemData.length; i += 1) {
                            k = BASE_64_URL.indexOf(itemData.charAt(i));

                            // input validation
                            if (k < 0) {
                                k = 0;
                            }

                            for (j = 5; j >= 0; j -= 1) {
                                ret[hashedPageName][index][6 * i + j] = (k & 0x1);
                                k >>= 1;
                            }
                        }
                    });
                });

            });

            return ret;
        },

        /*
         * Hash a string into a big endian 32 bit hex string. Used to hash page names.
         *
         * @param input The string to hash.
         *
         * @return the result of the hash.
         */
        hashString: function (input) {
            var ret = 0,
                table = [],
                i, j, k;

            // guarantee 8-bit chars
            input = window.unescape(window.encodeURI(input));

            // calculate the crc (cyclic redundancy check) for all 8-bit data
            // bit-wise operations discard anything left of bit 31
            for (i = 0; i < 256; i += 1) {
                k = (i << 24);

                for (j = 0; j < 8; j += 1) {
                    k = (k << 1) ^ ((k >>> 31) * CASTAGNOLI_POLYNOMIAL);
                }
                table[i] = k;
            }

            // the actual calculation
            for (i = 0; i < input.length; i += 1) {
                ret = (ret << 8) ^ table[(ret >>> 24) ^ input.charCodeAt(i)];
            }

            // make negative numbers unsigned
            if (ret < 0) {
                ret += UINT32_MAX;
            }

            // 32-bit hex string, padded on the left
            ret = '0000000' + ret.toString(16).toUpperCase();
            ret = ret.substr(ret.length - 8);

            return ret;
        }
    };

// disable for debugging
if (!(['User:Cqm/Scrapbook_4'].indexOf(conf.wgPageName) && conf.debug)) {
    $(self.init);
}

/*
// sample data for testing the algorithm used
var data = {
    // page1
    '0FF47C63': [
        [0, 1, 1, 0, 1, 0],
        [0, 1, 1, 0, 1, 0, 1, 1, 1],
        [0, 0, 0, 0, 1, 1, 0, 0]
    ],
    // page2
    '02B75ABA': [
        [0, 1, 0, 1, 1, 0],
        [1, 1, 1, 0, 1, 0, 1, 1, 0],
        [0, 0, 1, 1, 0, 0, 0, 0]
    ],
    // page3
    '0676470D': [
        [1, 0, 0, 1, 0, 1],
        [1, 0, 0, 1, 0, 1, 0, 0, 0],
        [1, 1, 1, 1, 0, 0, 1, 1]
    ]
};

console.log('input', data);

var compressedData = self.compress(data);
console.log('compressed', compressedData);

var parsedData = self.parse(compressedData);
console.log(parsedData);
*/